luv

Workshop wiki

frame-performance.org

Seeing a frame on the CPU

Frame time is several clocks #P49PBP

“The frame took eight milliseconds” is not yet an explanation. At least four intervals can appear under that sentence:

The first two are visible to a monotonic host clock. The fourth eventually needs backend timestamp queries or a GPU capture; naming a host interval gpu_ms would not make it GPU time. Likewise, a hidden demand-driven canvas avoids luvcraft's 60 Hz scheduler but does not promise that CAMetalLayer will make drawables available without pacing.

A statistical profiler and an instrumented trace answer different questions. Sampling is valuable when it can observe the actual native frame thread and attribute stacks. Tracy-style zones answer the immediate structural question: which named interval surrounded the wait, encoding pass, or submission? Luv's first tool deliberately implements the latter. It records nested host-clock intervals; it does not pretend to sample CPU instructions or time GPU work.

DONE Trace one real frame with nested CPU zones #OHNIWM

Intent: make a coarse frame explanation one ordinary benchmark away, with an opt-in macro whose disabled path neither constructs events nor changes backend control flow.

Evidence:

One standalone Apple M2 Pro run measured 60 steady frames at an 8.367 ms CPU median and then printed this representative structure:

luvcraft/frame                         7.376 ms
  luvcraft/presentation                7.365 ms
    canvas/frame                       7.363 ms
      canvas/acquire-drawable          5.747 ms
      metal/allocate-frame-resources   0.099 ms
      gpu/encode                       1.146 ms
        luvcraft/mesh-publication      0.403 ms
        luvcraft/shadow-pass           0.383 ms
        luvcraft/scene-pass            0.223 ms
      gpu/submit                       0.242 ms

That single frame is localization evidence, not a distribution. The adjacent fixed metrics retain median, p95, mean, and maximum across the measured batch. More zones should be added only when a coarse region earns a focused question.

Done when (met): a real frame produces a bounded nested report across the native thread handoff, the untraced benchmark remains the comparison baseline, tests prove nesting and storage reuse, and Vulkan and Metal smoke paths remain valid.

defmethod request-canvas-frame canvas.lisp:692
defmethodrequest-canvas-frame

Dynamic bindings do not follow a closure onto another thread. Preserve the opt-in trace explicitly so a caller can measure the real native frame rather than only the time spent waiting for its request. #OHNIWM

defmacro with-cpu-trace-zone trace.lisp:212
defmacrowith-cpu-trace-zone
name&key
tracy-valueniltracy-value-supplied-p
&bodybody

Measure body as nested zone name for whichever measurement is watching.

TRACY-VALUE, when supplied, is attached to the Tracy zone at exit. It does not affect the bounded CPU trace, whose zones retain time and runtime costs.

Two independent things may be: a Tracy viewer attached to this image, and an opt-in CPU-TRACE capture. Each disabled path is one special-variable test and does not allocate, so instrumenting a frame path costs nothing when nobody is measuring it. Active traces reuse their zone storage after the first capture.

The Tracy zone is the outer one. When only Tracy is watching there is no bookkeeping inside it to measure, and when a CPU-TRACE capture is running its own cost belongs to the zone it is attributed to rather than being hidden from it. #OHNIWM

let
trace
gensym"TRACE"
index
gensym"ZONE"
`
with-tracy-zone
,name,@
whentracy-value-supplied-p`
:value,tracy-value
let*
,index
and,trace

body occurs once so nested instrumentation does not multiply the

compiler's input. TRACE, rather than INDEX, guards cleanup because

zero is a valid first zone index.

unwind-protect
progn,@body
when,trace
end-cpu-trace-zone,trace,index

Ambient telemetry is a bounded semantic skeleton #7SOIWP

The useful ambition is broad coverage, but the unit of coverage is not every call. An ambient zone should name one event, frame, pass, batch, job, queue drain, publication, resource transaction, or other operation whose complete dynamic extent means something in a timeline. A loop may have a zone around the whole traversal and attach its visit or byte count; its individual sites, vertices, glyphs, contacts, and commands normally should not each open a zone.

