Measure the blit on the 68000: the 38% estimate was 53.6%

First 68000 instructions in this project to draw a pixel. Everything before
this was GVRAM filled from Lua, which costs zero 68000 cycles, so the blit
figure the whole CPU budget rests on had never been validated.

Four variants of a full-frame 256x192 paint, timed in MAME and each also
hand-derived from the MC68000 timing tables beforehand; the two agree to
0.006-0.43%, which is what makes the result trustworthy after this project's
history of false-good measurements.

  V1 movem.l blit from a word-expanded RAM frame   446,286 cyc   53.6%
  V2 naive move.b/move.w per pixel               1,284,174 cyc  154.1%
  V3 write-only floor, no source read              225,789 cyc   27.1%
  V4 same writes in 4x4 block order                637,971 cyc   76.6%

Scope: MAME's gvram_w/gvram_r carry no timing at all, so these are instruction
cycles against zero-wait-state memory -- a floor, not a hardware prediction.

V1's output snapshots pixel-exact through verify_frame256.py, closing
FINDINGS 23.5. The V1/V3 gap shows reading the source frame is exactly half
the cost, which makes the architecture question live: decode-direct-to-GVRAM
needs no RAM reference frame and scales with the non-SKIP block fraction,
crossing compose-then-blit at 70% of blocks changed. That fraction is now the
top priority and is already a by-product of vq_hybrid.py's mode decision.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 13:43:56 -07:00
parent 7ba979a236
commit 09a5a50065
4 changed files with 499 additions and 45 deletions
+98
View File
@@ -792,3 +792,101 @@ and the 38% full-frame blit estimate underpinning the CPU budget remains
unvalidated. What this section adds is that the *target mode* is now real, so
68000 code has a defined geometry to write into: 256 words per visible row, a
1024-byte line stride, and rows 32..223 of a 256-row page.
---
## 24. The blit, measured on the 68000 — the 38% estimate was wrong (session 5)
**The first 68000 instructions in this project to draw a pixel.** Everything in
22 and 23 was GVRAM filled from Lua, which costs zero 68000 cycles. This section
replaces the estimate that the whole CPU budget rested on with a measurement.
Harness: `tools/bench/blit.s` + `tools/bench/blit.lua`. Four variants of a
full-frame 256x192 paint, each looped to run ~4 emulated seconds, timed from
`machine.time` between two flag writes by the 68000 itself.
| variant | what it does | cycles/frame | % of a 12fps frame |
|---|---|---:|---:|
| **V1** | `movem.l` blit from a word-expanded RAM frame (96KB read + 96KB write) | **446,286** | **53.6%** |
| V2 | naive `move.b`/`move.w` per pixel from a byte source | 1,284,174 | 154.1% |
| **V3** | write-only floor — registers preloaded, no source read at all | **225,789** | **27.1%** |
| **V4** | the same 96KB of writes issued in **4x4 block order** | **637,971** | **76.6%** |
The 12fps budget is 833,333 cycles (10.0 MHz confirmed from `x68k.cpp:1133`,
`40_MHz_XTAL / 4`).
### 24.1 The numbers are cross-checked against hand-derived cycle counts
Every variant was predicted from the MC68000 timing tables *before* the run
(`MOVEM.L` M->R `(An)+` = 12+8n, `(d16,An)` = 16+8n; R->M `(An)` = 8+8n,
`(d16,An)` = 12+8n) and then measured:
| | predicted | measured | error |
|---|---:|---:|---:|
| V1 | 447,744 | 446,286 | 0.33% |
| V2 | 1,284,096 | 1,284,174 | 0.006% |
| V3 | 225,792 | 225,789 | 0.001% |
| V4 | 640,704 | 637,971 | 0.43% |
This agreement is the point. A MAME timing number on its own would be worth
little given how many false-good results this project has produced (FINDINGS 4);
two independent derivations landing within half a percent is worth something.
The residual error is the frame-granularity of the measurement — Lua gets no
cycle counter (`luaengine.cpp` exposes `machine.time` and nothing from
`device_execute_interface`), so timing resolution is one video frame, 18.03 ms.
### 24.2 SCOPE: these are instruction cycles, and therefore a LOWER BOUND
MAME's `gvram_w`/`gvram_r` (`x68k_crtc.cpp:501,595`) contain **no timing at
all** — no wait states, no `adjust_icount`. GVRAM in MAME is as fast as main
RAM. Real X68000 GVRAM stalls the CPU on access, so every figure above is a
floor, not a prediction. **Do not quote these as hardware numbers.** Interrupts
were masked (`SR = $2700`) so the IPL's timer and VBL handlers could not steal
cycles into the measurement; a real player will take interrupts on top.
### 24.3 The 38% estimate is dead — a full-frame blit is 53.6%
The realistic "decode into a RAM frame, then blit it" design costs **53.6% of
the frame budget before decoding a single block**, and that is the zero-wait-
state floor. The estimate the CPU budget has been carrying since session 1 was
38%. It was optimistic by 41%.
The cause is visible in the V1/V3 gap: **reading the source frame is exactly
half the total cost** (221,952 of 446,286 cycles). The 68000 pays 8 cycles per
longword read and 8 per longword written, and in 256-colour mode a pixel
occupies a whole word of address space, so a frame is 96KB of traffic in each
direction rather than 48KB.
### 24.4 The high byte of every GVRAM write is discarded — confirmed from source
`gvram_w` case `0x0100` writes `data & 0x00ff` with `mem_mask 0x00ff`. So in
256-colour mode the CPU cannot pack two pixels into one word, and the odd bytes
of a word-expanded source frame never need clearing — V1 exploits this by
leaving them uninitialised. This is why 96KB, not 48KB, is the irreducible
write traffic.
### 24.5 The architecture question, and where it turns over
V4 prices the access pattern a decoder that writes codewords **straight into
GVRAM** actually has: 4 rows of 8 bytes at a 1024-byte stride per 4x4 block. The
same 96KB of writes costs **76.6%** in block order versus 53.6% row-linear — the
stride destroys the `movem.l` burst, 208 cycles per block against a theoretical
best of ~150.
But a decoder never writes every block: SKIP blocks cost **nothing at all**, and
the previous frame is already sitting in GVRAM, so **no RAM reference frame is
needed for SKIP to work**. So the two designs scale differently:
- **compose-in-RAM then blit** — flat 53.6%, independent of how much changed
- **decode-direct-to-GVRAM** — 76.6% x (fraction of non-SKIP blocks)
**They cross at 70% of blocks changed.** Below that, writing straight into GVRAM
wins, and it also drops the 96KB RAM reference frame entirely. Above it, the
flat blit wins.
**This makes the non-SKIP block fraction the single most important unmeasured
number in the project.** It is already computable from the encoder — it is a
by-product of the mode decision in `vq_hybrid.py` — and it has never been
reported. Measure it before writing any decoder inner loop, because it selects
which inner loop to write.
### 24.6 The frame the 68000 drew is pixel-exact
V1's output was snapshotted and passes `verify_frame256.py` unchanged: `256x512
native, double-scan exact, active 256x192 pixel-exact, letterbox true black`,
40.81 dB. So 68000 code drives the mode of FINDINGS 23 correctly, and 23.5 is
now closed.