Put the ring on the 68000, and find the disc stops whenever the player is not asking
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
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""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.")
|
||||
Reference in New Issue
Block a user