// ggml scheduler field guide
ggml_backend_sched · integrated GPUs

One buffer.Two writers.

On a discrete GPU the scheduler copies every graph input across the backend boundary, and the copy is what orders the host against the device. On an integrated GPU the copy is elided — the device reads the exact bytes the host thread wrote. Nothing then stops the host writing the next ubatch into those bytes while the device is still reading the last one.

write-after-read · ROCm_Host[0, 2048)
gfx1151
inp_tokens — one buffer, no copy HOST ROCm0 write ub1 write ub2 reads inp_tokens host overwrites bytes still being read
RACES 87 with the ring: 0

Violet = the host thread's memcpy. Amber = the device's read, still in flight. The two are never ordered against each other, because the mechanism that would have ordered them — the input copy — was elided as an optimisation.

Part one

Nobody makes a copy

The race is not a missing lock. It is a synchronisation edge that only ever existed as a side effect of a copy, disappearing the moment the copy became unnecessary.

the same graph input, two devices discrete — device rejects the host buffer host buf copy device buf ROCm0 split->inputs[] ← inp_tokens event_wait before the split runs host and device are ordered integrated — device accepts it host buf reads in place ROCm0 split->inputs[] stays empty no event_wait, no synchronize nothing orders them at all
The wait on the left is not there because anyone reasoned about ordering. It is there because a copy has to finish before the split that consumes it. Remove the copy and the wait goes with it.
The elision
Gatesched_buffer_supported()
Populatessplit->inputs[]
Consumed bycompute_splits()

One predicate decides both things

A tensor becomes a split input only when the split's backend cannot use the buffer it already lives in. That single test decides whether a copy is made and whether the split waits, because the waits are driven by walking split->inputs[].

llama.cpp hands the CPU slot a device host buffer type — pinned memory owned by the GPU — so that intermediate results move faster. An integrated GPU reports that it can compute on that buffer type. The two facts combine into: no copy, no entry in inputs[], no wait, and a device reading memory a host thread is still writing.

The host writes are a plain memcpy on the calling thread. They are not ordered against an in-flight ggml_backend_graph_compute_async() by anything.

When it fires
Needs>1 ubatch per decode
Cleansingle ubatch
Cleantoken generation

It needs a second ubatch, not a second thread

The window is the gap between issuing one ubatch and writing the next one's inputs inside the same llama_decode(). Widen the batch so the prompt fits a single ubatch and the race vanishes; generate tokens one at a time and it never appears, because the sampling loop reads logits and therefore synchronises.

That is why it hides. The configuration that triggers it — a long prompt on an integrated GPU — is also the one nobody runs under a race detector.

RACE (write-after-read) on ROCm_Host[0, 2048)
  read  ROCm0#2  @3  split 0   inp_tokens (compute)
  write HOST     @5  split -1  inp_tokens (tensor_set)
  no happens-before edge: HOST knows ROCm0#2@2, needs >=3

[0, 2048) is exactly n_ubatch × 4 bytes — the whole inp_tokens tensor, overwritten at the first ubatch boundary.

Part two

Making it visible first

Ordering bugs do not announce themselves — they produce wrong numbers on some runs and not others. Before fixing anything, the race had to become something a single run either reports or does not.

Sanitizer
EnvGGML_SCHED_SANITIZE=1
Modelvector clock per actor
Stateshadow map per byte

A happens-before checker for the backend API

The sanitizer keeps a vector clock for the host thread and for each backend, plus a shadow map recording which actor last read or wrote every byte of every buffer. Synchronisation points — backend synchronize, event record, event wait, async copies — advance those clocks. An access that conflicts with an earlier one, where no chain of edges connects the two, is a race.

It is built into ggml-base and costs nothing when the variable is unset.

Blind spot
Addedtensor_set_direct()
Wrapperllama_host_write()
Sites22

Two thirds of the races were invisible

The sanitizer hooks the backend API. But llama.cpp does not always go through it: for host-visible tensors it asserts the buffer is host memory and then writes straight through tensor->data. Those writes were never announced, so every race against them was silently missed.

