Skip to content

feat(ds41): two-box expert-parallel serving - #826

Merged
davide221 merged 42 commits into
mainfrom
feat/ds41-cluster
Oct 12, 2026
Merged

davide221 merged 42 commits into
mainfrom
feat/ds41-cluster

Conversation

@davide221

@davide221 davide221 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Serves DeepSeek V4.1 Flash (ROCMFP2S) expert-parallel across two boxes, each an R9700 plus a Ryzen AI Max (Strix Halo) iGPU, linked by a direct ConnectX-5 cable.

  • Runtime. A port of maikzz32's lucebox-halo-cluster runtime onto the in-graph collective from feat(ggml): in-graph collective node via a caller-registered callback #700 and the cold-owner-None hybrid path from feat(moe): cold-owner None, and expert-major prefill for a hot-only stack #701. Rank 0 serves HTTP and makes every sampling and acceptance decision; the worker follows over a TCP control channel. Rank 0 also picks each request's decode mode, and every rank decodes in it: speculative, or AR for sampled and thinking-budget requests.
  • Expert placement. Each rank owns half of the routed experts and keeps the single-box hybrid tiers for its half: hot experts on its R9700, the rest on its iGPU.
  • Work split. Attention heads, the shared expert, the greedy verify head and the DSpark draft head are halved across the ranks. Each rank loads only its slices of the dense weights.
  • Decode exchange. Every MoE layer is reduced across the ranks by fast_reduce, a raw RDMA exchange between the two discrete GPUs. It waits on stream flags, so there is no host round trip, and sends a zero-copy bf16 wire.
  • Prefill. Prefill-sized reductions stay on RCCL, as 8-bit blocks with a per-block scale. Long bands pipeline their exchanges, and both expert owners run the grouped kernels (RDNA4 matrix cores on the R9700).
  • Kernels. Most of the decode speed comes from cutting kernel launches in the verify step. The fused kernels are bit-exact against the ops they replace (test_ds4_fused_ops_cuda); the one exception is the drafter head's skinny F16 matvec, which accumulates in F32 and runs on RDNA4 only.

Commits, by area:

Area Commits
Decode kernels 3d44f159 fused DS4.1 decode kernels, 228b5bf2 RDNA4 Q8_0 gate/up pair, 40c4808e grouped ROCmFP2 prefetch loop, 4f2c6933 their CUDA builds, 3a89de1c HC boundary barrier under graph replay, ab496e10 single-token gate/up pair, e5f6405b masked routes, resident split boundary, exact sink column
MoE 35251926 fused routed combine and q8_1 inputs for the hybrid owners
Cluster runtime d8f5c547 two-box runtime and raw-RDMA exchange, 53d673dd worker exit and flags, bcbfc8ca bounded handshake and stall clock, b3871251 8-bit prefill exchange, 6a28b6a4 rank 0 decodes in the mode it broadcast, 3645375f shared expert sharded by default, 190676b8 one wire format and lean rule, e28cf0f6 failed requests answer before exit
DS4.1 on two boxes 9f350f02 expert-parallel DS4.1 and the cluster defaults, 4e6cdcb4 start-up agreements, 8108b661 dead helpers, 5306ca57 pipelined prefill bands, 11a92e02 grouped kernels on both owners, 5e944874 8-bit prefill exchange by default, 60364d60 Engram rows once per band, 9c537619 async Engram keys only with the broadcast
Prefill kernels ffc9649c ROCmFP2 expert prefill on RDNA4 matrix cores, c8d12671 one-pass HC mix
Speculative decode af2e85f4 (superseded by #827 in a merge), fcfe8d59 width costs per context range, cb630567 two experiments removed
Tests a49bc3ef whole-row-lane slice
Cleanup after review 46e9fbe keepalive removed, 71c78c3 MMVQ fusion follows mul_mat's kernel choice, 617c75e skinny matvec on RDNA4 only, baac939 2144c38 3e6bf26 DS4.1 kill switches, timing and comments, 0972e72 de9b185 effc0c8 dead cluster code and the hash probe, 1996c81 one fast-reduce path

The branch merges main up to #835. Main's shared thinking budget (#816) replaced the DSpark budget hook this branch carried; the merge commit says how the cluster paths use it.

Review findings on a49bc3ef

The 17 bot findings, against this head:

  • Fixed: the RDMA error release (bcbfc8ca), the two unparsed flags (53d673dd), the HC boundary barrier under graph replay (3a89de1c, both reports), the bf16 wire rule and the lean slot guard (190676b8), the error response on a failed request and the unused sampling fallback (e28cf0f6), the async Engram keys (9c537619), the masked combine's -1 routes, the split boundary's residency, the CUDA sink-column rounding and the CPU masked sink column (e5f6405b).
  • Hardened, not reachable: the wo_b view (both reports). A sliced rank's head range starts at 0, so the offset was 0; the view is now taken only from unsliced storage.
  • Documented: the hybrid exchange counters, like the fast-reduce sequence numbers, assume both ranks run every exchange; a rank-local step failure ends both ranks through the stall watchdog.
  • Not a defect: the whole-row-lanes flag. op_params is int32_t[] in this ggml, so op_params[1] is the second int32.

Second review: cleanup

A structure and leftovers review of e5f6405b found no wrong results but too much weight. The branch is now 1,115 lines smaller (+17,378 / -728 against its merge base):

  • Dead code removed: the --cluster-verify-hash probe (never called outside tests), unused head-backend accessors and state, common/cluster_view.h, two cluster allreduce helpers with no callers, the worker keepalive (opt-in, never on) with ggml_backend_cuda_keepalive, and the verify input timing.
  • One path where the defaults only ever took one: fast_reduce keeps its zero-copy, bf16-wire, lean exchange and drops the copy-engine, kernel-flag, done-flag and two-export variants (and 7 switches); DS4.1 drops 9 kill switches whose on path was the default everywhere, and the kernels drop 5. Cluster mode now installs 26 environment defaults instead of 32.
  • Correctness: the cont-into-MMVQ fusion now declines whenever ggml_cuda_mul_mat would not pick MMVQ, so fused and unfused take the same kernel; the skinny F16 matvec runs on RDNA4 only, so CUDA keeps upstream's kernel.
  • Comments: benchmark numbers, plan and agent labels, an internal model name and repeated attributions are gone.

Not done here: deduplicating the split HC boundary kernel and the five hand-written q8_1 quantizers, and splitting the largest graph functions.

Results

Two boxes, defaults only: the command under "How to run", no tuning variables, five server starts. One box: a Zero 495 with 192 GB, ds41-gorgon, this branch's build and main's, same harness. One code prompt and one 2K-token prose prompt, 256 tokens, greedy, three requests each after a warm-up request.

Decode One 192 GB box, this branch (main) Two 128 GB boxes
Code prompt, third request 54.0 tok/s (50.3) 100.6-102.5 tok/s (90.4-93.4 on the first request)
2K prose, third request 37.2 tok/s (33.5) 55.7-57.6 tok/s (55.2-59.0 over all requests)

The shared expert's mode, back to back on the same build:

Replicated (old default) Sharded (new default)
Code prompt 89.7-91.9 tok/s 93.2-97.4 tok/s
2K prose 52.5 tok/s 56.3-56.5 tok/s
Code step 44.2 ms 41.7 ms
  • Width controller. With a fixed q5 verify the code prompt reaches 102.7-103.2 tok/s and prose drops to 52; the adaptive controller gives up at most a code tok/s for 4-7 prose tok/s. It gets there by the third code request (93.4, 97.5, 102.5): the first request also builds the verify graphs, and its slower steps are in the costs the next one starts from.
  • Text. The code answer is the same text as the 10-08 runs (8270cf3174ec). Sharding changes the rounding of the shared expert's sum, so greedy text can differ from the replicated mode and from a single box at near-ties.
  • Prefill. A 2K prompt reads at 640-660 tok/s with rank 1 holding 18.8 GB of hot experts, and at about 880 with 16.35 GB: the smaller budget leaves room for prefill chunks of 2,176 rows instead of 1,024. The log says which rank holds the chunks back.

How to run

Build with -DLUCE_CLUSTER=ON, which needs RCCL and libibverbs. Run the same command on each rank, changing only --cluster-rank:

LUCE_EXPERT_BUDGET_MB=<per-rank budget> \
luce_server DeepSeek-V4.1-Flash-ROCMFP2S.gguf --profile ds41-gorgon \
  --draft dspark.gguf \
  --ds4-expert-placement <placement_rankR.json> --ds4-router-bias <bias.bin> \
  --cluster-rank R --cluster-size 2 --cluster-head <rank0-ip>:9400 \
  --cluster-ifname <netdev> --cluster-ib-hca <hca> --cluster-gid-index 3 \
  --cluster-expert-placement <cluster_placement.json>

The placement and router-bias files are operator inputs, not part of this PR. scripts/cluster/build_expert_placement.py builds a uniform or balanced cluster placement from a routing hotness CSV.

Cluster-mode defaults

With --cluster-size above 1, the server installs the measured configuration as environment defaults (cluster_launch_env in launch_profiles.h). They go in before a launch profile's defaults, so they win over the profile but never over a variable that is already set. Set a variable to 0 to turn its feature off; the log names the variables the operator set.

Area Defaults
Decode exchange LUCE_CLUSTER_FAST_REDUCE, _STALL_S=15, LUCE_CLUSTER_HYBRID_EXCHANGE
Prefill reductions LUCE_CLUSTER_PREFILL_Q8, LUCE_CLUSTER_PREFILL_BF16 (the wire when Q8 is off)
Work split LUCE_CLUSTER_ATTENTION_PARALLEL, LUCE_CLUSTER_VOCAB_SPLIT, LUCE_CLUSTER_DRAFT_VOCAB_SPLIT, LUCE_CLUSTER_DRAFT_SPLIT, LUCE_CLUSTER_WORKER_SKIP_DRAFT
DS4.1 verify step LUCE_DS4_GPU_ARGMAX_VERIFY, LUCE_DS4_LANE_BATCH, LUCE_DS4_DRAFT_CONTEXT_KV_CACHE, LUCE_DS4_DRAFT_SWAP, LUCE_DS4_SCHED_DEDICATED_COPIES, LUCE_DS4_HC_BOUNDARY_SPLIT, LUCE_DS4_ENGRAM_PRECOMPUTE=2560, LUCE_DS4_TP_CAPTURE_CACHE_SLOTS=64, LUCE_DS4_TP_FUSED_CACHE_SLOTS=64
Expert products LUCE_MOE_Q8_HANDOFF, LUCE_MMID_GROUPED, LUCE_DS4_GROUPED_HYBRID_PREFILL=2, LUCE_MMID_GROUPED_TYPES=79, LUCE_CUDA_MMVQ_MOE_FP2_PREFETCH, GGML_CUDA_PEER_COPY_KERNEL
System HSA_FORCE_FINE_GRAIN_PCIE
  • Shared expert. --cluster-shared-expert now defaults to shard; replicate remains for A/B runs.
  • Drafter swap. LUCE_DS4_DRAFT_SWAP stays opt-in on a single box, so that prefix-cache hits match a full prefill. In cluster mode it is on, because cluster ranks run without the prefix cache.
  • RCCL. The rank's RCCL environment also defaults NCCL_PROTO=Simple, next to the existing IB hardening defaults.
  • Diagnostics, off unless set: LUCE_CLUSTER_TRACE.

Known limits

  • Link. The R9700 sits on a PCIe Gen3 x4 slot and one box's ConnectX-5 trains at Gen3 x2. Every exchange pays that link.
  • Sampled and budgeted requests decode AR on two boxes (34-40 tok/s): rank 0 would have to send the budget's forced tokens and its sampled acceptances inside the speculative exchange.
  • Width costs across contexts. A 2K-token prompt and a short one share the first cost range (under 2,048 tokens), so code right after 2K prompts starts from their slower steps and climbs back over three requests (88.1, 94.1, 99.1 tok/s).
  • Prefill. Attention runs on both ranks, and rank 1's expert budget caps the prefill chunk (see Results).
  • Ranks act together. A cluster rank never retries a failed prefill or drafter swap-in on its own; the request fails on every rank instead.
  • A lost peer ends both ranks. When one rank stops, the other releases its GPU and exits after LUCE_CLUSTER_FAST_REDUCE_STALL_S (15 s in cluster mode); restart both.
  • Topology. Two ranks only. fast_reduce is a two-rank exchange; more ranks fall back to RCCL.

Validation

On the rank-0 box, built from this branch with -DLUCE_CLUSTER=ON (gfx1151 + gfx1201):

  • CPU tests: test_cluster_unit 26/26, test_thinking_budget, test_server_unit 657 passed, 2 skipped, .github/ci/check_commit_messages.py (30 commits).

  • GPU tests, on both GPUs: test_ds4_fused_ops_cuda (0 failures, also with LUCE_DS4_HC_BOUNDARY_SPLIT=1), test_deepseek4_unit. With the split boundary on, the R9700 takes the split kernel for every launch; the Strix Halo iGPU (40 CUs) falls back to the unsplit kernel for the grids it cannot hold at once.

  • Two boxes, defaults only: the Results tables, on two builds (before and after the review fixes; the same text, decode within 2%), one with the bench run twice on one server. A sampled request, a thinking-budget request (closed at its 48-token budget) and a greedy request after them all complete on both ranks; before 6a28b6a4, rank 0 would have decoded the first two speculatively while the worker decoded AR.

  • After the cleanup (1996c81): every unit and GPU test above passes again on both GPUs. Two boxes: the code and 2K answers are the same text as before (8270cf3174ec, 020f5e2ba61b); code 93.5, 97.7, 102.9 tok/s on the first three requests and 103.4-103.8 after, 2K prose 55.3-58.4; sampled, budgeted and greedy requests complete. One box: the same text as before the cleanup on both prompts, at the same speed.

Before merge

  • Single-box ds41-gorgon against main (55a2dd0), on a Zero 495 with 192 GB: same CMake config, model, drafter, placement and bias, one warm-up request, 256 tokens, greedy.
    • Decode path unchanged. With --ds4-prefill exact and no drafter, both prompts give main's text (code 6e212c26fc09, 2K prompt 6eb664db61c2).
    • Code prompt (36 tokens): main's text with the drafter too, adaptive or fixed width.
    • 2K-token prompt: the text differs from main at a near-tie (line 17 with the drafter), in every mode that uses the profile's dense prefill, and matches it with exact prefill. Dense prefill is approximate by design, and two commits here change its rounding on every box: the R9700's ROCmFP2 expert prefill on F16 WMMA (ffc9649c, within F16 rounding of MMQ) and the HC pre-mix of bands of 64+ tokens in one F32 pass (c8d12671).
    • Faster on one box, third request: code 50.3 -> 54.0 tok/s, 2K prose 33.5 -> 37.2, 2K prefill 731 -> 784 tok/s. With the width fixed, a code step takes 78.3 ms instead of 84.0.
  • CI with LUCE_CLUSTER=ON. test_cluster_unit runs in the CPU job and test_ggml_cluster_allreduce in the AMD GPU job. A LUCE_CLUSTER=ON build there also needs the libibverbs development package on the AMD runner; until then the RCCL and RDMA paths are covered by two-box runs.

🤖 Generated with Claude Code

@davide221

Copy link
Copy Markdown
Contributor Author

CI failed on b34c688c

Every default build fails to link: the new cluster code calls FastReduce, but its source is only compiled with -DLUCE_CLUSTER=ON

The pull request adds calls to the new FastReduce RDMA exchange from code that is always compiled, but the file defining FastReduce is only added to the build when the HIP-only LUCE_CLUSTER option is on. So the normal Linux CUDA, Windows, ROCm and GB10 builds all fail at the final link with undefined FastReduce symbols. The change under test is at fault; nothing is wrong with the runners or tests.

How to fix: Compile a small FastReduce stub into luce_common whenever LUCE_CLUSTER is OFF, so non-cluster builds link and the fabric simply reports itself unavailable.

  1. Add server/src/cluster/fast_reduce_stub.cpp defining every luce::cluster::FastReduce method as a no-op/false (content in the patch).
  2. In server/CMakeLists.txt, after the HIP backend block, add if(NOT LUCE_CLUSTER) target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp) endif().
  3. Rebuild a default config to verify: cmake -S server -B build && cmake --build build -j (Linux CUDA), and let CI re-run the Windows/ROCm/GB10 legs.
  4. Confirm a cluster build still picks the real implementation: cmake -S server -B build-hip -DLUCE_GPU_BACKEND=hip -DLUCE_CLUSTER=ON compiles fast_reduce.cpp instead of the stub.

link error: undefined FastReduce symbols in non-cluster buil · high confidence · run

Details and evidence

