video_core: GPU thread split on by default for Switch 2 titles; cheaper front paths

Clean A/B in title 1 (3 rounds each): 1.46x the draws a second of one
thread, frames 17% shorter, no crashes, front register copies equal to
the back's in 57,089 checks. EDEN_NX2_SPLIT=0 runs everything on one
thread, =1 keeps macros on the back.

The front's per-write paths no longer check guarded statics
(GpuSplit::Get, Nx2Profile::Enabled, the skip-same switches) and the
ring's method writes are inline. Notes row 145.
This commit is contained in:
Takoroka 2026-10-29 11:55:07 -04:00
commit ac7ccd35f9
6 changed files with 58 additions and 45 deletions

View file

@ -59,4 +59,5 @@ on the back, `EDEN_NX2_SPLIT=2` also runs the 3D and compute macros on the front
copies. Measured in title 1 (NX2_NOTES rows 142-144): ~44,000 draws a second on one thread, ~47,400
with stage 2, 50,000-66,500 with stage 3. The front waits ~4,000-5,000 times a second (semaphore
acquires, MME DMA reads); the back still spends ~200 ms a second outside its zones (ring dispatch,
method sink). Next: a clean A/B for the default, then trimming the back's ring overhead.
method sink). Clean A/B (NX2_NOTES row 145): 1.46x throughput, frames 17% shorter; on by
default since. The back is ~92% busy, ~53% of it draw setup: the next step is there, not the ring.

View file

