Files
prosolis c520a89e14 Measure the finer chain tail: 84/120 becomes 18/120, and the derivation was right by cancellation
blit.s gains v7 -- v6's 24-pixel movem chain plus a second chain whose unit is
one `move.l (a0)+,(a2)+`. Measured over 13 span lengths by span.sh, every config
pixel-exact:

    cycles = 66.0 per span + 9.143 per COARSE pixel + 9.978 per FINE pixel

fitting all 13 to within 0.2%. v5 and v6 re-measure to FINDINGS 30 exactly, so
the harness has not drifted underneath the new variant.

Rescored against the same scsi window and the same additive model, v7 takes
84/120 frames over budget to 18/120 -- exactly what FINDINGS 39.4 derived, and
that agreement is two cancelling errors: the derivation's 2-register movem tail
is 29% too dear per pixel, and its "nothing per span" for the second chain entry
is 22.3 clocks too cheap. The plain post-incrementing move.l is the right tail
instruction, and it makes the padding quantum 2 pixels, which a run of 4x4
blocks pads to exactly zero.

The DMAC stays dropped on a measurement now rather than an argument: v7 takes
back 37 of the 43 frames the array chain would, with no reserved channel and no
timing neither emulator here can verify. Break-even against all-V1 moves from
L=4 blocks to L=2.

The fine displacement is carried mid-stream rather than in the span record, so
the decoder holds nothing across the copy and keeps all 12 payload registers --
which is the whole reason the coarse unit is 24 pixels.

span.sh is now -seconds_to_run 200 (30 s wall, 36 configs) and takes its
expected snapshot count from the generated metadata instead of a literal 23.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-23 19:02:23 -07:00

171 lines
7.9 KiB
Python

#!/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.
v7 -- v6 plus a second, FINER chain for the tail (FINDINGS 39.4). Padding to
v6's 24-pixel quantum wastes ~11 pixels on an average span, and FINDINGS
39.3 attributes 86% of the DMAC array-chain's advantage over v6 to it. A
v7 span is 24*c + 2*f pixels, so the quantum is 2 and a run of 4x4 blocks
(always a multiple of 4 pixels) pads to NOTHING:
u16 nspans
nspans * { u32 absolute GVRAM address, u16 coarse displacement,
c * 48 bytes of pixels,
u16 fine displacement, f * 4 bytes of pixels }
The fine displacement is in the STREAM rather than the record because that
is what lets the decoder keep all 12 payload registers: the coarse chain
falls out into a `move.w (a0)+,d0 / jmp` with d0 dead and a0 pointing at
it. Costed here as an 8-byte record, since it is 2 more bytes a span.
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
# v7 geometry, and it must match blit.s: coarse unit as v6, fine unit is one
# `move.l (a0)+,(a2)+` = 2 bytes of code = 2 pixels, 11 of them (22 px > 24).
FINEPX, FINESZ, FINES = 2, 2, 11
GVRAM, YOFF, STRIDE = 0xC00000, 32, 1024
# v7 span lengths, in pixels. Multiples of 4 (a real span is a run of 4x4
# blocks), chosen so the fine remainder P mod 24 takes every value a real span
# can: 0, 4, 8, 12, 16, 20. 4/8/12/16/20 are pure-fine, 24/48/72/120/240 are
# pure-coarse, the rest mix -- which is what makes the three-term fit
# cycles = A*spans + Bc*coarse_px + Bf*fine_px identifiable.
V7CONFIGS = [4, 8, 12, 16, 20, 24, 28, 44, 48, 72, 100, 120, 256]
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,
cpx=npix, fpx=0, 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, cpx=npix, fpx=0, var=6))
# v7: same tiling, but the span is cut at a 2-pixel quantum instead of 24.
for P in V7CONFIGS:
units, fine = divmod(P, UNITPX)
assert fine % FINEPX == 0 and fine // FINEPX <= FINES, P
assert units <= UNITS, P
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:
assert x + P <= STRIDE // 2, (P, x) # the overrun must stay on the line
blob += struct.pack(">IH", GVRAM + (YOFF+y)*STRIDE + x*2,
(UNITS-units)*UNITSZ)
px = np.concatenate([idx[y, x:x+P], np.zeros(max(0, x+P-W), np.uint8)])
blob += px[:units*UNITPX].astype(">u2").tobytes()
blob += struct.pack(">H", (FINES - fine//FINEPX)*FINESZ)
blob += px[units*UNITPX:].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,
cpx=nspans*units*UNITPX, fpx=nspans*fine, var=7))
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}, cpx={cpx},"
" fpx={fpx}}},\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)")