Measure the span: the mode survives, and it is an encoder format

FINDINGS 29 priced a literal-span mode at 4*(50 + 4L*9.08) cycles and labelled
the whole section DERIVED. Session 8 step 0 was to measure it before optimising
over the mode set it implies. Two variants in blit.s, one stream per span length
from prep_spans.py, timed by span.lua, driven by span.sh in ~25 s:

  v5, handed (x, npix) and left to work the copy out:  97.9/span + 10.459/px
  v6, handed an address and a jump displacement:       43.7/span +  9.152/px
  29 assumed                                           50.0/span +  9.080/px

So 29's arithmetic was right about a format nobody had written. The difference
is not tuning: v5 spends ~122 cycles a span computing a destination, dividing
npix into bursts and handling a 0..15 remainder, all of which the encoder knows
at build time. v6's record is {u32 absolute GVRAM address, u16 jump
displacement} into an unrolled chain of 24-pixel copy units -- no loop, no
remainder, no arithmetic -- and it fits 11 span lengths to 0.3%.

Three things that measurement showed and derivation could not:

  - The per-pixel cost is a function of REGISTER PRESSURE. FINDINGS 24's 9.08
    was a fixed blit with 12 registers free; v5 can spare 8 and pays 10.46; v6
    gets 12 back only because the encoder holds the state.
  - Short spans die in the remainder path -- a 12-pixel span costs MORE than a
    16-pixel one -- and the fix is padding, not avoidance.
  - Odd-x alignment is free (259.0 vs 261.8 cycles/span), as a 16-bit bus
    implies but nobody had checked.

Re-priced against the unchanged mode maps, sasi: median 74.4% -> 52.0% (29 said
43.0), misses 37 -> 10/120 (29 said 8), 448.0 KB/s. Break-even moved from runs
of 2 blocks to runs of 4. 29.4 survives: a scene cut needs x >= 0.196 of the
frame as spans and the bus allows x <= 0.373, so it fits at 12fps.

All 23 timing configs are also checked pixel-exact, so none of this was timed
against a decoder that quietly skipped work.

