docs(profiling): document screenshot/PNG trap; add full-loop workload

- docs/profiling.md: new "Profiling the render path" section — headless
  step() doesn't render; automation.screenshot() forces a render but PNG
  encoding then dominates (~96% of instructions in libsfml stbi_zlib_compress),
  burying engine work. Guidance: profile update/animation with step()-only,
  subtract stbi_* for the render path. Alludes to the Hybrid Scene
  Serialization wiki proposal (QOI + UI-tree serialization) as a faster
  capture path that would also de-noise screenshot-driven profiling.
- tests/benchmarks/profile_workload.py: reusable full-loop driver (textured
  grid + moving entities + animated nested UI, forced renders). This is the
  workload that surfaced #348.

Refs #345, #348.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-10 21:54:39 -04:00
commit ef0408e06e
2 changed files with 119 additions and 0 deletions

View file

@ -0,0 +1,94 @@
"""Full-loop profiling workload: textured grid + moving entities + animated
nested UI, with forced real renders each frame (screenshot flushes the render
target in headless mode). Exercises render + update + animation together so
Callgrind/perf surface where the ENGINE actually spends time -- not a microbench.
NOTE: forcing renders via automation.screenshot() means ~96% of instructions go
to PNG encoding (libsfml stbi_zlib_compress). See docs/profiling.md "screenshot/
PNG trap": profile update/animation with step()-only, or subtract stbi_* symbols.
This workload found #348 (get_grid alloc churn).
Usage:
./build-profile/mcrogueface --headless --exec tests/benchmarks/profile_workload.py
# or under Callgrind:
make callgrind SCRIPT=tests/benchmarks/profile_workload.py
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
import tempfile
GRID_W, GRID_H = 60, 60
ENTITY_COUNT = 80
FRAMES = 40
UI_FRAMES = 30 # nested animated frames
def build():
scene = mcrfpy.Scene("profile")
ui = scene.children
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
color_layer = mcrfpy.ColorLayer(z_index=-1, name="color")
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(0, 0), size=(960, 720),
texture=tex, layers=[color_layer])
for x in range(GRID_W):
for y in range(GRID_H):
c = grid.at(x, y)
c.walkable = True
c.transparent = True
color_layer.set((x, y), mcrfpy.Color(30, 30, 40, 255))
ui.append(grid)
entities = []
vels = []
for i in range(ENTITY_COUNT):
e = mcrfpy.Entity((i % GRID_W, (i * 7) % GRID_H),
sprite_index=(i % 12) + 10, grid=grid)
entities.append(e)
vels.append((0.3 if i % 2 else -0.3, -0.2 if i % 3 else 0.2))
# Nested animated UI hierarchy
frames = []
for i in range(UI_FRAMES):
f = mcrfpy.Frame(pos=(10 + i * 5, 10 + i * 3), size=(120, 60),
fill_color=mcrfpy.Color(80, 40, 40, 200))
cap = mcrfpy.Caption(text=f"f{i}", pos=(4, 4))
f.children.append(cap)
ui.append(f)
# animate several properties -> exercises the per-frame animation path
f.animate("x", 400.0 + i, 3.0, mcrfpy.Easing.EASE_IN_OUT)
f.animate("opacity", 0.4, 3.0, mcrfpy.Easing.LINEAR)
frames.append(f)
return scene, grid, entities, vels, frames
def main():
scene, grid, entities, vels, frames = build()
mcrfpy.current_scene = scene
with tempfile.TemporaryDirectory(prefix="mcrf_prof_") as tmp:
# warmup (build chunk caches etc.)
for _ in range(3):
automation.screenshot(os.path.join(tmp, "w.png"))
for frame in range(FRAMES):
# move entities (dirties grid) via cell position
for e, (vx, vy) in zip(entities, vels):
p = e.cell_pos
nx = (p.x + vx) % GRID_W
ny = (p.y + vy) % GRID_H
e.cell_pos = (nx, ny)
mcrfpy.step(0.016)
automation.screenshot(os.path.join(tmp, "f.png"))
print(f"profile workload done: {FRAMES} frames, {ENTITY_COUNT} entities, "
f"{UI_FRAMES} animated frames, grid {GRID_W}x{GRID_H}")
if __name__ == "__main__":
main()
sys.exit(0)