Skip to content

perf(hw): choose write_region's delta strategy by measured link cost - #275

Merged
kfox merged 3 commits into
mainfrom
perf/write-region-cost-model
Aug 12, 2026
Merged

perf(hw): choose write_region's delta strategy by measured link cost#275
kfox merged 3 commits into
mainfrom
perf/write-region-cost-model

Conversation

@kfox

@kfox kfox commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What

write_region decided how to split a changed region into writes by comparing byte counts: chunk when the dirty 256-byte slabs totalled under 60% of the region, otherwise push the whole region. Bytes are the wrong currency.

On socket DMA a write costs ~5.2 ms regardless of payload up to ~2.4 KB. Splitting a region into k pieces therefore multiplies its cost by k while the byte count obediently goes down — so the old rule was scored by a metric that could only ever say it was winning.

The choice is now a cost comparison against HardwareProfile.write_cost_s, max(floor, intercept + per_byte × B), measured per backend by a new diagnostic.

Why it belongs on the profile

The two links sit at opposite extremes and want opposite policies:

fixed per write marginal knee regime
Ultimate 64 (socket DMA) 5.22 ms 1.85 µs/B ~2.4 KB write-count-bound
TeensyROM+ (USB serial) 0.29 ms 1.44 µs/B ~54 B byte-bound

The marginal slopes agree within ~30% — both are the C64 bus at about a cycle a byte. What differs by 18× is the fixed per-write cost, which is the link protocol, and that single number inverts the right answer. The chunked branch still earns its place on the TeensyROM for the sparse-waveform case it was written for; it simply must not fire where the second write costs more than the bytes it saves.

Measurements

Offline, driving the real pipeline against real video (video_render_probe.py), the old rule was slower than not chunking on 19 of 19 firings across three display modes. One mhires clip lost 1083 ms over 400 frames, concentrated into 14 frames that each stalled ~77 ms — a visible hitch on scene cuts, which is exactly when a wide sparse dirty pattern occurs.

before after
mean frame 18.1 ms 13.3 ms
worst frame 104.4 ms 26.0 ms
mean bitmap push 4265 B 3280 B

On hardware, a minute of video per mode at unchanged write rate and frame rate: ~25% fewer bytes on the host-DMA bitmap path (220 → 166 KiB/s) and ~28% in the character modes (41 → 30 KiB/s).

The write rate deliberately does not move — the chunked branch fired on only ~3.5% of frames, so its cost lands in the worst frames rather than in an average. That is why the per-frame offline probe is the tool that can see it and a 10-second heartbeat average is not.

Also in here

  • The separate full-push branch is gone. A span write is a subset of the same bytes and cost is monotonic in payload, so the span is never worse; "everything dirty" is just the case where the span is the whole region. This is the broader win on real content.
  • full_threshold is removed from the write_region signature — no caller ever passed it, and it no longer names anything.
  • New scripts/diags/link_cost_model.py, which is where the profile constants come from. It fits the two regimes separately: a single straight line through the flat part and the sloped part reports a healthy r² while understating both terms, and the floor is the term the decision turns on.
  • video_render_probe.py now counts writes as well as bytes and prices frames with the profile's model, flagging any region split into more writes than pushing it whole would have cost.

Scope

With the REU enabled, [video].use_reu_staged = auto stages hires/mhires bitmaps through the REU bank-swap, which never reaches write_region — so on a default Ultimate the bitmap figures describe the host-DMA path, and what improves for those modes is screen and color RAM. Char modes are always on this path, as is everything on the TeensyROM+.

Verification

  • make check green: ruff, mypy --strict, pyright, 3742 tests. make site-check green.
  • New unit tests pin both regimes from one dirty pattern, and a randomised invariant test asserts the chosen strategy is never more expensive than one span write across four dirty densities on both profiles.
  • On hardware: byte/write rates above, zero transport errors, and captured frames confirm mhires renders identically to the base — no corruption from the span-write offset, no new tearing.

