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:
@@ -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}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user