The bus is 4x idle while the CPU is pinned: price the trade

The codec was designed when bytes were scarce, so every decision in it trades
cycles to save bytes. That is now backwards: sasi spends 110 KB/s of a 488 KB/s
pipe while missing 31% of frames on CPU.

The cheapest thing a 68000 can be handed is the most expensive thing to store.
Measured, per pixel: row-linear copy from word-expanded memory 9.08 cycles,
block-order 12.98, V1 codebook 18.74, RAW byte literals 25.03. So the 1024-byte
stride costs 43% and unpacking bytes to words costs more than the write itself.

Pricing one new mode -- a per-row span of word-expanded literals movem.l'd
straight from the stream buffer -- against the UNCHANGED mode maps:

  sasi   median 74.4% -> 43.0%, worst 136.2% -> 106.2%, misses 37 -> 8/120,
         101.7 -> 453.2 KB/s
  scsi   median 94.9% -> 69.4%, misses 51 -> 18/120, 272 -> 479.7 KB/s

scsi gains less precisely because it has less idle bandwidth left to trade.

Two consequences worth flagging. A word-expanded literal block derives to ~240
cycles, cheaper than V1's measured 299.9 and pixel-exact -- so every codebook
mode is CPU-dominated by a literal, and the codebook is a byte optimisation
that now costs cycles. And 28.5's "a scene cut cannot fit at 12fps" reopens:
CPU needs >=19% of the frame as spans, the bus allows <=39%, and that interval
is not empty.

DERIVED, NOT MEASURED, and labelled as such everywhere. The 9.08 cycles/pixel
is real but was measured at full row width with 12-register bursts, so short
spans are flattered. Measuring one span on the 68000 is now step 0 of the next
session, ahead of the cost-aware mode decision, because it changes the mode set
that decision optimises over.

FINDINGS 29. tools/analysis/12_span_tradeoff.py.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 15:28:29 -07:00
parent cb05e77a42
commit 3641f37e28
3 changed files with 272 additions and 17 deletions
+50 -17
View File
@@ -1,16 +1,42 @@
# Status & next-session handoff — end of session 7 (2026-08-23)
## NEXT SESSION: make the mode decision cost-aware
## NEXT SESSION: measure a span, then make the mode decision cost-aware
The decoder exists, it is pixel-exact, and **it does not fit**. On the worst
sustained window at the shipping `sasi` profile it costs a mean of **81.7% of a
12fps frame budget** and **31% of frames exceed 100%** (`scsi`: 94.9% median,
42% of frames miss). FINDINGS 28. CPU is now the binding constraint — the first
time in this project that it has been.
sustained window at `sasi` it costs a mean of **81.7% of a 12fps frame** and
**31% of frames exceed 100%** (`scsi`: 94.9% median, 42% miss). FINDINGS 28.
CPU is the binding constraint now — the first time in this project.
The fix is not assembly micro-optimisation. It is that **`vq_hybrid.decide()`
minimises `D + lam*R` — distortion against BYTES — on a machine where the
binding budget is CYCLES**, and the two are not proportional:
**Two levers, and the cheap one has to be measured first.**
*Lever A — spend bandwidth to buy cycles.* The bus sits 4x idle: `sasi` uses 110
KB/s of 488. Every codec decision was made when bytes were scarce, so each one
trades cycles to save them, and the cheapest thing a 68000 can be handed is the
most expensive thing to store — **word-expanded pixels in row-linear runs**.
Adding one mode, a per-row span of literal words `movem.l`-ed straight from the
stream buffer into GVRAM, prices out at (FINDINGS 29, `12_span_tradeoff.py`):
| | today | + literal spans |
|---|---:|---:|
| median frame | 74.4% | **43.0%** |
| worst frame | 136.2% | **106.2%** |
| frames missing | **37/120** | **8/120** |
| bitrate | 101.7 KB/s | 453.2 KB/s (bus 488) |
**This is DERIVED, not measured, and it is load-bearing — so measure it first.**
Extend `tools/bench/blit.s` with a span variant and time it against run length.
The 9.08 cycles/pixel it rests on is real (FINDINGS 24 V1) but was measured at
full row width with 12-register bursts; short and oddly-aligned spans cannot
burst as well and are flattered by the model. If spans come in near the derived
figure, the whole mode set changes and lever B optimises over different modes —
which is exactly why this goes first. FINDINGS 29.5 lists the other three things
that have to hold, of which **confirming DMA vs PIO is the cheapest and now the
most consequential**: at 453 KB/s a PIO fallback puts the transfer back on the
CPU this is trying to relieve.
*Lever B — stop buying modes the CPU cannot afford.* `vq_hybrid.decide()`
minimises `D + lam*R` — distortion against BYTES — on a machine whose binding
budget is CYCLES, and the two are not proportional:
| mode | payload bytes | measured cycles | cycles per byte |
|---|---:|---:|---:|
@@ -18,13 +44,18 @@ binding budget is CYCLES**, and the two are not proportional:
| V1 | 1 | 300 | 300 |
| V4 | 4 | 448 | 112 |
| RAW | 16 | 400 | 25 |
| *word-expanded literal block* | *32* | *~240 (derived)* | *7.5* |
V4 is **25% of blocks and 50% of the cycles**. The lagrangian charges it 4x a V1
block; the CPU charges it 1.49x. So the encoder currently buys V4 whenever it is
worth 4 bytes, with no idea what it costs to draw.
block; the CPU charges it 1.49x. Note the last row: a literal block is cheaper
than **every** codebook mode, and pixel-exact — the codebook is a byte
optimisation that now costs cycles (FINDINGS 29.2).
**The work, in order:**
0. **Measure the span cost on the 68000** (lever A above). Cheap, and everything
below optimises over whatever mode set it leaves.
1. **Add a cycle term to the mode decision.** `decide()` already builds a cost
matrix of `error + lam * bytes` per mode per block; add `+ mu * cycles`,
with the per-mode cycles measured in FINDINGS 28.2.
@@ -66,13 +97,15 @@ worth 4 bytes, with no idea what it costs to draw.
`sasi` cannot afford it, so expect the cycle ceiling to cost `sasi` more
quality even though it costs `sasi` fewer cycles. FINDINGS 28.8.
4. **28.5 may not be solvable by the encoder at all.** An all-V1 frame — the
cheapest possible full redraw — is **110.5%** of the budget. A scene cut
changes 100% of the screen, so *no* mode assignment fits one at 12fps. Decide
deliberately: allow one late frame at a cut (the outgoing content is
unrelated, so it may be invisible), spread a cut over two frame times, or
drop to 10fps where an all-V1 frame fits. This is a design decision, not a
measurement, and it needs the user.
4. **Scene cuts: 28.5 said impossible, 29.4 reopened it.** An all-V1 frame —
the cheapest full redraw the *current* mode set allows — is 110.5% of budget,
so no mode assignment fits a 100%-changed frame. With literal spans the
arithmetic changes: CPU needs at least 19% of the frame sent as spans, the
bus allows up to 39%, **and that interval is not empty**. So 28.5 was a
ceiling of the bitstream, not of the machine — *if* lever A measures out.
If it does not, this is still a design decision that needs the user: one late
frame at each cut (the outgoing content is unrelated, so it may be
invisible), a cut spread over two frame times, or 10fps.
**Do not start by hand-optimising `decode.s`.** The hand-derived timings agree
with the measurements to 0.5% on V1 and 1% on RAW (FINDINGS 28.4), so the