@ -200,6 +200,7 @@ With the patched decoder, games 1/3/4 only leave ~60-110 rare SIMD encodings in
| 142 | GPU thread ~100% busy, host GPU 20-40%: one thread parses, runs macros, sets state and records draws | the work is sequential but only a few points need a result from what came before (semaphore acquires, MME DMA reads) | GPU thread split (babd395, `gpu_split.h`, `EDEN_NX2_SPLIT=1`): engine methods run in order on a second thread fed by a lock-free ring; releases, syncpoints, wait-for-idle, flushes and channel binds are deferred into the ring, only acquires wait (~2,400 drains a second). ~47,400 draws a second against ~44,000 (3 A/B rounds), same per-call costs |
| 143 | crashes with the split: CommandChunk::Record (null chunk), vkCmdEndRenderPass on the Vulkan worker | two threads recording into the scheduler: (a) the DMA pusher's split-method debug log read guest memory with the cache-flushing ReadBlock on the front; (b) with async presentation, Swapchain::AcquireNextImage -> Scheduler::Wait flushed the main scheduler from the present thread whenever its tick was not submitted yet (pre-existing race, the split made it frequent) | (a) ReadBlockUnsafe; (b) e4523ab: the present thread waits up to 20 ms for the GPU thread to submit, never flushes. Found with `EDEN_NX2_SPLIT_CHECK=1` (stack of any other thread using the scheduler while the back runs) |
| 144 | macros on the split's front (`EDEN_NX2_SPLIT=2`) need the registers they read before the back has them | the front keeps copies: every write it sends plus what the back changes on its own (constant buffer offset, shadow_scratch[0], macro bind pointer, shadow RAM). `EDEN_NX2_SPLIT_VERIFY=1` found three differences: writes still in the back's method sink at the handover; a multi-word run that changes the shadow RAM mode mid-run; and shadow_state.shadow_ram_control being the shadow copy of register 0x49 itself (a Replay write reads the mode back) | all three modelled: 0 differences in 40,193 checks (~165 million macro calls). Front: macros 128 ns a call (180 on one thread, cache-hot), drops ~55% of 3D state writes as same-value. Back: 50,000-66,500 draws a second against 41,500-47,000 without the split; frames 20.5 / 25.5 ms on two courses where the unsplit runs measured 25.6-40.7 ms. Default stays off until a clean A/B |
| 145 | is the split worth having on by default | clean A/B, nothing else running, 3 rounds each (profiler on in both): one thread 41,100 / 48,600 / 37,000 draws a second, frames 30.5 / 35.0 / 27.1 ms; `EDEN_NX2_SPLIT=2` 59,200 / 62,400 / 62,700 draws a second, frames 28.6 / 23.3 / 25.1 ms. Throughput 1.46x, frames 17% shorter (the back is now ~92% busy, ~53% of it draw setup); 0 crashes, 0 register differences in 57,089 checks after the front's fast paths (no guarded statics, inline ring writes) | on by default for Switch 2 titles (`EDEN_NX2_SPLIT=0` one thread, `=1` macros on the back). Default run without the profiler: 16.9 ms frames (~59 fps) on a light course |
Run settings used (in `build/bin/user/config/sdl2-config.ini`): `memory_layout_mode=4`
(12 GB). Debug knobs: `EDEN_NX2_TC_EXPECTED_MIB` / `EDEN_NX2_TC_CRITICAL_MIB` (collector thresholds),
@ -214,7 +215,7 @@ faulting stack as module+offset, then a minidump), `EDEN_NX2_AUTO_STUB`, `EDEN_N
`EDEN_NX2_BUFFER_DOWNLOADS=0/1`, `EDEN_NX2_INLINE_DEFER=0`, `EDEN_NX2_TC_MIN_AGE=<frames>` (rows 131, 134, 133),
`EDEN_NX2_SKIP_SAME=0` (row 135),
`EDEN_NX2_MME_JIT=0` (row 140),
`EDEN_NX2_SPLIT=1/2`, `EDEN_NX2_SPLIT_VERIFY=1`, `EDEN_NX2_SPLIT_CHECK=1`, `EDEN_NX2_SPLIT_SKIP=0` (rows 142-144),
`EDEN_NX2_SPLIT=0/1` (default 2), `EDEN_NX2_SPLIT_VERIFY=1`, `EDEN_NX2_SPLIT_CHECK=1`, `EDEN_NX2_SPLIT_SKIP=0` (rows 142-145),
`EDEN_NX2_FRAME_CAPTURE=<dir>` (every 120th presented frame as TGA), `EDEN_NX2_PB_CHECK`
(pushbuffer entries changed between submit and execution), `EDEN_NX2_QUERY_TRACE` (every GPU
report/semaphore write). `C:\dev\crashrun.sh` runs eden-cli under cdb and prints the crash stack;

View file

@ -800,6 +800,7 @@ struct Maxwell3D::FrontState {
/// A method the draw manager handles was sent since the back last ran DrawDeferred: only
/// then can it have a deferred instanced draw to issue at the end of a macro.
bool draw_pending{true};
bool skip_same{}; ///< SkipSameValueWrites() && FrontSkipSame(), read once
};
namespace {
@ -865,6 +866,7 @@ Maxwell3D::FrontState& Maxwell3D::Front(Core::System& system, VideoCommon::GpuSp
std::memcpy(front->regs.data(), regs.reg_array.data(), sizeof(front->regs));
std::memcpy(front->shadow.data(), shadow_state.reg_array.data(), sizeof(front->shadow));
front->control = shadow_state.shadow_ram_control;
front->skip_same = SkipSameValueWrites() && FrontSkipSame();
front->executing_macro = executing_macro;
front->parameters = std::move(macro_params);
macro_params.clear();
@ -897,7 +899,7 @@ void Maxwell3D::FrontWrite(VideoCommon::GpuSplit& split, FrontState& fs, u32 met
if (!execution_mask[method]) {
// Nothing would change on the back: CallMethod and ConsumeSinkImpl skip same-value state
// writes, and the shadow RAM (tracked by default) holds the value already.
if (fs.regs[method] == value && shadow_kept && SkipSameValueWrites() && FrontSkipSame()) {
if (fs.regs[method] == value && shadow_kept && fs.skip_same) {
if (Profile::Enabled()) [[unlikely]] {
Profile::Add(Profile::Counter::SameValueWrites, 1);
}

View file

@ -80,15 +80,12 @@ void GpuSplit::CheckOwner(const char* where) {
#endif
}
GpuSplit& GpuSplit::Get() {
static GpuSplit split;
return split;
}
GpuSplit GpuSplit::instance;
void GpuSplit::MaybeStart(Core::System& system_) {
static const char mode = [] {
const char* value = std::getenv("EDEN_NX2_SPLIT");
return value != nullptr ? value[0] : '0';
return value != nullptr ? value[0] : '2';
}();
const bool wanted = mode == '1' || mode == '2';
if (active || !wanted || !Tegra::g_nx2_ampere.load(std::memory_order_relaxed)) {
@ -127,7 +124,7 @@ void GpuSplit::Stop() {
active = false;
}
u64* GpuSplit::Reserve(size_t words) {
u64* GpuSplit::ReserveSlow(size_t words) {
size_t index = write_position & RingMask;
if (index + words > RingWords) {
// Entries never wrap: skip to the start of the ring.
@ -194,10 +191,7 @@ void GpuSplit::Drain() {
}
}
void GpuSplit::SelectEngine(Tegra::Engines::EngineInterface* engine) {
if (engine == front_engine) {
return;
}
void GpuSplit::SwitchEngine(Tegra::Engines::EngineInterface* engine) {
front_engine = engine;
u64* const entry = Reserve(2);
entry[0] = static_cast<u64>(Kind::Engine);
@ -205,28 +199,6 @@ void GpuSplit::SelectEngine(Tegra::Engines::EngineInterface* engine) {
Commit(2);
}
void GpuSplit::Method(Tegra::Engines::EngineInterface* engine, u32 method, u32 argument,
bool is_last, const GPUVAddr* segment) {
SelectEngine(engine);
const size_t words = segment != nullptr ? 2 : 1;
u64* const entry = Reserve(words);
entry[0] = static_cast<u64>(Kind::Method) | (is_last ? IsLast : 0) |
(segment != nullptr ? HasSegment : 0) | (u64{method & 0xffff} << 8) |
(u64{argument} << 32);
if (segment != nullptr) {
entry[1] = *segment;
}
Commit(words);
}
void GpuSplit::MacroWrite(Tegra::Engines::EngineInterface* engine, u32 method, u32 argument) {
SelectEngine(engine);
u64* const entry = Reserve(1);
entry[0] = static_cast<u64>(Kind::Method) | IsLast | Direct | (u64{method & 0xffff} << 8) |
(u64{argument} << 32);
Commit(1);
}
void GpuSplit::MultiMethod(Tegra::Engines::EngineInterface* engine, u32 method, const u32* words,
u32 amount, u32 methods_pending, const GPUVAddr* segment) {
SelectEngine(engine);

View file

@ -28,12 +28,14 @@ namespace VideoCommon {
// second thread (the back), fed through a single-producer single-consumer ring. Work the front
// would do on back-owned state is deferred into the same ring; the front waits for the back to
// catch up (Drain) only where it needs a result from that state, e.g. a semaphore it acquires.
// Opt-in for Switch 2 titles: EDEN_NX2_SPLIT=1. EDEN_NX2_SPLIT=2 also runs the 3D and compute
// engines' macros on the front, against copies of the registers they read (Maxwell3D and
// KeplerCompute Front*), and sends the writes they make.
// On for Switch 2 titles with the 3D and compute engines' macros on the front, against copies of
// the registers they read (Maxwell3D and KeplerCompute Front*), which sends the writes they make.
// EDEN_NX2_SPLIT=1 keeps the macros on the back; EDEN_NX2_SPLIT=0 runs everything on one thread.
class GpuSplit {
public:
static GpuSplit& Get();
static GpuSplit& Get() {
return instance;
}
/// Whether the front sends work to the back (front thread only).
[[nodiscard]] bool Active() const {
@ -52,11 +54,28 @@ public:
/// An engine method write, executed as DmaPusher would (method sink, ConsumeSink, call).
/// `segment`: the engine's current_dma_segment for the call, when the engine reads it.
void Method(Tegra::Engines::EngineInterface* engine, u32 method, u32 argument, bool is_last,
const GPUVAddr* segment);
const GPUVAddr* segment) {
SelectEngine(engine);
const size_t words = segment != nullptr ? 2 : 1;
u64* const entry = Reserve(words);
entry[0] = static_cast<u64>(Kind::Method) | (is_last ? IsLast : 0) |
(segment != nullptr ? HasSegment : 0) | (u64{method & 0xffff} << 8) |
(u64{argument} << 32);
if (segment != nullptr) {
entry[1] = *segment;
}
Commit(words);
}
void MultiMethod(Tegra::Engines::EngineInterface* engine, u32 method, const u32* words,
u32 amount, u32 methods_pending, const GPUVAddr* segment);
/// A write made by a macro: CallMethod without the method sink, as the engines' macros do.
void MacroWrite(Tegra::Engines::EngineInterface* engine, u32 method, u32 argument);
void MacroWrite(Tegra::Engines::EngineInterface* engine, u32 method, u32 argument) {
SelectEngine(engine);
u64* const entry = Reserve(1);
entry[0] = static_cast<u64>(Kind::Method) | IsLast | Direct | (u64{method & 0xffff} << 8) |
(u64{argument} << 32);
Commit(1);
}
/// Runs `func` on the back after everything sent before it. Must be trivially copyable.
template <typename Func>
@ -103,7 +122,16 @@ private:
(*reinterpret_cast<const Func*>(payload))();
}
u64* Reserve(size_t words);
/// Room for an entry of `words` at the write position (never across the ring's end).
u64* Reserve(size_t words) {
const size_t index = write_position & RingMask;
if (index + words <= RingWords && write_position + words - consumed_cache <= RingWords)
[[likely]] {
return &ring[index];
}
return ReserveSlow(words);
}
u64* ReserveSlow(size_t words);
void Commit(size_t words) {
write_position += words;
if (write_position - published_local >= publish_every) {
@ -111,7 +139,12 @@ private:
}
}
void WaitForSpace(size_t words);
void SelectEngine(Tegra::Engines::EngineInterface* engine);
void SelectEngine(Tegra::Engines::EngineInterface* engine) {
if (engine != front_engine) [[unlikely]] {
SwitchEngine(engine);
}
}
void SwitchEngine(Tegra::Engines::EngineInterface* engine);
void BackLoop(std::stop_token stop_token);
size_t Execute(const u64* entry);
@ -132,6 +165,8 @@ private:
// Back side.
Tegra::Engines::EngineInterface* back_engine{};
static GpuSplit instance;
alignas(64) std::atomic<u64> published{};
alignas(64) std::atomic<u64> consumed{};
alignas(64) std::atomic<bool> back_waiting{};

View file

@ -64,9 +64,11 @@ inline constexpr std::array<const char*, static_cast<size_t>(Zone::Count)> zone_
"fence skipped", "method", "split drain", "split wait",
};
// Read once at startup: Enabled() runs several times per method write (no guard to check).
inline const bool g_enabled = std::getenv("EDEN_NX2_GPU_PROFILE") != nullptr;
inline bool Enabled() {
static const bool enabled = std::getenv("EDEN_NX2_GPU_PROFILE") != nullptr;
return enabled;
return g_enabled;
}
// Nanoseconds; from the host's own counter where the wall clock reads it directly (the zones