Failed: Build (cmake + uv sync --extra megakernel) › Build luce (smoke + server); Build Windows (MSVC + CUDA, library + server targets) › Build Windows targets; GPU tests (Radeon AI PRO R9700, gfx1201 / ROCm) › Build + test ROCm formats and inference core; GPU tests (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121)

All four failed jobs die at link time with the same set of undefined symbols: luce::cluster::FastReduce::{FastReduce,~FastReduce,init,ok,submit,submit_from_root,hyb_available,hyb_export,hyb_combine,hyb_import,gdr_available,gdr_cold_push,gdr_finish}, e.g. undefined reference to \luce::cluster::FastReduce::hyb_combine(...)'(Linux job, linkingtest_dflash) and error LNK2019: unresolved external symbol ... referenced in function ... set_cluster(Windows, linkingluce_server.exe). The references come from server/src/deepseek4/deepseek4_cluster.cpp(theds4_hyb_*_cbcallbacks andDs4ClusterRuntime::~Ds4ClusterRuntime, which destroys the std::unique_ptrcluster::FastReduce fastmember declared at server/src/deepseek4/deepseek4_cluster.h:60),server/src/deepseek4/deepseek4_backend.cpp:1215 (std::make_uniquecluster::FastReduce()inset_cluster), and server/src/deepseek4/deepseek4_dspark_spec.cpp:1314. All of these are compiled unconditionally into luce_common. The definitions live in server/src/cluster/fast_reduce.cpp+fast_reduce_kernels.cu, which server/CMakeLists.txtonly adds insideif(LUCE_CLUSTER)under the HIP branch (server/CMakeLists.txt:801-814), because they need libibverbs and HIP.LUCE_CLUSTERdefaults to OFF and is rejected outright on CUDA (server/CMakeLists.txt:208-210), so no CI configuration compiles the definitions while every configuration compiles the callers. The many-Wmaybe-uninitialized/#221-D` warnings in the logs are pre-existing noise, not the failure.

Other things to try

  • Alternative to the stub: guard every FastReduce call site with #ifdef LUCE_CLUSTER (deepseek4_cluster.cpp, deepseek4_backend.cpp, deepseek4_dspark_spec.cpp) and remove the fast member from non-cluster builds. That is more invasive and easy to regress; the stub keeps one interface.
  • Reproduce locally with the default configure: cmake -S server -B build && cmake --build build --target test_dflash fails at link before the fix and succeeds after.
  • Longer term, consider adding a CI leg that configures with -DLUCE_CLUSTER=ON on the ROCm runner so the real fast_reduce.cpp is compile-checked too.
Patch (2 files)

Not applied anywhere. To try it, save the diff as fix.patch and run git apply fix.patch.

diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt
index b305b5d5..fce953a5 100644
--- a/server/CMakeLists.txt
+++ b/server/CMakeLists.txt
@@ -815,6 +815,13 @@ elseif(LUCE_GPU_BACKEND STREQUAL "hip")
     endif()
 endif()
 
+# fast_reduce.cpp is compiled only under LUCE_CLUSTER (HIP + libibverbs), but
+# the DeepSeek4 cluster glue references FastReduce in every build. Link a stub
+# that reports the fabric as unavailable so non-cluster builds still link.
+if(NOT LUCE_CLUSTER)
+    target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp)
+endif()
+
 if(LUCE_GPU_BACKEND STREQUAL "cuda")
     target_link_libraries(luce_common PUBLIC CUDA::cudart)
 elseif(LUCE_GPU_BACKEND STREQUAL "hip")
diff --git a/server/src/cluster/fast_reduce_stub.cpp b/server/src/cluster/fast_reduce_stub.cpp
new file mode 100644
index 00000000..e723f5d1
--- /dev/null
+++ b/server/src/cluster/fast_reduce_stub.cpp
@@ -0,0 +1,45 @@
+// fast_reduce_stub.cpp - non-cluster builds (LUCE_CLUSTER=OFF) do not compile
+// fast_reduce.cpp: it needs HIP and libibverbs, and LUCE_CLUSTER is HIP-only.
+// deepseek4_cluster.cpp, deepseek4_backend.cpp and deepseek4_dspark_spec.cpp
+// reference FastReduce unconditionally, so these definitions keep those builds
+// linking. Every method reports the fabric as unavailable, which is exactly
+// the state the callers already handle when init() fails: rt.fast stays
+// unusable and the runtime keeps the IClusterComm (RCCL) path.
+
+#include "cluster/fast_reduce.h"
+
+namespace luce::cluster {
+
+// Complete the impl type for this translation unit so the unique_ptr member
+// can be destroyed; it is never allocated here.
+struct FastReduceImpl {};
+
+FastReduce::FastReduce() = default;
+FastReduce::~FastReduce() = default;
+
+bool FastReduce::init(const Config &, std::string * err) {
+    if (err) *err = "fast reduce not compiled in (build with -DLUCE_CLUSTER=ON on HIP)";
+    return false;
+}
+
+void FastReduce::shutdown() {}
+
+bool FastReduce::ok() const { return false; }
+
+bool FastReduce::submit(float *, size_t, void *, bool) { return false; }
+
+bool FastReduce::submit_from_root(float *, size_t, void *, int, bool) { return false; }
+
+bool FastReduce::hyb_available() const { return false; }
+bool FastReduce::hyb_export(const float *, size_t, void *, int, uint64_t) { return false; }
+bool FastReduce::hyb_combine(const float *, size_t, void *, uint64_t) { return false; }
+bool FastReduce::hyb_import(float *, size_t, void *, uint64_t) { return false; }
+
+bool FastReduce::gdr_available() const { return false; }
+bool FastReduce::gdr_cold_push(const float *, size_t, void *, uint64_t) { return false; }
+bool FastReduce::gdr_finish(float *, size_t, void *, uint64_t) { return false; }
+
+uint64_t FastReduce::submitted() const { return 0; }
+uint64_t FastReduce::timed_out() const { return 0; }
+
+}  // namespace luce::cluster
Prompt for a coding agent
A GitHub Actions run failed in Luce-Org/lucebox. Please fix it.

- Pull request #826 (branch `feat/ds41-cluster`), commit b34c688cc44846f479e4090f7d0cb36a36c5901f
- Workflow: CI (https://github.com/Luce-Org/lucebox/actions/runs/37769740677)
- Failed: Build (cmake + uv sync --extra megakernel) › Build luce (smoke + server); Build Windows (MSVC + CUDA, library + server targets) › Build Windows targets; GPU tests (Radeon AI PRO R9700, gfx1201 / ROCm) › Build + test ROCm formats and inference core; GPU tests (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121)

Diagnosis:
All four failed jobs die at link time with the same set of undefined symbols: `luce::cluster::FastReduce::{FastReduce,~FastReduce,init,ok,submit,submit_from_root,hyb_available,hyb_export,hyb_combine,hyb_import,gdr_available,gdr_cold_push,gdr_finish}`, e.g. `undefined reference to \`luce::cluster::FastReduce::hyb_combine(...)'` (Linux job, linking `test_dflash`) and `error LNK2019: unresolved external symbol ... referenced in function ... set_cluster` (Windows, linking `luce_server.exe`). The references come from `server/src/deepseek4/deepseek4_cluster.cpp` (the `ds4_hyb_*_cb` callbacks and `Ds4ClusterRuntime::~Ds4ClusterRuntime`, which destroys the `std::unique_ptr<cluster::FastReduce> fast` member declared at server/src/deepseek4/deepseek4_cluster.h:60), `server/src/deepseek4/deepseek4_backend.cpp:1215` (`std::make_unique<cluster::FastReduce>()` in `set_cluster`), and `server/src/deepseek4/deepseek4_dspark_spec.cpp:1314`. All of these are compiled unconditionally into `luce_common`. The definitions live in `server/src/cluster/fast_reduce.cpp` + `fast_reduce_kernels.cu`, which `server/CMakeLists.txt` only adds inside `if(LUCE_CLUSTER)` under the HIP branch (server/CMakeLists.txt:801-814), because they need libibverbs and HIP. `LUCE_CLUSTER` defaults to OFF and is rejected outright on CUDA (server/CMakeLists.txt:208-210), so no CI configuration compiles the definitions while every configuration compiles the callers. The many `-Wmaybe-uninitialized`/`#221-D` warnings in the logs are pre-existing noise, not the failure.

Proposed fix: Compile a small FastReduce stub into luce_common whenever LUCE_CLUSTER is OFF, so non-cluster builds link and the fabric simply reports itself unavailable.
1. Add `server/src/cluster/fast_reduce_stub.cpp` defining every `luce::cluster::FastReduce` method as a no-op/false (content in the patch).
2. In `server/CMakeLists.txt`, after the HIP backend block, add `if(NOT LUCE_CLUSTER) target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp) endif()`.
3. Rebuild a default config to verify: `cmake -S server -B build && cmake --build build -j` (Linux CUDA), and let CI re-run the Windows/ROCm/GB10 legs.
4. Confirm a cluster build still picks the real implementation: `cmake -S server -B build-hip -DLUCE_GPU_BACKEND=hip -DLUCE_CLUSTER=ON` compiles `fast_reduce.cpp` instead of the stub.

Suggested patch (a starting point):
```diff
diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt
index b305b5d5..fce953a5 100644
--- a/server/CMakeLists.txt
+++ b/server/CMakeLists.txt
@@ -815,6 +815,13 @@ elseif(LUCE_GPU_BACKEND STREQUAL "hip")
     endif()
 endif()
 
+# fast_reduce.cpp is compiled only under LUCE_CLUSTER (HIP + libibverbs), but
+# the DeepSeek4 cluster glue references FastReduce in every build. Link a stub
+# that reports the fabric as unavailable so non-cluster builds still link.
+if(NOT LUCE_CLUSTER)
+    target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp)
+endif()
+
 if(LUCE_GPU_BACKEND STREQUAL "cuda")
     target_link_libraries(luce_common PUBLIC CUDA::cudart)
 elseif(LUCE_GPU_BACKEND STREQUAL "hip")
diff --git a/server/src/cluster/fast_reduce_stub.cpp b/server/src/cluster/fast_reduce_stub.cpp
new file mode 100644
index 00000000..e723f5d1
--- /dev/null
+++ b/server/src/cluster/fast_reduce_stub.cpp
@@ -0,0 +1,45 @@
+// fast_reduce_stub.cpp - non-cluster builds (LUCE_CLUSTER=OFF) do not compile
+// fast_reduce.cpp: it needs HIP and libibverbs, and LUCE_CLUSTER is HIP-only.
+// deepseek4_cluster.cpp, deepseek4_backend.cpp and deepseek4_dspark_spec.cpp
+// reference FastReduce unconditionally, so these definitions keep those builds
+// linking. Every method reports the fabric as unavailable, which is exactly
+// the state the callers already handle when init() fails: rt.fast stays
+// unusable and the runtime keeps the IClusterComm (RCCL) path.
+
+#include "cluster/fast_reduce.h"
+
+namespace luce::cluster {
+
+// Complete the impl type for this translation unit so the unique_ptr member
+// can be destroyed; it is never allocated here.
+struct FastReduceImpl {};
+
+FastReduce::FastReduce() = default;
+FastReduce::~FastReduce() = default;
+
+bool FastReduce::init(const Config &, std::string * err) {
+    if (err) *err = "fast reduce not compiled in (build with -DLUCE_CLUSTER=ON on HIP)";
+    return false;
+}
+
+void FastReduce::shutdown() {}
+
+bool FastReduce::ok() const { return false; }
+
+bool FastReduce::submit(float *, size_t, void *, bool) { return false; }
+
+bool FastReduce::submit_from_root(float *, size_t, void *, int, bool) { return false; }
+
+bool FastReduce::hyb_available() const { return false; }
+bool FastReduce::hyb_export(const float *, size_t, void *, int, uint64_t) { return false; }
+bool FastReduce::hyb_combine(const float *, size_t, void *, uint64_t) { return false; }
+bool FastReduce::hyb_import(float *, size_t, void *, uint64_t) { return false; }
+
+bool FastReduce::gdr_available() const { return false; }
+bool FastReduce::gdr_cold_push(const float *, size_t, void *, uint64_t) { return false; }
+bool FastReduce::gdr_finish(float *, size_t, void *, uint64_t) { return false; }
+
+uint64_t FastReduce::submitted() const { return 0; }
+uint64_t FastReduce::timed_out() const { return 0; }
+
+}  // namespace luce::cluster
```

Check out that commit, confirm the diagnosis, make the fix (adjust it if the diagnosis is wrong), and re-run the failing step locally before pushing.

Reply with /ci-fix and what to change to get a revised diagnosis.

@davide221

Copy link
Copy Markdown
Contributor Author

CI failed on 80e5ec25

Build fails at link: FastReduce symbols are only compiled in the HIP cluster build

The new cluster code calls the FastReduce RDMA helper from files that are compiled in every build, but the helper's implementation is only added to the build when the HIP-only LUCE_CLUSTER option is switched on. So every ordinary build (CUDA, Windows, and the ROCm jobs, none of which enable that option) compiles the callers but not the callee, and linking the test and server binaries fails with undefined FastReduce symbols. The change under test is at fault; it is a build wiring bug, not a code logic bug.

How to fix: Add a no-op FastReduce stub and compile it whenever LUCE_CLUSTER is off.

  1. Create server/src/cluster/fast_reduce_stub.cpp defining every luce::cluster::FastReduce method as a no-op (init returns false with an error message, ok/hyb_available return false, counters return 0).
  2. In server/CMakeLists.txt, after the HIP backend block, add if(NOT LUCE_CLUSTER) target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp) endif().
  3. Re-run the failing builds: cmake --build build --target test_dflash test_deepseek4_unit luce_server on a CUDA and a ROCm config.
  4. Push and re-run CI.

link error: missing fast_reduce object · high confidence · run

Details and evidence

Failed: Build (cmake + uv sync --extra megakernel) › Build luce (smoke + server); Build Windows (MSVC + CUDA, library + server targets) › Build Windows targets; GPU tests (Radeon AI PRO R9700, gfx1201 / ROCm) › Build + test ROCm formats and inference core; GPU tests (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121); GPU tests (Strix Halo Radeon 8060S, gfx1151 / ROCm) › Build + test ROCm formats and inference core

All four failed jobs die at the final link of executables against luce_common, with the same set of undefined symbols: luce::cluster::FastReduce::ok/submit/submit_from_root/hyb_export/hyb_combine/hyb_import/hyb_available/init plus the constructor and destructor. Linux job: /usr/bin/ld: libluce_common.a(deepseek4_cluster.cpp.o): in function 'luce::common::ds4_hyb_combine_cb...': undefined reference to 'luce::cluster::FastReduce::hyb_combine(...)' (linking test_dflash). ROCm jobs: ld.lld: error: undefined symbol: luce::cluster::FastReduce::ok() const ... referenced by deepseek4_dspark_spec.cpp (linking test_deepseek4_unit). Windows: luce_common.lib(deepseek4_cluster.cpp.obj) : error LNK2019: unresolved external symbol ... FastReduce ... (linking backend_ipc_daemon.exe and luce_server.exe).

Root cause: the callers are always compiled. deepseek4_cluster.cpp is in the unconditional luce_common source list (server/CMakeLists.txt, added next to deepseek4_dspark_spec.cpp) and its graph callbacks call FastReduce methods; DeepSeek4Backend::set_cluster does std::make_unique<cluster::FastReduce>() (server/src/deepseek4/deepseek4_backend.cpp:1215); deepseek4_dspark_spec.cpp calls FastReduce::ok(). But the implementation, src/cluster/fast_reduce.cpp + fast_reduce_kernels.cu, is only added inside if(LUCE_CLUSTER) within the HIP-only branch (server/CMakeLists.txt:801-813), and LUCE_CLUSTER defaults to OFF and is rejected on CUDA (server/CMakeLists.txt:208-210). No CI job configures with -DLUCE_CLUSTER=ON, so every link that pulls in the cluster runtime objects fails. The runtime design already tolerates a missing fabric (callers check ok()/hyb_available() and fall back to the RCCL communicator), so a no-op stub is sufficient for non-cluster builds.

Other things to try

  • Alternative: guard every FastReduce call site in deepseek4_cluster.cpp, deepseek4_backend.cpp and deepseek4_dspark_spec.cpp with #ifdef LUCE_CLUSTER and leave rt.fast null otherwise — more invasive and easy to regress, since new call sites would each need the guard.
  • Verify locally before pushing: configure a plain CUDA build (cmake -S server -B build -DLUCE_GPU_BACKEND=cuda) and build test_deepseek4_unit and luce_server; both should link.
  • Consider adding a CI configuration that builds with -DLUCE_CLUSTER=ON on a ROCm runner, since today that option is never compiled in CI and this regression slipped through.
Patch (2 files)

Not applied anywhere. To try it, save the diff as fix.patch and run git apply fix.patch.

diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt
index b305b5d5..09accc5c 100644
--- a/server/CMakeLists.txt
+++ b/server/CMakeLists.txt
@@ -815,6 +815,12 @@ elseif(LUCE_GPU_BACKEND STREQUAL "hip")
     endif()
 endif()
 
+# deepseek4_cluster.cpp and deepseek4_backend.cpp call FastReduce whether or
+# not the cluster fabric is built; without LUCE_CLUSTER link the no-op stub.
+if(NOT LUCE_CLUSTER)
+    target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp)
+endif()
+
 if(LUCE_GPU_BACKEND STREQUAL "cuda")
     target_link_libraries(luce_common PUBLIC CUDA::cudart)
 elseif(LUCE_GPU_BACKEND STREQUAL "hip")
diff --git a/server/src/cluster/fast_reduce_stub.cpp b/server/src/cluster/fast_reduce_stub.cpp
new file mode 100644
index 00000000..004e9c6d
--- /dev/null
+++ b/server/src/cluster/fast_reduce_stub.cpp
@@ -0,0 +1,41 @@
+// fast_reduce_stub.cpp - no-op FastReduce for builds without LUCE_CLUSTER.
+//
+// deepseek4_cluster.cpp, deepseek4_dspark_spec.cpp and deepseek4_backend.cpp
+// reference FastReduce unconditionally (the runtime decides at run time
+// whether the fabric is there), so the symbols must exist in every build.
+// The real implementation is HIP + ibverbs only (src/cluster/fast_reduce.cpp,
+// added when LUCE_CLUSTER=ON); here every call reports the fabric as
+// unavailable, which the callers already handle by falling back to the RCCL
+// communicator or by leaving the hybrid exchange off.
+
+#include "cluster/fast_reduce.h"
+
+namespace luce::cluster {
+
+struct FastReduceImpl {};
+
+FastReduce::FastReduce() = default;
+FastReduce::~FastReduce() = default;
+
+bool FastReduce::init(const Config &, std::string * err) {
+    if (err) *err = "fast reduce not compiled in (build with -DLUCE_CLUSTER=ON on HIP)";
+    return false;
+}
+
+void FastReduce::shutdown() {}
+
+bool FastReduce::ok() const { return false; }
+
+bool FastReduce::submit(float *, size_t, void *, bool) { return false; }
+
+bool FastReduce::submit_from_root(float *, size_t, void *, int, bool) { return false; }
+
+bool FastReduce::hyb_available() const { return false; }
+bool FastReduce::hyb_export(const float *, size_t, void *, int, uint64_t) { return false; }
+bool FastReduce::hyb_combine(const float *, size_t, void *, uint64_t) { return false; }
+bool FastReduce::hyb_import(float *, size_t, void *, uint64_t) { return false; }
+
+uint64_t FastReduce::submitted() const { return 0; }
+uint64_t FastReduce::timed_out() const { return 0; }
+
+}  // namespace luce::cluster
Prompt for a coding agent
A GitHub Actions run failed in Luce-Org/lucebox. Please fix it.

- Pull request #826 (branch `feat/ds41-cluster`), commit 80e5ec25739ee328d473181cb2c52655bc0dbeb6
- Workflow: CI (https://github.com/Luce-Org/lucebox/actions/runs/37774864078)
- Failed: Build (cmake + uv sync --extra megakernel) › Build luce (smoke + server); Build Windows (MSVC + CUDA, library + server targets) › Build Windows targets; GPU tests (Radeon AI PRO R9700, gfx1201 / ROCm) › Build + test ROCm formats and inference core; GPU tests (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121); GPU tests (Strix Halo Radeon 8060S, gfx1151 / ROCm) › Build + test ROCm formats and inference core

Diagnosis:
All four failed jobs die at the final link of executables against `luce_common`, with the same set of undefined symbols: `luce::cluster::FastReduce::ok/submit/submit_from_root/hyb_export/hyb_combine/hyb_import/hyb_available/init` plus the constructor and destructor. Linux job: `/usr/bin/ld: libluce_common.a(deepseek4_cluster.cpp.o): in function 'luce::common::ds4_hyb_combine_cb...': undefined reference to 'luce::cluster::FastReduce::hyb_combine(...)'` (linking `test_dflash`). ROCm jobs: `ld.lld: error: undefined symbol: luce::cluster::FastReduce::ok() const ... referenced by deepseek4_dspark_spec.cpp` (linking `test_deepseek4_unit`). Windows: `luce_common.lib(deepseek4_cluster.cpp.obj) : error LNK2019: unresolved external symbol ... FastReduce ...` (linking `backend_ipc_daemon.exe` and `luce_server.exe`).

Root cause: the callers are always compiled. `deepseek4_cluster.cpp` is in the unconditional `luce_common` source list (server/CMakeLists.txt, added next to `deepseek4_dspark_spec.cpp`) and its graph callbacks call `FastReduce` methods; `DeepSeek4Backend::set_cluster` does `std::make_unique<cluster::FastReduce>()` (server/src/deepseek4/deepseek4_backend.cpp:1215); `deepseek4_dspark_spec.cpp` calls `FastReduce::ok()`. But the implementation, `src/cluster/fast_reduce.cpp` + `fast_reduce_kernels.cu`, is only added inside `if(LUCE_CLUSTER)` within the HIP-only branch (server/CMakeLists.txt:801-813), and `LUCE_CLUSTER` defaults to OFF and is rejected on CUDA (server/CMakeLists.txt:208-210). No CI job configures with `-DLUCE_CLUSTER=ON`, so every link that pulls in the cluster runtime objects fails. The runtime design already tolerates a missing fabric (callers check `ok()`/`hyb_available()` and fall back to the RCCL communicator), so a no-op stub is sufficient for non-cluster builds.

Proposed fix: Add a no-op FastReduce stub and compile it whenever LUCE_CLUSTER is off.
1. Create `server/src/cluster/fast_reduce_stub.cpp` defining every `luce::cluster::FastReduce` method as a no-op (`init` returns false with an error message, `ok`/`hyb_available` return false, counters return 0).
2. In `server/CMakeLists.txt`, after the HIP backend block, add `if(NOT LUCE_CLUSTER) target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp) endif()`.
3. Re-run the failing builds: `cmake --build build --target test_dflash test_deepseek4_unit luce_server` on a CUDA and a ROCm config.
4. Push and re-run CI.

Suggested patch (a starting point):
```diff
diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt
index b305b5d5..09accc5c 100644
--- a/server/CMakeLists.txt
+++ b/server/CMakeLists.txt
@@ -815,6 +815,12 @@ elseif(LUCE_GPU_BACKEND STREQUAL "hip")
     endif()
 endif()
 
+# deepseek4_cluster.cpp and deepseek4_backend.cpp call FastReduce whether or
+# not the cluster fabric is built; without LUCE_CLUSTER link the no-op stub.
+if(NOT LUCE_CLUSTER)
+    target_sources(luce_common PRIVATE src/cluster/fast_reduce_stub.cpp)
+endif()
+
 if(LUCE_GPU_BACKEND STREQUAL "cuda")
     target_link_libraries(luce_common PUBLIC CUDA::cudart)
 elseif(LUCE_GPU_BACKEND STREQUAL "hip")
diff --git a/server/src/cluster/fast_reduce_stub.cpp b/server/src/cluster/fast_reduce_stub.cpp
new file mode 100644
index 00000000..004e9c6d
--- /dev/null
+++ b/server/src/cluster/fast_reduce_stub.cpp
@@ -0,0 +1,41 @@
+// fast_reduce_stub.cpp - no-op FastReduce for builds without LUCE_CLUSTER.
+//
+// deepseek4_cluster.cpp, deepseek4_dspark_spec.cpp and deepseek4_backend.cpp
+// reference FastReduce unconditionally (the runtime decides at run time
+// whether the fabric is there), so the symbols must exist in every build.
+// The real implementation is HIP + ibverbs only (src/cluster/fast_reduce.cpp,
+// added when LUCE_CLUSTER=ON); here every call reports the fabric as
+// unavailable, which the callers already handle by falling back to the RCCL
+// communicator or by leaving the hybrid exchange off.
+
+#include "cluster/fast_reduce.h"
+
+namespace luce::cluster {
+
+struct FastReduceImpl {};
+
+FastReduce::FastReduce() = default;
+FastReduce::~FastReduce() = default;
+
+bool FastReduce::init(const Config &, std::string * err) {
+    if (err) *err = "fast reduce not compiled in (build with -DLUCE_CLUSTER=ON on HIP)";
+    return false;
+}
+
+void FastReduce::shutdown() {}
+
+bool FastReduce::ok() const { return false; }
+
+bool FastReduce::submit(float *, size_t, void *, bool) { return false; }
+
+bool FastReduce::submit_from_root(float *, size_t, void *, int, bool) { return false; }
+
+bool FastReduce::hyb_available() const { return false; }
+bool FastReduce::hyb_export(const float *, size_t, void *, int, uint64_t) { return false; }
+bool FastReduce::hyb_combine(const float *, size_t, void *, uint64_t) { return false; }
+bool FastReduce::hyb_import(float *, size_t, void *, uint64_t) { return false; }
+
+uint64_t FastReduce::submitted() const { return 0; }
+uint64_t FastReduce::timed_out() const { return 0; }
+
+}  // namespace luce::cluster
```

Check out that commit, confirm the diagnosis, make the fix (adjust it if the diagnosis is wrong), and re-run the failing step locally before pushing.

Reply with /ci-fix and what to change to get a revised diagnosis.

@davide221

Copy link
Copy Markdown
Contributor Author

CI failed on d5ba225b

New fused MoE-combine and norm+rope kernels fail their bit-exact test on the NVIDIA runner

The PR adds fused GPU kernels that must give bit-identical results to the separate ops they replace, and its own test checks exactly that. On the NVIDIA verifier two of the new fusions are off by one rounding step in a few values: the guard that prevents fused multiply-add only works with the AMD/Clang compiler, not NVIDIA's. The change under test is at fault; the same test passes on ROCm, where the kernels were developed.

How to fix: Use non-contracting FP intrinsics on CUDA in the masked-combine kernel, and enable the norm+rope fusion only on HIP.

  1. In server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu, make moe_combine_masked_kernel use __fmul_rn/__fadd_rn (true non-contracting intrinsics under nvcc) when GGML_USE_HIP is not defined.
  2. In server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu, make ggml_cuda_rms_norm_rope_f16_supported return false on non-HIP builds so CUDA runs the unfused, bit-exact chain.
  3. Rebuild the CUDA configuration and run test_ds4_fused_ops_cuda on an NVIDIA GPU, or push and let the GB10 runner re-verify.

fused-kernel bit-exactness failure on cuda · high confidence · run

Details and evidence

Failed: GPU tests (self-hosted DGX GB10, sm_121 verifier) › Run focused NVIDIA verifier tests

Job GPU tests (self-hosted DGX GB10, sm_121 verifier), step Run focused NVIDIA verifier tests, ends with ds4 fused ops: 8 failures and exit 1. The 8 failures are 4 masked combine mismatch (e.g. n_embd=5120 n_used=6 tokens=1 at 1: ref 0.5788095 fused 0.578809559) and 4 norm rope mismatch (e.g. ncols=512 tokens=5 at 449: ref 0.965860248 fused 0.965860307) — all 1-ulp differences, from the bit-exactness comparisons in server/test/unit/test_ds4_fused_ops_cuda.cpp:354 (run_masked_combine) and :480 (run_norm_rope), both added by this PR.

Masked combine. moe_combine_masked_kernel (server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu:379-420) accumulates sum = sum + v * w under #pragma clang fp contract(off). Its own comment (lines 395-399) explains plain operators are used because HIP's __fmul_rn/__fadd_rn are header functions that still contract when inlined. Under nvcc the Clang pragma is a no-op and --fmad=true is the default, so the multiply-add contracts into an FMA and the product is never rounded before the add — while the reference chain (mul, mul, repeat_back) rounds each step. That is exactly the observed 1-ulp drift.

Norm rope. rms_norm_rope_f16_kernel (server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu:169-223, enabled by ggml_cuda_try_rms_norm_rope_f16 at ggml-cuda.cu:5536) claims bit-identity with the unfused rms_norm → mul → rope → cast chain. All four mismatches are at indices ≥ rot_offset (448 for ncols=512, 960 for ncols=1024), i.e. only rotated outputs differ, so the fused kernel's rope arithmetic rounds differently from the unfused rope_norm under nvcc. I am unsure of the exact instruction-level divergence (FMA contraction vs. the norm's reduction order); the kernel was clearly verified on HIP only. The safe fix is to offer this fusion only on HIP, where it is bit-exact and where the decode performance it exists for lives.

Other things to try

  • Reproduce locally on any NVIDIA GPU: build with -DLUCE_GPU_BACKEND=cuda -DLUCE_TESTS=ON and run test_ds4_fused_ops_cuda.
  • Follow-up to re-enable the norm+rope fusion on CUDA: make the fused kernel's rope pair math and norm reduction use explicit __fmul_rn/__fadd_rn/__fsub_rn matching the unfused kernels' rounding under nvcc, then drop the HIP gate.
  • Gating the norm+rope fusion to HIP costs nothing on NVIDIA: it is a decode-launch optimization for the ROCm/RDNA4 path, and CUDA keeps the correct unfused chain.
Patch (2 files)

Not applied anywhere. To try it, save the diff as fix.patch and run git apply fix.patch.

diff --git a/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu b/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu
index e0510466..0c3a5a6e 100644
--- a/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu
+++ b/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu
@@ -413,8 +413,19 @@ static __global__ void moe_combine_masked_kernel(
         }
         const float v = *(const float *)(experts + (size_t)h * sizeof(float) +
                                          (size_t)e * experts_nb1 + (size_t)t * experts_nb2);
+#if defined(GGML_USE_HIP)
+        // HIP's __fmul_rn/__fadd_rn are header functions compiled with
+        // contraction on; plain operators under the contract(off) pragma
+        // above round each step instead.
         const float prod = v * w;
         sum = sum + prod;
+#else
+        // nvcc ignores the clang contract pragma, but its __fmul_rn/__fadd_rn
+        // are true intrinsics that never fuse into an FMA, matching the
+        // rounding of the replaced mul, mul and repeat_back.
+        const float prod = __fmul_rn(v, w);
+        sum = __fadd_rn(sum, prod);
+#endif
     }
     *(float *)(output + (size_t)h * sizeof(float) + (size_t)t * output_nb1) = sum;
 }
