Skip to content

sched, ggml-meta : pipeline the host KV delivery under split mode tensor - #92

Closed
Piggidragon wants to merge 53 commits into
GenerelSchwerz:llama/devfrom
Piggidragon:kv/pipelined-transport-tensor-split
Closed

Piggidragon wants to merge 53 commits into
GenerelSchwerz:llama/devfrom
Piggidragon:kv/pipelined-transport-tensor-split

Conversation

@Piggidragon

Copy link
Copy Markdown

Overview

Pipeline the host-to-device delivery of a host-resident KV cache under -sm tensor, the one case #39 left on the ordered path.

Depends on #39 (pipelined transport) and #66 (host cache split by head). The branch merges both, so the diff against llama/dev includes them. The change itself is the last three commits:

  1. ggml-meta : add events and a ranged write for a head-split host cache
    • A meta event is one event per simple device.
    • set_tensor_async / set_tensor_2d_async accept part of the window of the head-split copy (whole cells from an offset, per stream), as one 2d copy per stream per device.
    • ggml_backend_meta_init_transfer builds a meta backend without a communicator for the transfer streams.
    • The meta graph compute rotates the compute containers of buffers that are only sources, so tensors bound into the ring do not pile up.
  2. sched : pipeline the host KV delivery under split mode tensor
    • A meta backend is eligible when every simple backend is.
    • The headroom check uses the fullest simple device.
    • A device of the meta type that is not the ggml meta backend stays ordered (the existing test-alloc case).
    • test-llama-archs adds Meta -nkvo -np 2 -kvpd 1.
  3. docs : measure the pipelined transport under split mode tensor
    • Measurements and gates are in docs/kv-transport-pipelining.md#tensor-parallelism.
    • LLAMA_KV_SM works for the A/B and server gate scripts.

Testing

RTX 4070 (gen4 x16) + RTX 3060 (gen3 x4), CUDA + NCCL, Qwen3.8-27B-UD-IQ2_M, -sm tensor -nkvo --kv-cpu-pinned -ctk q8_0 -ctv q8_0.

llama-bench tg128, A/B/A/B, -kvpb 512:

context ordered pipelined gain
4,096 15.84 21.95 +38.6%
16,384 7.08 8.78 +24.0%
32,768 4.08 4.89 +19.7%

At 16,384 the ordered copy is 104 ms against 31 ms of compute. That is the 3060's x4 link, so the gain is bounded by the slower link, not by the scheduling.

Output is identical at depth 0, 1 and 4 in every gate:

  • server exactness gate (LLAMA_KV_SM=tensor): all 8 tasks
  • parallel gate, 8 concurrent sequences over streams: 125cb9c2082d36cf
  • greedy completion: Qwen3.8 64e86551f7ef1638, the same hash as a device-resident cache; gemma-4 5525e3f5ac7337d7 (-ts 50,50) and 4f8986fb3655a567 (-ts 55,45)

Existing paths are unchanged: the parallel gate still gives 17f946c340db110b with -sm none and db661b7a08686b97 with -sm layer at depth 0 and 1. test-llama-archs -s 1 passes on 2, 3 and 4 CUDA devices, and test-alloc passes.

Not done here

  • Each device allocates the whole ring slot although it holds only its share of the heads. The meta compute buffers work the same way; sizing per device is left as future work.
  • Not measured on devices with equal links.

Requirements

  • I have read and agree with the contributing guidelines
  • AI usage disclosure: YES - implemented by an agent on my instruction, see the Assisted-by: commit trailers.

🤖 Generated with Claude Code

https://claude.ai/code/session_0145V8xvxxUQMUVbg6ZiCZMg

Piggidragon and others added 30 commits September 9, 2026 00:44
With --no-kv-offload the attention history lives in host RAM and reaches the
accelerator on every decode token. The scheduler issued that transfer on the
consumer's own stream immediately before the kernels that read it, so a token
cost copy + compute in series.

The bytes and the attention operations are unchanged; only the point at which
the transfer is issued moves. Greedy output is byte-identical to the ordered
path -- verified against a build without these changes, at every look-ahead
tested, single GPU and layer-split across two.