This gives zdefun and zdefmethod a simple admission rule. Use them when the definition owns a bounded semantic operation. Use zone inside a large definition when its phases answer different questions. Keep ordinary defun and defmethod for accessors, predicates, coordinate arithmetic, per-element callbacks, foreign leaf calls, and tiny helpers. Literal semantic names are valuable where different implementations should align (:gpu/submit on two backends); inferred package-qualified names are better for cold or uniquely named operations where another vocabulary adds nothing.

The desired trace is therefore a semantic skeleton with measured dense work hanging from it, not a call graph. This revises the deliberately sparse first frame proof in #OHNIWM without discarding its boundedness: broad ambient telemetry is admitted at coarse grains, and a focused investigation may still add temporary finer zones.

The first ambient-zone census #1G3ORG

Verified now: the source index contains 7,536 top-level definitions across the project's ASDF systems. A mechanical ranking selected functions and methods by loop ownership, body extent, boundary words, and existing trace regions; the list below was then reviewed to remove accessors, predicates, per-element geometry and arithmetic, tests, probes, and generated foreign or shader vocabularies. It is intentionally generous: about two hundred candidates, grouped so each conversion can still be judged in context.

The names are a census, not a command to wrap every body unchanged. Large entries such as encode-luvcraft-frame and start-luvcraft want a zoned outer definition plus their existing or newly identified phases. Queue and loop owners should attach counts. Generic operations named create or encode belong only where the method specializer denotes a coarse resource or batch, not where it denotes one command.

Event, thread, and frame ownership

  • hal/sdl/canvas.lisp: call-with-sdl-main-thread, process-sdl-canvas-requests, call-on-sdl-canvas-thread, request-canvas-frame, fence-canvas, handle-sdl-canvas-event, wait-for-sdl-canvas-event, run-sdl-canvas-frame, sdl-canvas-event-loop, run-sdl-canvas, start-sdl-canvas-thread, open-canvas, and close-canvas. dispatch-sdl-key and drain-sdl-canvas-events are already zoned; pointer motion, button, and wheel dispatch are candidates only if an input trace needs that distinction.
  • hal/vulkan/canvas.lisp: bind-vulkan-canvas-device, configure-vulkan-canvas-context, unconfigure-vulkan-canvas-context, rebuild-vulkan-canvas-swapchain, ensure-vulkan-canvas-swapchain, acquire-vulkan-canvas-image, and %call-with-vulkan-canvas-frame.
  • hal/metal/canvas.lisp: synchronize-metal-canvas-drawable-size, configure-metal-canvas-context, unconfigure-metal-canvas-context, destroy-metal-canvas-context, and call-with-metal-canvas-frame.
  • luvcraft/render.lisp: make-luvcraft-frame-attachments, install-luvcraft-frame-attachments, release-luvcraft-frame-attachments, luvcraft-frame-state, discard-luvcraft-frame-states, ensure-luvcraft-frame-extent, encode-luvcraft-frame, render-luvcraft-frame, refresh-block-atlas, start-luvcraft, and stop-luvcraft. The existing frame-phase zones remain the inner semantic partition rather than being replaced by one giant span.
  • luvcraft/app.lisp: dispatch-luvcraft-focus-event is already zoned; add refresh-luvcraft-inventory and the coarse target activation or overlay ownership operations, not every key-specialized method.

Production, residency, lighting, and meshing

  • luvcraft/production.lisp: take-production-system-request, run-production-system, schedule-production-request, receive-production-result-no-hang, and stop-production-system. The request count and request kind are useful values; the condition-variable wait should remain visible as its own phase.
  • luvcraft/streaming.lisp: perform-production-request methods, maintain-generated-luvcraft-residency, maintain-static-luvcraft-residency, maintain-luvcraft-residency, destroy-luvcraft-chunk-products, ready-luvcraft-mesh-publication-groups, discard-stale-luvcraft-staged-products, publish-ready-luvcraft-meshes, schedule-luvcraft-chunk-loads, schedule-luvcraft-meshes, schedule-luvcraft-lighting, publish-production-result methods, drain-luvcraft-production, evict-luvcraft-products, refresh-luvcraft-mesh, and wait-for-luvcraft-streaming-quiescence. Counts should distinguish scheduled, drained, stale, published, and evicted products.
  • luvcraft/light.lisp: capture-light-region, make-light-candidate, publish-light-region, relight-block-world, attach-lighting-state, and reconcile-lighting. Captured chunks, changed chunks, and publication revisions are the values; solver work belongs to the compiled realization below.
  • luvcraft/frontier-light.lisp: solve-compiled-light-region, make-compiled-light-reconciliation, drain-compiled-light-removals, reseed-compiled-open-sky, seed-compiled-arrived-chunk, drain-compiled-light-additions, and reconcile-compiled-lighting. Existing inner zones and their visit counts should be retained while the containing definitions become concise.
  • luvcraft/light-reference.lisp: solve-light-region and compare-voxel-light-solvers are explicit test/reference instrumentation, not production fallback. Reference and candidate visits, allocation, GC, and mismatched keys remain valuable differential evidence when that system is deliberately loaded.
  • luvcraft/mesher.lisp: make-block-mesh-snapshot, mesh-block-snapshot, and mesh-block-chunk. Candidate values are visited blocks, emitted faces, and output vertices.
  • luvcraft/terrain.lisp: populate-little-world-chunk, center-little-world-residency, and little-world-landmarks-for-chunk.

