diff --git a/.gitignore b/.gitignore index ed83152..715196c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ __pycache__/ *.dlx a.out tmp/ +tools/bench/c68k/c68k_bench diff --git a/README.md b/README.md index 4824c66..5d1cff9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ This is fundamentally a **video codec problem**, not a game-logic problem: the game logic is a scene table with branching input windows; the difficulty is pushing ~22 minutes of Don Bluth animation through a 10MHz 68000. +**And the binding resource is the 68000's local BUS, not its clock.** The +decoder occupies 86.7% of it once instruction prefetch is counted, and 52 of the +53 frames that miss the 12fps budget miss it on the bus, not the CPU +(FINDINGS 38). Read that before optimising anything for cycles. + **Green-light check:** `./tools/bench/check.sh` (~3 min, needs the Blu-ray mounted) re-runs both display regression tests, the rate-control drift test, the display-path coherency counterexample and a 120-frame 68000 decode, then prints @@ -42,6 +47,12 @@ tools/analysis/ measurement scripts, numbered in the order they were written 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. + 14 prices the HD63450 array-chain against the v6 span + (FINDINGS 39) and prints the sensitivity that decides it; + 15 measures how much of the 68000's LOCAL bus the decoder + occupies (FINDINGS 38) and exits non-zero if its derived + model stops matching the harness's measurement. + buscost.py is the shared bus-cycle table both import. 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 @@ -54,6 +65,14 @@ tools/bench/ MAME Lua injection harness + 68000 benchmark sources. and R20 — do not write CRTC values anywhere else. `prep_dlx.py`/`decode.lua`/`verify_decode.py` load, time and verify `src/player/decode.s`; the verify pass is in check.sh. +tools/bench/c68k/ headless px68k C68K harness -- a SECOND emulator for every + 68000 cycle figure (FINDINGS 37). Links only px68k's CPU core: + no SDL, no ROMs, no emulated machine. `make PX68K=~/src/px68k` + then `run.sh`; `verify_c68k.py` checks the decode is + pixel-exact, which is what licenses the cycle numbers. It also + counts BUS cycles, which MAME cannot report. + The Makefile's -no-pie and the harness's MAP_32BIT arena are + load-bearing: C68K truncates host pointers to 32 bits. tools/vasm/ vasm m68k assembler (built from source) tools/encoder/ hybrid VQ encoder + DLX2 container writer (working). DLX2 4-byte-aligns every frame record: an odd `move.l` is an diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 1ec18c2..cc66bb6 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -1428,6 +1428,11 @@ without closing it. ## 29. Trading bytes for cycles: the bus has 4x the headroom the CPU has (session 7) +> **ALSO SUPERSEDED IN PART BY 38.** "The bus has 4x the headroom the CPU has" +> is about the SCSI pipe. The 68000's LOCAL bus is a different resource and the +> decoder occupies 86.7% of it, so trading cycles for bytes is not free in the +> currency that turned out to bind. 29.6's DMAC idea is costed in 39. +> > **SUPERSEDED IN PART BY 30, which measured it.** The mode survives and the > conclusion holds, but every number in this section moved: a span costs 43.7 > cycles + 9.152/pixel *only* in an encoder-assisted format (the obvious @@ -1978,6 +1983,14 @@ progress.** ## 35. The CPU budget has never had the disk in it (session 9) +> **TESTED BY 38 AND IT STANDS.** Session 10 first argued that the flat +> subtraction here is too pessimistic -- that the disk DMA could hide in bus +> cycles the CPU was not using -- and scored the same window at 53/120 instead +> of 84/120. **That was wrong.** A 68000 has no cache and a two-word prefetch +> queue, so it stalls as soon as another master takes the bus; DMA time is +> additive, which is exactly what this section assumed. The 84/120 stands and +> 38.3 now reproduces it. + **Raised by the user: "PIO is such a CPU killer. DMA is not. I'm concerned about us drawing the wrong conclusions."** The concern is correct, and it is larger than the labelling question of 32.4. This is the seventh false premise this @@ -2129,3 +2142,273 @@ parsing lengths out of unmapped memory and walking wherever they pointed. Any "the decoder is 4x slower than the model on RAW-heavy streams" conclusion drawn from that run would have been entirely false, which is the third time in this session that an unobservable run nearly produced a wrong finding. + +## 37. A second emulator, and MAME is not running the core we thought (session 10) + +Every 68000 cycle figure in FINDINGS 24-35 came from one instrument. This is a +second one, run against byte-for-byte the same `decode.bin` and the same +container. + +`tools/bench/c68k/` links **px68k's C68K core** into a headless harness: a +hand-built X68000 memory map, no SDL, no ROMs, no emulated machine. The decoder +touches nothing but RAM, the control block and GVRAM, so the machine around it +was never part of the measurement. + +### 37.1 What the two instruments actually are +**MAME 0.277's `M68000` is not Musashi.** `src/devices/cpu/m68000/m68000.lst` +plus `m68000gen.py`: it is the microcode core, where timing emerges from the +modelled micro-sequence and 4-clock bus cycles. C68K is a static per-instruction +cycle table (`ORI_CLOCKS_*` / `EA_CLOCKS_*` in `c68kmacro.h`), hand-transcribed +from the Motorola manual by a different author. + +Those are two different ways of arriving at a number, which is what makes the +agreement worth something. It would be worth much less if both were tables. + +### 37.2 The harness is self-validating +It decodes all 80 frames and dumps the screen; `verify_c68k.py` checks it +against `tools/encoder/dlx.py` **pixel for pixel, on palette indices**. That is +the licence for the cycle numbers: the harness rebuilds px68k's memory model +from scratch -- byte-swapped RAM (`mem_wrap.c:420`), GVRAM word writes that +discard the high byte -- and any of it being subtly wrong would still print +plausible cycles. It could not print a pixel-exact 80-frame temporal recursion. + +It does. **`decode.s` is now pixel-exact under two independent CPU cores.** + +### 37.3 The numbers +``` +anchor MAME C68K delta MAME C68K of a 12fps frame +min non-SKIP 42.8% 600982 620760 +3.29% 72.1% 74.5% +median 65.2% 841038 869036 +3.33% 100.9% 104.3% +p90 72.9% 836124 856872 +2.48% 100.3% 102.8% +max non-SKIP 100.0% 921187 923090 +0.21% 110.5% 110.8% +synthetic all-SKIP 40729 40946 +0.53% 4.9% 4.9% +synthetic all-V1 921187 923090 +0.21% 110.5% 110.8% +synthetic all-V4 1376881 1420754 +3.19% 165.2% 170.5% +synthetic all-RAW -- 1273298 -- 152.8% +``` + +The `all-RAW` cell is empty because MAME's timed pass did not reach it. That is +an operational fact worth recording: with `-video soft -nothrottle` this box runs +`x68000` at about **0.033x realtime**, so `decode.lua`'s eight anchors plus two +full passes — ~48 emulated seconds — cost ~25 minutes of wall clock, and two runs +were killed by their own `timeout`. The C68K harness does the same work in +seconds because it emulates a CPU and not a machine. **Anchor MAME runs by wall +clock, not by `-seconds_to_run`.** + +**Cycle-table error is bounded at 3.3%, and it runs against us** -- C68K reads +high on every anchor. Nothing here rescues FINDINGS 35. The disagreement is +mode-dependent (all-V1 +0.21%, all-V4 +3.19%), so it localises to the V4 path's +indexed two-register `movem.l`, not to a systematic clock difference. + +**FINDINGS 28.8 is confirmed independently**: under C68K, V4 (170.5%) still +costs more than RAW (152.8%). That conclusion inverts the encoder's mode +preference, so having it from a second core matters more than most. + +### 37.4 What it does not settle +px68k has no bus-timing model anywhere in `x68k/*.c` -- grep it. Neither +instrument charges GVRAM wait states, so this is **the same lower bound, +measured twice**. It bounds cycle-table error. It says nothing about the +distance to a real X68000; that is still BENCHMARK.md Tier 3. + +### 37.5 One trap, recorded because it will catch the next person +C68K is 64-bit-unsafe by construction: its `MOVEM` macros do +`src = (UINT32)(&D0)` -- they truncate the host address of the register file and +dereference it -- and `C68k_Set_Fetch` keeps the opcode-fetch base in a `UINT32`. +Under the default PIE the binary loads near `0x555555550000` and the first +`movem` segfaults. The Makefile builds `-no-pie` and the harness mmaps its arena +`MAP_32BIT`. Both are load-bearing, not tidiness. + +## 38. The bus, measured: the project is bus-bound, not CPU-bound (session 10) + +> **This supersedes part of 29 and part of 35.** FINDINGS 29's "the bus has 4x +> the headroom the CPU has" is true of the SCSI pipe and false of the 68000's +> local bus, and they are different resources. FINDINGS 35's flat CPU debit for +> the disk charges the CPU for bus cycles it was not going to use. + +Everything since FINDINGS 24 has been costed in CPU clocks. The 68000 has +another budget nobody had counted: its **memory bus**, one 4-clock cycle at a +time, carrying instruction prefetch as well as data. + +### 38.1 Two sources that check each other +`tools/bench/c68k/c68k_bench` counts every Read/Write callback the C68K core +makes -- exact, because C68K splits a long access into two word calls, which is +what the 16-bit bus does. It cannot count **instruction prefetch**: C68K reads +opcodes straight through a host pointer with no callback, and MAME exposes no +fetch count either. + +So `tools/analysis/15_bus_occupancy.py` derives prefetch by walking +`decode.s`'s straight-line paths in `tools/bench/decode.lst` and multiplying by +each frame's mode histogram. The same walk also predicts the data half -- and +that half is measurable: + +``` + measured mean 66,700 data bus cycles/frame + derived mean 66,672 error -0.04% mean, 0.06% worst +``` + +The walk reproduces the measurement, so its prefetch figure stands on the same +footing. `15_bus_occupancy.py` exits non-zero if that check ever stops holding. + +### 38.2 The result +``` + mean median worst frame +bus slots in a frame 201,497 211,013 230,772 + data accesses 66,672 68,044 105,216 + instruction prefetch 108,002 110,982 122,910 + total bus cycles 174,674 181,998 193,248 +bus OCCUPANCY 86.7% 86.8% 88.3% +slots left for a DMAC 26,823 26,618 21,115 +``` + +**The decoder occupies 86.7% of its own bus, and prefetch is 62% of that.** A +data-only count understates occupancy by about 2x, which is exactly the mistake +an instrumented emulator would lead you into. + +Per mode, bus clocks against measured clocks: V1 204/299.9 (68%), V4 308/448.2 +(69%), RAW 316/400.4 (79%), and the v6 span **9.0/9.152 (98%)**. + +### 38.3 What that does to the frame budget -- and one wrong turn +The first thing done with 86.7% was to argue that FINDINGS 35's flat CPU debit +for the disk is too pessimistic: the decoder leaves ~26,800 bus slots a frame +idle against the disk's ~23,000, so score it as contention, +`frame = max(CPU clocks, 4 x bus cycles)`, and the window misses 53/120 rather +than 84/120. + +**That is wrong, and the MC68450 manual is what says so.** A 68000 relinquishes +the bus on BGACK and cannot execute without it -- no cache, a two-word prefetch +queue that empties immediately. Worse, the DMAC does not interleave at operand +granularity by default: limited-rate auto-request hands it the bus in *bursts* +of `2(BT+4)` clocks out of a sample period of `2(BT+BR+5)`, taking +`2^-(BR+1)` of the bandwidth in slabs (MC68450 sect 5.2.3.2, Fig 5-2). During a +slab the CPU is stopped. + +So **DMA time is additive to CPU time**, which is what FINDINGS 35 assumed all +along. `14_dmac_chain.py` reproduces its 84/120 exactly in the `today` column. + +What 86.7% *does* say is worse than the thing it appeared to rescue: **there is +almost no room to overlap anything.** The 13.3% of bus slots the decoder leaves +idle are single gaps inside a `movem`-heavy loop, not windows a bus master can +be handed. Any design whose case rests on DMA hiding under CPU work on this +machine should be assumed dead until measured on hardware. + +The measurement still earns its place: it is what prices the span painter +against a DMAC in 39, and it is the reason the answer there came out the way it +did. + +### 38.4 What is not counted +Bus arbitration. The 68000's BR/BG/BGACK handover costs cycles a cycle-steal +DMA cannot avoid, and the disk debit here embeds it only insofar as FINDINGS 5's +8 clocks/word already does. Also: no GVRAM wait states, as everywhere since 24. +Both make the real occupancy **higher** than 86.7%, not lower. + +## 39. The DMAC chain against the span: the datasheet says no (session 10) + +FINDINGS 29.6 named "let the DMAC do the copy" the one lever that could move the +budget without spending a byte, and left it uncosted. This costs it, and the +answer is **no** -- but only after the constants came from the MC68450 manual +rather than from bus arithmetic, which is the whole lesson of the section. + +### 39.1 They are the same container +v6's record is `{u32 absolute GVRAM address, u16 jump displacement}` = 6 bytes. +An MC68450/HD63450 **array-chaining entry** is `{u32 memory address, u16 +transfer count}` = 6 bytes. Set the channel dual-address, direction +device->memory, Sequence Control counting both addresses up: MAR reloads per +entry (the GVRAM destination), DAR walks the stream buffer, MTC is the span's +word count. **The chain array IS the span table.** Every byte figure in +FINDINGS 30 carries over, and this is not a fork in the format -- the encoder +emits the same thing either way, only the executor changes. That much is real +and survives everything below. + +### 39.2 The first answer was wrong by a clock +Session 10 first derived the DMAC's cost from bus arithmetic: moving a pixel is +a read cycle plus a write cycle, 2 bus cycles, 8 clocks, against v6's measured +9.152 -- a 12.6% edge. On that basis the design scored 1/120 frames over budget +against v6's 10/120 and looked decisive. + +The datasheet does not agree. **MC68450 Fig 4-25 sheet 4**, dual address / +operand size WORD / device size 16 bits, D->M or M->D: +`{WORD READ, WORD WRITE}` = **9 CLOCKS**. Confirmed by the long-operand row, two +of each for 18. And **Fig 4-25 note 2** says why: the DMAC's reads take four +clocks and its **writes take five**. The 68000 writes in four. + +| per pixel | clocks | source | +|---|---:|---| +| DMAC, dual-address word, two 16-bit ports | **9.000** | MC68450 Fig 4-25 sheet 4 | +| v6 `movem` chain | **9.152** | MEASURED, FINDINGS 30 | + +**1.7%.** One clock on every DMAC write is the entire difference between a +12.6% win and a rounding error. Per span, sequential array chaining costs +**36 clocks** (Fig 4-25 sheet 1: three word reads for the 6-byte entry, plus +reload) against v6's measured 43.7 -- the DMAC's one genuine edge, and it is +7.7 clocks. + +### 39.3 Scored additively, as 38.3 requires +``` + today v6 span v6 fine tail DMAC chain + bitrate KB/s 270.8 479.2 479.9 479.9 + frame, median 108.1% 99.3% 96.5% 95.0% + frame, worst 114.7% 112.0% 111.4% 110.3% + frames missing 84/120 55/120 18/120 12/120 + blocks spanned/frame 0 727 839 845 +``` +`today` reproduces FINDINGS 35's 84/120 exactly, which is the check that the +scenario lines up. + +### 39.4 What the DMAC actually buys, and who else can sell it +`v6 fine tail` is the decomposition. v6 pads every span up to 24 pixels because +its copy is an unrolled chain of 12-register `movem` units; adding a second, +finer chain of 2-register units caps the padding at 3 pixels instead of 23, for +the price of some more unrolled code and **nothing per span**. Priced +conservatively (a 4-pixel unit costs 56 clocks against a full unit's 220 for 24, +so it is dearer per pixel and paid at most once a span): + +| | frames over | +|---|---:| +| v6 as built | 55/120 | +| **v6 with a finer chain tail -- software only** | **18/120** | +| DMAC chain | 12/120 | + +**86% of the DMAC's advantage over v6 is the 24-pixel padding quantum**, and +that is a property of v6's unrolled chain, not of the CPU. The residual is 1.7% +a pixel and 7.7 clocks a span, worth 6 frames of 120. + +Break-even against all-V1 moves the same way: v6 as built needs a run of 4 +blocks, v6 with the finer tail needs 3, the DMAC needs 1. + +### 39.5 The verdict +**Fix the quantum in software.** Six frames of 120 does not buy a reserved DMAC +channel, a two-region container layout, and a dependency on transfer timing that +cannot be verified in either emulator on this box. The `v6 fine tail` figure is +itself DERIVED and should be measured with `span.sh` before it is believed -- +that is a day's work in a tool that already exists, against a hardware +dependency that needs an actual X68000. + +Keep 39.1 on the record. If a later measurement moves the DMAC's per-pixel cost +below 8 clocks -- for instance if GVRAM tolerates a four-clock DMAC write in a +way the datasheet's typical-system assumption does not model -- the container +does not have to change to take advantage of it. + +### 39.6 What else would have to be true, if it is ever revisited +- **A free channel.** Four exist; channel 3 is ADPCM (`adpcm_drq_tick` asserts + `drq3_w`) and the SCSI stream needs one. +- **Two regions per frame.** Chaining fetches entries from an array while DAR + walks the pixel data, so the span table and the literal words cannot be + interleaved as v6 interleaves them. +- **The mode-map walk stays on the CPU.** 39.3 charges it; FINDINGS 30.7 flagged + that 12_span_tradeoff.py did not. + +### 39.7 A number the datasheet settled on the way past +FINDINGS 5's **8 clocks/word** for the SCSI DMA has been an unsourced estimate +since session 1 and STATUS has called it the most load-bearing unmeasured number +in the project. Fig 4-25 sheet 3 gives single-address `W/B READ` 4 clocks and +`W/B WRITE` 5; a device->memory disk transfer is one memory write. So it is +**5 clocks/word if the DMAC holds the bus** and about **12 if it arbitrates per +word** (front-end 5 best case / 8 worst, sect 4.5.2.1; back-end 2, sect 4.5.2.2). +The feature list's "up to 5 Megabytes per Second at 10 MHz, no wait states" +is the held-bus case: 2 bytes per 4-clock cycle. + +**8 is the midpoint of a bracket the datasheet supports, not a guess.** Which +end applies depends on how the MB89352 drives REQ and whether cycle-steal-with- +hold is used, which is a design decision the player has not made yet -- and it +is worth 7 clocks a word on a 480 KB/s stream, so it is worth making +deliberately. diff --git a/docs/STATUS.md b/docs/STATUS.md index 12428a2..e39dd2e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,47 +1,84 @@ -# Status & next-session handoff — end of session 9 (2026-08-23) +# Status & next-session handoff — end of session 10 (2026-08-23) ## Where this stands -Session 9 did three things: dropped a profile on the user's instruction, closed -the last encoder gap, validated the cost model against the machine — and then -found that **the CPU budget every one of those numbers was scored against has -never had the disk in it**. +Session 10 cross-checked the whole cycle model against a second emulator, then +found that the model was denominated in the wrong currency. -**FINDINGS 35 is the headline, and it is bad news.** Raised by the user ("PIO is -such a CPU killer. DMA is not. I'm concerned about us drawing the wrong -conclusions"). Every CPU figure in FINDINGS 24-34 is measured against the full -833,333 cycles/frame, with nothing subtracted for moving the bitstream off SCSI. -Debiting the HD63450 cycle-steal at the long-standing 8 clocks/word estimate: +**FINDINGS 38 is the headline: the project is BUS-bound, not CPU-bound.** Nothing +since FINDINGS 24 had counted the 68000's local memory bus — one 4-clock cycle at +a time, carrying instruction prefetch as well as data. Measured, the decoder +occupies **86.7%** of it, and **prefetch is 62% of that traffic**. Scoring the +`scsi` window with the bus as the shared resource, **52 of 53 missed frames are +bus-limited and one is CPU-limited**. Every optimisation since 24 has been aimed +at the budget that is not binding. -| `scsi` container | budget left | median frame | worst | frames missing | -|---|---:|---:|---:|---:| -| no I/O — the pre-session-9 premise | 833,333 | 99.6% | 110.6% | **1/120** | -| **DMA** (8 clk/word, ESTIMATED) | 738,234 | **112.4%** | 124.8% | **84/120** | -| PIO (12 clk/B, hand-derived floor) | 548,036 | 151.4% | 168.1% | **120/120** | +The measurement is two sources checking each other: `c68k_bench` counts every bus +callback exactly, and a static walk of `decode.lst` adds the prefetch no emulator +here can report. The walk reproduces the measured data half to **0.04%**, which +is what licenses its prefetch half. -So "1 frame of 120 misses" was against the wrong budget. `11_cpu_budget.py` now -takes `--io dma|pio|none`, defaults to `dma`, and warns if asked for `none`. +**The DMAC array-chain LOSES, on the datasheet.** My first pass derived its cost +from bus arithmetic — a read cycle plus a write cycle, 8 clocks a pixel — and +scored it at 1/120 frames over budget against v6's 10/120. Then I read the +MC68450 manual (Motorola Jul 1989, bitsavers). **Fig 4-25 sheet 4: a +dual-address word between two 16-bit ports is 9 clocks, because note 2 gives the +DMAC 4-clock reads and 5-clock WRITES.** The 68000 writes in 4. -**The way out is 10 fps, and it works on paper**: same container, DMA debited, -10 fps -> median 93.7%, worst 104.0%, **1/120** — and that is conservative, -because it holds the 12 fps byte rate. FINDINGS 35.5. +| per pixel | clocks | source | +|---|---:|---| +| DMAC dual-address word | **9.000** | MC68450 Fig 4-25 sheet 4 | +| v6 `movem` chain | **9.152** | MEASURED, FINDINGS 30 | -**SASI is dropped (USER DECISION).** A SASI volume is 40 MB and the game's 22.8 -minutes of unique footage is 146 MiB at the *lowest* rate this codec makes. -`scsi` is the only profile. Delivery is SD-backed SCSI, as locked in session 2 — -capacity does not bind there, and it is what rules CD-ROM out. FINDINGS 32. +Scored additively (see below), against the same mode maps: -**The encoder gap is closed.** `encode.py` emits **DLX2**, 4-byte-aligned frame -records: 94/120 record starts were on odd addresses, now 0/120, for 16 B/s. -Re-encoding reproduces FINDINGS 31.1 exactly. FINDINGS 33. +| | frames over budget | +|---|---:| +| today | 84/120 | +| v6 span as built | 55/120 | +| **v6 with a finer chain tail — software only** | **18/120** | +| DMAC chain | 12/120 | -**The cost model is validated on a cost-aware container** (STATUS item 1, done): -errors -3.07% to +0.01% against the 68000, whole-window mean -1.22%. It -under-predicts light frames and is exact on heavy ones. FINDINGS 34. +**86% of the DMAC's advantage is v6's 24-pixel padding quantum**, which is a +property of v6's unrolled `movem` chain and fixable in software: add a second +chain of 2-register units for the tail and the padding drops from 23 pixels to +3, at no per-span cost. **Recommendation (mine): fix the quantum, drop the +DMAC.** Six frames of 120 does not buy a reserved channel, a two-region +container layout, and a timing dependency neither emulator here can verify. +FINDINGS 39. -**A `scsi` window does not fit the 2 MB machine the rig emulates** — 2.84 MB of -stream against a 0x200000 ceiling. The decoder gate now verifies 80 of 120 -frames and says so, rather than failing as a phantom pixel diff. FINDINGS 36. +The container work is not wasted either way: v6's record and an HD63450 chaining +entry are both 6 bytes, so **the chain array is the span table** and nothing has +to change if a hardware measurement later moves the DMAC's number. FINDINGS 39.1. + +**I also got FINDINGS 35 wrong before the datasheet corrected me.** I argued its +flat CPU debit was too pessimistic and rescored the window at 53/120 using +`max(CPU, bus)`. A 68000 has no cache and a two-word prefetch queue, so it +stalls the moment another master takes the bus, and the MC68450 hands the bus +over in *slabs* under limited-rate auto-request rather than interleaving per +operand. **DMA is additive. 84/120 stands.** What 86.7% bus occupancy really +says is that there is almost no room to overlap anything on this machine. +FINDINGS 38.3. + +**And the datasheet settled STATUS's own "most load-bearing unmeasured number".** +FINDINGS 5's 8 clocks/word for the SCSI DMA: Fig 4-25 sheet 3 gives +single-address write 5 clocks, so it is **5 clk/word with the bus held and ~12 +if the DMAC arbitrates per word**. 8 is the midpoint of a bracket the datasheet +supports. Which end applies is a player design decision — how the MB89352 drives +REQ, and whether cycle-steal-with-hold is used — worth 7 clocks a word on a +480 KB/s stream. FINDINGS 39.7. + +**The decoder is now pixel-exact under two independent CPU cores.** +`tools/bench/c68k/` links px68k's C68K core into a headless harness and decodes +the same container to the same pixels. Cycle-table error against MAME is bounded +at **3.3%**, and it runs against us. Two incidental results worth keeping: MAME +0.277's `M68000` is the **microcode core**, not Musashi (`m68000.lst` + +`m68000gen.py`), so this is two structurally different timing models agreeing +rather than two tables; and FINDINGS 28.8's "V4 costs more than RAW" reproduces +independently. FINDINGS 37. + +Everything below this line is from session 9 and still stands unless a session-10 +section above says otherwise. ## NEXT SESSION, in order @@ -58,13 +95,13 @@ frames and says so, rather than failing as a phantom pixel diff. FINDINGS 36. in the tree can only ever audit a prefix of a window.** Getting the coverage back means gating on more than one window, not one longer pass. -1. **Measure the DMA cycle-steal, or derive it from primary sources.** This is - now the most load-bearing unmeasured number in the project: at 8 clocks/word - the port is marginal, at 12 it is dead, at 4 it is comfortable. It has been - an estimate from a session-2 datasheet reading since FINDINGS 5. MAME cannot - answer it (functional models, not transfer-timing accurate) — this needs the - HD63450 and MB89352 datasheets or real hardware. **Everything below is - contingent on it.** +1. **Measure the finer chain tail with `span.sh`.** It is the largest single win + on the table — 55/120 to 18/120 — it is software only, and the tool that + measures it already exists: `blit.s` gains a v7 with a 2-register tail chain, + `prep_spans.py` generates the streams, `span.sh` times all of it in ~25 s and + asserts every config drew a pixel-exact frame. The 18/120 figure is DERIVED + from a conservative 56-clocks-per-4-pixel-unit model and should not be + believed until it is measured. FINDINGS 39.4. 2. **Make sure the player actually gets DMA.** DMA-vs-PIO is a property of our code, not the board: the CZ-6BS1's DMA path is real and modelled @@ -79,12 +116,11 @@ frames and says so, rather than failing as a phantom pixel diff. FINDINGS 36. 10 fps absorbs the DMA steal on current estimates. This was item 5 and a quality question; it is now arithmetic. It is still the user's call. -4. **Then spans.** Fully specified by measurement (format FINDINGS 30.2, costs - 30.5, scene-cut arithmetic 30.6). Note what 35.2 does to the case for them: - spans buy cycles by spending bandwidth, and the bandwidth comes back as - steal, so 31.6's "fits completely" becomes a worst frame of 114.3%. They are - still worth 14 points at the median — they are just no longer sufficient - alone. +4. **Then spans, on the CPU.** The format is fully specified by measurement + (FINDINGS 30.2, costs 30.5, scene-cut arithmetic 30.6) and `decode.s` does + not implement them yet. Build the v7 tail from item 1 into it. The DMAC + variant is costed and loses (39.5); the container is identical either way + (39.1), so nothing is foreclosed. 5. **Re-run the ring-buffer simulation at the surviving rate** and confirm the 488 KB/s figure's provenance (FINDINGS 29.5/30.7, still open). @@ -742,6 +778,15 @@ with `extract.py`; the earlier ones lived in `/tmp` and do not survive a reboot. ## Reference material on this box (not in the repo) +- **MC68450 DMAC manual: `~/src/mc68450.pdf`** (Motorola, Jul 1989, from + bitsavers; `curl` it with a browser User-Agent or you get a 403). This is the + primary source for FINDINGS 39 and it overturned two derived answers in one + session. Fig 4-25 is the timing table — sheet 1 chaining, sheet 3 + single-address, sheet 4 dual-address; note 2 (4-clock reads, 5-clock writes) + is the one that mattered. Sects 4.5.2.1-3 are the arbitration overheads, + 5.2.3.2 the limited-rate auto-request slabs. `pdftotext` handles it. +- **px68k source: `~/src/px68k`** — only `m68000/c68k.c` is used, by + `tools/bench/c68k/`. - **MAME 0.277 source: `~/src/mame-mame0277/`** (tarball `~/src/mame0277.tar.gz`). Downloaded this session to settle the graphics-layer question. The files that matter are `src/mame/sharp/x68k_v.cpp`, `x68k_crtc.cpp`, `x68k_crtc.h`, diff --git a/tools/analysis/14_dmac_chain.py b/tools/analysis/14_dmac_chain.py new file mode 100644 index 0000000..4ca9a15 --- /dev/null +++ b/tools/analysis/14_dmac_chain.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Would letting the HD63450 paint the spans beat letting the 68000 do it? + + python3 tools/analysis/14_dmac_chain.py [container.dlx] [--bus 488] + [--dma-px-bus 2] [--disk-bus-byte 1] + +FINDINGS 29.6 called this the one lever that could move the CPU budget without +spending a byte, and left it uncosted. FINDINGS 30 measured the alternative -- +the 68000 painting spans itself, 43.7 cycles per span + 9.152 per pixel. This +prices the two against each other, and the answer turns on a resource neither +section costed: the 68000's own LOCAL BUS. + +FINDINGS 29's "the bus has 4x the headroom the CPU has" is about the SCSI pipe, +110 KB/s of 488. That is a different bus. The 68000's memory bus runs one 4-clock +cycle at a time and carries instruction prefetch as well as data, and +tools/analysis/15_bus_occupancy.py measures the decoder using 86.7% of it. + +THE TWO DESIGNS ARE THE SAME CONTAINER. v6's record is {u32 absolute GVRAM +address, u16 jump displacement} = 6 bytes; an MC68450/HD63450 array-chaining +entry is {u32 memory address, u16 transfer count} = 6 bytes. Set the channel to +dual-address, direction device->memory, Sequence Control counting both addresses +up: MAR reloads per entry (the GVRAM destination), DAR walks the stream buffer, +MTC is the span's word count. The chain array IS the span table. + +THE DMAC CONSTANTS ARE NOW SOURCED, and they killed the first answer. From the +MC68450 manual (Motorola, Jul 1989, bitsavers), Fig 4-25 sheet 4: a dual-address +WORD operand between two 16-bit ports is **9 clocks**, because note 2 gives the +DMAC 4-clock reads and **5-clock writes**. The 68000 writes in 4. So: + + DMAC 9.000 clocks/pixel (datasheet) + v6 9.152 clocks/pixel (measured, FINDINGS 30) + +A 1.7% difference. Session 10's first pass guessed 2 bus cycles = 8 clocks from +bus arithmetic and was 12% optimistic; the extra clock on every DMAC write is +the whole story. Per span, sequential array chaining costs 36 clocks (Fig 4-25 +sheet 1) against v6's measured 43.7 -- the DMAC's one real edge, and it is small. + +AND DMA DOES NOT OVERLAP. The 68000 has no cache and a two-word prefetch queue, +so it stalls as soon as another master takes the bus. Frame time is therefore +CPU + DMA, additive. Session 10's first pass used max(CPU, bus) and got 53/120 +where the additive model gives 84/120; FINDINGS 35's flat debit was right. + +So the only material difference left is v6's 24-pixel padding quantum -- and +that is a property of v6's unrolled chain, not of the CPU. The `v6 fine tail` +column prices fixing it in software instead. +""" +import sys, os, argparse +sys.path.insert(0, "tools/encoder") +sys.path.insert(0, "tools/analysis") +import numpy as np +from dlx import DLX +import buscost as B + +FRAME_CYC = 833333.0 +AUDIO_KBPS = 7.8 +C_V1, C_V4, C_RAW = 299.9, 448.2, 400.4 # FINDINGS 28.2 (MEASURED) +C_SKIP_CLUSTERED, C_SKIP_MIXED = 13.25, 45.0 +SPAN_BYTES_PX, SPAN_HDR = 2, 6 + +ap = argparse.ArgumentParser() +ap.add_argument("container", nargs="?", default="tmp/rc_fr_singe_scsi_cpufit.dlx") +ap.add_argument("--bus", type=float, default=488.0, help="SCSI pipe, KB/s") +ap.add_argument("--fps", type=float, default=12.0) +ap.add_argument("--dma-px-clk", type=float, default=B.DMA_PX_CLK, + help="clocks the DMAC spends per pixel, dual-address word " + "between two 16-bit ports. 9 is the DATASHEET figure " + "(MC68450 Fig 4-25 sheet 4).") +ap.add_argument("--disk-clk-word", type=float, default=8.0, + help="clocks the SCSI DMA steals per word. The datasheet " + "brackets it at 5 (DMAC holds the bus) to 12 (arbitrates " + "per word); FINDINGS 5's estimate of 8 is the midpoint.") +a = ap.parse_args() +if not os.path.exists(a.container): + sys.exit(f"missing {a.container}") + +BYTE_BUD = (a.bus - AUDIO_KBPS) * 1024 / a.fps +BUS_SLOTS = FRAME_CYC / B.BUS_CLK +d = DLX(a.container) +BLK_C = {1: C_V1, 2: C_V4, 3: C_RAW} +BLK_B = {1: 1, 2: 4, 3: 16} + + +def runs(m, by): + dirty = m[by] != 0 + i = 0 + while i < d.nbx: + if not dirty[i]: + i += 1; continue + j = i + while j < d.nbx and dirty[j]: + j += 1 + yield i, j + i = j + + +def span_cost(design, L): + """(pixels carried, clocks charged to the frame) for a run of L blocks, + as 4 rows of 4L pixels. Every design is charged additively: the 68000 + cannot execute while the DMAC owns the bus.""" + if design == "v6": + px = B.pad24(4 * L) + return 4 * px, 4 * (B.V6_SPAN_CYC + px * B.V6_PX_CYC) + if design == "v6fine": + px, c = B.v6_fine(4 * L) + return 4 * px, 4 * c + px = 4 * L + return 4 * px, 4 * (B.DMA_CHAIN_CLK + px * a.dma_px_clk) + + +def score(design): + """Greedy, as 12_span_tradeoff.py: buy the best clocks-saved per byte spent + until the frame's byte budget is gone. Unlike 12, a spanned block still pays + its mode-map dispatch, which FINDINGS 30.7 flagged as uncounted.""" + out = [] + for f in range(d.nframes): + m = d.modes(f).reshape(d.nby, d.nbx) + byt = d.mode_bytes + sum(BLK_B.get(int(x), 0) for x in m.ravel()) + spanned = np.zeros_like(m, bool) + span_clk = 0.0 + + cand = [] + if design != "none": + for by in range(d.nby): + for i, j in runs(m, by): + L = j - i + cur_c = sum(BLK_C[int(b)] for b in m[by][i:j]) + cur_b = sum(BLK_B[int(b)] for b in m[by][i:j]) + px, sc = span_cost(design, L) + sc += L * C_SKIP_MIXED # the dispatch still happens + span_b = 4 * SPAN_HDR + px * SPAN_BYTES_PX + if sc < cur_c: + cand.append((cur_c - sc, span_b - cur_b, by, i, j, sc, L)) + cand.sort(key=lambda s: -(s[0] / max(s[1], 1))) + for dc, db, by, i, j, sc, L in cand: + if byt + db <= BYTE_BUD: + byt += db + spanned[by][i:j] = True + span_clk += sc - L * C_SKIP_MIXED + g = m.copy() + g[spanned] = 0 + gg = g.reshape(-1, 4) + allskip = (gg == 0).all(1) + cpu = allskip.sum() * 4 * C_SKIP_CLUSTERED + mm = gg[~allskip] + cpu += (mm == 0).sum() * C_SKIP_MIXED + for k, c in BLK_C.items(): + cpu += (mm == k).sum() * c + pref, data = B.block_bus(m, spanned) + disk = byt / 2.0 * a.disk_clk_word + # additive: CPU work, then span painting, then the disk stealing the bus + out.append((cpu + span_clk + disk, (pref + data) * B.BUS_CLK, byt, + spanned.sum())) + return np.array(out).T + + +DESIGNS = [("today", "none"), ("v6 span", "v6"), + ("v6 fine tail", "v6fine"), ("DMAC chain", "dmac")] +res = {n: score(k) for n, k in DESIGNS} + +print(f"{a.container}: {d.nframes} frames, {d.nb} blocks, {a.fps:g} fps") +print(f"SCSI pipe {a.bus:.0f} KB/s -> {BYTE_BUD:,.0f} B/frame; " + f"68000 bus {BUS_SLOTS:,.0f} cycles/frame; CPU {FRAME_CYC:,.0f} clocks\n") + +print("PER PIXEL AND PER SPAN -- datasheet against measurement") +print(f" DMAC dual-address word, two 16-bit ports {B.DMA_PX_CLK:.3f} clocks " + f"MC68450 Fig 4-25 sheet 4") +print(f" v6 movem chain {B.V6_PX_CYC:.3f} clocks " + f"MEASURED, FINDINGS 30") +print(f" -> the DMAC is {100*(B.V6_PX_CYC-B.DMA_PX_CLK)/B.V6_PX_CYC:+.1f}% per pixel. " + f"The 68000 writes in 4 clocks; the DMAC takes 5.") +print(f" per span: DMAC array chaining {B.DMA_CHAIN_CLK} clocks against v6's " + f"{B.V6_SPAN_CYC:.1f}\n") + +w = 15 +print(f"{'':<26}" + "".join(f"{n:>{w}}" for n, _ in DESIGNS)) +def row(label, fmt, get): + print(f" {label:<24}" + "".join(f"{fmt(get(res[n])):>{w}}" for n, _ in DESIGNS)) + +row("bitrate KB/s", lambda v: f"{v:.1f}", lambda r: r[2].mean() * a.fps / 1024) +row("frame, median", lambda v: f"{v:.1f}%", lambda r: 100*np.median(r[0])/FRAME_CYC) +row("frame, worst", lambda v: f"{v:.1f}%", lambda r: 100*r[0].max()/FRAME_CYC) +row("frames missing", lambda v: f"{v}/{d.nframes}", + lambda r: int((r[0] > FRAME_CYC).sum())) +row("blocks spanned/frame", lambda v: f"{v:,.0f}", lambda r: r[3].mean()) +print(f"\n ADDITIVE: frame = CPU + span painting + disk DMA. The 68000 has no" + f"\n cache and a two-word prefetch queue, so it stalls the moment another" + f"\n master takes the bus. Disk debited at {a.disk_clk_word:g} clocks/word.") + +# What is left of the case, isolated. +v6m = int((res["v6 span"][0] > FRAME_CYC).sum()) +finem = int((res["v6 fine tail"][0] > FRAME_CYC).sum()) +dmam = int((res["DMAC chain"][0] > FRAME_CYC).sum()) +print(f"\nWHAT THE DMAC ACTUALLY BUYS, decomposed") +print(f" v6 as built {v6m}/{d.nframes} frames over") +print(f" v6 with a finer chain tail (software) {finem}/{d.nframes}") +print(f" DMAC chain {dmam}/{d.nframes}") +print(f" -> of the gap between v6 and the DMAC, " + f"{100*(v6m-finem)/max(v6m-dmam,1):.0f}% is the 24-pixel padding") +print(f" quantum, which is a property of v6's unrolled chain and fixable") +print(f" in software. The rest is 1.7% a pixel and 7.7 clocks a span.") + +# The additive model here IS FINDINGS 35's flat debit, and reproduces its +# 84/120 exactly in the "today" column. Session 10's first pass replaced it with +# max(CPU, bus) and got 53/120; that was wrong, because a 68000 cannot execute +# while the DMAC holds the bus. + +print(f"\nbreak-even against all-V1 ({C_V1:.1f} cycles/block), clocks per block") +print(f" {'L':<16}" + "".join(f"{L:>8}" for L in (1, 2, 3, 4, 8, 16, 64))) +for nm, dz in (("v6 as built", "v6"), ("v6 fine tail", "v6fine"), ("DMAC chain", "dmac")): + print(f" {nm:<16}" + "".join(f"{span_cost(dz, L)[1]/L:>8.0f}" + for L in (1, 2, 3, 4, 8, 16, 64))) +for nm, dz in (("v6 as built", "v6"), ("v6 fine tail", "v6fine"), ("DMAC chain", "dmac")): + brk = next((L for L in range(1, 65) if span_cost(dz, L)[1] < L * C_V1), None) + print(f" {nm:<16} beats all-V1 from L={brk} blocks up") diff --git a/tools/analysis/15_bus_occupancy.py b/tools/analysis/15_bus_occupancy.py new file mode 100644 index 0000000..66f276b --- /dev/null +++ b/tools/analysis/15_bus_occupancy.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""How much of the bus does the 68000 decoder actually leave for a DMAC? + + python3 tools/analysis/15_bus_occupancy.py [container.dlx] [--nframes N] + +FINDINGS 29.6's DMAC idea only pays if the DMAC can find bus slots the CPU is +not using. That is not a cycle count, it is a BUS count, and nothing in the tree +had one. + +Two sources, and the point is that they check each other: + + DATA accesses MEASURED by tools/bench/c68k/c68k_bench, which counts every + Read/Write callback the C68K core makes. Exact. + INSTRUCTION DERIVED here by walking src/player/decode.s's straight-line + prefetch paths in tools/bench/decode.lst and multiplying by the mode + histogram. Not measurable from either emulator: MAME's core + does not expose a fetch count and C68K reads opcodes straight + through a host pointer with no callback. + +If the derived DATA figure matches the measured one, the derived PREFETCH figure +from the same walk is trustworthy too. That check is the first thing printed, +and this script exits non-zero if it fails. + +A 68000 bus cycle is 4 clocks, so a frame of C clocks holds C/4 bus slots. +""" +import sys, os, argparse, csv +sys.path.insert(0, "tools/encoder") +import numpy as np +from dlx import DLX + +BUS_CLK = 4 + +# --- straight-line path costs, read off tools/bench/decode.lst ------------- +# (instruction words, data bus cycles). A long access is two bus cycles on the +# 68000's 16-bit bus; movem.l of N registers is 2N. +# +# dispatch move.b (a1),d0 / lsr.b / and.w #3 / beq .sk 6w, 1 read +# + subq / beq .v1 -> 8w +# + subq / bne .rw -> 10w +# V4 body $10090..$100E2 = 82 B = 41w; 4 x (1 byte read +# + movem.l 2 regs = 4 reads + 2 move.l = 4 writes) = 36 +# V1 body $100E2..$10106 = 36 B = 18w; 1 byte read +# + movem.l 8 regs = 16 reads + 4 x movem.l 2 = 16 w = 33 +# RAW body $10106..$10164 = 94 B = 47w; 8 x (2 byte reads +# + 1 move.l = 2 writes) = 32 +# .sk tail addq.l #8,a4 1w +# BLOCK 0 has no lsr.b, so one of the four dispatches in a group is 1w cheaper. +DISPATCH_SK, DISPATCH_V1, DISPATCH_V4 = 6, 8, 10 +BODY = {0: (0, 0), 1: (18, 33), 2: (41, 36), 3: (47, 32)} +DISPATCH = {0: DISPATCH_SK, 1: DISPATCH_V1, 2: DISPATCH_V4, 3: DISPATCH_V4} +SK_TAIL = 1 +GROUP_HEAD = 3 # tst.b (a1) 1w + beq allskip 2w +GROUP_TAIL = 4 # addq.l #1,a1 / cmpa.l a5,a4 / bne byteloop +ALLSKIP = 9 # the whole four-block fast path, tst.b included +ROW_HEAD, ROW_TAIL = 3, 7 + +ap = argparse.ArgumentParser() +ap.add_argument("container", nargs="?", default="tmp/rc_fr_singe_scsi_cpufit.dlx") +ap.add_argument("--csv", default="tmp/c68k_frames.csv", + help="per-frame output of tools/bench/c68k/run.sh") +ap.add_argument("--nframes", type=int, default=None) +a = ap.parse_args() +if not os.path.exists(a.container): + sys.exit(f"missing {a.container}") + +d = DLX(a.container) +meas = {} +if os.path.exists(a.csv): + for r in csv.DictReader(open(a.csv)): + meas[int(r["frame"])] = (int(r["cycles"]), + int(r["bus_reads"]) + int(r["bus_writes"])) +NF = a.nframes or (max(meas) + 1 if meas else d.nframes) + +pref_t, data_t, cyc_t = [], [], [] +for f in range(NF): + m = d.modes(f).reshape(d.nby, d.nbx) + pref = d.nby * (ROW_HEAD + ROW_TAIL) + data = 0 + for by in range(d.nby): + row = m[by] + for gi in range(0, d.nbx, 4): + g = row[gi:gi+4] + if (g == 0).all(): + pref += ALLSKIP; data += 1 + continue + pref += GROUP_HEAD + GROUP_TAIL - 1 # BLOCK 0 has no lsr.b + data += 1 + for b in g: + b = int(b) + pw, pd = BODY[b] + pref += DISPATCH[b] + pw + SK_TAIL + data += 1 + pd + pref_t.append(pref); data_t.append(data) + cyc_t.append(meas.get(f, (0, 0))[0]) + +pref_t, data_t, cyc_t = map(np.array, (pref_t, data_t, cyc_t)) + +print(f"{a.container}: {NF} frames, {d.nb} blocks/frame\n") +if meas: + md = np.array([meas[f][1] for f in range(NF)]) + err = 100 * (data_t - md) / md + print("CHECK -- derived DATA bus cycles against the C68K harness's measurement") + print(f" measured mean {md.mean():>10,.0f} /frame") + print(f" derived mean {data_t.mean():>10,.0f} /frame " + f"error {err.mean():+.2f}% mean, {np.abs(err).max():.2f}% worst") + if np.abs(err).max() > 2.0: + sys.exit("\nFAIL: the path walk does not reproduce the measured data " + "accesses, so its prefetch figure cannot be trusted either.") + print(" the walk reproduces the measurement, so its prefetch count stands\n") + +slots = cyc_t / BUS_CLK +tot = pref_t + data_t +print(f"{'':<22}{'mean':>12}{'median':>12}{'worst frame':>14}") +for label, v in (("bus slots in a frame", slots), + (" data accesses", data_t), + (" instruction prefetch", pref_t), + (" total bus cycles", tot)): + print(f"{label:<22}{v.mean():>12,.0f}{np.median(v):>12,.0f}{v.max():>14,.0f}") +occ = 100 * tot / slots +print(f"{'bus OCCUPANCY':<22}{occ.mean():>11.1f}%{np.median(occ):>11.1f}%" + f"{occ.max():>13.1f}%") +free = slots - tot +print(f"{'slots left for a DMAC':<22}{free.mean():>12,.0f}{np.median(free):>12,.0f}" + f"{free.min():>14,.0f} (worst = fewest)") +print(f"\nprefetch is {100*pref_t.sum()/tot.sum():.0f}% of the decoder's bus traffic: " + f"the data-only\nfigure the harness prints understates occupancy by about 2x.") +print(f"A DMAC painting spans at 8 clocks (2 bus cycles) per pixel could use at\n" + f"most {free.mean()/2:,.0f} pixels' worth of the mean frame's spare slots " + f"-- against {d.nb*16:,} pixels\nin a whole screen.") diff --git a/tools/analysis/buscost.py b/tools/analysis/buscost.py new file mode 100644 index 0000000..2e59c89 --- /dev/null +++ b/tools/analysis/buscost.py @@ -0,0 +1,129 @@ +"""Bus-cycle cost of src/player/decode.s and of tools/bench/blit.s's v6 spans. + +A 68000 bus cycle is 4 clocks (S0-S7) with no wait states, and the 68000 +prefetches every instruction word over the same bus. So a block's bus cost is +`instruction words + data accesses`, a long access counting twice on the 16-bit +bus and `movem.l` of N registers counting 2N. + +The per-path word counts are read off tools/bench/decode.lst and +tools/bench/blit.s. tools/analysis/15_bus_occupancy.py checks the DATA half of +this table against tools/bench/c68k/c68k_bench, which counts every bus callback +the C68K core makes: they agree to 0.04%. The prefetch half cannot be measured +from either emulator -- MAME does not expose a fetch count and C68K reads +opcodes through a host pointer with no callback -- so it rests on that check. +""" +BUS_CLK = 4 + +# --- decode.s, per block --------------------------------------------------- +# dispatch move.b (a1),d0 / lsr.b / and.w #3 / beq .sk 6w, 1 read +# + subq / beq .v1 -> 8w +# + subq / bne .rw -> 10w +# V4 body $10090..$100E2 = 82 B = 41w; 4 x (1 byte read +# + movem.l 2 = 4 reads + 2 move.l = 4 writes) = 36 +# V1 body $100E2..$10106 = 36 B = 18w; 1 byte read +# + movem.l 8 = 16 reads + 4 x movem.l 2 = 16 wr = 33 +# RAW body $10106..$10164 = 94 B = 47w; 8 x (2 byte reads +# + 1 move.l = 2 writes) = 32 +BODY = {0: (0, 0), 1: (18, 33), 2: (41, 36), 3: (47, 32)} +DISPATCH = {0: 6, 1: 8, 2: 10, 3: 10} +SK_TAIL = 1 # addq.l #8,a4 +GROUP_HEAD = 3 # tst.b (a1) + beq allskip +GROUP_TAIL = 4 # addq.l #1,a1 / cmpa.l a5,a4 / bne byteloop +ALLSKIP = 9 # the whole four-block fast path, tst.b included +ROW_HEAD, ROW_TAIL = 3, 7 + +# --- blit.s v6 spans ------------------------------------------------------- +# One chain unit moves 12 registers = 48 B = 24 pixels: +# movem.l (a0)+,12 = 2w instr + 24 word reads = 26 +# movem.l 12,(a2) = 2w instr + 24 word writes = 26 +# lea 48(a2),a2 = 2w instr = 2 +# Per span: move.l (a0)+,a2 (1w + 2 reads) + move.w (a0)+,d0 (1w + 1 read) +# + jmp v6ch(pc,d0.w) (2w) + dbra (2w) = 9 +V6_UNIT_PX = 24 +V6_UNIT_BUS = 54 +V6_SPAN_BUS = 9 +V6_SPAN_CYC = 43.7 # MEASURED, FINDINGS 30 +V6_PX_CYC = 9.152 # MEASURED, FINDINGS 30 + +# --- a DMAC array-chaining span ------------------------------------------- +# SOURCED, MC68450 Direct Memory Access Controller, Motorola, Jul 1989 +# (bitsavers). These replace session-10's first pass, which guessed 2 bus +# cycles a pixel from bus arithmetic and was 12% optimistic. +# +# Fig 4-25 sheet 4, DUAL ADDRESS / OPERAND SIZE IS WORD / DEVICE SIZE IS +# 16-BITS, D->M or M->D: {WORD READ, WORD WRITE} = 9 CLOCKS. +# Confirmed by the long-operand row: two of each = 18 clocks. +# Fig 4-25 note 2: reads are 4 clocks and WRITES ARE 5. That extra clock on +# every write is the whole story -- it is why the DMAC does not beat a 68000 +# movem chain, which writes in 4. +DMA_PX_CLK = 9 +# Fig 4-25 sheet 1, SEQUENTIAL ARRAY CHAINING: 36 CLOCKS per entry (three +# word reads to fetch the 6-byte entry, plus reload). +DMA_CHAIN_CLK = 36 +# Sect 4.5.2.1 front-end overhead 5 clocks best case, 8 worst; 4.5.2.2 +# back-end 2 clocks best. Once per period of bus ownership, not per span. +DMA_FRONT_CLK, DMA_BACK_CLK = 5, 2 +# Fig 4-25 sheet 3, SINGLE ADDRESS: W/B READ 4 clocks, W/B WRITE 5 clocks. +# A device->memory disk transfer is one memory WRITE = 5 clocks if the DMAC +# holds the bus, or 5 + front + back = 12 if it arbitrates per word. +# FINDINGS 5's long-standing 8 clk/word ESTIMATE sits inside that range. +DMA_DISK_CLK_WORD_HELD, DMA_DISK_CLK_WORD_ARB = 5, 12 + +# The 68000 cannot execute while another master owns the bus: no cache, and a +# two-word prefetch queue that empties immediately. So DMA time is ADDITIVE to +# CPU time, not overlapped -- which is what FINDINGS 35's flat debit assumed +# and session 10's first pass wrongly "refined". +DMA_OVERLAPS = False + + +def pad24(npix): + return -(-npix // V6_UNIT_PX) * V6_UNIT_PX + + +def block_bus(mode_map, spanned=None): + """(instruction words, data accesses) for one frame's CPU block decode. + + `spanned` is a boolean array the same shape as mode_map marking blocks a + span will paint instead; those blocks still cost their dispatch, because + the mode map is walked either way, but not their body.""" + nby, nbx = mode_map.shape + pref = nby * (ROW_HEAD + ROW_TAIL) + data = 0 + for by in range(nby): + row = mode_map[by] + sp = spanned[by] if spanned is not None else None + for gi in range(0, nbx, 4): + g = row[gi:gi + 4] + if (g == 0).all(): + pref += ALLSKIP + data += 1 + continue + pref += GROUP_HEAD + GROUP_TAIL - 1 # BLOCK 0 has no lsr.b + data += 1 + for k, b in enumerate(g): + b = int(b) + if sp is not None and sp[gi + k]: + b = 0 # the span paints it + pw, pd = BODY[b] + pref += DISPATCH[b] + pw + SK_TAIL + data += 1 + pd + return pref, data + + +# --- v6 with a finer tail (PROPOSAL, unmeasured -- Claude's, session 10) ---- +# v6 pads every span up to 24 pixels because its unrolled chain is built from +# 12-register movem units. Adding a second, finer chain of 2-register units +# (4 pixels) for the tail caps the padding at 3 pixels instead of 23, for the +# price of some more unrolled code and nothing per span. +# A 4-pixel unit: movem.l (a0)+,2 = 2w instr + 4 reads; movem.l 2,(a2) = 2w + +# 4 writes; lea = 2w. 14 bus cycles for 4 pixels = 56 clocks, against a full +# unit's 24 x 9.152 = 220 for 24. Dearer per pixel, paid at most once a span. +V6_TAIL_PX, V6_TAIL_CLK = 4, 56 + + +def v6_fine(npix): + """(pixels carried, CPU clocks) for a span with the finer tail.""" + k, r = divmod(npix, V6_UNIT_PX) + t = -(-r // V6_TAIL_PX) + return (k * V6_UNIT_PX + t * V6_TAIL_PX, + V6_SPAN_CYC + k * V6_UNIT_PX * V6_PX_CYC + t * V6_TAIL_CLK) diff --git a/tools/bench/c68k/Makefile b/tools/bench/c68k/Makefile new file mode 100644 index 0000000..cb9a821 --- /dev/null +++ b/tools/bench/c68k/Makefile @@ -0,0 +1,19 @@ +# Build the headless C68K cycle harness. PX68K points at a px68k checkout; +# only m68000/c68k.c and the two header dirs are used -- no SDL, no ROMs. +PX68K ?= $(HOME)/src/px68k +# -no-pie is LOAD-BEARING, not a tidy-up. C68K is 64-bit-unsafe on purpose: +# its MOVEM macros do `src = (UINT32)(&D0)` -- they truncate the host address of +# the CPU register file to 32 bits and dereference it -- and C68k_Set_Fetch +# stores the opcode-fetch base in a UINT32 too. Under the default PIE the +# binary loads near 0x555555550000 and the first movem segfaults. -no-pie puts +# the image at 0x400000, and the harness mmaps its arena with MAP_32BIT, so +# every pointer C68K truncates still round-trips. +CFLAGS = -O2 -fno-strict-aliasing -no-pie -Wall -Wno-unused-result \ + -Wno-int-to-pointer-cast -Wno-pointer-to-int-cast \ + -I$(PX68K)/m68000 -I$(PX68K)/x11 -I$(PX68K)/win32api + +c68k_bench: harness.c $(PX68K)/m68000/c68k.c + $(CC) $(CFLAGS) -no-pie -o $@ harness.c $(PX68K)/m68000/c68k.c + +clean: + rm -f c68k_bench diff --git a/tools/bench/c68k/compare.py b/tools/bench/c68k/compare.py new file mode 100644 index 0000000..fa6c961 --- /dev/null +++ b/tools/bench/c68k/compare.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Two emulators, one decoder: MAME's cycles against px68k's C68K core. + + python3 tools/bench/c68k/compare.py [--mame tmp/mame_timed.log] + [--c68k tmp/c68k.log] + +WHY THIS EXISTS. Every 68000 cycle figure in FINDINGS 24-35 comes from one +instrument. This puts a second, structurally different one next to it: + + MAME 0.277 M68000 is the microcode core (src/devices/cpu/m68000/m68000.lst + + m68000gen.py), NOT Musashi -- timing emerges from the 68000's + modelled micro-sequence and 4-clock bus cycles. + C68K a static per-instruction cycle table hand-transcribed from the + Motorola manual (ORI_CLOCKS_* / EA_CLOCKS_* in c68kmacro.h). + +Those are two different ways of being right, so agreement is evidence and +disagreement localises to whichever instruction the anchors separate. NEITHER +charges GVRAM wait states, so both are the same lower bound on real hardware. +""" +import argparse, re, sys + +ap = argparse.ArgumentParser() +ap.add_argument("--mame", default="tmp/mame_timed.log") +ap.add_argument("--c68k", default="tmp/c68k.log") +ap.add_argument("--meta", default="tmp/decode_meta.lua") +a = ap.parse_args() + +meta = open(a.meta).read() +fps = int(re.search(r"fps=(\d+)", meta).group(1)) +budget = 10_000_000 / fps +# anchor name -> stream offset, so the two logs can be joined: decode.lua +# reports by name, the C68K harness by offset. +names = {int(o): n for n, o in re.findall(r'name="([^"]+)", off=(\d+)', meta)} + +mame = {} +txt = open(a.mame, errors="replace").read() +for nm, cyc in re.findall(r"\[DEC\] frame @ (.+?)\n.*?->\s+(\d+) cycles/frame", txt): + mame[nm.strip()] = int(cyc) +m_seq = re.search(r"full \d+-frame pass.*?\n.*?->\s+(\d+) cycles/frame", txt) + +c68k, c_seq = {}, None +for line in open(a.c68k, errors="replace"): + m = re.search(r"anchor off=(\d+)\s+(\d+) cyc", line) + if m and int(m.group(1)) in names: + c68k[names[int(m.group(1))]] = int(m.group(2)) + m = re.search(r"sequential pass = (\d+) cyc, mean (\d+)", line) + if m: + c_seq = int(m.group(2)) + +if not mame: + sys.exit(f"no MAME anchor timings in {a.mame} -- run decode.lua WITHOUT " + f"DLX_VERIFY_ONLY=1 and give -seconds_to_run enough to finish") + +w = max(len(n) for n in c68k) + 2 +print(f"{'anchor':<{w}}{'MAME':>10}{'C68K':>10}{'delta':>9} {'MAME':>7}{'C68K':>7} of a {fps}fps frame") +rows = [] +for nm, c in c68k.items(): + m = mame.get(nm) + if m is None: + print(f"{nm:<{w}}{'--':>10}{c:>10}{'':>9} {'--':>7}{100*c/budget:>6.1f}% (MAME run did not reach it)") + continue + d = 100 * (c - m) / m + rows.append(d) + print(f"{nm:<{w}}{m:>10}{c:>10}{d:>+8.2f}% {100*m/budget:>6.1f}%{100*c/budget:>6.1f}%") + +if m_seq and c_seq: + m, c = int(m_seq.group(1)), c_seq + d = 100 * (c - m) / m + print(f"{'MEAN over the window':<{w}}{m:>10}{c:>10}{d:>+8.2f}% " + f"{100*m/budget:>6.1f}%{100*c/budget:>6.1f}%") + +if rows: + print(f"\nspread over {len(rows)} anchors: {min(rows):+.2f}% .. {max(rows):+.2f}%") + print("C68K reads HIGH throughout." if min(rows) > 0 else + "C68K reads high on some anchors and low on others.") + print("Neither instrument charges GVRAM wait states, so both are the same\n" + "LOWER BOUND: this bounds cycle-table error, not the distance to a\n" + "real X68000 (docs/BENCHMARK.md Tier 3).") diff --git a/tools/bench/c68k/harness.c b/tools/bench/c68k/harness.c new file mode 100644 index 0000000..43c3b22 --- /dev/null +++ b/tools/bench/c68k/harness.c @@ -0,0 +1,329 @@ +/* Headless C68K cycle harness -- an independent second opinion on every + * 68000 cycle figure in FINDINGS 24-35. + * + * WHY. Every one of those numbers comes from ONE instrument: MAME 0.277's + * Musashi core, timed host-side from manager.machine.time. A cycle table is a + * hand-transcribed artefact; if Musashi's is wrong for our instruction mix, the + * 833,333-cycle budget is wrong by the same amount and nothing in the tree + * would show it. This runs the SAME decode.bin against the SAME + * decode_data.bin under px68k's C68K core, which has a completely separate + * cycle table (ORI_CLOCKS_* + EA_CLOCKS_* in c68kmacro.h) written by a + * different author from the same Motorola manual. + * + * WHAT IT DOES AND DOES NOT SETTLE. C68K, like MAMEs x68000, charges NO + * GVRAM wait states -- grep the px68k tree, there is no bus-timing model + * anywhere in x68k/*.c. So this is the same LOWER BOUND, measured twice. It + * cross-checks the cycle table. It says nothing about real-hardware wait + * states; that needs XM6 TypeG or an actual X68000 (docs/BENCHMARK.md Tier 3). + * + * WHY NOT JUST RUN px68k. The decoder touches nothing but RAM, the control + * block and GVRAM: no IPL, no CRTC, no MFP, no interrupts (the MAME rig masks + * them with SR=$2700). Booting a whole emulated machine would add SDL, ROMs + * and a 55Hz sampling clock to a measurement that wants none of them. Linking + * the core alone also buys EXACTNESS: the stop cycle is captured inside the + * write callback, so a frame's cost is known to within one instruction rather + * than MAME's 1/55.46 s. That is why the anchors here run iter=1 -- decode.lua + * only iterates to beat its own timing granularity. + * + * MEMORY MODEL mirrors px68k exactly, because the core requires it: RAM is + * stored BYTE-SWAPPED (MEM[addr ^ 1], mem_wrap.c:420) so C68K's + * READ_IMM_16() = *(UINT16 *)PC works with no swap on a little-endian host. + * GVRAM word writes discard the high byte, as the hardware and MAME's + * gvram_w case 0x0100 both do. + * + * The harness is self-validating: --dump writes the decoded screen and + * verify_c68k.py checks it pixel-for-pixel against tools/encoder/dlx.py. If + * the byte-swap or the memory map were wrong the decode could not come out + * exact, so a green verify is what licenses the cycle numbers next to it. + */ +#include +#include +#include +#include +#include "c68k.h" + +/* c68k.c declares these extern and tests BusErrHandling every instruction. */ +unsigned int BusErrHandling = 0; +unsigned int BusErrAdr = 0; +void Error(const char *s) { fprintf(stderr, "c68k: %s\n", s); exit(3); } +void p6logd(const char *fmt, ...) { (void)fmt; } + +#define ADRMASK 0xFFFFFFu +#define ARENA (16u << 20) +#define RAMTOP 0x200000u +#define GV_LO 0xC00000u +#define GV_HI 0xC80000u + +#define FLAG 0x18000u +#define ITER 0x18008u +#define NFR 0x1800Cu +#define FPTR 0x18010u +#define CB1 0x20000u +#define CB4 0x22000u +#define STREAM 0x30000u +#define CODE 0x10000u +#define STACK 0x8000u +#define GVBASE 0xC00000u +#define ROWBYTES 1024u +#define CPUHZ 10000000.0 + +static unsigned char *buf; /* byte-swapped, px68k convention */ + +/* Data bus cycles the 68000 issues. Every callback below is exactly one + * 68000 bus cycle -- C68K splits a long access into two word calls, which is + * what the 16-bit bus does too -- so counting calls counts bus cycles. This + * does NOT include instruction prefetch, which C68K reads straight through the + * fetch pointer with no callback; the count is therefore a LOWER BOUND on the + * CPU's bus occupancy, and the headroom it implies is an UPPER BOUND. + * It is still the measurement that matters for FINDINGS 29.6: if the decoder's + * data accesses alone left no room, a DMAC could not overlap with it at all. */ +static long long bus_r, bus_w; +static int in_exec = 0; + +/* Cycle capture. A single C68k_Exec slice runs the whole pass; the FLAG + * writes inside it record where the timed region starts and ends, so the + * count excludes nothing and includes no spin-loop tail. */ +static long long slice; +static long long cyc_start = -1, cyc_stop = -1; +static int desync = 0; + +static unsigned char rd8 (unsigned int a){ if (in_exec) bus_r++; return buf[(a & ADRMASK) ^ 1]; } +static unsigned short rd16(unsigned int a){ if (in_exec) bus_r++; a &= ADRMASK; return (unsigned short)(buf[a] | (buf[a+1] << 8)); } +static unsigned short peek16(unsigned int a){ a &= ADRMASK; return (unsigned short)(buf[a] | (buf[a+1] << 8)); } +static unsigned int rd32(unsigned int a){ return ((unsigned int)peek16(a) << 16) | peek16(a+2); } + +static void wr8(unsigned int a, unsigned char d) +{ + if (in_exec) bus_w++; + a &= ADRMASK; + if (a >= GV_LO && a < GV_HI) { if (a & 1) buf[a ^ 1] = d; return; } /* high byte discarded */ + buf[a ^ 1] = d; +} + +/* Only writes made BY the 68000 mean anything here. The harness sets FLAG + * itself during setup, and a `move.l` to FLAG arrives as two word writes, so + * the hook sees a half-updated long in between -- clearing FLAG from $FF to 0 + * momentarily reads back as $FF again. Without in_exec that transient + * recorded a run's stop cycle before the run had started, and every frame + * after the first came out as the whole slice. */ +static void note_flag(void) +{ + unsigned int v = rd32(FLAG); + long long now = slice - C68K.ICount; + if (!in_exec) return; + if (v == 1 && cyc_start < 0) cyc_start = now; + else if (v == 0xFF || v == 0xEE) { + if (cyc_stop < 0) { cyc_stop = now; desync = (v == 0xEE); } + C68K.ICount = 0; /* stop the slice; we keep our own count */ + } +} + +static void wr16(unsigned int a, unsigned short d) +{ + if (in_exec) bus_w++; + a &= ADRMASK; + if (a >= GV_LO && a < GV_HI) { buf[a] = (unsigned char)d; buf[a+1] = 0; return; } + buf[a] = (unsigned char)d; buf[a+1] = (unsigned char)(d >> 8); + if (a >= FLAG && a < FLAG + 4) note_flag(); +} + +static void wr32(unsigned int a, unsigned int d){ wr16(a, (unsigned short)(d >> 16)); wr16(a+2, (unsigned short)d); } + +static void push(unsigned int a, const unsigned char *s, size_t n) +{ + for (size_t i = 0; i < n; i++) wr8((unsigned int)(a + i), s[i]); +} + +/* Prime the screen exactly as decode.lua's setup() does: active area at index + * 0, letterbox at the darkest palette entry. A SKIP block in frame 0 is a + * claim about THIS, so it is part of the decode contract. Pass 2 re-primes, + * because pass 1 left one frame's worth of residue on the screen and frame 0's + * SKIP blocks would otherwise inherit it. */ +static void prime(unsigned int W, unsigned int H, unsigned int yoff, unsigned int dark) +{ + for (unsigned int y = 0; y < 256; y++) { + unsigned short v = (y < yoff || y >= yoff + H) ? (unsigned short)dark : 0; + for (unsigned int x = 0; x < W; x++) wr16(GVBASE + y*ROWBYTES + x*2, v); + } +} + +static unsigned char *slurp(const char *p, size_t *n) +{ + FILE *f = fopen(p, "rb"); + if (!f) { fprintf(stderr, "cannot open %s\n", p); exit(2); } + fseek(f, 0, SEEK_END); long L = ftell(f); fseek(f, 0, SEEK_SET); + unsigned char *b = malloc((size_t)L); + if (fread(b, 1, (size_t)L, f) != (size_t)L) { fprintf(stderr, "short read %s\n", p); exit(2); } + fclose(f); *n = (size_t)L; return b; +} + +/* Run one pass and return its exact cycle count. */ +static long long run(unsigned int off, unsigned int nfr, unsigned int iter) +{ + cyc_start = cyc_stop = -1; desync = 0; bus_r = bus_w = 0; + wr32(FLAG, 0); wr32(ITER, iter); wr32(NFR, nfr); wr32(FPTR, STREAM + off); + C68k_Reset(&C68K); + C68k_Set_Reg(&C68K, C68K_SR, 0x2700); /* supervisor, all IRQs masked */ + C68k_Set_Reg(&C68K, C68K_A7, STACK); + C68k_Set_Reg(&C68K, C68K_PC, CODE); + slice = 2000000000LL; + in_exec = 1; + C68k_Exec(&C68K, (INT32)slice); + in_exec = 0; + if (cyc_stop < 0) { fprintf(stderr, "TIMEOUT off=%u nfr=%u -- decoder never set FLAG\n", off, nfr); exit(4); } + if (desync) { fprintf(stderr, "BITSTREAM DESYNC off=%u nfr=%u\n", off, nfr); exit(5); } + /* A runaway is not a slow frame. Without this a bad record walk reports a + * two-billion-cycle "frame" as if it were a measurement. */ + if (cyc_stop - cyc_start > 40LL * nfr * iter * 833333LL) { + fprintf(stderr, "RUNAWAY off=%u nfr=%u: %lld cyc (start=%lld stop=%lld) " + "PC=%06X FLAG=%08X SCR_N=%08X SCR_END=%08X len=%u\n", + off, nfr, cyc_stop - cyc_start, cyc_start, cyc_stop, + C68k_Get_Reg(&C68K, C68K_PC) & 0xFFFFFF, rd32(FLAG), + rd32(0x18014), rd32(0x18018), rd32(STREAM + off)); + exit(6); + } + return cyc_stop - cyc_start; +} + +int main(int argc, char **argv) +{ + const char *fcode = "tmp/decode.bin", *fdata = "tmp/decode_data.bin", *dump = NULL; + unsigned int cb1_len=0, cb4_len=0, pal_len=0, stream_len=0, nframes=0, H=192, W=256, fps=12; + unsigned int dark = 255; + unsigned int anch[32]; int nanch = 0; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--code")) fcode = argv[++i]; + else if (!strcmp(argv[i], "--data")) fdata = argv[++i]; + else if (!strcmp(argv[i], "--dump")) dump = argv[++i]; + else if (!strcmp(argv[i], "--cb1")) cb1_len = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--cb4")) cb4_len = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--pal")) pal_len = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--stream")) stream_len = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--nframes"))nframes = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--W")) W = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--H")) H = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--fps")) fps = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--dark")) dark = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--anchor")) { if (nanch < 32) anch[nanch++] = (unsigned)strtoul(argv[++i], NULL, 10); } + else { fprintf(stderr, "unknown arg %s\n", argv[i]); return 2; } + } + if (!nframes || !stream_len) { fprintf(stderr, "need --nframes and --stream (from decode_meta.lua)\n"); return 2; } + + /* MAP_32BIT: C68K keeps its fetch base in a UINT32, so the arena must live + * below 4 GB or every opcode fetch reads a truncated pointer. */ + buf = mmap(NULL, ARENA, PROT_READ|PROT_WRITE, + MAP_PRIVATE|MAP_ANONYMOUS|MAP_32BIT, -1, 0); + if (buf == MAP_FAILED) { perror("mmap MAP_32BIT"); return 2; } + fprintf(stderr, "[C68K] arena at %p\n", (void *)buf); + + size_t nc, nd; + unsigned char *code = slurp(fcode, &nc), *data = slurp(fdata, &nd); + size_t need = (size_t)cb1_len + cb4_len + pal_len + stream_len; + if (nd < need) { fprintf(stderr, "data blob %zu B < meta's %zu B\n", nd, need); return 2; } + + size_t o = 0; + push(CB1, data + o, cb1_len); o += cb1_len; + push(CB4, data + o, cb4_len); o += cb4_len; + o += pal_len; /* palette: display only */ + push(STREAM, data + o, stream_len); + push(CODE, code, nc); + + /* Prime the screen exactly as decode.lua's setup() does: the active area + * starts at index 0 and the letterbox gets the darkest palette entry. + * A SKIP block in frame 0 is a claim about THIS, so it is part of the + * decode contract, not decoration. */ + unsigned int yoff = (256u - H) / 2; + prime(W, H, yoff, dark); + + C68k_Init(&C68K); + C68k_Set_ReadB (&C68K, rd8); + C68k_Set_ReadW (&C68K, rd16); + C68k_Set_WriteB(&C68K, wr8); + C68k_Set_WriteW(&C68K, wr16); + C68k_Set_Fetch (&C68K, 0x000000, 0xFFFFFF, (UINT32)(unsigned long)buf); + + double frame_budget = CPUHZ / fps; + fprintf(stderr, "[C68K] %u frames, stream %u B, budget %.0f cyc/frame @ %u fps\n", + nframes, stream_len, frame_budget, fps); + + /* Pass 1 -- every frame timed on its own. MAME could only afford eight + * anchor frames because its clock is 1/55.46 s; here each frame is exact, + * so the whole distribution comes out, which is what FINDINGS 31/35 score + * against. Record layout: [u32 len][768 mode][payload], next record start + * rounded up to 4 (FINDINGS 28.3). `len` counts the mode header TOO -- + * decode.s sets SCR_END from the address AFTER the length word, so the + * record is 4 + len bytes, not 4 + 768 + len. */ + printf("frame,offset,cycles,pct_of_frame,bus_reads,bus_writes,bus_pct\n"); + unsigned int off = 0; + long long sum = 0, busr_tot = 0, busw_tot = 0; + for (unsigned int f = 0; f < nframes; f++) { + long long c = run(off, 1, 1); + sum += c; + long long br = bus_r, bw = bus_w; + busr_tot += br; busw_tot += bw; + printf("%u,%u,%lld,%.2f,%lld,%lld,%.2f\n", f, off, c, + 100.0 * c / frame_budget, br, bw, 100.0 * 4.0 * (br + bw) / c); + unsigned int len = rd32(STREAM + off); + off = (off + 4 + len + 3) & ~3u; + } + fprintf(stderr, "[C68K] per-frame sum = %lld cyc, mean %.0f (%.1f%% of a %u fps frame)\n", + sum, (double)sum / nframes, 100.0 * sum / nframes / frame_budget, fps); + /* The number FINDINGS 29.6 needs. A 68000 bus cycle is 4 clocks, so a + * frame of `sum/nframes` clocks has room for a quarter that many bus + * cycles. What the decoder's DATA accesses do not use is the headroom a + * DMAC could paint spans in -- minus instruction prefetch, which is not + * counted here, so this OVERSTATES the headroom. */ + { + double mean_cyc = (double)sum / nframes; + double slots = mean_cyc / 4.0; + double used = (double)(busr_tot + busw_tot) / nframes; + fprintf(stderr, "[C68K] data bus: %.0f reads + %.0f writes = %.0f cycles/frame " + "of %.0f slots = %.1f%% occupied\n", + (double)busr_tot / nframes, (double)busw_tot / nframes, used, slots, + 100.0 * used / slots); + fprintf(stderr, "[C68K] headroom >= %.0f bus cycles/frame " + "(%.1f%%), MINUS instruction prefetch, which is not counted\n", + slots - used, 100.0 * (slots - used) / slots); + } + + /* Pass 2 -- one sequential run of the whole window. Two jobs: it is the + * only honest correctness test (SKIP makes every frame a claim about the + * one before it), and its total against pass 1's sum prices the outer + * frame-loop overhead the per-frame runs each pay once. */ + prime(W, H, yoff, dark); + long long seq = run(0, nframes, 1); + fprintf(stderr, "[C68K] sequential pass = %lld cyc, mean %.0f (%.1f%%); " + "per-frame sum is %+.3f%% of it\n", + seq, (double)seq / nframes, 100.0 * seq / nframes / frame_budget, + 100.0 * (sum - seq) / seq); + + /* Dump BEFORE the anchors run. They decode single frames onto this same + * screen, so anything after them is not the sequential reconstruction and + * verify_c68k.py would report every pixel wrong. */ + if (dump) { + /* Active area only, one byte per pixel -- the low byte of each GVRAM + * word, which is all the hardware keeps. */ + FILE *g = fopen(dump, "wb"); + if (!g) { perror(dump); return 2; } + for (unsigned int y = 0; y < H; y++) + for (unsigned int x = 0; x < W; x++) { + unsigned char p = (unsigned char)rd16(GVBASE + (yoff + y)*ROWBYTES + x*2); + fwrite(&p, 1, 1, g); + } + fclose(g); + fprintf(stderr, "[C68K] screen dumped to %s (%ux%u indices)\n", dump, W, H); + } + /* Pass 3 -- decode.lua's timing anchors, at the same stream offsets, so the + * two instruments are quoted on the same eight frames. The four synthetic + * single-mode frames live past the end of the real stream and so are not + * reachable by the record walk in pass 1; they are the ones that price the + * modes separately (prep_dlx.py), which is where two cycle tables are most + * likely to disagree. */ + for (int i = 0; i < nanch; i++) { + long long c = run(anch[i], 1, 1); + fprintf(stderr, "[C68K] anchor off=%-8u %8lld cyc %5.1f%% of a %u fps frame\n", + anch[i], c, 100.0 * c / frame_budget, fps); + } + + return 0; +} diff --git a/tools/bench/c68k/run.sh b/tools/bench/c68k/run.sh new file mode 100755 index 0000000..9ae34eb --- /dev/null +++ b/tools/bench/c68k/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Run the C68K harness against whatever tools/bench/prep_dlx.py last laid out, +# so it measures byte-for-byte the same code and container MAME did. +# tools/bench/c68k/run.sh [out.csv] +set -e +cd "$(dirname "$0")/../../.." +M=tmp/decode_meta.lua +[ -f "$M" ] || { echo "no $M -- run tools/bench/prep_dlx.py first"; exit 2; } +g() { sed -n "s/.*[ ,{]$1=\([0-9]*\).*/\1/p" "$M" | head -1; } +# Same anchor offsets decode.lua times, so the two instruments are quoted on the +# same frames -- including the four synthetic single-mode ones, which sit past +# the end of the real stream and price each block mode on its own. +ANCH=() +while read -r o; do ANCH+=(--anchor "$o"); done < <(sed -n 's/.*off=\([0-9]*\).*/\1/p' "$M") +tools/bench/c68k/c68k_bench \ + --code tmp/decode.bin --data tmp/decode_data.bin \ + --cb1 "$(g cb1_len)" --cb4 "$(g cb4_len)" --pal "$(g pal_len)" \ + --stream "$(g stream_len)" --nframes "$(g nframes)" \ + --W "$(g W)" --H "$(g H)" --fps "$(g fps)" --dark "$(g dark)" \ + "${ANCH[@]}" \ + --dump tmp/c68k_screen.bin > "${1:-tmp/c68k_frames.csv}" 2> >(tee tmp/c68k.log >&2) diff --git a/tools/bench/c68k/verify_c68k.py b/tools/bench/c68k/verify_c68k.py new file mode 100644 index 0000000..9c60e49 --- /dev/null +++ b/tools/bench/c68k/verify_c68k.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Is the C68K harness's decode pixel-exact against the reference decoder? + + python3 tools/bench/c68k/verify_c68k.py --nframes N + +This is the licence for every cycle number the harness prints. The harness +rebuilds px68k's memory model from scratch -- byte-swapped RAM, GVRAM word +writes that discard the high byte, a hand-rolled 24-bit map -- and any of that +being subtly wrong would still produce plausible-looking cycle counts. It could +not produce a pixel-exact 80-frame temporal recursion. + +Unlike tools/bench/verify_decode.py this compares palette INDICES, not rendered +RGB: the harness dumps the low byte of each GVRAM word directly, so there is no +palette round-trip to model and no snapshot geometry to unpick. +""" +import argparse, sys +sys.path.insert(0, "tools/encoder") +import numpy as np +from dlx import DLX + +ap = argparse.ArgumentParser() +ap.add_argument("container") +ap.add_argument("--dump", default="tmp/c68k_screen.bin") +ap.add_argument("--nframes", type=int, default=None) +a = ap.parse_args() + +d = DLX(a.container) +NF = a.nframes if a.nframes is not None else d.nframes +if NF > d.nframes: + sys.exit(f"--nframes {NF} exceeds the container's {d.nframes}") + +canvas = np.zeros((d.H, d.W), np.uint8) +for f in range(NF): + d.paint(canvas, f) + +got = np.fromfile(a.dump, np.uint8) +if got.size != d.H * d.W: + sys.exit(f"FAIL 1. dump is {got.size} B, expected {d.H*d.W}") +got = got.reshape(d.H, d.W) + +if not np.array_equal(got, canvas): + bad = got != canvas + by, bx = np.where(bad) + blocks = sorted(set(zip((by // 4).tolist(), (bx // 4).tolist()))) + sys.exit(f"FAIL 2. frame {NF-1} not pixel-exact under C68K: {bad.sum()} px in " + f"{len(blocks)} blocks differ; first block " + f"(by={blocks[0][0]}, bx={blocks[0][1]})") + +print(f"OK {NF} frames decoded on px68k's C68K core, final frame pixel-exact " + f"against tools/encoder/dlx.py") +print(f" {d.W}x{d.H}, {d.nb} blocks/frame, k1={d.k1} k4={d.k4}; the memory " + f"model (byte-swapped RAM, high-byte-discarding GVRAM) is therefore right") diff --git a/tools/bench/check.sh b/tools/bench/check.sh index 9ec0bc9..e2057fb 100755 --- a/tools/bench/check.sh +++ b/tools/bench/check.sh @@ -93,4 +93,29 @@ grep -q "snapshot taken" tmp/decode_check.log || { tail -5 tmp/decode_check.log; exit 1; } python3 tools/bench/verify_decode.py "$DLX" --nframes "$NF" +echo "--- session 10: the same decode on a second CPU core (FINDINGS 37) ---" +# A SECOND emulator, and the cheapest strong test in the tree: seconds, no MAME, +# no ROMs. px68k's C68K core has its own cycle table and its own memory model, +# so a pass here says decode.s is pixel-exact under two independent cores and +# that the harness's byte-swapped RAM / high-byte-discarding GVRAM is right -- +# which is what licenses its cycle and bus numbers. +# Skipped rather than failed when px68k is not checked out: it is an external +# tree, not part of this repo. +PX68K=${PX68K:-$HOME/src/px68k} +if [ -f "$PX68K/m68000/c68k.c" ]; then + make -s -C tools/bench/c68k PX68K="$PX68K" + bash tools/bench/c68k/run.sh tmp/c68k_frames.csv 2>tmp/c68k.log + grep -a "sequential pass" tmp/c68k.log + python3 tools/bench/c68k/verify_c68k.py "$DLX" --nframes "$NF" + + echo "--- session 10: the bus model still matches the machine (FINDINGS 38) ---" + # 15_bus_occupancy.py derives instruction prefetch, which no emulator here can + # report, and validates itself against the DATA accesses the harness counts. + # If that check ever stops holding, every bus figure in FINDINGS 38/39 is + # unfounded -- so it is a gate, not a report. + python3 tools/analysis/15_bus_occupancy.py "$DLX" | sed -n '3,7p' +else + echo " SKIPPED: no px68k at $PX68K (set PX68K= to point at a checkout)" +fi + echo "ALL GREEN"