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.

Read

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):

Input assemblyfixedRead the index buffer, group vertices into primitives (triangles, lines, points).
↓
Vertex shaderprogrammableRuns once per vertex. Reads attributes, outputs a clip-space position plus arbitrary interpolated values.
↓
RasterisationfixedConvert each triangle into the set of pixels it covers. Interpolate the vertex outputs across that area.
↓
Pixel (fragment) shaderprogrammableRuns once per covered pixel. Samples textures, computes lighting, outputs colour — and optionally depth.
↓
Depth / stencil test, blendingfixedReject hidden pixels, combine the new colour with what is already in the render target.

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.

Jargon · scalar versus vector registers

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":

NameSizeDescribes
V# (buffer resource)4 dwords / 128 bitsBase address, stride, record count, data format, component swizzle.
T# (image resource)8 dwords / 256 bitsBase address, format, width, height, depth, mip levels, tiling mode, array slices, DCC/HTile metadata address.
S# (sampler)4 dwords / 128 bitsFilter 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:

Sharp decoder — V# / T# / S#edit any dword

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.

Learn more · how GPUs really work

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 objectWhat it isWhere Kyty creates it
VkInstance / VkDeviceThe library connection and a logical GPU.vulkanWindow.cpp, stored in GraphicContext.
VkCommandBufferA recorded list of GPU commands. Nothing executes until submitted.CommandScheduler::CommandPool allocates in batches of four and reuses buffers after their timeline tick completes.
VkPipelineShader 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.
VkDescriptorSetA bundle of resource bindings handed to a pipeline.DescriptorHeap, allocated in batches; its pool retires after the timeline tick completes.
VkImage / VkBufferGPU memory with a format and usage.TextureCache, BufferCache.
VkFence / timeline VkSemaphoreCPU–GPU and GPU–GPU synchronisation. A timeline semaphore carries a monotonically increasing counter.MasterSemaphore.
Why "explicit" matters here

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:

LevelRepresentation
Original shader sourcefloat d = dot(a, b);
RDNA 2 machine codev_mul_f32 v4, v0, v3 then v_fma_f32 v4, v1, v2, v4 …
Kyty decoder outputschematic: decoded Opcode::V_MUL_F32 with destination v4 and sources v0, v3
Kyty IRIR::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.

Tools in the build

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.

Learn more · Vulkan and SPIR-V
  • Tutorial
    Vulkan 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.
  • Tutorial
    Vulkan 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.
  • Hub
    Khronos — Learn VulkanThe official starting point, including the Khronos-maintained samples and tutorials.
  • Spec
    SPIR-V SpecificationRead section 2.11, "Structured Control Flow", before Shaders: RDNA 2 → SPIR-V. It defines OpSelectionMerge and OpLoopMerge and the dominance rules that make the structurisation pass necessary — and that make it able to fail.
  • Paper
    An 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.
  • Code
    SPIRV-ToolsThe validator and optimiser Kyty links against. spirv-dis from this repository is what you use to read a dumped module as text.
Watch

The stages a draw passes through, and where the emulator has to intervene in each.

Do

Background topic — the checkpoint is about vocabulary, since everything after this leans on it.

Checkpoint