Check from the sources that no upscaler waits for the device where it lets something go.

tests/upscaler_idle_check.py reads vk_dlaa.c, vk_ffx.cpp and vk_ffx4.cpp with their comments and strings blanked and
fails when vkDeviceWaitIdle is called outside vk_upscaler_device_idle and the --probe-ffx-cache path, when
vk_upscaler_device_idle is called outside vk_upscaler_settle and vk_upscaler_gpu_wait, or when one of the ten places
that let something go does not call vk_upscaler_let_go, through which NX2NAVI_UPSCALER_IDLE_WAIT=1 brings the old wait
back. It also checks that switch's sections[] entry and nx2navi.ini.example line (cfg_rows_check has its known[] name
and metas[] row), that perf.c names every PERF_M_* kind (a kind without a name prints a null string on a slow frame's
line), and that pb_images notes PB.used after the last call that clears PB.

On this branch it passes. On the base of this branch's work (stability-pass, 1f547853) it reports the twelve waits of
C-6 and Cv-1, each with its function, and on the first commit of this branch, where the waits went through
vk_upscaler_device_idle, the same twelve places. docs/building.md "Checks" describes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 19:09:56 -05:00
parent 9065d1485f
commit ed87d30628
2 changed files with 190 additions and 0 deletions

View File

