ROADMAP P5. The loader moved in session 21 and the frame clock in 22; the ring producer was the last policy living outside the machine. src/player/ring.i does `aligned` placement, the descriptor ring, a prefill, 51.2's slack rule and a seek, and the host keeps only the transport. It needed a container change. `aligned` asks whether the next record fits before the end of the ring -- a length asked BEFORE the record is fetched -- and every reader in this tree answered that by walking the frame stream, which is exactly what a player streaming off a disc cannot do. DLX4 carries nframes u16 record lengths in the scene header. Frame payloads are byte-identical to the DLX3 encode, so no fitted constant moves; the scene header goes 5,920 to 6,164 B. The producer reproduces the host's tiling exactly: 18 wraps, 14.7 KB mean hole, pixel-exact, a third independent implementation of the same policy. What it exposed is bigger than the item. A channel only moves bytes while it has a request and only the CPU can issue one, so the disc stands still between records by an amount the PLAYER sets, not the medium -- and no host-filled run could see it. At 488 KB/s in a 256 KB ring a one-deep request queue gives away 6.8% of the pipe and underruns 59 of 120 frames; two-deep gives away 3.4% and underruns none. The container's whole surplus over the wire is 8.7%, so the player's own loop was spending most of the slack a branch point saves up. Prefill is the weaker lever: six records of it still leaves 24 underruns. Three silent bugs are recorded in FINDINGS 55.7 -- all produced wrong pixels or a desync rather than a fault -- plus a rig one: MAME renders a screen line by line, so snapshotting the frame the decoder finished in captures a tear that reads exactly like a decoder bug. check.sh gains the machine-owned ring and a seek with the decode after it. decode.bin is unchanged at 1,296 B and a host-filled run executes none of the new code, so every FINDINGS 49/51 figure stands. ALL GREEN before and after. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
306 lines
13 KiB
Python
306 lines
13 KiB
Python
"""What the player's own fill loop costs the pipe (ROADMAP P5, FINDINGS 55).
|
|
|
|
19_ring_stream.py and 20_seek_slack.py model a ring whose producer is free to
|
|
act whenever it likes: bytes arrive at a rate and the only questions are where
|
|
they go and whether the ring can hold them. That is what a HOST-filled ring is,
|
|
and it is what every delivery figure in FINDINGS 49 and 51 was measured on.
|
|
|
|
A player has no host. The 68000 owns the ring (src/player/ring.i), and it can
|
|
only act when it is not decoding -- which turns the producer into a consumer of
|
|
the same resource the decoder is short of, and puts an idle CHANNEL between
|
|
every pair of records:
|
|
|
|
a transfer ends -> the disc has nothing to do -> the CPU next polls
|
|
-> it issues -> the disc starts again
|
|
|
|
The gap in the middle is bytes the medium could have delivered and did not, and
|
|
no rate table in this tree contains it. Its size is set by the PLAYER: how many
|
|
requests it may have outstanding (a DMAC channel takes one at a time; two slots
|
|
mean the next is already queued when the current lands), and when it polls.
|
|
|
|
This is that model, written from record sizes and per-frame decode costs, and
|
|
sharing no code with the Lua rig it is compared against -- the same arrangement
|
|
as 49.4 and 51.5. The rig drives a real 68000 through a real ring and is the
|
|
measurement; this says where to point it and what to expect.
|
|
|
|
python3 tools/analysis/24_ring_owner.py <container.dlx> --kbps R [R ...]
|
|
[--ring KB] [--qdepth N [N ...]] [--prefill RECORDS]
|
|
[--cadence raster|nominal]
|
|
|
|
`--kbps` is REQUIRED and has no default, for the reason FINDINGS 50 gives.
|
|
|
|
THE INDEX IS READ FROM THE CONTAINER, not derived by walking it. A DLX4
|
|
container carries nframes u16 record lengths in its scene header precisely
|
|
because the producer needs the length of a record it has not fetched; this model
|
|
reads the same table the 68000 does, so a container whose index disagreed with
|
|
its stream would be caught here as well as in dlx.py's constructor.
|
|
"""
|
|
import sys, os, argparse
|
|
sys.path.insert(0, "tools/encoder")
|
|
import numpy as np
|
|
from dlx import DLX
|
|
import vq_hybrid as H
|
|
import spans as SP
|
|
|
|
CPUHZ = 10_000_000
|
|
HFREQ = 31500 # lines/s in the 31.5 kHz modes (src/player/clock.i)
|
|
VTOTAL = 568 # CRTC R04+1 in the 256x256 mode (tools/bench/crtc_mode.lua)
|
|
CLK_ISR = 181.35 # clocks per V-DISP, MEASURED (FINDINGS 54.3)
|
|
AUDIO_KBPS = 7.8 # ratectl.AUDIO_KBPS; the pipe carries it too
|
|
|
|
|
|
def frame_costs(d):
|
|
"""Per-frame decode cost in 68000 clocks: blocks plus v7 spans.
|
|
|
|
Both halves come from the encoder's own measured constants -- H.cycles is
|
|
the single source 11_cpu_budget.py uses, and SP.clocks is the v7 fit of
|
|
FINDINGS 40 -- so this is the same cost model the rate controller fits `mu`
|
|
against, applied to the emitted container rather than to a candidate.
|
|
"""
|
|
out = []
|
|
for f in range(d.nframes):
|
|
c = H.cycles(d.modes(f))
|
|
for _, _, px in d.spans(f)[0]:
|
|
c += SP.clocks(len(px))
|
|
out.append(c)
|
|
return np.array(out)
|
|
|
|
|
|
def ticks(n, fps):
|
|
"""Frame tick times from src/player/clock.i's divider, or a nominal clock.
|
|
|
|
The player's clock is the raster with a remainder: a frame gets 4 refreshes
|
|
(72.13 ms) or 5 (90.16 ms) and there is no 83.33 ms frame (FINDINGS 54.4).
|
|
A model that hands out uniform slots gives every frame 13.4% more time than
|
|
the short one really has, so the cadence is reproduced here rather than
|
|
averaged away.
|
|
"""
|
|
R = VTOTAL / HFREQ # one refresh, seconds
|
|
acc, out, t = 0, [0.0], 0.0
|
|
while len(out) < n:
|
|
t += R
|
|
acc += fps * VTOTAL
|
|
if acc >= HFREQ:
|
|
acc -= HFREQ
|
|
out.append(t)
|
|
return np.array(out)
|
|
|
|
|
|
def simulate(rec, dec, tick, bps, ringsz, qdepth, prefill):
|
|
"""One pass of the machine-owned ring. Returns a dict of instruments.
|
|
|
|
The rules are src/player/ring.i's, stated as events:
|
|
* the CPU polls whenever it is NOT decoding -- the pace wait and the
|
|
record wait both call ring_poll, and nothing else in the frame does;
|
|
* a request occupies a slot until it is RETIRED, which happens at a poll,
|
|
so the queue is measured against retirement and not against completion;
|
|
* placement is `aligned`: a record that will not fit before the end of the
|
|
ring restarts at the base, and only if the base is free;
|
|
* the channel serves one transfer at a time, in order.
|
|
"""
|
|
n = len(rec)
|
|
# ring state, in ring offsets
|
|
wcur = rcur = 0
|
|
rq = 0 # next record to request
|
|
retired = 0 # requests retired (== FR_HEAD)
|
|
consumed = 0 # records the decoder has finished (== FR_TAIL)
|
|
inflight = [] # [(record, done_time)] in issue order
|
|
chan_free = 0.0 # when the channel finishes what it has
|
|
gaps, gap_tot, gap_max = 0, 0.0, 0.0
|
|
busy = 0.0
|
|
full_refusals = 0
|
|
started = False # the first transfer has no gap before it
|
|
|
|
def live_empty():
|
|
return rq == consumed
|
|
|
|
def place(length):
|
|
"""Where the next record goes: (offset, hole) or None if it cannot."""
|
|
nonlocal full_refusals
|
|
if live_empty():
|
|
if wcur + length <= ringsz:
|
|
return wcur, 0
|
|
return 0, ringsz - wcur
|
|
if rcur == wcur:
|
|
return None # completely full
|
|
if rcur < wcur: # free is [wcur, SZ) then [0, rcur)
|
|
if wcur + length <= ringsz:
|
|
return wcur, 0
|
|
if length <= rcur:
|
|
return 0, ringsz - wcur
|
|
return None
|
|
if wcur + length <= rcur: # live wraps; free is [wcur, rcur)
|
|
return wcur, 0
|
|
return None
|
|
|
|
def issue(now):
|
|
"""Issue as many requests as the queue and the ring allow, at `now`."""
|
|
nonlocal wcur, rcur, rq, chan_free, gaps, gap_tot, gap_max, busy
|
|
nonlocal full_refusals, started
|
|
while rq < n and (rq - retired) < qdepth:
|
|
p = place(rec[rq])
|
|
if p is None:
|
|
full_refusals += 1
|
|
return
|
|
off, _hole = p
|
|
if live_empty():
|
|
rcur = off
|
|
start = max(now, chan_free)
|
|
if started:
|
|
g = start - chan_free
|
|
if g > 1e-12:
|
|
gaps += 1
|
|
gap_tot += g
|
|
gap_max = max(gap_max, g)
|
|
started = True
|
|
dur = rec[rq] / bps
|
|
busy += dur
|
|
chan_free = start + dur
|
|
inflight.append((rq, chan_free))
|
|
wcur = off + rec[rq]
|
|
rq += 1
|
|
|
|
def retire(now):
|
|
"""Publish every transfer that has landed by `now`. In order."""
|
|
nonlocal retired
|
|
while inflight and inflight[0][1] <= now:
|
|
inflight.pop(0)
|
|
retired += 1
|
|
|
|
def advance_reader():
|
|
"""Step rcur over the records the decoder has finished with."""
|
|
nonlocal rcur
|
|
i = consumed_seen[0]
|
|
while i < consumed:
|
|
end = rcur + rec[i]
|
|
if i + 1 < n and end + rec[i + 1] > ringsz:
|
|
end = 0
|
|
rcur = end
|
|
i += 1
|
|
consumed_seen[0] = i
|
|
|
|
consumed_seen = [0]
|
|
|
|
# ---- prefill. The decoder is not running, so the CPU polls continuously
|
|
# and the channel never waits for it: this is the one part of a scene where
|
|
# the request loop costs nothing.
|
|
now = 0.0
|
|
while retired < prefill and rq < n:
|
|
issue(now)
|
|
if not inflight:
|
|
break
|
|
now = inflight[0][1]
|
|
retire(now)
|
|
prefill_done = now
|
|
t0 = now
|
|
|
|
underruns, worst_late, noidle = 0, 0.0, 0
|
|
slack_series = []
|
|
for i in range(n):
|
|
deadline = t0 + tick[i]
|
|
# TWO WAYS A FRAME CAN START LATE, AND THEY ARE NOT THE SAME FAILURE.
|
|
# The decoder reaches the record wait at max(its own finish, the tick):
|
|
# if it got there after the tick, the PREVIOUS frame used its whole slot
|
|
# and this is the CPU (54.4's cadence). If it got there on time and the
|
|
# record was not resident, that is the PIPE. The rig counts them
|
|
# separately -- NO IDLE and UNDERRUNS -- so conflating them here would
|
|
# have made the model disagree with it for a reason that is not about
|
|
# delivery at all.
|
|
if now > deadline + 1e-9:
|
|
noidle += 1
|
|
arrive = max(now, deadline)
|
|
now = arrive
|
|
# the record wait: the CPU polls, so it retires and issues while it waits
|
|
starved = retired <= i
|
|
while retired <= i:
|
|
issue(now)
|
|
if not inflight:
|
|
break
|
|
now = max(now, inflight[0][1])
|
|
retire(now)
|
|
if starved:
|
|
underruns += 1
|
|
worst_late = max(worst_late, now - arrive)
|
|
slack_series.append(retired - consumed)
|
|
issue(now)
|
|
# ---- decode. No polls: whatever the channel finishes now waits.
|
|
now += dec[i] / CPUHZ
|
|
consumed += 1
|
|
advance_reader()
|
|
retire(now)
|
|
issue(now)
|
|
# ---- idle until the next tick. The CPU polls throughout, so every
|
|
# completion is retired and every free slot is refilled at once.
|
|
nxt = t0 + tick[i + 1] if i + 1 < n else now
|
|
while inflight and inflight[0][1] < nxt:
|
|
now = max(now, inflight[0][1])
|
|
retire(now)
|
|
issue(now)
|
|
now = max(now, min(nxt, now))
|
|
span = max(now - t0, 1e-9)
|
|
return dict(underruns=underruns, worst_late=worst_late, gaps=gaps,
|
|
noidle=noidle,
|
|
gap_tot=gap_tot, gap_max=gap_max, busy=busy, span=span,
|
|
ceiling=max(slack_series), mean_slack=float(np.mean(slack_series)),
|
|
full=full_refusals, prefill_s=prefill_done)
|
|
|
|
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("container")
|
|
ap.add_argument("--kbps", type=float, nargs="+", required=True,
|
|
help="delivery rates to model. REQUIRED: this tree has no "
|
|
"default rate (FINDINGS 50)")
|
|
ap.add_argument("--ring", type=int, default=256, help="ring size, KB")
|
|
ap.add_argument("--qdepth", type=int, nargs="+", default=[1, 2],
|
|
help="requests the player may have outstanding")
|
|
ap.add_argument("--prefill", type=int, default=2, help="records before release")
|
|
ap.add_argument("--cadence", choices=["raster", "nominal"], default="raster")
|
|
a = ap.parse_args()
|
|
|
|
d = DLX(a.container)
|
|
if not d.has_index:
|
|
sys.exit(f"{a.container} is DLX{d.version}: this model reads the record "
|
|
f"index the player reads, and only DLX4 carries one.")
|
|
rec = np.array([q * 4 for q in d.index], np.int64)
|
|
dec = frame_costs(d) + CLK_ISR * (HFREQ / VTOTAL) / d.fps # + the clock's own
|
|
if a.cadence == "raster":
|
|
tick = ticks(d.nframes + 1, d.fps)
|
|
else:
|
|
tick = np.arange(d.nframes + 1) / d.fps
|
|
|
|
wire = rec.mean() * d.fps / 1024
|
|
print(f"{a.container}: {d.nframes} records, {rec.mean()/1024:.1f} KB mean, "
|
|
f"{rec.max()/1024:.1f} KB max, wire {wire:.1f} KB/s")
|
|
print(f" index: {2*d.nframes:,} B of scene header -- read, not walked")
|
|
print(f" decode: mean {dec.mean():,.0f} clk/frame ({100*dec.mean()/(CPUHZ/d.fps):.1f}% "
|
|
f"of a mean slot), p90 {np.percentile(dec,90):,.0f}")
|
|
print(f" cadence: {a.cadence}"
|
|
+ (" (4 or 5 refreshes a frame, 72.13/90.16 ms -- FINDINGS 54.4)"
|
|
if a.cadence == "raster" else " (uniform 1/fps slots)"))
|
|
print(f" ring {a.ring} KB, prefill {a.prefill} records\n")
|
|
|
|
hdr = (f"{'pipe':>8} {'Q':>2} {'idle':>9} {'gaps':>5} {'worst':>8} "
|
|
f"{'under':>7} {'late by':>8} {'noidl':>5} {'ceil':>5} {'mean':>5} "
|
|
f"{'refus':>6}")
|
|
print(hdr)
|
|
print("-" * len(hdr))
|
|
for kb in a.kbps:
|
|
bps = (kb - AUDIO_KBPS) * 1024
|
|
for q in a.qdepth:
|
|
r = simulate(rec, dec, tick, bps, a.ring * 1024, q, a.prefill)
|
|
print(f"{kb:8.0f} {q:2d} {100*r['gap_tot']/r['span']:8.1f}% "
|
|
f"{r['gaps']:5d} {r['gap_max']*1000:7.1f}ms "
|
|
f"{r['underruns']:3d}/{d.nframes:<3d} {r['worst_late']*1000:7.1f}ms "
|
|
f"{r['noidle']:5d} {r['ceiling']:5d} {r['mean_slack']:5.1f} "
|
|
f"{r['full']:6d}")
|
|
print()
|
|
print("idle = the channel with no request to work on, as a fraction of the")
|
|
print(" window. Bytes the medium could have delivered and did not.")
|
|
print("under = frames whose record was not resident when the decoder asked")
|
|
print(" for it. The PIPE.")
|
|
print("noidl = frames that reached the gate after their tick, because the one")
|
|
print(" before used its whole slot. The CPU, and 54.4's cadence.")
|
|
print("ceil = most records resident and unconsumed at a frame start: what a")
|
|
print(" branch point could spend, minus one for the restart (51.2).")
|
|
print("refus = placements refused for SPACE. Nonzero means the ring filled.")
|