Three pieces, each load-bearing:

- ggml_tensor::stable_prefix records how many leading bytes of a tensor's
  storage the graph about to run will not write. The KV window is not stable for
  a whole graph -- a CPU split writes this ubatch's rows into it between one
  layer's attention and the next -- but everything below the lowest written row
  is, and at decode depth that is essentially all of it. llama_kv_cache sets it
  from apply_ubatch(), before the graph is built and allocated, so the plan and
  the deliveries are decided against the same write position even when the graph
  is reused; build_graph_shift() clears it.

- A staging ring the graph allocator cannot reach. ggml-alloc may recycle a
  graph-owned input copy after its last graph-level consumer while a look-ahead
  transfer is still in flight. The scheduler allocates the ring itself and points
  the staged copies at it before allocation; a ready/release event pair per slot
  carries the handover in each direction. Every eligible accelerator gets its own
  ring, cursor and budget, so a layer-split model pipelines on each device and a
  device with no room falls back alone.

- A look-ahead that stays clear of the ring's tail. A delivery L splits ahead
  recycles the slot of the split L - n_slots back, so n_slots == L + 1 recycles
  the split just enqueued and still running. The ring keeps two slots of margin,
  deliveries are issued after a split is enqueued rather than before, and slot
  recycling is ordered stream to stream rather than through the host. Each of
  those three alone costs the entire gain while still producing correct output.

--kv-pipeline-depth N, default 1, 0 restores the ordered path exactly. It only
engages where a host-resident cache produces the deliveries.

Because a host-resident cache exists to keep device memory free, the staging is
capped outright by --kv-pipeline-budget (default 128 MiB per device) rather than
by a fraction of what happens to be free. A ring is (N + 2) slots of one
attention layer's K and V over the whole context, so it grows with the context:
27 MiB at 4k, 213 MiB at 32k, 1.7 GiB at 256k. Past the cap the scheduler
declines and keeps the ordered path, and declining costs nothing -- the check
runs before anything is allocated, the decision is latched because a context only
grows, and the transfer backend is created lazily and released with the ring.

Single GPU (RTX 4070, Qwen3.8-27B-UD-IQ2_M, -nkvo --kv-cpu-pinned, q8_0 K/V),
A/B/A/B with reversed arm order:

  depth   ordered            pipelined          gain
   4,096  31.7324, 31.7363   37.0889, 37.0741   +16.9%
  16,384  19.6765, 19.6854   31.5352, 31.5807   +60.4%
  32,768  13.0264, 13.0254   15.5325, 15.5329   +19.3%   (needs a raised budget)

Server decode behind an 18,422-token prompt: 18.468 -> 30.685 t/s, +66.2%.

Two GPUs (RTX 4070 + RTX 3060, Qwen3.8-27B-UD-Q5_K_M, -sm layer), both rings
engaged: 13.06 -> 18.05 t/s at 4,096 and 6.86 -> 9.75 t/s at 16,384.

The gain narrows with depth because compute is a shrinking share of the token,
so there is less to hide the copy behind. That is arithmetic, not an
implementation limit, and more look-ahead makes it worse rather than better.

Tensor parallelism keeps the ordered path: the scheduler sees one meta backend
there and the ring is a byte arena, while a meta buffer places tensors as
per-device slices rather than at offsets. It declines rather than staging into
something it cannot address.

docs/kv-transport-pipelining.md carries the design, the numbers and the limits;
docs/repro/ carries the scripts that produced them.

Assisted-by: Claude Opus 5
…cripts

llama-bench does not expose --kv-cpu-pinned or --recurrent-state-offload the way
llama-server does, so two of the reproduction scripts passed flags the binary
rejects. They now probe --help and pass only what it takes.

The feature doc gains what is actually left: why -sm tensor keeps the ordered
path (no events in the meta layer, and a ring that is a byte arena while a meta
buffer places tensors as per-device slices), the correctness problem underneath
it that is not this feature's, and the rest of the open list -- the transient
device-memory peak, the --kv-gpu-layers comparison, and the exactness harness's
dependence on baseline determinism it does not have.

