Step 28 · Graphics and performance

The Vulkan backend

The last leg: turning accumulated state into real Vulkan objects, getting texture memory into a layout the GPU can sample, and presenting the result. This is also where most performance answers live.

Before this page: How a GPU actually draws · Submitted is not completed. View the learning route.

First pass

The host rendering job

The host backend obtains buffers and images, binds a pipeline and resources, records commands, submits them, and eventually presents an image. Recording or submitting a command does not mean the device finished it. Reused objects need correct contents, layouts and lifetime as well as a matching cache key.

First follow that sequence and the completion timeline. Read device features, tiling formulas, cache keys and alias synchronization as deeper reference, then follow D3 with the current source.

Can a cached image always be reused because its guest address matches?

No. The format, extent, usage and current contents must still match the required view, and synchronization and lifetime must be valid. An address is only part of the resource identity.

Use the code-example guide: snippets below are source excerpts or labelled schematic sequences, not standalone programs.

Implementation reference

The host side. Its central job is caching: turning "this guest address range, in this format, used this way" into "this Vulkan object" — and knowing when that answer stops being true.

Device setup

src/graphics/host_gpu/graphicContext.h
inline constexpr uint32_t VULKAN_TARGET_API_VERSION = VK_API_VERSION_1_3;

struct GraphicContext {
    vk::Instance       instance;
    vk::PhysicalDevice physical_device;
    vk::Device         device;
    VmaAllocator       allocator;
    bool     memory_budget_ext_enabled;
    bool     supports_block_texel_view;
    bool     compute_subgroup_size_control_enabled;
    bool     sample_rate_shading_enabled;
    bool     mesh_shader_enabled;          // VK_EXT_mesh_shader, for merged geometry stages
    uint32_t subgroup_size, min_subgroup_size, max_subgroup_size;
    uint32_t max_push_descriptors;
    vk::ShaderStageFlags required_subgroup_size_stages;
    Common::Mutex queue_mutex;
    uint32_t      queue_family;
    vk::Queue     queue;
    ...
};

One queue, guarded by a mutex. The 57 guest queues of PM4 & the command processor are multiplexed onto it — ordering is preserved through timeline semaphores rather than through hardware queues.

Format capability queries are memoised, because they are asked constantly and are not cheap:

vk::FormatProperties GetFormatProperties(vk::Format format) const {
    std::scoped_lock lock(m_format_properties_mutex);
    auto [it, inserted] = m_format_properties.try_emplace(format);
    if (inserted) physical_device.getFormatProperties(format, &it->second);
    return it->second;
}

Resource managers and caches

CacheKeyed byNotes
TextureCache 77 KBaddress + format + extent + binding typeAliasing, depth/stencil pairing, mip expansion, DCC/HTile metadata clears, upload, download and LRU collection. It is owned with the buffer cache by RenderContext.
BufferCache 28 KBguest address rangePage-indexed vertex, index, uniform and storage buffers, batched uploads/downloads, page tracking for DMA fills and copies, LRU collection and the BDA fault path.
PipelineCache 36 KBshader IDs + non-dynamic static state + attachment formatsOwns the program cache (translation → per-specialization SPIR-V), the pipeline map, and a per-game Vulkan driver cache saved to disk. Logs compiled shader counts per guest stage.
DescriptorHeap 3 KBdescriptor-set layoutAllocates sets in batches of 32 and retires whole Vulkan descriptor pools against timeline ticks.
SamplerCache 6.5 KBthe guest S#Small and simple by comparison.
FaultManager 7 KBfaulted BDA page rangesNested in BufferCache; processes RDNA2 FLAT/BDA misses through a GPU fault buffer and page table, including faults at the very edge of a mapping.

The pipeline key

Vulkan bakes fixed-function state into the pipeline object, so every state difference needs a distinct pipeline — unless the state is declared dynamic and set per draw instead. Kyty packs everything that is not dynamic into one byte-comparable struct:

…/pipeline/pipelineCache.h
#pragma pack(push, 1)
struct PipelineStaticParameters {
    bool negative_one_to_one, depth_clip_enable;
    vk::PrimitiveTopology topology;
    bool primitive_restart_enable;
    uint32_t samples;
    bool sample_shading_enable;
    uint32_t color_mask[8];
    bool cull_front, cull_back, face, provoking_vtx_last;
    vk::PolygonMode polygon_mode;
    uint8_t color_srcblend[8], color_comb_fcn[8], color_destblend[8];
    uint8_t alpha_srcblend[8], alpha_comb_fcn[8], alpha_destblend[8];
    bool separate_alpha_blend[8], blend_enable[8];
};
#pragma pack(pop)
static_assert(sizeof(PipelineStaticParameters) == 116);

struct GraphicsPipelineKey {
    PipelineRenderingState rendering;
    std::array<uint64_t, 3> vertex_shader_ids;  // VS/mesh, or LS/HS/TES
    uint64_t ps_shader_id;
    PipelineVertexInputState vertex_input;
    PipelineStaticParameters static_params;
};

The packing and the size assertion exist because operator== is a memcmp and the hash uses XXH3_64bits on the packed static state. Padding bytes would be uninitialised, so identical states could hash differently — producing a slow, silent pipeline explosion. The static_assert makes any accidental padding a build error.

Viewport, scissor, depth test/write/compare, depth-bounds enable/range, stencil test/operations/masks/reference and blend constants are Vulkan dynamic state set on the command buffer per draw, so they are not in the key. Fewer distinct keys means fewer first-encounter compiles. The packed struct is 116 bytes.

PipelineCache now has two levels. Kyty's maps avoid rebuilding a program or pipeline during one run; a per-game VkPipelineCache is also loaded and saved so the driver can reuse its own compiled data on the next run. The driver cache improves creation time but does not replace Kyty's exact C++ keys.

Renderer ownership and transfers

RenderContext directly owns PageManager, BufferCache, TextureCache and the mapped-range set. Its fault, invalidation, map/unmap, BDA preparation and garbage-collection methods replaced the separate GPU-resource manager. The owning renderer serializes pipeline-cache use, including saving while the GPU is running; the cache is not independently thread-safe.

Buffer readback is batched through a 64 MiB download ring. Clean guest buffers can stream directly; GPU-written aliases still require synchronization before CPU use. Image dirty-region queries honor CPU ownership and image-local readback validity. Texture alias resolution shares canonical layouts across linear/tiled, 1D/2D, mip-tail and depth/stencil views. DCC/CMASK clears must become real pixels before an expanded alias or video-out consumer can use them.

Draw preparation reuses shader bindings and reads vertex semantics directly from metadata. Release-label work is batched without redundant submissions; indirect dispatch counts are read from GPU buffers. These changes reduce per-draw work while keeping ordering and resource lifetimes explicit.

Command submission

…/renderer/commandScheduler.h
class CommandScheduler {
public:
    void Begin(HW::Context&, HW::UserConfig&, HW::Shader&);
    void BeginRendering(const RenderState&);
    void Flush();
    void Finish();
    void Wait(uint64_t tick);
    void DeferOperation(Common::UniqueFunction<void>&&);
    void DeferPriorityOperation(Common::UniqueFunction<void>&&);
    ...
private:
    MasterSemaphore m_master;              // timeline semaphore, monotonic tick
    class CommandPool {
        static constexpr size_t GrowStep = 4;
        std::vector<vk::CommandBuffer> m_buffers;
        std::vector<uint64_t>          m_ticks;
    } m_command_pool;
    CommandBuffer m_command;
    std::queue<PendingOperation> m_pending_operations, m_priority_operations;
    std::jthread m_priority_thread;
};

There is no fixed eight-buffer ring and no per-command-buffer fence now. The pool searches for a buffer whose recorded tick is no newer than the GPU's known completed tick; if none is free, it allocates four more. Every queue submission signals MasterSemaphore with the next monotonic tick. Deferred destruction records the current tick and runs only after MasterSemaphore::IsFree says the GPU passed it.

The separate priority thread exists because some deferred work — a readback the guest is blocking on — cannot wait for the normal drain without deadlocking.

What happens on one draw

Following the real call chain:

CpOpDrawIndexAutoguestpm4Handlers.cpp — unpack index_count and flags from the packet body into a DrawAutoArgs.
↓
CommandProcessor::DrawIndexAuto(DrawAutoArgs)kytygraphicsRun.cpp — apply predication, resolve the current register state, forward to the executor. (The argument list was collapsed into one struct in September 2026, which also fixed the PS5 NGG vertex/instance offsets.)
↓
RenderExecutor::DrawAutohostrenderDraw.cpp — set debug info, build a DrawCallInfo.
↓
PrepareDrawRenderStatehostrenderDraw.cpp — phases ResolveRenderColorTarget, ResolveRenderDepthTarget, then GetGraphicsPrograms: the pipeline cache translates on a miss, materialises the resource snapshot, and compiles the specialization if it is new.
↓
ExecutePreparedDrawhostrenderDraw.cpp — phases PrepareBindings, PrepareVertexBuffers, CreatePipeline, BeginRendering, then set the dynamic state, bind and record vkCmdDraw; DrawComplete closes the log.

Each phase name is logged via LogDrawPhase, so a crash or hang inside a draw can be localised from the log alone. Unsupported primitive topologies are now skipped with a log line rather than aborting the draw.

Texture cache internals worth knowing

enum class BindingType : uint8_t { Texture, Storage, RenderTarget, DepthTarget, VideoOut };

ImageId       FindImage(ImageDesc& desc, bool exact_format = false);
ImageId       FindImageFromRange(uint64_t address, uint64_t size, bool ensure_valid = true);
vk::ImageView FindTexture(ImageId id, const ImageDesc& desc);
void          MarkGpuWritten(ImageId id);
void          InvalidateMemoryFromGPU(uint64_t address, uint64_t size);
void          RunGarbageCollector();

Aliasing is the routine case, not the exception — games reuse memory aggressively, so two images legitimately overlap:

std::vector<ImageId> FindImagesInRegion(uint64_t address, uint64_t size, bool page_overlap) const;
OverlapResult        ResolveOverlap(const ImageInfo& requested, BindingType binding,
                                    ImageId cached, ImageId merged);
ImageId              ResolveDepthOverlap(const ImageInfo& requested, BindingType binding, ImageId cached);
ImageId              ExpandImage(const ImageInfo& info, ImageId source);
void                 AssociateStencil(ImageId depth, GuestRange stencil);

And memory pressure is managed with explicit thresholds:

uint64_t m_total_used_memory   = 0;
uint64_t m_trigger_gc_memory   = 0;
uint64_t m_pressure_gc_memory  = 1536ull * 1024 * 1024;      // 1.5 GB
uint64_t m_critical_gc_memory  = 3ull * 1024 * 1024 * 1024;   // 3 GB
Common::LeastRecentlyUsedCache<ImageId, uint64_t> m_lru_cache;
Learn more · the Vulkan side
  • Tutorial
    Vulkan GuideIts chapters on descriptor sets, pipelines and the render loop map onto DescriptorHeap, PipelineCache and CommandScheduler. The fastest way to understand why those classes are shaped as they are.
  • Docs
    Khronos Vulkan Guide — SynchronizationFences, semaphores, events and pipeline barriers. This is the part of Vulkan most easily got subtly wrong, and it is exactly what MasterSemaphore and the fence-retention helpers are managing. Khronos also has Understanding Vulkan Synchronization as a gentler introduction.
  • Code
    VulkanMemoryAllocatorThe allocator Kyty links. Its documentation explains why raw vkAllocateMemory is unusable directly and what the sub-allocation strategies cost.

Guest memory holds swizzled data; Vulkan wants something else. tile.cpp describes guest layouts and tiler.cpp orchestrates conversion using compute shaders.

The layout families

src/graphics/guest_gpu/tile.h
enum class TileBlockFamily : uint32_t {
    Standard256B,      // small textures
    Standard4KB,
    Standard4KB3D,
    Standard64KB,      // the common case for large textures
    Standard64KB3D,
    Prt64KB,           // partially-resident (sparse) textures
    Prt64KB3D,
    RenderTarget64KB,  // colour attachments
    Depth64KB,         // depth/stencil, with its own interleaving
    Count,
};

Each family has a different swizzle, and the parameters change with bytes-per-element and with mip level. Small mip levels share a packed mip tail whose offsets still follow the tiled layout:

struct TileSurfaceDescription {
	Prospero::BufferFormat format    = Prospero::BufferFormat::kInvalid;
	Prospero::TileMode     tile_mode = Prospero::TileMode::kLinear;
	TileSurfaceDimension   dimension = TileSurfaceDimension::Dim2D;
	uint32_t               width     = 0;
	uint32_t               height    = 0;
	uint32_t               depth     = 1;
	uint32_t               levels    = 1;
	uint32_t               layers    = 1;
};

// Width/height fields are measured in addressable elements, not texture texels.
struct TileMipLayout {
	uint64_t offset        = 0;
	uint64_t size          = 0;
	uint32_t width         = 0;
	uint32_t height        = 0;
	uint32_t padded_width  = 0;
	uint32_t padded_height = 0;
	uint32_t tail_x        = 0;
	uint32_t tail_y        = 0;
	bool     operator==(const TileMipLayout&) const = default;
};

struct TileSurfaceLayout {
	TileSurfaceDescription description;
	TileTextureBlockLayout texture;
	uint32_t               first_tail_level = 0;
	uint64_t               block_slice_size = 0;
	uint64_t               total_size       = 0;
	TileMipLayout          mips[16]         = {};
};

The API

bool TileGetBlockLayout(TileBlockFamily family, uint32_t bytes_per_element,
                        TileBlockLayout& layout);
bool TileGetTextureElementLayout(Prospero::BufferFormat format, TileTextureElementLayout& layout);
bool TileGetTextureBlockLayout(Prospero::BufferFormat format, Prospero::TileMode tile, bool volume,
                               TileTextureBlockLayout& layout);
bool TileGetTiledTextureLayout(const TileSurfaceDescription& description,
                               TileSurfaceLayout&            layout);
bool TileGetBlockOffset(const TileBlockLayout& layout, uint32_t x, uint32_t y, uint32_t z,
                        uint32_t& byte_offset);
bool TileGetBlockXor(const TileBlockLayout& layout, uint32_t block_x, uint32_t block_y,
                     uint32_t& byte_offset);
bool TileGetBlockXor(const TileBlockLayout& layout, uint32_t block_x, uint32_t block_y,
                     uint32_t block_z, uint32_t& byte_offset);

bool     TileGetDepthSize(uint32_t width, uint32_t height, uint32_t pitch,
                          Prospero::DepthFormat z_format, Prospero::StencilFormat stencil_format,
                          bool htile, TileSizeAlign& stencil_size, TileSizeAlign& htile_size,
                          TileSizeAlign& depth_size, uint32_t num_fragments_log2 = 0);
uint32_t TileGetRenderTargetPitch(uint32_t width, uint32_t bytes_per_element,
                                  uint32_t num_fragments_log2 = 0);
uint32_t TileGetDepthPitch(uint32_t width, uint32_t bytes_per_element,
                           uint32_t num_fragments_log2 = 0);
bool     TileGetRenderTargetSize(uint32_t width, uint32_t height, uint32_t pitch,
                                 uint32_t bytes_per_element, TileSizeAlign& total_size,
                                 uint32_t num_fragments_log2 = 0);
bool     TileGetDccSize(uint32_t width, uint32_t height, uint32_t slices,
                        uint32_t bytes_per_element, uint32_t levels, Prospero::TileMode tile,
                        TileSizeAlign& total_size, uint32_t num_fragments_log2 = 0);
bool     TileGetCmaskSize(uint32_t width, uint32_t height, uint32_t slices, uint32_t levels,
                          TileSizeAlign& total_size);
void     TileGetTextureSize(Prospero::BufferFormat format, uint32_t width, uint32_t height,
                            uint32_t levels, Prospero::TileMode tile, TileSizeAlign* total_size,
                            TileSizeOffset* level_sizes, TilePaddedSize* padded_size);
void     TileGetTextureTotalSize(Prospero::BufferFormat format, uint32_t width, uint32_t height,
                                 uint32_t depth, uint32_t levels, Prospero::TileMode tile,
                                 bool volume_texture, TileSizeAlign& total_size);
