Step 24 · Graphics and performance
How a GPU actually draws
The next four topics translate one GPU's way of working into another's. That only makes sense if the pipeline itself is clear first: what a draw call actually sets in motion, and what Vulkan expects to be told.
Before this page: Instructions, addresses and the guest. View the learning route.
Half of KytyPS5 is graphics, and the graphics code assumes you know how the hardware works — not how an API looks. This chapter covers the hardware model.
The pipeline
To draw a triangle, a GPU runs a fixed sequence of stages. Some are programmable (you supply a shader), some are fixed-function (configured by registers):
Separately, compute shaders bypass all of this: they run a grid of threads doing arbitrary work, used for physics, culling, post-processing and — in Kyty — for detiling textures.
How shaders actually execute: waves and lanes
This is the part that trips people up, and it is essential for Shaders: RDNA 2 → SPIR-V.
A GPU does not run one shader invocation at a time. It runs a wave — 32 or 64 invocations — in lockstep on one instruction stream. Each invocation is a lane. One instruction, executed simultaneously for all lanes on their own data.
So what happens at an if? Lanes can take different logical paths, but the wave shares an instruction stream. Instead the hardware keeps an execution mask: a 64-bit register where bit n enables lane n. Branching is implemented by masking:
// if (x > 0) { A } else { B }
v_cmp_gt_f32 vcc, v0, 0 compare in every lane → 64-bit result in vcc
s_and_saveexec_b64 s[0:1], vcc enable only lanes where the test passed
… vector code for A — masked lanes do not update results …
s_andn2_b64 exec, s[0:1], exec flip to the other lanes
… code for B …
s_mov_b64 exec, s[0:1] restore
In this example both vector instruction sequences are visited and disabled lanes do not update their vector results. Scalar instructions are wave-wide, and scalar branches can skip blocks; EXEC masking is not a universal rule for every instruction. This is why GPU code is fast when threads agree and slow when they diverge — and, more importantly for us, why GPU control flow is data manipulation rather than program structure. Recovering structure from it is the hardest job the shader recompiler has.
RDNA 2 has two register files. VGPRs (vector registers) hold a different value per lane — a pixel's colour, a vertex's position. SGPRs (scalar registers) hold one value shared by the whole wave — a loop counter, a base address, a resource descriptor.
Image and buffer instructions consume resource descriptors through scalar registers, but shaders can load those descriptors from memory using runtime-selected addresses. Kyty must trace that dataflow and, where supported, discover a bounded set of compatible descriptor candidates.
Descriptors: how a shader reaches a texture
In a PC API you call BindTexture(slot, texture). On RDNA 2 there are no slots. A texture is described by a descriptor — a packed block of bits holding its address, format, size, tiling and swizzle — and the shader simply has that block in its registers, or loads it from memory itself.
AMD calls these "sharps":
| Name | Size | Describes |
|---|---|---|
| V# (buffer resource) | 4 dwords / 128 bits | Base address, stride, record count, data format, component swizzle. |
| T# (image resource) | 8 dwords / 256 bits | Base address, format, width, height, depth, mip levels, tiling mode, array slices, DCC/HTile metadata address. |
| S# (sampler) | 4 dwords / 128 bits | Filter modes, clamp modes, anisotropy, LOD range and bias, border colour. |
You can decode real ones here. The bit layouts are taken directly from Kyty's shaderBindings.h:
Every accessor here corresponds to a real method — ShaderBufferResource::Base48(), ShaderTextureResource::Width5(), ShaderSamplerResource::XyMagFilter() and so on. src/graphics/shader/shaderBindings.h
Tiling: textures are not stored in rows
A linear image stores pixel (x, y) at base + y*pitch + x*bpp. GPUs do not do this, because a shader sampling a texture reads a 2D neighbourhood, and a linear layout puts vertically adjacent pixels far apart in memory.
Instead pixels are interleaved into small blocks so that a 2D neighbourhood lands in one cache line. AMD has several schemes — 256 B, 4 KB, 64 KB, plus special layouts for render targets, depth buffers and partially-resident textures — each with its own swizzle function and alignment rules that vary with bytes-per-pixel and mip level.
Guest memory holds tiled data. Vulkan wants either linear data or its own opaque layout. Converting between them is The Vulkan backend.
- ArticleFabian Giesen — A trip through the Graphics PipelineA thirteen-part series on what the hardware actually does between a draw call and a pixel. Slightly older than RDNA 2, but the concepts — command processors, wave scheduling, rasterisation, depth and colour compression, tiling — are exactly the ones this book keeps returning to. The single best background reading for Part V.
- VideoBranch Education — How do Graphics Cards Work? Exploring GPU ArchitectureHighly detailed 3D animations of a real GPU die. The section on single-instruction-multiple-data execution and thread architecture is the clearest visual explanation of waves and lanes you will find.
- SlidesKayvon Fatahalian — How a GPU Works (CMU 15-462)Builds a GPU from first principles: start with a simple core, then add SIMD lanes, then multithreading, and watch a shader core emerge. Pairs with his "A Closer Look at GPUs" paper.
- SpecAMD RDNA 2 Instruction Set Architecture Reference GuideThe 291-page manual for the exact shader ISA the recompiler consumes. The README names this as the primary reference for shader work — Shaders: RDNA 2 → SPIR-V is not really workable without it. Announced here; more architecture documentation at GPUOpen.
Kyty targets Vulkan 1.3. You do not need to be able to write a Vulkan renderer to read this book, but you need the vocabulary and one key idea: Vulkan is explicit about everything, which is both why it suits an emulator and why it makes some things awkward.
The objects you will meet
| Vulkan object | What it is | Where Kyty creates it |
|---|---|---|
VkInstance / VkDevice | The library connection and a logical GPU. | vulkanWindow.cpp, stored in GraphicContext. |
VkCommandBuffer | A recorded list of GPU commands. Nothing executes until submitted. | CommandScheduler::CommandPool allocates in batches of four and reuses buffers after their timeline tick completes. |
VkPipeline | Shader stages plus static pipeline state. Supported dynamic state, such as viewport and depth bounds, is set separately. | PipelineCache, keyed on shader IDs and a packed state struct. |
VkDescriptorSet | A bundle of resource bindings handed to a pipeline. | DescriptorHeap, allocated in batches; its pool retires after the timeline tick completes. |
VkImage / VkBuffer | GPU memory with a format and usage. | TextureCache, BufferCache. |
VkFence / timeline VkSemaphore | CPU–GPU and GPU–GPU synchronisation. A timeline semaphore carries a monotonically increasing counter. | MasterSemaphore. |
In OpenGL the driver guessed what you meant, tracked state changes and re-compiled shaders behind your back. Vulkan removes all of that: you declare everything up front and manage synchronisation yourself.
For an emulator this is exactly right, because the guest has already declared everything — the PM4 register state is a complete pipeline description. Kyty's job is translation, not inference. The awkward part is the reverse: Vulkan bakes state into immutable pipeline objects, whereas the guest changes registers freely, so Kyty must detect which changes actually require a new pipeline. That is what the pipeline cache key is for.
SPIR-V, and why it is stricter than machine code
Vulkan does not accept GLSL or HLSL. It accepts SPIR-V: a binary intermediate language of 32-bit words. Every value has a declared type, every capability used must be requested, and — critically — control flow must be structured.
Structured means: every conditional branch declares in advance where its two paths reconverge (the merge block), and every loop declares both its merge block and its continue target. In SPIR-V assembly:
OpSelectionMerge %merge None "these branches rejoin at %merge" OpBranchConditional %cond %then %else %then = OpLabel ... OpBranch %merge %else = OpLabel ... OpBranch %merge %merge = OpLabel
Compare that with the RDNA 2 snippet in this topic, which had no merge point at all — just mask manipulation and fall-through. Reconstructing the declarations SPIR-V demands, from code that never had them, is the single hardest pass in the recompiler, and it is allowed to fail: there is a documented fallback for shaders whose control flow cannot be structured.
A worked mapping
To make the shape of the translation concrete, here is one line of the same operation at four levels:
| Level | Representation |
|---|---|
| Original shader source | float d = dot(a, b); |
| RDNA 2 machine code | v_mul_f32 v4, v0, v3 then v_fma_f32 v4, v1, v2, v4 … |
| Kyty decoder output | schematic: decoded |
| Kyty IR | IR::ValueOpcode::FPMul32 with typed operands and source-location metadata |
| SPIR-V | %r = OpFMul %float %a %b |
Multiply is easy. The work is in the thousand cases that are not: packed 16-bit maths, saturating conversions, cross-lane shuffles, image sampling with a dozen modifier bits, buffer loads that need bounds checking SPIR-V does not provide for free.
Kyty links SPIRV-Tools, so it can run spirv-val on everything it emits (--shader-validation true) and spirv-opt to optimise (--shader-optimization-type). Turning validation on is the fastest way to catch a malformed module before the GPU driver does something unpredictable with it.
- TutorialVulkan TutorialThe standard first Vulkan tutorial: builds a triangle from nothing, covering instance, device, swapchain, pipeline and command buffers. You do not need to finish it to read this book, but working through the pipeline and command-buffer chapters makes The Vulkan backend far more legible.
- TutorialVulkan Guide (vkguide.dev)A more practical, engine-shaped take on the same material — closer to how a renderer is actually structured. Its Great Resources page is a good onward index.
- HubKhronos — Learn VulkanThe official starting point, including the Khronos-maintained samples and tutorials.
- SpecSPIR-V SpecificationRead section 2.11, "Structured Control Flow", before Shaders: RDNA 2 → SPIR-V. It defines
OpSelectionMergeandOpLoopMergeand the dominance rules that make the structurisation pass necessary — and that make it able to fail. - PaperAn Introduction to SPIR-VA short white paper on what SPIR-V is and why it looks the way it does. Much gentler than the specification.
- CodeSPIRV-ToolsThe validator and optimiser Kyty links against.
spirv-disfrom this repository is what you use to read a dumped module as text.
The stages a draw passes through, and where the emulator has to intervene in each.
Background topic — the checkpoint is about vocabulary, since everything after this leans on it.
Checkpoint