Copy a frame's uniform blocks at the submit call with the replay on its own thread, and say what that option can show.

With NX2NAVI_REPLAY_ASYNC the uniform snapshot was taken when the replay thread started recording a submission, up to a frame after the game submitted it. The game rewrites that memory without waiting for the GPU: in a graveyard the settings block of the shadow pass held another block's values in the frame its per-frame allocator moved on to the next stretch of memory, about every 364 frames. The submitting thread now takes the copy before it queues the submission (vk_usnap_take), and the replay thread takes it over and leaves out what GPU writes in flight touch (vk_usnap_begin adopt). A frame dump of such a frame shows the block as the game submitted it.

That frame still comes out dark with the option on: vertex, storage and index memory reaches the VRAM mirrors when the replay uses it, as late as the uniform blocks did, and the snapshot covers uniform bindings only. With the option off, 3,000 presents in a row were free of dark and odd frames in that graveyard and in a village; with it on, one frame in about 364 was dark. It gave 65.6 against 56.1 and 64.1 against 54.3 frames per second in those two places. The option stays off by default, and its menu text, the example settings file and the release notes now say that it can show a dark or flickering frame.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
epicmeme546
2026-11-10 06:04:14 -05:00
parent d1bebd34cd
commit 7e23e7309b
6 changed files with 130 additions and 16 deletions

View File

@@ -27,6 +27,13 @@ Draft notes for the release after 0.6.1. The version number is not decided yet.
pass among them once its 50 ms were spent, and the frame was shown without it. Such a frame is no longer shown:
the previous picture stays for the frame or two it takes, while the game runs on (`frame_hold_ms`, 120 by default;
0 shows every frame as before).
- *GPU frame prep on its own thread* (`replay_async`, off by default): the shader constants of a frame are now
copied when the game hands the frame over, not when the other thread gets to it up to a frame later, by which time
the game had sometimes reused that memory (a shadow pass got another block's values about every 364 frames in one
graveyard). The option can still show a dark or flickering frame: vertex, storage and index data are read late in
the same way, and this game rewrites them without waiting for the GPU. In that graveyard and a village, 3,000
frames in a row were clean with the option off, against a dark frame about every 364 with it on. The menu says so
now. It gave 15 to 18 percent more frames per second in those two places.
- The performance overlay's VRAM line counted every program on the graphics card as if it were the game. It now
shows the game's own share beside it (*game 6.8 GB* of 10.4 GB in use, in one measurement).
- Textures out of view for 11 seconds at 165 fps (30 seconds were meant) could show a blank stand-in for a few frames

View File

@@ -54,7 +54,9 @@
# without the interrupt, 0 = off (the old scheduling).
# async_preempt = 1
# Replay NVN submissions on their own thread, at most one frame behind the game, 0 or 1. Default: off. Helps where the
# game waits for the GPU; adds up to a frame of latency.
# game waits for the GPU and in heavy scenes (15 to 18 percent more frames per second in a village); adds up to a frame
# of latency, and can show a dark or flickering frame now and then: the other thread reads vertex and buffer memory
# the game has sometimes rewritten by then.
# replay_async = 0
# EXPERIMENTAL, THE GAME CRASHES WITH 1. 1 lifts the virtual core gate: guest threads run without the console's limit
# of one thread per core, and the game keeps per-core data without locks. With 1 the game crashes or hangs,

View File

