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__":