Price cycles in the mode decision: 37 misses become 1, for 0.26 dB

The decoder has been CPU-bound since FINDINGS 28 while the mode decision
minimised D + lam*R -- distortion against BYTES. decide() now minimises
D + lam*bytes + mu*cycles, and ratectl bisects mu per frame against the
833,333-cycle budget with the lam bisection nested inside it. On the worst
sustained window:

  sasi  27.22 -> 26.95 dB, 109.5 -> 109.4 KB/s, 37/120 misses -> 1
  scsi  29.90 -> 29.27 dB, 280.0 -> 278.6 KB/s, 51/120 misses -> 1

Bitrate does not move: the byte controller still binds, and mu changes WHICH
modes are bought. V4 is what it stops buying -- 25.2 -> 20.3% of blocks at sasi
and 15.0 -> 5.3% at scsi, where RAW takes it. That is 28.8's inversion in
practice: RAW is dearer in bytes and cheaper in cycles, so only the byte-rich
profile can buy its way out of V4.

Three things worth knowing beyond the headline:

  - The one frame that still misses, at both profiles, is FRAME 0 -- no previous
    reconstruction, so 100% changed by definition, which is also what a scene
    cut is. It comes out at the all-V1 floor of 110.6% and is emitted late on
    purpose. Freezing a cut to make a deadline is the worse failure.
  - 28.7's "11 frames are impossible" was too pessimistic. That floor held the
    SKIP set fixed and asked how cheaply the drawn blocks could be drawn; the
    real decision can also MOVE a block to SKIP, which above ~90% non-SKIP is
    the only lever left.
  - SKIP's price depends on its neighbours (13.25 cycles clustered, 45 mixed),
    which a per-block lagrangian cannot see. The way out is that the two uses
    need not share a cost function: a ranking constant inside decide(), the
    exact clustered rule for the frame-level bisection. vq_hybrid.cycles() is
    now the one definition of that rule and 11_cpu_budget.py imports it.

Gated: 09_ratectl_drift.py runs both controllers, both 0/120 drifting frames.
The cost-aware container decodes pixel-exact on the 68000 (120 frames). ON by
default in encode.py; --no-cpu-fit restores session 7. check.sh ALL GREEN.

Still a model, not a measurement, for THIS container: FINDINGS 31's cycle
figures come from vq_hybrid.cycles (within 1 point of the 68000 on four frames
of the session-7 container). Timing this one on the machine is step 1 of the
next session -- it was started and killed for time, and it is slow.