ggml_backend_tensor_set_direct() announces such a write and does nothing else. It is folded into llama_host_write(), which carries the assertion those sites already had — putting the announcement inside the idiom that already marked them, so the two cannot drift apart.

tensorseen viaraces
attn_inp_kq_maskset_direct40 — entirely unseen before
inp_tokenstensor_set9
attn_inp_k_idxsset_direct9
attn_inp_v_idxsset_direct9
inp_s_copyset_direct9
inp_postensor_set9
inp_out_idsset_direct2

87 races on one multi-ubatch prefill, 69 of them only visible after the hook was added. The largest single contributor was the attention mask, which no earlier instrumentation could see at all.

Part three

Write somewhere else instead

The obvious fix — synchronise before writing — pays for ordering with a stall on every ubatch. The ring buys the same guarantee with memory: give each input several slots and step onto the next one, so the bytes being written are never the bytes being read.

n_copies = 2 — the host is always a slot ahead slot 0 slot 1 write ub1 ROCm0 reads write ub2 ROCm0 reads write ub3 ROCm0 reads host writes slot 1 while slot 0 is still being read — different bytes slot 0's reader finished here the wait before reusing slot 0 is normally already satisfied — it was last read n_copies−1 iterations ago depth 2 is the default: the smallest ring that removes the stall. GGML_SCHED_MAX_COPIES caps it at 4.
The wait is not removed, it is moved somewhere it costs nothing. Stepping onto a slot still waits for that slot's previous reader — but that reader is a full iteration old, so the wait returns immediately.
Obligation 1
Callerssched_prepare_inputs()
Whyreuse skips allocation

The reuse path allocates nothing, so it cannot rotate on its own

Rotation happens in ggml_backend_sched_alloc_graph(). But llama.cpp's fast path checks whether the previous graph can be reused and, when it can, skips splitting and allocation entirely — which is exactly the path taken during steady-state decoding.

ggml_backend_sched_prepare_inputs() is the rotation without the allocation. A caller writing into a reused graph must call it first. After alloc_graph() it is a no-op, because allocating already rotated.

This obligation is easy to get wrong in a way that looks fine: forget it and the inputs simply stay at fixed addresses, which is correct — right up until two computes are in flight.

Obligation 2
Backendscgraph::uid
AffectsCUDA graph cache
CheckGGML_CUDA_GRAPH_VERIFY_UID

A cached graph must not trust that addresses stood still

Backends that cache work against a graph — the CUDA graph cache is the one in tree — may treat an unchanged ggml_cgraph::uid as a promise that nothing the cached work captured has moved, and skip re-reading the addresses.

Rotation moves tensor addresses without re-splitting. So the scheduler re-stamps the split uids whenever it re-points inputs. A backend keeping such a cache must key it on the uid or re-check addresses itself.

This does not degrade gracefully. The cached work replays against stale addresses and the results are quietly wrong — no crash, no warning, just different numbers. The uid fast path is now verified rather than trusted in debug builds.
Cost control
Rotatesonly when in flight
Prefill12 rotations
Decode0 rotations

Generation never pays for it

The scheduler tracks whether a compute has been issued that might still be reading. Anything that waits for the backends clears that state. A sampling loop reads logits every token, which synchronises, so its inputs stay at fixed addresses and the ring costs nothing but the memory.

Measured on one multi-ubatch prefill followed by generation: 12 rotations during prefill, zero during decode. The ring engages precisely where the hazard is.

The memory is not free, though — one extra copy of every graph input per extra slot, and attention masks scale with context and batch. That is the argument for a default depth of 2 rather than the maximum of 4.

Part four

Three ways it went wrong

Rotating buffers means tensor addresses move, and identity changes between allocations. Both assumptions were load-bearing in places nobody had written down. Two of the three bugs below were pre-existing — the ring only made them reachable.

