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
+159
View File
@@ -0,0 +1,159 @@
-- Time the full-frame GVRAM blit (tools/bench/blit.s) on the emulated 68000.
--
-- This is the first measurement in the project where 68000 instructions, not
-- Lua, put the pixels on screen. It validates (or kills) the 38% full-frame
-- blit estimate the whole CPU budget rests on.
--
-- MEASUREMENT SCOPE. MAME's gvram_w/gvram_r (x68k_crtc.cpp:501,595) contain
-- no timing whatsoever -- no wait states, no icount adjustment. So what is
-- measured here is pure 68000 instruction cycles against zero-wait-state
-- memory. Real X68000 GVRAM stalls the CPU; every number below is therefore
-- a LOWER BOUND, not a prediction. Interrupts are masked (SR=$2700) so the
-- IPL's timer and VBL handlers cannot steal cycles into the measurement.
--
-- Timing resolution is one video frame (1/55.46 s = 18.03 ms), because Lua
-- gets no cycle counter -- luaengine.cpp exposes machine.time and nothing
-- from device_execute_interface. Each variant therefore loops enough times
-- to run ~4 emulated seconds, putting the granularity error near 0.4%.
M = manager.machine
SP = M.devices[":maincpu"].spaces["program"]
local function load_mode()
for _,p in ipairs{"../tools/bench/crtc_mode.lua","tools/bench/crtc_mode.lua","crtc_mode.lua"} do
local f = loadfile(p); if f then return f() end
end
error("crtc_mode.lua not found")
end
local MODE = load_mode()
local FLAG, VAR, ITER = 0x18000, 0x18004, 0x18008
local SRCW, SRCB = 0x60000, 0x80000
local GVRAM, GPAL = 0xC00000, 0xE82000
local CPUHZ = 10000000 -- x68k.cpp:1133, 40_MHz_XTAL/4
local FRAME12 = CPUHZ / 12 -- 833333 cycles at 12 fps
-- Iteration counts sized so every variant runs ~4 emulated seconds.
local PLAN = {
{var=1, iter=100, name="V1 movem.l blit from word-expanded RAM (96KB read + 96KB write)"},
{var=2, iter= 50, name="V2 naive byte-source expansion (move.b/move.w per pixel)"},
{var=3, iter=200, name="V3 write-only floor (no source read at all)"},
{var=4, iter= 60, name="V4 same 96KB of writes, issued in 4x4 BLOCK order (decoder access pattern)"},
}
local code do
local f = io.open("blit.bin","rb"); code = f:read("a"); f:close()
end
local frame do
local f = io.open("frame256.bin","rb"); frame = f:read("a"); f:close()
end
local function B(i) return string.byte(frame,i) end
local W, H = B(5)*256+B(6), B(7)*256+B(8)
local PAL0, PIX0 = 9, 9+256*3
local YOFF = (MODE.height - H) // 2
-- Identical packing to show_frame256.lua: shared LSB I chosen per entry.
local function pal6(v) return ((v<<2)|(v>>4)) & 0xff end
local function pack(r,g,b)
local f = {r>>3, g>>3, b>>3}
local best, bestI = nil, 1
for I = 0,1 do
local e = 0
for c = 1,3 do
local want = ({r,g,b})[c]
local d = pal6((f[c]<<1)|I) - want
e = e + d*d
end
if best == nil or e < best then best, bestI = e, I end
end
return (f[2]<<11)|(f[1]<<6)|(f[3]<<1)|bestI
end
local function T() local t = M.time; return t.seconds + t.attoseconds/1e18 end
local function P(s) print("[BLIT] "..s) end
local function setup()
MODE.apply(SP)
-- letterbox rows: GVRAM holds IPL leftovers, not zeros
for y = 0, MODE.height-1 do
if y < YOFF or y >= YOFF+H then
local base = GVRAM + y*1024
for x = 0, MODE.width-1 do SP:write_u16(base + x*2, 0) end
end
end
for c = 0, 255 do
local o = PAL0 + c*3
SP:write_u16(GPAL + c*2, pack(B(o), B(o+1), B(o+2)))
end
-- Source frames in main RAM. SRCW holds one pixel per WORD with the index
-- in the low byte; the high byte is left as-is because gvram_w masks it off.
for y = 0, H-1 do
local row = PIX0 + y*W
for x = 0, W-1 do
local px = B(row+x)
SP:write_u16(SRCW + y*512 + x*2, px)
SP:write_u8 (SRCB + y*256 + x, px)
end
end
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
P(string.format("loaded blit.bin=%d bytes, source frame %dx%d at yoff=%d", #code, W, H, YOFF))
end
local step, st, t0, snapped = 0, "boot", nil, false
local results = {}
local function launch(p)
SP:write_u32(FLAG, 0)
SP:write_u32(VAR, p.var)
SP:write_u32(ITER, p.iter)
local cpu = M.devices[":maincpu"]
cpu.state["SR"].value = 0x2700 -- supervisor, ALL interrupts masked
cpu.state["SP"].value = 0x8000
cpu.state["PC"].value = 0x10000
st, t0 = "running", nil
end
local function report(p, dt)
local cyc = dt * CPUHZ / p.iter
local pct = 100 * cyc / FRAME12
results[#results+1] = {p=p, cyc=cyc, pct=pct}
P(string.format("%s", p.name))
P(string.format(" %d iterations in %.4f s -> %.0f cycles/frame = %.1f%% of a 12fps frame",
p.iter, dt, cyc, pct))
end
SUB = emu.add_machine_frame_notifier(function()
local ok, err = pcall(function()
local t = T()
if st == "boot" then
if t < 3.0 then return end
setup(); step = 1; launch(PLAN[1]); return
end
if st == "running" then
local fl = SP:read_u32(FLAG)
if fl == 1 and not t0 then t0 = t; return end
if fl == 0xFF then
report(PLAN[step], t - (t0 or t))
if step == 1 and not snapped then st, snapped = "snap", true; return end
step = step + 1
if PLAN[step] then launch(PLAN[step]) else st = "finish" end
return
end
if t > 60 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
return
end
if st == "snap" then
M.video:snapshot(); P("snapshot taken after V1 -- 68000-drawn frame")
step = step + 1; launch(PLAN[step]); return
end
if st == "finish" then
P("---- summary (instruction cycles only; real GVRAM adds wait states) ----")
for _,r in ipairs(results) do
P(string.format(" V%d %8.0f cyc %5.1f%% of 12fps frame", r.p.var, r.cyc, r.pct))
end
M:exit()
end
end)
if not ok then print("[BLIT] LUA ERROR: "..tostring(err)); M:exit() end
end)
+156
View File
@@ -0,0 +1,156 @@
; Full-frame GVRAM blit cost on a stock 68000 @ 10MHz.
;
; Answers: what fraction of a 12fps frame budget (833,333 cycles) does simply
; PUTTING a decoded 256x192 frame on screen cost, before any decoding?
;
; Geometry (tools/bench/crtc_mode.lua): 256-colour page, one pixel per WORD of
; CPU address space, 1024-byte line stride, picture in rows 32..223 of a
; 256-row page. So a row is 512 contiguous bytes of writes, then a 512-byte
; skip. 192 rows = 98,304 bytes of GVRAM write traffic per frame.
;
; Confirmed from MAME 0.277 x68k_crtc.cpp:501 (gvram_w, case 0x0100): a CPU
; write in 256-colour mode is masked to 0x00ff, so the HIGH byte of every word
; written is discarded by the hardware. V1 exploits this -- it never has to
; clear the odd bytes of its source.
;
; Three variants, selected by VAR, each looped ITER times:
; V1 movem.l blit from a word-expanded RAM frame (96KB). The realistic
; "decode to RAM, then blit" design. Reads 96KB, writes 96KB.
; V2 naive byte-source expansion (move.b / move.w per pixel). The obvious
; implementation, kept as the baseline V1 has to beat.
; V3 write-only floor: registers preloaded once, no source read at all.
; Nothing that puts this many pixels on screen can beat V3. The gap
; V1-V3 is the price of reading a source frame at all.
; V4 the SAME 96KB of writes, but issued in 4x4 BLOCK order instead of
; row-linear order. This is the access pattern a decoder that writes
; codewords straight into GVRAM actually has, and it is the number that
; picks the decoder architecture: compose-in-RAM-then-blit (V1) versus
; decode-direct-to-GVRAM (V4 scaled by the fraction of non-SKIP blocks).
; Each block is 4 rows of 8 bytes at a 1024-byte stride, so the
; destination displacements 0/1024/2048/3072 all fit a 16-bit offset and
; the block needs only one base pointer. V4 deliberately scrambles the
; picture (it reads a row-linear source in block order); it is a timing
; probe, which is why the correctness snapshot is taken after V1.
;
; 12 registers per movem burst (d0-d7/a2-a5 = 48 bytes) is the maximum
; available: a0=src, a1=dst, a6=end sentinel. The row counter lives in the
; a1-vs-a6 compare rather than a d-register for exactly this reason.
; 512 = 10*48 + 32, hence ten 12-register bursts and one 8-register tail.
; Destination uses (d16,a1) displacement rather than post-increment because
; movem cannot post-increment a destination; the displacement costs 4 cycles
; per burst but saves an 8-cycle lea, so it is the cheaper of the two.
FLAG = $18000 ; 0 idle / 1 running / $FF done
VAR = $18004 ; variant selector, written by Lua
ITER = $18008 ; iteration count, written by Lua
SRCW = $60000 ; word-expanded frame 192*512 = 96KB
SRCB = $80000 ; byte-per-pixel frame 192*256 = 48KB
DST0 = $C08000 ; GVRAM + 32*1024 (first picture row)
DSTE = $C38000 ; GVRAM + 224*1024 (one past last)
org $10000
start:
move.l VAR.l,d0
move.l #1,FLAG.l ; timer starts here
cmp.l #1,d0
beq v1
cmp.l #2,d0
beq v2
cmp.l #4,d0
beq v4
bra v3
; ---------------------------------------------------------------- V1
v1: lea SRCW,a0
lea DST0,a1
lea DSTE,a6
v1row: movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,48(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,96(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,144(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,192(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,240(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,288(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,336(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,384(a1)
movem.l (a0)+,d0-d7/a2-a5
movem.l d0-d7/a2-a5,432(a1)
movem.l (a0)+,d0-d7
movem.l d0-d7,480(a1)
lea 1024(a1),a1
cmpa.l a6,a1
bne v1row
subq.l #1,ITER.l
bne v1
bra done
; ---------------------------------------------------------------- V2
v2: lea SRCB,a0
lea DST0,a1
lea DSTE,a6
v2row: move.w #255,d1
v2px: move.b (a0)+,d0
move.w d0,(a1)+ ; high byte is discarded by gvram_w
dbra d1,v2px
lea 512(a1),a1 ; skip the unused half of the line
cmpa.l a6,a1
bne v2row
subq.l #1,ITER.l
bne v2
bra done
; ---------------------------------------------------------------- V3
v3: lea SRCW,a0
movem.l (a0),d0-d7/a2-a5 ; load the burst once, outside the loop
lea DST0,a1
lea DSTE,a6
v3row: movem.l d0-d7/a2-a5,(a1)
movem.l d0-d7/a2-a5,48(a1)
movem.l d0-d7/a2-a5,96(a1)
movem.l d0-d7/a2-a5,144(a1)
movem.l d0-d7/a2-a5,192(a1)
movem.l d0-d7/a2-a5,240(a1)
movem.l d0-d7/a2-a5,288(a1)
movem.l d0-d7/a2-a5,336(a1)
movem.l d0-d7/a2-a5,384(a1)
movem.l d0-d7/a2-a5,432(a1)
movem.l d0-d7,480(a1)
lea 1024(a1),a1
cmpa.l a6,a1
bne v3row
subq.l #1,ITER.l
bne v3
bra done
; ---------------------------------------------------------------- V4
v4: lea SRCW,a0
lea DST0,a3 ; base of the current block row
lea DSTE,a4 ; one past the last block row
v4brow: move.l a3,a1
lea 512(a3),a5 ; 64 blocks * 8 bytes
v4blk: movem.l (a0)+,d0-d7 ; 32 bytes = one 4x4 block, expanded
movem.l d0-d1,(a1)
movem.l d2-d3,1024(a1)
movem.l d4-d5,2048(a1)
movem.l d6-d7,3072(a1)
addq.l #8,a1
cmpa.l a5,a1
bne.s v4blk
lea 4096(a3),a3 ; next block row is 4 picture lines
cmpa.l a4,a3
bne v4brow
subq.l #1,ITER.l
bne v4
bra done
done: move.l #$FF,FLAG.l ; timer stops here
halt: bra.s halt