Measure the span: the mode survives, and it is an encoder format

FINDINGS 29 priced a literal-span mode at 4*(50 + 4L*9.08) cycles and labelled
the whole section DERIVED. Session 8 step 0 was to measure it before optimising
over the mode set it implies. Two variants in blit.s, one stream per span length
from prep_spans.py, timed by span.lua, driven by span.sh in ~25 s:

  v5, handed (x, npix) and left to work the copy out:  97.9/span + 10.459/px
  v6, handed an address and a jump displacement:       43.7/span +  9.152/px
  29 assumed                                           50.0/span +  9.080/px

So 29's arithmetic was right about a format nobody had written. The difference
is not tuning: v5 spends ~122 cycles a span computing a destination, dividing
npix into bursts and handling a 0..15 remainder, all of which the encoder knows
at build time. v6's record is {u32 absolute GVRAM address, u16 jump
displacement} into an unrolled chain of 24-pixel copy units -- no loop, no
remainder, no arithmetic -- and it fits 11 span lengths to 0.3%.

Three things that measurement showed and derivation could not:

  - The per-pixel cost is a function of REGISTER PRESSURE. FINDINGS 24's 9.08
    was a fixed blit with 12 registers free; v5 can spare 8 and pays 10.46; v6
    gets 12 back only because the encoder holds the state.
  - Short spans die in the remainder path -- a 12-pixel span costs MORE than a
    16-pixel one -- and the fix is padding, not avoidance.
  - Odd-x alignment is free (259.0 vs 261.8 cycles/span), as a 16-bit bus
    implies but nobody had checked.

Re-priced against the unchanged mode maps, sasi: median 74.4% -> 52.0% (29 said
43.0), misses 37 -> 10/120 (29 said 8), 448.0 KB/s. Break-even moved from runs
of 2 blocks to runs of 4. 29.4 survives: a scene cut needs x >= 0.196 of the
frame as spans and the bus allows x <= 0.373, so it fits at 12fps.

All 23 timing configs are also checked pixel-exact, so none of this was timed
against a decoder that quietly skipped work.