Physics, simulation, and populations

  • luvcraft/physics.lisp: rebuild-physics-grid, prune-physics-contacts, generate-physics-body-pairs, generate-physics-terrain-contacts, generate-physics-box-contacts, prepare-physics-constraints, physics-integrate-velocities, physics-integrate-positions, %physics-warm-start-scalar, physics-warm-start, %physics-solve-contacts-scalar, physics-solve-contacts, %physics-apply-restitution-scalar, physics-apply-restitution, store-physics-impulses, finalize-physics-bodies, wake-physics-bodies-near, step-physics-world, and validate-physics-world. The outer step should expose body, pair, contact, constraint, awake, and event counts; SIMD/scalar kernel choice belongs in a value or an enclosing phase, not one zone per constraint.
  • luvcraft/simulation.lisp: advance-camera-focus, map-body-overlapping-blocks, move-body-axis, and step-block-world-player.
  • luvcraft/critters.lisp, luvcraft/balls.lisp, and luvcraft/particles.lisp: maintain-critter-population, advance-luvcraft-physics, build-physics-body-vertices, and advance-block-particles. Geometry emitters such as emit-critter-box remain unzoned unless a containing batch cannot explain their cost.
  • luvcraft/world.lisp: raycast-block-world is the coarse traversal candidate; coordinate and block accessors are not.

Terminals, McCLIM, and interactive surfaces

  • terminal/pty.lisp: send-pty-device-bytes, read-pty-stream-bytes, perform-pty-device-message, drain-pty-device-messages, update-pty-child-status, release-pty-device-process, run-pty-device, launch-pty-child, open-pty-device, wait-for-pty-device, and close-pty-device. Bytes and message counts are natural values.
  • ghostty/screen.lisp: read-screen-cells and terminal-screen, valued by dirty cells or grid area.
  • luvcraft/terminal-wall.lisp: open-terminal-display-portal, discover-terminal-component, find-terminal-surface, initialize-terminal-surface-dependencies, reconcile-terminal-surface, make-terminal-display-glyph-instances, make-terminal-display-cell-instances, make-terminal-display-screen-instances, make-terminal-display-glyph-population, make-terminal-cell-run, make-terminal-display-glyph-run, refresh-luvcraft-overlay, encode-luvcraft-overlay, handle-luvcraft-focus-event, open-activated-wall-display, attach-terminal-display-shell, attach-terminal-display-pty, open-terminal-display, and make-terminal-display. Component sites, cells, glyphs, runs, and draw populations are the useful work values.
  • mcclim/gpu.lisp: repaint-gpu-mirror, render-gpu-mirror-frame, present-mirror, capture-gpu-mirror-screenshot, release-gpu-mirror-frame-states, and the coarse ensure-gpu-mirror-* pipeline/resource constructors. Composition, preparation, individual text batches, and upload are already zoned; drawing one polygon, segment, or glyph remains below the ambient grain.
  • mcclim/luvcraft.lisp: prepare-direct-widget-overlay, the coarse encode-luvcraft-overlay methods, ensure-world-widget-text-pipeline, ensure-widget-overlay-relief-pipeline, and embed-luvcraft-frame.
  • McCLIM instruments: handle-repaint methods in hotbar, inventory, metabar, terminal film browser, legend, command menu, and tape. Their substantial paint helpers belong beneath those methods, following the communicator example.
  • mcclim/telegram.lisp already covers key input, refresh, repaint, painting regions, preparation, and upload. Add console-transcript-lines only if transcript reconstruction remains visible outside the paint zones.

GPU submission, resource construction, and compilation

  • hal/vulkan/gpu.lisp: submit-vulkan-command-buffers, record-vulkan-texture-write, and coarse create methods for devices, pipelines, bind groups, and large resources. Per-command encode methods are excluded unless they batch an entire pass.
  • hal/metal/gpu.lisp: submit-metal-command-buffers, configure-metal-pass-bind-group, and the corresponding coarse device, pipeline, bind-group, and resource create methods. One buffer write or one encoded command is below the ambient grain.
  • hal/shader/language.lisp: parse-shader-specification, whole shader-function parsing, and live source revision/dependent notification. Per-expression parsing is excluded.
  • hal/vulkan/spir-v/lowering.lisp: compile-shader-specification and the complete task, mesh, vertex, and fragment entry-point lowering operations; expression-level lowering is excluded.
  • hal/metal/msl/lowering.lisp: lower-traditional-msl-specification, lower-task-msl-specification, lower-mesh-msl-specification, lower-msl-bindings, and write-msl-entry-point. lower-msl-expression and write-msl-statement methods are too fine for ambient use.
  • luvcraft/frontier.lisp: compile-frontier-program, emit-frontier-drain-form, emit-frontier-admit-form, and emit-frontier-relate-form. Runtime execution belongs to the client operation; generated per-site forms do not open zones.
  • arithmetic/lisp/compiler.lisp and arithmetic/language/frontend.lisp: whole-function compilation, frontend parsing, and realization boundaries; individual expression-lowering methods remain ordinary methods.
  • hal/shader/slug-cache.lisp and hal/shader/slug-harfbuzz.lisp: create-slug-glyph-atlas, collect-slug-shaped-glyphs, and shape-slug-text, valued by glyph and outline counts.

Media, portals, persistence, and tools

  • libav/decode.lisp: enable-vulkan-decoding, enable-videotoolbox-decoding, open-video, close-video, decode-next-frame, ensure-video-scaler, scale-frame-into, and copy-rgba-words. Bytes or pixels distinguish setup, decode, conversion, and copying.
  • libav/audio.lisp: open-audio, decode-next-audio-frame, and audio-frame-mono-samples.
  • luvcraft/video-screen.lisp, luvcraft/tape.lisp, and luvcraft/sound.lisp: make-video-screen, advance-video-screen, release-video-screen, start-tape-download, open-film-sound, and run-film-sound.
  • luvcraft/portal.lisp and luvcraft/capture.lisp: open-luvcraft-portal, release-luvcraft-overlay, capture-hidden-luvcraft-frames, and temporal-derivative-rgba.
  • luvcraft/persistence.lisp: make-luvcraft-save-description, restore-luvcraft-resume-save-description, restore-luvcraft-save-description, read-luvcraft-save, write-luvcraft-save-description, perform-world-checkpoint, run-world-checkpoint-writer, request-world-checkpoint, and stop-world-checkpoint-writer.
  • Developer tools are a final, low-frequency wave: source indexing and site rendering in wiki, Parinfer's whole-input repair/check entry points, shader-lab display/build operations, and screenshot or gazetteer commands. Tests, generated vocabularies, formatters, and tiny renderer nodes remain outside the ambient census.

DONE Convert the outer runtime boundaries #G3CFYX

Intent: make one ordinary live capture explain the outer event, frame, production, streaming, simulation, and overlay structure before adding the denser subsystem waves in #1G3ORG.

Evidence: the communicator instrumentation already localized a roughly 5.4 MB keypress repaint to about 4.4 MB of McCLIM command preparation. The new zdefun, zdefmethod, and zone forms have a zero-allocation disabled path, while #7SOIWP supplies a bounded admission rule for wider coverage.

Done when: SDL requests/events and frame ownership, luvcraft frame and simulation ownership, production scheduling/draining/publication, top-level streaming phases, and overlay refresh/encode each have stable ambient zones; loop owners attach semantic counts; a representative live capture stays legible rather than emitting per-element spans; and the relevant tests and both source-structure checks pass.

