A scene that was slower than it should be
Hyrule Field is where we measure. It is a wide, open view full of grass and foliage, and on the Arc 140T it ran at 14.8 game fps. Game fps counts only the frames the game renders itself. Frames made by frame generation never count, and frame generation was off for everything in this post.
An integrated GPU shares its memory and its power budget with the CPU, so there is no headroom to waste. The first job was to find out where the GPU time went. Per-draw GPU timing pointed at one draw: the foliage, drawn with mesh shaders, at 15.7 ms of GPU time. A 60 Hz display gives a whole frame 16.67 ms, so this one draw was using nearly all of it on its own.
Rather than keep guessing at why a foliage draw would be that heavy, we read the shader the emulator had generated for it.
Why an emulator searches for memory
Switch 2 shaders can read and write memory through raw 64-bit addresses, a model usually called global memory. A Vulkan shader on a PC cannot follow a guest address like that. It reaches memory only through buffers that the emulator binds for the draw.
So the shader translator, inherited through the yuzu, Eden, Eglantine and Lichii line, maps each memory access to a bound buffer while it translates the program. When it can prove where an address comes from, the access becomes a plain buffer read. When it cannot, the access stays global, and the generated SPIR-V finds the right buffer at run time by comparing the address against the bound buffers until one matches.
That fallback is correct and general, and on most draws it costs little. In the foliage program it ran on access after access.
What the foliage shader was spending its time on
The translated program spent its time on address routing rather than on foliage. Two things stood out:
- The buffer base addresses never changed while the program ran, yet every access worked them out again.
- Each address was built from a pointer the program had just loaded from a known buffer, so only the buffers that pointer could point into were real candidates. Every access still compared the address against all of the bound buffers.
Neither of those is a GPU or driver problem. It is work the translated shader repeats because, in general, it cannot know better. In this program it could.
The fix: three bits in one switch
EDEN_NX2_GM is a set of bits, and 11 turns on three of them:
| Bit | Effect |
|---|---|
1 | Computes the buffer base addresses once per shader invocation instead of in every access, in programs that cannot change them. |
2 | A load searches only the pointer buffers its address is built from, instead of every bound buffer. |
8 | With bit 2: when the address is in none of those buffers, the load returns zero instead of falling back to a search of every buffer. |
Bits 1 and 2 remove repeated work. Bit 8 is different: it changes behaviour when an address falls outside the buffers the translator expected. That load now reads zero, where the full search might have found the address somewhere else. The fix is verified in Ocarina of Time. Everywhere else it is unproven, which is why the switch is scoped the way it is.
The result, and its limits
| Measure | Switch off | EDEN_NX2_GM=11 |
|---|---|---|
| Game fps, frame generation off | 14.8 | 22.8 |
| Foliage mesh draw, GPU time | 15.7 ms | 5.8 ms |
Core Ultra 9 285H with Arc 140T, Intel driver 32.0.101.9175, The Legend of Zelda: Ocarina of Time.
Read this as one data point: one laptop, one game, one scene. Other Intel iGPUs, AMD Radeon iGPUs, other drivers, games and scenes are unknown until someone measures them. The benchmarks page has the full table and the frame-generation play configuration, and the Arc 140T page has the machine details.
Where it is on, and the known issue
- Since v20.0.1,
EDEN_NX2_GM=11is on by default only for Ocarina of Time, the one game it is verified in. An explicitEDEN_NX2_GMvalue still wins. - You can try it in another game by setting it in your
nx2_switches.txt. If the game renders wrong with it, setEDEN_NX2_GM=0and report the game. - Known issue #6: with bit 2, a narrowed load keeps its buffer list in instruction flags that another pass also reads as its "coherent" bit. It is under review, and the switch gets measured again once it is settled.
For setup, follow the README quick start and the switch reference in docs/switches.md. Those two are kept current.
Measure it on your device
The most useful next step is the same A/B on hardware we do not have. tools/bench plays a fixed scene, checks with a screenshot that it reached the right spot, and writes one JSON row with personal details scrubbed. A paired run, switch off and then on in one session, is the evidence that counts. docs/BENCHMARKING.md has the steps.
Questions people ask
Does frame generation add to these numbers?
No. Game fps counts only frames the game renders, and frame generation was off for this A/B. How frame generation and pacing work is in the post on 16.67 ms.
Will the fix help on AMD Radeon iGPUs?
Unknown. The routing code the translator emits does not depend on the GPU vendor, but nothing has been measured on an AMD iGPU yet. A device report from an AMD handheld is one of the most useful things you can send.
Why not resolve every address while translating?
The translator can turn an access into a plain buffer read only when it can prove where the address comes from. Everything else has to be routed at run time to stay correct. The fix makes that routing cheaper; it does not guess.
Help make it fast on your hardware
Every device except the Arc 140T is untested by the maintainers. Send a device report, run the benchmark, or pick an agent-ready issue. Shader people: issue #6 is open. How to contribute.