The decline for a backend that cannot record events now says so by name rather
than falling into the generic "no backend supports" line, because the backend it
catches is the meta backend and the next person to look will want to know that.

Assisted-by: Claude Opus 5
The host-time breakdown the feature doc describes had no code behind it: the
counters existed but nothing accumulated or printed them. GGML_SCHED_TRANSPORT_DEBUG=2
now reports the split loop as a mean over each 128 graphs, with the bytes the
ordered path still moves and why the look-ahead stopped; =3 names the tensors
that are still on it. That is what found the rest: 40 blocking copies a token
moving 0.4 MiB, 32 of them the device-to-host KV store.

The budget warning now reports what the ring costs at the full context next to
what it costs now, so --kv-pipeline-budget can be sized against the number that
matters. It is still applied per graph: enforcing the projection would refuse the
ring for every large -c even when the window never gets near it.

llama-bench gains -kvcp and -rso. Without them a host-resident run measures
something else entirely -- 9.02 against 19.43 t/s ordered at 16,384 -- and the
repro scripts had been silently dropping both since llama-bench lost them.

The exactness harness gives every task a nonce derived from its own name and
length, so no two share a prefix the server can restore, and fails a task whose
prompt_n says one was reused anyway.

Assisted-by: Claude Opus 5
…t it does

GGML_SCHED_TRANSPORT_DEBUG=3 now reports what each remaining blocking copy cost,
not just its name. It turns out one of them is almost all of it: attn_inp_k_rot,
256 KiB, 18 us on the ordered path and 3.4 ms behind one split of look-ahead.

That is the copy engine, not latency. A blocking copy waits for the deliveries
already queued on it, and two staged splits at 22.0 GB/s is 3.6 ms. Issuing the
delivery in pieces does not help, the engine is FIFO across streams. Putting the
copy on the consumer's stream so the host never blocks moves the time into the
consumer wait and leaves throughput alone. The doc records both, so the next
person does not spend the afternoon on it again.

Assisted-by: Claude Opus 5
records@18432 was giving different answers across otherwise identical N = 0 runs,
which made it useless as a gate and looked like the pipeline breaking exactness.
It is not the task: asked on its own with the prompt cache off it returns the same
hash three times running, at -c 32768 and at -c 65536.

It is the harness. All eight tasks share one server with prompt caching on, and
records@18432 is about 29.6k tokens with a task of about the same size ahead of
it, so the two do not both fit in a 32,768 cache and placement depended on what
was still resident. The nonce stops a prefix being restored, it does not stop the
pressure. cache_prompt=false does.

Two independent N = 0 passes now agree on all eight tasks, and N = 0, N = 1 and
N = 4 agree on all eight.

Assisted-by: Claude Opus 5
Restrict staging to annotated CUDA inputs, make backend decline complete, re-evaluate budgets, freeze scheduler configuration, and preserve tensor layout. Add regressions for prefix changes and fallback behavior.

Assisted-by: OpenAI Codex
The ring laid its entries out with ggml_nbytes() and bound them by writing data
and buffer directly. A buffer type may ask for more than ggml_nbytes() for a
tensor -- CUDA does for a quantized one, and MMQ clears that padding -- so an
entry could reach into the next one. Entries are now sized with
ggml_backend_buft_get_alloc_size() and bound with ggml_backend_tensor_alloc(),
which also gives them the buffer's own initialization and its bounds check.

test-alloc gets a dummy buffer type whose get_alloc_size exceeds ggml_nbytes,
and a two-entry ring test that checks the entries stay inside the ring and out
of each other, that every byte of an entry is delivered once from the matching
source offset, and that nothing waits on an event before it is recorded.

llama-bench takes -kvpb/--kv-pipeline-budget and reports it. The repro scripts
pass 512 and now fail closed: they refuse a build without -kvcp, -rso or -kvpb
instead of dropping the option, and every arm propagates its status. The
llama-bench table in the doc was measured before the budget existed, so it says
so, and the 32,768 row is marked as needing a re-measurement.

