layer-split pipeline parallel: run a contiguous layer range per node, bit-exact - #180
layer-split pipeline parallel: run a contiguous layer range per node, bit-exact#180danielhanchen wants to merge 8 commits into
Conversation
…ices - RPC backend now advertises caps.async/caps.events and implements event_new/free/synchronize/record/wait plus a real synchronize(), built on the fact that RPC_CMD_GRAPH_COMPUTE is fire-and-forget and any response-bearing command on the ordered socket acts as a barrier. GGML_RPC_NO_PIPELINE=1 restores the previous behaviour exactly. - Optional GGML_RPC_GRAPH_HASH=1: reuse the server-side cached graph when a rebuilt graph serializes to identical bytes. - llama-context: disable graph reuse when pipeline parallelism is on (a reused graph pins sched->cur_copy and forces a full drain), overridable with LLAMA_PP_REUSE=1. - llama-context: LLAMA_PP_UBATCH_TG splits a token-generation batch into microbatches so consecutive ubatches can overlap across backends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdJkDZnvYjMpWD8K4EcBHB
…xact Stage A executes layers [0, il_end) and emits the raw residual stream entering il_end, before output_norm and the LM head. Stage B executes [il_beg, n_layer) and takes that residual in through llama_batch.embd. Absolute layer indices are preserved on both sides, so rope, is_recr()/full_attention_interval, SWA patterns and per-layer MoE hparams stay correct with no GGUF renumbering -- both stages load the same unmodified file. Selected with LLAMA_PP_IL_BEG / LLAMA_PP_IL_END. Also fixes a class of latent bug this exposed: llama.cpp's graph inputs are written unconditionally in set_input(), but ggml-alloc never allocates an input the built graph does not consume. Building only part of the layer stack leaves the KV index/mask inputs unconsumed on a recurrent-only range, s_copy unconsumed on an attention-only range, and inp_pos unconsumed when no layer uses rope -- each of which crashed on a null buffer. Every such write is now guarded on the tensor actually being live. Verified numerically equivalent (max|dlogit| = 0, run-to-run deterministic) against the unsplit model at 12 split points on Qwen3.5-4B, and on Qwen3.8-27B and a 24-layer qwen35, with stage A's emitted residual bit-identical to the model's own t_layer_inp[] extraction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…its own shard hparams::il_load_beg/il_load_end are read from LLAMA_PP_IL_BEG/END before any tensor is created, and load_arch_tensors passes TENSOR_SKIP for layers this instance does not own. TENSOR_SKIP still increments n_created, so done_getting_tensors() stays consistent and no 'partial' mode is needed. A non-final stage additionally skips output_norm and the LM head. Qwen3.8-27B-UD-Q4_K_XL, --no-mmap so the buffers reflect what is actually materialised: full model 16400 MiB stage A [0,32) 8908 MiB (54%) stage B [32,64) 10597 MiB (65%) The ~19% total overhead is the tied token_embd table, which both stages need: stage A to embed tokens, stage B because output.weight is tied to it. Splitting by byte count rather than layer count would balance the two halves. Correctness unchanged: still bit-exact at every split point on 4B and 27B. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tures Moved the skip into llama_model_loader::create_tensor, keyed on the block id carried by LLM_TN_IMPL, so it applies to every arch without editing each load_arch_tensors(). Also catches the MTP/nextn blocks, which the arch-specific version missed. A non-final stage additionally skips output_norm and output.weight, but NOT the tied TOKEN_EMBD+TENSOR_DUPLICATED alias: that is not a distinct tensor in the file, so it costs nothing, and skipping it made n_created exceed n_tensors. Qwen3.8-27B-UD-Q4_K_XL, --no-mmap: full 16400.50 MiB stage A [0,32) 7683.83 MiB (47%) stage B [32,64) 9398.71 MiB (57%) i.e. 4% total overhead, which is the tied embedding table both stages need. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ture) Same three edits as qwen35: bound the layer loop by [il_beg, il_end), create inp_out_ids only on the final stage, and return the raw residual before output_norm on a non-final one. The 235B is qwen3moe with block_count 94 and an untied output.weight, so a non-final stage genuinely drops an n_embd x n_vocab LM head rather than a free alias. NOT numerically verified: the only qwen3moe on disk is the 126 GB 235B, and a CPU equivalence run needs six full forward passes over 117 GiB of weights. Compiles and follows the pattern verified bit-exact on qwen35; treat as untested until it is run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tools/server has no route that accepts a float tensor -- the embeddings endpoints are output-only and the sole embd-input path is multimodal -- and wrapping n_embd floats per token in JSON would dominate the measurement this exists to make. So this is a small dedicated binary instead: request : 'PPS1' | op | seq_id | n_tokens | n_embd | pos[] | payload response: 'PPS1' | status | n_floats | payload Stage A (LLAMA_PP_IL_END=k) takes token ids, returns the residual stream entering layer k. Stage B (LLAMA_PP_IL_BEG=k) takes that residual, returns a greedily sampled token. With no env vars set the same binary serves the whole model, which is the control: same binary, same protocol, same driver, differing only in where the layers live. Stage B builds the M-RoPE-safe batch (n_pos_per_embd sections of n_tokens positions), which is the trap documented in LLAMACPP_PP_FINDINGS.md section 17.1. Not yet run against a GPU: the box has been held by another study throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Serving one request per llama_decode made the two-stage split 0.35x a properly batched single node at concurrency 8: each stage moved the whole weight set once per sequence where one node moved it once per batch. - one engine thread drains all queued requests into a single llama_decode; connection threads own their sockets so a stalled client cannot hold the GPU - lay the batch out in seq_id order, not arrival order. Row order changes CUDA reduction order, and three identical concurrent runs on a SINGLE UNSPLIT node produced three different greedy streams. Two stages racing independently would not agree; sorting makes a batch a function of the request set alone. - linger for stragglers, bounded by PP_BATCH_LINGER_US. A client is a round trip behind, so firing on first arrival shredded the batch (mean_group ~2 at 8 seqs). Bounded, so it cannot deadlock; a no-op at concurrency 1, so single-stream TPOT is untouched. - large requests are never coalesced, preserving the 1.70x prefill overlap - export decodes/reqs/tokens/mean_group so 'is it actually batching' is evidence llama-context: do not reject a vocab-only model. n_layer == 0 makes [0,0) the correct empty range, not an error; the guard broke llama-tokenize and every other vocab-only tool in this build, split or not. Verified bit-identical to the unsplit model at group 1, 4 and 8 with caching on, plus prefix-cache-hit and mixed-length batches.
Three fixes from a pre-merge regression audit. None of them are about layer
splitting; all three are about not changing behaviour for people who are not
using it.
1. RPC pipelining is now opt-in via GGML_RPC_PIPELINE, where it was opt-out via
GGML_RPC_NO_PIPELINE. Advertising async+events flips ggml_backend_sched into
pipeline parallelism with n_copies = 4 and disables graph reuse, and that is
not free. Measured on two loopback rpc-servers (zero network latency, so a
best case), Qwen3.5-4B over --device RPC0,RPC1 -sm layer -ngl 99:
compute buffers 320 -> ~1100 MiB per device
throughput ~56 -> ~35 tok/s
The events are barriers rather than real events, so the overlap that would
pay for the extra copies never materialises. As written this imposed that
cost on every existing multi-RPC-device user, none of whom are splitting
layers. Off by default it also restores ggml_backend_rpc_synchronize to the
no-op it was, which matters because the new one can GGML_ABORT on a
transient socket failure where the old one could not fail at all.
2. qwen35.cpp no longer passes TENSOR_NOT_REQUIRED for output_norm. It was both
redundant and harmful: llama_model_loader::create_tensor() already ORs in
TENSOR_NOT_REQUIRED | TENSOR_SKIP for a non-final stage, which is why qwen3
and qwen3moe still pass 0 and work. Relaxing it here additionally let a
qwen3.5 GGUF genuinely missing output_norm.weight load with a null tensor,
and build_norm() treats a null weight as "no weight" and returns an
unweighted RMS norm. That turned a clear load-time error into silently wrong
output for unsplit users.
3. llm_graph_input_out_ids::set_input and llm_graph_input_attn_no_cache::set_input
assert on a null tensor again. Replacing GGML_ASSERT with the liveness check
collapsed two distinct cases: a null tensor is a graph-construction bug and
must still abort loudly, while a present-but-unallocated tensor is the new
and legitimate case (a non-final stage registers an input it never uses, so
sched leaves it unbacked). Now only the second is skipped.
Verified after the changes: test-layer-split still reports
max |dlogit| = 0.000000 rmse = 0.000000 on Qwen3-27B split at layer 32, and
llama-tokenize still works on the vocab-only path.
|
Pushed three fixes from a pre-merge regression audit. None are about layer splitting; all three are about not changing behaviour for people who are not using it. 1. RPC pipelining was opt-out, and it should not have beenThis was the real problem with the PR. Measured on two loopback
About 3.5x the compute-buffer memory and 35 to 45% less throughput, by default. Worse on a real LAN, since disabling graph reuse means re-serializing and re-sending the whole graph every token. The reason it does not pay off is that the events are not really events. Now opt-in via 2.
|
|
Closing this. It is superseded by an already-merged upstream commit, and measurement says the upstream path is better on every axis I can measure. What changedThe premise of this PR was that llama.cpp could not split a model across two machines usefully, so core needed modifying. That premise was wrong, and the reason is #18626, This fork does not have it. Verified directly: fork The measurementCherry-picking End to end, for a real workload rather than a component ratio:
Component ratios, npp 2048: prefill 1.54x / 1.67x / 1.80x / 1.89x at depth 1 / 2 / 4 / 8, with decode at 0.94x to 0.98x throughout. This PR reached 1.68x prefill but 0.37x decode, which is why it loses badly end to end: the prefill win saves about 2 seconds and the decode loss gives back 28. Memory also favours upstream: +10.9 / +11.3 GiB per node against this PR's +12.4 / +13.3, because this PR duplicates Correctness, and the part that matters mostRun through The deeper point is structural. Under RPC there is one What was actually worth finding
NextThe useful deliverable is not this PR, it is cherry-picking ggml-org#18626 into the fork so fork users get the faster two-node path. That will go up separately. Nothing here is a criticism of layer splitting as an idea. The split in this PR is numerically exact and always was. It simply is not needed, because upstream already supports the same thing better through a backend that does not require touching core inference at all. |
Splits a model's decoder stack across two machines so a GGUF larger than one node's memory can be served, and runs each contiguous layer range in its own
llama_context. Built for two DGX Sparks cabled over ConnectX-7, but nothing here is Spark specific.This is marked draft because prefill works and batched decode does not. Details and numbers below, including the part that failed.
Approach
Each node loads only its own layer range.
llama-model-loaderskips out of range weights at load time, so a node never allocates memory for layers it will not run. That skip is generic across architectures rather than per model, with qwen3 and qwen3moe (the 235B architecture) wired through.Stage A runs layers
[0, k)and emits hidden states. Stage B accepts them throughbatch.embd, runs[k, n_layer)and produces logits.tools/pp-stageserves one stage over a minimal binary TCP protocol.Also included: the RPC backend gains async submit plus events, so
ggml_backend_schedcan pipeline across RPC devices instead of blocking on each transfer.Numerical equivalence
The split is bit exact, not approximately equal.
tests/test-layer-split.cppchecks the whole chain against the unsplit model on the same machine:Qwen3-27B Q4_K_XL, 64 layers, split at layer 32.
Cache correctness was checked separately on hardware, since a split that quietly breaks the KV or prefix cache would still look right on a single prompt. A batched group of 8 with caching on is token identical to the unsplit model, a prefix cache hit (300 of 332 tokens reused, and reported as reused so the cache is provably still in play) equals cold recompute, and a mixed batch of lengths 128/512/61/300 equals each sequence run alone.
Measured on two nodes, Qwen3-27B
Model buffers land at 7683.83 MiB on stage A against 8716.68 MiB on stage B, so the pair holds one copy of the model, not two.
Prefill scales with batch depth. Decode does not, and gets worse. Reporting the second number rather than shipping the first one alone.
Why decode is slow, and what would fix it
Not the weights. An earlier version reloaded weights per sequence; that is fixed, and
mean_groupis now identical on the split and the control at every depth, so each stage moves its weights once per batch.The remaining deficit is that the two stages never overlap. At depth 8, TPOT is 362 ms split against 132 ms single, and per stage utilisation sampled inside the split arm only is 72% on A and 44% on B. That sums to 116%, not 200%: the stages are taking turns and the gap is pipeline bubble.
The fix is two or more microbatches in flight (1F1B) in the driver. The stage engine already batches correctly and needs no change for it. I did not get there, which is why this is draft.
One caveat for anyone measuring this: the batching fix moved the control from 40.3 to 60.7 tok/s at depth 8, after which it agrees with
llama-batched-benchwithin 0.5%. Measured against the old denominator the split would have posted a fraudulent 1.5x. If you benchmark this, check your single node control is actually batching first.Requests within a stage are sorted by
seq_idrather than arrival order, because the unsplit single node otherwise produced three different greedy streams across three identical runs. Note that argmax on knife edge logits can legitimately differ between serial and batched execution in llama.cpp CUDA regardless of splitting, so use high margin prompts for strict comparisons.Also fixed
The split guard rejected vocab only models, which broke
llama-tokenizein this build whether or not anything was being split.