From 64cd1ffd727c5975c50d4bc6413904c33c7b5bc3 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:28:41 -0700 Subject: [PATCH] Handoff: reconcile docs and tooling with the corrections made this session Session 2 reversed several of its own conclusions. The docs are append-only, so a reader could land on a superseded section and act on it. This pass makes the repo internally consistent. Defects found and fixed in STATUS.md: - claimed "Hybrid VQ with k=1024: no" as the answer to the linework question, directly contradicting FINDINGS 14, which rejected k=1024. Both profiles are k=256. - malformed profile table (six column separators, five columns). - next-steps list had two items numbered 3 and listed the full-disc survey twice. - the disk-benchmark section still read CRITICAL-PATH with "if SCSI sustains >=800 KB/s, ship pixel-exact". That was written while the bandwidth figure was misread as 4 MB/s. At 4 Mbps pixel-exact needs 92-97% of the pipe and is not available, and the ring-buffer result means the design no longer hangs on the benchmark at all. Rewritten with what it IS still worth doing: confirming the 4 Mbps provenance, and confirming DMA is used rather than PIO. FINDINGS now carries supersession blockquotes on 5, 8, 11, 17 and 18 pointing at the sections that correct them. 18 is the dangerous one -- its peak-vs- sustained test is reversed by 21 -- so it is marked DO NOT ACT ON THIS SECTION while noting the per-frame data itself remains valid. profile_gen.py had the same problem in code: it defaulted to the superseded peak sizing and returned lam=25 where the docs say lam=10. The buffered test is now the default and peak sizing is behind --size-for-peak as a bound only. A tool that contradicts the findings is worse than no tool. Also preserves the five measurement scripts that produced this session's numbers as tools/analysis/05-09, following the session 1 precedent, and adds an "explicitly abandoned -- do not re-propose" list to STATUS covering entropy coding, k=1024 codebooks and flat 4x4 VQ. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6 --- README.md | 22 ++++++- docs/BENCHMARK.md | 32 +++++---- docs/FINDINGS.md | 24 +++++++ docs/STATUS.md | 94 ++++++++++++++++----------- tools/analysis/05_lossless_floor.py | 63 ++++++++++++++++++ tools/analysis/06_codebook_fair.py | 15 +++++ tools/analysis/07_highrate_deflate.py | 62 ++++++++++++++++++ tools/analysis/08_peaks.py | 18 +++++ tools/analysis/09_buffer_sim.py | 34 ++++++++++ tools/encoder/profile_gen.py | 54 +++++++++------ 10 files changed, 345 insertions(+), 73 deletions(-) create mode 100644 tools/analysis/05_lossless_floor.py create mode 100644 tools/analysis/06_codebook_fair.py create mode 100644 tools/analysis/07_highrate_deflate.py create mode 100644 tools/analysis/08_peaks.py create mode 100644 tools/analysis/09_buffer_sim.py diff --git a/README.md b/README.md index ac416ad..7e0253c 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,18 @@ pushing ~22 minutes of Don Bluth animation through a 10MHz 68000. decision, and a section on measurement traps that produced three separate false results. Read §4 before trusting any pipeline number. - **`docs/STATUS.md`** — current state, working setup, blockers, next steps. + **Start here.** It also lists what has been explicitly abandoned, so old ideas + do not get re-proposed. +- **`docs/BENCHMARK.md`** — how to measure the storage subsystem, and why a + bandwidth figure out of MAME would be meaningless. - **`docs/HARDWARE.md`** — X68000 GVRAM/CRTC reference. ## Layout ``` docs/ findings, status, hardware reference -tools/analysis/ frame-analysis scripts (01/02 marked BROKEN as regression refs) +tools/analysis/ measurement scripts, numbered in the order they were written + (01/02 marked BROKEN deliberately, kept as regression refs). + Run from the repo root — they import from tools/encoder/. tools/bench/ MAME Lua injection harness + 68000 benchmark sources tools/vasm/ vasm m68k assembler (built from source) tools/encoder/ hybrid VQ encoder + DLX1 container writer (working) @@ -31,12 +37,22 @@ python3 tools/encoder/extract.py 00020 /tmp/fr 12 crop 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` (45 KB/s) and -`scsi` (120 KB/s) are two points on the same rate-distortion curve. The codec is +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. 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. The RAW escape means `lam=0` is pixel-exact against the palettised frame, so the quality knob spans lossless to heavily-compressed without changing the bitstream. +Profiles are derived from a bandwidth figure, not chosen by eye: + +``` +python3 tools/encoder/profile_gen.py --bw-mbps 4 --name scsi +``` + +> **On reading `docs/FINDINGS.md`:** it is append-only and several later sections +> overturn earlier ones. Superseded sections carry a blockquote at the top +> pointing to the correction — heed those, especially 18 (reversed by 21). + Source media (`DRAGONS_LAIR.iso`) and ROMs are gitignored — supply your own. diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md index e825dc2..3a5e1de 100644 --- a/docs/BENCHMARK.md +++ b/docs/BENCHMARK.md @@ -106,20 +106,24 @@ FINDINGS 17.4: python3 tools/encoder/profile_gen.py --bw-mbps 4 --name scsi ``` -It accounts for the three things that eat the pipe before video sees any of it: -audio (7.8 KB/s), peak-to-mean burstiness (measured 1.4-1.9x), and it reports -the DMA cycle-steal so the CPU coupling stays visible. +It accounts for what eats the pipe before video sees any of it: audio +(7.8 KB/s), the buffering condition, and it reports the DMA cycle-steal so the +CPU coupling stays visible. -At 4 Mbps it currently returns: +At 4 Mbps it returns: -| | lam | mean | peak | 00020 | 00146 | CPU | -|---|---|---|---|---|---|---| -| today (no rate control) | 25 | 194 KB/s | 368 KB/s | -1.22 dB | -4.21 dB | 53% | -| with rate control wired | 10 | 305 KB/s | 305 KB/s | -0.52 dB | -2.98 dB | 51% | +| sizing rule | lam | mean | 00020 | 00146 | CPU | +|---|---|---|---|---|---| +| **buffered (default, FINDINGS 21)** | **10** | 305 KB/s | -0.52 dB | -2.98 dB | 51% | +| `--size-for-peak` (FINDINGS 18, superseded) | 25 | 194 KB/s | -1.22 dB | -4.21 dB | 46% | -**Rate control is worth a full step on the quality ladder** — it is not a -tidiness feature, it is the difference between sizing for the peak and sizing -for the mean. That is the strongest argument yet for wiring up `ratectl.py`. +The default is the buffered test: cumulative demand vs cumulative supply. +Ring-buffer simulation gives **zero required prefill** for every measured scene, +so `lam=10` ships without rate control. `--size-for-peak` reproduces the earlier +pessimistic sizing and is kept only as a bound. + +**Rate control is therefore insurance, not a fix.** Its value is a deterministic +ceiling over the 220 streams not yet measured — see the survey caveat below. ## What would change the design @@ -127,7 +131,11 @@ for the mean. That is the strongest argument yet for wiring up `ratectl.py`. collapses toward today's `sasi`, and the two profiles stop being meaningfully different. At that point reconsider 10 fps, or a narrower active area. - **If sustained is much above** (>=8 Mbps / 976 KB/s): `lam=0` fits with - margin and the port ships **pixel-exact** video on SCSI. + margin and the port ships **pixel-exact** video on SCSI. At the current + 4 Mbps figure this is NOT available — `lam=0` needs 92-97% of the pipe. +- **If the full-disc survey finds a sustained action sequence hotter than + 00146** (313 KB/s mean, the worst of 4 clips sampled): that is the scenario + rate control exists for, and the reason to wire it up before the survey run. - **If DMA cannot be used** and transfers fall back to PIO, the CPU cost rises from ~15% to something far larger and CPU becomes the binding constraint. This is the single worst outcome and is worth checking early in Tier 1. diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 4746317..a5091d5 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -103,6 +103,10 @@ regression references; 03 and 04 are the correct ones. ## 5. Storage interface — the SASI/SCSI split +> **SUPERSEDED IN PART.** The claim below that DMA means streaming "costs +> essentially no CPU" is **wrong** — see 19. The bandwidth figures here are +> folklore; the working figure is now **4 Mbps = 488 KB/s** (21). + [Yasuma, X68030 internal SCSI controller] - Interface: **SCSI-1**, 50-pin, 5 MB/s bus spec @@ -196,6 +200,11 @@ Caveat: all of the above is from README/repo-tree summaries, not their source. ## 8. CORRECTION to session 1: halving the framerate does NOT halve the bitrate +> **PARTLY SUPERSEDED.** The framerate correction stands. The +> "changed-spans + deflate = 247 KB/s" figure is a **compression upper bound, +> not a shippable design** — deflate decode does not fit the 68000's frame +> budget. See 17.2. + Session 1 measured 365 KB/s for naive delta+RLE at 24 fps and wrote "(~183 KB/s at 12fps)". **That extrapolation is wrong.** Decimating to 12 fps roughly doubles the per-frame delta, so the *rate* stays nearly flat. @@ -267,6 +276,9 @@ should: under a hard ceiling, detail blocks are the first thing sacrificed. ## 11. Codebook size sweep (flat 4x4, for reference) +> **SUPERSEDED.** The k=1024 result below is an artifact of a rate model that +> charged 1 byte for a 10-bit index. k=256 ships. See 14. + | block | k | PSNR | loss | key B | changed% | KB/s @12 | codebook RAM | |---|---|---|---|---|---|---|---| | 4x4 | 256 | 30.46 | 8.39 | 3072 | 52.7 | 28.5 | 8K | @@ -373,6 +385,12 @@ transcription errors before any of it is committed to 68000 tables. ## 17. The profiles were set far too low — and entropy coding is a CPU trap +> **PARTLY SUPERSEDED.** 17.1's diagnosis (the profiles were not derived from +> hardware) and 17.2's CPU analysis both stand. But 17 reasoned against a +> misread bandwidth of 4 MB/s; the correct figure is **4 Mbps = 488 KB/s**, so +> the "ship pixel-exact if SCSI sustains >=800 KB/s" conclusion in 17.5 is +> **not available**. See 18 and 21. + Prompted by the user asking why the SCSI profile was only 75 KB/s. It should not have been. Two separate errors, one of them serious. @@ -453,6 +471,12 @@ SCSI is now waiting on one measurement. ## 18. Peak-to-mean burstiness — the mean was hiding the problem +> **SUPERSEDED — DO NOT ACT ON THIS SECTION.** The peak-vs-sustained comparison +> below is the **wrong test**. With a ring buffer the correct test is cumulative +> demand vs cumulative supply, and both profiles pass it with **zero required +> prefill**. `scsi` at lam=10 ships. See 21. The per-frame peak numbers +> themselves are still valid data; only the conclusion drawn from them is wrong. + Prompted by the user clarifying that the bandwidth figure is **4 Mbps = 488 KB/s**, not 4 MB/s. That is ~8x tighter than what 17 was reasoning against, and it changes the answer. diff --git a/docs/STATUS.md b/docs/STATUS.md index 37dc6f5..a3e7f03 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,7 +19,7 @@ as two quality profiles. This is now implemented rather than hypothetical — th bitrate ceiling is a build parameter in `tools/encoder/ratectl.py`: | profile | target | lam | quality (00020 / 00146) | machine | -|---|---|---|---|---|---| +|---|---|---|---|---| | `sasi` | 110 KB/s | 60 | 36.9 / 29.6 dB | stock 10MHz ACE/EXPERT | | `scsi` | 280 KB/s | 10 | 39.4 / 32.3 dB | Super/XVI, or CZ-6BS1 board | @@ -54,8 +54,9 @@ rate-distortion curve, not two codecs. ## What session 2 settled 1. **The critical-path question is answered.** "Does VQ soften Bluth's linework - unacceptably?" — **flat 4x4 k=256 VQ: yes, badly. Hybrid VQ with k=1024: no.** - Verified by eye, not just PSNR. See `docs/FINDINGS.md` 9-11. + unacceptably?" — **flat 4x4 VQ: yes, badly. The hybrid (SKIP/V1/V4/RAW): no.** + Verified by eye, not just PSNR. See `docs/FINDINGS.md` 9-11 and the two + images in `docs/images/`. Both profiles use **k=256**; see item 2b. 2. **Session 1's 12fps bitrate was wrong** (183 KB/s claimed, 340 KB/s measured). Halving the framerate does not halve the bitrate. FINDINGS 8. 2b. **A fourth false-good result was produced and caught this session** — k=1024 @@ -119,49 +120,66 @@ notifier subscription in a global; the stack register is `SP` not `A7`; --- -## STILL BLOCKED: disk throughput benchmark +## Disk throughput benchmark — still blocked, no longer gating -Unchanged from session 1 — `IOCS _B_READ` returns -1 uniformly. Full diagnosis -and the four untested hypotheses are in session 1's notes (git history of this -file, commit 65112b9). +`IOCS _B_READ` returns -1 uniformly. Full diagnosis and the four untested +hypotheses are in session 1's notes (git history of this file, commit 65112b9); +the ordered plan for retrying is in **`docs/BENCHMARK.md`**. -**This is now CRITICAL-PATH, not optional.** Session 1 dismissed it because -"VQ at 30 KB/s is correct whether SASI does 300 or 600 KB/s". That reasoning is -dead: the profiles now sit at 110 and 280 KB/s, close enough to the folklore -ceilings that the error bars change the product. Specifically — +**Status changed twice this session — read this rather than the git history.** +It was briefly promoted to critical-path while the working bandwidth figure was +misread as 4 MB/s. With the correct figure (**4 Mbps = 488 KB/s**) and the +ring-buffer simulation showing **zero required prefill** for both profiles +(FINDINGS 21), the design no longer hangs on it. Pixel-exact on SCSI is **not** +available at 4 Mbps — it needs 92-97% of the pipe — so there is no longer a +"measure it and maybe ship transparent" decision waiting. -**If SCSI sustains >=800 KB/s, the correct `scsi` profile is `lam=0`: pixel-exact -video, ~450 KB/s, and only 38% of the CPU budget.** Whether this port ships -transparent or lossy on SCSI is waiting on one measurement. +What the benchmark is still worth doing for: +- **Confirming the 4 Mbps figure.** It is user-supplied and its provenance is + not recorded. Every profile hangs off it. +- **Confirming DMA is actually used.** If transfers fall back to PIO the CPU + cost rises far above the ~12-15% cycle-steal estimate and CPU becomes the + binding constraint. This is the worst plausible outcome and the cheapest to + check — do it first. -Next move is the untried SCSI path: `-exp1 cz6bs1 -hard disk.chd`. - ---- +**Do not try to get the bandwidth number out of MAME.** Its SCSI/SASI devices are +functional models, not timing-accurate; a KB/s figure from MAME measures the +emulator's scheduler. `docs/BENCHMARK.md` covers the three-tier approach +(MAME validates the path, derivation bounds it, real hardware settles it). ## Next steps, in priority order -1. **Full-disc survey** — we have measured 4 clips of 1.2-1.7 s out of 224 - streams, and 00146 already runs 23% hotter than 00020. A sustained action - sequence is the one thing that could still break the bitrate. This is now - the highest-value measurement, ahead of rate control. -2. **Wire rate control into `encode.py`** — no longer a blocker (FINDINGS 21), - but it is what gives a deterministic ceiling over content we have not - measured, which was the original reason for choosing VQ. Insurance, not a fix. -3. ~~Entropy-code the payload~~ — **ABANDONED, see FINDINGS 17.2.** Deflate - decode is ~216% of the frame budget on a 68000 and LZ4 is ~54%; there is no - room beside a 38% blit. All bitrates are raw payload. This also demotes the - "247 KB/s lossless" figure in FINDINGS 8 to a compression bound, not a design. -3. **68000 decoder skeleton**: parse `DLX1`, expand codebooks to word-per-pixel, - blit V1/V4/RAW/SKIP. Measure real cycles with the existing MAME Lua harness — - this is the first time the harness gets used for its actual purpose. -4. **Full-disc survey** — classify menu vs content first (FINDINGS 13), then - measure bitrate across all 224 streams per profile. +1. **Full-disc survey.** Only 4 clips of 1.2-1.7 s out of 224 streams have been + measured, and 00146 already runs 23% hotter than 00020. A *sustained* action + sequence is the one thing that could still break the bitrate. Classify menu + vs content first (FINDINGS 13) or the averages are diluted by static menus. + **Vectorise `_paint` before this run** — it is a Python per-block loop. +2. **68000 decoder skeleton.** Parse `DLX1`, expand codebooks to word-per-pixel, + blit SKIP/V1/V4/RAW. Measure real cycles with the existing MAME Lua harness — + the first time that harness gets used for its actual purpose. Validates the + 38% full-frame blit estimate that the whole CPU budget rests on. +3. **Wire rate control into `encode.py`.** No longer a blocker (FINDINGS 21), but + it is what gives a deterministic ceiling over content not yet measured, which + was the original reason for choosing VQ. Insurance, not a fix. Pairs with (1). +4. **Confirm DMA vs PIO in MAME** (see the benchmark section above) — cheap, and + the only thing that could still move CPU into the binding position. 5. **Resolve the framing question** (FINDINGS 12: crop vs squash vs wide). -6. Unblock the disk benchmark via the SCSI path, then re-set profile bitrates. -7. Import the SNES project's `data/events/` (MIT, cleared) as the scene graph. - Cross-check against DirkSimple (zlib) which has the same data independently. -8. ADPCM audio: MSM6258, 15.6kHz mono, 7.8 KB/s — already budgeted in `ratectl`, - not yet extracted or encoded. + Needs an eyeball against arcade reference, not a measurement. +6. **Import the scene graph.** SNES project `data/events/` (MIT, cleared), + cross-checked against DirkSimple (zlib) which transcribed the same data + independently — diff them to catch transcription errors before committing + any of it to 68000 tables. +7. **ADPCM audio.** MSM6258, 15.6kHz mono, 7.8 KB/s — already budgeted in + `ratectl.py`, not yet extracted or encoded. + +### Explicitly abandoned — do not re-propose +- ~~Entropy-code the payload.~~ Deflate decode is ~216% of the frame budget on a + 68000; LZ4 is ~54% with no room beside a 38% blit (FINDINGS 17.2). All bitrates + are raw payload. This also demotes the "247 KB/s lossless" figure in FINDINGS 8 + to a compression upper bound, not a shippable design. +- ~~k=1024 codebooks.~~ False-good result from a rate model that charged 1 byte + for a 10-bit index (FINDINGS 14). k=256 wins at every matched bitrate. +- ~~Flat 4x4 VQ.~~ Rejected by eye (FINDINGS 9). ## Not yet started - Any 68000 player code diff --git a/tools/analysis/05_lossless_floor.py b/tools/analysis/05_lossless_floor.py new file mode 100644 index 0000000..65414dc --- /dev/null +++ b/tools/analysis/05_lossless_floor.py @@ -0,0 +1,63 @@ +"""What does LOSSLESS (palettised-quality) delta coding actually cost at 12fps? + +Session 1 measured only a hand-rolled row-span+RLE (3.2:1). The 68000 has ~12x +CPU headroom, so a real LZ decoder is affordable -- byte copies are what the +68000 is good at. This measures the achievable floor at zero extra quality loss. +""" +import sys, zlib, lzma; sys.path.insert(0,'tools/encoder') +import vq, numpy as np +S=sys.argv[1] +RAW=256*192 + +def rle_delta(a,b,gap=4): + """session 1's row-span + RLE, reimplemented for comparison""" + t=0 + for y in range(a.shape[0]): + d=np.nonzero(a[y]!=b[y])[0] + if not len(d): continue + sp=[];s=d[0];p=d[0] + for x in d[1:]: + if x-p>gap: sp.append((s,p)); s=x + p=x + sp.append((s,p)); t+=2 + for s0,e0 in sp: + seg=b[y][s0:e0+1]; t+=2; i=0 + while i=3 else r; i+=r + return t + +print(f'{"scene":8}{"frames":>7}{"raw KB/s":>9}{"rle":>8}{"xor+zl":>8}{"delta+zl":>9}{"lzma":>8}',flush=True) +tot={} +for s in ['00010','00020','00146','00181']: + rgb=vq.load_frames(f'{S}/fr_{s}') + ref,pal=vq.scene_palette(rgb) + idx=vq.palettise(rgb,ref) + n=len(idx)-1 + r_rle=sum(rle_delta(idx[i-1],idx[i]) for i in range(1,len(idx)))/n + # XOR against previous frame then deflate -- unchanged pixels become 0 runs + r_xor=sum(len(zlib.compress((idx[i]^idx[i-1]).tobytes(),9)) for i in range(1,len(idx)))/n + # RLE-of-changed-spans payload, then deflate on top + def spans(a,b,gap=8): + out=bytearray() + for y in range(a.shape[0]): + d=np.nonzero(a[y]!=b[y])[0] + if not len(d): continue + sp=[];st=d[0];p=d[0] + for x in d[1:]: + if x-p>gap: sp.append((st,p)); st=x + p=x + sp.append((st,p)) + for s0,e0 in sp: + out += bytes([y,s0,e0-s0]) + b[y][s0:e0+1].tobytes() + return bytes(out) + r_dz=sum(len(zlib.compress(spans(idx[i-1],idx[i]),9)) for i in range(1,len(idx)))/n + r_lz=sum(len(lzma.compress(spans(idx[i-1],idx[i]),preset=9))for i in range(1,len(idx)))/n + f=12/1024.0 + print(f'{s:8}{len(idx):7}{RAW*f:9.0f}{r_rle*f:8.0f}{r_xor*f:8.0f}{r_dz*f:9.0f}{r_lz*f:8.0f}',flush=True) + for k,v in [('rle',r_rle),('xor',r_xor),('dz',r_dz),('lz',r_lz)]: tot.setdefault(k,[]).append(v) +print() +for k,v in tot.items(): + m=np.mean(v) + print(f'{k:8} mean {m:7.0f} B/frame {m*12/1024:6.0f} KB/s {m*12*22*60/1048576:5.0f} MB/22min ratio {RAW/m:.1f}:1') diff --git a/tools/analysis/06_codebook_fair.py b/tools/analysis/06_codebook_fair.py new file mode 100644 index 0000000..1ac3f87 --- /dev/null +++ b/tools/analysis/06_codebook_fair.py @@ -0,0 +1,15 @@ +"""Fair k=256 vs k=1024 comparison: k=1024 pays 2-byte indices, so the +2.4 dB +it showed earlier may be entirely eaten by the doubled payload. Sweep lam and +report the rate-distortion CURVE, then compare at matched KB/s.""" +import sys; sys.path.insert(0,'tools/encoder') +import vq_hybrid as H, numpy as np +S=sys.argv[1] +for s in ['00020','00146']: + print(f'=== scene {s} ===',flush=True) + for k in [256,1024]: + m=H.build(f'{S}/fr_{s}',k1=k,k4=k,iters=16) + ib=1 if k<=256 else 2 + print(f' k={k} ({ib}-byte idx) {"lam":>7}{"PSNR":>8}{"KB/s":>8}{"SKIP":>7}{"V1":>6}{"V4":>6}{"RAW":>6}',flush=True) + for lam in [25.,100.,300.,800.,2000.]: + e=H.encode(m,lam=lam); r=H.evaluate(m,e) + print(f' {"":16}{lam:7.0f}{r["psnr"]:8.2f}{r["kbps"]:8.1f}{r["skip"]:7.1f}{r["v1"]:6.1f}{r["v4"]:6.1f}{r["raw"]:6.1f}',flush=True) diff --git a/tools/analysis/07_highrate_deflate.py b/tools/analysis/07_highrate_deflate.py new file mode 100644 index 0000000..9feedf7 --- /dev/null +++ b/tools/analysis/07_highrate_deflate.py @@ -0,0 +1,62 @@ +"""How high should the SCSI profile go? + +Two questions the current profiles never answered: + 1. With the payload deflated, where does the hybrid's lam->0 end actually land? + (Un-deflated it is 439 KB/s, which is NOT comparable to the 247 KB/s + lossless changed-spans+deflate path.) + 2. Is there any point shipping lossy VQ on SCSI at all, or does the lossless + path dominate once both are entropy-coded? +""" +import sys, zlib; sys.path.insert(0,'tools/encoder') +import vq_hybrid as H, vq as VQ, numpy as np, struct +S=sys.argv[1] + +def pack_modes(mode): + n=len(mode); out=bytearray((n*2+7)//8) + for i,m in enumerate(mode): out[i//4] |= (int(m)&3)<<(6-2*(i%4)) + return bytes(out) + +def payload(mode,l1,l4g,src,nbx): + b=bytearray() + for i,mo in enumerate(mode): + if mo==1: b.append(int(l1[i])) + elif mo==2: + for j in range(4): b.append(int(l4g[i][j])) + elif mo==3: + by,bx=divmod(i,nbx); b+=src[by*4:by*4+4,bx*4:bx*4+4].tobytes() + return bytes(b) + +def spans(a,b,gap=8): + out=bytearray() + for y in range(a.shape[0]): + d=np.nonzero(a[y]!=b[y])[0] + if not len(d): continue + sp=[];st=d[0];p=d[0] + for x in d[1:]: + if x-p>gap: sp.append((st,p)); st=x + p=x + sp.append((st,p)) + for s0,e0 in sp: out+=bytes([y,s0,e0-s0])+b[y][s0:e0+1].tobytes() + return bytes(out) + +for s in ['00020','00146']: + m=H.build(f'{S}/fr_{s}',k1=256,k4=256,iters=16) + pal,idx,W=m['pal'],m['idx'],m['W']; nbx=W//4 + ceil_db=np.mean([VQ.psnr(o,pal[i]) for o,i in zip(m['rgb'],idx)]) + # lossless reference, same frames + lz=np.mean([len(zlib.compress(spans(idx[i-1],idx[i]),9)) for i in range(1,len(idx))]) + print(f'=== {s} palette ceiling {ceil_db:.2f} dB ===',flush=True) + print(f' LOSSLESS changed-spans+deflate : {lz*12/1024:6.1f} KB/s (pixel-exact)',flush=True) + print(f' {"lam":>6}{"PSNR":>8}{"raw KB/s":>10}{"defl KB/s":>11}{"gain":>7}{"RAW%":>7}',flush=True) + for lam in [0.,10.,25.,60.,150.,300.,800.]: + e=H.encode(m,lam=lam); r=H.evaluate(m,e) + tot_r=tot_d=0 + for f,im in enumerate(idx): + B1=H.blocks_of(im,pal,4,4); l1=VQ.assign(B1,m['C1s']) + B4=H.blocks_of(im,pal,2,2); l4=VQ.assign(B4,m['C4s']) + q=H._group_2x2_into_4x4(np.arange(len(l4)),W); l4g=l4[q].reshape(-1,4) + blob=pack_modes(e['modes'][f])+payload(e['modes'][f],l1,l4g,im,nbx) + tot_r+=len(blob); tot_d+=len(zlib.compress(blob,9)) + n=len(idx) + rk=tot_r/n*12/1024; dk=tot_d/n*12/1024 + print(f' {lam:6.0f}{r["psnr"]:8.2f}{rk:10.1f}{dk:11.1f}{rk/dk:7.2f}{r["raw"]:7.1f}',flush=True) diff --git a/tools/analysis/08_peaks.py b/tools/analysis/08_peaks.py new file mode 100644 index 0000000..64fc76e --- /dev/null +++ b/tools/analysis/08_peaks.py @@ -0,0 +1,18 @@ +"""At a 488 KB/s (4 Mbps) ceiling and ~52% mean utilisation, the mean is not the +risk -- the peaks are. Measure per-frame peak-to-mean, then check whether the +leaky-bucket rate controller actually holds the ceiling.""" +import sys; sys.path.insert(0,'tools/encoder') +import vq_hybrid as H, ratectl as RC, numpy as np +S=sys.argv[1] +BW=4_000_000/8/1024 +print(f"ceiling {BW:.0f} KB/s (4 Mbps), audio {RC.AUDIO_KBPS} KB/s\n") +print(f'{"scene":8}{"lam":>5}{"mean":>8}{"p90":>8}{"MAX":>8}{"pk/mean":>9}{"MAX % of pipe":>15}',flush=True) +for s in ['00010','00020','00146','00181']: + m=H.build(f'{S}/fr_{s}',k1=256,k4=256,iters=16) + for lam in [60.,10.]: + e=H.encode(m,lam=lam) + kb=e['sizes']*12/1024 # per-frame instantaneous KB/s + tot=kb+RC.AUDIO_KBPS + print(f'{s:8}{lam:5.0f}{tot.mean():8.1f}{np.percentile(tot,90):8.1f}' + f'{tot.max():8.1f}{tot.max()/tot.mean():9.2f}{tot.max()/BW*100:14.1f}%' + + (' OVER' if tot.max()>BW else ''),flush=True) diff --git a/tools/analysis/09_buffer_sim.py b/tools/analysis/09_buffer_sim.py new file mode 100644 index 0000000..007a35e --- /dev/null +++ b/tools/analysis/09_buffer_sim.py @@ -0,0 +1,34 @@ +"""Ring-buffer simulation. The peak-vs-sustained comparison in FINDINGS 18 was +the wrong test: with SD-backed SCSI the fill rate is a CONSTANT, and a burst +frame is absorbed by the buffer rather than having to arrive within one frame. + +What actually matters: + 1. required PREFILL so the buffer never underruns mid-scene + 2. STALL TOLERANCE at a branch point -- Dragon's Lair seeks between streams, + and the buffer drains while the seek completes +""" +import sys; sys.path.insert(0,'tools/encoder') +import vq_hybrid as H, ratectl as RC, numpy as np +S=sys.argv[1]; BW=4_000_000/8/1024; FPS=12 +fill=BW/FPS +print(f"fill {BW:.0f} KB/s = {fill:.2f} KB per frame time\n") +print(f'{"scene":8}{"lam":>5}{"mean":>8}{"max f":>8}{"prefill":>9}{"stall @0KB":>12}{"stall @256KB":>14}') +for s in ['00010','00020','00146','00181']: + m=H.build(f'{S}/fr_{s}',k1=256,k4=256,iters=16) + for lam in [10.,60.]: + e=H.encode(m,lam=lam) + kb=e['sizes']/1024 + RC.AUDIO_KBPS/FPS # KB demanded per frame + # cumulative deficit: worst shortfall of supply vs demand + deficit=np.maximum.accumulate(np.cumsum(kb-fill)) + prefill=max(0.0,deficit.max()) + # stall tolerance: with buffer B prefilled, how many frame times can the + # fill be zero (seeking) before the buffer empties, at mean drain + mean_kb=kb.mean() + stall0 = prefill/mean_kb if mean_kb>0 else 0 + stall256 = (256.0+0.0)/mean_kb + print(f'{s:8}{lam:5.0f}{kb.mean()*FPS:8.1f}{kb.max():8.2f}{prefill:9.1f}' + f'{stall0:9.1f} fr{stall256:11.1f} fr') +print() +print("prefill = KB the buffer must hold before playback starts") +print("stall = frame times the buffer survives with NO fill (seek/branch)") +print(" at 12fps, 1 frame = 83.3 ms") diff --git a/tools/encoder/profile_gen.py b/tools/encoder/profile_gen.py index 48d318a..59972f1 100644 --- a/tools/encoder/profile_gen.py +++ b/tools/encoder/profile_gen.py @@ -11,12 +11,12 @@ bandwidth and it returns the lam that fits, with the headroom accounted for. Three things eat the pipe before video gets any: 1. AUDIO -- 7.8 KB/s of MSM6258 ADPCM, constant. - 2. PEAK/MEAN -- measured 1.4-1.9x (FINDINGS 18). The disk delivers a - SUSTAINED rate; a frame that overruns is a DROPPED frame. - Either size for the peak, or rate-control to the mean and - carry a bucket. We do the latter, so we need bucket depth - rather than peak headroom -- but until rate control is - actually wired in (it is not), size for the peak. + 2. BUFFERING -- NOT peak/mean. FINDINGS 18 sized against the per-frame + peak; 21 showed that is the wrong test. The disk keeps + filling DURING a frame, so the condition is cumulative + demand vs cumulative supply, which every measured scene + passes with ZERO required prefill. Peak sizing is kept + behind --size-for-peak only as a pessimistic bound. 3. DMA CYCLE-STEAL -- the HD63450 steals ~8 clocks per 16-bit word from the 68000. At 488 KB/s that is 20% of the CPU, on top of the blit. Bandwidth and CPU are NOT independent budgets. @@ -49,16 +49,23 @@ CURVE = [ ] CEILING = {"00020": 39.90, "00146": 35.25} PEAK_OVER_MEAN = 1.9 # measured worst case, FINDINGS 18 +# Per-frame fill at 12fps must cover the worst single frame, else the buffer +# has to carry the difference. Measured worst frame is 42.10 KB (00146 lam=10). +WORST_FRAME_KB = 42.10 def dma_steal_pct(kbps): return (kbps * 1024 / 2) * DMA_CLOCKS_PER_WORD / CLK * 100 -def pick(bw_kbps, peak_factor=PEAK_OVER_MEAN, margin=0.85, rate_controlled=False): - """Largest-quality lam whose worst-case demand fits inside bw_kbps.""" +def pick(bw_kbps, peak_factor=PEAK_OVER_MEAN, margin=0.85, size_for_peak=False): + """Largest-quality lam whose sustained demand fits inside bw_kbps. + + Default is the BUFFERED test (FINDINGS 21): compare mean demand against the + sustained fill. size_for_peak=True restores the pessimistic FINDINGS 18 + sizing, which is retained only as a bound -- it is not the shipping rule.""" usable = bw_kbps * margin - AUDIO_KBPS - factor = 1.0 if rate_controlled else peak_factor + factor = peak_factor if size_for_peak else 1.0 allow_mean = usable / factor for lam, k20, d20, k146, d146 in CURVE: worst = max(k20, k146) @@ -81,9 +88,9 @@ def main(): help="fraction of the pipe we allow ourselves (seeks, " "container overhead, and the fact that the bandwidth " "figure itself is folklore)") - ap.add_argument("--rate-controlled", action="store_true", - help="assume the leaky bucket absorbs peaks (NOT YET TRUE " - "-- ratectl.py is written but not wired into encode.py)") + ap.add_argument("--size-for-peak", action="store_true", + help="pessimistic FINDINGS 18 sizing (mean * 1.9). Superseded " + "by 21 -- kept only as a bound, not the shipping rule.") a = ap.parse_args() bw = a.bw_kbps if a.bw_kbps else a.bw_mbps * 1_000_000 / 8 / 1024 @@ -91,26 +98,33 @@ def main(): print(f"bandwidth {src} = {bw:.0f} KB/s sustained") print(f" usable at {a.margin:.0%} margin : {bw*a.margin:.0f} KB/s") print(f" less audio ({AUDIO_KBPS}) : {bw*a.margin-AUDIO_KBPS:.0f} KB/s for video") - if not a.rate_controlled: - print(f" less peak/mean {PEAK_OVER_MEAN}x : " - f"{(bw*a.margin-AUDIO_KBPS)/PEAK_OVER_MEAN:.0f} KB/s mean allowance") + fill_per_frame = bw / FPS + if a.size_for_peak: + print(f" less peak/mean {PEAK_OVER_MEAN}x : " + f"{(bw*a.margin-AUDIO_KBPS)/PEAK_OVER_MEAN:.0f} KB/s mean allowance" + f" [pessimistic, FINDINGS 18 -- superseded]") else: - print(" peaks absorbed by rate control (bucket depth must be validated)") + print(f" fill per frame time : {fill_per_frame:.2f} KB " + f"(worst measured frame {WORST_FRAME_KB:.2f} KB" + f"{' -- COVERED' if fill_per_frame >= WORST_FRAME_KB else ' -- needs buffer'})") - r = pick(bw, margin=a.margin, rate_controlled=a.rate_controlled) + r = pick(bw, margin=a.margin, size_for_peak=a.size_for_peak) if r is None: print("\n NO PROFILE FITS -- even lam=800 overruns. Lower the framerate,") print(" the resolution, or get more bandwidth.") return - steal = dma_steal_pct(r["peak_kbps"]) - print(f"\n -> {a.name}: lam={r['lam']}, {r['mean_kbps']:.0f} KB/s mean, " - f"{r['peak_kbps']:.0f} KB/s peak") + steal = dma_steal_pct(r["mean_kbps"]) + print(f"\n -> {a.name}: lam={r['lam']}, {r['mean_kbps']:.0f} KB/s mean") print(f" quality 00020 {r['psnr20']:.2f} dB (-{r['loss20']:.2f} from ceiling)") print(f" 00146 {r['psnr146']:.2f} dB (-{r['loss146']:.2f} from ceiling)") print(f" CPU blit {BLIT_FULL_FRAME_PCT:.0f}% + DMA steal {steal:.1f}% " f"= {BLIT_FULL_FRAME_PCT+steal:.0f}% of the frame budget") if BLIT_FULL_FRAME_PCT + steal > 85: print(" WARNING: CPU is now the binding constraint, not the bus.") + if not a.size_for_peak and fill_per_frame < WORST_FRAME_KB: + short = WORST_FRAME_KB - fill_per_frame + print(f" note: worst frame exceeds one frame-time of fill by " + f"{short:.2f} KB -- buffer must carry it (2MB RAM, non-issue).") if __name__ == "__main__":