@@ -811,6 +811,14 @@ once the submission that last named it is done and its present gate is passed, t
they were put and the rest keep theirs, that a full list refuses without overwriting, and that an entry a release puts
waits for the next take. No GPU, no game files.
A change to where the upscalers free what they let go of (`runtime/vk_dlaa.c` "what the upscalers let go of while the
GPU may still use it", `runtime/vk_ffx.cpp` and `runtime/vk_ffx4.cpp` `context_for`) passes
`python tests\upscaler_idle_check.py`, which reads the sources: `vkDeviceWaitIdle` only in `vk_upscaler_device_idle`
and the `--probe-ffx-cache` path, that function only behind the rollback switch, a full list and a refused creation,
every place that lets something go calling `vk_upscaler_let_go`, `upscaler_idle_wait`'s `sections[]` entry and
`nx2navi.ini.example` line, a name in `perf.c` for each perf mark kind, and `pb_images` noting the images' last use
after it makes them. Any Python, no game files.
A change to which frames the flight recorder takes for a one-frame flash (`runtime/flight_flash.h`, `runtime/flight.c`
`flight_cells`) passes `python tests\flight_flash_check.py`, which builds the header alone, draws pictures of the
thumbnails' size and checks that a patch or a whole frame shown once is a flash, that a camera cut, a fade, a pan and

View File

@@ -0,0 +1,182 @@
"""Static checks of the upscalers' waits for the device (runtime/vk_dlaa.c "what the upscalers let go of while the GPU
may still use it", runtime/vk_ffx.cpp, runtime/vk_ffx4.cpp).
It fails when:
- vkDeviceWaitIdle is called in vk_dlaa.c outside vk_upscaler_device_idle, in vk_ffx.cpp outside vk_ffx_probe_cache
(the --probe-ffx-cache path), or anywhere in vk_ffx4.cpp: each such wait stalls the replaying thread with the queue
lock held;
- vk_upscaler_device_idle is called outside vk_upscaler_settle and vk_upscaler_gpu_wait (the rollback switch
NX2NAVI_UPSCALER_IDLE_WAIT=1, a full list, a refused creation's failed wait);
- one of the places that let something go (vk_dlaa.c free_size, free_snap, stale_sizes, release_kept, hudless_from,
pb_images, retire_idle and evaluate, vk_ffx.cpp and vk_ffx4.cpp context_for) does not call vk_upscaler_let_go, which
frees what the GPU is past and which the rollback switch turns into a wait for the device;
- NX2NAVI_UPSCALER_IDLE_WAIT has no sections[] entry in cfg.c or no line in nx2navi.ini.example (its known[] name and
its metas[] row, and a // that would hide them, are tests/cfg_rows_check.py's);
- perf.c's mark_name has another count of names than host.h has PERF_M_* kinds: a kind without a name prints a null
string on a slow frame's line;
- vk_dlaa.c pb_images does not note PB.used after the last call that clears PB: idle_release then took the images it
had just made for 600 presents unused and freed them while the frame that used them could be in flight.
No GPU, no game files, any Python. Usage: upscaler_idle_check.py [repository root]
"""
import re
import sys
from pathlib import Path
WAIT_ALLOWED = { # where vkDeviceWaitIdle may be called
'vk_dlaa.c': {'vk_upscaler_device_idle'},
'vk_ffx.cpp': {'vk_ffx_probe_cache'},
'vk_ffx4.cpp': set(),
}
IDLE_ALLOWED = {'vk_upscaler_settle', 'vk_upscaler_gpu_wait'} # vk_dlaa.c: who may call vk_upscaler_device_idle
LET_GO = { # functions that let something go and must call vk_upscaler_let_go
'vk_dlaa.c': ['free_size', 'free_snap', 'stale_sizes', 'release_kept', 'hudless_from', 'pb_images', 'retire_idle',
'evaluate'],
'vk_ffx.cpp': ['context_for'],
'vk_ffx4.cpp': ['context_for'],
}
# A function definition: at column 0, a return type, the name, the parameters and the body's brace (the runtime's
# style); declarations end with ';' before any '{'. Read in code whose string literals are blanked: extern "C" is
# extern " " there.
FUNC = re.compile(r'^(?:extern "[^"\n]*" )?(?:static )?(?:inline )?[A-Za-z_][\w \*]*?\b([A-Za-z_]\w*)\s*\([^;{}]*\)\s*\{',
re.M)
def blank_comments(src):
"""src with comments and string literals replaced by spaces (newlines kept), so offsets stay and only code is read."""
out, i, n = list(src), 0, len(src)
while i < n:
if src.startswith('//', i):
j = src.find('\n', i)
j = n if j < 0 else j
for k in range(i, j):
out[k] = ' '
i = j
elif src.startswith('/*', i):
j = src.find('*/', i + 2)
j = n if j < 0 else j + 2
for k in range(i, j):
if out[k] != '\n':
out[k] = ' '
i = j
elif src[i] in '"\'':
q, j = src[i], i + 1
while j < n and src[j] != q and src[j] != '\n':
j += 2 if src[j] == '\\' else 1
for k in range(i + 1, min(j, n)):
out[k] = ' '
i = j + 1
else:
i += 1
return ''.join(out)
def functions(code):
"""[(name, body start, body end)] of each function defined in code (comments blanked)."""
found = []
for m in FUNC.finditer(code):
depth, k = 0, m.end() - 1
while k < len(code):
if code[k] == '{':
depth += 1
elif code[k] == '}':
depth -= 1
if depth == 0:
break
k += 1
found.append((m.group(1), m.end() - 1, k))
return found
def enclosing(funcs, pos):
for name, a, b in funcs:
if a <= pos <= b:
return name
return None
def calls(code, funcs, callee):
"""[(line, enclosing function)] of each call of callee (its definition left out)."""
out = []
for m in re.finditer(r'\b%s\s*\(' % re.escape(callee), code):
fn = enclosing(funcs, m.start())
if fn is None: # the definition's own head, or a declaration
continue
out.append((code.count('\n', 0, m.start()) + 1, fn))
return out
def body(code, funcs, name):
for n, a, b in funcs:
if n == name:
return code[a:b + 1]
return None
def main():
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[1]
rt = root / 'runtime'
fails = []
sources = {}
for f in WAIT_ALLOWED:
code = blank_comments((rt / f).read_text(encoding='utf-8'))
sources[f] = (code, functions(code))
for f, allowed in WAIT_ALLOWED.items():
code, funcs = sources[f]
for line, fn in calls(code, funcs, 'vkDeviceWaitIdle'):
if fn not in allowed:
fails.append(f'{f}:{line}: vkDeviceWaitIdle in {fn}, allowed only in {sorted(allowed) or "none"}')
for line, fn in calls(code, funcs, 'vk_upscaler_device_idle'):
if f != 'vk_dlaa.c' or fn not in IDLE_ALLOWED:
fails.append(f'{f}:{line}: vk_upscaler_device_idle in {fn}, allowed only in vk_dlaa.c '
f'{sorted(IDLE_ALLOWED)}')
nwaits = len(calls(*sources['vk_dlaa.c'], 'vkDeviceWaitIdle'))
if nwaits != 1:
fails.append(f'vk_dlaa.c: {nwaits} calls of vkDeviceWaitIdle, expected the one in vk_upscaler_device_idle')
for f, names in LET_GO.items():
code, funcs = sources[f]
for name in names:
b = body(code, funcs, name)
if b is None:
fails.append(f'{f}: no function {name}')
elif not re.search(r'\bvk_upscaler_let_go\s*\(', b):
fails.append(f'{f} {name}: lets something go without vk_upscaler_let_go')
key = 'UPSCALER_IDLE_WAIT'
cfg = (rt / 'cfg.c').read_text(encoding='utf-8-sig')
if not re.search(r'\{\s*"%s"\s*,\s*"display"\s*\}' % key, cfg):
fails.append(f'cfg.c: {key} has no sections[] entry in "display"')
ini = (root / 'nx2navi.ini.example').read_text(encoding='utf-8-sig')
if not re.search(r'^#\s*%s\s*=' % key.lower(), ini, re.M):
fails.append(f'nx2navi.ini.example: no {key.lower()} line')
host = (rt / 'host.h').read_text(encoding='utf-8')
m = re.search(r'enum\s*\{\s*(PERF_M_FILE[^}]*)\}', host)
kinds = [k.strip() for k in m.group(1).split(',') if k.strip() and k.strip() != 'PERF_M_N'] if m else []
perf = (rt / 'perf.c').read_text(encoding='utf-8')
m = re.search(r'mark_name\[PERF_M_N\]\s*=\s*\{(.*?)\};', perf, re.S)
names = re.findall(r'"[^"]*"', m.group(1)) if m else []
if not kinds or len(names) != len(kinds):
fails.append(f'perf.c: mark_name has {len(names)} names for {len(kinds)} PERF_M kinds in host.h')
code, funcs = sources['vk_dlaa.c']
b = body(code, funcs, 'pb_images')
if b is None:
fails.append('vk_dlaa.c: no function pb_images')
else:
clears = [mm.end() for mm in re.finditer(r'\b(?:pb_destroy|retire_pb)\s*\(\s*\)|memset\s*\(\s*&PB\b', b)]
sets = [mm.start() for mm in re.finditer(r'\bPB\.used\s*=', b)]
last_clear = max(clears) if clears else -1
if not sets or max(sets) < last_clear:
fails.append('vk_dlaa.c pb_images: PB.used is not noted after the last call that clears PB')
for f in fails:
print('FAIL', f)
print('upscaler_idle_check:', 'FAILED' if fails else
f'passed (vkDeviceWaitIdle once in vk_dlaa.c, {sum(len(v) for v in LET_GO.values())} places let go, '
f'{len(kinds)} perf mark kinds named)')
return 1 if fails else 0
if __name__ == '__main__':
sys.exit(main())