trap 1 — the allocator compares everything except identity alias follows cur_copy alloc #1 → inputs#0 alloc #2 → inputs#1 the leaf is a different tensor ggml_gallocr needs_realloc node / leaf counts ✓ sizes ✓ identity — never compared "no realloc needed" stale node_allocs replayed galloc->buffers[-1] → SIGSEGV alias pinned to slot 0 both allocations → inputs#0 identity stable, rotation unaffected
The scheduler aliases a split input to its source when they can share memory. Letting that alias follow the current slot changed which tensor the leaf array held, and the allocator's reuse check — counts and sizes, never identity — reported no change.
Trap 1
SymptomSIGSEGV in test-opt
Fixalias pinned to slot 0

The alias must not move

A crash inside the allocator, dereferencing buffers[-1], on a test that has nothing to do with integrated GPUs. The ring did not corrupt anything; it violated an undocumented invariant — that the identity of the tensors in a graph does not change between two allocations the allocator considers equivalent.

Pinning the alias to slot 0 keeps identity fixed while the other slots still rotate, which is all the ring actually needs.

trap 2 — the same bytes, a different tensor compute buffer (ROCm_Host) [6309920, 6408224) still reading dnet_add_ch_state-0 ROCm0 read @166 already writing conv_states-1 CPU write @164 refcount hit zero, the range went back on the free list, and the reader was never waited for fix — pin the view root out of the reuse pool while another backend may still be reading it (+4.3 MiB)
Two tensor names, one memory range. This is not a data dependency the scheduler could have seen: the allocator recycled the region after its last use, and the backend that was reading it had never been waited for.
Trap 2
Symptomrace with -ngl 0
Fixgallocr_pin_tensor()
EnvGGML_SCHED_PIN_ASYNC_READS

The ring cannot help here, and neither can copies

This one appeared with no layers offloaded at all, and survived the ring completely — the two tensors are not graph inputs, so rotating inputs changes nothing. It is the same root cause wearing different clothes: a backend reads memory in place, and nothing orders a later writer against it.

The fix keeps memory that another backend may still be reading out of the allocator's reuse pool, keyed on the view root — the tensor that actually owns the bytes and the one the allocator frees. Keying it on the accessed tensor instead misses every access that goes through a view, which is most of them; that mistake cost a full measurement cycle before the race counts refused to move.

trap 3 — the write that goes nowhere node placed away from what it aliases state (CPU) pass 5 ROCm0#state#0 SET writes here never written back the state tensor on CPU is never updated every op runs, the answer is wrong recurrent state stops carrying forward pass 4 keeps them together state (CPU) SET runs on CPU no copy is substituted, the write lands in the state state carries between tokens again guarded: the move only happens if that backend supports the op — CUDA runs GGML_OP_SET for F32 and I32 only
Some ops write through to a source instead of to fresh memory — ggml_set(), ggml_set_rows(), ggml_cpy(), every in-place variant. Their result is a view of the source, so running them anywhere else means writing into a copy nobody reads back.
Trap 3
Symptomsilent wrong output
Fixpass 4 alias rule
Scopeevery backend

Found while chasing the ring, unrelated to the ring

Pass 4 already said "views are always on the same backend as the source", but only applied it to nodes still unassigned — passes 2 and 3 can move an aliasing node before it gets there. Enforcing the invariant for nodes that alias a source and are not pure view ops moves 13 nodes on a hybrid recurrent model at partial offload, and removes the copies that fed them.

This one reproduces on unmodified master and is the only bug here that is not about integrated GPUs at all. CUDA escapes it because it reports integrated = false, which changes placement enough to avoid the trigger.

The guard matters as much as the rule. Op support can be conditional on tensor types, so the backend owning the aliased memory is not guaranteed to be able to run the op writing into it. There is no correct placement in that case — the scheduler copies operands into a split, never results out of one — so the node is left where it was, and the reason is logged.
Part five

Who actually gets a ring

The first two attempts at the detection predicate were both the wrong shape — not wrong about integrated GPUs, but wrong about what the condition actually is.

Predicate
Teststhe copy elision itself
Requirescaps.async
Nota device allow-list

Test the condition, not the device

The scheduler detects the hazard by testing the thing that causes it — a host buffer type in the last slot that some other backend accepts for compute — rather than by naming devices. A device that refuses to compute on pinned host memory is unaffected without anyone maintaining a list.

