Files
NX2navi/runtime/vk_sync.c
chrg6 518185b808 Set vk_sub_in_frame under the submission lock from the place the submitting thread noted, so a thread about to submit no longer changes it under another thread's replay.
vk_sub_in_frame says which of its frame's submissions the one being recorded is; vk_memory.c sub_place files the mirror
pre-snapshot's statistics by it during the replay, on the replaying thread and its workers. vk_be_submit wrote it on
every submitting thread before submit_sync, outside sub_lock, so with the replay on the submitting threads another
thread entering a submit changed it under a replay in progress, and the replay filed its work at the other
submission's place. Each thread now notes the place in a thread-local (vk_be_submit, and replay_main for the
asynchronous replay), and vk_sync.c submit publishes it right after taking the lock for a submission with commands
(vk_sub_in_frame_publish, a weak no-op without the back end). Submissions without commands of their own (held work, the
present thread's blit) leave it as it was, as before. Performance heuristics only; no timing change.

Not reproduced before the change. Checks that passed: pass_cc_check on every runtime file (a header changed), no new
-Wall -Wextra warnings in vk_backend.c and vk_sync.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00

1975 lines
112 KiB
C

// Submission, fences and the completion thread of the Vulkan back end.
// One timeline semaphore (VkCtx.timeline) orders everything: each submission records its command handles into a
// primary command buffer (vk_exec_record) and signals the next timeline value, which is the NVN fence value
// the front end hands to syncs. The completion thread waits on the timeline and, in submit order, runs the CPU side
// effects queued for each value (event writes, counter reports, sync state), then frees the memory and texture objects whose users
// completed. A value counts as done only after its side effects ran, so a guest woken by fence_wait sees them.
//
// One timeline value per submission, signalled at ALL_COMMANDS; waits use vkWaitSemaphores, and a failed wait is
// treated as a lost device.
// Contains code Copyright (c) 2026 Peiikai, Copyright (c) 2026 jhaizhou (MIT; see THIRD_PARTY_NOTICES.md).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cfg.h"
#include "gfx.h"
#include "host.h"
#include "nvn_api.h"
#include "prof.h"
#include "vk.h"
#include "vk_lowlat.h"
#include "vk_sync_hw.h"
#include "platform/platform.h"
#define WEAK __attribute__((weak))
typedef struct { uint64_t fence; void (*fn)(void *); void *arg; } Pending;
typedef struct { VkCommandBuffer cb; uint64_t fence; } CmdSlot; // reusable once the timeline reached fence
static PlatMutex start_lock = PLAT_MUTEX_INIT;
static int started;
static PlatThread *completion_thread;
// Submissions: one at a time, so timeline values are signalled in the order they are handed out.
static PlatMutex sub_lock = PLAT_MUTEX_INIT;
static VkCommandPool cmd_pool;
static CmdSlot *slots;
static int nslots;
static int cur_slot; // the slot vk_sync_submit records into now (an index: take_slot may move slots)
static uint64_t cur_fence; // the value of the submission being recorded
// The command buffers of the part being recorded that come before slots[cur_slot] (vk_sync_append): earlier pieces of
// the replay's own and those the replay workers recorded, in queue order.
#define PART_CBS 254
static VkCommandBuffer part_cbs[PART_CBS];
static int npart_cbs;
// Shared with the completion thread (st_lock).
static PlatMutex st_lock = PLAT_MUTEX_INIT;
static PlatCond cv_work = PLAT_COND_INIT; // a submission, a callback or stop
static PlatCond cv_done = PLAT_COND_INIT; // done advanced
static uint64_t issued, done; // newest submitted value; newest value whose side effects ran
static uint64_t reserved; // asynchronous replay: newest value handed out before its submission (>= issued)
// Staged mirror uploads (vk_memory.c "staged mirror uploads"): per submission value, whether its GPU still reads guest
// memory itself (value << 1 | live), under st_lock. A value not found counts as live.
#define LIVE_RING 4096
static uint64_t live_of[LIVE_RING];
// Per submission value: the newest submission up to it whose GPU reads guest memory itself (live_of), 0 none. The
// submit wait is for the frame's first submission, but the game's writes after it race with every submission before it
// that still reads guest memory: those of the frame before (the instances of its grass and moss, read through the
// device address table) were often still on the GPU when the wait ended at the first one's hand-over, and the game
// wrote the next frame's instances under them (flicker and sliding foliage, RX 580, 2026-11-11). The wait goes on until
// that one is done.
static uint64_t live_last_of[LIVE_RING], live_last;
// per value (index & LIVE_RING - 1): the newest value up to it whose completion hands results to the CPU (vk_exec_take_results)
static uint64_t res_of[LIVE_RING], res_last;
static uint8_t res_kinds[LIVE_RING]; // per value: its VK_RES_* kinds
// The early tail (vk_exec.c "the early tail"), per value under st_lock: early_of holds the value when its part with
// the write-backs signalled the executed timeline before its tail; empty_of when the submission handed the GPU nothing
// and the CPU no results (a submission held at its first event wait: Ocarina's first of a frame in the field).
static uint64_t early_of[LIVE_RING], empty_of[LIVE_RING];
static VkCommandBuffer tail_cb; // sub_lock: the tail's command buffer between hold and release
// sub_lock: this submission's tail went behind its write-backs; the parts of it handed over before the last
static int tail_early, sub_parts;
static volatile uint64_t tail_ns; // about how long the GPU takes for a tail (a moving average)
static volatile uint64_t tail_t0, tail_val; // the first early return for tail_val, for tail_ns
static volatile uint64_t tail_n, tail_waits; // since the last vk_sync_tail_stats: early tails; waits they ended
static int res_mask = 0x1f; // NX2NAVI_SUBMIT_FINISH_KINDS: the kinds the wait counts
static int stop;
static Pending *queue; // side effects in the order they were added
static int qhead, qn, qcap;
// The side effects the game's CPU reads (vk_completion_add_result: occlusion counts, counter reports, event values),
// in a queue of their own: the wait after a frame's first submission runs those that are due itself (results_run,
// "the executed timeline" below). One runner at a time, in the order they were added.
static Pending *rqueue;
static int rqhead, rqn, rqcap;
static PlatMutex res_lock = PLAT_MUTEX_INIT; // the runner; taken before st_lock
static void results_run(uint64_t upto);
// ---- submit batching
// Every game submission used to be its own vkQueueSubmit2, about 150 a frame, each a trip into the driver on the render
// thread. Now up to hold_max of them are held and go to the queue together, as one vkQueueSubmit2 with one
// VkSubmitInfo2 each, in the order they were made; a part of a long submission (vk_sync_split) goes at once, with what
// is held before it, since it was split off to start the GPU early. Queue order is what orders them with everything
// else, so held work goes to the queue before anything else does or is waited for:
// - every other submitter, and every idle wait, takes the queue lock through vk_queue_lock, which flushes first;
// - a fence wait for a value still held flushes (fence_wait_impl), as do vk_sync_finish and shutdown;
// - the completion thread flushes what has been held for HOLD_NS, so a guest that polls memory a held submission
// writes, instead of waiting on its fence, still sees it soon; it only tries the lock, never waits for it.
// H is guarded by the queue lock. NX2NAVI_SUBMIT_BATCH=0 (or 1) hands each to the queue at once; =N holds up to N.
#define HOLD_CAP 16
#define HOLD_NS 500000ull
// Command buffers of one submission: the mirror prologue, the parts cut before the part with their pre-pass command
// buffers (CHAIN_MAX), the part's pre-pass command buffer, the part (vk_sync_pre, vk_sync_cut, chain_infos).
#define CHAIN_MAX 64
#define SUB_CBS (CHAIN_MAX + PART_CBS + 3)
static struct {
int n;
VkSubmitInfo2 si[HOLD_CAP];
VkCommandBufferSubmitInfo cbi[HOLD_CAP][SUB_CBS];
VkSemaphoreSubmitInfo sig[HOLD_CAP][2];
VkLatencySubmissionPresentIdNV lat[HOLD_CAP]; // the frame's number for the driver's low latency (vk_lowlat.c)
uint64_t last; // the newest timeline value a held submission signals, 0 none
uint64_t batches, subs; // vkQueueSubmit2 calls and the submissions they carried
} H;
static volatile int32_t held_n; // H.n, for readers without the lock
// The GPU timeline (vk_gtl_note): the timestamp pairs of the held submissions, stamped with the time they really go to
// the queue (ts_queue_ns), as a held part waits here and not in the queue. hold_pend: closed by ts_submitted for the
// submission queue_submit takes next (sub_lock); hold_pairs: those of the held ones (queue lock).
#define HOLD_PAIRS 512
static uint32_t hold_pend[HOLD_PAIRS], hold_pairs[HOLD_PAIRS];
static int nhold_pend, nhold_pairs;
static uint64_t ts_queue_ns[1024]; // per pair (TS_PAIRS): when its submission went to the queue
static volatile uint64_t held_t0; // when the oldest held submission was held (kern_now_ns)
static volatile uint64_t queued_v; // the newest timeline value handed to the queue
static int hold_max = -1;
// ---- the executed timeline
// The wait after a frame's first submission (nvn_api_core.c nvnQueueSubmitCommands) is there so that the GPU has run
// that submission's passes before the game rewrites what they read. With NX2NAVI_WB_SPLIT=0 it is the fence's wait.
// With 1, the default, it is a wait on exec_tl, after which the waiting thread itself runs the results the game
// reads (results_run, the last paragraph but one below). exec_tl is a second timeline that reaches a submission's value when
// the GPU has executed its own commands: the replay ends the part before the write-backs (vk_exec.c,
// vk_sync_split_exec; vk_memory.c vk_mem_mirror_end: what the shaders wrote into VRAM mirrors goes back to guest
// memory), that part signals exec_tl, and the write-backs follow in a part of their own, which signals the timeline
// as before. A submission without that split signals both at its end. Every other wait, the fences the game asks for
// among them, is the fence's either way.
// That was the default for a day, on the thought that the wait covered 3 to 4 ms of write-backs a frame that nothing
// needed before the game went on. It left out more than GPU work. A value counts as done only when the completion
// thread has run its side effects and those of every value before it, and among them is what the game's CPU reads
// next: the occlusion counts, counter reports and event values (vk_exec_xfer.c oq_cb, counter_cb, event_cb), and
// the stamps that tell the texture checks which pages the GPU wrote (vk_gpu_written). A frame's first submission is
// small and runs right behind the last frame's large one, so its signal on exec_tl comes while the completion thread
// is still at that one's callbacks. One frame with the wait on exec_tl:
// 1. the GPU finishes the last frame's main submission; the completion thread wakes and starts on its callbacks,
// 800 to 1,000 occlusion counts among them in Hyrule Field;
// 2. the GPU finishes this frame's first submission and signals exec_tl: the wait returns and the game goes on;
// 3. the game reads its occlusion counts (a 0 culls what the query tested) while some still hold the frame
// before's; half a millisecond later, more when the CPUs are busy, the callbacks are through.
// Measured in Hyrule Field with the camera turning (RTX 5090, lazy write-back on, 2026-11-11; NX2NAVI_TRACE=perf, "waits
// after a submit on the executed timeline"): 2,267 of 2,320 such waits returned before those side effects had run,
// which were through 0.45 to 0.8 ms later on average and 12.6 ms at most; in that time 770 to 1,015 occlusion counts,
// 21 to 28 counter reports and 17 event values a frame were written into guest memory. The submission the game
// waits for wrote nothing back in any window of those runs, so its own write-backs were never what the wait left
// out. A player saw a patch of grass near the camera change from frame to frame there, and less of it with the
// fence's wait; no scripted run showed such a frame with either wait, so that the late counts are what the player
// saw is not shown. The fence's wait takes 0.65 to 0.7 ms more of a frame there: 85.5 against 86 and 92 frames a
// second in the field, 80.7 against 85.8, and 98 against 105 in a village, in single runs beside other work.
// Later a player's capture showed it plainly: in Hyrule Field, the camera nearly still, trees, ground and walls lost
// their direct light for single frames about every 36 frames (the game's own cycle there), 57 of 1,800 frames, with
// the wait on exec_tl alone and 28,000 to 33,000 occlusion counts per 60 frames written after the game had gone on.
// The fence's wait covers them but waits for everything else the completion thread does for the last frame's
// submissions as well: of its 0.49 ms a frame, 0.11 ms are the occlusion counts and 0.23 to 0.40 ms one loop that
// stamps the pages those submissions' shaders wrote (vk_exec_draw.c cb_done), which the game does not read.
// So the results the game reads have a queue of their own (vk_completion_add_result: occlusion counts, counter
// reports, event values), and the waiter runs those of its submission and of the ones before it as soon as exec_tl is
// reached (results_run; the GPU ran those submissions first, so their queries are complete). The completion thread
// runs them the same way when it reaches a value, before its other callbacks, and one runner at a time keeps them in
// the order they were queued. What stays behind the wait: the write-backs and the page stamps, as with the bare wait.
// A player's hand test of this wait at the place of that capture showed none of those frames (2026-11-11). No
// scripted run has measured it: the one run made for it, with the wait on exec_tl alone, standing in the same field,
// had 8,147 of 8,160 waits back early and 1,170 occlusion counts a frame written after them, and showed no such
// frame either, so the scripted scene does not reproduce what the player saw. What the wait costs against the
// bare one is not measured; the results it runs take 0.11 ms a frame on the completion thread there.
// NX2NAVI_WB_SPLIT=3 is the wait on exec_tl alone, as it was, to measure against.
// With shadow storage (vk_memory.c) NX2NAVI_WB_SPLIT=1 keeps the fence's wait as well: there the write-backs
// themselves reach guest memory in a completion callback. With an import a
// write-back stores into guest memory on the GPU, right behind the passes. With a shadow copy it stores into the copy,
// and the bytes reach guest memory in the fence's completion callback (shadow_wb_cb), after the write-back part, the
// fence and the callbacks queued before it, while the game is already rewriting for its next frame what those passes
// wrote. A write-back that lands after such a rewrite puts the GPU's older bytes over it in guest memory and moves the
// page's base copy along, so the next copy-in finds nothing the CPU changed; and a rewrite with the value the base
// copy holds (a reset to zero) is no difference at any time. The GPU then goes on from its own last output, as the
// game's lighting accumulators do, which the shaders add to and the CPU resets.
// Measured: with the split the whole 3D scene came out brighter for single frames (3.8 of 255, the interface as it
// was), each of them a frame whose main work was held at its CPU event wait and replayed late, together with the next
// frame's: standing outdoors, 695, 624, 443, 323 and 2 of 1,800 frames in five runs, and none in eight runs without
// the split (RTX 5090 with NX2NAVI_SHADOW=1, 2026-11-10). A player on Linux (RX 7800 XT, RADV) saw the open field lit
// in one frame and flat in the next; that it is the same fault is not confirmed on their machine. Which words the
// write-backs overtook was not traced: NX2NAVI_SHADOW_CHECK counts no byte that both sides changed to different
// values, which fits a reset to the base copy's value.
// NX2NAVI_WB_SPLIT=2 keeps the split with shadow storage too, to measure that fault.
static VkSemaphore exec_tl;
static uint64_t exec_sig; // sub_lock: the newest value a submitted part signals on exec_tl
// ---- a lost device in the timelines' values (vk_sync_hw.h)
// The newest value any signal of the timeline (tl_hw) and of exec_tl (ex_hw) carries, noted before the signal can
// happen: by hold_flush_locked before vkQueueSubmit2, by the host's own signals before vkSignalSemaphore. A value read
// above the note ends the run as a lost device (timeline_beyond). Reaching those values is no proof the device lives:
// a driver that reports every pending signal as done after a reset reads at or below the note, and the line an
// occlusion query left unwritten logs (vk_exec_xfer.c oq_cb, vk_sync_state_text) shows which of the two a report had.
static volatile uint64_t tl_hw, ex_hw;
static int wb_split = -1;
static int wb_results; // the wait on exec_tl also runs the results the game reads (not with 3)
int vk_sync_wb_split(void) {
if (wb_split < 0) {
const char *e = cfg_get("NX2NAVI_WB_SPLIT");
int forced = e && !strcmp(e, "2"), bare = e && !strcmp(e, "3"), shadow = vk_mem_shadow_storage();
wb_results = !bare;
wb_split = cfg_bool("NX2NAVI_WB_SPLIT") && (forced || !shadow);
rt_log("vk: the wait after a frame's first submission %s (NX2NAVI_WB_SPLIT%s)",
!wb_split ? "is the fence's: it covers the write-backs and the completion thread's side effects"
: wb_results ? "ends when the GPU has run the submission's own commands and the results the game reads "
"are in its memory: the write-backs and the other side effects are left out"
: "ends when the GPU has run the submission's own commands: its write-backs and every side "
"effect of the completion thread are left out",
shadow && !forced ? "; shadow storage keeps the fence's wait unless it is 2" : "");
}
return wb_split && exec_tl != VK_NULL_HANDLE;
}
// What a wait on exec_tl leaves behind: the completion thread's side effects for the values up to the waiter's.
// Counted for NX2NAVI_TRACE=perf (vk_exec.c logs them): the waits that returned with such side effects still to
// run, how long after the return they were through, and (vk_sync_effect_late, asked by the callbacks that write
// into guest memory) how many of those ran in that time.
static volatile uint64_t exw_n, exw_early, exw_lag_ns, exw_lag_max; // since the last vk_sync_exec_stats
static volatile uint64_t exw_value, exw_t0; // the newest wait that returned early: its value and when; 0 none
static volatile int32_t cb_late; // the callback being run belongs to a value such a waiter is past
int vk_sync_effect_late(void) { return __atomic_load_n(&cb_late, __ATOMIC_RELAXED); }
// The value whose side effect the calling thread is running, 0 when it is not the completion thread inside one.
static volatile uint64_t cb_value;
static volatile uint32_t cb_tid;
uint64_t vk_sync_cb_value(void) {
return __atomic_load_n(&cb_tid, __ATOMIC_ACQUIRE) == plat_thread_id() ? __atomic_load_n(&cb_value, __ATOMIC_RELAXED) : 0;
}
void vk_sync_exec_stats(uint64_t *waits, uint64_t *early, uint64_t *lag_ns, uint64_t *lag_max_ns) {
*waits = __atomic_exchange_n(&exw_n, 0, __ATOMIC_RELAXED), *early = __atomic_exchange_n(&exw_early, 0, __ATOMIC_RELAXED);
*lag_ns = __atomic_exchange_n(&exw_lag_ns, 0, __ATOMIC_RELAXED);
*lag_max_ns = __atomic_exchange_n(&exw_lag_max, 0, __ATOMIC_RELAXED);
}
// NX2NAVI_TRACE=perf: the completion thread's time by callback (st_lock), since the last vk_sync_cb_stats. A callback
// is named by its distance from vk_completion_add, which the build's link map turns into its function.
#define CBT_N 32
static struct { void (*fn)(void *); uint64_t ns, calls; } cbt[CBT_N];
static int cbt_n;
void vk_sync_cb_stats(char *out, size_t n) {
int k = 0;
*out = 0;
plat_mutex_lock(&st_lock);
for (int i = 0; i < cbt_n && k < (int)n - 64; i++) {
if (!cbt[i].calls) continue;
k += snprintf(out + k, n - (size_t)k, " %+lld %.3f ms %llu calls;",
(long long)((intptr_t)cbt[i].fn - (intptr_t)vk_completion_add), cbt[i].ns / 1e6,
(unsigned long long)cbt[i].calls);
cbt[i].ns = cbt[i].calls = 0;
}
plat_mutex_unlock(&st_lock);
}
// NX2NAVI_DEVICE_LOST_AT=n (a test of the lost device's end, vk_device.c vk_device_lost_check): the n-th hand-over to
// the queue is not made and counts as one the driver answered with a lost device. 0 or unset: never.
static int lost_at_due(void) {
static int at = -1;
static int32_t calls;
if (at < 0) {
const char *e = cfg_get("NX2NAVI_DEVICE_LOST_AT");
at = e && *e ? atoi(e) : 0;
if (at < 0) at = 0;
if (at) rt_log("vk: queue hand-over %d will be taken as a lost device (NX2NAVI_DEVICE_LOST_AT)", at);
}
return at && ++calls == at; // queue lock held
}
static void hold_flush_locked(VkCtx *c) {
if (!H.n) return;
if (vk_device_is_lost()) { // the run is ending: the held submissions are dropped, nothing reaches the queue
H.n = 0, H.last = 0;
__atomic_store_n(&held_n, 0, __ATOMIC_RELEASE);
return;
}
// every value this hand-over signals is noted before the queue can reach it ("a lost device in the timelines'
// values")
for (int i = 0; i < H.n; i++)
for (uint32_t k = 0; k < H.si[i].signalSemaphoreInfoCount; k++) {
const VkSemaphoreSubmitInfo *s = &H.si[i].pSignalSemaphoreInfos[k];
if (s->semaphore == c->timeline) sync_hw_raise(&tl_hw, s->value);
else if (s->semaphore == exec_tl) sync_hw_raise(&ex_hw, s->value);
}
VkResult r = lost_at_due() ? VK_ERROR_DEVICE_LOST : vkQueueSubmit2(c->gfx_queue, (uint32_t)H.n, H.si, VK_NULL_HANDLE);
if (r != VK_SUCCESS) {
vk_mem_oom_report("replay submit", r);
vk_device_lost_check(r, "the submit of the replay's command buffers");
rt_fatal("vk: vkQueueSubmit2 of %d held submissions up to value %llu failed (%d)", H.n,
(unsigned long long)H.last, (int)r);
}
if (H.last) __atomic_store_n(&queued_v, H.last, __ATOMIC_RELEASE);
uint64_t now = plat_now_ns();
for (int k = 0; k < nhold_pairs; k++) ts_queue_ns[hold_pairs[k]] = now;
nhold_pairs = 0;
H.batches++, H.subs += (uint64_t)H.n;
H.n = 0, H.last = 0;
__atomic_store_n(&held_n, 0, __ATOMIC_RELEASE);
}
void vk_sync_flush_held(void) {
if (!H.n) return; // queue lock held: H is stable
VkCtx *c = vk_ctx();
if (c) hold_flush_locked(c);
}
// The replay's submission si (signalling value, 0 for a part without a signal), held or handed to the queue.
static void queue_submit(VkCtx *c, const VkSubmitInfo2 *si, uint64_t value) {
if (hold_max < 0) {
const char *e = cfg_get("NX2NAVI_SUBMIT_BATCH");
int v = e && *e ? atoi(e) : 4;
hold_max = v < 1 ? 1 : v > HOLD_CAP ? HOLD_CAP : v;
rt_log("vk: up to %d submissions handed to the queue together (NX2NAVI_SUBMIT_BATCH)", hold_max);
}
// The present holds this lock for its whole flip. A slow flip (frame generation allocating, a swapchain remade) makes
// the submitting thread wait here; counted as a present wait, so the slow-frame line no longer shows such a frame
// with nothing blocked.
uint64_t p0 = perf_begin();
vk_queue_lock_raw();
perf_end(PERF_FENCE, p0);
if (H.n == HOLD_CAP) hold_flush_locked(c);
for (int k = 0; k < nhold_pend && nhold_pairs < HOLD_PAIRS; k++) hold_pairs[nhold_pairs++] = hold_pend[k];
nhold_pend = 0;
int i = H.n++;
H.si[i] = *si;
if (si->commandBufferInfoCount > SUB_CBS) rt_fatal("vk: a submission of %u command buffers", si->commandBufferInfoCount);
if (si->commandBufferInfoCount)
memcpy(H.cbi[i], si->pCommandBufferInfos, sizeof H.cbi[i][0] * si->commandBufferInfoCount);
H.si[i].pCommandBufferInfos = H.cbi[i];
if (si->signalSemaphoreInfoCount) { // at most two: the timeline and the executed timeline (exec_tl)
memcpy(H.sig[i], si->pSignalSemaphoreInfos, sizeof H.sig[i][0] * si->signalSemaphoreInfoCount);
H.si[i].pSignalSemaphoreInfos = H.sig[i];
}
// NVIDIA's low latency on (vk_lowlat.c): the submission carries the number of the frame being replayed, so the
// driver counts its GPU time to that frame. The callers' submissions have no chain of their own.
if (vk_lowlat_submit_tag(&H.lat[i])) H.si[i].pNext = &H.lat[i];
if (value) H.last = value;
uint64_t now = kern_now_ns();
if (i == 0) __atomic_store_n(&held_t0, now, __ATOMIC_RELEASE);
__atomic_store_n(&held_n, H.n, __ATOMIC_RELEASE);
// A part of a long submission (no value) goes at once with whatever is held: parts exist to start the GPU early.
// So do held submissions that have waited HOLD_NS, and a full batch.
if (H.n >= hold_max || !value || now - __atomic_load_n(&held_t0, __ATOMIC_ACQUIRE) >= HOLD_NS) hold_flush_locked(c);
vk_queue_unlock();
}
// Hands the held submissions to the queue when value is among them (a waiter needs it) or they have waited HOLD_NS
// (aged). The completion thread passes try_only: it never waits for the queue lock, whose holders may wait for it.
static void hold_release(uint64_t value, int aged, int try_only) {
if (!__atomic_load_n(&held_n, __ATOMIC_ACQUIRE)) return;
int due = value > __atomic_load_n(&queued_v, __ATOMIC_ACQUIRE) ||
(aged && kern_now_ns() - __atomic_load_n(&held_t0, __ATOMIC_ACQUIRE) >= HOLD_NS);
if (!due) return;
if (try_only) {
if (!vk_queue_trylock_raw()) return; // the holder's thread is submitting: it flushes or holds on
} else {
vk_queue_lock_raw();
}
vk_sync_flush_held();
vk_queue_unlock();
}
// ---- GPU time (NX2NAVI_TRACE=perf): each submission part (vk_sync_split) writes a timestamp when it starts and when it
// ends, into a ring of query pairs. The completion thread reads a pair once the part's value is reached and adds the
// part's interval to the busy time, counting overlap with the previous part once. Off (one log line) when the queue
// family has no timestamp bits or the period is 0. Upload batches (vk_memory.c) are not timed.
#define TS_PAIRS 1024
_Static_assert(TS_PAIRS == sizeof ts_queue_ns / sizeof ts_queue_ns[0], "ts_queue_ns has a slot per pair");
static VkQueryPool ts_pool;
static int ts_state; // 0 not set up, 1 on, -1 off
static double ts_period; // ns per tick
static uint64_t ts_mask; // valid bits of a timestamp
static uint32_t ts_next; // submitting thread (sub_lock): next pair
static volatile uint8_t ts_busy[TS_PAIRS]; // written until the completion thread read the pair
static int ts_cur = -1; // the pair of the part being recorded, -1 untimed
static uint64_t ts_last_end; // completion thread: end of the latest part read (ticks)
static volatile int64_t ts_busy_ns, ts_parts, ts_missed;
// The same durations, never reset: dynamic resolution differences it across presented frames (the perf window
// consumes ts_busy_ns, so a reader that sampled it would steal from the perf line and see a reset every window).
static volatile int64_t ts_busy_all;
// GPU idle (with calibrated timestamps): the gap before each timed part, split by whether the part was submitted after
// the previous one had ended (the GPU waited for the CPU: starved) or before (it waited for untimed work or a wait).
static uint64_t ts_sub_ns[TS_PAIRS]; // CPU time the part was handed to vkQueueSubmit2
// A pair to read at its value. mirror: the copies of a mirror prologue (vk_sync.c prologue), counted apart as well.
// mirror: GTL_PART, GTL_MIRROR or GTL_PRE (the GPU timeline's kinds, below); cb: the command buffer it times.
typedef struct { uint32_t pair; int mirror; uint64_t fence; VkCommandBuffer cb; } TsRead;
// closed and not yet submitted (sub_lock): with cuts a submission carries up to SUB_CBS command buffers, each timed;
// with room for 4 the rest went to the completion queue at once, without a submit time and out of the GPU's order, and
// their gaps counted as idle "with it queued"
#define TS_UNSENT (2 * SUB_CBS)
static TsRead *ts_unsent[TS_UNSENT];
static int ts_n_unsent;
static volatile int64_t ts_mirror_ns; // of ts_busy_ns, the mirror prologues' copies
static uint64_t ts_last_end_ns; // completion thread: ts_last_end on the CPU clock
static volatile int64_t ts_idle_ns, ts_starved_ns, ts_gaps, ts_max_gap_ns, ts_queued_ns;
static volatile int64_t cal_tick, cal_ns, cal_at; // completion thread: a calibrated (GPU tick, CPU ns) pair and when
// it was taken; the GPU timeline's notes read them from other threads
static PFN_vkGetCalibratedTimestampsKHR cal_fn;
// On the first submission that wants timing (perf_gpu_timing: NX2NAVI_TRACE=perf or the full performance overlay).
static void ts_setup(VkCtx *c) {
ts_state = -1;
uint32_t nq = 0;
vkGetPhysicalDeviceQueueFamilyProperties(c->phys, &nq, NULL);
VkQueueFamilyProperties *qp = calloc(nq ? nq : 1, sizeof *qp);
if (!qp) return;
vkGetPhysicalDeviceQueueFamilyProperties(c->phys, &nq, qp);
uint32_t bits = c->gfx_family < nq ? qp[c->gfx_family].timestampValidBits : 0;
free(qp);
float period = c->props.limits.timestampPeriod;
if (!bits || period <= 0) {
rt_log("vk: no GPU timestamps on this queue (valid bits %u, period %.3f ns): the perf line shows no GPU time", bits, period);
return;
}
VkQueryPoolCreateInfo qi = {.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO, .queryType = VK_QUERY_TYPE_TIMESTAMP,
.queryCount = 2 * TS_PAIRS};
VkResult r = vkCreateQueryPool(c->device, &qi, NULL, &ts_pool);
if (r != VK_SUCCESS) {
rt_log("vk: timestamp query pool failed (%d): the perf line shows no GPU time", (int)r);
return;
}
ts_period = period;
ts_mask = bits >= 64 ? ~0ull : (1ull << bits) - 1;
ts_state = 1;
}
// Completion thread: refresh the GPU tick / CPU clock pair every 250 ms; 0 when there is no calibration. The host
// domain is the clock plat_now_ns reads (QPC on Windows, CLOCK_MONOTONIC elsewhere), plat_counter_hz ticks a second.
static int cal_refresh(VkCtx *c) {
if (!c->has_calibrated) return 0;
if (!cal_fn) {
cal_fn = (PFN_vkGetCalibratedTimestampsKHR)vk_dpa(c->device, c->has_calibrated == 2 ? "vkGetCalibratedTimestampsKHR"
: "vkGetCalibratedTimestampsEXT");
if (!cal_fn) { c->has_calibrated = 0; return 0; }
}
int64_t now = (int64_t)plat_now_ns();
if (cal_at && now - cal_at < 250000000) return 1;
VkCalibratedTimestampInfoKHR ci[2] = {{.sType = VK_STRUCTURE_TYPE_CALIBRATED_TIMESTAMP_INFO_KHR, .timeDomain = VK_TIME_DOMAIN_DEVICE_KHR},
#ifdef _WIN32
{.sType = VK_STRUCTURE_TYPE_CALIBRATED_TIMESTAMP_INFO_KHR, .timeDomain = VK_TIME_DOMAIN_QUERY_PERFORMANCE_COUNTER_KHR}};
#else
{.sType = VK_STRUCTURE_TYPE_CALIBRATED_TIMESTAMP_INFO_KHR, .timeDomain = VK_TIME_DOMAIN_CLOCK_MONOTONIC_KHR}};
#endif
uint64_t v[2], dev;
if (cal_fn(c->device, 2, ci, v, &dev) != VK_SUCCESS) {
if (!cal_at) { // the domain pair is not supported: no split
rt_log("vk: calibrated timestamps failed: the perf line shows no GPU idle split");
c->has_calibrated = 0;
}
return cal_at != 0;
}
cal_tick = (int64_t)(v[0] & ts_mask), cal_ns = (int64_t)((double)v[1] * 1e9 / (double)plat_counter_hz()), cal_at = now;
return 1;
}
static int64_t tick_ns(uint64_t t) { return cal_ns + (int64_t)((double)((int64_t)t - cal_tick) * ts_period); }
static void max64(volatile int64_t *m, int64_t v) {
int64_t o = __atomic_load_n(m, __ATOMIC_RELAXED);
while (v > o && !__atomic_compare_exchange_n(m, &o, v, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) {}
}
static void ts_read_cb(void *arg);
// ---- the GPU timeline (NX2NAVI_TRACE=perf, vk.h vk_gtl_note): every timed piece of GPU work, with the CPU time it was
// handed to the queue and its GPU start and end on the CPU clock (calibrated timestamps). The replay's parts, their
// mirror prologues and pre-pass command buffers come from here, the upload batches from vk_memory.c, the presents from
// gfx_present.c. Every 60 frames (vk_gtl_log) the pieces are put in GPU order and each gap between them is split:
// starved, nothing handed to the queue yet, or queued, the next piece handed over and not started; the queued part is
// summed by the kind of the piece that started after it. A gap with work queued is a wait inside the queue (a
// semaphore, the present's acquire) or the driver's and the system's delay to start work on an idle GPU.
#define GTL_N 16384
typedef struct { int kind; int64_t call, sub, start, end; } GtlEv; // call: handed to vk_sync's submit; sub: to the queue
static GtlEv gtl[GTL_N];
static int gtl_n;
static uint64_t gtl_dropped;
static PlatMutex gtl_lock = PLAT_MUTEX_INIT;
static const char *const gtl_names[GTL_KINDS] = {"replay parts", "mirror prologues", "pre-pass buffers", "upload batches",
"presents"};
// The last timed pieces, for the line of a frame whose render thread waited long for the GPU (vk_gpu_parts_log): each
// piece's hand-over to the queue, GPU start and end (plat_now_ns) and its kind (vk.h GTL_*). Every kind enters, through
// vk_gtl_note_ns under gtl_lock: the replay's parts as the completion thread reads them, the upload batches when their
// batch retires (vk_memory.c up_ts_read), the presents when their slot is used again (gfx_present.c pts_read). So the
// entries are not in GPU order; the reader sorts what it takes. It takes the newest PART_RING / 2 entries, which the
// writers do not reach before it has read them unless that many pieces end meanwhile (an area load adds hundreds of
// upload batches).
#define PART_RING 2048
static struct { int64_t sub, sn, en; int kind; } part_ring[PART_RING];
static volatile uint64_t part_n;
static void vk_gtl_note_ns(int kind, int64_t call, int64_t sub, int64_t start, int64_t end) {
if (!(rt_trace & RT_TRACE_PERF) || kind < 0 || kind >= GTL_KINDS || !sub || end < start) return;
plat_mutex_lock(&gtl_lock);
if (gtl_n < GTL_N) gtl[gtl_n++] = (GtlEv){kind, call ? call : sub, sub, start, end};
else gtl_dropped++;
uint64_t pn = part_n;
part_ring[pn % PART_RING].sub = sub, part_ring[pn % PART_RING].sn = start;
part_ring[pn % PART_RING].en = end, part_ring[pn % PART_RING].kind = kind;
__atomic_store_n(&part_n, pn + 1, __ATOMIC_RELEASE);
plat_mutex_unlock(&gtl_lock);
}
void vk_gtl_note(int kind, uint64_t sub_ns, uint64_t start_tick, uint64_t end_tick) {
if (!(rt_trace & RT_TRACE_PERF) || ts_state != 1 || !__atomic_load_n(&cal_at, __ATOMIC_ACQUIRE)) return;
uint64_t s = start_tick & ts_mask, e = end_tick & ts_mask;
if (e < s) return;
vk_gtl_note_ns(kind, (int64_t)sub_ns, (int64_t)sub_ns, tick_ns(s), tick_ns(e));
}
// ---- the game's waits for GPU work (NX2NAVI_TRACE=perf): for each submission value, when the game handed it to the
// replay (vk_sync_reserve; the submit itself without asynchronous replay), when the replay had recorded and submitted it
// (issued) and when it was done (GPU and completion). A fence wait of any thread but the replay's is split into its
// time until the waited submission was submitted (the replay still recording it or those before it) and its time after
// (the GPU, the submit batching's hold, the completion thread). Every 60 frames with the GPU timeline (vk_gtl_log).
#define GW_RING 4096
typedef struct { uint64_t v, res, iss, done; } GwSub;
static GwSub gw[GW_RING];
static PlatMutex gw_lock = PLAT_MUTEX_INIT;
static uint64_t gw_n, gw_ns, gw_rep_ns, gw_gpu_ns, gw_lead_ns, gw_age, gw_before, gw_lat_rep, gw_lat_gpu, gw_lat_n;
static uint64_t gw_max_ns, gw_unknown, gw_rec_n, gw_rec_done, gw_rec_self, gw_need_back, gw_need_n;
static uint64_t gw_need_kind[5], gw_skip_kind[5]; // VK_RES_KINDS // gw_rec: vk_sync_recorded_wait, those it ended
static GwSub *gw_at(uint64_t v) { // gw_lock held
GwSub *e = &gw[v & (GW_RING - 1)];
if (e->v != v) *e = (GwSub){v, 0, 0, 0};
return e;
}
static void gw_mark(uint64_t v, int what) {
if (!(rt_trace & RT_TRACE_PERF) || !v) return;
uint64_t now = plat_now_ns();
plat_mutex_lock(&gw_lock);
GwSub *e = gw_at(v);
if (what == 0) e->res = now;
else if (what == 1) e->iss = now;
else if (!e->done) e->done = now;
plat_mutex_unlock(&gw_lock);
}
// the waits: t0 to t1 for value; newest: the newest value handed out at t0, sent: the newest submitted at t0
static void gw_wait(uint64_t value, uint64_t t0, uint64_t t1, uint64_t newest, uint64_t sent) {
if (!(rt_trace & RT_TRACE_PERF) || t1 <= t0 || plat_thread_id() == prof_replay_tid) return;
uint64_t d = t1 - t0;
plat_mutex_lock(&gw_lock);
GwSub e = gw[value & (GW_RING - 1)];
gw_n++, gw_ns += d;
if (d > gw_max_ns) gw_max_ns = d;
if (e.v != value || !e.iss) {
gw_unknown++, gw_gpu_ns += d;
} else {
uint64_t rep = e.iss > t0 ? (e.iss < t1 ? e.iss : t1) - t0 : 0;
gw_rep_ns += rep, gw_gpu_ns += d - rep;
if (e.res && e.res <= t0) gw_lead_ns += t0 - e.res;
if (e.res && e.done && e.iss >= e.res && e.done >= e.iss)
gw_lat_rep += e.iss - e.res, gw_lat_gpu += e.done - e.iss, gw_lat_n++;
}
if (newest > value) gw_age += newest - value;
if (value > sent + 1) gw_before += value - sent - 1;
plat_mutex_unlock(&gw_lock);
}
static void gw_log(uint64_t frame, int frames) {
plat_mutex_lock(&gw_lock);
uint64_t n = gw_n, ns = gw_ns, rep = gw_rep_ns, gpu = gw_gpu_ns, lead = gw_lead_ns, age = gw_age, before = gw_before,
lr = gw_lat_rep, lg = gw_lat_gpu, ln = gw_lat_n, mx = gw_max_ns, unk = gw_unknown, rn = gw_rec_n,
rd = gw_rec_done, rs = gw_rec_self;
gw_n = gw_ns = gw_rep_ns = gw_gpu_ns = gw_lead_ns = gw_age = gw_before = gw_lat_rep = gw_lat_gpu = gw_lat_n = 0;
uint64_t nb = gw_need_back, nn = gw_need_n, nk[5], sk[5];
memcpy(nk, gw_need_kind, sizeof nk), memcpy(sk, gw_skip_kind, sizeof sk);
memset(gw_need_kind, 0, sizeof gw_need_kind), memset(gw_skip_kind, 0, sizeof gw_skip_kind);
gw_max_ns = gw_unknown = gw_rec_n = gw_rec_done = gw_rec_self = gw_need_back = gw_need_n = 0;
plat_mutex_unlock(&gw_lock);
if (!n) {
rt_log("perf: frame %llu: waits for GPU work outside the replay: none", (unsigned long long)frame);
return;
}
double f = frames;
rt_log("perf: frame %llu: waits for GPU work outside the replay: %.1f per frame, %.2f ms per frame (longest %.2f ms): "
"%.2f ms until the replay had submitted the waited submission (%.2f submissions still before it in the replay "
"on average), %.2f ms after (GPU, submit batching hold, completion; %llu without a record); the waited one was "
"%.2f submissions behind the newest and handed to the replay %.2f ms before the wait; per waited submission "
"%.2f ms from hand-over to submit and %.2f ms from submit to done",
(unsigned long long)frame, n / f, ns / 1e6 / f, mx / 1e6, rep / 1e6 / f, (double)before / (double)n,
gpu / 1e6 / f, (unsigned long long)unk, (double)age / (double)n, lead / 1e6 / (double)n,
ln ? lr / 1e6 / (double)ln : 0.0, ln ? lg / 1e6 / (double)ln : 0.0);
if (rn) rt_log("perf: frame %llu: submit waits: %llu, %llu of them without the GPU's run of the submission itself (%llu of "
"those waited for it anyway: it hands the CPU results), the rest went on to the GPU (the submission's GPU "
"read guest memory itself)", (unsigned long long)frame, (unsigned long long)rn, (unsigned long long)rd,
(unsigned long long)rs);
if (nn) rt_log("perf: frame %llu: the submission the submit wait waited for was %.2f submissions before the waited one; "
"its kinds: events %llu, counter reports %llu, occlusion reports %llu, unmirrored writes %llu, write-backs "
"%llu; the submissions after it (left out by the mask): %llu, %llu, %llu, %llu, %llu",
(unsigned long long)frame, (double)nb / (double)nn, (unsigned long long)nk[0], (unsigned long long)nk[1],
(unsigned long long)nk[2], (unsigned long long)nk[3], (unsigned long long)nk[4], (unsigned long long)sk[0],
(unsigned long long)sk[1], (unsigned long long)sk[2], (unsigned long long)sk[3], (unsigned long long)sk[4]);
}
static int gtl_cmp(const void *a, const void *b) {
int64_t x = ((const GtlEv *)a)->start, y = ((const GtlEv *)b)->start;
return x < y ? -1 : x > y;
}
void vk_gtl_log(uint64_t frame, int frames) {
static GtlEv ev[GTL_N];
gw_log(frame, frames);
plat_mutex_lock(&gtl_lock);
int n = gtl_n;
memcpy(ev, gtl, sizeof ev[0] * (size_t)n);
gtl_n = 0;
uint64_t dropped = gtl_dropped;
gtl_dropped = 0;
plat_mutex_unlock(&gtl_lock);
if (n < 2 || frames < 1) return;
qsort(ev, (size_t)n, sizeof ev[0], gtl_cmp);
double busy = 0, starved = 0, queued = 0, q_by[GTL_KINDS] = {0}, lat[GTL_KINDS] = {0}, gpu[GTL_KINDS] = {0};
double held = 0, held_idle = 0; // replay pieces: held in the submit batching; of it, while the GPU was idle
int big[GTL_KINDS] = {0}, nlat[GTL_KINDS] = {0}, cnt[GTL_KINDS] = {0};
int64_t end = ev[0].start;
for (int i = 0; i < n; i++) {
const GtlEv *e = &ev[i];
cnt[e->kind]++, gpu[e->kind] += (double)(e->end - e->start);
held += (double)(e->sub - e->call);
if (e->start > end) { // a gap before e
int64_t gap = e->start - end, s = e->sub > end ? e->sub - end : 0;
int64_t h0 = e->call > end ? e->call : end, h1 = e->sub < e->start ? e->sub : e->start;
if (h1 > h0) held_idle += (double)(h1 - h0);
if (s > gap) s = gap;
starved += (double)s, queued += (double)(gap - s), q_by[e->kind] += (double)(gap - s);
if (gap - s > 500000) big[e->kind]++;
if (e->sub >= end) lat[e->kind] += (double)(e->start - e->sub), nlat[e->kind]++; // handed to an idle GPU
}
int64_t from = e->start > end ? e->start : end;
if (e->end > from) busy += (double)(e->end - from), end = e->end;
}
double f = 1e6 * frames;
char by[512], la[512], gp[512];
int a = 0, b = 0, g = 0;
for (int k = 0; k < GTL_KINDS; k++) {
if (!cnt[k]) continue;
a += snprintf(by + a, sizeof by - (size_t)a, "%s%s %.2f ms (%.1f gaps over 0.5 ms)", a ? ", " : "", gtl_names[k],
q_by[k] / f, (double)big[k] / frames);
b += snprintf(la + b, sizeof la - (size_t)b, "%s%s %.2f ms (%d)", b ? ", " : "", gtl_names[k],
nlat[k] ? lat[k] / nlat[k] / 1e6 : 0.0, nlat[k]);
g += snprintf(gp + g, sizeof gp - (size_t)g, "%s%s %.1f x %.2f ms", g ? ", " : "", gtl_names[k],
(double)cnt[k] / frames, gpu[k] / f);
}
rt_log("perf: frame %llu: GPU timeline per frame: busy %.2f ms; idle %.2f ms starved (nothing in the queue; %.2f ms of "
"it while the next piece was held in the submit batching) and %.2f ms with work in the queue, before %s; held "
"in the submit batching %.2f ms in all", (unsigned long long)frame, busy / f, starved / f, held_idle / f,
queued / f, by, held / f);
rt_log("perf: frame %llu: GPU timeline: pieces per frame and their GPU time: %s; from hand-over to start on an idle "
"GPU, on average: %s; %d pieces, %llu dropped", (unsigned long long)frame, gp, la, n, (unsigned long long)dropped);
}
// Just before vkQueueSubmit2 (sub_lock held): the parts closed since the last submit leave the CPU now. Their reads
// are queued in the order the GPU runs them: a mirror prologue is closed after the part it was made for but runs
// first. Read after its part, its copies counted neither as busy time nor as a gap of their own: they showed up as
// idle time with work queued, 7-8 ms a frame over PCIe 3.0 in Kokiri Forest, and the frame as CPU-bound.
// cbi: the submission's command buffers in queue order; their pairs are read in that order (a part's pre-pass command
// buffer and a cut part's are closed in another order than they run).
static void ts_submitted(const VkCommandBufferSubmitInfo *cbi, uint32_t ncb) {
uint64_t now = plat_now_ns();
gfx_tl_note('S', now, cur_fence, 0);
for (uint32_t k = 0; k <= ncb; k++) // k == ncb: whatever no command buffer named, last
for (int i = 0; i < ts_n_unsent; i++) {
TsRead *t = ts_unsent[i];
if (!t || (k < ncb ? t->cb != cbi[k].commandBuffer : 0)) continue;
ts_sub_ns[t->pair] = now;
ts_queue_ns[t->pair] = 0;
if (nhold_pend < HOLD_PAIRS) hold_pend[nhold_pend++] = t->pair;
vk_completion_add(t->fence, ts_read_cb, t);
ts_unsent[i] = NULL;
}
ts_n_unsent = 0;
}
static void ts_read_cb(void *arg) { // completion thread, in submission order
TsRead *t = arg;
VkCtx *c = vk_ctx();
uint64_t v[2];
if (vkGetQueryPoolResults(c->device, ts_pool, 2 * t->pair, 2, sizeof v, v, sizeof v[0], VK_QUERY_RESULT_64_BIT) == VK_SUCCESS) {
uint64_t s = v[0] & ts_mask, e = v[1] & ts_mask;
if (e >= s) {
if (cal_refresh(c)) {
int64_t sn = tick_ns(s), en = tick_ns(e), sub = (int64_t)ts_sub_ns[t->pair];
if (ts_last_end_ns && sn > ts_last_end_ns) {
int64_t gap = sn - ts_last_end_ns, late = sub - ts_last_end_ns;
plat_add64(&ts_idle_ns, gap);
if (late > 0) plat_add64(&ts_starved_ns, late < gap ? late : gap);
if (gap > 500000) plat_inc64(&ts_gaps);
max64(&ts_max_gap_ns, gap);
}
if (sub && sn > sub) plat_add64(&ts_queued_ns, sn - sub);
gfx_tl_note('G', (uint64_t)sn, (uint64_t)en, (uint64_t)sub);
if (en > ts_last_end_ns) ts_last_end_ns = en;
int64_t q = (int64_t)ts_queue_ns[t->pair];
vk_gtl_note_ns(t->mirror, sub, q ? q : sub, sn, en); // also the parts' ring (vk_gpu_parts_log)
}
uint64_t from = s > ts_last_end ? s : ts_last_end;
if (e > from) {
int64_t ns = (int64_t)((double)(e - from) * ts_period);
plat_add64(&ts_busy_ns, ns);
if (t->mirror == GTL_MIRROR) plat_add64(&ts_mirror_ns, ns);
plat_add64(&ts_busy_all, ns);
perf_gpu_busy((uint64_t)ns);
}
if (e > ts_last_end) ts_last_end = e;
plat_inc64(&ts_parts);
} else plat_inc64(&ts_missed); // the counter wrapped
} else plat_inc64(&ts_missed);
__atomic_store_n(&ts_busy[t->pair], 0, __ATOMIC_RELEASE);
free(t);
}
// NX2NAVI_TRACE=perf (perf.c): a frame whose render thread waited 20 ms or more for GPU fences gets, at the next
// present, a line of the timed parts that ended in its interval [t0, t1]: how many, the GPU time they took, the
// longest ones with their kind and the time they waited in the queue, and the longest gap between two. In a session of
// 2026-11-13 frames of 60-96 ms followed each area load by 2 to 6 frames, the render thread waiting 59-86 ms for the
// GPU, and no line said which work the GPU did then. Read from the ring vk_gtl_note_ns fills, without its lock: nothing
// here waits for the GPU or for another thread. "In the queue" is the time from a piece's hand-over to the queue to
// its GPU start (for a replay part held by the submit batching, from the batch's hand-over, not from its submit call).
typedef struct { int64_t sub, sn, en; int kind; } GpuPart;
static int part_by_start(const void *a, const void *b) {
int64_t x = ((const GpuPart *)a)->sn, y = ((const GpuPart *)b)->sn;
return x < y ? -1 : x > y;
}
void vk_gpu_parts_log(uint64_t frame, uint64_t t0, uint64_t t1, double fence_ms) {
static uint32_t logged;
if (logged >= 400) return;
uint64_t n = __atomic_load_n(&part_n, __ATOMIC_ACQUIRE), from = n > PART_RING / 2 ? n - PART_RING / 2 : 0;
GpuPart *sel = malloc(sizeof *sel * (PART_RING / 2));
if (!sel) return;
int nsel = 0;
for (uint64_t i = from; i < n; i++) { // those that ended in the interval, then in GPU order: the gaps need it
const typeof(part_ring[0]) *p = &part_ring[i % PART_RING];
if (p->en < (int64_t)t0 || p->en > (int64_t)t1 || p->en < p->sn) continue;
sel[nsel++] = (GpuPart){p->sub, p->sn, p->en, p->kind};
}
qsort(sel, (size_t)nsel, sizeof *sel, part_by_start);
struct { int64_t ns, q; int kind; } top[6] = {{0}};
static const char *const kn[GTL_KINDS] = {"part", "mirror copies", "pre-pass", "uploads", "present"};
uint64_t parts = 0, busy = 0, gap = 0;
int64_t prev_end = 0;
for (int i = 0; i < nsel; i++) {
const GpuPart *p = &sel[i];
int64_t ns = p->en - p->sn, q = p->sub && p->sn > p->sub ? p->sn - p->sub : 0;
parts++, busy += (uint64_t)ns;
if (prev_end && p->sn > prev_end && (uint64_t)(p->sn - prev_end) > gap) gap = (uint64_t)(p->sn - prev_end);
if (p->en > prev_end) prev_end = p->en;
for (int k = 0; k < 6; k++)
if (ns > top[k].ns) {
memmove(&top[k + 1], &top[k], sizeof top[0] * (size_t)(5 - k));
top[k].ns = ns, top[k].q = q, top[k].kind = p->kind;
break;
}
}
free(sel);
if (!parts) return;
logged++;
char line[700];
int o = snprintf(line, sizeof line, "perf: frame %llu: the GPU's timed parts that ended while the render thread waited "
"%.1f ms for fences: %llu parts, %.1f ms of GPU time, the longest gap between two %.1f ms; the longest:",
(unsigned long long)frame, fence_ms, (unsigned long long)parts, busy / 1e6, gap / 1e6);
for (int k = 0; k < 6 && top[k].ns && o < (int)sizeof line - 80; k++)
o += snprintf(line + o, sizeof line - (size_t)o, "%s %.1f ms (%s, %.1f ms in the queue)", k ? "," : "",
top[k].ns / 1e6, top[k].kind >= 0 && top[k].kind < GTL_KINDS ? kn[top[k].kind] : "?", top[k].q / 1e6);
rt_log("%s", line);
}
// ---- frame timeline
// NX2NAVI_GPU_TIMELINE=<frame>: from that presented frame on, TL_FRAMES frames of what the CPU and the GPU did go to the
// log once they are complete, in milliseconds from the first of those presents: each replay's begin and end and its
// thread, each timed part's submit, GPU start and end (calibrated timestamps, so with NX2NAVI_TRACE=perf), the waits for
// the GPU after a frame's first submission, the replay workers' groups, chunks and the submitting thread's waits for
// them, and the presents. It tells whether the GPU waits for the replay, for a wait, or for the CPU behind one. Off
// (and one load per note) without the setting.
#define TL_CAP 16384
#define TL_FRAMES 4
typedef struct { uint64_t t, a, b; uint32_t tid; int kind; } TlEv;
static TlEv *tl_ev;
static volatile int32_t tl_n, tl_rec;
static int tl_on = -1;
static uint64_t tl_from, tl_frames;
void gfx_tl_note(int kind, uint64_t t, uint64_t a, uint64_t b) {
if (!__atomic_load_n(&tl_rec, __ATOMIC_ACQUIRE)) return;
int32_t i = plat_inc32(&tl_n) - 1;
if (i < TL_CAP) tl_ev[i] = (TlEv){t ? t : plat_now_ns(), a, b, plat_thread_id(), kind};
}
static int tl_cmp(const void *x, const void *y) {
uint64_t a = ((const TlEv *)x)->t, b = ((const TlEv *)y)->t;
return a < b ? -1 : a > b;
}
static void tl_dump(void) {
int n = tl_n < TL_CAP ? tl_n : TL_CAP;
qsort(tl_ev, (size_t)n, sizeof *tl_ev, tl_cmp);
uint64_t t0 = 0, t1 = UINT64_MAX;
for (int i = 0; i < n; i++)
if (tl_ev[i].kind == 'P') {
if (tl_ev[i].a == tl_from) t0 = tl_ev[i].t;
if (tl_ev[i].a == tl_from + TL_FRAMES) t1 = tl_ev[i].t;
}
rt_log("vk: timeline of frames %llu-%llu (%d events), ms from the first present:", (unsigned long long)tl_from,
(unsigned long long)(tl_from + TL_FRAMES), n);
for (int i = 0; i < n; i++) {
const TlEv *e = &tl_ev[i];
if (e->t < t0 || e->t > t1) continue;
double at = (double)(int64_t)(e->t - t0) / 1e6;
switch (e->kind) {
case 'P': rt_log("vk: tl %8.3f t%-5u present, frame %llu", at, e->tid, (unsigned long long)e->a); break;
case 'R': rt_log("vk: tl %8.3f t%-5u replay begins: %llu handles, value %llu", at, e->tid,
(unsigned long long)e->a, (unsigned long long)e->b); break;
case 'r': rt_log("vk: tl %8.3f t%-5u replay ends: %llu commands in the last part", at, e->tid,
(unsigned long long)e->a); break;
case 'W': rt_log("vk: tl %8.3f t%-5u waits for the GPU to run value %llu", at, e->tid, (unsigned long long)e->a); break;
case 'w': rt_log("vk: tl %8.3f t%-5u wait ends", at, e->tid); break;
case 'g': rt_log("vk: tl %8.3f t%-5u group of %llu draws in %llu chunks", at, e->tid, (unsigned long long)e->a,
(unsigned long long)e->b); break;
case 'h': rt_log("vk: tl %8.3f t%-5u group recorded and in the part", at, e->tid); break;
case 'q': rt_log("vk: tl %8.3f t%-5u waits for its group: %s", at, e->tid, (const char *)(uintptr_t)e->a); break;
case 'u': rt_log("vk: tl %8.3f t%-5u group done, goes on", at, e->tid); break;
case 'c': rt_log("vk: tl %8.3f t%-5u records chunk %llu (%llu ops)", at, e->tid, (unsigned long long)e->a,
(unsigned long long)e->b); break;
case 'd': rt_log("vk: tl %8.3f t%-5u chunk %llu done", at, e->tid, (unsigned long long)e->a); break;
case 'S': rt_log("vk: tl %8.3f t%-5u part submitted, value %llu", at, e->tid, (unsigned long long)e->a); break;
case 'G': rt_log("vk: tl %8.3f gpu part runs %.3f ms (submitted %.3f ms before)", at,
(double)(e->a - e->t) / 1e6, (double)(int64_t)(e->t - e->b) / 1e6); break;
default: break;
}
}
}
void gfx_tl_present(void) {
if (tl_on < 0) {
const char *e = cfg_get("NX2NAVI_GPU_TIMELINE");
tl_from = e && *e ? strtoull(e, NULL, 0) : 0;
tl_on = tl_from && (tl_ev = calloc(TL_CAP, sizeof *tl_ev)) != NULL;
}
if (!tl_on) return;
uint64_t f = ++tl_frames;
if (f == tl_from) __atomic_store_n(&tl_rec, 1, __ATOMIC_RELEASE);
gfx_tl_note('P', 0, f, 0);
if (f == tl_from + TL_FRAMES + 3) { // the parts of the last frame have been read back by now
__atomic_store_n(&tl_rec, 0, __ATOMIC_SEQ_CST);
tl_dump();
tl_on = 0;
}
}
// At the start of a command buffer (sub_lock held): the pair it times, -1 untimed.
static int ts_open(VkCommandBuffer cb) {
if (ts_state != 1 || !perf_gpu_timing()) {
if (ts_state || !perf_gpu_timing()) return -1;
ts_setup(vk_ctx()); // the overlay turned full: timestamps from now on
if (ts_state != 1) return -1;
}
uint32_t p = ts_next % TS_PAIRS;
if (__atomic_load_n(&ts_busy[p], __ATOMIC_ACQUIRE)) { plat_inc64(&ts_missed); return -1; }
ts_next++;
ts_busy[p] = 1;
vkCmdResetQueryPool(cb, ts_pool, 2 * p, 2);
vkCmdWriteTimestamp(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, ts_pool, 2 * p);
return (int)p;
}
// At its end, before vkEndCommandBuffer; fence: the value whose completion covers it. The read is queued at the
// submit (ts_submitted).
static void ts_close(VkCommandBuffer cb, int pair, uint64_t fence, int mirror) {
if (pair < 0) return;
vkCmdWriteTimestamp(cb, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, ts_pool, 2 * (uint32_t)pair + 1);
ts_sub_ns[pair] = 0;
TsRead *t = malloc(sizeof *t);
if (!t) rt_fatal("vk: out of memory");
*t = (TsRead){(uint32_t)pair, mirror, fence, cb};
if (ts_n_unsent < TS_UNSENT) ts_unsent[ts_n_unsent++] = t;
else vk_completion_add(fence, ts_read_cb, t); // more than a part and its prologue between two submits
}
// The pair of the part being recorded.
static void ts_begin(VkCommandBuffer cb) { ts_cur = ts_open(cb); }
static void ts_end(VkCommandBuffer cb, uint64_t fence) {
ts_close(cb, ts_cur, fence, 0);
ts_cur = -1;
}
// An empty submission records no command buffer: its pair is never written.
static void ts_drop(void) {
if (ts_cur < 0) return;
__atomic_store_n(&ts_busy[ts_cur], 0, __ATOMIC_RELEASE);
ts_cur = -1;
}
uint64_t vk_gpu_busy_all(void) { return (uint64_t)__atomic_load_n(&ts_busy_all, __ATOMIC_RELAXED); }
static void gpu_stats(PerfGpu *g) {
g->have_gpu = ts_state == 1;
g->gpu_ns = (uint64_t)plat_xchg64(&ts_busy_ns, 0);
g->gpu_parts = (uint64_t)plat_xchg64(&ts_parts, 0);
g->gpu_missed = (uint64_t)plat_xchg64(&ts_missed, 0);
g->have_vram = vk_mem_vram(&g->vram_used, &g->vram_budget, &g->vram_exact);
g->have_idle = vk_ctx()->has_calibrated != 0 && cal_at != 0;
g->idle_ns = (uint64_t)plat_xchg64(&ts_idle_ns, 0);
g->starved_ns = (uint64_t)plat_xchg64(&ts_starved_ns, 0);
g->queued_ns = (uint64_t)plat_xchg64(&ts_queued_ns, 0);
g->idle_gaps = (uint64_t)plat_xchg64(&ts_gaps, 0);
g->max_gap_ns = (uint64_t)plat_xchg64(&ts_max_gap_ns, 0);
g->mirror_ns = (uint64_t)plat_xchg64(&ts_mirror_ns, 0);
}
WEAK void vk_exec_mesh_flush(VkCommandBuffer cb) { (void)cb; } // without vk_exec_draw.c: nothing is queued
// without vk_backend.c: nothing registers through a back end, so no registration waits for a submission
WEAK void vk_reg_replay_begin(void) {}
WEAK void vk_reg_replay_end(uint64_t fence) { (void)fence; }
// Weak stand-in for vk_exec.c's replay: only empty recordings ({OP_BEGIN, OP_END}) can be submitted.
WEAK int vk_exec_record(VkCommandBuffer cb, const uint64_t *handles, int n, uint64_t fence) {
(void)cb, (void)fence;
for (int i = 0; i < n; i++) {
const op_hdr *h = handles[i] ? nvn_op_first(handles[i]) : NULL;
if (!h || h->op != OP_BEGIN || h->size < sizeof *h || nvn_op_next(h)->op != OP_END)
rt_fatal("vk: op replay is not linked");
}
return 0;
}
// Without vk_exec.c (the probes): no submission is held back for an event.
WEAK int vk_exec_held(void) { return 0; }
// raw, read from the timeline whose signals hw notes: above every one of them the device is lost, and the run ends
// here (vk_device_lost_check) after a line with the two values.
static void timeline_beyond(const char *name, uint64_t raw, volatile uint64_t *hw, const char *where) {
uint64_t h = __atomic_load_n(hw, __ATOMIC_ACQUIRE);
if (!sync_hw_beyond(raw, h)) return;
rt_log("vk: the %s reads %llu, above every value its signals carry (%llu): the device is lost", name,
(unsigned long long)raw, (unsigned long long)h);
vk_device_lost_check(VK_ERROR_DEVICE_LOST, where);
}
void vk_sync_signal_note(uint64_t value) { sync_hw_raise(&tl_hw, value); }
void vk_sync_check_timeline(const char *where) {
VkCtx *c = vk_ctx();
if (!c || !c->timeline || vk_device_is_lost()) return;
uint64_t raw = 0;
VkResult r = vkGetSemaphoreCounterValue(c->device, c->timeline, &raw);
vk_device_lost_check(r, where);
if (r == VK_SUCCESS) timeline_beyond("submissions' timeline", raw, &tl_hw, where);
}
// No lock: the thread that reports a lost device may hold any (vk_device.c device_fault_report).
void vk_sync_state_text(char *o, size_t n) {
VkCtx *c = vk_ctx();
uint64_t tl = 0, ex = 0;
char a[32] = "nothing (unreadable)", b[32] = "nothing (unreadable)";
if (c && c->timeline && vkGetSemaphoreCounterValue(c->device, c->timeline, &tl) == VK_SUCCESS)
snprintf(a, sizeof a, "%llu", (unsigned long long)tl);
if (c && exec_tl && vkGetSemaphoreCounterValue(c->device, exec_tl, &ex) == VK_SUCCESS)
snprintf(b, sizeof b, "%llu", (unsigned long long)ex);
snprintf(o, n,
"submissions issued up to %llu, their side effects run up to %llu, handed to the queue up to %llu with %d "
"held back; the submissions' timeline reads %s of %llu signalled at most, the executed timeline %s of %llu; "
"%d submission(s) held for an event",
(unsigned long long)__atomic_load_n(&issued, __ATOMIC_ACQUIRE),
(unsigned long long)__atomic_load_n(&done, __ATOMIC_ACQUIRE),
(unsigned long long)__atomic_load_n(&queued_v, __ATOMIC_ACQUIRE), (int)__atomic_load_n(&held_n, __ATOMIC_ACQUIRE),
a, (unsigned long long)__atomic_load_n(&tl_hw, __ATOMIC_ACQUIRE), b,
(unsigned long long)__atomic_load_n(&ex_hw, __ATOMIC_ACQUIRE), vk_exec_held());
}
void vk_sync_lost_report(void) {
char t[512];
vk_sync_state_text(t, sizeof t);
rt_log("vk: when the device was lost: %s", t);
}
// The hang watch's reading (hang_watch.c): the newest value submitted and the newest done, without a lock or a device
// call.
void vk_sync_progress(uint64_t *issued_out, uint64_t *done_out) {
*issued_out = __atomic_load_n(&issued, __ATOMIC_ACQUIRE);
*done_out = __atomic_load_n(&done, __ATOMIC_ACQUIRE);
}
static void completion_main(void *arg) {
VkCtx *c = arg;
__atomic_store_n(&cb_tid, plat_thread_id(), __ATOMIC_RELEASE);
plat_mutex_lock(&st_lock);
for (;;) {
while (!stop && done == issued && !(qn && queue[qhead].fence <= done) && !(rqn && rqueue[rqhead].fence <= done))
plat_cond_wait(&cv_work, &st_lock);
if (stop) break;
uint64_t reached = done;
if (issued > done) {
uint64_t want = done + 1;
plat_mutex_unlock(&st_lock);
// Held submissions that have waited long enough go to the queue; while some are held, the wait is short so
// their age is checked again (submit batching).
hold_release(0, 1, 1);
uint64_t tmo = __atomic_load_n(&held_n, __ATOMIC_ACQUIRE) ? HOLD_NS : 100000000ull;
VkSemaphoreWaitInfo wi = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO, .semaphoreCount = 1,
.pSemaphores = &c->timeline, .pValues = &want};
VkResult r = vkWaitSemaphores(c->device, &wi, tmo); // 100 ms: stop is checked between waits
if (r != VK_SUCCESS && r != VK_TIMEOUT) {
vk_device_lost_check(r, "the completion thread's wait for a submission");
rt_fatal("vk: vkWaitSemaphores failed (%d; device lost?)", (int)r);
}
// A lost device is reported here, where it is first seen. Unchecked, the value was taken as it came: the
// callbacks of submissions that never ran were called as if their work were done (one report's first sign
// was an occlusion query "not available after its fence"), and the game went on recording until the next
// checked queue call failed, an upload's, which then read as the cause.
r = vkGetSemaphoreCounterValue(c->device, c->timeline, &reached);
if (r != VK_SUCCESS) {
vk_device_lost_check(r, "the completion thread's read of the submissions' timeline");
rt_fatal("vk: the submissions' timeline could not be read (%d; device lost?)", (int)r);
}
// Both calls may succeed on a lost device and give a value its submissions never reached. A value above
// every one the timeline's signals carry ends the run ("a lost device in the timelines' values"); then,
// before a new value counts, the device is asked once more with a call of its own (vk_device.c
// vk_device_lost_poll, which ends the run when it is lost). That poll alone did not see the losses of the
// RX 6000 reports of 0.9.1 and 0.9.2: their callbacks ran for work the GPU never finished.
timeline_beyond("submissions' timeline", reached, &tl_hw, "the completion thread's read of the submissions' "
"timeline (a value no submission signals)");
if (reached > __atomic_load_n(&done, __ATOMIC_ACQUIRE)) vk_device_lost_poll();
plat_mutex_lock(&st_lock);
if (reached > issued) reached = issued;
}
// Another thread saw the device lost (vk_lost): no value counts from here on and no callback runs. Theirs is
// work that never finished: frees of what the GPU may still hold, records handed out again, counter reports.
// done stays where it is, the waiters stay asleep, and the thread that saw the loss ends the process.
if (vk_device_is_lost()) break;
plat_mutex_unlock(&st_lock);
results_run(reached); // what the game reads first; a waiter after its submit may have run them already
plat_mutex_lock(&st_lock);
if (vk_device_is_lost()) break;
while (qn && queue[qhead].fence <= reached) {
Pending p = queue[qhead++];
qn--;
plat_mutex_unlock(&st_lock);
__atomic_store_n(&cb_late, p.fence <= __atomic_load_n(&exw_value, __ATOMIC_ACQUIRE), __ATOMIC_RELAXED);
uint64_t c0 = (rt_trace & RT_TRACE_PERF) ? kern_now_ns() : 0;
__atomic_store_n(&cb_value, p.fence, __ATOMIC_RELAXED);
p.fn(p.arg);
__atomic_store_n(&cb_value, 0, __ATOMIC_RELAXED);
uint64_t c1 = c0 ? kern_now_ns() : 0;
__atomic_store_n(&cb_late, 0, __ATOMIC_RELAXED);
plat_mutex_lock(&st_lock);
if (c0) {
int k = 0;
while (k < cbt_n && cbt[k].fn != p.fn) k++;
if (k == cbt_n && cbt_n < CBT_N) cbt[cbt_n++].fn = p.fn;
if (k < cbt_n) cbt[k].ns += c1 - c0, cbt[k].calls++;
}
if (vk_device_is_lost()) break;
}
if (vk_device_is_lost()) break;
if (reached > done) {
uint64_t ev = __atomic_load_n(&exw_value, __ATOMIC_ACQUIRE);
if (ev && reached >= ev) { // the side effects a waiter on exec_tl went ahead of are through
uint64_t lag = kern_now_ns() - __atomic_load_n(&exw_t0, __ATOMIC_RELAXED);
__atomic_fetch_add(&exw_lag_ns, lag, __ATOMIC_RELAXED);
if (lag > __atomic_load_n(&exw_lag_max, __ATOMIC_RELAXED)) __atomic_store_n(&exw_lag_max, lag, __ATOMIC_RELAXED);
__atomic_compare_exchange_n(&exw_value, &ev, 0, 0, __ATOMIC_RELEASE, __ATOMIC_RELAXED);
}
// The waiters go first (a fence wait, the frame's first submission, the acquire): freeing what the
// finished submissions held (vk_mem_collect: upload batches, destroyed images, views and buffers, each
// checked against the timeline itself) is nothing they read, and it takes cmd_lock, which the upload
// path holds, so done waited behind uploads. It only tries that lock (vk_mem_collect_try): an upload flush
// can hold it while it waits behind a present for the queue lock, and the next value's done waited too.
uint64_t tv = __atomic_load_n(&tail_val, __ATOMIC_ACQUIRE);
if (tv && reached >= tv) { // the tail a wait went ahead of is through: how long it took from there
uint64_t d = kern_now_ns() - __atomic_load_n(&tail_t0, __ATOMIC_RELAXED), a = tail_ns;
if (d < 50000000ull) __atomic_store_n(&tail_ns, a ? (a * 7 + d) / 8 : d, __ATOMIC_RELAXED);
__atomic_compare_exchange_n(&tail_val, &tv, 0, 0, __ATOMIC_RELEASE, __ATOMIC_RELAXED);
}
uint64_t was = done;
__atomic_store_n(&done, reached, __ATOMIC_RELEASE);
plat_cond_broadcast(&cv_done);
for (uint64_t v = reached - was > GW_RING ? reached - GW_RING + 1 : was + 1; v <= reached; v++) gw_mark(v, 2);
plat_mutex_unlock(&st_lock);
vk_mem_collect_try();
plat_mutex_lock(&st_lock);
}
}
plat_mutex_unlock(&st_lock);
return;
}
void vk_sync_start(void) {
if (__atomic_load_n(&started, __ATOMIC_ACQUIRE)) return;
plat_mutex_lock(&start_lock);
if (!started) {
if (!vk_init()) rt_fatal("vk: no usable Vulkan device (see the vk: lines above; NX2NAVI_GPU=null runs without a GPU)");
VkCtx *c = vk_ctx();
VkCommandPoolCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
.queueFamilyIndex = c->gfx_family};
if (vkCreateCommandPool(c->device, &pi, NULL, &cmd_pool) != VK_SUCCESS) rt_fatal("vk: submission command pool");
if (!ts_state && perf_gpu_timing()) ts_setup(c);
perf_gpu_stats = gpu_stats;
uint64_t now = 0;
vkGetSemaphoreCounterValue(c->device, c->timeline, &now);
sync_hw_raise(&tl_hw, now > issued ? now : issued); // before the restart's signal below
sync_hw_raise(&ex_hw, now > issued ? now : issued); // the executed timeline below starts there
if (issued > now) { // a new device after vk_shutdown: values keep increasing for the front end
VkSemaphoreSignalInfo si = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO, .semaphore = c->timeline,
.value = issued};
if (vkSignalSemaphore(c->device, &si) != VK_SUCCESS) rt_fatal("vk: timeline restart at %llu", (unsigned long long)issued);
now = issued;
}
plat_mutex_lock(&st_lock);
issued = done = now;
if (reserved < now) reserved = now;
stop = 0;
plat_mutex_unlock(&st_lock);
// the executed timeline starts where the timeline is; without it every exec wait is a fence wait
VkSemaphoreTypeCreateInfo et = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO,
.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE, .initialValue = now};
VkSemaphoreCreateInfo ei = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, .pNext = &et};
if (vkCreateSemaphore(c->device, &ei, NULL, &exec_tl) != VK_SUCCESS) exec_tl = VK_NULL_HANDLE;
exec_sig = now;
vk_mem_note_submit(now);
completion_thread = plat_thread_new(completion_main, c, "vk completion");
if (!completion_thread) rt_fatal("vk: cannot start the completion thread");
__atomic_store_n(&started, 1, __ATOMIC_RELEASE);
vk_astc_mode(); // decides NX2NAVI_ASTC and builds the ASTC decoder now, not at the first ASTC upload
}
plat_mutex_unlock(&start_lock);
}
// The index of a command buffer the GPU no longer uses, claimed for value `fence`. Called with sub_lock held; may move
// slots[].
static int claim_slot(VkCtx *c, uint64_t fence) {
uint64_t gpu = 0;
vkGetSemaphoreCounterValue(c->device, c->timeline, &gpu);
for (int i = 0; i < nslots; i++)
if (slots[i].fence <= gpu) {
slots[i].fence = fence;
return i;
}
CmdSlot *n = realloc(slots, sizeof *n * (size_t)(nslots + 1));
if (!n) rt_fatal("vk: out of memory (command buffers)");
slots = n;
CmdSlot *s = &slots[nslots];
VkCommandBufferAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = cmd_pool,
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 1};
if (vkAllocateCommandBuffers(c->device, &ai, &s->cb) != VK_SUCCESS) rt_fatal("vk: command buffer allocation");
s->fence = fence;
return nslots++;
}
static CmdSlot *take_slot(VkCtx *c, uint64_t fence) {
cur_slot = claim_slot(c, fence);
return &slots[cur_slot];
}
// ---- the part's pre-pass command buffer (vk.h vk_sync_pre; vk_exec_draw.c mesh emulation, NX2NAVI_MESH_EMU_BATCH)
// Begun at the first call of a part, submitted right before the part (after the mirror prologue) and ended there. Its
// own pool: with the recorder on, the recorder thread records the part (cmd_pool) while the replay records this.
static VkCommandPool pre_pool;
static CmdSlot *pre_slots;
static int npre, pre_pair = -1;
static VkCommandBuffer pre_cb;
VkCommandBuffer vk_sync_pre(void) {
if (pre_cb) return pre_cb;
VkCtx *c = vk_ctx();
if (!pre_pool) {
VkCommandPoolCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
.queueFamilyIndex = c->gfx_family};
if (vkCreateCommandPool(c->device, &pi, NULL, &pre_pool) != VK_SUCCESS) return VK_NULL_HANDLE;
}
uint64_t gpu = 0;
vkGetSemaphoreCounterValue(c->device, c->timeline, &gpu);
int i = 0;
while (i < npre && pre_slots[i].fence > gpu) i++;
if (i == npre) {
CmdSlot *n = realloc(pre_slots, sizeof *n * (size_t)(npre + 1));
if (!n) return VK_NULL_HANDLE;
pre_slots = n;
VkCommandBufferAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = pre_pool,
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 1};
if (vkAllocateCommandBuffers(c->device, &ai, &pre_slots[npre].cb) != VK_SUCCESS) return VK_NULL_HANDLE;
npre++;
}
pre_slots[i].fence = cur_fence; // the submission's value: its parts are done by then
VkCommandBuffer cb = pre_slots[i].cb;
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
vkBeginCommandBuffer(cb, &bi);
pre_pair = ts_open(cb);
// everything earlier in queue order (the parts before, the mirror prologue) before what it records
VkMemoryBarrier2 mb = {.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2, .srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
.srcAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT, .dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
.dstAccessMask = VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT};
VkDependencyInfo di = {.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, .memoryBarrierCount = 1, .pMemoryBarriers = &mb};
vkCmdPipelineBarrier2(cb, &di);
pre_cb = cb;
return cb;
}
// Command buffers of the submission that a cut (vk_sync_cut) ended and that go to the queue with the next part, in
// order before it: a cut part's pre-pass command buffer, then the part.
static VkCommandBuffer chain[CHAIN_MAX];
static int nchain;
// The pre-pass command buffer of the part that goes now, ended; VK_NULL_HANDLE without one.
static VkCommandBuffer pre_end(uint64_t fence) {
VkCommandBuffer cb = pre_cb;
if (!cb) return VK_NULL_HANDLE;
vk_exec_mesh_flush(cb); // the stages of the pass's emulated draws, by stage
ts_close(cb, pre_pair, fence, GTL_PRE);
pre_pair = -1;
vkEndCommandBuffer(cb);
pre_cb = VK_NULL_HANDLE;
return cb;
}
// The copies the replay's first accesses to VRAM mirrors queued (vk_memory.c), in a command buffer of their own that is
// submitted right before the part that needs them and timed like a part; VK_NULL_HANDLE when there are none. After the
// part was recorded, sub_lock held; may move slots[].
// First brings the shadow storage up to date with the CPU's writes, which needs no command buffer.
static VkCommandBuffer prologue(VkCtx *c, uint64_t fence) {
vk_mem_shadow_sync(fence); // shadow storage: the CPU's writes go in before the part is submitted
vk_mem_lazy_part_end(); // what the part left in the mirror and has to write back after all joins the snapshots
if (!vk_mem_mirror_pending()) return VK_NULL_HANDLE;
int i = claim_slot(c, fence); // before reading slots: the claim may move it
VkCommandBuffer cb = slots[i].cb;
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
vkBeginCommandBuffer(cb, &bi);
int pair = ts_open(cb);
vk_mem_mirror_prologue(cb);
ts_close(cb, pair, fence, 1);
vkEndCommandBuffer(cb);
return cb;
}
// What goes to the queue for a part: the mirror prologue, the parts cut before it with their pre-pass command buffers
// and pieces (the chain, emptied), the part's own pre-pass command buffer, the part's earlier pieces (vk_sync_append:
// the replay's and its workers', emptied), the part's last command buffer. Any of them may be missing.
static uint32_t chain_infos(VkCommandBufferSubmitInfo *cbi, VkCommandBuffer pro, VkCommandBuffer pre, VkCommandBuffer cb) {
uint32_t n = 0;
#define CHAIN_ADD(x) \
if (x) cbi[n++] = (VkCommandBufferSubmitInfo){.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO, .commandBuffer = (x)}
CHAIN_ADD(pro);
for (int k = 0; k < nchain; k++) CHAIN_ADD(chain[k]);
CHAIN_ADD(pre);
for (int i = 0; i < npart_cbs; i++) {
if (!part_cbs[i]) rt_fatal("vk: a replay worker's command buffer is missing from a part");
CHAIN_ADD(part_cbs[i]);
}
CHAIN_ADD(cb);
#undef CHAIN_ADD
nchain = 0, npart_cbs = 0;
return n;
}
// A cut: the part ends and the replay goes on in a new command buffer, as at a split, but nothing goes to the queue:
// the part (its pre-pass command buffer, its pieces and itself) waits in the chain for the next part that does. The
// texture pool set stays as it is, as nothing of the submission has been handed to the GPU. VK_NULL_HANDLE when the
// chain is full.
VkCommandBuffer vk_sync_cut(void) {
if (nchain + npart_cbs + 2 > CHAIN_MAX) return VK_NULL_HANDLE;
for (int i = 0; i < npart_cbs; i++)
if (!part_cbs[i]) return VK_NULL_HANDLE; // a replay worker's piece not filled yet: the part goes on
VkCtx *c = vk_ctx();
VkCommandBuffer cb = slots[cur_slot].cb;
vk_gpu_passes_part(cb, 1);
ts_end(cb, cur_fence);
if (!rec_end_cb(cb, 0, 0)) vkEndCommandBuffer(cb);
VkCommandBuffer pre = pre_end(cur_fence);
if (pre) chain[nchain++] = pre;
for (int i = 0; i < npart_cbs; i++) chain[nchain++] = part_cbs[i];
npart_cbs = 0;
chain[nchain++] = cb;
CmdSlot *s = take_slot(c, cur_fence); // the cut part keeps its slot until the value is reached
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
vkBeginCommandBuffer(s->cb, &bi);
ts_begin(s->cb);
rec_begin(s->cb, cur_fence);
vk_gpu_passes_part(s->cb, 0);
return s->cb;
}
// The replay workers (vk_exec.c "replay workers"): the replay's command buffer being recorded ends and joins the
// part's list, then n places for the command buffers the workers record, in that order, and a new command buffer of
// the same part is begun and returned: the same timeline value, timed and submitted with the rest, so the workers'
// commands run between the replay's commands before and after. The places are filled (vk_sync_append_fill) before the
// part is submitted, which chain_infos checks. vk_sync_append_room: whether the list has room for n more.
int vk_sync_append_room(int n) { return npart_cbs + n + 1 <= PART_CBS; }
VkCommandBuffer vk_sync_append_later(int n, int *at) {
if (!vk_sync_append_room(n)) rt_fatal("vk: a submission part of more than %d command buffers", PART_CBS);
VkCtx *c = vk_ctx();
VkCommandBuffer cb = slots[cur_slot].cb;
if (!rec_end_cb(cb, 0, 0)) vkEndCommandBuffer(cb); // every command of it is in cb, ended by its recorder
part_cbs[npart_cbs++] = cb;
*at = npart_cbs;
for (int i = 0; i < n; i++) part_cbs[npart_cbs++] = VK_NULL_HANDLE;
CmdSlot *s = take_slot(c, cur_fence); // the ended one keeps its slot until the value is reached
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
vkBeginCommandBuffer(s->cb, &bi);
rec_begin(s->cb, cur_fence);
return s->cb;
}
void vk_sync_append_fill(int at, const VkCommandBuffer *cbs, int n) {
if (at < 0 || at + n > npart_cbs) rt_fatal("vk: replay worker command buffers past the part's list");
for (int i = 0; i < n; i++) part_cbs[at + i] = cbs[i];
}
// The replay workers' command buffers: each worker has a command pool of its own (a pool belongs to one thread at a
// time) and claims command buffers from it for the submission being recorded, as the replay claims its slots. Called
// on the worker while the submitting thread holds sub_lock and records on (NX2NAVI_REPLAY_PIPE) or waits for it: the
// part with the worker's command buffer is not submitted before it is done, so cur_fence stays put.
#define SYNC_WORKERS 17 // the replay workers (at most 16) and the submitting thread
static struct { VkCommandPool pool; CmdSlot *slots; int n; } WP[SYNC_WORKERS];
VkCommandBuffer vk_sync_worker_begin(int w) {
VkCtx *c = vk_ctx();
if (w < 0 || w >= SYNC_WORKERS) rt_fatal("vk: replay worker %d of %d", w, SYNC_WORKERS);
typeof(WP[0]) *p = &WP[w];
if (!p->pool) {
VkCommandPoolCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
.queueFamilyIndex = c->gfx_family};
if (vkCreateCommandPool(c->device, &pi, NULL, &p->pool) != VK_SUCCESS) rt_fatal("vk: replay worker command pool");
}
uint64_t gpu = 0;
vkGetSemaphoreCounterValue(c->device, c->timeline, &gpu);
CmdSlot *s = NULL;
for (int i = 0; i < p->n && !s; i++)
if (p->slots[i].fence <= gpu) s = &p->slots[i];
if (!s) {
CmdSlot *m = realloc(p->slots, sizeof *m * (size_t)(p->n + 1));
if (!m) rt_fatal("vk: out of memory (command buffers)");
p->slots = m, s = &m[p->n++];
VkCommandBufferAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = p->pool,
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 1};
if (vkAllocateCommandBuffers(c->device, &ai, &s->cb) != VK_SUCCESS) rt_fatal("vk: command buffer allocation");
}
s->fence = cur_fence;
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
vkBeginCommandBuffer(s->cb, &bi);
return s->cb;
}
void vk_sync_worker_end(VkCommandBuffer cb) { vkEndCommandBuffer(cb); }
static VkCommandBuffer split_part(int exec);
// ---- the early tail (vk_exec.c "the early tail"). The replay cut the part where the tail began (vk_sync_cut), so the
// chain holds what came before and the command buffer being recorded is the tail's. vk_sync_tail_hold sets that one
// aside, ended and timed as a part of its own, and begins the one the write-backs are recorded into;
// vk_sync_tail_release hands the chain and the write-backs to the queue with the executed timeline's signal
// (split_part), and puts the tail into the chain for the submission's last part, which signals the timeline. The queue
// runs them in that order, so the GPU's work is what it was with the write-backs moved before the tail.
int vk_sync_tail_ok(void) { return !pre_cb && !tail_cb && exec_tl && exec_sig < cur_fence && nchain + 2 <= CHAIN_MAX; }
VkCommandBuffer vk_sync_tail_hold(void) {
VkCtx *c = vk_ctx();
VkCommandBuffer cb = slots[cur_slot].cb;
vk_gpu_passes_part(cb, 1);
ts_end(cb, cur_fence);
if (!rec_end_cb(cb, 0, 0)) vkEndCommandBuffer(cb);
tail_cb = cb;
CmdSlot *s = take_slot(c, cur_fence); // the tail keeps its slot until the value is reached
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
vkBeginCommandBuffer(s->cb, &bi);
ts_begin(s->cb);
rec_begin(s->cb, cur_fence);
vk_gpu_passes_part(s->cb, 0);
return s->cb;
}
VkCommandBuffer vk_sync_tail_release(void) {
VkCommandBuffer cb = split_part(1);
chain[nchain++] = tail_cb;
tail_cb = VK_NULL_HANDLE;
tail_early = 1;
return cb;
}
// The newest value up to `value` whose GPU work a wait of the game is for: the empty submissions after it are passed
// over. *early: that one's write-backs went before its tail, so the executed timeline reaches it there.
static uint64_t wait_target(uint64_t value, int *early) {
plat_mutex_lock(&st_lock);
uint64_t t = value;
while (t > done && empty_of[t & (LIVE_RING - 1)] == t) t--;
*early = t > done && early_of[t & (LIVE_RING - 1)] == t;
plat_mutex_unlock(&st_lock);
return t;
}
uint64_t vk_sync_tail_pending_ns(uint64_t value) {
plat_mutex_lock(&st_lock);
int pending = value > done && early_of[value & (LIVE_RING - 1)] == value;
plat_mutex_unlock(&st_lock);
return pending ? __atomic_load_n(&tail_ns, __ATOMIC_RELAXED) : 0;
}
void vk_sync_tail_stats(uint64_t *tails, uint64_t *waits, double *ms) {
*tails = __atomic_exchange_n(&tail_n, 0, __ATOMIC_RELAXED);
*waits = __atomic_exchange_n(&tail_waits, 0, __ATOMIC_RELAXED);
*ms = (double)__atomic_load_n(&tail_ns, __ATOMIC_RELAXED) / 1e6;
}
VkCommandBuffer vk_sync_split(void) { return split_part(0); }
// The part that ends here is the last with the submission's own commands: it signals the executed timeline.
VkCommandBuffer vk_sync_split_exec(void) { return split_part(1); }
static VkCommandBuffer split_part(int exec) {
VkCtx *c = vk_ctx();
VkCommandBuffer cb = slots[cur_slot].cb;
vk_gpu_passes_part(cb, 1);
ts_end(cb, cur_fence);
if (!rec_end_cb(cb, 0, 0)) vkEndCommandBuffer(cb); // every command of the part is in cb, ended by its recorder
vk_upload_flush(0); // the uploads this part asked for precede it in queue order
VkCommandBuffer pro = prologue(c, cur_fence), pre = pre_end(cur_fence);
VkCommandBufferSubmitInfo cbi[SUB_CBS];
uint32_t ncb = chain_infos(cbi, pro, pre, cb); // the mirror copies, the cut parts, the pre-pass work, the part
VkSubmitInfo2 si = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2, .commandBufferInfoCount = ncb, .pCommandBufferInfos = cbi};
VkSemaphoreSubmitInfo esig = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, .semaphore = exec_tl,
.value = cur_fence, .stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT};
if (exec && exec_tl && exec_sig < cur_fence)
si.signalSemaphoreInfoCount = 1, si.pSignalSemaphoreInfos = &esig, exec_sig = cur_fence;
ts_submitted(cbi, ncb);
queue_submit(c, &si, 0); // held or handed to the queue (submit batching)
sub_parts++;
vk_mem_lazy_handed();
vk_mem_note_submit(cur_fence); // objects destroyed from here on may be in use by the part just submitted
CmdSlot *s = take_slot(c, cur_fence); // the submitted part keeps its slot until the value is reached
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
vkBeginCommandBuffer(s->cb, &bi);
vk_crumb(s->cb, VK_CRUMB_SUBMIT, cur_fence);
ts_begin(s->cb);
rec_begin(s->cb, cur_fence);
vk_gpu_passes_part(s->cb, 0);
return s->cb;
}
// Asynchronous replay (vk_backend.c): the value the next queued submission or present will signal. Values are handed
// out in queue order and the replay thread submits them in that order, so each vk_sync_submit gets exactly its value.
uint64_t vk_sync_reserve(void) {
vk_sync_start();
plat_mutex_lock(&st_lock);
if (reserved < issued) reserved = issued;
uint64_t v = ++reserved;
plat_mutex_unlock(&st_lock);
gw_mark(v, 0);
return v;
}
// own: NULL for the replay of handles; else the caller's commands in the replay's place (vk_sync_submit_own).
typedef struct {
void (*record)(VkCommandBuffer, void *);
void *arg;
} OwnCommands;
static uint64_t submit(const uint64_t *handles, int n, const OwnCommands *own) {
vk_sync_start();
// The run is ending (vk_device.c vk_device_lost_check): nothing is recorded or submitted and no value is issued.
// The value returned is the last one issued; a wait for it sleeps until the process ends.
if (vk_device_is_lost()) return __atomic_load_n(&issued, __ATOMIC_ACQUIRE);
VkCtx *c = vk_ctx();
plat_mutex_lock(&sub_lock);
vk_present_frames_owed(); // frames presented while another thread replayed close before this replay begins
if (n > 0 && !own) vk_sub_in_frame_publish(); // this submission's place in its frame, for the replay
vk_mem_dirty_submit(); // NX2NAVI_TRACE=perf: what a mirror would upload before this submit
uint64_t fence = __atomic_load_n(&issued, __ATOMIC_ACQUIRE) + 1;
cur_fence = fence;
tail_early = sub_parts = 0;
CmdSlot *s = take_slot(c, fence);
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
// NX2NAVI_REPLAY_TIME: the replaying thread's CPU time over the replay too (perf_replay_cpu), against its wall time
static int cpu_on = -1;
if (cpu_on < 0) cpu_on = cfg_bool("NX2NAVI_REPLAY_TIME");
uint32_t me = plat_thread_id();
uint64_t t0 = perf_begin(), cpu0 = cpu_on ? plat_thread_cpu_ns(me) : 0;
vkBeginCommandBuffer(s->cb, &bi);
vk_crumb(s->cb, VK_CRUMB_SUBMIT, fence);
ts_begin(s->cb);
rec_begin(s->cb, fence);
vk_gpu_passes_fence(fence); // the submission the parts and results that follow belong to
vk_gpu_passes_part(s->cb, 0);
int recorded = 1;
// The game's registrations made from here to the queue call below take effect after it (vk_backend.c
// "registrations in submit order"): the replay writes the submission's texture pool set as it goes.
if (own) own->record(s->cb, own->arg);
else vk_reg_replay_begin(), recorded = vk_exec_record(s->cb, handles, n, fence);
s = &slots[cur_slot]; // the last part (vk_sync_split)
vk_gpu_passes_part(s->cb, 1);
if (recorded) ts_end(s->cb, fence);
else ts_drop();
// The recorder is done with the part: the end-of-submission crumb, then the end, by the recorder when it records.
if (!rec_end_cb(s->cb, VK_CRUMB_SUBMIT_END, fence)) vkEndCommandBuffer(s->cb);
perf_replay(t0);
if (cpu0) perf_replay_cpu(plat_thread_cpu_ns(me) - cpu0);
// After the recording: the uploads it asked for (and every earlier one) precede it in queue order.
vk_upload_flush(0);
VkCommandBuffer pro = prologue(c, fence), pre = pre_end(fence);
s = &slots[cur_slot];
VkCommandBufferSubmitInfo cbi[SUB_CBS];
int sent = recorded || nchain || npart_cbs; // a cut part or a piece goes with the part after it, which goes too
uint32_t ncb = chain_infos(cbi, pro, pre, sent ? s->cb : VK_NULL_HANDLE);
// the timeline, and the executed timeline unless a part of this submission signalled it already (split_part)
VkSemaphoreSubmitInfo sig[2] = {{.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, .semaphore = c->timeline,
.value = fence, .stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT},
{.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, .semaphore = exec_tl,
.value = fence, .stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT}};
int both = exec_tl && exec_sig < fence;
if (both) exec_sig = fence;
VkSubmitInfo2 si = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2, .commandBufferInfoCount = ncb, .pCommandBufferInfos = cbi,
.signalSemaphoreInfoCount = both ? 2u : 1u, .pSignalSemaphoreInfos = sig};
ts_submitted(cbi, ncb);
queue_submit(c, &si, fence); // held or handed to the queue (submit batching)
if (!own) vk_reg_replay_end(fence);
vk_mem_lazy_handed();
s->fence = sent ? fence : 0;
vk_mem_note_submit(fence);
gw_mark(fence, 1);
// after the last part's prologue: every staged copy of the submission is taken; a probe's submit leaves the note to
// the replay's submission it may have come in the middle of
int live = own ? 1 : vk_mem_take_live();
int res = own ? 0x1f : vk_exec_take_results();
plat_mutex_lock(&st_lock);
live_of[fence & (LIVE_RING - 1)] = fence << 1 | (uint64_t)(live != 0);
if (live) live_last = fence;
live_last_of[fence & (LIVE_RING - 1)] = live_last;
res_kinds[fence & (LIVE_RING - 1)] = (uint8_t)res;
early_of[fence & (LIVE_RING - 1)] = tail_early ? fence : 0;
empty_of[fence & (LIVE_RING - 1)] = !own && !sent && !sub_parts && !res ? fence : 0;
if (tail_early) __atomic_fetch_add(&tail_n, 1, __ATOMIC_RELAXED);
if (res & res_mask) res_last = fence;
res_of[fence & (LIVE_RING - 1)] = res_last;
__atomic_store_n(&issued, fence, __ATOMIC_RELEASE);
plat_cond_broadcast(&cv_work);
plat_cond_broadcast(&cv_done); // vk_sync_recorded_wait
plat_mutex_unlock(&st_lock);
static unsigned subs;
if (++subs % 4096 == 0) {
rec_stats_log();
if (subs % (4096 * 8) == 4096) // statistics only: read without the queue lock
rt_log("vk: submit batching: %llu submissions in %llu queue calls (%.2f each)", (unsigned long long)H.subs,
(unsigned long long)H.batches, H.batches ? (double)H.subs / (double)H.batches : 0.0);
}
plat_mutex_unlock(&sub_lock);
return fence;
}
uint64_t vk_sync_submit(const uint64_t *handles, int n) { return submit(handles, n, NULL); }
// The upload batch is no place for a probe's commands: it is submitted before the memory prologue.
uint64_t vk_sync_submit_own(void (*record)(VkCommandBuffer, void *), void *arg) {
OwnCommands own = {record, arg};
return submit(NULL, 0, &own);
}
int vk_sync_try_locked(void (*fn)(void)) {
if (!plat_mutex_trylock(&sub_lock)) return 0;
fn();
plat_mutex_unlock(&sub_lock);
return 1;
}
// vk_backend.c's, which run the frames' bookkeeping (vk_frame_owed.h) and set the place in the frame of the submission
// being recorded; builds without the back end have neither.
WEAK void vk_present_frames_owed(void) {}
WEAK void vk_sub_in_frame_publish(void) {}
// st_lock held: p at the end of the queue *q.
static void pending_push(Pending **q, int *head, int *n, int *cap, Pending p) {
if (*head + *n == *cap) {
if (*head) {
memmove(*q, *q + *head, sizeof **q * (size_t)*n);
*head = 0;
} else {
int c = *cap ? *cap * 2 : 256;
Pending *m = realloc(*q, sizeof *m * (size_t)c);
if (!m) rt_fatal("vk: out of memory (completion queue)");
*q = m, *cap = c;
}
}
(*q)[*head + (*n)++] = p;
}
void vk_completion_add(uint64_t fence, void (*fn)(void *), void *arg) {
plat_mutex_lock(&st_lock);
pending_push(&queue, &qhead, &qn, &qcap, (Pending){fence, fn, arg});
// The completion thread sleeps only while nothing is issued beyond done: a callback for a later value (the replay's,
// for the submission it records: counter reports, freed resources) becomes due through the submit, which wakes it.
if (fence <= done) plat_cond_broadcast(&cv_work);
plat_mutex_unlock(&st_lock);
}
// A side effect the game's CPU reads once the GPU has run submission fence's own commands: it runs before the other
// side effects of the values reached, and a wait after a submit runs it itself (vk_sync_exec_wait).
void vk_completion_add_result(uint64_t fence, void (*fn)(void *), void *arg) {
plat_mutex_lock(&st_lock);
pending_push(&rqueue, &rqhead, &rqn, &rqcap, (Pending){fence, fn, arg});
if (fence <= done) plat_cond_broadcast(&cv_work);
plat_mutex_unlock(&st_lock);
}
// Runs the results queued for the values up to upto, which the GPU has passed: on the completion thread for each
// value it reaches, and on a guest thread whose wait on exec_tl has just returned. st_lock not held.
static void results_run(uint64_t upto) {
plat_mutex_lock(&res_lock);
plat_mutex_lock(&st_lock);
while (rqn && rqueue[rqhead].fence <= upto && !vk_device_is_lost()) {
Pending p = rqueue[rqhead++];
rqn--;
plat_mutex_unlock(&st_lock);
__atomic_store_n(&cb_late, p.fence <= __atomic_load_n(&exw_value, __ATOMIC_ACQUIRE), __ATOMIC_RELAXED);
p.fn(p.arg);
__atomic_store_n(&cb_late, 0, __ATOMIC_RELAXED);
plat_mutex_lock(&st_lock);
}
plat_mutex_unlock(&st_lock);
plat_mutex_unlock(&res_lock);
}
static int fence_wait_impl(uint64_t value, uint64_t timeout_ns);
// The game's wait after a frame's first submission (nvn_api_core.c NX2NAVI_SUBMIT_FINISH): until the replay has
// submitted value, which took every copy of guest memory the submission reads; then also until the GPU has run it when
// its GPU reads guest memory itself (vk_mem_take_live) or NX2NAVI_SUBMIT_FINISH_GPU=1. 1 when reached.
int vk_sync_result_mask(void) { return res_mask; }
int vk_sync_recorded_wait(uint64_t value, uint64_t timeout_ns, int (*gpu_wait)(uint64_t, uint64_t)) {
vk_sync_start();
static int gpu = -1, res_mode = 1;
if (gpu < 0) {
res_mode = rt_knob("SUBMIT_FINISH_RESULTS", "1");
const char *km = cfg_get("NX2NAVI_SUBMIT_FINISH_KINDS"); // a number (rt_knob reads only 0, 1 and ab)
res_mask = (km && *km ? (int)strtol(km, NULL, 0) : 31) & 0x1f;
rt_log("vk: result kinds the submit wait counts: %#x (NX2NAVI_SUBMIT_FINISH_KINDS: 1 events, 2 counter reports, "
"4 occlusion reports, 8 GPU writes of unmirrored memory, 16 mirror write-backs)", res_mask);
rt_log("vk: the wait after a frame's first submission also waits for %s (NX2NAVI_SUBMIT_FINISH_RESULTS=%d)",
res_mode == 2 ? "every submission before it" : res_mode ? "the newest submission up to it that hands the "
"CPU results at its completion (events, counter reports, write-backs)" : "nothing more", res_mode);
gpu = rt_knob("SUBMIT_FINISH_GPU", "1") != 0; // 0 with low_end_gpu = 1
rt_log("vk: the wait after a frame's first submission ends %s (NX2NAVI_SUBMIT_FINISH_GPU=%d)",
gpu ? "when the GPU has run it" : "when the replay has submitted it, or the GPU has run it where it reads guest "
"memory itself", gpu);
}
if (__atomic_load_n(&done, __ATOMIC_ACQUIRE) >= value) return 1;
if (gpu) return gpu_wait(value, timeout_ns);
uint64_t w0 = plat_now_ns(), newest = __atomic_load_n(&reserved, __ATOMIC_ACQUIRE);
uint64_t sent = __atomic_load_n(&issued, __ATOMIC_ACQUIRE);
int live = 1, ok = 1;
plat_mutex_lock(&st_lock);
if (value > issued && value > reserved) ok = 0; // never handed over
while (ok && issued < value && done < value) {
if (timeout_ns < (1ull << 62)) {
uint64_t el = plat_now_ns() - w0;
if (el >= timeout_ns) { ok = 0; break; }
plat_cond_wait_ns(&cv_done, &st_lock, timeout_ns - el);
} else {
plat_cond_wait(&cv_done, &st_lock);
}
}
uint64_t need = 0; // the newest submission up to value whose completion hands the CPU results
if (ok) {
live = done < value && live_of[value & (LIVE_RING - 1)] != (value << 1);
int known = live_of[value & (LIVE_RING - 1)] >> 1 == value;
need = known ? res_of[value & (LIVE_RING - 1)] : value;
if (res_mode == 2) need = value - 1;
if (!res_mode) need = 0;
uint64_t lneed = known ? live_last_of[value & (LIVE_RING - 1)] : value; // the newest reading guest memory
if (lneed > need) need = lneed;
while (!live && need && done < need) {
if (timeout_ns < (1ull << 62)) {
uint64_t el = plat_now_ns() - w0;
if (el >= timeout_ns) { ok = 0; break; }
plat_cond_wait_ns(&cv_done, &st_lock, timeout_ns - el);
} else {
plat_cond_wait(&cv_done, &st_lock);
}
}
}
plat_mutex_unlock(&st_lock);
uint64_t w1 = plat_now_ns();
if (w1 > w0) {
if (!live) gw_wait(value, w0, w1, newest > sent ? newest : sent, sent); // a GPU wait counts there
plat_mutex_lock(&gw_lock);
gw_rec_n++;
if (!live) gw_rec_done++;
if (!live && need == value) gw_rec_self++;
if (!live && need) vk_gpu_passes_waited(need);
if (!live && need && need <= value) {
gw_need_back += value - need, gw_need_n++;
int k = res_kinds[need & (LIVE_RING - 1)];
for (int b = 0; b < VK_RES_KINDS; b++) if (k >> b & 1) gw_need_kind[b]++;
if (need < value) { // what the submissions between need and value had that the mask leaves out
for (uint64_t v = need + 1; v <= value && v - need < 64; v++)
for (int b = 0; b < VK_RES_KINDS; b++) if (res_kinds[v & (LIVE_RING - 1)] >> b & 1) gw_skip_kind[b]++;
}
}
plat_mutex_unlock(&gw_lock);
}
if (!ok) return 0;
return live ? gpu_wait(value, timeout_ns) : 1;
}
int vk_sync_fence_wait(uint64_t value, uint64_t timeout_ns) {
uint64_t p0 = perf_begin();
uint64_t newest = __atomic_load_n(&reserved, __ATOMIC_ACQUIRE), sent = __atomic_load_n(&issued, __ATOMIC_ACQUIRE);
uint64_t w0 = __atomic_load_n(&done, __ATOMIC_ACQUIRE) >= value ? 0 : plat_now_ns();
int ok = fence_wait_impl(value, timeout_ns);
if (w0 && ok) gw_wait(value, w0, plat_now_ns(), newest > sent ? newest : sent, sent);
perf_end(PERF_FENCE, p0);
perf_gpu_wait(p0);
return ok;
}
static int fence_wait_impl(uint64_t value, uint64_t timeout_ns) {
vk_sync_start();
hold_release(value, 0, 0); // a held submission the waiter needs goes to the queue now
plat_mutex_lock(&st_lock);
int ok;
if (value > issued && value > reserved) {
ok = 0; // never submitted nor queued: never reached (as in the null back end)
} else {
uint64_t t0 = kern_now_ns();
while (done < value) {
if (timeout_ns < (1ull << 62)) {
uint64_t el = kern_now_ns() - t0;
if (el >= timeout_ns) break;
plat_cond_wait_ns(&cv_done, &st_lock, timeout_ns - el);
} else {
plat_cond_wait(&cv_done, &st_lock);
}
}
ok = done >= value;
}
plat_mutex_unlock(&st_lock);
return ok;
}
// The wait after a submit: the fence's (vk_sync_fence_wait), or with NX2NAVI_WB_SPLIT until the GPU has executed
// submission value's own commands (the executed timeline) and the results the game reads are in its memory
// (results_run), its write-backs and the completion thread's other side effects for it and for the submissions
// before it still to come. 1 when reached.
int vk_sync_exec_wait(uint64_t value, uint64_t timeout_ns) {
vk_sync_start();
// The fence's wait unless the split is asked for ("the executed timeline" above says what the game reads that a
// wait on exec_tl leaves out). With shadow storage (vk_mem_shadow_storage) what the GPU wrote reaches guest memory
// in such a callback too: a wait that returned before it let the game rewrite a pool that an older write-back
// then overwrote (Kokiri Forest: a sky coefficient the game had reset came back, and the world flickered washed
// out).
// The run is ending (vk_device.c vk_device_lost_check): no wait on the lost device, and none for a value that
// will not be reached.
if (vk_device_is_lost()) return __atomic_load_n(&done, __ATOMIC_ACQUIRE) >= value;
if (!vk_sync_wb_split()) return vk_sync_fence_wait(value, timeout_ns);
if (__atomic_load_n(&done, __ATOMIC_ACQUIRE) >= value) return 1;
// The early tail: a value whose GPU work is that of an earlier submission with its write-backs before its tail (the
// submissions since handed the GPU nothing) is reached where that one's executed signal is, its tail still to run.
int early = 0;
uint64_t asked = value, target = wb_results ? wait_target(value, &early) : value;
if (early) value = target;
uint64_t p0 = perf_begin();
hold_release(asked, 0, 0); // a held submission the waiter needs goes to the queue now
VkCtx *c = vk_ctx();
VkSemaphoreWaitInfo wi = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO, .semaphoreCount = 1,
.pSemaphores = &exec_tl, .pValues = &value};
VkResult r = vkWaitSemaphores(c->device, &wi, timeout_ns);
if (r != VK_SUCCESS && r != VK_TIMEOUT) {
vk_device_lost_check(r, "the wait for a submission's commands");
rt_fatal("vk: the wait for a submission's commands failed (%d; device lost?)", (int)r);
}
if (r == VK_SUCCESS) { // a lost device may end the wait too: the results below would be read from work never run
uint64_t raw = 0;
VkResult q = vkGetSemaphoreCounterValue(c->device, exec_tl, &raw);
vk_device_lost_check(q, "the read of the executed timeline");
if (q == VK_SUCCESS)
timeline_beyond("executed timeline", raw, &ex_hw, "the wait for a submission's commands (a value no part "
"signals)");
}
// The results the game reads, of this submission and of those before it, which the GPU ran first: here, not when
// the completion thread gets to them behind the other side effects of the last frame's submissions.
if (r == VK_SUCCESS && wb_results) results_run(value);
perf_end(PERF_FENCE, p0);
perf_gpu_wait(p0);
if (r == VK_SUCCESS && early && __atomic_load_n(&done, __ATOMIC_ACQUIRE) < value) {
__atomic_fetch_add(&tail_waits, 1, __ATOMIC_RELAXED);
uint64_t none = 0; // the first wait back for this tail starts its clock (the completion thread ends it)
if (__atomic_load_n(&tail_val, __ATOMIC_ACQUIRE) != value &&
__atomic_compare_exchange_n(&tail_val, &none, value, 0, __ATOMIC_RELEASE, __ATOMIC_RELAXED))
__atomic_store_n(&tail_t0, kern_now_ns(), __ATOMIC_RELAXED);
}
if (r == VK_SUCCESS) {
__atomic_fetch_add(&exw_n, 1, __ATOMIC_RELAXED);
if (__atomic_load_n(&done, __ATOMIC_ACQUIRE) < value) {
__atomic_fetch_add(&exw_early, 1, __ATOMIC_RELAXED);
__atomic_store_n(&exw_t0, kern_now_ns(), __ATOMIC_RELAXED);
__atomic_store_n(&exw_value, value, __ATOMIC_RELEASE);
}
}
return r == VK_SUCCESS;
}
// The game's own wait for a fence (nvn_api_core.c nvnSyncWait): the fence's wait, or with the early tail the wait for
// the results of the submission the value's GPU work belongs to (vk_sync_exec_wait), when that one's tail is all the
// GPU still has of it: the tail reads nothing the game can change and hands it nothing.
int vk_sync_game_wait(uint64_t value, uint64_t timeout_ns) {
vk_sync_start();
if (vk_sync_wb_split() && wb_results && __atomic_load_n(&done, __ATOMIC_ACQUIRE) < value) {
int early = 0;
wait_target(value, &early);
if (early) return vk_sync_exec_wait(value, timeout_ns);
}
return vk_sync_fence_wait(value, timeout_ns);
}
uint64_t vk_sync_fence_done(void) { return __atomic_load_n(&done, __ATOMIC_ACQUIRE); }
uint64_t vk_sync_issued(void) { return __atomic_load_n(&issued, __ATOMIC_ACQUIRE); }
void vk_sync_finish(void) {
vk_sync_start();
vk_sync_fence_wait(__atomic_load_n(&issued, __ATOMIC_ACQUIRE), UINT64_MAX);
vk_upload_flush(1);
}
void vk_sync_shutdown(void) {
plat_mutex_lock(&start_lock);
if (started) {
vk_sync_finish();
plat_mutex_lock(&st_lock);
stop = 1;
plat_cond_broadcast(&cv_work);
plat_mutex_unlock(&st_lock);
plat_thread_join(completion_thread);
completion_thread = NULL;
plat_mutex_lock(&st_lock);
qhead = qn = rqhead = rqn = 0; // callbacks of values never submitted
plat_mutex_unlock(&st_lock);
plat_mutex_lock(&sub_lock);
VkCtx *c = vk_ctx();
if (c && cmd_pool) vkDestroyCommandPool(c->device, cmd_pool, NULL);
for (int w = 0; w < SYNC_WORKERS; w++) {
if (c && WP[w].pool) vkDestroyCommandPool(c->device, WP[w].pool, NULL);
free(WP[w].slots);
memset(&WP[w], 0, sizeof WP[w]);
}
if (c && exec_tl) vkDestroySemaphore(c->device, exec_tl, NULL);
exec_tl = VK_NULL_HANDLE;
perf_gpu_stats = NULL;
if (c && ts_pool) vkDestroyQueryPool(c->device, ts_pool, NULL);
ts_pool = VK_NULL_HANDLE;
ts_state = 0;
ts_last_end = 0;
memset((void *)ts_busy, 0, sizeof ts_busy);
cmd_pool = VK_NULL_HANDLE;
free(slots);
slots = NULL;
nslots = 0;
plat_mutex_unlock(&sub_lock);
__atomic_store_n(&started, 0, __ATOMIC_RELEASE);
}
plat_mutex_unlock(&start_lock);
}
// ---- --probe-devlost (host.h): the end of a run whose device is lost, without a lost device and without a window.
// The device itself stays healthy, so what the driver answers after a real loss is not covered here.
// 1. A callback queued for the next value runs when the timeline reaches it (the check that step 2 can fail).
// 2. With vk_lost set, as after another thread saw the loss: a callback queued for the next value does not run although
// the timeline reaches it, done stays, and a submit, an upload flush and a wait for a submission's commands return
// at once without issuing a value.
// 3. With vk_lost clear again, four threads report a lost device at the same moment: the process must end with
// RT_LOST_EXIT, one report and one FATAL line in the output (tests/device_lost_check.py counts them). Reaching the
// end of this function is the failure.
static volatile int32_t probe_ran[2];
static void probe_lost_cb(void *arg) { plat_xchg32((volatile int32_t *)arg, 1); }
static void probe_lost_record(VkCommandBuffer cb, void *arg) { (void)cb; plat_xchg32((volatile int32_t *)arg, 1); }
static volatile int32_t probe_go;
static void probe_lost_thread(void *arg) {
static const char *const where[4] = {"probe thread 0", "probe thread 1", "probe thread 2", "probe thread 3"};
while (!__atomic_load_n(&probe_go, __ATOMIC_ACQUIRE)) {}
vk_device_lost_check(VK_ERROR_DEVICE_LOST, where[(intptr_t)arg & 3]);
}
// Makes the timeline reach the next value as a finished submission would: issued moves on, the semaphore is signalled
// from the host, and the completion thread is woken. Returns the value.
static uint64_t probe_lost_advance(VkCtx *c, volatile int32_t *ran) {
plat_mutex_lock(&st_lock);
uint64_t v = issued + 1;
plat_mutex_unlock(&st_lock);
vk_completion_add(v, probe_lost_cb, (void *)ran);
sync_hw_raise(&tl_hw, v); // a host signal: noted before it ("a lost device in the timelines' values")
VkSemaphoreSignalInfo si = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO, .semaphore = c->timeline, .value = v};
if (vkSignalSemaphore(c->device, &si) != VK_SUCCESS) return 0;
plat_mutex_lock(&st_lock);
__atomic_store_n(&issued, v, __ATOMIC_RELEASE);
plat_cond_broadcast(&cv_work);
plat_mutex_unlock(&st_lock);
return v;
}
int vk_sync_probe_devlost(void) {
if (!vk_init()) {
printf("probe-devlost: SKIP (no Vulkan device on this machine)\n");
return 0;
}
vk_sync_start();
VkCtx *c = vk_ctx();
uint64_t v1 = probe_lost_advance(c, &probe_ran[0]);
for (int i = 0; i < 200 && !plat_cas32(&probe_ran[0], 1, 1); i++) plat_sleep_ms(10);
if (!v1 || !plat_cas32(&probe_ran[0], 1, 1) || !vk_sync_fence_wait(v1, 2000000000ull)) {
printf("probe-devlost: FAILED: a callback did not run on a healthy device (value %llu)\n", (unsigned long long)v1);
return 1;
}
printf("probe-devlost: healthy device: the callback of value %llu ran\n", (unsigned long long)v1);
plat_xchg32(&vk_lost, 1);
uint64_t v2 = probe_lost_advance(c, &probe_ran[1]);
plat_sleep_ms(500); // five of the completion thread's 100 ms waits
int ran = plat_cas32(&probe_ran[1], 1, 1), reached = vk_sync_fence_done() >= v2;
volatile int32_t recorded = 0;
uint64_t before = vk_sync_issued(), t0 = plat_now_ms();
uint64_t got = vk_sync_submit_own(probe_lost_record, (void *)&recorded);
vk_upload_flush(1);
int waited = vk_sync_exec_wait(v2, 5000000000ull);
uint64_t ms = plat_now_ms() - t0;
int ok = v2 && !ran && !reached && !recorded && got == before && vk_sync_issued() == before && !waited && ms < 1000;
printf("probe-devlost: device marked lost: callback of value %llu %s, done %s, submit %s (issued %llu -> %llu), "
"the paths returned in %llu ms\n", (unsigned long long)v2, ran ? "RAN" : "not run", reached ? "ADVANCED" : "stayed",
recorded ? "RECORDED" : "not recorded", (unsigned long long)before, (unsigned long long)vk_sync_issued(),
(unsigned long long)ms);
if (!ok) { printf("probe-devlost: FAILED\n"); return 1; }
printf("probe-devlost: four threads report the loss now; the process must end with exit code %d\n", RT_LOST_EXIT);
fflush(stdout);
plat_xchg32(&vk_lost, 0);
for (intptr_t i = 0; i < 4; i++) plat_thread_spawn(probe_lost_thread, (void *)i, "probe lost", 0);
plat_sleep_ms(50);
__atomic_store_n(&probe_go, 1, __ATOMIC_RELEASE);
plat_sleep_ms(20000);
printf("probe-devlost: FAILED: still running 20 s after the loss was reported\n");
return 1;
}
// NX2NAVI_TRACE=perf "mem: sizes" (perf.c): command buffer slots (read without sub_lock, which a whole replay holds),
// the completion queue and the submissions held back.
void vk_sync_sizes(PerfSizes *s) {
uint64_t slots = (uint64_t)__atomic_load_n(&nslots, __ATOMIC_RELAXED);
plat_mutex_lock(&st_lock);
uint64_t q = (uint64_t)qn;
plat_mutex_unlock(&st_lock);
perf_size(s, "command buffer slots", slots, 0, PERF_SIZE_NONE);
perf_size(s, "completion queue", q, 0, PERF_SIZE_NONE);
perf_size(s, "held submissions", (uint64_t)__atomic_load_n(&held_n, __ATOMIC_RELAXED), 0, PERF_SIZE_NONE);
}