FINDINGS 31. tools/analysis/13_cpu_ratectl.py.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 16:24:22 -07:00
parent 29eb78a599
commit 06b98d4b47
10 changed files with 586 additions and 186 deletions
+105
View File
@@ -1678,3 +1678,108 @@ re-priced `sasi` stream buys 8773 spans across 120 frames, a mean of 73 a frame,
and each one's three-instruction dispatch is inside the fitted 43.7 — but the
mode-map walk that decides a span exists is not. `decode.s` does not implement
spans yet.
## 31. The mode decision can see cycles now, and it costs 0.26 dB (session 8)
FINDINGS 28 left the decoder missing 31% of frames at `sasi` and 42% at `scsi`
while the mode decision minimised `D + lam*R` — distortion against BYTES — on a
machine whose binding budget is CYCLES. This is the second controller.
`vq_hybrid.decide(ctx, lam, mu)` now minimises `D + lam*bytes + mu*cycles`, and
`ratectl.encode_rate_controlled(cycle_budget=...)` bisects `mu` per frame
against 833,333 cycles with the `lam` bisection nested inside it.
`tools/analysis/13_cpu_ratectl.py` measures what it costs.
### 31.1 The result
Worst sustained window, 120 frames, same targets, same quality floors:
| | PSNR | KB/s | CPU median | CPU max | frames missing |
|---|---:|---:|---:|---:|---:|
| `sasi` bytes only | 27.22 dB | 109.5 | 74.4% | 136.2% | **37/120** |
| `sasi` + cycle ceiling | **26.95 dB** | 109.4 | 81.5% | 110.6% | **1/120** |
| `scsi` bytes only | 29.90 dB | 280.0 | 94.9% | 146.6% | **51/120** |
| `scsi` + cycle ceiling | **29.27 dB** | 278.6 | 99.6% | 110.6% | **1/120** |
**36 of 37 misses at `sasi` for 0.26 dB, 50 of 51 at `scsi` for 0.62 dB.** The
bitrate does not move: the byte controller still binds, and mu changes *which*
modes are bought rather than how many bytes.
`sasi` pays less quality than `scsi` because it had less to give up: it was
already short of bytes, so the cycle-cheap directions it takes (V4 -> V1, and
blocks it can afford to hold) were near where the byte lagrangian already sat.
28.8 predicted the shape of this and got the sign right.
Mode mix, `sasi`, bytes-only -> with the ceiling: SKIP 46.4 -> 47.1%,
V1 19.8 -> 23.0%, **V4 25.2 -> 20.3%**, RAW 8.5 -> 9.6%. At `scsi` the V4
collapse is dramatic — **15.0 -> 5.3%**, with RAW taking it at 41.3 -> 43.2%,
which is 28.8's inversion happening in practice: RAW is dearer in bytes and
cheaper in cycles, so a byte-rich profile buys its way out of V4.
Only **46 of 120 frames need any mu at all** at `sasi`; the median frame is
decided at mu=0 and is unchanged from session 6.
### 31.2 The one frame that cannot fit is the intra frame, not a hard case
Both profiles miss exactly one frame, both at 110.6% — the all-V1 floor of
FINDINGS 28.5 — and in both it is **frame 0**. It has no previous
reconstruction, so every block must be coded, which is the definition of a
100%-changed frame. A scene cut mid-stream is the same thing.
That is the correct behaviour rather than a failure, and it is worth being
explicit about why: at `MU_CLIFF` a block only becomes SKIP if holding the
previous reconstruction costs less than ~28,665 units of distortion. A frame
with nothing on screen worth holding stays fully coded and is emitted **late on
purpose**, exactly as a frame that will not fit at `LAM_CLIFF` is emitted over
budget. Freezing a cut to make a deadline is the worse failure.
### 31.3 28.7 was too pessimistic, and the reason is instructive
28.7 estimated that only ~three quarters of the misses were the encoder's to
fix — 26 of 37 at `sasi` — because re-coding every non-SKIP block as V1 still
missed 11 frames. Measured, the controller fixes **36 of 37**.
The gap is that 28.7's floor held the SKIP set fixed and asked "how cheap can
the blocks we already decided to draw be?". The real decision can also **move a
block to SKIP**, paying distortion for it, and above ~90% non-SKIP that is the
only lever left. So 28.7's floor was a floor for a fixed SKIP set, not for the
mode decision. Two conclusions of 28.7 stand: the profiles are an I/O axis and
both must fit the same 10 MHz budget.
### 31.4 SKIP is not a constant, and the way out is two cost functions
A SKIP block costs 13.25 cycles when all four blocks sharing its header byte are
SKIP (one `tst.b` clears the group) and ~45 in a mixed byte, so its price
depends on its neighbours — which a per-block lagrangian cannot see. Picking one
number is a real trade: 45 overcharges clustered SKIPs and pushes the encoder
away from the mode that saves the most cycles, 13.25 undercharges isolated ones
and lets frames overrun.
The resolution is that **the budget check does not have to use the same cost
function as the mode decision**. `decide()` uses 13.25 purely to *rank* modes
within a block, where the choice only scales the incentive (the V1-SKIP gap
moves 12% between the two candidates). The controller scores whole frames with
`vq_hybrid.cycles()`, the exact clustered rule, validated to 1 point against the
68000 — so the bisection converges on what the machine will really do, whatever
the ranking constant was. That function is now defined once and imported by
`11_cpu_budget.py`, rather than living in two places that can drift apart.
### 31.5 Both controllers are gated against decoder drift
The mu controller varies the mode map frame to frame exactly as the lam
controller does, so it is exposed to the FINDINGS 26.1 failure — an encoder
reporting a reconstruction the decoder will never produce. `09_ratectl_drift.py`
now runs **both** configurations and both report 0/120 drifting frames, 0.00 dB
overstatement. The CPU ceiling is on by default in `encode.py`
(`--no-cpu-fit` restores session 7 behaviour).
### 31.6 With spans on top, the window fits completely
Re-running the span pricing of FINDINGS 30 against a cost-aware container —
lever B first, then lever A on what it leaves:
| `sasi` | bytes only | + cycle ceiling | + ceiling + spans |
|---|---:|---:|---:|
| median frame | 74.4% | 81.5% | **56.8%** |
| worst frame | 136.2% | 110.6% | **91.5%** |
| frames missing | 37/120 | 1/120 | **0/120** |
| bitrate | 101.7 KB/s | 101.6 | 449.3 KB/s |
The intra frame lands at 91.5% — spans are what make a full redraw fit, which is
30.6's arithmetic arriving in a real container. That row is still a **model** of
a bitstream nothing implements; the two levers have never run on the 68000
together, and the ring-buffer question of 30.7 gets sharper at 449 KB/s.
+100 -94
View File
@@ -1,115 +1,116 @@
# Status & next-session handoff — session 8 (2026-08-23)
# Status & next-session handoff — end of session 8 (2026-08-23)
## Where this stands
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.
Session 7 left the decoder pixel-exact and **31% of frames over the CPU budget**
at `sasi`, 42% at `scsi` (FINDINGS 28), with two levers proposed and neither
measured. Session 8 did both.
Session 7 proposed two levers and session 8 measured the cheap one first.
**Lever A, spans: measured.** A row-linear span of word-expanded literals costs
**43.7 cycles per span + 9.152 per pixel** — but only in an encoder-assisted
format, `{u32 absolute GVRAM address, u16 jump displacement}` into an unrolled
copy chain. The obvious decoder, handed `(x, npix)`, is 97.9 + 10.46.
FINDINGS 30, `tools/bench/span.sh` (~25 s).
**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:
**Lever B, the cost-aware mode decision: implemented, measured, and ON by
default.** `decide()` minimises `D + lam*bytes + mu*cycles`; `mu` is bisected
per frame against 833,333 cycles with the `lam` bisection nested inside it.
FINDINGS 31, `tools/analysis/13_cpu_ratectl.py`.
| | 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) |
| `sasi`, worst sustained window | PSNR | KB/s | CPU median | CPU max | missing |
|---|---:|---:|---:|---:|---:|
| bytes only (session 7) | 27.22 dB | 109.5 | 74.4% | 136.2% | **37/120** |
| + cycle ceiling (now the default) | 26.95 dB | 109.4 | 81.5% | 110.6% | **1/120** |
| + ceiling + spans (MODEL, nothing implements it) | — | 449.3 | 56.8% | 91.5% | **0/120** |
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.
`scsi`: 51/120 -> 1/120 for 0.62 dB. The one remaining miss at either profile is
**frame 0**, which has no previous reconstruction and so is 100% changed by
definition — the same case as a scene cut. It is emitted late on purpose.
**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:
The cost-aware container is verified pixel-exact on the 68000 (120 frames,
`tools/bench/verify_decode.py`).
| mode | payload bytes | cycles | cycles per byte |
|---|---:|---:|---:|
| SKIP | 0 | 13 (clustered) | — |
| V1 | 1 | 300 | 300 |
| V4 | 4 | 448 | 112 |
| RAW | 16 | 400 | 25 |
| **span, per 4x4 block in a run of L** | **32** | **1053/L, floor 154** | **~5** |
## NEXT SESSION, in order
V4 is 25% of blocks and 50% of the cycles. The lagrangian charges it 4x a V1
block; the CPU charges it 1.49x.
0. **Green light first.** `./tools/bench/check.sh` (~3 min, Blu-ray mounted).
The drift stage now runs BOTH controllers; both must report 0/120.
## The work, in order
1. **Time the cost-aware container on the 68000.** Everything in FINDINGS 31 is
the validated cost MODEL (`vq_hybrid.cycles`, within 1 point of the machine
on four frames of the session-7 container), not a measurement of this one.
The full timing pass was started and killed for time:
```
python3 tools/bench/prep_dlx.py tmp/rc_fr_singe_sasi_cpufit.dlx
tools/vasm/vasmm68k_mot -Fbin -o tmp/decode.bin src/player/decode.s
( cd tmp && SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -ramsize 2M \
-video soft -window -sound none -nothrottle -plugins \
-autoboot_script ../tools/bench/decode.lua -snapshot_directory ./snap_decode \
-snapview native -seconds_to_run 300 > decode_cpufit.log 2>&1 )
```
**Budget real time for it: the run was still going at 12 minutes of CPU.**
MAME's stdout is block-buffered to a file, so there is no progress to watch
— wait on the PID, never on a `pgrep -f` match (see the shell traps below).
Confirm the four anchors against `11_cpu_budget.py` on the same container,
and update FINDINGS 31 with measured-vs-model errors.
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.
**SKIP is not a constant and this is the one trap here.** A SKIP block costs
13.25 cycles when all four blocks in its header byte are SKIP (one `tst.b`
clears the group) and ~45 when it sits in a mixed byte — so SKIP's price
depends on its *neighbours*, which a per-block lagrangian cannot see. Do not
pick one number and move on: 45 overcharges clustered SKIPs and pushes the
encoder away from the mode that saves cycles, 13.25 undercharges isolated
ones and lets frames overrun. The way out is that the **budget check does not
have to use the same cost function as the mode decision** — score frames with
the exact clustered cost (`cycles()` in `tools/analysis/11_cpu_budget.py`,
validated to 1 point against the 68000) and let the bisection converge on
that, while the per-block term uses a constant purely to *rank* modes.
2. **Then bisect `mu` per frame against the 833,333-cycle budget**, exactly as
session 6 bisects `lam` against the byte budget. The machinery is already
there and already gated: `ratectl.encode_rate_controlled` is frame-driven and
feeds back the frame it emitted. **But cycles have NO bucket.** Bytes can be
banked in the ring buffer; a frame that misses its decode deadline is just
late, because there is no double buffer to decode ahead into. So this is a
hard per-frame ceiling, not a leaky bucket — simpler than rate control, and
the two controllers have to run together (raising `mu` moves blocks to SKIP
and V1, which also *lowers* the bitrate, so the byte controller must see it).
3. **Measure the quality cost.** Everything session 6 did for bytes: what does
fitting 100% of frames in the CPU budget cost in dB, and does any frame hit a
cliff? `tools/analysis/11_cpu_budget.py` scores a container without needing
MAME, so the search loop is cheap; confirm the winner on the 68000 with
`tools/bench/decode.lua`.
2. **Put spans in the bitstream.** This is the big one and it is now fully
specified by measurement: format in FINDINGS 30.2, costs in 30.5, and the
scene-cut arithmetic in 30.6. It touches `encode.py` (a fifth mode and a
run-aware decision), `dlx.py` (the reference decoder), and `decode.s`. The
24-pixel quantisation and the free row overrun are part of the format, not
optimisations to add later. Order it AFTER item 1 so the model that prices it
has been checked against the machine once more.
3b. **Know which misses are yours to fix before starting.** Re-coding every
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.
3. **The three open items of FINDINGS 29.5/30.7**, now load-bearing because a
span design runs at ~449 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 user's 4 Mbps figure, and **confirm DMA rather than
PIO** — a PIO fallback puts a 449 KB/s transfer back on the CPU the whole
lever exists to relieve. The DMA check is the cheapest of the three and the
most consequential.
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 —
the byte lagrangian's preference inverts. `scsi` can take that escape and
`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. **Encoder gap, still open from session 7:** `encode.py` should pad frame
records to 4 bytes. Frame boundaries land on odd addresses and a 68000 takes
an address error, not a slow read (FINDINGS 28.3). `prep_dlx.py` pads at load
time, which is why the decoder works; the container itself does not.
Measured cost of fixing it: 1.5 B/frame = 18 B/s.
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.
5. **A quality-vs-framerate question that is the user's, not the encoder's.**
Every miss is now one frame per cut. The options remain: 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. Spans (item 2) make the question go
away if they land as modelled.
**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
inner loop is close to what the instruction set allows; the plausible wins are
single-digit percentages against a 36-point gap. The V4 write pattern is the one
place worth a look afterwards — pairing sub-block rows into `movem.l d0/d2,(a4)`
saves ~16 of 448 cycles.
with the measurements to 0.5% on V1 and 1% on RAW (FINDINGS 28.4), so the inner
loop is close to what the instruction set allows.
---
## What session 8 settled
0. **The mode decision can see cycles, it is on by default, and it costs
0.26 dB.** `decide(ctx, lam, mu)` minimises `D + lam*bytes + mu*cycles`;
`ratectl` bisects mu per frame against a HARD 833,333-cycle ceiling (bytes
bank in the ring buffer, cycles cannot — there is no double buffer to decode
ahead into). `sasi` 37/120 misses -> 1, `scsi` 51 -> 1. Bitrate does not
move: mu changes which modes are bought, not how many bytes. FINDINGS 31,
`tools/analysis/13_cpu_ratectl.py`.
0b. **28.7's "11 frames are impossible" was too pessimistic — it is 1.** That
floor held the SKIP set fixed; the real decision can also move a block to
SKIP, which above ~90% non-SKIP is the only lever left. FINDINGS 31.3.
0c. **V4 collapses when cycles are priced**, as 28.8 predicted: 25.2 -> 20.3%
of blocks at `sasi` and **15.0 -> 5.3%** at `scsi`, where RAW takes it. RAW
is dearer in bytes and cheaper in cycles, so the byte lagrangian's preference
inverts and only the byte-rich profile can take the escape.
0d. **SKIP's price depends on its neighbours, and the way out is two cost
functions**: a ranking constant inside the per-block lagrangian, the exact
clustered rule (`vq_hybrid.cycles`, validated to 1 point against the 68000)
for the frame-level bisection. That function is now defined once and imported
by `11_cpu_budget.py`. FINDINGS 31.4.
0e. **Both controllers are gated against decoder drift.**
`09_ratectl_drift.py` runs bytes-only AND bytes+cycles; both 0/120.
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`
@@ -217,10 +218,15 @@ saves ~16 of 448 cycles.
```
./tools/bench/check.sh
```
~3 min, needs the Blu-ray mounted. From source media it re-runs both display
regression tests, the rate-control drift test (session 6), the display-path
coherency counterexample and a **120-frame 68000 decode** (session 7), then
prints `ALL GREEN`. Verified green at end of session 7.
~4 min, needs the Blu-ray mounted. From source media it re-runs both display
regression tests, the rate-control drift test (session 6, now covering BOTH
controllers -- bytes, and bytes+cycles), the display-path coherency
counterexample and a **120-frame 68000 decode** (session 7), then prints
`ALL GREEN`. Verified green at end of session 8.
Do not run two of these at once, and do not run one alongside a MAME timing
job: they share `tmp/` snapshot directories and log files, and the second run
silently truncates the first one's output.
If it fails, fix that before doing anything else — everything downstream assumes
the display path is pixel-exact.