That was still not sufficient. An early version excluded backends by device type, which missed BLAS: an accelerator device that computes on host memory and therefore matched. The ring engaged inside ggml_opt, which does not meet the caller obligation, and 45 tests started failing on a Windows CI runner.

The real condition is asynchrony. A backend with no synchronize has finished its work by the time graph_compute returns, so nothing of its can still be reading. Requiring caps.async covers CPU, BLAS, and everything else synchronous in one test instead of a growing list of exclusions.

// before — a device-type exclusion, and BLAS is not a CPU device
if (props.type == GGML_BACKEND_DEVICE_TYPE_CPU) { continue; }

// after — the property that actually matters
if (!props.caps.async) { continue; }
Blast radius
Engages onHIP integrated
Callerllama_context only

Every backend in tree, and why it does or does not qualify

Two conditions have to hold together: the backend must be asynchronous, and it must accept the host buffer type the CPU slot was given. Checking every backend's supports_buft rather than reasoning from device class gives an exact answer.

backendasyncaccepts a host buft?ring
CUDA / HIPyesonly pinned host memory, and only when integratedHIP APUs only
Metalyesrequires buft->device == devnever
Vulkan, SYCLyesrequire their own buffer typenever
Hexagon, ETyesrequire their own buffer typenever
CPU, BLAS, RPC, CANN, OpenCL, othersnoexcluded by the async gate

CUDA hard-codes integrated = false, so in practice the ring engages on HIP integrated GPUs and nowhere else. And only llama_context hands the CPU slot a device host buffer type — clip.cpp and ggml_opt both pass a plain CPU buffer type, which no asynchronous backend accepts.

Worth stating plainly: Apple Silicon is the most familiar unified-memory machine in the world and it is not affected, because Metal declines the CPU buffer type and the scheduler therefore still makes the copy that orders everything.
Part six

In practice

Everything here is switchable at runtime, which is the only reason the measurements are trustworthy — the same binary produces both arms.

Switches
Allruntime, not build-time

Environment

variableeffect
GGML_SCHED_SANITIZE1 enables the happens-before checker, 2 also traces synchronization edges
GGML_SCHED_SANITIZE_NONFATAL1 reports every race instead of aborting on the first
GGML_SCHED_UMA_RING0/1 disables the ring, larger sets the depth. Cannot enable it where it was not detected
GGML_SCHED_PIN_ASYNC_READS0 disables pinning of memory read in place by another backend
GGML_SCHED_DEBUG1 prints scheduler decisions — including every rotation and every aliasing move
GGML_CUDA_GRAPH_VERIFY_UIDverify the uid fast path instead of trusting it (default on in debug builds)

Note that GGML_SCHED_UMA_RING can only ever reduce what was detected. There is no way to switch the ring on where the scheduler did not find the hazard, because the caller obligations would not be met.

Measurement
Trap-ub 512 never reuses
Trapdebug logs are filtered

Two ways to measure nothing at all

Both of these produced clean, confident, meaningless numbers before being caught:

  • The reuse path needs a small ubatch. At -ub 512 the graph is rebuilt rather than reused, so prepare_inputs() never runs. Race counts went to zero while the code under test had never executed. -ub 32 exercises it.
  • Scheduler debug output is filtered by the tool's verbosity. GGML_SCHED_DEBUG=1 without -v prints nothing, which reads identically to "the condition never fired". The aliasing rule appeared to move zero nodes for an entire investigation.

A third: the aliasing rule only fires on a graph shape that needs warmup and a real multi-ubatch prompt. With --no-warmup -n 1 it moves nothing, on a model where it demonstrably moves 13 nodes.

The general lesson is that a green result proves nothing unless the instrument is independently known to be live. Every one of these was caught by checking that some other output from the same code path was present.
Results
Models4 + server
Throughputunchanged

What it bought

workloadring offring on
multi-ubatch prefill, four models87 races0
server, -np 4 --kv-unified82 races0
partial offload, -ngl 16race + abort0
token generation00 — never rotates

Throughput is at parity with racy master and with the alternative fix proposed upstream. The ring buys coverage, not speed: it removes a class of wrong answers that were previously invisible, and it does so without adding a stall to the path that matters.