Outcome: forty-two outer functions and methods now use the concise zoned definition forms. SDL request draining, event dispatch, and native frame service; Vulkan and Metal frame ownership; production request, worker, and publication transactions; the streaming reconciliation phases; fixed-step simulation; frame encoding; terminal and McCLIM overlay refresh, preparation, and encoding all have stable semantic names. Scheduling, publication, fixed-step, and retained-command owners carry their natural work counts.

A live focused-phone frame produced 54 CPU zones and remained a readable frame -> simulation/streaming/presentation -> overlay tree. It attributed about 4.36 MiB in total, including the terminal -> Telegram -> McCLIM overlay path, without per-glyph, per-cell, or per-command spans. Caller-side request-canvas-frame and call-on-sdl-canvas-thread deliberately remain ordinary definitions: the former transports the same opt-in CPU trace onto the native thread, so opening that trace on both sides would manufacture cross-thread nesting. Native request draining and frame service provide the honest ownership zones instead.

The full test matrix, strict Parinfer source check, cold luvcraft system load, and wiki build pass with this coverage.

DONE Stream one chunk window under measurement #887PO7

Intent: measure the frames where traversal creates real asynchronous chunk load, lighting, mesh, GPU upload, and publication work, rather than infer that cost from the fully resident steady benchmark.

Evidence:

LUVCRAFT_BENCHMARK_FRAMES=240 make metal-streaming-benchmark

This begins from the same settled 9 by 9 resident world as the steady case, then moves the player one chunk in +X at the measured boundary. One edge of nine chunks leaves and one edge of nine genuinely new chunks enters. Every CSV frame now records resident chunks, pending production, staged products, and rendered products; the printed transition summary stops at the first frame where residency, meshes, and the worker are settled again.

The first Apple M2 Pro run settled at frame 57. Across those 58 transition frames, CPU frame time had an 8.453 ms median, 43.225 ms p95, and 46.078 ms maximum; 10 frames exceeded the 16.667 ms 60 Hz deadline. The worst repeated cost was not worker generation: owner-side mesh/lighting publication reached 42.209 ms. This is now a reproducible hitch and a next optimization target, not a claim that streaming performance is solved.

Done when (met): one command produces a bounded steady/streaming comparison, names how many chunks entered and when the transition settled, reports 60 Hz deadline misses, and leaves per-frame lifecycle counts in a comparison-ready CSV.

defun benchmark-luvcraft-frame-performance benchmark.lisp:61
defunbenchmark-luvcraft-frame-performance
&key
frame-count120
warmup-count30
camera
make-instance'fly-camera
scenario:steady
high-pixel-density-pnil
csv-pathname
stream*standard-output*

Measure steady or streaming world frames through the real Metal path.

The streaming scenario moves one chunk in +X after warmup and records the entire asynchronous load, mesh, and owner-publication transition. #887PO7

The hidden demand-clock canvas avoids the application's 60 Hz scheduler, but CAMetalLayer may still pace drawable availability. All desired chunk products arrive before warmup, then frame-count consecutive frames reuse the same world, pipelines, attachments, and drawable pool. Per-frame values measure CPU orchestration and encoding. Completion throughput includes final shared-event drainage and is intentionally not labelled as GPU time.

check-typeframe-count
integer1
check-typewarmup-count
integer0
check-typewidth
integer1
check-typeheight
integer1
check-typescenario
member:steady:streaming
check-typehigh-pixel-density-pboolean
let
sessionnil
samples
make-arrayframe-count
provider
make-instance'metal-gpu-provider
unwind-protect
progn
setfsession
start-luvcraft:title"luvcraft Metal frame benchmark":widthwidth:heightheight:visible-pnil:frames-per-secondnil

width and height remain the logical scene controls. The Retina benchmark deliberately lets SDL make the drawable denser, then records both extents below so the physical post-pass cost cannot disappear behind the input size.

:high-pixel-density-phigh-pixel-density-p:providerprovider:worldworld:cameracamera:sky-clock
make-instance'sky-clock:pinned-day-fraction0.5
let
desired
hash-table-count
luvcraft-session-desired-chunkssession
wait-for-luvcraft-productssession:minimumdesired:timeout30d0
looprepeatwarmup-countdo
let
queue
device-queue
luvcraft-session-devicesession
let
entering0
settled-framenil
batch-start
get-internal-real-time
dotimes
indexframe-count
let
sample
make-luvcraft-frame-sample
setf
arefsamplesindex
sample
when
and
eqscenario:streaming
zeropindex
when
and
eqscenario:streaming
nullsettled-frame
setfsettled-frameindex
let
drain-start
get-internal-real-time
let*
finished
get-internal-real-time
units
coerceinternal-time-units-per-second'double-float
trace
make-cpu-trace:label"representative frame"
render-extent
presentation-extent
benchmark
make-luvcraft-frame-benchmark:backend:metal:scenarioscenario:device
benchmark-metal-device-name
luvcraft-session-devicesession
:width
firstrender-extent
:height
secondrender-extent
:presentation-width
firstpresentation-extent
:presentation-height
secondpresentation-extent
:warmup-countwarmup-count:samplessamples:completion-seconds
/
-finishedbatch-start
units
:drain-seconds
/
-finisheddrain-start
units
:desired-chunk-countdesired:entering-chunk-countentering:settled-framesettled-frame

Warm the reusable zone buffer, then capture one extra frame after the measured batch so detailed zones and their first-use allocation cannot perturb its metrics.

formatstream"~%"
print-cpu-tracetracestream
whencsv-pathname
formatstream" samples: ~A~%"
namestring
truenamecsv-pathname
benchmark
whensession

The game names usage; backends realize hazards #T5MQO0

Luvcraft used to specialize prepare-luvcraft-shadow-map-sampling on Vulkan and Metal encoder classes. That put Vulkan image layout and Metal 4 barrier mechanics in the game even though the game knows only one fact: the completed shadow depth texture will be sampled by the following pass.

The game now emits prepare-texture with :texture-binding usage. Vulkan validates the declared usage and records a shader-read layout transition. Metal validates the same relationship and installs its consumer barrier when the following native render encoder begins. This is not an assertion that all backend mechanics can be identical. It is a placement rule: application code states resource intent, while the backend that owns the hazard model realizes it. The cold Vulkan smoke and Metal API-validated smoke both cross this command; the Metal image stayed bit-identical to its pre-change baseline.

defun prepare-texture gpu.lisp:1187
defunprepare-texture
encodertextureusage

Prepare texture for semantic usage in encoder's following commands.

The backend owns any layout transition, hazard barrier, or validation needed to realize the usage. Application code does not dispatch on the backend. #T5MQO0

encodeencoder
make-gpu-prepare-texture-command:texturetexture:usageusage

TODO Reuse Metal frame submission resources #YKITZ8

Intent: stop constructing and releasing one MTL4CommandAllocator and one MTL4CommandBuffer for every frame when a bounded frame-slot owner can reuse them safely.

Evidence: the current trace exposes :metal/allocate-frame-resources as a separate region, and source inspection confirms that both native objects are created after every drawable acquisition. The representative frame above spent 0.099 ms there. That one number is not a stable estimate, but it makes the ownership experiment measurable. #JAM5XR is the related lifetime work: reuse must follow the shared-event frontier rather than reintroduce a queue stall.

Done when: a bounded Metal frame-slot structure owns reusable submission resources, the completion frontier prevents early reuse, traces show the creation region absent from steady frames, allocation and frame distributions improve in a repeated A/B run, and API validation remains clean.

TODO Separate drawable pacing from encoding distributions #JBZ4SC

Intent: report how much host frame time is runnable encoding work and how much is waiting for presentation availability, instead of treating both as one “encoding” number.

Evidence: native sampling after #WEE1DX found the frame thread mostly sleeping inside CAMetalLayer nextDrawable, while the instrumented representative frame above localized 5.747 ms to drawable acquisition and 1.146 ms to the inclusive encoding region. These are compatible observations from different windows, not a fixed ratio.

Done when: repeated captures summarize drawable-acquisition and encoding distributions separately for Metal and Vulkan, the benchmark explains which intervals include pacing, and eventual GPU timestamps remain a distinct measurement rather than being inferred from host waits.