Repository navigation
Description
[Engine] Layer split over two CUDA sm_86 cards: stage 2 never gets prompt rows, stall at layer 1 of a small tail chunk
Model: Edge0/Edge0-145B-q3k (Q3_K, D/U Q4, PLE fp8 direct).
Engine: built from this repository, strata 0.1.55 (merge base upstream c62d5e3a), CUDA 13.3.
Machine
| GPU | 2 × CMP 90HX, 10 GB each, sm_86, PCIe gen2 x16 (card's own LnkCap is 5 GT/s) |
| RAM | 74 GB, ~67 GB free with no model loaded |
| CPU | 2 sockets Xeon, 36 physical cores |
| Everything below was measured on this box; nothing is extrapolated. |
Single-GPU on a 16 GB RTX A4000 runs this model (slowly), so the single-device path is fine. The failures below are all on --layer-split.
1. Without any patch: every request faults on the second card
--layer-split 24 --split-device 1 --vram-reserve-later-mib 2600 --expert-cache 0
--spec 2 --max-context 32768 --kv int8 --prefill 256
Engine loads, session is up, and the first request ends with
strata serve: ... verify: layer 24 never rang (an illegal memory access was encountered)
layer 24 is the first layer of the later stage. The later stage's own stream reports the illegal access, so it is the second card reading something it does not own, not a host-side wait.
Cause found
include/strata/core/latent_share.hpp keeps the session's latent D/U pair in one process-global pointer (static uint8_t* p), allocated on whichever device happened to be current. Every stage reads that pointer, so a stage on another device reads memory it does not own.
It is worse in practice: LatentTab::load is called only inside if (o.expert_cache < 0) (generate.cpp:6925), i.e. only with --expert-cache auto. A layer split needs a numeric --expert-cache (to leave room for the D/U buffers), so with a split the pair is never loaded and
Prefill::init and Verifier::init each allocate their own copy on their own device - 2 × 1.85 GiB (Q4) per card. That also shows up as the boot error seen on small cards:
strata serve: stage 2: verify: the device arena (2112 MiB) does not fit
2. With the patch below: prefill works, then a stall
Patch edge0-latent-2gpu.patch (4 files, +66/−11): the accessors take a device (latent_shared_d(int dev)), LatentTab gains load_device() and remembers alloc_dev, one block before any stage sizes its expert cache loads the pair for the primary and for every later stage, and the two consumers pass their own device (prefill.cpp m.device, verify.cpp device_).
Result: no more illegal access, the boot fits (arena drops to 222 MiB because the copy is shared), and --layer-split 30 with 180K context loads and serves. Then a request stalls:
prompt 823 tokens = 0 reused + 818 of 823 read in 1465 ms (558.5 tok/s), 0 generated ... (cancelled) no progress for 60 s during a request (reading the prompt (batched), done up to token 512)
stall report: verify window (last window, not the current stage): 0 tokens at position 0,
host at layer step 1; the GPU rang 0
Two facts from that report: stage 2's verifier shows 0 tokens at position 0 (it never received rows for that window), and the prompts are split into parts (547, 128, 128, …).
3. Instrumentation
Added STRATA_DBG_TAIL (marks around the cudaStreamSynchronize that stalls, prefill.cpp:3294 and :4600). For the failing request:
DBG chunk C=128 n=547 c0=512 T=35 p0=512
DBG wait l=0 T=35 p0=512
DBG ok l=0
DBG wait l=1 T=35 p0=512 <- never answered
(meanwhile stage 2: DBG chunk n=128 c0=0 T=128 p0=384, DBG ok l=30..33)
So it is the 35-token tail chunk of the first part, stalling at layer 1 of the first card. Layer 0 of the same chunk completes. Stage 2 is still working the previous chunk at that moment, which is the normal overlap (next_run_ = std::async(...), prefill.cpp:6874).
4. Ruled out, each by measurement on this box
| knob | result |
|---|---|
STRATA_HANDOFF_SYNC=1 (new: wait for the next stage each chunk instead of overlapping) |
identical stall |
| layer-major on / off | identical stall |
--short-read 0 vs 64 |
identical stall |
--kv k8v4 vs int8 |
identical stall (k8v4 does free ~1 GiB per card) |
--kv-resident 32768 |
required at 180K on 10 GB cards; removing it makes the boot refuse (no room) |
STRATA_VERIFY_ALL_RESIDENT=0 |
already the x86 default; explicit - identical stall |
STRATA_HC_SPLIT=0 |
identical stall |
| VRAM balanced (739/763 MiB free) vs skewed | identical stall |
| expert cache 13 / 141 / 600 / 1000 slots | identical stall |
--layer-split 24 and --layer-split 30 |
identical stall |
Not the trigger by size alone: layer 0 of the same 35-token chunk runs.
5. Suggested direction
Layer 1 is the first layer that routes experts using the previous layer's hidden state. With T=35 on a split, something in that step never returns - expert fetch, doorbell, or pool claim.
Two cheap things to try first, neither needs the kernels:
- Do not leave a small tail.
planned_chunk/equal_chunkalready exist with the comment "equal chunks, no tail" (generate.cpp:17831); they are only used on the lend path. If layer 1 only stalls at small T, making the last chunk of a part at least ~64 rows would sidestep it. - Serve splits where each card has room for a super-chunk: on 10 GB cards the layer-major path is only taken when ~500-700 MiB stays free at prompt time, and it is worth a lot (818 tokens in 266 ms, ~3 075 tok/s inside the super-chunk, vs ~180 tok/s overall when it is off).
Attachments in this report
- edge0-latent-2gpu.patch - the per-device D/U fix (needed for any split to work at all).
- The config used (
--layer-split 30 --split-device 1 --vram-reserve-later-mib 1700 --expert-cache 600 --resident-experts --kv k8v4 --kv-resident 32768 --max-context 180000 --prefill 128 --spec 2). STRATA_DBG_TAIL=1andSTRATA_HANDOFF_SYNC=1are switches added while diagnosing; both are small and
can be dropped or kept behind the env names.
Activity
[-][Engine] Layer split over two CUDA cards: stage 2 never gets prompt rows, stall at layer 1 of a small tail chunk[/-][+][Engine] Layer split over two CUDA sm_86 cards: stage 2 never gets prompt rows, stall at layer 1 of a small tail chunk[/+]JayOfTheKeyboard commentedon Nov 15, 2026
Confirming the root cause in this issue on a second sm_86 box, and with it three changes that make a 2-card split serve Edge0-120B Q3_K end to end. Patch against
main@7b60076is at the end (4 files, +100/−19). Happy to split it into PRs if you prefer.Machine
2 × RTX 3070 8 GB (sm_86), PCIe 3.0 x8 each, 64 GB RAM, driver 620.70.06, CUDA 13.0, Linux. Model
Edge0-120BQ3_K.Control
Unpatched
7b60076,--layer-split 24 --expert-cache 0: the first request (141 tokens) fails with the same error as above:Changes
Per-device latent D/U pair (
latent_share.hpp,prefill.cpp,verify.cpp,generate.cpp). Essentially @Kotali-2019's patch from this issue, rebased ontomain:latent_shared_d(dev)/latent_shared_u(dev),LatentTab::load_device(), and every pair loaded before any stage sizes its expert cache.Added: the three hand-off slots
latent_xl_current(),latent_xlseq_current()andlatent_act0_current()are nowthread_local. Each stage's verifier runs on its own thread, and with process-wide slots one stage's pool call can read the other stage's pointers.latent_act0is device memory on the other GPU.Each card holds only its own layers' D/U under an explicit
--layer-split(LatentTab::load/load_device). The base pointer is offset by the stage's first layer, so readers keep indexing with the global layer number. This applies only whenmulti_gpuis set, so--split-device 0still loads the full pair. It saves 0.66 GiB per card on Edge0-120B, which an 8 GB card needs.Layer-major prefill refused for latent models (
Prefill::lm_prepare). Layer-major is on by default with 2+ stages, butrun_layer_major/lm_runhave no latent D/U step. Once a card had enough free VRAM for a super-chunk, the request produced non-finite logits (All-logits-NaN degeneration (one repeated token forever) still fires on 0.1.39b / current main — first poison traced to the MoE output rows: garbage bits, varying layer Niko1221/Strata#879) and returned HTTP 503. Reproduced. With this check the request falls back to the chunk-major path.Result
Working config on 2 × 8 GB:
lat_dense_d_tail_testpasses on both cards.Not tested
--mtpon a split. The draft layer did not fit, so that path is untouched.--layer-split autoand 3+ stages. The trimming in change 2 is skipped forauto.thread_localchange is what removes the tail stall in section 2. I never ran this patch without it on a split. @Kotali-2019, it would be worth trying on your CMP 90HX box.Related:
cuBLAS status 14on a nearly full split stageSeparate from this issue, but found on the same setup. Stage sizing can leave a card with a few MiB free. Then the first prompt's BF16
cublasGemmEx(end ofGemm::bf16) fails withcuBLAS status 14after the engine has reported READY.serve/server.pythen restarts the engine into the same failure every 15 s.The cause is that cuBLAS loads its kernels on first use.
warm_gemm_libraries()would take that memory at boot, but it is compiled for HIP only. Running it on CUDA as well, at the end ofPrefill::init, moves the load into boot. There it comes out of the stage's reserve and shows in the "VRAM free with everything loaded" check. It costs about 35 MiB and 130 ms per card.Tested by filling CUDA0 to 2 MiB free after boot. The unfixed build fails with status 14. With the warm-up the GEMM passes, and the request then stops at
verify: instantiate: out of memory, a clean 400, which I haven't touched. Decode and prefill on the working config were unchanged (within 4%). The patch also prints a hint inck()when cuBLAS returns 1, 3 or 14 with under 256 MiB free. I can open it as a separate PR if it's wanted.Patch: layer-split fixes 1-3 (vs 7b60076)
Patch: cuBLAS warm-up on CUDA (separate)