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 mostWhat it can’t do
clockARBis per-invocation — warp divergence means the slowest lane’s number is the warp’s number; for warp-level timing useshaderSubgroupClock+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
shaderSubgroupClockfor warp-level measurements — all lanes in the subgroup read the same counter, so the delta is the warp’s, not one lane’s. clockARBreturns 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_querycounters (separate post) — shader clocks are for intra-shader profiling, counters for hardware units.
If this post helped you, please consider buying me a coffee or donating via PayPal to support research & publishing of new posts on TechOverflow