@@ -396,7 +396,7 @@ static const char *const text_en[UI_TEXT_COUNT] = {
[UI_MENU_ASYNC_PREEMPT_DESC] = "Lets a game thread interrupt another one on the same virtual core.",
[UI_MENU_PREEMPT_OFF] = "Off", [UI_MENU_PREEMPT_ON] = "On", [UI_MENU_PREEMPT_INHERIT] = "Inheritance only",
[UI_MENU_REPLAY_ASYNC] = "GPU frame prep on its own thread",
[UI_MENU_REPLAY_ASYNC_DESC] = "Prepares frames for the GPU on a separate thread instead of the game thread. Adds up to one frame of latency.",
[UI_MENU_REPLAY_ASYNC_DESC] = "Prepares frames for the GPU on a separate thread instead of the game thread. Faster in heavy scenes, but can show a dark or flickering frame now and then, and adds up to one frame of latency.",
[UI_MENU_TIMER_1MS] = "1 ms Windows timer",
[UI_MENU_TIMER_1MS_DESC] = "Raises the system timer resolution to 1 ms for steadier frame pacing.",
[UI_MENU_PIPE_WAIT] = "Waiting draw handling",
@@ -939,7 +939,7 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
[UI_MENU_ASYNC_PREEMPT_DESC] = "Позволяет потоку игры прервать другой поток на том же виртуальном ядре.",
[UI_MENU_PREEMPT_OFF] = "Выкл.", [UI_MENU_PREEMPT_ON] = "Вкл.", [UI_MENU_PREEMPT_INHERIT] = "Только наследование",
[UI_MENU_REPLAY_ASYNC] = "Подготовка кадра для GPU в отдельном потоке",
[UI_MENU_REPLAY_ASYNC_DESC] = "Кадр для GPU готовится в отдельном потоке, а не в потоке игры. Добавляет до одного кадра задержки.",
[UI_MENU_REPLAY_ASYNC_DESC] = "Кадр для GPU готовится в отдельном потоке, а не в потоке игры. Быстрее в тяжёлых сценах, но иногда может показать тёмный или мерцающий кадр и добавляет до одного кадра задержки.",
[UI_MENU_TIMER_1MS] = "Таймер Windows 1 мс",
[UI_MENU_TIMER_1MS_DESC] = "Повышает точность системного таймера до 1 мс для ровного темпа кадров.",
[UI_MENU_PIPE_WAIT] = "Обработка ожидающих отрисовок",
@@ -1328,7 +1328,7 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
[UI_MENU_ASYNC_PREEMPT_DESC] = "Deixa uma thread do jogo interromper outra no mesmo núcleo virtual.",
[UI_MENU_PREEMPT_OFF] = "Desligado", [UI_MENU_PREEMPT_ON] = "Ligado", [UI_MENU_PREEMPT_INHERIT] = "Só herança",
[UI_MENU_REPLAY_ASYNC] = "Preparo de quadro da GPU em thread própria",
[UI_MENU_REPLAY_ASYNC_DESC] = "Prepara os quadros para a GPU numa thread separada em vez da thread do jogo. Adiciona até um quadro de latência.",
[UI_MENU_REPLAY_ASYNC_DESC] = "Prepara os quadros para a GPU numa thread separada em vez da thread do jogo. Mais rápido em cenas pesadas, mas pode mostrar um quadro escuro ou piscando de vez em quando, e adiciona até um quadro de latência.",
[UI_MENU_TIMER_1MS] = "Timer do Windows de 1 ms",
[UI_MENU_TIMER_1MS_DESC] = "Aumenta a resolução do timer do sistema para 1 ms, para um ritmo de quadros mais estável.",
[UI_MENU_PIPE_WAIT] = "Tratamento de desenhos em espera",
@@ -1964,7 +1964,7 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
[UI_MENU_PREEMPT_ON] = "开",
[UI_MENU_PREEMPT_INHERIT] = "仅继承",
[UI_MENU_REPLAY_ASYNC] = "GPU 帧准备独立线程",
[UI_MENU_REPLAY_ASYNC_DESC] = "在单独的线程而不是游戏线程上为 GPU 准备帧。"
[UI_MENU_REPLAY_ASYNC_DESC] = "在单独的线程而不是游戏线程上为 GPU 准备帧。在复杂场景中更快,但偶尔可能出现变暗或闪烁的画面。"
"最多增加一帧延迟。",
[UI_MENU_TIMER_1MS] = "1 ms Windows 计时器",
[UI_MENU_TIMER_1MS_DESC] = "将系统计时器精度提高到 1 ms,使帧间隔更平稳。",

View File

@@ -297,6 +297,9 @@ void vk_mem_gpu_wrote_shader(const VkMemSpan *sp, uint64_t size);
// Uniform blocks as of the submit call (vk_usnap.c): vk_exec_record copies the uniform ranges a submission binds before
// it records, ubo bindings take the copy (0: not copied, bind memory), GPU writes of the replay drop the copies they touch.
void vk_usnap_begin(const uint64_t *handles, int n, uint64_t fence);
// NX2NAVI_REPLAY_ASYNC: the submitting thread takes the copy before it queues the submission; vk_usnap_begin on the
// replay thread then uses it.
void vk_usnap_take(const uint64_t *handles, int n);
void vk_usnap_end(uint64_t fence);
int vk_usnap_find(uint64_t addr, uint64_t n, uint64_t align, VkDescriptorBufferInfo *out);
const uint8_t *vk_usnap_map(void); // a host copy of the current arena, at the offsets vk_usnap_find gives

View File

@@ -552,6 +552,7 @@ static uint64_t vk_be_submit(const uint64_t *handles, int n) {
if (!it.h) rt_fatal("vk: out of memory (replay queue)");
memcpy(it.h, handles, sizeof *it.h * (size_t)n);
}
vk_usnap_take(handles, n); // the uniform blocks as they are now: the game writes on once this returns
return replay_enqueue(&it);
}
static void vk_be_finish(void) { vk_sync_start(); replay_drain(DR_FINISH); vk_present_drain(); vk_sync_finish(); }

