From 06b98d4b476bc78ca7f786e440b5fd163b60fab7 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:24:22 -0700 Subject: [PATCH] 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 --- README.md | 13 +- docs/FINDINGS.md | 105 ++++++++++++++++ docs/STATUS.md | 194 +++++++++++++++-------------- tools/analysis/09_ratectl_drift.py | 98 +++++++++------ tools/analysis/11_cpu_budget.py | 23 ++-- tools/analysis/13_cpu_ratectl.py | 101 +++++++++++++++ tools/bench/decode.lua | 5 + tools/encoder/encode.py | 52 +++++--- tools/encoder/ratectl.py | 111 +++++++++++++++-- tools/encoder/vq_hybrid.py | 70 +++++++++-- 10 files changed, 586 insertions(+), 186 deletions(-) create mode 100644 tools/analysis/13_cpu_ratectl.py diff --git a/README.md b/README.md index 952de48..8bfb461 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ tools/analysis/ measurement scripts, numbered in the order they were written 11 scores a container against the MEASURED per-mode block costs without needing MAME; 12 prices the literal-span mode of FINDINGS 30 against those same mode maps, and prints whether a - scene cut still fits at 12fps. + scene cut still fits at 12fps; 13 measures what fitting the + CPU budget costs in dB (FINDINGS 31) and caches H.build so the + search loop is seconds, not minutes. tools/bench/ MAME Lua injection harness + 68000 benchmark sources. `check.sh` re-runs both display regression tests (~40 s). `blit.s`/`blit.lua` time the full-frame GVRAM blit on the @@ -70,7 +72,14 @@ python3 tools/encoder/encode.py /tmp/fr out.dlx --profile sasi --preview p.png Two quality profiles ship from one codec and one decoder — `sasi` (110 KB/s) and `scsi` (280 KB/s) are two points on the same rate-distortion curve. Both are **ceilings**: lam is bisected per frame under a leaky bucket, so the profile's -`lam` is a quality floor rather than a setting (`--fixed-lam` opts out). The codec is +`lam` is a quality floor rather than a setting (`--fixed-lam` opts out). + +There are **two** ceilings, on two different axes. The second is the 68000's +decode budget: `mu` is bisected per frame against 833,333 cycles so the frame +also *decodes* in time, which takes the worst sustained window from 37 frames +over budget to 1 for 0.26 dB (FINDINGS 31). It is on by default; `--no-cpu-fit` +restores session 7 behaviour. Unlike bytes, cycles have no bucket — there is no +double buffer to decode ahead into, so it is a hard per-frame ceiling. The codec is a Cinepak-style hybrid: each 4x4 block is coded as SKIP, one 4x4 codeword, four 2x2 codewords, or RAW literal pixels, chosen per block by rate-distortion. diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 7c713ca..b492800 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 2993e5d..fca4301 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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. diff --git a/tools/analysis/09_ratectl_drift.py b/tools/analysis/09_ratectl_drift.py index 3adcb33..10a8ea6 100644 --- a/tools/analysis/09_ratectl_drift.py +++ b/tools/analysis/09_ratectl_drift.py @@ -19,6 +19,10 @@ paint), so drift is zero by construction rather than by tuning. This replays what a real decoder does -- SKIP copies the ACTUALLY EMITTED previous frame -- and compares it to the reconstruction ratectl recorded. +Session 8 added a SECOND controller (mu, the per-frame 68000 decode ceiling) +that also varies the mode map frame to frame, so it is exposed to exactly the +same failure and is tested here too. Both configurations must show zero drift. + Needs tmp/fr_singe (see docs/STATUS.md, reproducing the sustained-action result). ~55 s, nearly all of it the k-means in H.build; the rate-controlled encode of 120 frames is ~2 s. @@ -29,49 +33,63 @@ import numpy as np import vq as VQ, vq_hybrid as H, ratectl as RC m = H.build("tmp/fr_singe", k1=256, k4=256, iters=16) -# lam_lo=1.0: let quiet frames spend the whole allowance, which is the -# harder case for this test -- it maximises how often lam moves frame to frame. -enc = RC.encode_rate_controlled(m, target_kbps=110, lam_lo=1.0) -lam = enc["lam"] -sw = int((np.diff(lam) != 0).sum()) -print(f"frames={len(lam)} distinct lam used={len(set(lam.tolist()))} " - f"lam changes frame-to-frame={sw} " - f"overruns={int(enc['overrun'].sum())}") -pal, nbx = m["pal"], m["W"] // 4 -emitted = [] -drift_px, drift_db = [], [] -for f, (rec, mode) in enumerate(zip(enc["recon"], enc["modes"])): - out = rec.copy() - if f > 0: - prev_true = emitted[-1] - for b in np.flatnonzero(mode == 0): # SKIP blocks - by, bx = divmod(int(b), nbx) - y, x = by*4, bx*4 - out[y:y+4, x:x+4] = prev_true[y:y+4, x:x+4] - emitted.append(out) - d = (out != rec).sum() - drift_px.append(d) - drift_db.append(VQ.psnr(pal[rec], pal[out])) +def check(label, cycle_budget): + """Encode, replay as a decoder would, and return the drift in pixels.""" + print(f"\n=== {label} ===") + m.pop("_sym", None) + # lam_lo=1.0: let quiet frames spend the whole allowance, which is the + # harder case for this test -- it maximises how often lam moves frame to + # frame. + enc = RC.encode_rate_controlled(m, target_kbps=110, lam_lo=1.0, + cycle_budget=cycle_budget) + lam = enc["lam"] + sw = int((np.diff(lam) != 0).sum()) + print(f"frames={len(lam)} distinct lam used={len(set(lam.tolist()))} " + f"lam changes frame-to-frame={sw} " + f"overruns={int(enc['overrun'].sum())}") -drift_px = np.array(drift_px) -print(f"pixels differing from what the encoder recorded:") -print(f" frames with ANY drift: {int((drift_px>0).sum())}/{len(drift_px)}") -print(f" max {drift_px.max()} px ({100*drift_px.max()/(m['H']*m['W']):.1f}% of frame)") -print(f" mean {drift_px.mean():.0f} px") -fin = [d for d in drift_db if np.isfinite(d)] -if fin: - print(f" encoder-vs-decoder agreement: min {min(fin):.1f} dB " - f"(inf = identical on {len(drift_db)-len(fin)} frames)") + pal, nbx = m["pal"], m["W"] // 4 + emitted = [] + drift_px, drift_db = [], [] + for f, (rec, mode) in enumerate(zip(enc["recon"], enc["modes"])): + out = rec.copy() + if f > 0: + prev_true = emitted[-1] + for b in np.flatnonzero(mode == 0): # SKIP blocks + by, bx = divmod(int(b), nbx) + y, x = by*4, bx*4 + out[y:y+4, x:x+4] = prev_true[y:y+4, x:x+4] + emitted.append(out) + d = (out != rec).sum() + drift_px.append(d) + drift_db.append(VQ.psnr(pal[rec], pal[out])) + + drift_px = np.array(drift_px) + print(f"pixels differing from what the encoder recorded:") + print(f" frames with ANY drift: {int((drift_px>0).sum())}/{len(drift_px)}") + print(f" max {drift_px.max()} px ({100*drift_px.max()/(m['H']*m['W']):.1f}% of frame)") + print(f" mean {drift_px.mean():.0f} px") + fin = [d for d in drift_db if np.isfinite(d)] + if fin: + print(f" encoder-vs-decoder agreement: min {min(fin):.1f} dB " + f"(inf = identical on {len(drift_db)-len(fin)} frames)") + + r = RC.summarise(m, enc, 110) + print(f"\nratectl reports PSNR {r['psnr']:.2f} dB, {r['kbps']:.1f} KB/s " + f"(target 110), {r['over']:.0f}% of frames over budget") + tp = np.mean([VQ.psnr(o, pal[e]) for o, e in zip(m["rgb"], emitted)]) + print(f"what a decoder actually reconstructs: {tp:.2f} dB " + f"-> overstated by {r['psnr']-tp:.2f} dB") + return drift_px -r = RC.summarise(m, enc, 110) -print(f"\nratectl reports PSNR {r['psnr']:.2f} dB, {r['kbps']:.1f} KB/s " - f"(target 110), {r['over']:.0f}% of frames over budget") -tp = np.mean([VQ.psnr(o, pal[e]) for o, e in zip(m["rgb"], emitted)]) -print(f"what a decoder actually reconstructs: {tp:.2f} dB " - f"-> overstated by {r['psnr']-tp:.2f} dB") # Acceptance criterion for the fix: a decoder replaying the emitted stream must -# reconstruct exactly what the encoder recorded. -sys.exit(1 if (drift_px > 0).any() else 0) +# reconstruct exactly what the encoder recorded -- under either controller. +bad = 0 +for label, cb in (("bytes only (session 6)", None), + ("bytes + CPU ceiling (session 8)", RC.FRAME_CYCLES)): + d = check(label, cb) + bad += int((d > 0).any()) +sys.exit(1 if bad else 0) diff --git a/tools/analysis/11_cpu_budget.py b/tools/analysis/11_cpu_budget.py index 7c6910d..44ba8fc 100644 --- a/tools/analysis/11_cpu_budget.py +++ b/tools/analysis/11_cpu_budget.py @@ -22,6 +22,7 @@ import sys, os, argparse sys.path.insert(0, "tools/encoder") import numpy as np from dlx import DLX +import vq_hybrid as H # Machine clocks, confirmed from MAME 0.277 src/mame/sharp/x68k.cpp:1133/1194/ # 1200 -- not recalled. x68000 and x68ksupr are BOTH 40_MHz_XTAL/4 = 10 MHz; @@ -30,10 +31,13 @@ from dlx import DLX CLOCKS = {"stock": 10.0, "super": 10.0, "xvi": 33.33 / 2, "x68030": 25.0} FPS = 12 -# cycles per block, measured on the emulated 68000 (synthetic single-mode frames) -C_V1, C_V4, C_RAW = 299.9, 448.2, 400.4 -C_SKIP_FAST = 53.0 / 4 # all-SKIP header byte: one tst.b for 4 -C_SKIP_MIXED = 45.0 # a SKIP block inside a mixed byte +# Cycles per block, measured on the emulated 68000 (synthetic single-mode +# frames). Defined in tools/encoder/vq_hybrid.py, which is where the mode +# decision needs them too -- one copy, not two, so a re-measurement cannot +# leave the encoder and the scorer disagreeing. +C_V1, C_V4, C_RAW = H.C_V1, H.C_V4, H.C_RAW +C_SKIP_FAST, C_SKIP_MIXED = H.C_SKIP_CLUSTERED, H.C_SKIP_MIXED +cycles = H.cycles ap = argparse.ArgumentParser() ap.add_argument("container", nargs="?", @@ -50,17 +54,6 @@ if not os.path.exists(a.container): d = DLX(a.container) -def cycles(mode): - g = mode.reshape(-1, 4) # one header byte = four blocks - allskip = (g == 0).all(1) - c = allskip.sum() * 4 * C_SKIP_FAST - m = g[~allskip] - c += (m == 0).sum() * C_SKIP_MIXED - c += (m == 1).sum() * C_V1 - c += (m == 2).sum() * C_V4 - c += (m == 3).sum() * C_RAW - return c - modes = [d.modes(f) for f in range(d.nframes)] cyc = np.array([cycles(m) for m in modes]) pct = 100 * cyc / FRAME diff --git a/tools/analysis/13_cpu_ratectl.py b/tools/analysis/13_cpu_ratectl.py new file mode 100644 index 0000000..993ffcc --- /dev/null +++ b/tools/analysis/13_cpu_ratectl.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""What does fitting the CPU budget cost in quality? (session 8, lever B) + + python3 tools/analysis/13_cpu_ratectl.py [frames_dir] [--profiles sasi,scsi] + +Session 6 made the BYTE budget a ceiling by bisecting `lam` per frame. FINDINGS +28 then showed the binding budget is CYCLES, not bytes, and that the mode +decision cannot see them: it minimises `D + lam*R` on a machine that charges V4 +1.49x a V1 block while the lagrangian charges it 4x. + +`ratectl.encode_rate_controlled(cycle_budget=...)` adds the second controller -- +`mu` bisected per frame against 833,333 cycles, with the lam bisection nested +inside it. This measures what that costs: PSNR, bitrate, and how many frames +still miss, against the same encode with the ceiling off. + +The cycle budget is HARD, not a bucket. Bytes bank in the player's ring buffer; +there is no double buffer to decode ahead into, so a frame that misses its +decode deadline is simply late (FINDINGS 28). + +Both controllers score frames with the exact clustered cost `vq_hybrid.cycles`, +validated to 1 point against the 68000 (FINDINGS 28.2) -- not with the per-block +ranking constant the mode decision uses. See vq_hybrid's note on SKIP. +""" +import argparse, os, pickle, sys, time +sys.path.insert(0, "tools/encoder") +import numpy as np +import vq as VQ, vq_hybrid as H, ratectl as RC + +ap = argparse.ArgumentParser() +ap.add_argument("frames_dir", nargs="?", default="tmp/fr_singe") +ap.add_argument("--profiles", default="sasi,scsi") +ap.add_argument("--fps", type=int, default=12) +ap.add_argument("--cache", default=None, help="pickle of H.build (auto by dir)") +a = ap.parse_args() +if not os.path.isdir(a.frames_dir): + sys.exit(f"missing {a.frames_dir} -- see tools/bench/check.sh for extraction") + +BUDGET = RC.FRAME_CYCLES + +# H.build is ~55 s, nearly all k-means, and it does not depend on the profile: +# both ship k1=k4=256. One build, cached, serves every row of the table. +cache = a.cache or f"tmp/model_{os.path.basename(a.frames_dir.rstrip('/'))}.pkl" +if os.path.exists(cache): + m = pickle.load(open(cache, "rb")) + print(f"model from {cache}") +else: + t = time.time() + m = H.build(a.frames_dir, k1=256, k4=256, iters=16) + pickle.dump(m, open(cache, "wb")) + print(f"built model in {time.time()-t:.0f} s -> {cache}") +print(f"{a.frames_dir}: {len(m['idx'])} frames, {m['nb']} blocks, " + f"budget {BUDGET:,.0f} cycles/frame at {a.fps}fps\n") + + +def run(prof_name, cycle_budget): + p = RC.PROFILES[prof_name] + m.pop("_sym", None) # the frame-symbol cache holds one frame + t = time.time() + enc = RC.encode_rate_controlled(m, p["kbps"], fps=a.fps, lam_lo=p["lam"], + cycle_budget=cycle_budget) + s = RC.summarise(m, enc, p["kbps"], fps=a.fps) + s["secs"] = time.time() - t + s["ns"] = float(np.mean([100*(mm != 0).mean() for mm in enc["modes"]])) + return s, enc + + +rows = [] +for name in a.profiles.split(","): + for label, cb in (("bytes only", None), ("bytes + cycles", BUDGET)): + s, enc = run(name, cb) + rows.append((name, label, s)) + print(f"{name:5s} {label:<15s} {s['secs']:5.1f} s " + f"PSNR {s['psnr']:.2f} dB {s['kbps']:6.1f} KB/s " + f"CPU med {100*s['cyc_med']/BUDGET:5.1f}% p90 " + f"{100*s['cyc_p90']/BUDGET:5.1f}% max {100*s['cyc_max']/BUDGET:5.1f}% " + f"miss {s['cpu_miss']:3d} late {s['late']:2d} " + f"mu med {s['mu_med']:.4f} max {s['mu_max']:.3f}") + +print() +hdr = f"{'':<22}{'PSNR':>8}{'KB/s':>9}{'CPU med':>10}{'CPU max':>10}{'miss':>7}" +for name in a.profiles.split(","): + r = {lab: s for n, lab, s in rows if n == name} + b, c = r["bytes only"], r["bytes + cycles"] + print(f"--- {name} (target {RC.PROFILES[name]['kbps']} KB/s) ---") + print(hdr) + for lab, s in (("bytes only", b), ("bytes + cycles", c)): + print(f" {lab:<20}{s['psnr']:>7.2f} {s['kbps']:>8.1f} " + f"{100*s['cyc_med']/BUDGET:>9.1f}%{100*s['cyc_max']/BUDGET:>9.1f}%" + f"{s['cpu_miss']:>6d}") + print(f" {'cost of fitting':<20}{c['psnr']-b['psnr']:>+7.2f} dB, " + f"{c['kbps']-b['kbps']:+.1f} KB/s, " + f"{b['cpu_miss']-c['cpu_miss']} fewer misses, " + f"{c['late']} frames unfixable at mu={RC.MU_CLIFF:g}") + print(f" {'modes % (b/c)':<20}SKIP {b['skip']:.1f}/{c['skip']:.1f} " + f"V1 {b['v1']:.1f}/{c['v1']:.1f} V4 {b['v4']:.1f}/{c['v4']:.1f} " + f"RAW {b['raw']:.1f}/{c['raw']:.1f}") + print() + +print("FINDINGS 28.7: re-coding every non-SKIP block as V1 is the floor the " + "CURRENT mode set\nallows, and it still misses 11 frames at sasi / 12 at " + "scsi. Misses above that floor\nare item 4 (spans), not item 1.") diff --git a/tools/bench/decode.lua b/tools/bench/decode.lua index a9f27b6..f5e4e64 100644 --- a/tools/bench/decode.lua +++ b/tools/bench/decode.lua @@ -157,6 +157,11 @@ SUB = emu.add_machine_frame_notifier(function() M.video:snapshot() P("snapshot taken after the sequential pass -- last frame, 68000-decoded") step = step + 1 + -- DLX_VERIFY_ONLY leaves nothing after the correctness pass, and this + -- used to walk off the end of PLAN and raise a Lua error AFTER the + -- snapshot was already on disk -- harmless to check.sh, and exactly the + -- kind of thing that gets mistaken for a decoder failure later. + if not PLAN[step] then st = "finish"; return end launch(PLAN[step].off, PLAN[step].nfr, PLAN[step].iter) st, t0 = "running", nil; return end diff --git a/tools/encoder/encode.py b/tools/encoder/encode.py index d1d4b60..67e8a2d 100644 --- a/tools/encoder/encode.py +++ b/tools/encoder/encode.py @@ -93,6 +93,9 @@ def main(): help="quality floor for rate control") ap.add_argument("--bucket-frames", type=int, default=8, help="leaky-bucket depth, in frame budgets") + ap.add_argument("--no-cpu-fit", action="store_true", + help="drop the per-frame 68000 decode ceiling (session 7 " + "behaviour: 31%% of frames on hard content do not fit)") ap.add_argument("--prefill", type=float, default=0.0, help="how full the player's buffer is assumed to be at " "scene start, as a fraction of the bucket (0 = cold " @@ -108,12 +111,19 @@ def main(): # An explicit --lam is a request for that lam, so it implies --fixed-lam. rc = not (a.fixed_lam or a.lam is not None) lam_lo = lam if a.rc_floor == "profile" else 1.0 + # The CPU ceiling is hardware, not taste: without it 31%% of frames on the + # worst sustained window do not decode in time on a stock 68000, and with + # it that is one frame -- the intra frame -- for 0.26 dB. FINDINGS 31. + cyc_budget = None if a.no_cpu_fit else RC.FRAME_CYCLES print(f"profile {a.profile}: {prof['desc']}") if rc: print(f" target {prof['kbps']} KB/s CEILING, rate-controlled: " f"lam bisected per frame in [{lam_lo:g}, {RC.LAM_CLIFF:g}], " f"{a.bucket_frames}-frame bucket") + print(f" CPU ceiling: " + (f"mu bisected per frame against " + f"{RC.FRAME_CYCLES:,.0f} cycles (12fps, stock 68000)" + if cyc_budget else "OFF (--no-cpu-fit)")) else: print(f" target {prof['kbps']} KB/s, FIXED lam={lam} (no rate control)") print(f" k1={k1} k4={k4}, {_IDX_BYTES}-byte indices") @@ -122,7 +132,8 @@ def main(): if rc: enc = RC.encode_rate_controlled(m, prof["kbps"], fps=a.fps, bucket_frames=a.bucket_frames, - lam_lo=lam_lo, prefill=a.prefill) + lam_lo=lam_lo, prefill=a.prefill, + cycle_budget=cyc_budget) else: enc = H.encode(m, lam=lam) r = H.evaluate(m, enc, fps=a.fps) @@ -186,23 +197,34 @@ def main(): print(f" frames that could not fit even at the lam={RC.LAM_CLIFF:g} " f"cliff: {rr['overrun']}/{len(lm)}") - # PER-FRAME non-SKIP distribution. The mean above cannot answer the - # decoder-architecture question (FINDINGS 24.5): decode-direct-to-GVRAM - # costs 76.6% of a 12fps frame budget x (non-SKIP fraction), while - # compose-in-RAM-then-blit is a flat 53.6% regardless. They cross at 70%, - # and that is a decision taken FRAME BY FRAME -- a scene cut is ~100% - # non-SKIP and a held frame near 0%, so their mean describes no real frame. + # PER-FRAME DECODE COST, from the measured per-mode block costs + # (FINDINGS 28.2, vq_hybrid.cycles). The mean cannot answer this: a scene + # cut is ~100% non-SKIP and a held frame near 0%, so their mean describes + # no real frame. What matters is how many frames MISS, and by how much. + # + # This replaces the per-frame blit-vs-direct path choice that used to be + # printed here. That plan is withdrawn -- mixing the two paths displays + # stale pixels on 70 of 120 frames, and there was never a crossover to + # begin with, because the compose path pays the blit ON TOP of decoding. + # FINDINGS 28.1/28.4. The player has one path and no reference frame. ns = np.array([100 * (mm != 0).mean() for mm in enc["modes"]]) - over = int((ns > CROSSOVER_PCT).sum()) + cyc = np.array([H.cycles(mm) for mm in enc["modes"]]) + pct = 100 * cyc / RC.FRAME_CYCLES + miss = int((pct > 100).sum()) print(f" non-SKIP blocks/frame: median {np.median(ns):.1f}% " f"p90 {np.percentile(ns, 90):.1f}% max {ns.max():.1f}%") - print(f" frames above the {CROSSOVER_PCT:.0f}% blit crossover: " - f"{over}/{len(ns)} ({100*over/len(ns):.1f}%) -> " - f"{'compose+blit wins on those' if over else 'direct-to-GVRAM wins throughout'}") - cost = np.minimum(BLIT_PCT, DIRECT_PCT * ns / 100) - print(f" display cost if the player picks the cheaper path per frame: " - f"median {np.median(cost):.1f}% p90 {np.percentile(cost, 90):.1f}% " - f"max {cost.max():.1f}% of a 12fps frame") + print(f" decode cost: median {np.median(pct):.1f}% " + f"p90 {np.percentile(pct, 90):.1f}% max {pct.max():.1f}% " + f"of a {a.fps}fps frame") + print(f" frames that do NOT decode in time: {miss}/{len(pct)} " + f"({100*miss/len(pct):.0f}%)" + + (f" -- worst {pct.max():.1f}%" if miss else "")) + if rc and cyc_budget: + rr2 = RC.summarise(m, enc, prof["kbps"], fps=a.fps) + print(f" mu: median {rr2['mu_med']:.4f} max {rr2['mu_max']:.3f} " + f"frames needing any mu at all: {int((enc['mu'] > 0).sum())}/{len(pct)}") + print(f" frames that cannot fit even at mu={RC.MU_CLIFF:g} " + f"(emitted late on purpose): {rr2['late']}") if a.preview: from PIL import Image diff --git a/tools/encoder/ratectl.py b/tools/encoder/ratectl.py index f1c5414..f0f6463 100644 --- a/tools/encoder/ratectl.py +++ b/tools/encoder/ratectl.py @@ -81,6 +81,27 @@ PROFILES = { # instead (FINDINGS 26.2). The old ladder ran to lam=2e5, 250x past shippable. LAM_CLIFF = 800.0 +# Ceiling on the CYCLE search. mu prices a cycle in the same units lam prices a +# byte, so the scale that matters is set by their ratio: at the `sasi` floor of +# lam=60, mu=0.2 makes a V1 block's 300 cycles cost what its 1 payload byte +# costs. MU_CLIFF=100 is three decades past that: a V1 block priced at 30,000 +# distortion units. +# +# It does NOT freeze the picture, and that is the point. At MU_CLIFF a block +# only becomes SKIP if holding the previous reconstruction costs less than +# 28,665 units of distortion, so a frame with nothing on screen to hold -- the +# first frame of a stream, or a scene cut -- stays fully coded and comes out at +# the all-V1 floor of 110.6% (FINDINGS 28.5). Such a frame 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 the deadline would be the worse failure. +MU_CLIFF = 100.0 +MU_FLOOR = 1e-4 # bisection is geometric, so lo must be > 0 + +# The hard per-frame decode budget. NOT a bucket: bytes can be banked in the +# player's ring buffer, but there is no double buffer to decode ahead into, so +# a frame that misses its deadline is simply late. FINDINGS 28. +FRAME_CYCLES = 10_000_000 / 12.0 + AUDIO_KBPS = 7.8 # MSM6258 ADPCM 15.6kHz mono -- comes out of the same budget @@ -89,7 +110,7 @@ def frame_budget(kbps, fps=12, audio=AUDIO_KBPS): return (kbps - audio) * 1024.0 / fps -def _search_lam(ctx, allow, lam_lo, lam_hi, iters=12): +def _search_lam(ctx, allow, lam_lo, lam_hi, iters=12, mu=0.0): """Smallest lam (=> best quality) whose frame fits `allow` bytes. Payload size is non-increasing in lam -- raising lam can only move a block @@ -100,17 +121,17 @@ def _search_lam(ctx, allow, lam_lo, lam_hi, iters=12): not fit: that frame is emitted over budget on purpose. Past the FINDINGS 15 cliff a frame is not rate-controlled, it is destroyed, so a visible overrun is the better failure (FINDINGS 26.2).""" - mode, sz = H.decide(ctx, lam_lo) + mode, sz = H.decide(ctx, lam_lo, mu) if sz <= allow: return lam_lo, mode, sz, False - mode_hi, sz_hi = H.decide(ctx, lam_hi) + mode_hi, sz_hi = H.decide(ctx, lam_hi, mu) if sz_hi > allow: return lam_hi, mode_hi, sz_hi, True lo, hi = lam_lo, lam_hi # lo does not fit, hi does best = (lam_hi, mode_hi, sz_hi) for _ in range(iters): mid = float(np.sqrt(lo * hi)) - mode_m, sz_m = H.decide(ctx, mid) + mode_m, sz_m = H.decide(ctx, mid, mu) if sz_m <= allow: hi = mid; best = (mid, mode_m, sz_m) else: @@ -118,9 +139,55 @@ def _search_lam(ctx, allow, lam_lo, lam_hi, iters=12): return best[0], best[1], best[2], False +def _search_mu(ctx, allow, lam_lo, lam_hi, cyc_budget, iters=10): + """Smallest mu whose frame fits BOTH budgets: `allow` bytes and + `cyc_budget` 68000 cycles. + + Two controllers, one nested inside the other, because the constraints are + not separable. Raising mu moves blocks to cheaper-to-DECODE modes, which + usually also shrinks the frame -- but not always: RAW is 400 cycles against + V4's 448 and 16 bytes against 4, so mu can buy cycles by SPENDING bytes + (FINDINGS 28.8). So every mu step re-runs the lam bisection and the byte + budget is enforced at the mu that is actually chosen. + + Cost is scored with H.cycles(), the exact clustered rule, NOT with the + per-block ranking constant the decision uses -- see vq_hybrid's note on + SKIP. The controller therefore converges on what the 68000 will really do. + + Monotonicity: at a fixed lam, raising mu can only move a block to a mode + that costs no more cycles, and it can only ADD to a SKIP cluster, so frame + cycles are non-increasing in mu. The nested lam re-search can perturb that + at the margin (a smaller frame permits a smaller lam, which buys quality + back and can cost a few cycles), so the bisection keeps the best FEASIBLE + point it has actually seen rather than trusting the invariant. + + Returns (mu, lam, mode, size, cyc, over_bytes, over_cycles).""" + lam, mode, sz, ovr = _search_lam(ctx, allow, lam_lo, lam_hi, mu=0.0) + cyc = H.cycles(mode) + if cyc <= cyc_budget: + return 0.0, lam, mode, sz, cyc, ovr, False + + lam_h, mode_h, sz_h, ovr_h = _search_lam(ctx, allow, lam_lo, lam_hi, mu=MU_CLIFF) + cyc_h = H.cycles(mode_h) + if cyc_h > cyc_budget: # cannot fit even frozen: emit late + return MU_CLIFF, lam_h, mode_h, sz_h, cyc_h, ovr_h, True + + lo, hi = MU_FLOOR, MU_CLIFF # lo overruns, hi fits + best = (MU_CLIFF, lam_h, mode_h, sz_h, cyc_h, ovr_h) + for _ in range(iters): + mid = float(np.sqrt(lo * hi)) + lam_m, mode_m, sz_m, ovr_m = _search_lam(ctx, allow, lam_lo, lam_hi, mu=mid) + cyc_m = H.cycles(mode_m) + if cyc_m <= cyc_budget: + hi = mid; best = (mid, lam_m, mode_m, sz_m, cyc_m, ovr_m) + else: + lo = mid + return (*best, False) + + def encode_rate_controlled(m, target_kbps, fps=12, bucket_frames=8, lam_lo=1.0, lam_hi=LAM_CLIFF, prefill=0.0, - steps=None, verbose=False): + steps=None, verbose=False, cycle_budget=None): """Per-frame lam search under a leaky bucket, driving the encoder ONE FRAME AT A TIME and feeding back the frame actually emitted. @@ -149,6 +216,12 @@ def encode_rate_controlled(m, target_kbps, fps=12, bucket_frames=8, 109.5 to 116.3 KB/s against a 110 ceiling, and on a 14-frame clip it disables rate control entirely because the bucket is larger than the clip. + `cycle_budget` adds the SECOND controller (session 8): a hard per-frame + 68000 decode ceiling, bisected on `mu` inside the lam search. None (the + default) leaves it off and reproduces session 6 exactly, which is what + keeps tools/analysis/09_ratectl_drift.py comparable. Pass + FRAME_CYCLES for the 12fps stock-68000 budget. + `steps` is accepted and ignored -- there is no ladder any more. """ if steps is not None and verbose: @@ -156,25 +229,35 @@ def encode_rate_controlled(m, target_kbps, fps=12, bucket_frames=8, budget = frame_budget(target_kbps, fps) cap = bucket_frames * budget bucket = prefill * cap # banked bytes; bounded by the player's buffer both ways - out = dict(recon=[], modes=[], sizes=[], lam=[], l1=[], l4g=[], overrun=[]) + out = dict(recon=[], modes=[], sizes=[], lam=[], l1=[], l4g=[], overrun=[], + mu=[], cycles=[], late=[]) prev = None for f in range(len(m["idx"])): ctx = H.frame_ctx(m, f, prev) allow = budget + bucket - lam, mode, sz, ovr = _search_lam(ctx, allow, lam_lo, lam_hi) + if cycle_budget is None: + lam, mode, sz, ovr = _search_lam(ctx, allow, lam_lo, lam_hi) + mu, cyc, late = 0.0, H.cycles(mode), False + else: + mu, lam, mode, sz, cyc, ovr, late = _search_mu( + ctx, allow, lam_lo, lam_hi, cycle_budget) rec = H.paint(m, ctx, mode) bucket = float(np.clip(bucket + budget - sz, -cap, cap)) out["recon"].append(rec); out["modes"].append(mode) out["sizes"].append(sz); out["lam"].append(lam); out["overrun"].append(ovr) + out["mu"].append(mu); out["cycles"].append(cyc); out["late"].append(late) out["l1"].append(ctx["sym"]["l1"]); out["l4g"].append(ctx["sym"]["l4g"]) prev = rec if verbose: - print(f" f{f:04d} lam={lam:8.2f} {sz:7.0f} B " - f"(allow {allow:7.0f}){' OVER' if ovr else ''}") + print(f" f{f:04d} lam={lam:8.2f} mu={mu:8.4f} {sz:7.0f} B " + f"(allow {allow:7.0f}) {100*cyc/FRAME_CYCLES:5.1f}% cpu" + f"{' OVER' if ovr else ''}{' LATE' if late else ''}") return dict(recon=out["recon"], modes=out["modes"], sizes=np.array(out["sizes"]), lam=np.array(out["lam"]), l1=out["l1"], l4g=out["l4g"], overrun=np.array(out["overrun"]), - nb=m["nb"], budget=budget, cap=cap) + mu=np.array(out["mu"]), cycles=np.array(out["cycles"]), + late=np.array(out["late"]), + nb=m["nb"], budget=budget, cap=cap, cycle_budget=cycle_budget) def summarise(m, enc, target_kbps, fps=12): @@ -192,6 +275,14 @@ def summarise(m, enc, target_kbps, fps=12): over=100.0 * np.mean(sz > enc.get("budget", np.inf)), skip=100 * (mo == 0).mean(), v1=100 * (mo == 1).mean(), v4=100 * (mo == 2).mean(), raw=100 * (mo == 3).mean()) + if "cycles" in enc: + cy = np.asarray(enc["cycles"]) + d.update(cyc_med=float(np.median(cy)), cyc_max=float(cy.max()), + cyc_p90=float(np.percentile(cy, 90)), + cpu_miss=int((cy > FRAME_CYCLES).sum()), + mu_med=float(np.median(enc["mu"])), + mu_max=float(np.asarray(enc["mu"]).max()), + late=int(np.asarray(enc.get("late", [])).sum())) if "lam" in enc: lam = enc["lam"] d.update(lam_med=float(np.median(lam)), lam_max=float(lam.max()), diff --git a/tools/encoder/vq_hybrid.py b/tools/encoder/vq_hybrid.py index 5ebd303..c3d6d53 100644 --- a/tools/encoder/vq_hybrid.py +++ b/tools/encoder/vq_hybrid.py @@ -42,6 +42,49 @@ LUMA = VQ.LUMA _HDR_BYTES_PER_BLOCK = 2 / 8.0 RAW_BYTES = 16.0 # literal palette bytes, never indices +# --------------------------------------------------------------------------- +# CYCLE cost of each mode, per block, MEASURED on the 68000 (FINDINGS 28.2, +# tools/bench/decode.lua). This is the other axis: `lam` prices bytes, `mu` +# prices cycles, and the two are not proportional -- V4 is 4x a V1 block in +# bytes and 1.49x in cycles. +# +# SKIP IS NOT A CONSTANT, and it is the one trap in here. 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 when it sits in a mixed byte -- so its +# price depends on its NEIGHBOURS, which a per-block lagrangian cannot see. +# The way out is that the two uses do not need the same number: +# * `decide` uses C_SKIP_RANK purely to RANK modes within a block. SKIP is +# the cheapest mode either way, so the choice only scales the incentive: +# the V1-SKIP gap moves 12% between the two candidates. +# * `cycles()` scores a WHOLE frame with the exact clustered rule, and that +# is what the rate controller bisects against. Nothing downstream of the +# mode decision uses the ranking constant. +C_V1, C_V4, C_RAW = 299.9, 448.2, 400.4 +C_SKIP_CLUSTERED = 53.0 / 4 # all-SKIP header byte: one tst.b for four +C_SKIP_MIXED = 45.0 # a SKIP block inside a mixed byte +C_SKIP_RANK = C_SKIP_CLUSTERED # ranking only -- see above +MODE_CYCLES = np.array([C_SKIP_RANK, C_V1, C_V4, C_RAW], dtype=np.float64) + +FRAME_CYCLES_12FPS = 10_000_000 / 12.0 # 833,333, x68k.cpp:1133 + + +def cycles(mode): + """Exact decode cost of one frame's mode map, in 68000 cycles. + + Single source of truth: tools/analysis/11_cpu_budget.py imports this, and + it reproduces the four frames timed on the 68000 to within 1 point + (FINDINGS 28.2). Instruction cycles against zero-wait-state memory, so a + LOWER BOUND like every 68000 figure since FINDINGS 24.""" + g = np.asarray(mode).reshape(-1, 4) # one header byte = four blocks + allskip = (g == 0).all(1) + c = allskip.sum() * 4 * C_SKIP_CLUSTERED + mm = g[~allskip] + c += (mm == 0).sum() * C_SKIP_MIXED + c += (mm == 1).sum() * C_V1 + c += (mm == 2).sum() * C_V4 + c += (mm == 3).sum() * C_RAW + return float(c) + def blocks_of(idx, pal, bw, bh): return VQ.blockify(idx, pal, bw, bh) @@ -149,17 +192,24 @@ def frame_ctx(m, f, prev, idx_bytes=None): idx_bytes=default_idx_bytes(m) if idx_bytes is None else idx_bytes) -def decide(ctx, lam): - """Lagrangian mode decision at one lam. Returns (mode, payload bytes). +def decide(ctx, lam, mu=0.0): + """Lagrangian mode decision at one lam and one mu. Returns (mode, bytes). - Cheap by design: no painting, no image-sized work. A lam search calls this - a dozen times per frame and paints once.""" + Minimises `distortion + lam*bytes + mu*cycles` per block. `mu=0` is the + byte-only decision every session before 8 made; the machine's binding + budget is cycles, and bytes and cycles do not rank the modes the same way + (V4 is 4x V1 in bytes, 1.49x in cycles; RAW is dearer than V4 in bytes and + CHEAPER in cycles, so mu inverts that preference -- FINDINGS 28.8). + + Cheap by design: no painting, no image-sized work. A search calls this a + dozen times per lam step and paints once.""" ib = ctx["idx_bytes"] s = ctx["sym"] - cost = np.stack([ctx["eS"], - s["e1"] + lam * (1.0 * ib), - s["e4"] + lam * (4.0 * ib), - np.full(ctx["nb"], lam * RAW_BYTES)]) + mc = mu * MODE_CYCLES + cost = np.stack([ctx["eS"] + mc[0], + s["e1"] + lam * (1.0 * ib) + mc[1], + s["e4"] + lam * (4.0 * ib) + mc[2], + np.full(ctx["nb"], lam * RAW_BYTES + mc[3])]) mode = np.argmin(cost, axis=0).astype(np.uint8) return mode, frame_bytes(mode, ctx["nb"], ib) @@ -193,10 +243,10 @@ def paint(m, ctx, mode): return from_blocks(ob, nbx, nby) -def encode_frame(m, f, prev, lam, idx_bytes=None): +def encode_frame(m, f, prev, lam, idx_bytes=None, mu=0.0): """One frame at one lam against one previous reconstruction.""" ctx = frame_ctx(m, f, prev, idx_bytes) - mode, sz = decide(ctx, lam) + mode, sz = decide(ctx, lam, mu) return dict(recon=paint(m, ctx, mode), mode=mode, size=sz, l1=ctx["sym"]["l1"], l4g=ctx["sym"]["l4g"], ctx=ctx)