uint32_t TileGetTexturePitch(Prospero::BufferFormat format, uint32_t width,
                             Prospero::TileMode tile);

TileGetBlockXor deserves a note. Beyond the in-block interleave, AMD applies an XOR of block coordinates into the address so that adjacent blocks land in different memory channels and banks. Miss it and images come out looking like a shuffled checkerboard rather than obviously wrong.

Conversion runs on the GPU

Detiling a 4K texture on the CPU would be unacceptable, so Kyty ships a compute shader per layout family. They are compiled at build time and embedded into the binary (embed_spirv.cmake):

src/graphics/host_gpu/shaders/
├─ gpu_tiler_common.inc            shared helpers
├─ gpu_tiler_standard.inc          shared 256B/4KB logic
├─ gpu_tiler_standard4.comp        standard 4 KB
├─ gpu_tiler_standard64.comp       standard 64 KB
├─ gpu_tiler_standard256.comp      standard 256 B
├─ gpu_tiler_standard4_3d.comp     3D variants
├─ gpu_tiler_standard64_3d.comp
├─ gpu_tiler_prt.comp              partially-resident
├─ gpu_tiler_prt_3d.comp
├─ gpu_tiler_render_target.comp    colour attachments
├─ gpu_tiler_depth.comp            depth/stencil
├─ gpu_tiler_promote_d16.comp      16-bit depth → 32-bit
├─ gpu_tiler_demote_d16.comp       and back
└─ gpu_tiler_color_transform.comp      channel order fix-ups

The last three are not tiling at all — they are format adaptations for cases where the host GPU cannot represent a guest format directly. promote_d16 / demote_d16 exist because some drivers handle 16-bit depth poorly, so Kyty may store it as 32-bit and convert on the way in and out.

Compression metadata

SurfaceWhat it isKyty's approach
HTilePer-tile depth range and compression state alongside a depth buffer, used for fast rejection and fast clears.Sizes are computed by TileGetHtileSize; clear state is tracked so a "cleared" surface need not be materialised.
DCCDelta Colour Compression metadata for render targets.The address comes from the T#'s MetaAddr(). The texture cache tracks meta surfaces and their clear masks (IsMeta, IsMetaCleared, ClearMeta, TouchMeta).
CMask / FMaskFast-clear mask and MSAA fragment mask.Addresses are carried in HW::RenderTarget; used to detect fast-clear-only targets.
Why fast clears matter so much

A "cleared" render target on real hardware may contain no pixel data at all — only a metadata surface saying "everything is this colour." If Kyty ignored that and read the pixels, it would get stale garbage. Tracking clear state is therefore not an optimisation but a correctness requirement.

A game does not "present a frame." It registers buffers, renders into one, and asks the display hardware to scan it out at the next vertical blank. Reproducing that protocol faithfully matters more than it sounds.

The protocol

src/graphics/presentation/videoOut.h
int VideoOutOpen(int user_id, int bus_type, int index, const void* param);
int VideoOutRegisterBuffers2(int handle, int set_index, int buffer_index_start,
                             const VideoOutBuffers* buffers, int buffer_num,
                             const VideoOutBufferAttribute2* attribute, int category, void* option);
int VideoOutSubmitFlip(int handle, int index, int flip_mode, int64_t flip_arg);
int VideoOutIsFlipPending(int handle);
int VideoOutGetFlipStatus(int handle, VideoOutFlipStatus* status);
int VideoOutAddVblankEvent(EventQueue::KernelEqueue eq, int handle, void* udata);
int VideoOutAddFlipEvent(EventQueue::KernelEqueue eq, int handle, void* udata);
int VideoOutWaitVblank(int handle);

Step by step:

  1. Register. The game hands over guest addresses plus a tiling and format description. Kyty records them; the texture cache creates images lazily.
  2. Render. Ordinary draws into one of those buffers, through the normal command stream.
  3. Flip. VideoOutSubmitFlip is queued, not immediate. A flip submitted while another is pending must block, exactly as on hardware — games rely on this for pacing.
  4. Notify. Flip and vertical-blank events are delivered into the game's event queues.

The GPU-side flip

The interesting case is a flip requested from inside the command stream, which must happen after the preceding draws complete:

class VideoOutDriver final {
public:
    int  SubmitFlipFromGpu(Graphics::CommandBuffer& buffer, int handle, int index,
                           int flip_mode, int64_t flip_arg, uint64_t& request_id);
    void PrepareFlip(uint64_t request_id, Graphics::CommandBuffer& buffer);
    void CompleteFlip(uint64_t request_id);
    void SubmitFlipPreparation(uint64_t request_id);
    void WaitForSubmitSlot();
    void WaitFlipDone(int handle, int index);
};

It becomes a Vulkan submission ordered by timeline semaphore, and the command processor has matching entry points — Flip(), FlipWithInterrupt(), PrepareCpuFlip(), WaitFlipDone().

Virtual vertical blank

A timer fires at the configured rate — 60 Hz by default, --vblank-frequency — and pushes events into every queue that registered for them. This must stay steady even when rendering is slow, because many engines drive their entire simulation from it. A game that waits on vblank and gets it late does not just render late; it simulates late.

The final blit

Only at the very end does the guest's buffer reach the host window. The swapchain path is ordinary Vulkan: acquire an image, blit or resolve the guest buffer into it, present. swapchain.cpp handles format negotiation, present modes and resize.

Presentation statistics and the window thread

Presenter::Impl::Present() increments WindowLoopState::presented_frames after a successful presentation path. WindowContext::Run() samples that atomic counter on the main thread at roughly one-second intervals, computes FPS from the frame-count difference and elapsed SDL milliseconds, and calls UpdateTitle(frame_num, current_fps). HostInputWaitEvent(event, max_wait_ms) bounds the input wait so title updates also happen when no input arrives.

Follow src/graphics/presentation/window/swapchain.cpp, window/window.cpp and window/hostInput.cpp. The title uses SDL_SetWindowTitle() directly on the main thread; presentation does not wait for a title callback. The relaxed frame counter supplies display statistics, not a Vulkan completion fence. C4 explains the ordering distinction.

FileRole
presentation/videoOut.cppThe whole flip model, buffer sets, attributes, events, output modes, colour settings.
presentation/window/vulkanWindow.cppInstance and device creation, extension and feature selection (mesh shaders, atomics, depth features), --gpu device choice, queue setup.
presentation/window/swapchain.cppSwapchain lifecycle, format selection, present; waits for image acquisition before the layout transition.
presentation/window/window.cpp + hostInput.cppSDL3 window and the main-thread event loop; keyboard, mouse, touchpad emulation and gamepad handling.
presentation/systemOverlay.cppThe imgui overlay drawn over the game: the IME keyboard and system error dialogs, which stay open until dismissed and keep controller focus.
presentation/renderDoc.cppLoads the RenderDoc API at runtime for frame capture (--rd); captures start and stop on the present thread so a capture is a whole guest frame.

Blend mapping and readback lifetimes

PipelineStaticParameters is 116 bytes. Depth-bounds enable and range are dynamic state. Inactive blend fields are canonicalized, and identical color/alpha equations share a key. Logical-alpha handling belongs to ShaderPixelInputInfo::alpha_blend_source and the effective blend factors, with None, SourceAlpha, SourceAlphaOne and SourceAlphaZero variants. blendMapping.cpp classifies supported remaps; unsupported combinations disable blending with a warning. Sampler LOD bias is clamped to the host limit.

The 64 MiB download ring is the common readback path, not a hard transfer limit. Oversized transfers allocate a temporary download Buffer; the deferred completion operation owns it until GPU completion, then invalidates host caches and copies back. See BufferCache::DownloadBufferMemory() and CommandScheduler::DeferPriorityOperation().

Watch

And how a finished frame actually reaches the screen.

Try

Compute the byte offset of a texel under both layouts. Note that with a narrow texture the two agree — tiling only pays off once a row exceeds a cache line, which is why the default here is wide.

Change render state and watch the pipeline cache key move. Every distinct key is a pipeline compile, and a key that includes state which cannot affect the result is where first-encounter stutter comes from.

And the synchronisation: timeline semaphores ordering work that the CPU and GPU are both racing through.

Do

If you have RenderDoc set up, this is the topic where a capture pays for itself.

Checkpoint