View File

@@ -12,8 +12,12 @@
// - A GPU write within the submission (UpdateUniformBuffer, a storage buffer a shader writes, a buffer copy or fill,
// an image-to-buffer copy: vk_usnap_drop) drops the copies it touches from then on, so later draws read the result.
// - The arena goes back to a free list once the submission's fence is done (completion thread).
// The copy is taken where vk_exec_record starts: at the submit call when the replay runs on the submitting thread, later
// with NX2NAVI_REPLAY_ASYNC. NX2NAVI_UBO_SNAPSHOT=0 binds live memory as before; =ab alternates (NX2NAVI_AB).
// The copy is taken at the submit call: where vk_exec_record starts when the replay runs on the submitting thread, and
// by the submitting thread itself with NX2NAVI_REPLAY_ASYNC (vk_usnap_take), where the record starts up to a frame
// later on the replay thread. Taken only there, it held whatever the game had put into those bytes by then: in a
// graveyard the block of the shadow pass came out with another block's values in the frame its allocator moved on to
// the next stretch of memory, about every 364 frames, and that frame's sunlight was all in shadow (a dark frame with
// the interface intact). NX2NAVI_UBO_SNAPSHOT=0 binds live memory as before; =ab alternates (NX2NAVI_AB).
#include <stdlib.h>
#include <string.h>
@@ -60,6 +64,20 @@ static struct {
static Arena *free_arenas;
static PlatMutex free_lock = PLAT_MUTEX_INIT;
// U.col, sort_col's buffer and collect's neighbour cache: the submitting thread (vk_usnap_take) and the replay thread
// (vk_usnap_begin, for a submission nothing was taken for) both collect.
static PlatMutex gather_lock = PLAT_MUTEX_INIT;
// A copy taken at the submit call for the replay thread (vk_usnap_take), in submission order.
typedef struct Taken {
struct Taken *next;
Arena *arena;
Reg *reg;
int nreg, n;
uint64_t first, total, refused, ns; // first, n: the submission's first handle and handle count
} Taken;
static Taken *tq_head, *tq_tail;
static PlatMutex tq_lock = PLAT_MUTEX_INIT;
static void *grow(void *p, int *cap, size_t elem) {
int n = *cap ? *cap * 2 : 1024;
@@ -245,6 +263,94 @@ static void summary(void) {
U.subs = U.off_subs = U.regions = U.bytes = U.copy_ns = U.hits = U.drops = U.inflight = U.refused = 0;
}
// The bytes of regions reg[0..nreg) (total bytes in all) into a's device copy, through its host copy with DLSS.
static void copy_regions(Arena *a, const Reg *reg, int nreg, uint64_t total) {
uint8_t *dst = a->map;
if (vk_dlaa_available()) {
if (!a->cpu && !(a->cpu = malloc(a->size))) rt_fatal("vk_usnap: out of memory");
dst = a->cpu;
}
for (int i = 0; i < nreg; i++) memcpy(dst + reg[i].off, (const void *)(uintptr_t)reg[i].lo, reg[i].hi - reg[i].lo);
if (dst != a->map) memcpy(a->map, dst, total); // one sequential write into the device copy
vmaFlushAllocation(vk_ctx()->vma, a->alloc, 0, total); // a no-op on coherent memory
}
// NX2NAVI_REPLAY_ASYNC, the submitting thread before the submission is queued for the replay: the uniform ranges it
// binds are copied now, while they hold what the game submitted. vk_usnap_begin takes the copy over when the replay
// reaches the submission, and leaves out there what GPU writes in flight touch (the replay knows them only then).
void vk_usnap_take(const uint64_t *handles, int n) {
if (!U.mode) U.mode = rt_knob("UBO_SNAPSHOT", "1") + 1;
if (n <= 0 || !RT_KNOB_ON(U.mode - 1)) return;
uint64_t t0 = kern_now_ns();
Taken *t = calloc(1, sizeof *t);
if (!t) rt_fatal("vk_usnap: out of memory");
t->n = n, t->first = handles[0];
int creg = 0;
plat_mutex_lock(&gather_lock);
U.ncol = 0;
for (int i = 0; i < n; i++)
if (handles[i]) collect(nvn_op_first(handles[i]), 0);
sort_col();
for (int i = 0; i < U.ncol;) { // merged, keeping what lies wholly in one imported pool
uint64_t lo = U.col[i].lo, hi = U.col[i].hi;
for (i++; i < U.ncol && U.col[i].lo <= hi; i++)
if (U.col[i].hi > hi) hi = U.col[i].hi;
VkMemSpan sp;
if (!vk_mem_resolve(lo, hi - lo, &sp) || sp.alloc || !sp.pool || sp.avail < hi - lo ||
(sp.cls != VK_MCLS_HOST_CACHED && sp.cls != VK_MCLS_HOST_UNC)) {
t->refused++;
continue;
}
if (t->nreg == creg) t->reg = grow(t->reg, &creg, sizeof *t->reg);
t->reg[t->nreg++] = (Reg){lo, hi, t->total, 0};
t->total += (hi - lo + ALIGN - 1) & ~(uint64_t)(ALIGN - 1);
}
plat_mutex_unlock(&gather_lock);
if (t->nreg && (t->arena = arena_get(t->total))) copy_regions(t->arena, t->reg, t->nreg, t->total);
t->ns = kern_now_ns() - t0;
plat_mutex_lock(&tq_lock);
if (tq_tail) tq_tail->next = t;
else tq_head = t;
tq_tail = t;
plat_mutex_unlock(&tq_lock);
}
static void taken_free(Taken *t) {
if (t->arena) arena_done(t->arena);
free(t->reg);
free(t);
}
// The copy vk_usnap_take made for this submission becomes the replay's. 0: none was taken.
static int adopt(const uint64_t *handles, int n) {
plat_mutex_lock(&tq_lock);
Taken *t = tq_head;
// in submission order: an entry ahead of this submission's belongs to one that never reached the replay
while (t && (t->n != n || t->first != (n > 0 ? handles[0] : 0))) {
tq_head = t->next;
taken_free(t);
t = tq_head;
}
if (t) tq_head = t->next;
if (!tq_head) tq_tail = NULL;
plat_mutex_unlock(&tq_lock);
if (!t) return 0;
U.refused += t->refused;
if (t->arena) {
wr_prune();
while (U.creg < t->nreg) U.reg = grow(U.reg, &U.creg, sizeof *U.reg);
memcpy(U.reg, t->reg, (size_t)t->nreg * sizeof *U.reg);
U.nreg = t->nreg;
for (int i = 0; i < U.nreg; i++) // a GPU write in flight: the mirror holds those bytes, not guest memory
if (wr_hits(U.reg[i].lo, U.reg[i].hi)) U.reg[i].dropped = 1, U.inflight++;
U.arena = t->arena, t->arena = NULL;
U.on = 1;
U.subs++, U.regions += (uint64_t)U.nreg, U.bytes += t->total, U.copy_ns += t->ns;
}
taken_free(t);
return 1;
}
void vk_usnap_begin(const uint64_t *handles, int n, uint64_t fence) {
if (!U.mode) U.mode = rt_knob("UBO_SNAPSHOT", "1") + 1; // stored +1: 0 is "not read yet"
U.on = 0, U.fence = fence, U.gen++;
@@ -253,7 +359,9 @@ void vk_usnap_begin(const uint64_t *handles, int n, uint64_t fence) {
U.off_subs++;
return;
}
if (adopt(handles, n)) return;
uint64_t t0 = kern_now_ns();
plat_mutex_lock(&gather_lock);
U.ncol = 0;
for (int i = 0; i < n; i++)
if (handles[i]) collect(nvn_op_first(handles[i]), 0);
@@ -280,17 +388,10 @@ void vk_usnap_begin(const uint64_t *handles, int n, uint64_t fence) {
U.reg[U.nreg++] = (Reg){lo, hi, total, 0};
total += (hi - lo + ALIGN - 1) & ~(uint64_t)(ALIGN - 1);
}
plat_mutex_unlock(&gather_lock);
if (!U.nreg) return;
if (!(U.arena = arena_get(total))) return;
uint8_t *dst = U.arena->map;
if (vk_dlaa_available()) {
if (!U.arena->cpu && !(U.arena->cpu = malloc(U.arena->size))) rt_fatal("vk_usnap: out of memory");
dst = U.arena->cpu;
}
for (int i = 0; i < U.nreg; i++)
memcpy(dst + U.reg[i].off, (const void *)(uintptr_t)U.reg[i].lo, U.reg[i].hi - U.reg[i].lo);
if (dst != U.arena->map) memcpy(U.arena->map, dst, total); // one sequential write into the device copy
vmaFlushAllocation(vk_ctx()->vma, U.arena->alloc, 0, total); // a no-op on coherent memory
copy_regions(U.arena, U.reg, U.nreg, total);
U.on = 1;
U.subs++, U.regions += (uint64_t)U.nreg, U.bytes += total, U.copy_ns += kern_now_ns() - t0;
}