Fix live-render lag: bulk memory read + masked texture compositing (~2.1x)

User-visible bug: connected the reconstructed renderer to the REAL running pod
for the first time (M4d demo) and the render fell increasingly behind the live
game, plus looked near-grayscale. Root-caused with direct profiling instead of
guessing:

1. VPX_RENDER=1 was set in launch_live.ps1 (copied from launch_pod.ps1's
   defaults without thinking it through) -- it starts vpxlog.cpp's OWN native
   GL render thread (rt_main), which contends with our renderer for the same
   CPU/GL resources. Live-caught: with it set, wire delivery stalled at wire
   command 8 for 90+s; removing it, the same mission reached command 21,488
   in the same window. This was the dominant cause of the visible lag.

2. gpu_raster.build_prims() scanned the whole per-draw program window
   (0x08158000-0x08170000, ~24k words) via a Python-level r32() call per word
   every frame. Added emu860c.dump_range(lo,hi)->bytes (one bulk C read) and
   rewrote build_prims to index a numpy array instead -- verified byte-identical
   dump_range output first, then reconfirmed the whole render is STILL
   bit-identical to the CPU reference (frames 0/5/11, differ>24 = 0.000%).

3. Profiling after (1) showed the per-texture compositing loop was the real
   remaining cost: 62ms/frame doing full-832x512-frame fancy-indexed texture
   sampling for EACH of 13 textures, regardless of how few pixels use each one.
   Added dpl_sampler.composite_masked (sample+composite only the pixels a
   texture actually covers; verified equivalent to composite() on a partial
   mask in dpl_sampler's own conformance test) and hoisted the invariant
    out of the loop.

Combined: render time 95.8ms -> 54.9ms/frame (~1.75x from this change alone,
~2.1x vs the original ~117ms/frame baseline), still bit-identical to the CPU
reference throughout.

launch_live.ps1: removed VPX_RENDER, renamed the automatic-variable shadow
 ->  (flagged by PSScriptAnalyzer), added -Pin <fifodump> to
pre-load the full texture set (bit-identical to offline) instead of the
incremental default, for a demo that wants correct color immediately rather
than waiting on the live mission's own upload order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-20 10:07:55 -05:00
co-authored by Claude Opus 4.8
parent bf234842cb
commit ef7db9f2e4
4 changed files with 66 additions and 18 deletions
@@ -781,6 +781,33 @@ static PyObject *py_w32(PyObject *self, PyObject *args) {
Py_RETURN_NONE;
}
/* Bulk read [lo, hi) as bytes in ONE call -- replaces a Python-level r32()
* loop (~24k calls for a typical per-draw program-window scan), which was
* the dominant per-frame cost in the live render path. */
static PyObject *py_dump_range(PyObject *self, PyObject *args) {
(void)self;
unsigned long lo_, hi_;
if (!PyArg_ParseTuple(args, "kk", &lo_, &hi_)) return NULL;
uint32_t lo = (uint32_t)lo_, hi = (uint32_t)hi_;
if (hi <= lo) return PyBytes_FromStringAndSize(NULL, 0);
Py_ssize_t n = (Py_ssize_t)(hi - lo);
PyObject *out = PyBytes_FromStringAndSize(NULL, n);
if (!out) return NULL;
uint8_t *dst = (uint8_t *)PyBytes_AS_STRING(out);
memset(dst, 0, (size_t)n);
uint32_t addr = lo;
while (addr < hi) {
uint32_t pi = addr >> PAGE_BITS;
uint32_t off = addr & (PAGE_SIZE - 1);
uint32_t take = PAGE_SIZE - off;
if (take > hi - addr) take = hi - addr;
uint8_t *p = g_pages[pi];
if (p) memcpy(dst + (addr - lo), p + off, take);
addr += take;
}
return out;
}
static PyMethodDef methods[] = {
{"init", py_init, METH_NOARGS, "reset state"},
{"load_blob", py_load_blob, METH_VARARGS, "load bytes at addr"},
@@ -795,6 +822,7 @@ static PyMethodDef methods[] = {
{"set_sentinel", py_setsentinel, METH_VARARGS, "set return sentinel"},
{"r32", py_r32, METH_VARARGS, "read mem"},
{"w32", py_w32, METH_VARARGS, "write mem"},
{"dump_range", py_dump_range, METH_VARARGS, "bulk read [lo,hi) as bytes"},
{NULL, NULL, 0, NULL}
};
+27 -13
View File
@@ -6,13 +6,14 @@ per-draw primitives -> GPU compute raster (6-edge clip, fp64 planes, nearest-z
winner) -> the verified perspective divide + real-texture texel decode as one
vectorized numpy post-pass.
Renderer(ctx, texlist).frame(r32) -> HxWx3 uint8
Renderer(ctx, texlist).frame(dump_range) -> HxWx3 uint8
where dump_range(lo, hi) -> bytes is emu860c.dump_range (a bulk memory read).
where r32(addr)->uint32 reads the running firmware's memory (the live per-draw
coefficient program lives at 0x08158000..0x08170000).
"""
import struct
import numpy as np
from dpl_sampler import mode_to_texflags, wrap_index, composite
from dpl_sampler import mode_to_texflags, wrap_index, composite_masked
W, H = 832, 512
PROG_LO, PROG_HI = 0x08158000, 0x08170000
@@ -70,14 +71,21 @@ def f32(w):
return struct.unpack('<f', struct.pack('<I', w & 0xffffffff))[0]
def build_prims(r32):
"""Reconstruct per-draw primitive records from the live program in memory."""
prog = {a: r32(a) for a in range(PROG_LO, PROG_HI, 4) if r32(a)}
def build_prims(dump_range):
"""Reconstruct per-draw primitive records from the live program in memory.
dump_range(lo, hi) -> bytes: ONE bulk read of the whole program window,
replacing a ~24k-call Python r32() loop (the dominant per-frame cost that
made the live renderer fall behind the running game -- see M4B-RESULTS.md).
"""
words = np.frombuffer(dump_range(PROG_LO, PROG_HI), dtype='<u4')
n = len(words)
def rd(a):
return prog.get(a, 0)
idx = (a - PROG_LO) >> 2
return int(words[idx]) if 0 <= idx < n else 0
setups = [a for a in sorted(prog) if rd(a) == 0x100]
setups = (PROG_LO + 4 * np.flatnonzero(words == 0x100)).tolist()
recs4 = []
for a in setups:
p = a + 4
@@ -120,8 +128,8 @@ class Renderer:
self.prog_gl = ctx.compute_shader(FX_SHADER)
self.texlist = texlist
def frame(self, r32):
prims = build_prims(r32)
def frame(self, dump_range):
prims = build_prims(dump_range)
if prims is None:
return None
n_prims = len(prims) // 10
@@ -147,13 +155,19 @@ class Renderer:
ucoord = (uu / tzc * 2048).astype(np.int64)
vcoord = (vv / tzc * 2048).astype(np.int64)
ntex = len(self.texlist)
textured = filled & (hasuv > 0.5)
tidm = tid % ntex # hoisted: was recomputed per slot
for slot in range(ntex):
sel = filled & (hasuv > 0.5) & ((tid % ntex) == slot)
sel = textured & (tidm == slot)
if not sel.any():
continue
h, (tu, tv, mode, arr) = self.texlist[slot]
wrap_u, wrap_v, cut_mode = mode_to_texflags(mode)
ui = wrap_index((ucoord * tu) >> 8, tu, wrap_u)
vi = wrap_index((vcoord * tv) >> 8, tv, wrap_v)
composite(img, sel, arr[vi, ui], cut_mode)
# sample only the pixels THIS texture covers, not the whole frame
# (composite_masked -- verified equivalent to the full-frame form
# in dpl_sampler's conformance test; this loop runs once per
# texture, so full-frame sampling here was O(ntex x W x H))
ui = wrap_index((ucoord[sel] * tu) >> 8, tu, wrap_u)
vi = wrap_index((vcoord[sel] * tv) >> 8, tv, wrap_v)
composite_masked(img, sel, arr[vi, ui], cut_mode)
return img
@@ -173,7 +173,7 @@ while not stopped:
if tex_dirty and not pinned:
renderer.texlist = list(build_texstore(recs).items())
tex_dirty = False
img = renderer.frame(emu860c.r32)
img = renderer.frame(emu860c.dump_range)
if img is not None:
if not present(img, frames):
break
+10 -4
View File
@@ -16,7 +16,12 @@ param(
[string]$Work = "$env:LOCALAPPDATA\Temp\vwe-live",
[switch]$Sound,
[switch]$PodOnly,
[switch]$Present # live_render window (pygame)
[switch]$Present, # live_render window (pygame)
[string]$Pin # pre-load the full texture set from this
# .fifodump (bit-identical to offline;
# avoids grey/incomplete early textures
# while the live mission is still
# uploading them incrementally)
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force $Work | Out-Null
@@ -57,9 +62,10 @@ if ($Sound) { Write-Host "sound ON: boot adds ~4 min for the SoundFont upload" }
if (-not $PodOnly) {
$rl = Join-Path (Resolve-Path "$PSScriptRoot\..\firmware-decomp\emu860c").Path 'live_render.py'
$args = @("`"$rl`"", "sock:$Port")
if ($Present) { $args += '--present' }
$rendererArgs = @("`"$rl`"", "sock:$Port")
if ($Present) { $rendererArgs += '--present' }
if ($Pin) { $rendererArgs += '--pin'; $rendererArgs += "`"$(Resolve-Path $Pin)`"" }
Write-Host "connecting reconstructed renderer: python $rl sock:$Port"
Start-Sleep -Milliseconds 800 # let the device open the listener
& python @args
& python @rendererArgs
}