kfox added 3 commits August 12, 2026 14:25
write_region decided how to split a changed region into writes by
comparing byte counts: chunk when the dirty 256-byte slabs totalled
under 60% of the region, else push the whole thing. Bytes are the wrong
currency. On socket DMA a write costs ~5.2 ms regardless of payload up
to ~2.4 KB, so splitting a region into k pieces multiplies its cost by k
while the byte count obediently goes down.

Priced against the real link, the old rule lost every time it fired: 19
of 19 firings across three display modes on real video were slower than
not chunking, one mhires clip losing 1083 ms over 400 frames
concentrated into 14 frames that each stalled ~77 ms — a visible hitch
on scene cuts, which is exactly when a wide sparse dirty pattern occurs.

The choice is now a cost comparison against HardwareProfile.write_cost_s,
max(floor, intercept + per_byte * B), measured per backend by the new
link_cost_model.py. The two links sit at opposite extremes and want
opposite policies, which is why this belongs on the profile:

  Ultimate 64    5.22 ms/write, 1.85 us/B, knee ~2.4 KB  count-bound
  TeensyROM+     0.29 ms/write, 1.44 us/B, knee ~54 B    byte-bound

The marginal slopes agree within ~30% because both are the C64 bus at
about a cycle a byte; the 18x gap in fixed cost is the link protocol,
and it inverts the answer. The chunked branch still earns its place on
the TeensyROM for the sparse-waveform case it was written for.

The separate full-push branch is gone: a span write is a subset of the
same bytes and cost is monotonic in payload, so the span is never worse.
Wide changes now push only the dirty range instead of the whole region,
which took the same clip's mean bitmap push from 4265 to 3280 B/frame.

Measured on the same clip: mean frame 18.1 -> 13.3 ms, worst frame
104.4 -> 26.0 ms, link-bound ceiling 55 -> 75 fps.

full_threshold is removed from the write_region signature; no caller
ever passed it, and it no longer names anything.
The mhires numbers describe the host-DMA path. On an Ultimate with the
REU enabled, use_reu_staged stages hires/mhires bitmaps through the REU
bank-swap and never reaches write_region — so on a default U64 what
improves for those modes is screen and color RAM, not the bitmap. Char
modes and the whole TeensyROM+ are always on this path.

Adds the on-hardware result: at unchanged write rate and frame rate,
~25% fewer bytes on the host-DMA bitmap path (220 -> 166 KiB/s) and
~28% in char modes (41 -> 30 KiB/s), measured over a minute per mode.
The write rate is deliberately reported as unchanged: the chunked branch
fired on ~3.5% of frames, so its cost lands in the worst frames, not in
an average.
The 'reducing write count is the only real lever' rule was asserted
without its reason. The reason is now measured: payload is free below
~2.4 KB on socket DMA, so an 8-byte write and a 2 KB write cost the
same ~5.2 ms and splitting one write in two doubles its price. That is
what makes count the lever, and it is also what makes widening a write
across a clean gap free.

Records it in the three places someone would hit it from: the caveats
transport section (beside the latency table it follows from), CLAUDE.md's
standing note, and the extending.md guidance against hand-rolled diff
layers. All three now say the same thing about scope — the trade is
per-link and inverted on the TeensyROM+, which is why it lives on
HardwareProfile.write_cost_s and not in any caller.
@kfox
kfox merged commit e168a88 into main Aug 12, 2026
17 checks passed
@kfox
kfox deleted the perf/write-region-cost-model branch August 12, 2026 22:16
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.04%. Comparing base (53e8ce1) to head (4e9ee82).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
c64cast/hw/backend.py 93.75% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #275   +/-   ##
=======================================
  Coverage   82.03%   82.04%           
=======================================
  Files         142      142           
  Lines       24729    24731    +2     
  Branches     3628     3627    -1     
=======================================
+ Hits        20287    20291    +4     
+ Misses       3648     3647    -1     
+ Partials      794      793    -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant