Environment
- native-sdk v0.4.2
- macOS aarch64 (dev machine; not yet re-run on 8 GB Apple Silicon baseline)
- Zig 0.16.0
- Renderer: Metal, macOS host,
ReleaseFast
What happens
With a canvas scene holding several registered images drawn every frame, the
plan frame stage costs the same whether or not any image content actually
changed. On a 4×4 grid of 512×512 registered images (16 drawn tiles), plan
p50 is 21.6 ms even on a tick where only unrelated text changed — no
image was re-registered, nothing about the images differs from the prior
frame. That number does not move across batch=0/1/4/16 (0 to 16 MiB/tick of
actual image churn); it only moves with the count of drawn images, not
with how many changed.
By contrast, the present stage (GPU upload) is correctly change-gated:
it costs 0.56 ms when nothing changed and scales up with churned bytes
(~0.5–0.6 ms/MiB) only when images are actually re-registered.
What I expected
Since present already skips re-uploading images whose registered content
hasn't changed, I expected plan to similarly skip re-planning draw packets
for registered images that haven't changed since the last frame — i.e. a
fingerprint/dirty-check ahead of packet planning, not just ahead of GPU
upload.
Measured data
4×4 grid of 512×512 registered canvas images, all at the 1 MiB per-image
cap, all 16 registry slots in use. 16 ms repeating timer re-registers
batch tiles per tick with freshly painted pixels. Measured via in-model
monotonicNanoseconds around fx.registerImage and native automate profile frame-stage timings.
| Scenario |
plan p50 |
present p50 |
interval p50 |
≈fps |
| batch=0 (text-only change), 16 tiles drawn |
21.6 ms |
0.56 ms |
24.2 ms |
~41 |
| batch=1 (1 MiB/tick churn), 16 tiles |
21.5 ms |
7.5 ms |
31.5 ms |
~32 |
| batch=4 (4 MiB/tick), 16 tiles |
21.5 ms |
7.7 ms |
32.8 ms |
~30 |
| batch=16 (16 MiB/tick), 16 tiles |
21.3 ms |
9.3 ms |
38.3 ms |
~26 |
| batch=1, 4 tiles drawn |
5.5 ms |
3.3 ms |
16.7 ms |
~60 |
| paused (no model change) |
— |
— |
no frames |
idle |
fx.registerImage itself (1 MiB tile) is cheap and not the bottleneck: avg
0.03 ms, worst 0.18 ms, 0 errors across 9,100+ calls.
Takeaways:
plan cost is ~1.35 ms per drawn 512×512 registered image per produced
frame, scaling linearly with drawn-image count, flat across churn
levels (0/1/4/16 batch).
present cost scales with churned bytes and is ~free when nothing
changed — the change-gating pattern we'd like to see applied to plan
too.
- Static scenes (no model change) produce no frames at all, so this only
bites during continuous interaction (pan/zoom/drag).
Repro
Repro app: spike/canvas-tiles/ (attached/available on request; not yet
published). Shape:
- 4×4 grid of 512×512 registered canvas images, each at the 1 MiB
per-image cap, using all 16 registry slots.
- A 16 ms repeating timer re-registers
batch tiles per tick with freshly
painted pixels (batch configurable 0/1/4/8/16).
- Toggle for how many tiles are drawn (4 or 16) and a pause control.
- Run with
native build -Dautomation=true, drive with native automate,
and read frame-stage timings via native automate profile on.
- Compare
plan p50 across batch=0 vs batch=16 at a fixed drawn-tile
count: cost is flat, which is the core observation.
Suggested direction
We're building an image-optimization app where a comparison canvas
(before/after tiles) is a core interaction, so plan cost sets our
interaction budget more than present does today. Given present already
has a change-gate, it looks like plan's image-packet planning could use
the same fingerprint (e.g. keyed on registered-image generation/version)
to skip re-planning packets for images that haven't changed since the last
produced frame. If that's already on the roadmap or there's a reason
plan can't easily reuse packets across frames (e.g. packet buffers get
reset/invalidated for other reasons), we'd appreciate a pointer — happy to
help verify a fix against this repro.
Related: #102, #103 (same project/spikes)
Environment
ReleaseFastWhat happens
With a canvas scene holding several registered images drawn every frame, the
planframe stage costs the same whether or not any image content actuallychanged. On a 4×4 grid of 512×512 registered images (16 drawn tiles),
planp50 is 21.6 ms even on a tick where only unrelated text changed — no
image was re-registered, nothing about the images differs from the prior
frame. That number does not move across batch=0/1/4/16 (0 to 16 MiB/tick of
actual image churn); it only moves with the count of drawn images, not
with how many changed.
By contrast, the
presentstage (GPU upload) is correctly change-gated:it costs 0.56 ms when nothing changed and scales up with churned bytes
(~0.5–0.6 ms/MiB) only when images are actually re-registered.
What I expected
Since
presentalready skips re-uploading images whose registered contenthasn't changed, I expected
planto similarly skip re-planning draw packetsfor registered images that haven't changed since the last frame — i.e. a
fingerprint/dirty-check ahead of packet planning, not just ahead of GPU
upload.
Measured data
4×4 grid of 512×512 registered canvas images, all at the 1 MiB per-image
cap, all 16 registry slots in use. 16 ms repeating timer re-registers
batchtiles per tick with freshly painted pixels. Measured via in-modelmonotonicNanosecondsaroundfx.registerImageandnative automate profileframe-stage timings.fx.registerImageitself (1 MiB tile) is cheap and not the bottleneck: avg0.03 ms, worst 0.18 ms, 0 errors across 9,100+ calls.
Takeaways:
plancost is ~1.35 ms per drawn 512×512 registered image per producedframe, scaling linearly with drawn-image count, flat across churn
levels (0/1/4/16 batch).
presentcost scales with churned bytes and is ~free when nothingchanged — the change-gating pattern we'd like to see applied to
plantoo.
bites during continuous interaction (pan/zoom/drag).
Repro
Repro app:
spike/canvas-tiles/(attached/available on request; not yetpublished). Shape:
per-image cap, using all 16 registry slots.
batchtiles per tick with freshlypainted pixels (
batchconfigurable 0/1/4/8/16).native build -Dautomation=true, drive withnative automate,and read frame-stage timings via
native automate profile on.plan p50acrossbatch=0vsbatch=16at a fixed drawn-tilecount: cost is flat, which is the core observation.
Suggested direction
We're building an image-optimization app where a comparison canvas
(before/after tiles) is a core interaction, so
plancost sets ourinteraction budget more than
presentdoes today. Givenpresentalreadyhas a change-gate, it looks like
plan's image-packet planning could usethe same fingerprint (e.g. keyed on registered-image generation/version)
to skip re-planning packets for images that haven't changed since the last
produced frame. If that's already on the roadmap or there's a reason
plancan't easily reuse packets across frames (e.g. packet buffers getreset/invalidated for other reasons), we'd appreciate a pointer — happy to
help verify a fix against this repro.
Related: #102, #103 (same project/spikes)