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;
|
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[] = {
|
static PyMethodDef methods[] = {
|
||||||
{"init", py_init, METH_NOARGS, "reset state"},
|
{"init", py_init, METH_NOARGS, "reset state"},
|
||||||
{"load_blob", py_load_blob, METH_VARARGS, "load bytes at addr"},
|
{"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"},
|
{"set_sentinel", py_setsentinel, METH_VARARGS, "set return sentinel"},
|
||||||
{"r32", py_r32, METH_VARARGS, "read mem"},
|
{"r32", py_r32, METH_VARARGS, "read mem"},
|
||||||
{"w32", py_w32, METH_VARARGS, "write 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}
|
{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
|
winner) -> the verified perspective divide + real-texture texel decode as one
|
||||||
vectorized numpy post-pass.
|
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
|
where r32(addr)->uint32 reads the running firmware's memory (the live per-draw
|
||||||
coefficient program lives at 0x08158000..0x08170000).
|
coefficient program lives at 0x08158000..0x08170000).
|
||||||
"""
|
"""
|
||||||
import struct
|
import struct
|
||||||
import numpy as np
|
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
|
W, H = 832, 512
|
||||||
PROG_LO, PROG_HI = 0x08158000, 0x08170000
|
PROG_LO, PROG_HI = 0x08158000, 0x08170000
|
||||||
@@ -70,14 +71,21 @@ def f32(w):
|
|||||||
return struct.unpack('<f', struct.pack('<I', w & 0xffffffff))[0]
|
return struct.unpack('<f', struct.pack('<I', w & 0xffffffff))[0]
|
||||||
|
|
||||||
|
|
||||||
def build_prims(r32):
|
def build_prims(dump_range):
|
||||||
"""Reconstruct per-draw primitive records from the live program in memory."""
|
"""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)}
|
|
||||||
|
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):
|
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 = []
|
recs4 = []
|
||||||
for a in setups:
|
for a in setups:
|
||||||
p = a + 4
|
p = a + 4
|
||||||
@@ -120,8 +128,8 @@ class Renderer:
|
|||||||
self.prog_gl = ctx.compute_shader(FX_SHADER)
|
self.prog_gl = ctx.compute_shader(FX_SHADER)
|
||||||
self.texlist = texlist
|
self.texlist = texlist
|
||||||
|
|
||||||
def frame(self, r32):
|
def frame(self, dump_range):
|
||||||
prims = build_prims(r32)
|
prims = build_prims(dump_range)
|
||||||
if prims is None:
|
if prims is None:
|
||||||
return None
|
return None
|
||||||
n_prims = len(prims) // 10
|
n_prims = len(prims) // 10
|
||||||
@@ -147,13 +155,19 @@ class Renderer:
|
|||||||
ucoord = (uu / tzc * 2048).astype(np.int64)
|
ucoord = (uu / tzc * 2048).astype(np.int64)
|
||||||
vcoord = (vv / tzc * 2048).astype(np.int64)
|
vcoord = (vv / tzc * 2048).astype(np.int64)
|
||||||
ntex = len(self.texlist)
|
ntex = len(self.texlist)
|
||||||
|
textured = filled & (hasuv > 0.5)
|
||||||
|
tidm = tid % ntex # hoisted: was recomputed per slot
|
||||||
for slot in range(ntex):
|
for slot in range(ntex):
|
||||||
sel = filled & (hasuv > 0.5) & ((tid % ntex) == slot)
|
sel = textured & (tidm == slot)
|
||||||
if not sel.any():
|
if not sel.any():
|
||||||
continue
|
continue
|
||||||
h, (tu, tv, mode, arr) = self.texlist[slot]
|
h, (tu, tv, mode, arr) = self.texlist[slot]
|
||||||
wrap_u, wrap_v, cut_mode = mode_to_texflags(mode)
|
wrap_u, wrap_v, cut_mode = mode_to_texflags(mode)
|
||||||
ui = wrap_index((ucoord * tu) >> 8, tu, wrap_u)
|
# sample only the pixels THIS texture covers, not the whole frame
|
||||||
vi = wrap_index((vcoord * tv) >> 8, tv, wrap_v)
|
# (composite_masked -- verified equivalent to the full-frame form
|
||||||
composite(img, sel, arr[vi, ui], cut_mode)
|
# 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
|
return img
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ while not stopped:
|
|||||||
if tex_dirty and not pinned:
|
if tex_dirty and not pinned:
|
||||||
renderer.texlist = list(build_texstore(recs).items())
|
renderer.texlist = list(build_texstore(recs).items())
|
||||||
tex_dirty = False
|
tex_dirty = False
|
||||||
img = renderer.frame(emu860c.r32)
|
img = renderer.frame(emu860c.dump_range)
|
||||||
if img is not None:
|
if img is not None:
|
||||||
if not present(img, frames):
|
if not present(img, frames):
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ param(
|
|||||||
[string]$Work = "$env:LOCALAPPDATA\Temp\vwe-live",
|
[string]$Work = "$env:LOCALAPPDATA\Temp\vwe-live",
|
||||||
[switch]$Sound,
|
[switch]$Sound,
|
||||||
[switch]$PodOnly,
|
[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'
|
$ErrorActionPreference = 'Stop'
|
||||||
New-Item -ItemType Directory -Force $Work | Out-Null
|
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) {
|
if (-not $PodOnly) {
|
||||||
$rl = Join-Path (Resolve-Path "$PSScriptRoot\..\firmware-decomp\emu860c").Path 'live_render.py'
|
$rl = Join-Path (Resolve-Path "$PSScriptRoot\..\firmware-decomp\emu860c").Path 'live_render.py'
|
||||||
$args = @("`"$rl`"", "sock:$Port")
|
$rendererArgs = @("`"$rl`"", "sock:$Port")
|
||||||
if ($Present) { $args += '--present' }
|
if ($Present) { $rendererArgs += '--present' }
|
||||||
|
if ($Pin) { $rendererArgs += '--pin'; $rendererArgs += "`"$(Resolve-Path $Pin)`"" }
|
||||||
Write-Host "connecting reconstructed renderer: python $rl sock:$Port"
|
Write-Host "connecting reconstructed renderer: python $rl sock:$Port"
|
||||||
Start-Sleep -Milliseconds 800 # let the device open the listener
|
Start-Sleep -Milliseconds 800 # let the device open the listener
|
||||||
& python @args
|
& python @rendererArgs
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user