FINDINGS 30. Next: lever B, the cost-aware mode decision.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 15:45:51 -07:00
parent 3641f37e28
commit 29eb78a599
9 changed files with 780 additions and 73 deletions
+49 -10
View File
@@ -12,10 +12,16 @@ This prices ONE new mode against the real mode maps: a per-row SPAN of
word-expanded literals, `movem.l`-ed straight from the stream buffer into GVRAM.
A run of L horizontally adjacent dirty blocks becomes 4 spans of 4L pixels.
DERIVED, NOT MEASURED (FINDINGS 29). The 9.08 cycles/pixel is measured
(FINDINGS 24 V1) but at full row width with 12-register bursts; SPAN_OVERHEAD is
hand-derived. Short spans are therefore flattered. Measure before believing --
FINDINGS 29.5 item 1.
MEASURED as of session 8 (FINDINGS 30), on the 68000, with the span decoder in
tools/bench/blit.s v6 and the streams in tools/bench/prep_spans.py:
43.7 cycles per span + 9.152 per pixel, fitting eleven span lengths to within
0.3%. That is the ENCODER-ASSISTED format: the record is an absolute GVRAM
address and a jump displacement into an unrolled copy chain, so the decoder does
no arithmetic per span. The obvious decoder -- handed (x, npix) and left to work
the copy out -- measures 97.9 + 10.46 and is 2.2x dearer on a 24-pixel span (v5).
Span length is therefore a multiple of 24 pixels, and a run pads up to it; the
padding is free of cycles beyond its pixels and correct on screen, because a
literal span carries true pixels of the current frame.
The mode maps are NOT re-optimised: this only re-codes regions the encoder
already chose to redraw, so it is a lower bound on what a cost-aware encoder
@@ -29,12 +35,17 @@ from dlx import DLX
FRAME_CYC = 833333.0 # 12fps at 10 MHz
AUDIO_KBPS = 7.8
CYC_PX_ROWLIN = 446286 / 49152. # 9.08, FINDINGS 24 V1 (measured)
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_OVERHEAD = 50.0 # per span, DERIVED
SPAN_OVERHEAD = 43.7 # per span, MEASURED, FINDINGS 30
CYC_PX_ROWLIN = 9.152 # per pixel, MEASURED, FINDINGS 30
SPAN_UNIT_PX = 24 # 12 registers of movem.l, one chain unit
SPAN_BYTES_PX = 2 # word-expanded: 1 pixel = 1 word
SPAN_HDR = 3 # x, count, and a byte of slack
SPAN_HDR = 6 # u32 GVRAM address + u16 jump displacement
def span_px(npix): # a span is a whole number of units
return -(-npix // SPAN_UNIT_PX) * SPAN_UNIT_PX
ap = argparse.ArgumentParser()
ap.add_argument("container", nargs="?",
@@ -78,8 +89,9 @@ for f in range(d.nframes):
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])
span_c = 4 * (SPAN_OVERHEAD + 4 * L * CYC_PX_ROWLIN)
span_b = 4 * (SPAN_HDR + 4 * L * SPAN_BYTES_PX)
sp = span_px(4 * L) # padded to the chain's 24-pixel unit
span_c = 4 * (SPAN_OVERHEAD + sp * CYC_PX_ROWLIN)
span_b = 4 * (SPAN_HDR + sp * SPAN_BYTES_PX)
if span_c < cur_c:
cand.append((cur_c - span_c, span_b - cur_b, L))
i = j
@@ -108,4 +120,31 @@ print(f" {'bitrate':<24}{bb.mean()*a.fps/1024:>10.1f} KB/s"
f"{nb.mean()*a.fps/1024:>13.1f} KB/s")
print(f"\nspans taken: {ntaken.sum()} of {ncand.sum()} candidate runs "
f"({100*ntaken.sum()/max(ncand.sum(),1):.0f}%) -- the rest priced out by the bus")
print("\nDERIVED, NOT MEASURED: see FINDINGS 29.5 before acting on this.")
brk = next(L for L in range(1, 65)
if 4*(SPAN_OVERHEAD + span_px(4*L)*CYC_PX_ROWLIN) < L*C_V1)
print(f"\nspan cost MEASURED (FINDINGS 30): {SPAN_OVERHEAD:.1f}/span + "
f"{CYC_PX_ROWLIN:.3f}/pixel, {SPAN_UNIT_PX}-pixel units.")
print(f"a run of L blocks beats all-V1 from L={brk} blocks up "
f"({4*(SPAN_OVERHEAD + span_px(4*brk)*CYC_PX_ROWLIN)/brk:.0f} vs {C_V1:.0f} "
f"cycles/block); the floor at a full row is "
f"{4*(SPAN_OVERHEAD + span_px(256)*CYC_PX_ROWLIN)/64:.0f}.")
print("The mode maps are NOT re-optimised, so this is a lower bound on a "
"cost-aware encoder.")
# FINDINGS 28.5 said a scene cut cannot fit at 12fps: the cheapest full redraw
# the codec's mode set allows is all-V1 at 110.5% of budget. 29.4 reopened that
# on derived span costs; this is the same arithmetic on measured ones. Mix a
# fraction x of a 100%-changed frame as full-row spans, V1 for the rest.
NB = d.nb
row_c = 4 * (SPAN_OVERHEAD + span_px(4 * d.nbx) * CYC_PX_ROWLIN) / d.nbx
row_b = 4 * (SPAN_HDR + span_px(4 * d.nbx) * SPAN_BYTES_PX) / d.nbx
x_cpu = (NB * C_V1 - FRAME_CYC) / (NB * (C_V1 - row_c))
x_bus = (BYTE_BUD - d.mode_bytes - NB * BLK_B[1]) / (NB * (row_b - BLK_B[1]))
print(f"\nscene cut (100% of blocks change), spans at full row width "
f"({row_c:.0f} cyc, {row_b:.1f} B per block):")
print(f" all-V1 costs {100*NB*C_V1/FRAME_CYC:.1f}% of the frame -- FINDINGS 28.5")
print(f" CPU needs x >= {x_cpu:.3f} of the frame as spans; "
f"the bus allows x <= {x_bus:.3f}")
print(" " + ("the interval is NOT empty: a cut fits at 12fps (FINDINGS 29.4 holds)"
if x_cpu <= x_bus else
"the interval IS empty: a cut does not fit (FINDINGS 28.5 stands)"))
+130
View File
@@ -31,6 +31,43 @@
; 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.
; V5 ROW-LINEAR LITERAL SPANS, the mode priced in FINDINGS 29 and never
; measured. Walks a stream of per-row span records
; row: u16 nspans, then nspans * { u16 x, u16 npix, npix*u16 pixels }
; for 192 rows, copying each span's word-expanded pixels straight from
; the stream buffer into GVRAM. Unlike V1-V4 the work per call is set by
; the STREAM, not by the code, so one variant measures every span length:
; tools/bench/prep_spans.py generates a stream per span length and
; tools/bench/span.lua times them and fits cycles = A*spans + B*pixels.
; The point of the measurement is A -- the per-span overhead FINDINGS 29
; guessed at 50 cycles -- and how much B degrades from V1's 9.08 when a
; span is too short to burst. Every config covers the whole frame, so
; V5 draws the SAME picture V1 does and can be verified, not just timed.
;
; Bursts are 8 registers (d0-d3/a3-a6 = 32 bytes = 16 pixels), not V1's
; 12: a0/a1/a2 and d4-d7 are all live across a span (stream, row base,
; destination, and three counters). The remainder is copied move.l at a
; time with a leading move.w when it is odd, so a 4-pixel span never
; reaches a movem at all -- which is exactly the case FINDINGS 29's
; full-row-width extrapolation flatters.
;
; V6 the SAME spans with the arithmetic moved into the encoder. V5 measures
; a decoder that is handed (x, npix) and has to work out how to copy it;
; most of its per-span cost is that working-out, and an encoder can do it
; once at build time instead of 12 times a second. V6's record is
; { u32 absolute GVRAM address, u16 jump displacement } -- no row
; structure, no counters, no remainder logic -- and the displacement
; jumps into an unrolled chain of 24-pixel copy units, so a span of any
; supported length is straight-line code with no loop at all.
; GVRAM sits at a fixed $C00000 on every X68000, so absolute destinations
; are a legitimate thing for an encoder to bake in.
;
; Two consequences of the format. Span lengths are multiples of 24
; pixels, and a span may overrun the 256 visible pixels of its row by up
; to 23 -- harmless, because the line stride is 1024 bytes and only the
; first 512 are displayed, so the overrun lands in the invisible half.
; And with row and remainder handling gone, 12 registers are free again
; (d0-d6/a1/a3-a6), which is why the unit is 24 pixels and not V5's 16.
;
; 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
@@ -43,10 +80,14 @@
FLAG = $18000 ; 0 idle / 1 running / $FF done
VAR = $18004 ; variant selector, written by Lua
ITER = $18008 ; iteration count, written by Lua
SPTR = $1800C ; V5 span stream pointer, 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)
ROWS = 192 ; picture rows a V5 stream describes
V6UNIT = 12 ; bytes of code per V6 chain unit
V6MAX = 11 ; chain units = 11*24 = 264 pixels >= one row
org $10000
start:
@@ -58,6 +99,10 @@ start:
beq v2
cmp.l #4,d0
beq v4
cmp.l #5,d0
beq v5
cmp.l #6,d0
beq v6
bra v3
; ---------------------------------------------------------------- V1
@@ -152,5 +197,90 @@ v4blk: movem.l (a0)+,d0-d7 ; 32 bytes = one 4x4 block, expanded
bne v4
bra done
; ---------------------------------------------------------------- V5
; a0 stream, a1 row base, a2 span destination, d7 rows, d6 spans, d5 pixels,
; d4 burst/tail counter. Everything else (d0-d3/a3-a6) is burst payload.
v5: move.l SPTR.l,a0
lea DST0,a1
move.w #ROWS-1,d7
v5row: move.w (a0)+,d6 ; spans in this row
subq.w #1,d6
bmi.s v5eor ; a row may legitimately have none
v5span: move.w (a0)+,d0 ; x, in pixels
add.w d0,d0 ; one pixel = one word
lea 0(a1,d0.w),a2
move.w (a0)+,d5 ; pixels in this span
move.w d5,d4
lsr.w #4,d4 ; 16-pixel bursts
beq.s v5tail
subq.w #1,d4
v5burst: movem.l (a0)+,d0-d3/a3-a6 ; 32 bytes straight out of the stream
movem.l d0-d3/a3-a6,(a2)
lea 32(a2),a2
dbra d4,v5burst
v5tail: moveq #15,d4
and.w d5,d4 ; 0..15 pixels left
beq.s v5eos
lsr.w #1,d4 ; C = odd pixel count
bcc.s v5t2
move.w (a0)+,(a2)+
v5t2: subq.w #1,d4
bmi.s v5eos
v5tl: move.l (a0)+,(a2)+
dbra d4,v5tl
v5eos: dbra d6,v5span
v5eor: lea 1024(a1),a1
dbra d7,v5row
subq.l #1,ITER.l
bne v5
bra done
; ---------------------------------------------------------------- V6
; a0 stream, a2 destination, d7 spans remaining; everything else is payload.
v6: move.l SPTR.l,a0
move.w (a0)+,d7 ; total spans in the frame
subq.w #1,d7
v6span: move.l (a0)+,a2 ; absolute GVRAM destination
move.w (a0)+,d0 ; (V6MAX - units) * V6UNIT, from the encoder
jmp v6ch(pc,d0.w)
v6ch:
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
movem.l (a0)+,d0-d6/a1/a3-a6
movem.l d0-d6/a1/a3-a6,(a2)
lea 48(a2),a2
dbra d7,v6span
subq.l #1,ITER.l
bne v6
bra done
done: move.l #$FF,FLAG.l ; timer stops here
halt: bra.s halt
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Generate V5 span streams for tools/bench/span.lua (FINDINGS 29.5 item 1).
FINDINGS 29 prices a new decoder mode -- a row-linear run of word-expanded
literal pixels, movem.l'd straight from the stream buffer into GVRAM -- at
`4 * (50 + 4L * 9.08)` cycles for a run of L blocks. Both halves of that are
extrapolations: the 50-cycle per-span overhead is hand-derived, and the 9.08
cycles/pixel was measured (FINDINGS 24 V1) at FULL ROW WIDTH with 12-register
bursts, which a short span cannot match. This script builds the stimulus that
replaces both numbers with measured ones.
One stream per span length. Every stream covers the SAME 192x256 picture
completely, so all of them draw an identical, verifiable frame and differ only
in how many spans it is cut into -- which is what lets span.lua regress
cycles = A * spans + B * pixels
across the set and read the per-span overhead off directly.
Two stream formats, both big-endian, both drawing the same frame.
v5 -- a decoder handed (x, npix) that works out the copy itself:
per row, 192 rows in order:
u16 nspans
nspans * { u16 x, u16 npix, npix * u16 pixel }
v6 -- the same spans with that arithmetic moved here, where it is free:
u16 nspans (whole frame; there is no row structure)
nspans * { u32 absolute GVRAM address, u16 jump displacement,
units * 48 bytes of pixels }
Span lengths are multiples of 24 pixels (one chain unit) and the last span
in a row may overrun the visible 256 by up to 23 pixels, which is free: the
line stride is 1024 bytes and only the first 512 are displayed. The jump
displacement selects an entry point into the decoder's unrolled copy chain.
Pixels are word-expanded with the palette index in the low byte; the high byte
is whatever we put there because gvram_w masks it off (x68k_crtc.cpp:501).
"""
import struct, sys
import numpy as np
SRC = sys.argv[1] if len(sys.argv) > 1 else "tmp/frame256.bin"
OUT = sys.argv[2] if len(sys.argv) > 2 else "tmp/spans.bin"
META = OUT.replace(".bin", "_meta.lua")
d = open(SRC, "rb").read()
assert d[:4] == b"DLXR", SRC
W, H = struct.unpack(">HH", d[4:8])
idx = np.frombuffer(d[8+768:8+768+W*H], np.uint8).reshape(H, W)
assert (W, H) == (256, 192), f"{W}x{H}: span bench assumes the 256x192 picture"
# (span length in pixels, x of the first span). 4 px = one 4x4 block wide, the
# case the whole FINDINGS 29 argument turns on; 256 = one span per row, the
# case closest to the V1 measurement it extrapolates from. 16u starts at an
# odd x so its bursts run at addr mod 4 == 2: a claim about the 68000's 16-bit
# bus that costs nothing to test and would be embarrassing to assume.
CONFIGS = [(4, 0), (8, 0), (12, 0), (16, 0), (16, 1), (20, 0), (24, 0),
(32, 0), (48, 0), (64, 0), (128, 0), (256, 0)]
# v6 geometry, and it must match blit.s: 12 registers per movem = 48 bytes =
# 24 pixels per chain unit, 11 units in the chain.
UNITPX, UNITSZ, UNITS = 24, 12, 11
GVRAM, YOFF, STRIDE = 0xC00000, 32, 1024
blob, metas = bytearray(), []
for P, x0 in CONFIGS:
off = len(blob)
nspans = npix = 0
for y in range(H):
cuts = []
x = 0
if x0: # a short leading span to shift the phase
cuts.append((0, x0)); x = x0
while x < W:
n = min(P, W - x)
cuts.append((x, n)); x += n
blob += struct.pack(">H", len(cuts))
for x, n in cuts:
blob += struct.pack(">HH", x, n)
blob += idx[y, x:x+n].astype(">u2").tobytes()
nspans += 1; npix += n
metas.append(dict(name=f"{P}{'u' if x0 else ''}", p=P, x0=x0, off=off,
len=len(blob)-off, nspans=nspans, npix=npix, var=5))
# v6: one config per chain depth, so the fit sees spans from 24 to 264 pixels.
for units in range(1, UNITS+1):
P = units * UNITPX
off = len(blob)
nspans = npix = 0
rows = []
for y in range(H):
x = 0
while x < W:
rows.append((y, x)); x += P
blob += struct.pack(">H", len(rows))
for y, x in rows:
blob += struct.pack(">IH", GVRAM + (YOFF+y)*STRIDE + x*2,
(UNITS-units)*UNITSZ)
# Pad the last span of a row past the visible width; the overrun lands
# in the undisplayed half of the line.
px = np.concatenate([idx[y, x:x+P], np.zeros(max(0, x+P-W), np.uint8)])
blob += px.astype(">u2").tobytes()
nspans += 1; npix += P
metas.append(dict(name=f"{P}", p=P, x0=0, off=off, len=len(blob)-off,
nspans=nspans, npix=npix, var=6))
open(OUT, "wb").write(blob)
with open(META, "w") as f:
f.write("-- generated by tools/bench/prep_spans.py -- do not edit\nreturn {\n")
f.write(f" W={W}, H={H}, total={len(blob)},\n configs = {{\n")
for m in metas:
f.write(" {{var={var}, name=\"{name}\", p={p}, x0={x0}, off={off},"
" len={len}, nspans={nspans}, npix={npix}}},\n".format(**m))
f.write(" },\n}\n")
print(f"{SRC} {W}x{H} -> {OUT} {len(blob)} B, {len(metas)} configs")
for m in metas:
print(f" v{m['var']} span {m['name']:>4} px: {m['nspans']:6d} spans, "
f"{m['npix']:6d} px, {m['len']:7d} B "
f"(+{100*m['len']/(2*W*H)-100:.1f}% over bare pixels)")
+218
View File
@@ -0,0 +1,218 @@
-- Measure the cost of a row-linear literal SPAN on the 68000 (FINDINGS 29.5.1).
--
-- FINDINGS 29 proposes one new decoder mode and prices it at
-- 4 * (50 + 4L*9.08) cycles for a run of L blocks
-- then labels the whole section DERIVED, NOT MEASURED, because both terms are
-- extrapolations: the 50-cycle per-span overhead is hand-derived, and the 9.08
-- cycles/pixel is a FINDINGS 24 measurement taken at FULL ROW WIDTH with
-- 12-register bursts. A 4-pixel span cannot burst at all. Everything session
-- 8 wants to do downstream optimises over the mode set this number decides, so
-- it goes first.
--
-- Method: v5 in tools/bench/blit.s walks a stream of per-row span records and
-- copies each span into GVRAM. tools/bench/prep_spans.py emits one stream per
-- span length, every one covering the same whole frame, so the work differs
-- only in how finely it is cut. Regressing
-- cycles = A*spans + B*pixels
-- over the set reads A (the per-span overhead) and B (the per-pixel cost)
-- straight off, and every config also draws a verifiable picture: the frame is
-- cleared before each run and snapshotted after, so a config that timed fast
-- by not writing pixels fails tools/bench/verify_frame256.py.
--
-- MEASUREMENT SCOPE, unchanged from blit.lua: MAME's gvram_w carries no timing,
-- so these are 68000 instruction cycles against zero-wait-state memory -- a
-- LOWER BOUND on real hardware. Interrupts are masked (SR=$2700).
M = manager.machine
SP = M.devices[":maincpu"].spaces["program"]
local function findfile(n)
for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do
local f = io.open(p,"rb"); if f then f:close(); return p end
end
error(n.." not found")
end
local MODE = loadfile(findfile("crtc_mode.lua"))()
local SPEC = loadfile("spans_meta.lua")()
local FLAG, VAR, ITER, SPTR = 0x18000, 0x18004, 0x18008, 0x1800C
local STREAM = 0x90000
local GVRAM, GPAL = 0xC00000, 0xE82000
local CPUHZ = 10000000 -- x68k.cpp:1133, 40_MHz_XTAL/4
local FRAME12 = CPUHZ / 12
local code do local f=io.open("blit.bin","rb"); code=f:read("a"); f:close() end
local blob do local f=io.open("spans.bin","rb"); blob=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 = 9
local YOFF = (MODE.height - H) // 2
-- Identical packing to blit.lua / show_frame256.lua: shared LSB I 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("[SPAN] "..s) end
-- 148 KB one byte at a time is 148k Lua->C calls; longwords cut that by four.
local function push(addr, s, from, len)
local i, n = from, len
while n >= 4 do
SP:write_u32(addr, (string.unpack(">I4", s, i)))
addr, i, n = addr+4, i+4, n-4
end
while n > 0 do
SP:write_u8(addr, string.byte(s,i)); addr, i, n = addr+1, i+1, n-1
end
end
local function clear_picture() -- so a config that writes nothing is caught
for y = YOFF, YOFF+H-1 do
local base = GVRAM + y*1024
for x = 0, MODE.width-1, 2 do SP:write_u32(base + x*2, 0) end
end
end
local function setup()
MODE.apply(SP)
for y = 0, MODE.height-1 do
local base = GVRAM + y*1024
for x = 0, MODE.width-1, 2 do SP:write_u32(base + x*2, 0) 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
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
P(string.format("loaded blit.bin=%d B, %d span configs, picture %dx%d at yoff=%d",
#code, #SPEC.configs, W, H, YOFF))
end
local function launch(cfg)
push(STREAM, blob, cfg.off+1, cfg.len)
clear_picture()
-- ~4 emulated seconds per config: 1/55.46 s granularity costs under 0.5%.
local est = cfg.nspans*(cfg.var == 6 and 50 or 60) + cfg.npix*10
cfg.iter = math.max(4, math.floor(4*CPUHZ/est))
SP:write_u32(FLAG, 0)
SP:write_u32(VAR, cfg.var)
SP:write_u32(ITER, cfg.iter)
SP:write_u32(SPTR, STREAM)
local cpu = M.devices[":maincpu"]
cpu.state["SR"].value = 0x2700
cpu.state["SP"].value = 0x8000
cpu.state["PC"].value = 0x10000
end
local results = {}
local function report(cfg, dt)
local cyc = dt * CPUHZ / cfg.iter
results[#results+1] = {cfg=cfg, cyc=cyc}
P(string.format("v%d span %4s px: %5d spans %6d px %d iter in %.4f s -> %8.0f cyc/frame"
.." %5.2f cyc/px %5.1f%% of a 12fps frame",
cfg.var, cfg.name, cfg.nspans, cfg.npix, cfg.iter, dt, cyc,
cyc/cfg.npix, 100*cyc/FRAME12))
end
-- Ordinary least squares on cycles = A*spans + B*pixels, no intercept: the
-- 192 row headers and the outer loop are the only work not attributable to a
-- span or a pixel, and at ~10 cycles a row they are 0.2% of the smallest run.
local function fit(rs)
local ss,sp,pp,sy,py = 0,0,0,0,0
for _,r in ipairs(rs) do
local s,p,y = r.cfg.nspans, r.cfg.npix, r.cyc
ss=ss+s*s; sp=sp+s*p; pp=pp+p*p; sy=sy+s*y; py=py+p*y
end
local det = ss*pp - sp*sp
return (sy*pp - py*sp)/det, (ss*py - sp*sy)/det
end
local step, st, t0 = 0, "boot", nil
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(SPEC.configs[1]); st, t0 = "running", nil; 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(SPEC.configs[step], t - (t0 or t))
st = "snap"; return
end
if t > 300 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
return
end
if st == "snap" then
M.video:snapshot() -- verified by tools/bench/span.sh
step = step + 1
if SPEC.configs[step] then
launch(SPEC.configs[step]); st, t0 = "running", nil
else
st = "finish"
end
return
end
if st == "finish" then
P("---- measured (instruction cycles only; real GVRAM adds wait states) ----")
for _,v in ipairs{5,6} do
local sub = {}
for _,r in ipairs(results) do if r.cfg.var == v then sub[#sub+1] = r end end
-- v5's fit is over its BURSTING configs only (span length a multiple of
-- the 16-pixel burst). Mixing the remainder-path configs in would hide
-- the two costs behind one bad line; they are reported against the fit
-- instead, which is where the remainder shows up as error.
local fitset = {}
for _,r in ipairs(sub) do
if v == 6 or r.cfg.p % 16 == 0 then fitset[#fitset+1] = r end
end
local A, Bp = fit(fitset)
P(string.format("-- v%d: cycles = %.1f per span + %.3f per pixel"
.." (fitted on %d of %d configs)", v, A, Bp, #fitset, #sub))
for _,r in ipairs(sub) do
local model = A*r.cfg.nspans + Bp*r.cfg.npix
P(string.format(" span %4s px %8.0f cyc %5.2f cyc/px %6.1f cyc/span"
.." vs fit %+6.1f%%", r.cfg.name, r.cyc,
r.cyc/r.cfg.npix, r.cyc/r.cfg.nspans, 100*(model/r.cyc-1)))
end
-- What the mode decision actually needs: a run of L horizontally
-- adjacent 4x4 blocks is 4 spans of 4L pixels, one per pixel row, and
-- v6 pads each to a whole 24-pixel chain unit.
local line = " -> cycles per 4x4 block in a run of L blocks: "
for _,L in ipairs{1,2,4,8,16,64} do
local px = 4*L
if v == 6 then px = math.ceil(px/24)*24 end
line = line..string.format("L=%d %.0f ", L, 4*(A + px*Bp)/L)
end
P(line.."(V1 is 299.9)")
if v == 5 then
P(" v5's fit only holds where 4L is a whole number of 16-pixel bursts.")
P(" L=1 and L=2 are extrapolations its own measured spans"
.." contradict: 721 and 482.")
end
end
P(" FINDINGS 29 assumed 50.0 per span + 9.080 per pixel, 4 spans per run")
M:exit()
end
end)
if not ok then print("[SPAN] LUA ERROR: "..tostring(err)); M:exit() end
end)
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Measure the cost of a row-linear literal span on the 68000 (FINDINGS 30).
# ~25 s. Run from the repo root. Needs tmp/frame256.bin (check.sh makes it).
#
# NOT part of check.sh, for the same reason blit.s is not: the output is a wall
# timing, so gating on it would make the green light host-sensitive. What IS
# gated here is correctness -- all 23 configs must draw a pixel-exact frame,
# which is what stops a config timing fast by quietly writing nothing.
set -e
cd "$(dirname "$0")/../.."
[ -f tmp/frame256.bin ] || { echo "need tmp/frame256.bin -- run tools/bench/check.sh"; exit 2; }
python3 tools/bench/prep_spans.py
tools/vasm/vasmm68k_mot -Fbin -o tmp/blit.bin tools/bench/blit.s > /dev/null
mkdir -p tmp/snap_span
rm -f tmp/snap_span/x68000/*.png
( cd tmp && SDL_VIDEODRIVER=dummy timeout -k 5 1800 mame x68000 -bios ipl10 \
-ramsize 2M -video soft -window -sound none -nothrottle -plugins \
-autoboot_script ../tools/bench/span.lua \
-snapshot_directory ./snap_span -snapview native -seconds_to_run 150 \
> span.log 2>&1 )
grep -a "^\[SPAN\]" tmp/span.log
n=0
for f in tmp/snap_span/x68000/*.png; do
python3 tools/bench/verify_frame256.py "$f" > /dev/null || {
echo "FAIL: $f is not pixel-exact"; python3 tools/bench/verify_frame256.py "$f"; exit 1; }
n=$((n+1))
done
[ "$n" -eq 23 ] || { echo "FAIL: $n snapshots, expected 23"; exit 1; }
echo "OK $n/23 span configs drew a pixel-exact frame"
+6 -3
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""Regression test for the 256x256 CRTC mode (docs/FINDINGS 23).
Checks tmp/snap256/x68000/0000.png against tmp/frame256.bin:
Checks a native snapshot (default tmp/snap256/x68000/0000.png, override with
argv[1] -- tools/bench/span.lua verifies twelve of them) against
tmp/frame256.bin:
1. native snapshot is 256x512 -- 256 dots, and 512 active scanlines of a
568-line 31.5kHz raster carrying 256 double-scanned graphics rows
2. double-scan pairing is (1,2),(3,4),... -- MAME halves the ABSOLUTE
@@ -16,7 +18,8 @@ import struct, sys
import numpy as np
from PIL import Image
s = np.asarray(Image.open("tmp/snap256/x68000/0000.png").convert("RGB")).astype(int)
snap = sys.argv[1] if len(sys.argv) > 1 else "tmp/snap256/x68000/0000.png"
s = np.asarray(Image.open(snap).convert("RGB")).astype(int)
d = open("tmp/frame256.bin", "rb").read()
W, H = struct.unpack(">HH", d[4:8])
pal = np.frombuffer(d[8:8+768], np.uint8).reshape(256, 3).astype(int)
@@ -53,7 +56,7 @@ if fail:
sys.exit(1)
mse = ((act - pal[idx]) ** 2).mean()
print(f"OK 256x512 native, double-scan exact, active {W}x{H} pixel-exact, "
print(f"OK {snap}: 256x512 native, double-scan exact, active {W}x{H} pixel-exact, "
f"letterbox true black")
print(f" palette ceiling vs 24-bit palettised source: "
f"{10*np.log10(255**2/mse):.2f} dB ({(I==0).sum()}/256 entries use I=0)")