diff --git a/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu b/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu
index 3c95f220..9c9c70e7 100644
--- a/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu
+++ b/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu
@@ -316,7 +316,15 @@ void ggml_cuda_rope_q8(ggml_backend_cuda_context & ctx, const ggml_tensor * rope
 }
 
 bool ggml_cuda_rms_norm_rope_f16_supported(int ncols) {
+#if defined(GGML_USE_HIP)
     return ncols % 2 == 0 && (size_t) ncols * sizeof(float) <= 32u * 1024u;
+#else
+    // Bit-exact against the unfused rms_norm/mul/rope/cast chain on HIP only:
+    // under nvcc the fused kernel's rounding differs (the norm rope case of
+    // test_ds4_fused_ops_cuda), so CUDA keeps the unfused chain.
+    (void) ncols;
+    return false;
+#endif
 }
 
 void ggml_cuda_rms_norm_rope_f16(ggml_backend_cuda_context & ctx, const ggml_tensor * norm,
Prompt for a coding agent
A GitHub Actions run failed in Luce-Org/lucebox. Please fix it.

- Pull request #826 (branch `feat/ds41-cluster`), commit d5ba225bece9effc92172ccf1b115281d2de4e9a
- Workflow: CI (https://github.com/Luce-Org/lucebox/actions/runs/37779174214)
- Failed: GPU tests (self-hosted DGX GB10, sm_121 verifier) › Run focused NVIDIA verifier tests

Diagnosis:
Job **GPU tests (self-hosted DGX GB10, sm_121 verifier)**, step **Run focused NVIDIA verifier tests**, ends with `ds4 fused ops: 8 failures` and exit 1. The 8 failures are 4 `masked combine mismatch` (e.g. `n_embd=5120 n_used=6 tokens=1 at 1: ref 0.5788095 fused 0.578809559`) and 4 `norm rope mismatch` (e.g. `ncols=512 tokens=5 at 449: ref 0.965860248 fused 0.965860307`) — all 1-ulp differences, from the bit-exactness comparisons in `server/test/unit/test_ds4_fused_ops_cuda.cpp:354` (`run_masked_combine`) and `:480` (`run_norm_rope`), both added by this PR.

**Masked combine.** `moe_combine_masked_kernel` (`server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu:379-420`) accumulates `sum = sum + v * w` under `#pragma clang fp contract(off)`. Its own comment (lines 395-399) explains plain operators are used because HIP's `__fmul_rn`/`__fadd_rn` are header functions that still contract when inlined. Under nvcc the Clang pragma is a no-op and `--fmad=true` is the default, so the multiply-add contracts into an FMA and the product is never rounded before the add — while the reference chain (`mul`, `mul`, `repeat_back`) rounds each step. That is exactly the observed 1-ulp drift.

**Norm rope.** `rms_norm_rope_f16_kernel` (`server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu:169-223`, enabled by `ggml_cuda_try_rms_norm_rope_f16` at `ggml-cuda.cu:5536`) claims bit-identity with the unfused rms_norm → mul → rope → cast chain. All four mismatches are at indices ≥ rot_offset (448 for ncols=512, 960 for ncols=1024), i.e. only rotated outputs differ, so the fused kernel's rope arithmetic rounds differently from the unfused `rope_norm` under nvcc. I am unsure of the exact instruction-level divergence (FMA contraction vs. the norm's reduction order); the kernel was clearly verified on HIP only. The safe fix is to offer this fusion only on HIP, where it is bit-exact and where the decode performance it exists for lives.

Proposed fix: Use non-contracting FP intrinsics on CUDA in the masked-combine kernel, and enable the norm+rope fusion only on HIP.
1. In `server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu`, make `moe_combine_masked_kernel` use `__fmul_rn`/`__fadd_rn` (true non-contracting intrinsics under nvcc) when `GGML_USE_HIP` is not defined.
2. In `server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu`, make `ggml_cuda_rms_norm_rope_f16_supported` return false on non-HIP builds so CUDA runs the unfused, bit-exact chain.
3. Rebuild the CUDA configuration and run `test_ds4_fused_ops_cuda` on an NVIDIA GPU, or push and let the GB10 runner re-verify.

Suggested patch (a starting point):
```diff
diff --git a/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu b/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu
index e0510466..0c3a5a6e 100644
--- a/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu
+++ b/server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu
@@ -413,8 +413,19 @@ static __global__ void moe_combine_masked_kernel(
         }
         const float v = *(const float *)(experts + (size_t)h * sizeof(float) +
                                          (size_t)e * experts_nb1 + (size_t)t * experts_nb2);
+#if defined(GGML_USE_HIP)
+        // HIP's __fmul_rn/__fadd_rn are header functions compiled with
+        // contraction on; plain operators under the contract(off) pragma
+        // above round each step instead.
         const float prod = v * w;
         sum = sum + prod;
+#else
+        // nvcc ignores the clang contract pragma, but its __fmul_rn/__fadd_rn
+        // are true intrinsics that never fuse into an FMA, matching the
+        // rounding of the replaced mul, mul and repeat_back.
+        const float prod = __fmul_rn(v, w);
+        sum = __fadd_rn(sum, prod);
+#endif
     }
     *(float *)(output + (size_t)h * sizeof(float) + (size_t)t * output_nb1) = sum;
 }
diff --git a/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu b/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu
index 3c95f220..9c9c70e7 100644
--- a/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu
+++ b/server/deps/llama.cpp/ggml/src/ggml-cuda/rope.cu
@@ -316,7 +316,15 @@ void ggml_cuda_rope_q8(ggml_backend_cuda_context & ctx, const ggml_tensor * rope
 }
 
 bool ggml_cuda_rms_norm_rope_f16_supported(int ncols) {
+#if defined(GGML_USE_HIP)
     return ncols % 2 == 0 && (size_t) ncols * sizeof(float) <= 32u * 1024u;
+#else
+    // Bit-exact against the unfused rms_norm/mul/rope/cast chain on HIP only:
+    // under nvcc the fused kernel's rounding differs (the norm rope case of
+    // test_ds4_fused_ops_cuda), so CUDA keeps the unfused chain.
+    (void) ncols;
+    return false;
+#endif
 }
 
 void ggml_cuda_rms_norm_rope_f16(ggml_backend_cuda_context & ctx, const ggml_tensor * norm,
```

Check out that commit, confirm the diagnosis, make the fix (adjust it if the diagnosis is wrong), and re-run the failing step locally before pushing.

Reply with /ci-fix and what to change to get a revised diagnosis.

mrciffa and others added 5 commits October 8, 2026 16:20
The verify step of DS4.1 runs many short kernels per layer; these cut the
launch count without changing a bit of the output:

- one launch for an HC sub-block boundary (norm, mix, Sinkhorn split,
  post, collapse and the next norm), and for the router's select and
  weights over the same logits;
- RMS norm, rope and the F16 copy in one launch (HIP only: nvcc's fast
  math contracts rope_norm's rotation in a way another kernel cannot be
  guaranteed to repeat), and a rope that also writes the q8_1 input of
  the grouped output projection;
- the masked sink softmax in one launch, and one split-KV flash launch
  for all single-token verify lanes of a layer;
- a skinny-K single-column F16 matvec, and wide Q4_K products over many
  rows routed to MMQ on RDNA4;
- one expert grouping per MoE layer for gate, up and down, and a q8_1
  activation memo shared across reshapes;
- column-range Q8_0 products that keep the whole row's lane mapping, so a
  slice of a weight matrix gives the same sums as the full product;
- a split's batched peer copies as one copy kernel, an in-place cluster
  all-reduce node, and an argmax pair/pick that finds the greedy token of
  a vocabulary split across ranks exactly.

Each fused kernel repeats the replaced kernels' arithmetic in the same
order; test_ds4_fused_ops_cuda checks them bit for bit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… owners

- The owner route mask, the route weighting and the route sum run in one
  launch, and the clamped routed SwiGLU in one launch; the masked combine
  rounds its products and its sums separately, as the unfused ops do.
- The shared-expert slice is queued ahead of the owner join.
- Opt-in (LUCE_MOE_Q8_HANDOFF=1): the secondary expert owner receives its
  FFN input as the q8_1 rows its MMVQ reads, quantized once on the primary
  device. That moves about 3.6x fewer bytes over PCIe and the secondary
  skips its own quantize; the products are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AdaptiveSpecWidth seeded every width with one device's cost curve and
replaced an entry only when it ran that width, so a narrow width measured
on faster hardware could price every wider one out for good. Unmeasured
widths are now priced from the seed shape scaled by the nearest measured
width, never below a measured narrower one; graph-build outliers are
clipped out of the samples, and a width's first live sample is held to
1.25x its unmeasured price once another width has been measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A port of maikzz32's lucebox-halo-cluster runtime onto the in-graph
collective of #700 and the cold-owner-None hybrid path of #701:

- rank bootstrap over TCP (build, model and placement identity checked),
  a control channel on which rank 0 decides and the workers follow, an
  RCCL communicator for prefill-sized reductions, a worker main, and the
  expert placement JSON with its uniform and balanced builders
  (scripts/cluster/build_expert_placement.py);
- fast_reduce, the decode all-reduce for discrete GPUs: raw RDMA verbs
  between two ranks with stream write/wait flags instead of a host round
  trip, a zero-copy bf16 wire, and exchange kernels that publish their own
  completion flags. Its switches treat 0 as off, so cluster mode can turn
  them on by default;
- the RCCL environment of a rank, now with NCCL_PROTO=Simple, the only
  protocol that runs reliably on the R9700;
- server flags --cluster-rank, --cluster-size, --cluster-head,
  --cluster-ifname, --cluster-ib-hca, --cluster-gid-index,
  --cluster-expert-placement, --cluster-shared-expert,
  --cluster-allreduce-dtype, --cluster-timeout-ms, --cluster-verify-hash.

Built with -DLUCE_CLUSTER=ON (RCCL and libibverbs). Without it the RCCL
communicator and fast_reduce compile as unavailable stubs, so the default
build links and a rank falls back to RCCL. test_cluster_unit covers the
protocol, placement and control channel in both builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each rank keeps the single-box hybrid tiers (hot experts on the discrete
GPU, the rest on the integrated GPU) for its share of the routed experts,
and the two ranks reduce every MoE layer over the cluster exchange:

- hybrid FFN exchange through the integrated GPU (it sums the cold and
  imported rows and sends one export), a lean exchange, and one export
  per layer;
- attention heads split across the ranks, and a rank that loads only its
  attention slice;
- greedy verify head and DSpark draft head with their vocabulary split
  across the ranks (exact argmax over both halves), and the DSpark
  drafter split across both ranks;
- workers that skip the drafter they never use, a drafter swap for long
  prompts with chunks agreed across ranks, and a pipelined prefill only
  when every rank pipelines;
- prefill that keeps the device hot and cold join and reduces in the HC
  post graph, with a bf16 wire for prefill-sized reductions;
- the drafter's context window and the verify's window rows kept on the
  device, batched verify uploads, one wait per step for small transfers,
  and the Engram projection run inside the FFN waits.

Cluster mode (--cluster-size > 1) installs the measured configuration as
environment defaults (cluster_launch_env in launch_profiles.h) before a
launch profile's, so a two-box run needs only the operator's inputs:
expert budget, placement and router bias. Each default is a switch that
0 turns off, and an explicitly set variable always wins.

A cluster rank never retries a failed prefill or a failed drafter
swap-in on its own: the ranks run every chunk together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrciffa and others added 3 commits October 8, 2026 16:22
…U-DS4

A shared expert's gate and up products (Q8_0, several columns in a verify)
run as two batch-invariant RDNA4 launches and SwiGLU-DS4 as a third.
mul_mat_vec_q8_0_rdna4 gets a GLU variant that reads both matrices with one
load of each activation fragment, keeps each product's partials and
reduction exactly as the single kernel does, and writes
ggml_cuda_op_swiglu_ds4_single(gate, up) in its epilogue: the value the two
products and the GLU kernel give, in one launch.

The graph matcher also finds the pair with weight views between the
products (the cluster shared expert's row slices; views launch nothing).
ggml_cuda_mmvq_rdna4_glu_pair gates it: RDNA4, batch-invariant products,
2..8 columns, a Q8_0 pair on the same activations, a non-swapped SwiGLU-DS4
that reads exactly their outputs, no biases. LUCE_CUDA_MMVQ_Q8_RDNA4_GLU=0
keeps three launches. The pair has its own launcher on one block shape (two
rows per wave, four waves) whose partials of both products fit the LDS at
eight columns: seven new kernel instances, the dispatch table unchanged.

test_ds4_fused_ops_cuda: the pair against the products kept apart, and a
whole-row-lane column slice against the zero-padded product, bit for bit
at 1..8 columns. 0 failures on gfx1201 and gfx1151; a kernel trace on
gfx1201 shows the fused launch at 2, 4, 5 and 8 columns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…loop

The grouped MoE MMVQ (LUCE_MMID_GROUPED) ran ROCmFP2 experts on the generic
loop, where every iteration waits for a row's scale byte before the next
loads leave: on the Strix Halo iGPU the cold experts read at ~100 GB/s. The
per-expert kernel already has a prefetching loop for this
(mul_mat_vec_q_moe_fp2_prefetch: the loads of several K iterations first,
then the dot products in the same kbx order into the same accumulators, so
the result is bit-identical). The grouped kernel now uses it too, and both
kernels read one switch: on by default on gfx1151,
LUCE_CUDA_MMVQ_MOE_FP2_PREFETCH=0/1 overrides (cluster mode sets 1).

Two boxes, DS4.1, with the gate/up launch (previous commit), 2 x 3 runs
against both off, same text on both prompts: step 42.1-42.4 -> 41.5-41.8 ms
(code), 42.1-43.9 -> 41.4-41.7 ms (2K); code 100.7-101.2 -> 102.1-102.7
tok/s, 2K 55.8-58.8 -> 59.3-60.0. test_deepseek4_mmid_grouped_cuda passes
on gfx1201 and gfx1151 with the switch at its default and forced on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The two commits above built on HIP only. ggml_cuda_mmvq_rdna4_glu_pair sat
in the HIP-only RDNA4 block while ggml-cuda.cu calls it on every build: it
is now defined everywhere and answers false outside HIP. nvcc rejects the
grouped kernel's unbraced else before the K loop ("expected a }"): the loop
now sits in braces, the way the per-expert kernel writes it. No change on
HIP.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@davide221

davide221 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

CI failed on 4f2c6933

DS4 fused-ops column-slice test fails on NVIDIA: that bit-identity only holds on the HIP RDNA4 kernel

A new test checks that multiplying a column slice of a weight matrix gives bit-for-bit the same result as the full matrix times zero-padded input. On the NVIDIA runner it differs by one rounding unit, because that exactness is only implemented in the AMD-only RDNA4 matvec kernel; the CUDA kernel computes the slice the ordinary way. The PR added both the shortcut and the test, so the change is at fault; the test simply needs to run only where the shortcut's contract exists.

How to fix: Skip the column-slice bit-exactness test on non-HIP builds, where the whole-row-lane contract is not implemented.

  1. In server/test/unit/test_ds4_fused_ops_cuda.cpp, make run_col_slice a no-op that reports a skip when GGML_USE_HIP is not defined (the pattern the neighbouring HIP-only checks already use).
  2. Keep the test unchanged on HIP, where the RDNA4 Q8_0 kernel implements the contract and the test passes.
  3. Push and let the sm_121 verifier re-run; the binary should end with OK.
  4. Optionally verify locally on a CUDA box that test_ds4_fused_ops_cuda prints the skip and exits 0.

failing bit-exactness unit test · high confidence · run

Details and evidence

Failed: GPU tests (self-hosted DGX GB10, sm_121 verifier) › Run focused NVIDIA verifier tests

Job 'GPU tests (self-hosted DGX GB10, sm_121 verifier)', step 'Run focused NVIDIA verifier tests': the test_ds4_fused_ops_cuda binary ends with col slice mismatch n_out=64 n_full=2304 begin=1152 count=1152 cols=4 at 2: ref -1.77656889 fused -1.77656901 and the same at cols=1, then ds4 fused ops: 2 failures and ##[error]Process completed with exit code 1.

Root cause: run_col_slice (server/test/unit/test_ds4_fused_ops_cuda.cpp:540) flags a column-range Q8_0 mul_mat with ggml_backend_cuda_mul_mat_whole_row_lanes and then memcmps it against the full-width product over zero-padded input (lines 582-585). The flag's contract is only implemented by the HIP-only RDNA4 kernel: it is translated to kb_off at mmvq.cu:4013-4025 and consumed only inside the #if defined(GGML_USE_HIP) kernel mul_mat_vec_q8_0_rdna4 (mmvq.cu:2604, lane alignment at mmvq.cu:2687-2691). On CUDA the flag is silently dropped and the generic mul_mat_vec_q runs the 36-block slice with its own block-to-lane assignment, which does not reproduce the full row's 72-block reduction order — hence last-ULP diffs exactly at nonzero offsets (begin=1152, i.e. kb_off=36). The header itself documents this: '...on the RDNA4 Q8_0 matvec every block keeps the lane... Other kernels compute the range's product as usual' (server/deps/llama.cpp/ggml/include/ggml-cuda.h:165-170). This surfaced now because the head commit made the series compile on CUDA at all, so the sm_121 job ran this newly added test for the first time. The same binary already skips its other HIP-only checks at runtime ('skipped (HIP-only contract)' lines in the same log).

Other things to try

  • If the bit-identity is ever needed on NVIDIA, teach the generic mul_mat_vec_q kernel the whole-row-lane alignment instead: offset each lane's kbx loop start by -kb_off mod (vdr*warp_size/qi) so every lane keeps the blocks it would own in the full rows, then the warp butterfly matches bit for bit.
  • Reproduce locally on any CUDA GPU: build with -DLUCE_TESTS=ON and run the test_ds4_fused_ops_cuda binary; it prints the two col slice mismatch lines before the patch and a skip plus OK after.
Patch (1 file)

Not applied anywhere. To try it, save the diff as fix.patch and run git apply fix.patch.

diff --git a/server/test/unit/test_ds4_fused_ops_cuda.cpp b/server/test/unit/test_ds4_fused_ops_cuda.cpp
index d4b46977..3a8acdf2 100644
--- a/server/test/unit/test_ds4_fused_ops_cuda.cpp
+++ b/server/test/unit/test_ds4_fused_ops_cuda.cpp
@@ -538,6 +538,14 @@ bool run_glu_pair(ggml_backend_t backend, int n_in, int n_full, int n_ff, int ff
 // ggml_backend_cuda_mul_mat_whole_row_lanes vs the whole rows times the slice
 // zero-padded to full width (build_shared_ffn_slice's padded form).
 bool run_col_slice(ggml_backend_t backend, int n_out, int n_full, int k_begin, int k_count, int ncols, uint32_t seed) {
+#if !defined(GGML_USE_HIP)
+    // The whole-row-lane contract (ggml_backend_cuda_mul_mat_whole_row_lanes)
+    // is implemented only by the HIP RDNA4 Q8_0 MMVQ kernel; other kernels
+    // compute the slice as usual and are not bit-identical to the padded
+    // full-width product (see the API comment in ggml-cuda.h).
+    std::fprintf(stderr, "col slice skipped (HIP-only contract)\n");
+    return true;
+#else
     ggml_init_params params{};
     params.mem_size = ggml_tensor_overhead() * 48 + ggml_graph_overhead();
     params.no_alloc = true;
@@ -591,6 +599,7 @@ bool run_col_slice(ggml_backend_t backend, int n_out, int n_full, int k_begin, i
     ggml_backend_buffer_free(buf);
     ggml_free(ctx);
     return ok;
+#endif
 }
 
 #if defined(GGML_USE_HIP)
Prompt for a coding agent
A GitHub Actions run failed in Luce-Org/lucebox. Please fix it.

- Pull request #826 (branch `feat/ds41-cluster`), commit 4f2c69333d7d951973339fb7a1d4a72e868c80a3
- Workflow: CI (https://github.com/Luce-Org/lucebox/actions/runs/37811392450)
- Failed: GPU tests (self-hosted DGX GB10, sm_121 verifier) › Run focused NVIDIA verifier tests

Diagnosis:
Job 'GPU tests (self-hosted DGX GB10, sm_121 verifier)', step 'Run focused NVIDIA verifier tests': the `test_ds4_fused_ops_cuda` binary ends with `col slice mismatch n_out=64 n_full=2304 begin=1152 count=1152 cols=4 at 2: ref -1.77656889 fused -1.77656901` and the same at `cols=1`, then `ds4 fused ops: 2 failures` and `##[error]Process completed with exit code 1`.

Root cause: `run_col_slice` (server/test/unit/test_ds4_fused_ops_cuda.cpp:540) flags a column-range Q8_0 mul_mat with `ggml_backend_cuda_mul_mat_whole_row_lanes` and then `memcmp`s it against the full-width product over zero-padded input (lines 582-585). The flag's contract is only implemented by the HIP-only RDNA4 kernel: it is translated to `kb_off` at mmvq.cu:4013-4025 and consumed only inside the `#if defined(GGML_USE_HIP)` kernel `mul_mat_vec_q8_0_rdna4` (mmvq.cu:2604, lane alignment at mmvq.cu:2687-2691). On CUDA the flag is silently dropped and the generic `mul_mat_vec_q` runs the 36-block slice with its own block-to-lane assignment, which does not reproduce the full row's 72-block reduction order — hence last-ULP diffs exactly at nonzero offsets (`begin=1152`, i.e. `kb_off=36`). The header itself documents this: '...on the RDNA4 Q8_0 matvec every block keeps the lane... Other kernels compute the range's product as usual' (server/deps/llama.cpp/ggml/include/ggml-cuda.h:165-170). This surfaced now because the head commit made the series compile on CUDA at all, so the sm_121 job ran this newly added test for the first time. The same binary already skips its other HIP-only checks at runtime ('skipped (HIP-only contract)' lines in the same log).

Proposed fix: Skip the column-slice bit-exactness test on non-HIP builds, where the whole-row-lane contract is not implemented.
1. In `server/test/unit/test_ds4_fused_ops_cuda.cpp`, make `run_col_slice` a no-op that reports a skip when `GGML_USE_HIP` is not defined (the pattern the neighbouring HIP-only checks already use).
2. Keep the test unchanged on HIP, where the RDNA4 Q8_0 kernel implements the contract and the test passes.
3. Push and let the sm_121 verifier re-run; the binary should end with `OK`.
4. Optionally verify locally on a CUDA box that `test_ds4_fused_ops_cuda` prints the skip and exits 0.

Suggested patch (a starting point):
```diff
diff --git a/server/test/unit/test_ds4_fused_ops_cuda.cpp b/server/test/unit/test_ds4_fused_ops_cuda.cpp
index d4b46977..3a8acdf2 100644
--- a/server/test/unit/test_ds4_fused_ops_cuda.cpp
+++ b/server/test/unit/test_ds4_fused_ops_cuda.cpp
@@ -538,6 +538,14 @@ bool run_glu_pair(ggml_backend_t backend, int n_in, int n_full, int n_ff, int ff
 // ggml_backend_cuda_mul_mat_whole_row_lanes vs the whole rows times the slice
 // zero-padded to full width (build_shared_ffn_slice's padded form).
 bool run_col_slice(ggml_backend_t backend, int n_out, int n_full, int k_begin, int k_count, int ncols, uint32_t seed) {
+#if !defined(GGML_USE_HIP)
+    // The whole-row-lane contract (ggml_backend_cuda_mul_mat_whole_row_lanes)
+    // is implemented only by the HIP RDNA4 Q8_0 MMVQ kernel; other kernels
+    // compute the slice as usual and are not bit-identical to the padded
+    // full-width product (see the API comment in ggml-cuda.h).
+    std::fprintf(stderr, "col slice skipped (HIP-only contract)\n");
+    return true;
+#else
     ggml_init_params params{};
     params.mem_size = ggml_tensor_overhead() * 48 + ggml_graph_overhead();
     params.no_alloc = true;
@@ -591,6 +599,7 @@ bool run_col_slice(ggml_backend_t backend, int n_out, int n_full, int k_begin, i
     ggml_backend_buffer_free(buf);
     ggml_free(ctx);
     return ok;
+#endif
 }
 
 #if defined(GGML_USE_HIP)
```

Check out that commit, confirm the diagnosis, make the fix (adjust it if the diagnosis is wrong), and re-run the failing step locally before pushing.

Reply with /ci-fix and what to change to get a revised diagnosis.

ggml_backend_cuda_mul_mat_whole_row_lanes promises the padded product's
bits only where the lanes are implemented (HIP); other kernels compute the
range's product as usual. The column-slice check now compares bits on HIP
and the value to 1e-5 relative elsewhere: on the DGX GB10 (sm_121) the
slice and the padded product differ in the last bit (-1.77656889 vs
-1.77656901), which failed the CUDA GPU job.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@davide221
davide221 marked this pull request as ready for review October 8, 2026 22:25
- A cluster worker runs to completion inside load_model and has no HTTP
  server; main() went on to call run() on the null server, so every clean
  shutdown of rank 1 crashed. It now returns the worker's exit code.
- --cluster-selftest and --cluster-replicate-hot were documented, wired
  to the head, the worker and the placement, but rejected by the parser.
- The cluster lines of --help sat inside --ds4-prefill's description.
- LUCE_CLUSTER_FAST_REDUCE_STALL_S=0 made every wait a stall; 0 now turns
  the watchdog off, and the defaults comment says which entries are sizes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@davide221 davide221 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code review

Found 1 issue:

  1. HeadHooks fallback RNG is seeded once and ignores later request seeds · likely (server/src/cluster/cluster_decision_hooks.cpp:62-64)

Note: the diff of server/src/cluster/fast_reduce.cpp, server/src/deepseek4/deepseek4_dspark_spec.cpp, server/src/deepseek4/deepseek4_fused_verify.inc, server/src/deepseek4/deepseek4_graph.cpp, server/test/unit/test_adaptive_spec_width.cpp, server/test/unit/test_cluster_unit.cpp, server/test/unit/test_deepseek4_unit.cpp, server/test/unit/test_ds4_fused_ops_cuda.cpp, server/test/unit/test_launch_policy.cpp was too large to include; the reviewers could read those files but not see what changed in them; tick the box in the bot's comment below to review them.

Reviewed a49bc3ef · 2 candidate issues checked, kept at ≥70% confidence · add the code-review label to review again

Comment on lines +62 to +64
static thread_local std::mt19937_64 rng{cfg.seed != 0 ? cfg.seed : 0x5eedULL};
std::vector<int32_t> no_history;
token = common::sample_logits(logits, n_vocab, cfg, no_history, rng);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

HeadHooks fallback RNG is seeded once and ignores later request seeds · likely

In HeadHooks::decide_next_token, when the caller passes token < 0 and cfg.temp > 0, the sampler RNG is a static thread_local std::mt19937_64 initialized from cfg.seed on the first call only. Every subsequent request — including ones with a different seed, or the same seed that should reproduce a stream — reuses the first request's generator state, so sampled tokens are wrong relative to the requested seed and are not reproducible across requests/restarts. Construct the RNG per request (or reseed when cfg.seed changes).

Checked (70%): cluster_decision_hooks.cpp:62 uses static thread_local std::mt19937_64 rng{cfg.seed ...}, so the seed from cfg is applied only on the first call and every later request's cfg.seed is ignored, exactly as claimed. However, the only current callers (deepseek4_backend.cpp:4860/4927/5198) all pass an already-sampled token >= 0, so the path is a documented fallback (cluster_decision_hooks.h:84-86) not exercised in normal use today, which lowers severity but the defect is present in the new code.

@davide221

Copy link
Copy Markdown
Contributor Author

9 files left out of the code review of a49bc3ef: the diff was over the size limit, so generated files, docs and tests went first, then the largest diffs.

  • server/src/cluster/fast_reduce.cpp

  • server/src/deepseek4/deepseek4_dspark_spec.cpp

  • server/src/deepseek4/deepseek4_fused_verify.inc

  • server/src/deepseek4/deepseek4_graph.cpp

  • server/test/unit/test_adaptive_spec_width.cpp

  • server/test/unit/test_cluster_unit.cpp

  • server/test/unit/test_deepseek4_unit.cpp

  • server/test/unit/test_ds4_fused_ops_cuda.cpp

  • server/test/unit/test_launch_policy.cpp

  • Review the left-out files (one review of these files only; needs write access)

@davide221

Copy link
Copy Markdown
Contributor Author

The code review bot stopped.

An unexpected error occurred.

Maintainers: see the lucebox_bots run log.

@davide221 davide221 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code review

Found 9 issues:

  1. bf16 wire length is chosen from env only, but the receive/publish path depends on cfg.size and KERNEL_FLAGS: silent corr · confirmed (server/src/cluster/fast_reduce.cpp:680-692)

  2. Lean hybrid exchange never writes the slot-done flag, but submit's slot-reuse guard still waits on it when ZC is off · confirmed (server/src/cluster/fast_reduce.cpp:1006-1007)

  3. Hybrid-exchange call counters are never reset, so one rank-local failure permanently desynchronizes the k indices · likely (server/src/deepseek4/deepseek4_cluster.h:62)

  4. Async Engram read starts even when the graph has no cluster broadcast node, so the keys are never uploaded · confirmed (server/src/deepseek4/deepseek4_fused_verify.inc:2326-2332)

  5. Whole-row-lanes flag is read as a single byte, so it never matches the magic and the feature is dead · confirmed (server/deps/llama.cpp/ggml/src/ggml-cuda/mmvq.cu:4016-4019)

  6. HC-boundary spin barrier bakes a stale target under CUDA-graph replay · likely (server/deps/llama.cpp/ggml/src/ggml-cuda/ds4-hc.cu:1127)

  7. moe_combine_masked_kernel indexes valid with an unchecked (possibly -1) route id · likely (server/deps/llama.cpp/ggml/src/ggml-cuda/moe-fused.cu:409-411)

  8. Worker death mid-request calls fail_cluster, which _Exit(3)s immediately and discards the error response the code just b · likely (server/src/cluster/cluster_head_backend.cpp:429-434)

  9. Boundary kernel spin-barrier can deadlock when its blocks are not all co-resident · likely (server/deps/llama.cpp/ggml/src/ggml-cuda/ds4-hc.cu:848-858)

Reviewed a49bc3ef · 13 candidate issues checked, kept at ≥70% confidence · add the code-review label to review again

Comment thread server/src/cluster/fast_reduce.cpp Outdated
Comment on lines +680 to +692
static const bool bf16_wire = fr_env_on("LUCE_CLUSTER_FAST_REDUCE_BF16") &&
fr_env_on("LUCE_CLUSTER_FAST_REDUCE_ZC");
sge.length = (uint32_t) ((size_t) n * (bf16_wire && !exact_row ? 2u : sizeof(float)));
sge.lkey = s.mr_data->lkey;

ibv_send_wr w{};
w.wr_id = next;
w.sg_list = &sge;
w.num_sge = 1;
w.opcode = IBV_WR_RDMA_WRITE;
w.send_flags = 0;
w.wr.rdma.remote_addr = pe.data_addr +
((size_t) slot * s.slot_stride() + (size_t) s.cfg.rank * s.cfg.max_elems) * sizeof(float);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

bf16 wire length is chosen from env only, but the receive/publish path depends on cfg.size and KERNEL_FLAGS: silent corr · confirmed

The progress thread's bf16_wire is LUCE_CLUSTER_FAST_REDUCE_BF16 && LUCE_CLUSTER_FAST_REDUCE_ZC, with no check of cfg.size or LUCE_CLUSTER_FAST_REDUCE_KERNEL_FLAGS. But FastReduce::submit only takes the zero-copy bf16 path when zc && !kernel_flags && cfg.size == 2; otherwise it publishes the row as f32 (hipMemcpyAsync of n * sizeof(float), or the kernel-flags path with no packing). With the cluster-mode defaults (which install _ZC and _BF16 for any --cluster-size > 1) and --cluster-size 3, every reduction sends only 2*n bytes of an f32 row while the receiving rank copies and adds 4*n bytes as float — silently garbage output. The same mismatch happens at size 2 with LUCE_CLUSTER_FAST_REDUCE_KERNEL_FLAGS=1. The bf16_wire decision must match the submit-side condition (gate it on cfg.size == 2 and !kernel_flags, or export the decision from one place).

Checked (90%): The progress thread's bf16_wire (fast_reduce.cpp:680-682) is only LUCE_CLUSTER_FAST_REDUCE_BF16 && LUCE_CLUSTER_FAST_REDUCE_ZC, while the submit-side zero-copy/bf16 path (fast_reduce.cpp:833) requires zc && !kernel_flags && s.cfg.size == 2; otherwise submit publishes an f32 row via hipMemcpyAsync (line 856, nsizeof(float)). cluster_launch_env (launch_profiles.h:244-247) installs ZC=1 and BF16=1 for any --cluster-size > 1, and cluster_config allows size up to 8, so with size 3 (or KERNEL_FLAGS=1 at size 2) the wire carries 2n bytes while the peer's stream ops read the row as n floats — a silent length/format mismatch on every reduction. Only slight assumption: that a size>2 run is a configuration operators would hit, which the code explicitly supports.

Suggested change
static const bool bf16_wire = fr_env_on("LUCE_CLUSTER_FAST_REDUCE_BF16") &&
fr_env_on("LUCE_CLUSTER_FAST_REDUCE_ZC");
sge.length = (uint32_t) ((size_t) n * (bf16_wire && !exact_row ? 2u : sizeof(float)));
sge.lkey = s.mr_data->lkey;
ibv_send_wr w{};
w.wr_id = next;
w.sg_list = &sge;
w.num_sge = 1;
w.opcode = IBV_WR_RDMA_WRITE;
w.send_flags = 0;
w.wr.rdma.remote_addr = pe.data_addr +
((size_t) slot * s.slot_stride() + (size_t) s.cfg.rank * s.cfg.max_elems) * sizeof(float);

Comment thread server/src/cluster/fast_reduce.cpp Outdated
Comment on lines +1006 to +1007
if (fast_reduce_lean()) return true;
return hipStreamWriteValue32(stm, s.flag_dev + s.done_off(slot), (uint32_t) seq, 0) == hipSuccess;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Lean hybrid exchange never writes the slot-done flag, but submit's slot-reuse guard still waits on it when ZC is off · confirmed

Sequence numbers are shared by submit and the hybrid exchange (hyb_seq_for draws from the same s.seq), and a ring slot's previous user can be either kind. With LUCE_CLUSTER_FAST_REDUCE_LEAN=1 (a cluster-mode default) hyb_import returns before hipStreamWriteValue32(done_off) (line 1006), so hybrid sequences never publish done. submit's slot-reuse guard (lines 818-826) is skipped only when zc_lean (lean and LUCE_CLUSTER_FAST_REDUCE_ZC and size==2). The documented operator kill switch LUCE_CLUSTER_FAST_REDUCE_ZC=0 leaves lean and LUCE_CLUSTER_HYBRID_EXCHANGE on: submits then take the guard, and once a submit lands on a slot whose previous user (seq - 4096) was a hybrid exchange, hipStreamWaitValue32(done >= seq-slots) can never be satisfied. The stream parks forever and the stall watchdog _Exit(4)s the whole server mid-request. hyb_export's guard (line 935) has the same asymmetry (!fast_reduce_lean() only). The skip conditions must match: only omit the done write when the submit guard is also omitted.

_Checked (90%): With LUCE_CLUSTER_FAST_REDUCE_LEAN=1 and ZC=0 (the documented kill switch), hyb_import returns before the done write (fast_reduce.cpp:1006-1007) and hyb_combine never writes done either, while submit's slot-reuse guard is skipped only for zc_lean (lean && ZC && size==2, line 818), so a submit whose slot's previous user (seq-slots) was a hybrid exchange waits at line 822 on hipStreamWaitValueGte for a done value that is never written — the stream parks and the watchdog Exit(4)s. hyb_export's guard at line 935 checks only fast_reduce_lean(), confirming the skip conditions are asymmetric.

Suggested change
if (fast_reduce_lean()) return true;
return hipStreamWriteValue32(stm, s.flag_dev + s.done_off(slot), (uint32_t) seq, 0) == hipSuccess;
static const bool zc_lean = fast_reduce_lean() && fr_env_on("LUCE_CLUSTER_FAST_REDUCE_ZC") &&
s.cfg.size == 2;
if (zc_lean || std::getenv("LUCE_CLUSTER_FAST_REDUCE_KERNEL_FLAGS")) return true;
return hipStreamWriteValue32(stm, s.flag_dev + s.done_off(slot), (uint32_t) seq, 0) == hipSuccess;

// whatever it declines (prefill-sized payloads).
std::unique_ptr<cluster::FastReduce> fast;
// Hybrid exchange call counters: the k-th export/combine/import call of each kind belongs to exchange k.
uint64_t hyb_export0 = 0, hyb_export1 = 0, hyb_combine = 0, hyb_import = 0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hybrid-exchange call counters are never reset, so one rank-local failure permanently desynchronizes the k indices · likely

hyb_export0/1, hyb_combine and hyb_import are monotonic counters used as the exchange index k passed to FastReduce::hyb_export/combine/import (deepseek4_cluster.cpp:648-664). Nothing in the runtime ever resets them — not at request start, not when node_error is cleared before a new graph, not on error recovery. As long as both ranks execute exactly the same number of callbacks over the process lifetime the pairing holds, but any rank-local failure that aborts a step on one rank only (a fast_reduce timeout, an OOM in verify_batch, a run_split_head failure at deepseek4_dspark_spec.cpp:1372 which does ok = false; break on that rank alone) leaves the two ranks' counters different. Every subsequent hybrid exchange then pairs mismatched slots: the peer is already waiting on a different row, so the next decode step hangs until the 30 s watchdog or silently exchanges wrong buffers and corrupts the model state. Reset the counters at a point both ranks reach by agreement (e.g. when the request ends, alongside the node_error clear at deepseek4_graph.cpp:11173 / deepseek4_dspark_spec.cpp:1368), or make the slot index positional rather than a running count.

Checked (80%): The counters are only ever initialized (deepseek4_cluster.h:62) and incremented in the callbacks (deepseek4_cluster.cpp:650-663); grep finds no reset anywhere. FastReduce's hyb_seq_for/fast_reduce.cpp:919-926 derives the wire sequence number from these per-rank call counts, and rank-local failures exist that clear node_error and fail only one rank's step (deepseek4_graph.cpp:11170-11174, deepseek4_dspark_spec.cpp:1368-1375 with ok=false; break), so a recovered rank then pairs a different seq than its peer's flag waits expect, hanging or corrupting subsequent exchanges. Remaining uncertainty is only whether every such failure is treated as process-fatal in practice, which the clearing code suggests it is not.

Comment on lines +2326 to +2332
if (pinned_io && engram_rt && engram && token_ids && ds4_engram_async_enabled() &&
ex->engram_keys->buffer) {
if (!vc.engram_async) vc.engram_async = std::make_unique<Ds4EngramAsync>();
engram_async = vc.engram_async->start(
backend, w, cache.engram_tokens, token_ids, kv_start, q,
(size_t) engram->n_layers() * (size_t) q * engram->key_floats());
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Async Engram read starts even when the graph has no cluster broadcast node, so the keys are never uploaded · confirmed

The async reader is started whenever pinned_io && engram_rt && engram && token_ids holds, where engram_rt only checks cache.cluster_rt && size > 1. But the graph only contains the ds4_cluster_broadcast_node that consumes the reader's pinned buffer (via the before_broadcast hook installed at line 2610) when cluster_hybrid is true, i.e. when the graph was built with a non-null hybrid. In deepseek4_step_layer_range the fused verify is invoked with fused_hybrid_ready ? moe_hybrid : nullptr, so whenever the cluster is attached but the fused hybrid graph is unavailable (ds4_cluster_fused_graph_available false, or the hybrid storage/cold backend not ready), the graph still allocates ex.engram_keys as a plain input while the step takes the async path and skips the in-line ds4_fv_set(ex->engram_keys, ...) upload. The before_broadcast hook never fires, the device keys tensor is never written, and every Engram layer reads uninitialized device memory — silent output corruption on a normal decode step. Gate the async start on the same condition that adds the broadcast node (a non-null hybrid for this step), e.g. add hybrid && to the condition at line 2326.

Checked (92%): The async start at deepseek4_fused_verify.inc:2326-2332 gates only on pinned_io/engram_rt/engram/token_ids, but engram_rt is set whenever cache.cluster_rt->size() > 1 regardless of hybrid; the ds4_cluster_broadcast_node that consumes the reader's buffer via the before_broadcast hook (lines 592, 2610) is added only when cluster_hybrid = hybrid && cluster_rt && size>1, and deepseek4_graph.cpp:11168 passes fused_hybrid_ready ? moe_hybrid : nullptr. When the cluster is attached but the fused hybrid graph is unavailable (fused_hybrid_ready false, e.g. ds4_cluster_fused_graph_available false or cold backend not ready), the graph keeps ex.engram_keys as a plain input, the in-line ds4_fv_set upload is skipped, the hook never fires, and the Engram layers read uninitialized device memory.

Comment on lines +4016 to +4019
if (!ids && src0->type == GGML_TYPE_Q8_0 && dst->op == GGML_OP_MUL_MAT &&
dst->op_params[1] == GGML_CUDA_WHOLE_ROW_LANES &&
src0->view_src != nullptr && src0->nb[1] == src0->view_src->nb[1]) {
kb_off = (int) ((src0->view_offs % src0->nb[1]) / sizeof(block_q8_0));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Whole-row-lanes flag is read as a single byte, so it never matches the magic and the feature is dead · confirmed

ggml_backend_cuda_mul_mat_whole_row_lanes stores GGML_CUDA_WHOLE_ROW_LANES (0x574C524B) with ggml_set_op_params_i32(mul_mat, 1, ...), i.e. as a 32-bit value in int32 slot 1 (bytes 4..7 of op_params). But ggml_tensor::op_params is a byte array, and the check here reads dst->op_params[1] — a single byte (part of the precision slot) — and compares it to the 4-byte magic. The comparison is always false, so kb_off is always 0 and the whole-row-lane block mapping never engages. The column-range product then uses the range-only lane order, which is not bit-identical to the whole-row product this PR promises (the slice bit-exactness test would also pass vacuously, since both sides take the same unflagged path). Read the parameter with ggml_get_op_params_i32(dst, 1) instead.

Checked (95%): ggml-cuda.cu:159 sets the magic as a 32-bit value at int32 slot 1 (bytes 4..7 of the byte-array op_params, ggml-impl.h:163-166), but mmvq.cu:4017 reads dst->op_params[1], a single byte that is part of int32 slot 0, so the comparison to 0x574C524B can never be true and kb_off stays 0; the whole-row-lane path used by deepseek4_graph.cpp:4760 is dead.

Suggested change
if (!ids && src0->type == GGML_TYPE_Q8_0 && dst->op == GGML_OP_MUL_MAT &&
dst->op_params[1] == GGML_CUDA_WHOLE_ROW_LANES &&
src0->view_src != nullptr && src0->nb[1] == src0->view_src->nb[1]) {
kb_off = (int) ((src0->view_offs % src0->nb[1]) / sizeof(block_q8_0));
if (!ids && src0->type == GGML_TYPE_Q8_0 && dst->op == GGML_OP_MUL_MAT &&
ggml_get_op_params_i32(dst, 1) == GGML_CUDA_WHOLE_ROW_LANES &&
src0->view_src != nullptr && src0->nb[1] == src0->view_src->nb[1]) {

CUDA_CHECK(cudaMemsetAsync(ctx.ds4_hc_barrier, 0, sizeof(unsigned long long), ctx.stream()));
ctx.ds4_hc_barrier_blocks = 0;
}
ctx.ds4_hc_barrier_blocks += (unsigned long long) n_tokens;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

HC-boundary spin barrier bakes a stale target under CUDA-graph replay · likely

The cross-block barrier uses a monotonically growing device counter (ctx.ds4_hc_barrier) with barrier_target taken from a host-side running count (ctx.ds4_hc_barrier_blocks += n_tokens) at launch time. That is only correct while every launch executes exactly once in order. The DS4.1 decode graph is run under ggml's CUDA-graph capture in this serving path (the PR even ships capture-related cluster defaults), and during capture the kernel is recorded with the current cumulative count baked in while the device counter does not advance. On the second and later replays the device counter already meets or exceeds every baked target, so while (atomicAdd(barrier, 0ull) < barrier_target) returns immediately and blocks no longer wait for their peers. The barrier exists precisely because out may share memory with R/block_out (the kernel comment says so), so on replays a block can write out over R while another block is still reading it, silently corrupting the HC streams. Fix by making the target self-contained per launch (e.g. a per-launch target slot/phase, or resetting the counter via a captured cudaMemsetAsync to 0 paired with a per-launch base), or by refusing the fusion when the op will be captured in a CUDA graph.

Checked (88%): The fusion runs in the graph-eval path (ggml-cuda.cu:6078) which executes under cudaStreamBeginCapture (ggml-cuda.cu:7090) when CUDA graphs are enabled. The kernel's barrier_target is a host running count baked in at launch (ds4-hc.cu:1127, 1151/1168) while the device counter is only advanced by the recorded kernel; on graph replays the kernel re-executes with the stale constant while the counter has already passed it, so the spin at ds4-hc.cu:1064-1065 returns immediately and the write-to-out-after-all-reads ordering the kernel comment requires (ds4-hc.cu:1024-1028) is not enforced, corrupting out when it aliases R/block_out. Only slight uncertainty is whether some guard disables this fusion under capture, which I did not find.

Comment on lines +409 to +411
const int32_t id = *(const int32_t *)(ids + (size_t)e * sizeof(int32_t) + (size_t)t * ids_nb1);
const float valid_w = *(const float *)(valid + (size_t)id * valid_nb1 + (size_t)t * valid_nb2);
const float w = *(const float *)(weights + (size_t)e * sizeof(float) + (size_t)t * weights_nb1) * valid_w;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

moe_combine_masked_kernel indexes valid with an unchecked (possibly -1) route id · likely

In the cluster expert-parallel path (and the cold-owner-None hybrid path), routes not owned by this rank arrive masked to expert id -1 (see the new foreign_by_global/foreign_routes handling in moe_hybrid_storage.h and the comment in moe_hybrid_ffn_eval.cpp: "routes masked to -1 by the cluster runtime are evaluated elsewhere"). build_moe_hybrid_ffn_graph now defaults to routing those ids straight into ggml_moe_combine_masked (combine_ids = global_ids, mask_in_combine is on by default via moe_masked_combine_enabled). The kernel then computes valid + (size_t)id * valid_nb1 with id = -1, reading four bytes before the valid LUT tensor — an out-of-bounds device read. If the word read is non-zero, w = router_weight * garbage is non-zero and the kernel adds the expert slot's stale (possibly NaN, since masked owner ids leave the slot unwritten — exactly the hazard the sibling laguna_moe_combine_kernel documents) value into the output, corrupting the layer. Whether it fires depends on allocator layout, so it can pass validation runs and corrupt later ones. Guard the lookup: treat id < 0 (or id >= n_expert) as a masked route and continue.

Checked (85%): moe_combine_masked_kernel reads id at moe-fused.cu:409 and indexes valid with (size_t)id * valid_nb1 at :410 with no id<0 guard, so a -1 route reads before the valid tensor. The default path sets combine_ids = global_ids (moe_hybrid_ffn_eval.cpp:1024) and moe_masked_combine_enabled() defaults on (:1081-1087), while the codebase explicitly routes foreign cluster experts as ids masked to -1 (moe_hybrid_storage.h:110-113, moe_hybrid_ffn_eval.cpp:1883-1886, mmvq.cu:1604), so masked -1 ids reach the kernel in cluster mode. Only residual uncertainty is whether some unverified upstream step filters -1 out of global_ids before the graph combine.

Suggested change
const int32_t id = *(const int32_t *)(ids + (size_t)e * sizeof(int32_t) + (size_t)t * ids_nb1);
const float valid_w = *(const float *)(valid + (size_t)id * valid_nb1 + (size_t)t * valid_nb2);
const float w = *(const float *)(weights + (size_t)e * sizeof(float) + (size_t)t * weights_nb1) * valid_w;
const int32_t id = *(const int32_t *)(ids + (size_t)e * sizeof(int32_t) + (size_t)t * ids_nb1);
if (id < 0) {
continue;
}
const float valid_w = *(const float *)(valid + (size_t)id * valid_nb1 + (size_t)t * valid_nb2);

Comment on lines +429 to +434
if (!result.ok() && (!control_.all_alive() || (comm_ && comm_->async_error(&err)))) {
fail_cluster("request " + std::to_string(request_id) + " failed: " +
std::string(result.error_detail()) + (err.empty() ? "" : " / " + err),
kAbortCodeRequest, request_id);
return result;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Worker death mid-request calls fail_cluster, which _Exit(3)s immediately and discards the error response the code just b · likely

When a request fails because a worker died, run_request calls fail_cluster(...), which (with the default exit_on_abort_ = true) ends in std::_Exit(3) on the request thread. The result carrying the DecodeFailed error detail is never returned to the HTTP layer, so the client gets a reset connection instead of the error response. The class has request_fatal_exit specifically to give the request thread kFatalExitGraceMs to unwind and write the HTTP response before exiting; this call site bypasses it. Use request_fatal_exit(...) here (or route fail_cluster's exit through the grace path when a request is in flight).

Checked (85%): fail_cluster (cluster_head_backend.cpp:291-295) calls std::Exit(3) directly on the calling thread, and run_request's worker-death path (lines 429-434) calls fail_cluster on the request thread before returning result, so the HTTP error response is discarded. The class provides request_fatal_exit precisely to give kFatalExitGraceMs for the response to be written, and this call site bypasses it (the watchdog path can't save it either, since request_in_flight is cleared at line 420 before the check).

Suggested change
if (!result.ok() && (!control_.all_alive() || (comm_ && comm_->async_error(&err)))) {
fail_cluster("request " + std::to_string(request_id) + " failed: " +
std::string(result.error_detail()) + (err.empty() ? "" : " / " + err),
kAbortCodeRequest, request_id);
return result;
}

Comment on lines +848 to +858
if (tid == 0) {
__threadfence();
atomicAdd(barrier, 1ull);
while (atomicAdd(barrier, 0ull) < barrier_target) {
#if defined(GGML_USE_HIP)
__builtin_amdgcn_s_sleep(1);
#endif
}
__threadfence();
}
__syncthreads();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Boundary kernel spin-barrier can deadlock when its blocks are not all co-resident · likely

Both ds4_hc_boundary_kernel and ds4_hc_boundary_split_kernel end with a block-wide spin barrier (atomicAdd/spin until the cumulative target) that every launched block must reach before any may exit. Each block is 1024 threads with up to 48 KB of dynamic shared memory, so at most one block can be resident per CU; the launch therefore deadlocks whenever the GPU cannot host all n_tokens (or n_tokens * NSPLIT) blocks simultaneously — the unlaunched blocks can never arrive and the resident ones spin forever. ggml_cuda_ds4_hc_boundary_supported checks only shapes and mmv_block, not the device's CU count or occupancy; the code asserts n_tokens <= 64, and any RDNA part with fewer than 64 CUs (e.g. the gfx1151 Strix Halo iGPU with ~40 CUs, which this project explicitly supports in mixed-backend setups) hangs as soon as a boundary runs with more tokens than resident workgroups. The split path's n_tokens * 6 <= 48 gate shows residency was considered there, but the non-split path has no equivalent guard. Fix: compute the maximum co-resident block count (CU count × blocks-per-CU given 1024 threads and the shmem size) in ggml_cuda_ds4_hc_boundary_supported and fall back to the unfused launches when the launch would not fully fit.

Checked (82%): Confirmed in the code: ds4-hc.cu:848-858/1062-1071 has every launched block spin on a cumulative atomicAdd barrier before exiting, the non-split launch at ds4-hc.cu:1164 issues n_tokens (asserted only <= 64, line 1118) 1024-thread blocks with up to 48KB dynamic shmem (so at most one block per CU/SM), and ggml_cuda_ds4_hc_boundary_supported (1099-1106) checks only shapes/mmv_block/shmem, never occupancy — so on any GPU with fewer co-resident blocks than n_tokens the resident blocks spin forever. The split path's own comment (1130-1133, gate n_tokens*6 <= 48) shows residency was a known requirement that the non-split path omits; only uncertainty is whether decode batches large enough to exceed CU count occur on a given device, hence not 95.

@davide221 davide221 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code review

Found 3 issues:

  1. sink-col masked softmax relies on a clang-only fp-contract pragma that nvcc ignores · confirmed (server/deps/llama.cpp/ggml/src/ggml-cuda/softmax.cu:61-64)

  2. --cluster-replicate-hot and --cluster-selftest are documented but rejected by the flag parser · confirmed (server/src/server/server_main.cpp:657-659)

  3. Head-split output_b view applies the group offset twice when the loader already sliced the tensor · confirmed (server/src/deepseek4/deepseek4_graph.cpp:2342-2346)

Reviewed a49bc3ef · 5 candidate issues checked, kept at ≥70% confidence · add the code-review label to review again

Comment on lines +61 to +64
static __device__ __forceinline__ float soft_max_sink_col_value(float x, float scale, float m) {
#pragma clang fp contract(off)
return x*scale + m;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

sink-col masked softmax relies on a clang-only fp-contract pragma that nvcc ignores · confirmed

soft_max_sink_col_value must round the scale multiply and the mask add separately (the op's contract is bit-exactness with a scale launch followed by an add launch, and run_sink_mask checks bit equality on every GPU backend, including CUDA). It only uses #pragma clang fp contract(off). This PR itself notes in moe_combine_masked_kernel (moe-fused.cu) that nvcc ignores the clang pragma and builds with fast math, so on CUDA builds x*scale + m will be contracted to an FMA and the result differs in the last bits from the separate mul+add. Use the same belt-and-braces form as moe_combine_masked_kernel: keep the pragma for HIP and force the multiply with __fmul_rn.

Checked (90%): The new soft_max_sink_col_value at softmax.cu:61-64 guards x*scale + m only with #pragma clang fp contract(off), but CUDA builds with -use_fast_math (CMakeLists.txt:226) and nvcc ignores the clang pragma — a fact this same PR documents in moe-fused.cu:396-399, where it uses __fmul_rn for exactly this reason. Since run_sink_mask (test_ds4_fused_ops_cuda.cpp:732-779) compares the fused path bit-exactly (memcmp) against separate scale-then-add launches, an FMA contraction on CUDA would produce last-bit mismatches.

Suggested change
static __device__ __forceinline__ float soft_max_sink_col_value(float x, float scale, float m) {
#pragma clang fp contract(off)
return x*scale + m;
}
static __device__ __forceinline__ float soft_max_sink_col_value(float x, float scale, float m) {
#pragma clang fp contract(off)
return __fmul_rn(x, scale) + m;
}

Comment on lines +657 to +659
} else {
std::fprintf(stderr, "[server] unknown or incomplete cluster flag %s\n", flag);
return 2;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

--cluster-replicate-hot and --cluster-selftest are documented but rejected by the flag parser · confirmed

ClusterConfig in cluster_config.h documents --cluster-replicate-hot <k> and --cluster-selftest as operator flags, and the runtime consumes both (deepseek4_cluster.cpp builds the placement from cfg.replicate_hot; ClusterHeadBackend::init and run_cluster_worker branch on cfg.selftest). But the --cluster-* parsing block added in server_main.cpp has no branch for either flag, so passing them falls into the catch-all and the server exits with "unknown or incomplete cluster flag". An operator following the header documentation (or trying to replicate the k hottest experts, a knob the placement builders and the Python script both support) cannot start the server. Add the two branches, e.g.:

} else if (std::strcmp(flag, "--cluster-replicate-hot") == 0) {
    if (!int_value(bargs.cluster.replicate_hot) || bargs.cluster.replicate_hot < 0) {
        std::fprintf(stderr, "[server] --cluster-replicate-hot must be non-negative\n");
        return 2;
    }
} else if (std::strcmp(flag, "--cluster-selftest") == 0) {
    bargs.cluster.selftest = true;
}

Checked (95%): ClusterConfig documents --cluster-replicate-hot (cluster_config.h:71) and --cluster-selftest (cluster_config.h:81-82), and the runtime consumes both (deepseek4_cluster.cpp:266,285; cluster_head_backend.cpp:224; cluster_worker_main.cpp:389), but the --cluster-* parsing block in server_main.cpp:595-660 has branches for neither flag, so both fall into the catch-all at lines 657-659 and the server exits with 'unknown or incomplete cluster flag'. A grep confirms no other code in server_main.cpp sets replicate_hot or selftest.

Comment on lines +2342 to +2346
ggml_tensor * out_b_w = head_split
? ggml_view_2d(ctx, L.attn_output_b, (int64_t) w.n_lora_o * n_groups,
L.attn_output_b->ne[1], L.attn_output_b->nb[1],
ggml_row_size(L.attn_output_b->type, (int64_t) group_first * w.n_lora_o))
: L.attn_output_b;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Head-split output_b view applies the group offset twice when the loader already sliced the tensor · confirmed

When LUCE_CLUSTER_SLICE_WEIGHTS makes the loader store only this rank's output groups, L.attn_output_b is loaded with ne[0] == n_lora_o * n_groups (deepseek4_loader.cpp slices .attn_output_b.weight columns to [slice_group_first*n_lora_o, +slice_groups*n_lora_o)). The first stage handles this correctly: stored_groups detects the sliced attn_output_a and skips the group_first view. But out_b_w unconditionally applies ggml_row_size(type, group_first * n_lora_o) as the view offset whenever head_split is true. On rank 1 of 2 that offset equals the entire sliced tensor's width, so the view starts at (or past) the end of the buffer and the output projection reads out of bounds / garbage on every attention layer of the prefill and uncached-decode paths. The offset must be zero when the stored tensor is already the rank's slice (mirror the stored_groups logic, e.g. use L.attn_output_b->ne[0] == (int64_t) w.n_lora_o * n_groups ? 0 : group_first * w.n_lora_o for the offset), matching how out_a_3d is handled.

Checked (90%): The loader slices attn_output_b columns under LUCE_CLUSTER_SLICE_WEIGHTS (deepseek4_loader.cpp:2320-2325, slice_group_first = rankslice_groups at 2288), so on rank 1 ne[0] = n_groupsn_lora_o, yet build_mla_output_projection applies the group_first*n_lora_o offset unconditionally when head_split (deepseek4_graph.cpp:2342-2345) while only attn_output_a gets the stored_groups already-sliced handling (2330-2338). With head split active whenever w.cluster_heads_local > 0 (graph 11804-11819), rank 1's view offset equals the full sliced tensor width, so the output projection reads at/past the buffer end on every attention layer.

davide221 and others added 4 commits October 9, 2026 10:40
…release

- The bootstrap exchange waits at most timeout_ms for each accept, read and
  write (cluster_control's deadline helpers), closes every socket it opened
  on failure and refuses a duplicate rank: a worker that died during its load
  fails rank 0's set-up instead of parking it.
- The unpublished-reduction watchdog starts its clock when the host hands the
  reduction out, not when the progress thread starts waiting, so an idle
  server no longer trips it between requests.
- A failed ibv_post_send or a failed completion now releases every reduction
  still in flight and exits, like a stall, instead of stopping the progress
  thread with the GPU parked on a flag that never comes. Both use one
  release_and_exit, which says whether the GPU drained or was only released.
- The zero-copy path allocates no device scratch (80 MiB at the cluster
  sizes); the per-device publish counters are freed at shutdown; the submit
  trace and the unused system-scope flag kernel are gone; the kernel-flag and
  import switches read "0" as off like the others; comments match the code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s fail the load

- Every rank keeps the fast reduce only if all ranks brought it up: one rank
  on RCCL and the other on the raw-RDMA path desynced the collectives.
- After the comm is up the ranks compare drafters (none, whole, split).
  Rank 0 decides whether a request drafts, so without a drafter there every
  rank decodes AR; otherwise every rank must draft the way rank 0 does, and a
  mismatch fails the load with a message naming both sides.
- A cluster slice the quant blocks cannot follow fails the load. Keeping that
  one tensor whole left it mismatched with its sliced partner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…asured slower

- ds4_cluster_checksum and ds4_cluster_env_prefill_single_token had no
  callers.
- LUCE_CLUSTER_PREFILL_ATTN_SPLIT forced the prefill head split on unsliced
  weights and measured slower at 2K and 30K; the split now follows the
  sliced weights alone, as it did by default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fused boundary kernel synchronized its blocks on a running device count
against a target the host computed at each launch. A CUDA graph replays the
launch with the target it captured while the count keeps growing, so from
the second replay on the barrier let every block through. The barrier now
keeps its whole state on the device: the last block to arrive resets the
count and advances a generation the others wait on. HIP builds, which do
not capture graphs, run the same barrier.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@davide221 davide221 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code review

Found 4 issues:

  1. RDMA write failure kills the progress thread and parks the GPU forever · confirmed (server/src/cluster/fast_reduce.cpp:735-742)

  2. Head-split wo_b view applies the global group offset to an already-sliced weight · confirmed (server/src/deepseek4/deepseek4_graph.cpp:2342-2346)

  3. HC boundary barrier breaks on CUDA graph replay (target baked at capture, device counter keeps growing) · confirmed (server/deps/llama.cpp/ggml/src/ggml-cuda/ds4-hc.cu:850-856)

  4. ggml_soft_max_ext_sink_col_mask allows a mask the CPU backend refuses (GGML_ASSERT abort) · likely (server/deps/llama.cpp/ggml/src/ggml.c:4429-4446)

Reviewed a49bc3ef · 7 candidate issues checked, kept at ≥70% confidence · add the code-review label to review again

Comment on lines +735 to +742
if (wc[(size_t) i].status != IBV_WC_SUCCESS) {
std::fprintf(stderr,
"[fast-reduce] write failed: %s (seq %llu)\n",
ibv_wc_status_str(wc[(size_t) i].status),
(unsigned long long) wc[(size_t) i].wr_id);
s.stop.store(true);
return;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

RDMA write failure kills the progress thread and parks the GPU forever · confirmed

In the default configuration (LUCE_CLUSTER_FAST_REDUCE_KERNEL_FLAGS unset), the GPU waits for peer data with hipStreamWaitValue32, which has no timeout. When a send fails — either ibv_post_send returning an error at line 722 or a non-success completion here — the progress thread just sets stop and returns. It does not set failed (so submit() keeps returning true and enqueueing more stream waits), does not release any flags, and does not exit. The stall watchdog that handles the peer-gone case lives inside this same progress thread, so once it returns nothing ever fires: the next reduction's hipStreamWaitValue32 blocks the GPU queue indefinitely, which is exactly the "parked queue costs a GPU reset" outcome the stall path at lines 647-668 was built to avoid. The comment here ("the waiting kernels time out into a counted, visible failure") only describes the kernel-flag form, which is not the default. Handle it like the stall path: set failed, release the visible/hybrid flags for every queued reduction so the GPU drains, and _Exit (or otherwise fail the run loudly) instead of silently returning.

_Checked (90%): Confirmed in the diff: at fast_reduce.cpp:722-725 and 735-742 a send failure or bad completion only stores stop and returns from the progress thread, never setting failed, never calling release_one, and never exiting. submit() (line 764) checks only failed, so it keeps returning true and enqueueing hipStreamWaitValue32 waits (lines 846, 882) that no one will ever satisfy in the default stream-flag mode, and the stall watchdog that would have released flags and Exit'd lives inside the same returned thread (lines 647-668) — the GPU queue parks indefinitely, exactly the outcome the stall path avoids.

Comment on lines +2342 to +2346
ggml_tensor * out_b_w = head_split
? ggml_view_2d(ctx, L.attn_output_b, (int64_t) w.n_lora_o * n_groups,
L.attn_output_b->ne[1], L.attn_output_b->nb[1],
ggml_row_size(L.attn_output_b->type, (int64_t) group_first * w.n_lora_o))
: L.attn_output_b;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Head-split wo_b view applies the global group offset to an already-sliced weight · confirmed

When the cluster rank loads only its own output groups (w.cluster_heads_local > 0, the TargetLoadPlan-sliced case this function explicitly handles for out_a_3d), L.attn_output_b already holds just this rank's n_groups columns (stored_groups == n_groups). The out_b_w view is taken whenever head_split is true, with a byte offset of group_first * n_lora_o rows, where group_first is the global first group of the rank (e.g. half the groups on rank 1). Applied to the sliced tensor that offset lands past the end of the buffer, so the second stage of the output projection reads out-of-bounds/wrong weight rows on every attention layer of rank 1 whenever weights are sliced (the LUCE_CLUSTER_SLICE_WEIGHTS default). Mirror the out_a_3d handling: only view when the tensor still holds all groups.

Checked (95%): The loader slices attn_output_b by columns to only the rank's groups (deepseek4_loader.cpp:2320-2324, ne[0] becomes n_lora_oslice_groups), and the same function's assertion confirms stored_groups == n_groups in this case, yet out_b_w at deepseek4_graph.cpp:2342-2346 is taken whenever head_split is true and adds the global group_firstn_lora_o byte offset — on rank 1 that offset equals the sliced tensor's full width, reading past the buffer every attention layer under LUCE_CLUSTER_SLICE_WEIGHTS.

Suggested change
ggml_tensor * out_b_w = head_split
? ggml_view_2d(ctx, L.attn_output_b, (int64_t) w.n_lora_o * n_groups,
L.attn_output_b->ne[1], L.attn_output_b->nb[1],
ggml_row_size(L.attn_output_b->type, (int64_t) group_first * w.n_lora_o))
: L.attn_output_b;
ggml_tensor * out_b_w = head_split && stored_groups != n_groups
? ggml_view_2d(ctx, L.attn_output_b, (int64_t) w.n_lora_o * n_groups,
L.attn_output_b->ne[1], L.attn_output_b->nb[1],
ggml_row_size(L.attn_output_b->type, (int64_t) group_first * w.n_lora_o))
: L.attn_output_b;

Comment on lines +850 to +856
atomicAdd(barrier, 1ull);
while (atomicAdd(barrier, 0ull) < barrier_target) {
#if defined(GGML_USE_HIP)
__builtin_amdgcn_s_sleep(1);
#endif
}
__threadfence();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

HC boundary barrier breaks on CUDA graph replay (target baked at capture, device counter keeps growing) · confirmed

ggml_cuda_ds4_hc_boundary implements the cross-block barrier as a running count: the host increments ctx.ds4_hc_barrier_blocks per launch and passes the cumulative value as barrier_target, while each block does atomicAdd(barrier, 1) and spins until the device counter reaches it (also in the split kernel at lines 1063-1069). This invariant — host count == device count — only holds when every kernel execution is preceded by the host incrementing the counter.

These fused launches happen inside ggml_cuda_graph_evaluate_and_capture (called from the graph evaluation loop in ggml-cuda.cu), so they are recorded into CUDA graphs. On capture the kernels do not execute, but the host counter advances; the recorded kernels carry the capture-time target by value. The first replay is consistent, but from the second replay onward the device counter already exceeds the baked target before the launch starts (previous replays added their blocks without any host increment), so while (atomicAdd(barrier, 0ull) < barrier_target) never blocks. The barrier exists because out may alias R/block_out (per the comment at lines 812-814): without the wait, one block can write out while another token's block still reads those buffers, silently corrupting results on the normal decode path, which replays captured graphs every step.

The counter/target scheme needs to be made replay-safe — e.g. reset the device counter (and host count) at the start of each evaluation/replay, or synchronize without depending on absolute cumulative targets (per-launch sense-reversing epoch, or a zeroed scratch counter with the launch writing its own target into device memory at replay time).

Checked (90%): ggml_cuda_ds4_hc_boundary (ds4-hc.cu:1121-1127) keeps a host-side running count and passes the cumulative value as a by-value kernel argument, while the kernels spin until the device counter reaches it (ds4-hc.cu:851, 1065). The launch is made from the node loop inside ggml_cuda_graph_evaluate_and_capture (ggml-cuda.cu:6078), which per line 5909 only walks nodes when !use_cuda_graph || cuda_graph_update_required — i.e. during capture the host counter advances (kernels only recorded with the capture-time target) and on later replays the host loop is skipped entirely, so the device counter already meets or exceeds the baked target and the cross-block wait never blocks, defeating the alias-protection the barrier exists for (comment at ds4-hc.cu:810-814). Nothing resets ds4_hc_barrier_blocks or the device counter between replays.

Comment on lines +4429 to +4446
struct ggml_tensor * ggml_soft_max_ext_sink_col_mask(
struct ggml_context * ctx,
struct ggml_tensor * a,
struct ggml_tensor * mask,
struct ggml_tensor * sinks,
float scale) {
struct ggml_tensor * result = ggml_soft_max_ext_sink_col(ctx, a, sinks, scale);
if (mask) {
GGML_ASSERT(mask->type == GGML_TYPE_F16 || mask->type == GGML_TYPE_F32);
GGML_ASSERT(mask->nb[0] == ggml_type_size(mask->type));
GGML_ASSERT(mask->ne[0] == a->ne[0]);
GGML_ASSERT(mask->ne[1] >= a->ne[1]);
GGML_ASSERT(a->ne[2] % mask->ne[2] == 0);
GGML_ASSERT(a->ne[3] % mask->ne[3] == 0);
result->src[1] = mask;
}
return result;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ggml_soft_max_ext_sink_col_mask allows a mask the CPU backend refuses (GGML_ASSERT abort) · likely

The new public op builds a sink-column softmax with result->src[1] = mask, and the CUDA softmax kernel (softmax.cu, part 7) was extended to handle that combination. The CPU kernel in ggml-cpu/ops.cpp was not: in sink_col mode it has GGML_ASSERT(sk && !src1) (ops.cpp:5369), which rejects exactly the mask+sink_col combination this function creates. GGML_ASSERT aborts the process, and the CPU backend's supports_op still advertises SOFT_MAX unconditionally, so any graph that lands this op on the CPU backend — a CPU-fallback split in the scheduler, or a CPU run of the op outside the HIP/CUDA test path — crashes at compute time instead of being rejected at planning. Either teach the CPU kernel the masked sink-col path (scale, add mask row, append sink column, softmax over ne00+1) or make the CPU backend reject the op when sink_col is set and src[1] is non-NULL so the scheduler places it elsewhere.

Checked (75%): ggml.c:4443 sets result->src[1] = mask on a sink_col SOFT_MAX, and the CPU kernel asserts GGML_ASSERT(sk && !src1) in sink_col mode (ggml-cpu/ops.cpp:5369), while ggml_backend_cpu_device_supports_op has no SOFT_MAX case and so returns true by default (ggml-cpu/ggml-cpu.cpp:499-500) — unlike SSM_CONV/FLASH_ATTN_EXT, which are explicitly guarded. So the op is admitted by the CPU backend and aborts at compute time; the only assumption left is that a graph actually places this op on the CPU backend (scheduler fallback or CPU run), which I could not directly confirm.

mrciffa and others added 2 commits October 9, 2026 15:01
Conflicts with #827 (Qwen3.8-Flash-Next split), resolved:
- ggml-backend.cpp: a split input copy stays persistent for the parallel
  scheduler's copy events (main) and for dedicated copies (this branch).
- ggml-cuda.cu: the DS4 fusions keep their place before main's qwen4exp
  fusions, which now run on every device of the graph.
- adaptive_spec_width.h: main's shared controller replaces this branch's
  change, as planned: DS4 seeds it with CostSeed::kShape, and this branch's
  tests of the old change go with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…/up pair

The generic gate/GLU fusion forms a one-column gate/up pair with SwiGLU-DS4
(a decode step without a drafter, or the last tokens of a reply), and the
RDNA4 Q8_0 dispatch took it to the pair launcher, which had no one-column
case: "unreachable q8_0 rdna4 glu width" on a two-box rank. One column is now
a case of the same launcher, so a decode step runs the kernel a verify's
columns run and the two stay batch-invariant. test_ds4_fused_ops_cuda
checks the pair at one column too, bit for bit against the products kept
apart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
mrciffa and others added 26 commits October 9, 2026 23:44
…ough

A cluster rank kept prefill bands whole unless LUCE_CLUSTER_PREFILL_PIPELINE
was set, because at 512-row bands the pipeline lost. With bands of 1536 rows
or more, one part's cold FFN running beside the next part's attention wins:
two boxes, a 2K prompt in one band, 649-669 -> 710-727 tok/s (2 runs each).
The pipeline is now on whenever every band of the pass is that long; the
switch still forces it either way. Every rank sees the same bands, so the
decision needs no extra agreement.

Each prefill chunk reads all of a rank's experts once, so the rank with the
least free memory sets the cluster's chunk for every prompt. Rank 0 now names
that rank when it holds the chunk below the pipeline's length (a 2K prompt
then runs as two chunks: 558-598 tok/s): a smaller expert budget there leaves
room for longer chunks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ernels too

The ds41-gorgon profile sends only the cold owner's experts through the
grouped MUL_MAT_ID prefill graph (LUCE_DS4_GROUPED_HYBRID_PREFILL=1); the
R9700's hot experts took the per-expert path. On a cluster rank the R9700
does about 70% of the routed work in prefill, so cluster mode now sets 2
(both owners). Two boxes, 2K prompt in one band: the hot owner's prefill
time 776 -> 582-599 ms, prefill 688 -> 711-731 tok/s; decode unchanged
(code 85.5-88.1, 2K 50.8-51.5). The 2K text differs at near-ties, as any
change of the prefill kernels does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The F16 WMMA routed-expert GEMM for large mul_mat_id batches (ROCmFP2/FP3
MIX and plain ROCmFP2) ran on RDNA3.5 only; an R9700 (gfx1201) took MMQ.
gfx12 has the same 16x16x16 F16 WMMA with another fragment layout (a lane
holds 8 of the K values and C rows 8*hi + e instead of all 16 and 2*e + hi),
so the kernel now builds for both and picks the layout per arch.

Two boxes, DS4.1, 2K prompt: the R9700's expert time in prefill 600 -> 463
ms, prefill 715-733 -> 744-756 tok/s; decode unchanged. The kernel decodes
the same F16 weights, so results stay within F16 rounding of MMQ:
test_rocmfp_mix_gateup_glu passes on gfx1201 through path=mix_wmma, and
test_deepseek4_mmid_grouped_cuda on both GPUs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LUCE_CLUSTER_PREFILL_Q8=1 sends prefill-sized partials as blocks of 32
int8 values with one f32 scale, all-gathered (1.125 bytes a value against
the bf16 all-reduce's 2 per direction), and every rank sums the ranks'
dequantized values in rank order, so the result is the same on all ranks.
On a link held to about 1.8 GB/s by the NIC's x2 slot, one exchange of a
1K-row band took 5.95 ms in bf16 and 4.36 ms in 8 bits (waits for the
other rank included). Two boxes, 2K prompt: 744-756 -> 789-807 tok/s.

Off by default: it rounds every prefill partial to 8 bits, so outputs change
at near-ties; it stays opt-in until a quality check against bf16.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The quality check the 8-bit exchange waited for, two boxes, harness suites,
greedy: Math500 9/10 (bf16: 8/10), GSM8K 9/10 (bf16: 10/10), one answer
flipping each way, 18/20 both. Small, but no sign that rounding the prefill
partials to 8-bit blocks costs answers, and 2K prefill goes 744-756 ->
789-807 tok/s. LUCE_CLUSTER_PREFILL_Q8=0 returns to the bf16 all-reduce.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The HC pre-mix of a prefill band is RMS_NORM of the [n_hc * n_embd, T]
residual read only by a product against 24 F16 rows. It ran as a norm, an
F16 copy of the normed rows and a GEMM 24 rows tall: three passes over a
band's ~83 MB residual for 1 GFLOP. ggml_cuda_ds4_rms_norm_mix computes
the 24 dot products and the sum of squares in one pass, four tokens a block,
F32 throughout. Only for bands of 64 tokens or more: decode and verify keep
their batch-invariant products.

Two boxes, DS4.1, 2K prompt: the HC pre-mix 235 -> 106 ms per prefill.
test_ds4_fused_ops_cuda checks it against a double-precision reference on
gfx1201 and gfx1151 (the unfused path rounds its input to 16 bits on some
GPUs, so it is no reference for an F32 kernel).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A layer-major prefill band holds every Engram layer's rows, and rank 0
broadcast all of them at each Engram layer the band reached: twice per band
on V4.1 (layers 1 and 14), about 100 MB a time for a 1K-row band. The band
now remembers that its rows reached every rank. The prefill timing line
counts the broadcast (engram_read) and the pipelined joins (hc_post).

Two boxes, 2K prompt, with the one-pass HC mix: prefill 788-809 ->
877-902 tok/s; decode unchanged (code 86.8-88.9, 2K 53.8-53.9).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main's shared thinking budget (#816) replaces the DSpark budget hook this
branch carried, so the cluster paths now drive the one ThinkingBudget:

- AR decode: rank 0 applies the budget and sends each token with its
  flags (the budget flag is forced_close()); workers adopt the token and
  never run a controller of their own.
- DSpark seed: rank 0 applies the budget before the seed exchange.
- DSpark blocks: rank 0's accept and bonus still cross before the emit
  loop. A cluster rank that drafts has a settled budget and no local
  cancel check (rank 0 stops through an empty draft block); the decoder
  refuses anything else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rank 0 picks each request's decode mode for every rank and sends it in the
request message: sampled and budgeted requests decode AR. The workers
followed it, but rank 0 handed the original request to its own backend,
which speculates on both now that main runs sampling and the thinking
budget inside DSpark. The ranks then sat in two different decode loops.
Rank 0 now decodes the same forced-AR request the workers rebuild, through
one rule both sides share (request_decodes_ar).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ents

Both were off by default and measured no better: LUCE_DS4_STAGE_BATCH (the
step's pinned uploads as one copy kernel) gained nothing, and
LUCE_DS4_VERIFY_PREBUILD (building the next shapes while a verify runs)
filled the slot cache and made the two-box verify 42 -> 82 ms. The pinned
stage loses its batch mode and ds4_fused_graph_compute is main's again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each rank now computes half of the shared expert's intermediate axis and
sends its partial in the routed all-reduce, the configuration the measured
numbers and the documented command use. Replicate computed the whole
expert on both ranks: two boxes, code 89.7-91.9 -> 93.2-97.4 tok/s, 2K
prose 52.5 -> 56.3-56.5, at 44.2 -> 41.7 ms a code step. The rank0 mode,
accepted by the parser but refused at load, is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
DSpark's width controller started every request from one set of step
costs, whatever the context. A step costs more as the context grows (on
two boxes q5 is 43 ms on a short prompt and 47-53 ms after a 2K one), so a
request that starts from another context's costs picks its widths from the
wrong prices until its own steps outweigh them. The costs now live in the
shared SpecWidthCostMemory, one set per context range (under 2K, 2-8K,
8-32K, longer), as Qwen's MTP keeps them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… path

- The progress thread sized each RDMA write as bf16 whenever _BF16 and _ZC
  were set, while submit packs bf16 only on the zero-copy path (two ranks,
  stream flags). With _KERNEL_FLAGS=1 or more than two ranks it sent half
  of an f32 row. fr_bf16_wire() is now the one rule: the progress thread,
  submit and the hybrid combine all follow it.
- _LEAN drops the slot-reuse wait and the done write on every stream-flag
  path. Submit kept its wait unless zero-copy was also on, so with _ZC=0 a
  submit landing on a slot a hybrid exchange last used waited for a done
  flag nothing writes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A worker lost mid-request, or a failed request/RequestEnd/report/backend
  op broadcast, called fail_cluster, which exits on the spot: the client
  got a reset connection instead of the error. These request-thread paths
  now use request_fatal_exit, which exits after a grace period, as the
  watchdog already did. fail_cluster stays for start-up failures.
- The head hook no longer samples when the caller passes no token: every
  caller samples in its decode loop with the request's sampler and RNG,
  and the unused fallback seeded its RNG once per process.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The verify step read the Engram keys on a worker whenever the cluster was
  attached, but only a graph built for the hybrid cluster path has the
  broadcast whose callback uploads them. A cluster step without the hybrid
  graph would have read stale keys; it now reads them in line.
- The head-split wo_b column view is taken only from unsliced storage, like
  wo_a's group view (a sliced rank's head range starts at 0, so the view
  was a no-op there).
- The hybrid exchange counters say what they assume: every rank runs every
  exchange, and a rank-local step failure ends both ranks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lumn

- moe_combine_masked skips a route masked to -1 instead of reading the
  validity row before its tensor.
- The split HC boundary (LUCE_DS4_HC_BOUNDARY_SPLIT) runs only when the
  device holds its whole grid at once (occupancy x CUs): its blocks wait
  for each other at the output barrier, so a wider grid would never end.
  The unsplit grid stays at most MMVF_MAX_BATCH_SIZE blocks.
- The sink-column softmax rounds the scaled score before the mask add on
  CUDA too (__fmul_rn; nvcc ignores the clang pragma).
- The CPU softmax runs the masked sink-column form it used to assert on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It kept a per-device stream and pinned host word in statics that were
never freed, and its only caller is an opt-in switch that goes away.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CONT-into-MMVQ fusion ran MMVQ directly and the rope q8_1 producer
assumed MMVQ, each with its own copy of a partial eligibility check.
ggml_cuda_mul_mat sends some of those products elsewhere: RDNA4 Q4_K at
two or more columns over 2048 or more rows goes to MMQ, MXFP8 to its own
matvec, and the qwen4exp BF16 shadow to cuBLAS. The fused graph then ran
another kernel than the unfused one (or MMVQ on MXFP8, which it has no
kernel for).

ggml_cuda_mul_mat now takes its MMVQ eligibility, the RDNA4 Q4_K MMQ
preference and the BF16 shadow test from shared helpers, and both
fusions decline unless ggml_cuda_mul_mat_takes_mmvq agrees.

Also drops the kill switches this PR added for its default paths:
LUCE_CUDA_Q4K_NCOL_MMQ, LUCE_DS4_FUSE_HC_BOUNDARY, LUCE_MMID_PREP_MEMO
and LUCE_CUDA_MMVQ_Q8_RDNA4_GLU. The tests compare fused against
unfused by graph shape, not by these switches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The skinny-K kernel accumulates in F32, so it changes the bits of the
single-column F16 products it takes. It had no vendor guard and changed
CUDA results too. It now runs only on RDNA4 under HIP, the GPU it was
tuned on; CUDA and other AMD archs keep the generic MMVF kernel. Its
LUCE_CUDA_MMVF_SKINNY switch and benchmark-log comment are gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…builder

The per-verify input timers and the vocab split debug line were research
aids. The vocab split is now a builder argument instead of a field kept
across Extra::reset(), and the split head logs its rows once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pinned step I/O, the async Engram read, the lane batch compressor and
selection, the masked MoE combine and the drafter's device context window
are on wherever they run, so their switches and off paths go. The shared
expert slice view, sliced dense weights and the prefill device join are
cluster-only and on in cluster mode. The worker keepalive was never on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The attribution to the upstream cluster project stays once in
deepseek4_cluster.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Remove ds4_cluster_allreduce_layer and ds4_cluster_allreduce_fn, which
had no callers, and the LUCE_CLUSTER_TRACE_COLL debug print. The plain
and exact in-graph broadcasts share one callback body and one node
builder. Name the prefill partial threshold. Comments lose benchmark
numbers, plan labels, the protocol changelog and a stale note about
mixed qtypes; one attribution stays in deepseek4_cluster.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing outside the unit tests ever called hash_probe, so the flag only
travelled in Welcome and was printed. Remove the flag, its validation,
the Welcome field, HashProbeMsg, hash_floats/hash_tokens, the mismatch
counters and the head hooks' unused config. Welcome changes on the
wire, so the protocol version goes to 3. The gather timeout test now
calls the control channel directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tale switches

The head backend's introspection accessors, its fault reason and its
exit-on-abort toggle had no readers or callers, and common/cluster_view.h
declared types nothing used. Cluster ranks always slice their dense weights
and keep the device join in prefill now, so LUCE_CLUSTER_SLICE_WEIGHTS,
LUCE_DS4_SHEXP_SLICE_VIEW and LUCE_CLUSTER_PREFILL_DEVICE_JOIN go from the
cluster defaults and the code. Plan labels and a reference to a launcher
script that does not exist are gone from the comments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cluster mode always ran the zero-copy, bf16-wire, lean exchange with
in-kernel publishes, one hybrid export and a DMA import. The other paths
behind LUCE_CLUSTER_FAST_REDUCE_ZC, _BF16, _LEAN, _KPUB, _KERNEL_FLAGS,
LUCE_CLUSTER_HYBRID_ONE_EXPORT and LUCE_CLUSTER_HYBRID_IMPORT_KERNEL were
never taken by default, so they go with their switches: the copy-engine
add and its scratch, the kernel-form flags and their words, the done flags
and the second hybrid export. The fast reduce now refuses any size but two,
so other sizes keep RCCL as documented. The MTU and stall settings are read
once at init instead of in function-local statics, and the accessors
nothing read are gone. The default path's arithmetic is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@davide221
davide221 merged commit 5f402c6 into main Oct 12, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant