VK_KHR_timeline_semaphore explained: monotonic frame counters for GPU sync

Binary semaphores are the acquire→signal→wait one-shot primitive — each is born, fires once, dies. Frame-in-flight tracking becomes a ring of them. VK_KHR_timeline_semaphore replaces the ring with a single monotonic counter: each submit signals a value, each wait targets a value — “wait until frame 42’s work is done” is vkWaitSemaphores(sem, 42).

Binary vs. timeline

compare.txt
binary:    sem signaled → consumed by the wait → must be recreated
           next frame — N in-flight frames = N semaphores + N fences
timeline:  one semaphore, uint64 counter — "wait for value ≥ N" is
           a query against the counter — one object per queue, forever

The primitives

timeline.cpp
// creation — just a flag + initial value:
vk::SemaphoreTypeCreateInfo tci{vk::SemaphoreType::eTimeline, 0};
auto sem = dev.createSemaphore({{}, &tci});

// a submit signals a value:
vk::TimelineSemaphoreSubmitInfo tssi{};
uint64_t next = ++frameCounter;
tssi.signalSemaphoreValueCount = 1;
tssi.pSignalSemaphoreValues    = &next;
si.signalSemaphoreCount        = 1;
si.pSignalSemaphores           = &sem;
si.pNext = &tssi;
queue.submit(si, fence);   // fence still covers CPU-side wait

// host-side wait for a specific value:
dev.waitSemaphores({{}, sem, frameN}, UINT64_MAX);

// GPU-side wait — another submit waits on a specific value:
tssi.waitSemaphoreValueCount = 1;
tssi.pWaitSemaphoreValues    = &frameN;

Why a camera app cares

uses.txt
in-flight frame tracking  — "is frame N's DMA-Buf free to reuse?"
                            = timeline ≥ N — no per-frame fence ring
decoder → render          — decode worker signals value N, renderer
                            waits on N — the frame number IS the
                            sync token
multi-slot                — one timeline per slot; each camera's
                            stream is its own counter
screenshot/capture        — "wait until the GPU-JPEG dispatch for
                            frame N landed" = waitSemaphores(N)

The convenience calls

calls.cpp
// CPU wait — blocks until the counter reaches value:
dev.waitSemaphores({{}, sem, value}, timeout);

// query without waiting:
uint64_t cur = dev.getSemaphoreCounterValue(sem);

// signal from the host (rare — usually GPU signals):
dev.signalSemaphore({sem, value});

Practical rules

  • timelineSemaphore device feature + eTimeline type at creation — a binary semaphore can’t be promoted.
  • Values must be monotonic — signal N, then N+1, never N-1; the counter only goes forward.
  • A wait on value N means “wait until at least N” — a submit signaling N+5 satisfies a wait on N.
  • Still use fences for “the submit is fully retired” — a timeline wait says the semaphore reached N, not that the command buffer’s side effects are CPU-visible; the fence is the memory-flush.
  • vkWaitSemaphores on a timeline is the frame-pacing primitive — wait on N-2 to allow 2 frames in flight, no fence ring needed.

Check out similar posts by category: Vulkan C++