Assisted-by: Claude Opus 5
The rings are laid out and allocated before the graph is, so a device that can
hold the graph alone but not the graph next to a ring turned into
GGML_STATUS_ALLOC_FAILED. The configuration is locked by then, so the caller
could not turn the ring off and retry either. When graph reservation fails the
rings are now released and the reservation is retried once on the ordered path,
and that scheduler keeps the ordered path from then on.

A plan over a split list with no inputs left input_staged unallocated and passed
it to memset, which UBSan reports even at size 0. Such a plan stages nothing, so
it now returns after putting every split back on the ordered path.

test-alloc gets a device capacity on the dummy backend and a test that sizes it
to hold the graph or the ring but not both.

The prose and public comments this branch added were hard-wrapped to a fixed
column, against the repository rule. They are unwrapped, one sentence per line.

Assisted-by: Claude Opus 5
The llama-bench table predated the budget and said so, and the context sweep
and the exactness gate were last run before the ring allocation and binding
changed. All three are re-run on an RTX 4070 with a CUDA build of this head, at
--kv-pipeline-budget 512.

Greedy server output is identical at depth 0, 1 and 4 across all eight tasks.
Throughput is +16.1% at 4,096, +56.6% at 16,384, +20.1% at 32,768 and +13.4% at
65,536, for +28, +104, +206 and +410 MiB of device memory. The 131,072 and
262,144 arms are not re-measured and say so.

The server table is replaced with the four 18,432-prefill tasks of the exactness
gate, which is what this head was actually run on; the copy/compute breakdown
keeps its earlier numbers and says which head they came from.

Assisted-by: Claude Opus 5
… MiB

The context sweep is measured with the budget raised, so its deep rows read
as default behaviour when they are not: past 20,556 rows of window the
default declines and those depths stay ordered. Say so, and add a finer
sweep that puts the peak at 16,384 rows and shows the gain per MiB falling
off as 1/rows^2 above it.

The peak is where copy and compute are equal, and the ring size there works
out to (n_slots / n_attn) * compute * BW - the bytes per row cancel, so the
budget is quant-invariant. That predicts 101 MiB against the 102 MiB
measured, which is what the 128 MiB default is sized against.

Assisted-by: Claude Opus 5
A window over several streams is one view of a tensor whose streams sit
end to end, and both the prefix and the delivery treated it as one flat
byte range. That made the lowest-writing stream cap the stable prefix for
every stream above it, and it copied the cells between one stream's window
and the next, which the graph never reads.

The prefix is now counted within a stream, and a staged input whose last
dimension indexes streams is delivered as one range per stream. A window
over one stream keeps the single flat range it had, so single-sequence
timing and bytes are unchanged.

Behind 8 slots of a non-unified cache this takes decode from 72.18 to
112.70 t/s, against 70.61 ordered. At one slot it measures 35.31 against
35.32 before.

test_transport_multi_stream_ranges pins the delivery: every stream's
window covered once from its own source offset, the unread cells between
them never moved, and the early and late bytes split as the prefix says.
Concurrent slots cannot be gated on output the way one sequence can -
their batching varies between runs, so the same depth gives different
greedy output - which is why this is a unit test.

Assisted-by: Claude Opus 5
The q4_0 arm of the context sweep moves the peak from 16,384 rows to
32,768 and leaves the ring at it at 108 MiB against 102, which is what
"the bytes per row cancel" claims. It was derived before and is measured
now.

Add the parallel numbers and say what the 8-slot row depends on: run on
its own the unified ring fits, and only after a sweep has allocated for
1, 2 and 4 slots in the same process does the headroom guard refuse it.

Assisted-by: Claude Opus 5
ne[2]*nb[2] is one KV cell, not the window: attention permutes the
window before reading it, so its rows sit on dimension 1. A ubatch
over several streams delivered one cell per stream and attention read
whatever the ring slot held before.

The test built the window in the pre-permute shape, so it passed.

