Files
Dragon-s-Lair-X68k/tools/analysis/14_dmac_chain.py
T
prosolis c5ca56330e A second emulator agrees, the bus was never counted, and the DMAC loses by one clock
Three things, and the last one reversed itself when the datasheet arrived.

A SECOND EMULATOR. tools/bench/c68k/ links px68k's C68K core into a headless
harness -- no SDL, no ROMs, no emulated machine, because the decoder touches
nothing but RAM, the control block and GVRAM. decode.s is now pixel-exact under
two independent CPU cores, and cycle-table error against MAME is bounded at
3.3%, running against us. MAME 0.277's M68000 turns out to be the MICROCODE
core, not Musashi (m68000.lst + m68000gen.py), so this is two structurally
different timing models agreeing rather than two tables. FINDINGS 28.8's "V4
costs more than RAW" reproduces independently. FINDINGS 37.

THE BUS. 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. The
decoder occupies 86.7% of it and PREFETCH IS 62% OF THAT TRAFFIC, so a data-only
count understates occupancy by 2x. Two sources check each other: c68k_bench
counts every bus callback exactly, and a static walk of decode.lst supplies the
prefetch no emulator here can report. The walk reproduces the measured data half
to 0.04%, which is what licenses its prefetch half, and 15_bus_occupancy.py is a
gate rather than a report because every bus figure depends on that check.
FINDINGS 38.

THE DMAC CHAIN LOSES. FINDINGS 29.6 named it the one uncosted lever. Costed from
bus arithmetic -- a read cycle plus a write cycle, 8 clocks a pixel -- it scored
1/120 frames over budget against the v6 span's 10/120 and looked decisive. Then
the MC68450 manual (Motorola Jul 1989, now at ~/src/mc68450.pdf): Fig 4-25 sheet
4 puts a dual-address word between two 16-bit ports at 9 CLOCKS, because note 2
gives the DMAC 4-clock reads and 5-clock WRITES. The 68000 writes in 4.

    DMAC   9.000 clocks/pixel   datasheet
    v6     9.152 clocks/pixel   measured, FINDINGS 30

1.7%. Scored additively, 86% of what remains of the DMAC's advantage is v6's
24-pixel padding quantum -- a property of its unrolled movem chain, fixable in
software with a finer tail chain, worth 55/120 -> 18/120 against the DMAC's
12/120. Recommendation: fix the quantum, drop the DMAC. Six frames does not buy
a reserved channel, a two-region container layout and a timing dependency
neither emulator here can verify. The container is identical either way -- v6's
record and an HD63450 chaining entry are both 6 bytes, so the chain array IS the
span table -- so nothing is foreclosed. FINDINGS 39.

TWO CORRECTIONS TO MY OWN WORK IN THE SAME SESSION:

- I argued FINDINGS 35's flat CPU debit for the disk was too pessimistic and
  rescored the window at 53/120 with max(CPU, bus). Wrong. 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 and 14_dmac_chain.py reproduces it exactly. What 86.7% occupancy really
  says is that there is almost no room to overlap anything. FINDINGS 38.3.
- The first DMAC costing was derived where a primary source existed. Both wrong
  answers were confident and both were caught by reading the manual.

Also landed:
- FINDINGS 5's 8 clocks/word for the SCSI DMA, STATUS's own "most load-bearing
  unmeasured number", is now bracketed by the datasheet: 5 clk/word with the bus
  held, ~12 if the DMAC arbitrates per word. 8 is a supported midpoint, and
  which end applies is a player design decision worth 7 clocks a word on a
  480 KB/s stream. FINDINGS 39.7.
- check.sh gains two gates: the C68K pixel-exact decode (seconds, no MAME) and
  the bus-model self-check. Both skip cleanly without a px68k checkout.
- spanned blocks are now charged their mode-map dispatch, which FINDINGS 30.7
  flagged as uncounted in 12_span_tradeoff.py.
- MAME timed runs must be budgeted by WALL CLOCK, not -seconds_to_run: this box
  runs x68000 at ~0.033x realtime and two runs were killed by their own timeout.
  That is why the all-RAW cell in 37.3 is empty. The C68K harness does the same
  work in seconds because it emulates a CPU and not a machine.

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

215 lines
9.9 KiB
Python

#!/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")