FINDINGS 30. Next: lever B, the cost-aware mode decision.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 15:45:51 -07:00
parent 3641f37e28
commit 29eb78a599
9 changed files with 780 additions and 73 deletions
+74 -55
View File
@@ -1,60 +1,48 @@
# Status & next-session handoff — end of session 7 (2026-08-23)
# Status & next-session handoff — session 8 (2026-08-23)
## NEXT SESSION: measure a span, then make the mode decision cost-aware
## Where this stands
The decoder exists, it is pixel-exact, and **it does not fit**. On the worst
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 decoder exists, it is pixel-exact, and **it does not fit**: mean 81.7% of a
12fps frame on the worst sustained window at `sasi`, 31% of frames over budget
(`scsi`: 94.9% median, 42% miss). FINDINGS 28. CPU is the binding constraint.
**Two levers, and the cheap one has to be measured first.**
Session 7 proposed two levers and session 8 measured the cheap one 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`):
**Lever A — spend bandwidth to buy cycles — is real, and it is an encoder
format.** A row-linear span of word-expanded literals measures **43.7 cycles per
span + 9.152 per pixel** (FINDINGS 30, `tools/bench/span.sh`), which is what
FINDINGS 29 assumed — but only when the *encoder* hands the decoder an absolute
GVRAM address and a jump displacement into an unrolled copy chain. The obvious
decoder, handed `(x, npix)` and left to work the copy out, is 97.9 + 10.46 and
2.2x dearer on a short span. Re-priced against the unchanged mode maps:
| | 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) |
| | today | 29 (derived) | **30 (measured)** |
|---|---:|---:|---:|
| `sasi` median frame | 74.4% | 43.0% | **52.0%** |
| `sasi` worst frame | 136.2% | 106.2% | **108.7%** |
| `sasi` frames missing | 37/120 | 8/120 | **10/120** |
| bitrate | 101.7 KB/s | 453.2 | **448.0 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.
Break-even moved with it: a run beats all-V1 **from 4 blocks up**, not 2. And
29.4 survives — a scene cut needs `x >= 0.196` of the frame as spans and the bus
allows `x <= 0.373`, so it fits at 12fps.
*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:
**Lever B — stop buying modes the CPU cannot afford — is untouched.**
`vq_hybrid.decide()` still minimises `D + lam*R`, distortion against BYTES, on a
machine whose binding budget is CYCLES:
| mode | payload bytes | measured cycles | cycles per byte |
| mode | payload bytes | cycles | cycles per byte |
|---|---:|---:|---:|
| SKIP | 0 | 13 (clustered) | — |
| V1 | 1 | 300 | 300 |
| V4 | 4 | 448 | 112 |
| RAW | 16 | 400 | 25 |
| *word-expanded literal block* | *32* | *~240 (derived)* | *7.5* |
| **span, per 4x4 block in a run of L** | **32** | **1053/L, floor 154** | **~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. 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).
V4 is 25% of blocks and 50% of the cycles. The lagrangian charges it 4x a V1
block; the CPU charges it 1.49x.
**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.
## The work, in order
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`,
@@ -86,10 +74,11 @@ optimisation that now costs cycles (FINDINGS 29.2).
`tools/bench/decode.lua`.
3b. **Know which misses are yours to fix before starting.** Re-coding every
non-SKIP block as V1 is the floor any mode assignment can reach, and it
still misses 11 frames at `sasi` and 12 at `scsi` every frame above ~90%
non-SKIP. So the cost-aware decision can reach about three quarters of the
misses (26 of 37 at `sasi`) and the rest are item 4. FINDINGS 28.7.
non-SKIP block as V1 is the floor any mode assignment *of the current mode
set* can reach, and it still misses 11 frames at `sasi` and 12 at `scsi`
every frame above ~90% non-SKIP. So the cost-aware decision can reach about
three quarters of the misses (26 of 37 at `sasi`) and the rest need item 4.
FINDINGS 28.7.
3c. **Buy RAW, not V4, wherever the bytes allow.** RAW is 400 cycles against
V4's 448 *and* is pixel-exact, so on the CPU axis V4 is strictly dominated —
@@ -97,15 +86,18 @@ optimisation that now costs cycles (FINDINGS 29.2).
`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. **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.
4. **Put spans in the bitstream** — the mode is measured and nothing implements
it. This is a container change (`encode.py`, `dlx.py`, `decode.s`), a mode
decision that can see runs rather than blocks, and the 24-pixel quantisation
and row-overrun rules of FINDINGS 30.2. It subsumes item 4 of session 7's
plan: with spans, a scene cut fits.
5. **The three things 30.7 leaves open, now more load-bearing than before**,
because the span design runs at 448 KB/s of a 488 KB/s pipe: re-run the
ring-buffer simulation at that rate (FINDINGS 21 was established at 110 and
280), confirm the provenance of the 4 Mbps figure, and **confirm DMA rather
than PIO** — a PIO fallback puts a 448 KB/s transfer back on the CPU this
whole lever exists to relieve. The DMA check is the cheapest of the three.
**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
@@ -116,6 +108,33 @@ saves ~16 of 448 cycles.
---
## What session 8 settled
1. **The span is measured: 43.7 cycles/span + 9.152/pixel, fitted to 0.3% over
eleven span lengths.** `tools/bench/blit.s` v5/v6, `prep_spans.py`,
`span.lua`, driven by `tools/bench/span.sh` (~25 s, not in `check.sh`
because it is a wall timing). FINDINGS 30.
2. **Only in an encoder-assisted format.** `{u32 absolute GVRAM address, u16
jump displacement}` into an unrolled chain, versus `(x, npix)` and a decoder
that works it out: 43.7 + 9.152 against 97.9 + 10.46. All the arithmetic a
span decoder would do per frame is known at encode time. FINDINGS 30.2.
3. **The per-pixel cost is a function of register pressure**, which FINDINGS 24
could not have shown: 9.08 was a fixed blit with 12 registers free, v5 can
spare 8 and pays 10.46, v6 gets 12 back by making the encoder hold the state.
4. **Short spans die in the remainder path, and the fix is padding.** A 12-pixel
span costs more than a 16-pixel one in v5. v6 has no remainder path: lengths
are multiples of 24 pixels, padding is free of everything but bytes, and an
overrun past the visible 256 lands in the invisible half of the 1024-byte
line stride. FINDINGS 30.3.
5. **Odd-`x` alignment is free** (259.0 vs 261.8 cycles/span) — expected on a
16-bit bus, now measured rather than assumed.
6. **The trade is smaller than 29 derived but the conclusion holds**, including
29.4's reopening of the scene cut. All 23 timing configs also drew a
pixel-exact frame, so nothing here was timed against a decoder that skipped
work. FINDINGS 30.5/30.6.
---
## What session 7 settled
1. **68000 code parses a bitstream and draws frames, pixel-exact.**