Assisted-by: Claude Opus 5
A staged delivery reads its host source after the call that issued it
returns. The stable prefix keeps the host off that source within a
graph, but the next graph writes wherever its own ubatch lands, so a
recycled cell below the previous window could be rewritten under an
in-flight copy.

The ordered path gets this from its blocking copy, once per split.

Assisted-by: Claude Opus 5
Freeing the ring already goes through the backend that allocated it,
so that backend is alive here and its kernels may still be reading the
slots. Teardown skipped the wait and released the memory under them.

Assisted-by: Claude Opus 5
The range check left only the inner loop and inserted the values anyway.

Assisted-by: Claude Opus 5
The scheduler does not own its backends, and llama_context declares its
scheduler before them, so member destruction frees the backends first
and sched->backends[] dangles by the time the ring is freed.

A slot's release event is recorded past every kernel that reads it and
dispatches through the device, so waiting on it orders the free after
the consumer without touching the backend.

Assisted-by: Claude Opus 5
The per-stream loop in both delivery paths is what that helper does, and
it lets a backend with a 2d set issue one copy instead of one per stream.

Assisted-by: Claude Opus 5
The count is per stream, so the contract has to name the stride it goes
with rather than leave it as "the first nbytes".

Assisted-by: Claude Opus 5
The server's batching varies between runs, so it cannot gate concurrent
sequences. llama-parallel seeds its client schedule, so it can, and its
clients ask different questions: with one shared prompt every stream
holds the same bytes and a cross-stream read stays invisible.

Fails at depth 1 on the commit before the multi-stream span fix.

Assisted-by: Claude Opus 5
The parallel table was taken before the multi-stream span fix, so it
reported a delivery that moved a fraction of the window. Gates 1, 2 and
5 are re-run here, and the numbers that are still from an earlier head
now say so.

Assisted-by: Claude Opus 5
A graph that stages nothing kept the ring and the second device context
for the life of the scheduler. Give them back, from every path that ends
with nothing staged.

A window wider than the ring holds frees the ring and allocates it again,
which a prefill did on nearly every ubatch. Allocate a slot in powers of
two, capped by the full context, the budget and the headroom check, so a
16k prefill reallocates 6 times rather than 32. The decline decision still
goes by what the graph needs.

Wait at a graph boundary only for a ring that delivered, not for every ring
that has a transfer backend.

Assisted-by: Claude Opus 5
The platform check it had can never fail on a 64-bit size_t, so any value
was accepted and a large one was silently the same as 0. Cap it at 65536
MiB in all three places that parse it.

Assisted-by: Claude Opus 5
A staged delivery reads the host cache long after the decode that issued
it returned, so a memset of those buffers races it. llama_memory_clear
holds no context and cannot wait, so say so on the public function, and
wait where a context is at hand.

Assisted-by: Claude Opus 5
clear(data=true) memsets the buffers while a decode can still be reading them:
a staged delivery for a host-resident cache, the graph itself for a device-resident
one. The second one is not new and is easy to hit - llama_decode followed by
llama_memory_clear(mem, true) changed the logits of that decode on every trial.

Every memory type already passes the context down through init_update, so the
caches keep it from there and wait on it before the memset.

Assisted-by: Claude Opus 5
Keep a staged input's producer on the CPU or on the consumer itself, keep the transfer context over a graph that stages nothing, and stop asking a device that cannot give one. Say in the header and the docs that the destination is CUDA-only, and that a delivering graph costs the pipelining of n_copies > 1.

Assisted-by: Claude Opus 5
- Fix kv_pipeline_budget_mib handling to preserve negative sentinel value (-1 = not set, 0 = no cap)
- Add null check for transport backend before synchronizing
- Disable pipelined transport when n_copies > 1 to avoid conflicts with pipeline parallelism
- Expose set_lctx() method in kv_cache for hybrid index context management

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbyQFddbGJQ6MpRwUH2Rtj
The ordered copy of a multi-stream window landed in llama/dev on its own.
It brings its own copy of the range geometry, which this branch already
had in a form that also carries the copy's stride and the stable prefix,
so drop the duplicate and keep the one the ring uses.

