How to profile inside a shader with VK_KHR_shader_clock and clockRealtimeEXT

Timestamp queries bracket a pass from the command-buffer side — they can’t tell you which branch inside the shader ate the time. VK_KHR_shader_clock puts a readable clock inside the shader: each invocation can ask “what cycle count is it now” and diff the samples. Combined with VK_EXT_shader_realtime_clock you get both a stable GPU-cycle counter and a nanosecond wall clock.

The two clocks

clocks.frag
#extension GL_EXT_shader_realtime_clock : require
#extension GL_ARB_shader_clock          : require  // device cycles

uint64_t c0 = clockARB();        // device-dependent units (GPU cycles)
// … the code you're timing …
uint64_t c1 = clockARB();
uint64_t cycles = c1 - c0;       // per-invocation cycle delta

// wall-clock version (GL_EXT_shader_realtime_clock):
uint64_t t0 = clockRealtimeEXT();   // ns since some epoch
uint64_t t1 = clockRealtimeEXT();

clockARB is cycles — the value scales with GPU clock throttling but is monotonic per-invocation. clockRealtimeEXT is nanoseconds — same epoch as the host’s clock_gettime, so GPU↔CPU correlation is possible.

Wiring it up

enable.cpp
// VK_KHR_shader_clock — device/subgroup cycle counters:
VkPhysicalDeviceShaderClockFeaturesKHR sc{};
sc.shaderDeviceClock   = VK_TRUE; // clockARB — device cycles
sc.shaderSubgroupClock = VK_TRUE; // a *warp-synchronized* clock —
                                  // lanes read the same value

// VK_EXT_shader_realtime_clock — nanosecond wall clock:
VkPhysicalDeviceShaderRealtimeClockFeaturesEXT rc{};
rc.shaderRealtimeClock = VK_TRUE; // clockRealtimeEXT — ns
// … both chain into device pNext …

The pattern

profile.frag
// write per-fragment cycle counts into a buffer:
layout(set=0,binding=0) buffer Timings { uint64_t t[]; } times;

uint64_t start = clockARB();
vec4 c = expensiveLighting(...);   // ← the suspect
times[gl_FragCoord.y * W + gl_FragCoord.x] = clockARB() - start;
// read back + histogram → which pixels/branches cost the most

What it can’t do

  • clockARB is per-invocation — warp divergence means the slowest lane’s number is the warp’s number; for warp-level timing use shaderSubgroupClock + clock2x32ARB/shared writeback.
  • It doesn’t account for stalls between invocations — memory latency hidden by the scheduler doesn’t show up in a single invocation’s diff; a cluster of invocations shows it.
  • clockRealtimeEXT’s epoch is implementation-defined — compare deltas, don’t compare absolute values across devices.

Practical rules

  • VK_KHR_shader_clock → shaderDeviceClock/shaderSubgroupClock; VK_EXT_shader_realtime_clock → shaderRealtimeClock — two separate extensions, both needed for the full picture.
  • Use shaderSubgroupClock for warp-level measurements — all lanes in the subgroup read the same counter, so the delta is the warp’s, not one lane’s.
  • clockARB returns cycles — compare ratios between two runs, not absolute cycle counts across GPUs (boost clocks differ).
  • Pair with VK_EXT_shader_demote_to_helper_invocation + debugPrintfEXT (earlier posts) — the clock tells you where the time went, printf tells you what branch took it.
  • For pass-level timing you still want VK_KHR_performance_query counters (separate post) — shader clocks are for intra-shader profiling, counters for hardware units.

Check out similar posts by category: Vulkan C++