The ordered copy now steps by the copy's own stride. A copy is laid out
for the ring only while it is staged, so the two agree today, but reading
it from the copy is what keeps them agreeing.

The dummy backend's set_tensor_async records a delivery and now performs
it as well when the backend is asked for real memory, so the test that
checks the bytes of the ordered copy still sees them.

ggml_new_tensor_impl needs a brace for the array inside the union.

Assisted-by: Claude Opus 5
The ordered copy of a multi-stream window landed in llama/dev, so the
baseline the parallel table compares against moves. Only the ordered
column of a cache split into streams changes; the rest is within noise.

The pipeline is worth +8.5%, +13.8%, +19.8% and +26.0% at 1, 2, 4 and 8
slots over a non-unified cache, against +72% and +73% read off the old
baseline, and packing the ring is worth +25.7% at the default budget
rather than +49.6%. The single-sequence table does not move at all: a
unified window is one range and has no gaps to have been wasting.

Gate 5 is re-stated against a build of llama/dev rather than an earlier
head of this branch, which is the stronger comparison.

Assisted-by: Claude Opus 5
Also gives the stable prefix one value per stream, so a slot that was just
reset no longer caps the early region of every other stream.

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01JycLdWs6KRgizbdnNAfZqM
A copy of an input is named "<backend>#<source>#<copy>" in a name field of fixed
size. With many devices the backend label of the meta backend lists all of them
and the source name is what gets cut, so a consumer can no longer tell which
tensor the copy was made from. Cut the label instead.

Assisted-by: Claude Opus 5
A KV cache in host memory reaches attention as a scheduler copy, which is a leaf
in the compute buffer. Such a leaf never reached the device split-state callback
and fell through to MIRRORED, while the queries stayed split by head: each device
then attended heads whose keys live on the other device. With more than one KV
head that aborts in the FlashAttention kernel, or returns wrong output where the
query split happens to stay a multiple of the KV head count.

Offer a copied-in leaf to the callback under the name the graph gave it, and let
the callback recognise the cache there. The cache folds its heads into one flat
axis but the copy arrives permuted, with the heads on an axis of their own, so
its segments and granularity are rescaled to whole heads.

Assisted-by: Claude Opus 5
The KV granularity was derived from the query granularity through n_gqa, which
assumes the V side has the head size of the K side. Count whole KV heads and
scale each side by its own head size.

Assisted-by: Claude Opus 5
A fused QKV puts Kcur and Vcur in a strided view, which a host-resident cache
reads back through the meta buffer. The rows split, so the chunk splice could
not express the read.

Assisted-by: Claude Opus 5
A linear-attention op packs the state it writes back together with its output, so
that split does not line up with the one a host-resident state expects. On device
the two orders agree; in host memory they disagree, and the split state of the
fused op no longer resolves. The state is small next to the attention cache that
-nkvo exists to move, so keep it device-resident.

Assisted-by: Claude Opus 5
Run the tensor-split architecture matrix a second time with the cache in host
memory, and add an 8-device CI run, where the scheduler copy name is long enough
to be truncated.

Assisted-by: Claude Opus 5
The strided head split path ran only for a single stream, so a host-resident
cache built with --parallel N fell through to the chunk splice, which cannot
express that write. Loop over ne[3] and offset each side by its own stride.

Assisted-by: Claude Opus 5
A split that ends with a view of a host tensor left the subgraph bookkeeping short
of the node count and aborted. That happens with more than one cache stream, which
the test matrix now covers.

Also make the scheduler copy name a stated contract instead of a grammar that two
files reconstruct on their own, note the 2d transfer fallback at both strided cache
paths, say out loud that split mode tensor overrides the recurrent state placement,
and record the Gemma 4 host cache accuracy gap where it is skipped.

Assisted-by: Claude Opus 5
The scheduler flags a copy as an input when it keeps more than one, which
hid a host-resident cache from the split state callback. A cut source name
now asserts instead of naming another tensor, and memset writes a head
split like set_tensor does.

Assisted-by: Claude Opus 5
The ranged copy of a host-resident window calls set_tensor_async once per
stream. A meta backend only takes a whole contiguous tensor, so with two or
more streams it aborts. Keep the whole-span copy for a meta destination.

Assisted-by: Claude Opus 5

@Piggidragon Piggidragon left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged llama/dev (after #94) into this branch and ran the arch-coverage suite on CPU + RTX 4070 + RTX 3060 (CUDA, Meta tensor split). Merged instead of rebased because the branch already has merge commits carrying resolutions; the only conflict was the <filesystem> / <map> includes in test-llama-archs.cpp.

  • ctest workspace|generate-models|rollback|save-load|sched-copy: 6/6 pass
  • test-llama-archs -s 1: 4 FAIL, all in the #94 Parallel column. llama/dev alone has 0 FAIL.
| gpt2      | Meta -nkvo -np 2         | Dense | OK (4.68e-08) | SKIP | FAIL |
| gpt2      | Meta -nkvo -np 2 -kvpd 1 | Dense | OK (4.68e-08) | SKIP | FAIL |
| starcoder | Meta -nkvo -np 2         | Dense | OK (4.68e-08) | SKIP | FAIL |
| starcoder | Meta -nkvo -np 2 -kvpd 1 | Dense | OK (4.68e-08) | SKIP | FAIL |

Same cause as in #66: on n_seq_max > 1 rows logits_dev is a 2-sequence decode (second half at pos 0), but test_parallel_seqs() uses it as the reference for one sequence at pos 0..n-1. NMSE vs CPU is OK, so the pipelined transport output itself is fine; only learned-position archs expose the mismatch. Fixing it in #66 (skip Parallel when dc.kvc.n_seq_max > 1) fixes both rows here.

// the same copy, delivered ahead of the split that reads it
kv_config kvc_host_pipelined = kvc_host_streams;
kvc_host_pipelined.kv_pipeline_depth = 1;
dev_configs.emplace_back(devices_meta, "Meta -nkvo -np 2 -kvpd 1", LLAMA_SPLIT_MODE_TENSOR, kvc_host_pipelined);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parallel column FAILs on this row for gpt2 and starcoder, see the review body. Not a transport bug: test_parallel_seqs() gets 2-sequence logits as a single-sequence reference.

A multi-stream row decodes its reference as several sequences, so it cannot serve as a one-sequence reference - skip the check there.
Give the check the row's offload_kqv, so it covers a host-resident cache with two streams.

Assisted-by: Claude Opus 5
A meta event is one event per simple device, recorded and waited for on
each device's own stream. set_tensor_async and set_tensor_2d_async take
part of the window of a host cache split by head: whole cells from an
offset, once per stream, as one 2d copy per stream per device. A mirrored
tensor passes the offset through.

Add ggml_backend_meta_init_transfer, a meta backend that does not start a
communicator, for streams that only move data. Rotate the compute
containers of buffers that are only sources of a graph, so tensors bound
into a buffer the graph allocator does not own do not pile up.

Assisted-by: Claude Opus 5
A meta backend is eligible when each of its simple backends is. Its
transfer backend has no communicator, and the headroom check uses the
simple device with the least free memory, since a meta buffer allocates
the whole ring on every device. A device of the meta type that is not the
ggml meta backend stays ordered.

test-llama-archs runs the host-resident tensor split with two streams a
second time at pipeline depth 1.

Assisted-by: Claude Opus 5
The A/B and server gate scripts take LLAMA_KV_SM, as the parallel gate
already did.

Assisted-by: Claude Opus 5
@Piggidragon
Piggidragon force-pushed the kv/pipelined-transport-tensor-split branch from 7b8fb71 to 0f0f11e Compare September 17, 2026 21:19
@Piggidragon

Copy link
Copy Markdown
Author

Moved to Piggidragon#8, stacked on its parent branch there.

The diff is now +298/-45 instead of +4104/-272, because the parent's commits no longer show up in it. Same commits, same result.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devops documentation Improvements or additions to documentation examples ggml testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant