Skip to content

fix(server): disable GGML_CUDA_GRAPHS to avoid ggml CUDA graph key collision crash#460

Open
zatchbell1311-wq wants to merge 6 commits into
pie-project:mainfrom
zatchbell1311-wq:fix/cuda-graph-key-collision
Open

fix(server): disable GGML_CUDA_GRAPHS to avoid ggml CUDA graph key collision crash#460
zatchbell1311-wq wants to merge 6 commits into
pie-project:mainfrom
zatchbell1311-wq:fix/cuda-graph-key-collision

Conversation

@zatchbell1311-wq

@zatchbell1311-wq zatchbell1311-wq commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Problem

pie serve crashes reliably on the CUDA portable driver backend after 1-2 successful requests to the same inferlet, or under moderate concurrency.
The crash signature varies (different GGML_ASSERT failure each time —ggml.c:6965 graph-size overflow, ggml.c:3637 tensor shape mismatch),which initially made this look like several unrelated bugs.

Root cause

ggml's CUDA graph cache (ggml_cuda_graph_get_key, ggml-cuda.cu) keys cached graphs by the raw memory address of the graph's first node
(cgraph->nodes[0]):

static const void * ggml_cuda_graph_get_key(ggml_cgraph * cgraph) {
    return cgraph->nodes[0];
}

Pie's portable driver allocates a fresh ggml context arena per request. When a new request's first-node address happens to land at the same address as a previous (now-freed) request's, the cache treats the new — and possibly differently-shaped — graph as identical to the cached one, skips rebuilding it, and replays a stale captured CUDA graph against mismatched tensor
shapes. This is a known upstream issue: ggml-org/llama.cpp#20315, ggml-org/llama.cpp#19708. The maintainers' sanctioned workaround is disabling CUDA graph capture entirely via GGML_CUDA_GRAPHS=OFF.

Pie's server/build.rs was explicitly forcing GGML_CUDA_GRAPHS=ON when configuring the portable driver's CUDA CMake build, which is what exposes this bug.

Fix

Flip GGML_CUDA_GRAPHS from "ON" to "OFF" in server/build.rs.

Verification

Before this fix: 6 identical repeated requests to the same inferlet (self-consistency, 8 concurrent proposal contexts) crashed the server on request 2, every time, reproducibly.

After this fix: 6 repeated self-consistency requests and 3 repeated graph-of-thought requests (14 generation calls each) all completed cleanly with consistent, correct outputs. Zero crashes.

Tradeoff

Disabling CUDA graphs removes a ~10-15% raw decode throughput optimization (per upstream benchmarks) in exchange for correctness. Given the backend is otherwise unusable for any sustained workload, this is a clear net improvement.

Possible follow-up

A more surgical fix — incorporating cgraph->n_nodes or another shape-derived value into the cache key (similar to the graph_recompute approach suggested in ggml-org/llama.cpp#20315) — could restore CUDA graph reuse performance for genuinely-repeated shapes while preserving correctness. Happy to explore this as a separate PR if there's interest; opted for the minimal, verified-safe fix here given the crash makes the backend fully unusable as-is.

Summary by CodeRabbit

  • Bug Fixes
    • Improved stability on memory-constrained systems by reducing the graph size limit, helping small-model runs avoid allocation failures.
  • Performance
    • Reworked hierarchical processing in the Graph-of-Thought example to use fully batched aggregation, improving throughput and making execution more predictable.
  • Build & Configuration
    • Updated portable CUDA build configuration to better control CUDA graph behavior.
    • Added a build option to enable/disable FlashInfer/CUTLASS MoE kernels during CUDA builds.

zatchbell1311-wq and others added 5 commits June 26, 2026 15:29
…llision crash

Pie's portable driver allocates a fresh ggml context arena per request.
ggml's CUDA graph cache keys captured graphs by the raw pointer address
of the graph's first node (cgraph->nodes[0]) — when a new request's
first-node address happens to coincide with a previous (now-freed)
request's address, the cache incorrectly treats the new, differently-
shaped graph as identical to the cached one, skips rebuilding it, and
replays a stale CUDA graph against mismatched tensor shapes.

This crashed pie-server reliably (via different GGML_ASSERT failures
depending on which shapes collided) after 1-2 successful requests to
the same inferlet, or under moderate concurrency.

This is a known upstream ggml/llama.cpp issue (ggml-org/llama.cpp
#20315, #19708) — the maintainers' sanctioned workaround is disabling
CUDA graph capture via GGML_CUDA_GRAPHS=OFF, which this commit forces
at the Pie build.rs level for the portable driver's CUDA backend.

Verified: before this fix, 6 identical repeated requests to the same
inferlet crashed the server on request 2 every time. After, 6 repeated
requests (self-consistency, 4-8 concurrent proposal contexts) and 3
repeated requests (graph-of-thought, 14 generation calls each) all
completed cleanly with consistent correct outputs and no crashes.

Tradeoff: disables a ~10-15% raw decode throughput optimization in
exchange for correctness. Given crashes make the backend unusable for
any sustained workload, this is a clear net win; a more surgical fix
(incorporating cgraph->n_nodes or another shape-derived value into the
cache key, matching the graph_recompute fix suggested upstream) could
restore graph-reuse performance for genuinely-repeated shapes as a
follow-up.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR lowers the portable driver's graph node budget, rewrites the graph-of-thought example to batch proposal and aggregation work, disables GGML_CUDA_GRAPHS in the portable CUDA build, and adds an opt-in CMake switch for FlashInfer/CUTLASS MoE kernels.

Changes

Driver Graph Node Budget

Layer / File(s) Summary
Lower GRAPH_MAX_NODES
driver/portable/src/graph_common.hpp
Reduces the per-call ggml graph node budget from 1ull << 19 to 1ull << 14.

Graph-of-Thought Batched Aggregation

Layer / File(s) Summary
Batching imports and docs
inferlets/graph-of-thought/src/lib.rs
Removes FuturesUnordered/StreamExt imports and adds futures::future for join_all-based batching.
Batched aggregation helper and stages
inferlets/graph-of-thought/src/lib.rs
Adds launch_aggregation_pairs, runs proposal generation concurrently, and batches first- and second-level aggregation with future::join_all.
Call site update
inferlets/graph-of-thought/src/lib.rs
Updates main to pass the model reference into run_hierarchical_aggregation.

Server CUDA Graphs Build Flag

Layer / File(s) Summary
Disable GGML_CUDA_GRAPHS
server/build.rs
Switches the GGML_CUDA_GRAPHS CMake definition from "ON" to "OFF".

CUDA MoE Build Gate

Layer / File(s) Summary
Gate MoE kernel compilation
driver/cuda/CMakeLists.txt
Adds PIE_CUDA_BUILD_MOE, conditionally builds the FlashInfer/CUTLASS MoE object library, and falls back to a stub source when disabled.

Sequence Diagram(s)

sequenceDiagram
    participant Main
    participant Model
    participant ProposalTasks as Proposal futures
    participant AggTasks as Aggregation futures

    Main->>Model: fork base_context per proposal
    Model-->>ProposalTasks: generate proposals concurrently
    ProposalTasks-->>Main: join_all collects proposals

    Main->>AggTasks: launch first-level aggregation pairs
    AggTasks-->>Main: join_all collects aggregated texts and contexts

    Main->>AggTasks: launch second-level aggregation pairs
    AggTasks-->>Main: join_all collects final texts
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary fix: disabling GGML_CUDA_GRAPHS to prevent the portable CUDA crash.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
server/build.rs (1)

130-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale comment contradicts the new OFF setting.

The comment directly above (lines 135-139) explains the rationale for turning CUDA graphs ON ("Enable explicitly" for perf reasons), but Line 140 now sets GGML_CUDA_GRAPHS to "OFF". This leaves misleading documentation in place that contradicts the actual behavior and the PR's stated fix rationale (stale graph-cache collisions causing crashes). Update the comment to explain the new rationale (crash avoidance) instead of the old perf-enabling rationale.

📝 Proposed comment fix
-            // ggml's CUDA-graph capture/replay is OFF by default at the ggml-
-            // subproject level; llama.cpp's parent CMakeLists.txt flips it
-            // ON. Embedding ggml directly (as we do here) misses that override
-            // and ends up issuing ~30 kernel launches per decode step, which
-            // costs ~1 ms vs ~0.3 ms with capture. Enable explicitly.
+            // ggml-cuda's CUDA graph cache keys captured graphs by the raw
+            // address of the first node. Since Pie allocates a fresh ggml
+            // context arena per request, a new graph can collide with a
+            // previously freed one and replay against mismatched tensor
+            // shapes, tripping GGML_ASSERT failures under repeated/
+            // concurrent requests. Disable explicitly until upstream
+            // resolves the caching issue; costs ~1 ms vs ~0.3 ms per
+            // decode step (more kernel launches instead of a captured
+            // graph replay).
             .define("GGML_CUDA_GRAPHS", "OFF")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/build.rs` around lines 130 - 140, The comment above the
GGML_CUDA_GRAPHS setting is stale and still explains enabling CUDA graphs for
performance, which now conflicts with the actual OFF value. Update the inline
explanation in the cuda_enabled block within build.rs to match the new behavior
and rationale (crash avoidance from stale graph-cache collisions), keeping the
comment aligned with the GGML_CUDA_GRAPHS configuration.
inferlets/graph-of-thought/src/lib.rs (2)

130-147: 🚀 Performance & Scalability | 🔵 Trivial

Bound concurrency for large proposal sets.

future::join_all launches every proposal and then every first-level aggregation pair at once, each holding its own Context and KV pages simultaneously. This is the intended batching win, but with a large proposal_tokens it can spike KV/page usage. Consider a chunked launch (e.g. bounded concurrency) if inputs can grow, so peak context/KV allocation stays within budget.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/graph-of-thought/src/lib.rs` around lines 130 - 147, The unbounded
use of future::join_all in the first and second aggregation stages can launch
too many proposal and Context allocations at once, spiking KV/page usage for
large inputs. Update the launch flow in lib.rs around launch_aggregation_pairs
and the two join_all calls to use bounded concurrency or chunked batching so
only a limited number of proposal pairs are active at a time while preserving
the batching behavior.

49-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the dead AGGREGATE_PROMPT constant and its stale comment.

The constant is empty and explicitly marked unused, and the comment points to format_aggregate_prompt, which no longer exists (the prompt is now inlined in launch_aggregation_pairs). This is misleading dead code.

♻️ Proposed cleanup
-const AGGREGATE_PROMPT: &str = "";  // unused - see format_aggregate_prompt below
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/graph-of-thought/src/lib.rs` at line 49, Remove the dead
AGGREGATE_PROMPT constant and its stale inline comment from lib.rs. The prompt
is now inlined in launch_aggregation_pairs, so delete the unused empty constant
and make sure any references to the old format_aggregate_prompt path are also
cleaned up so the module only contains the active prompt source.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@inferlets/graph-of-thought/src/lib.rs`:
- Around line 65-89: The pairing loop in launch_aggregation_pairs is dropping
the leftover proposal when iterating with iter.next(), which can leave
final_results empty on the next pass. Preserve the unpaired item by carrying it
forward into the next aggregation level instead of discarding it, and make sure
the logic around the aggregation task creation still handles the existing
Context/new prompt flow for paired items.

---

Nitpick comments:
In `@inferlets/graph-of-thought/src/lib.rs`:
- Around line 130-147: The unbounded use of future::join_all in the first and
second aggregation stages can launch too many proposal and Context allocations
at once, spiking KV/page usage for large inputs. Update the launch flow in
lib.rs around launch_aggregation_pairs and the two join_all calls to use bounded
concurrency or chunked batching so only a limited number of proposal pairs are
active at a time while preserving the batching behavior.
- Line 49: Remove the dead AGGREGATE_PROMPT constant and its stale inline
comment from lib.rs. The prompt is now inlined in launch_aggregation_pairs, so
delete the unused empty constant and make sure any references to the old
format_aggregate_prompt path are also cleaned up so the module only contains the
active prompt source.

In `@server/build.rs`:
- Around line 130-140: The comment above the GGML_CUDA_GRAPHS setting is stale
and still explains enabling CUDA graphs for performance, which now conflicts
with the actual OFF value. Update the inline explanation in the cuda_enabled
block within build.rs to match the new behavior and rationale (crash avoidance
from stale graph-cache collisions), keeping the comment aligned with the
GGML_CUDA_GRAPHS configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cd1b10e0-796f-4701-bdfa-495991c4bf10

📥 Commits

Reviewing files that changed from the base of the PR and between a6d1e2d and f712de7.

📒 Files selected for processing (3)
  • driver/portable/src/graph_common.hpp
  • inferlets/graph-of-thought/src/lib.rs
  • server/build.rs

Comment on lines +65 to +89
while let Some((text_a, _ctx_a)) = iter.next() {
if let Some((text_b, _ctx_b)) = iter.next() {
// Both proposal contexts are dropped here — all their KV pages
// are freed immediately. The aggregation uses a fresh context
// with only the question + both proposals, so the model always
// knows what problem it is solving.
let mut ctx = Context::new(model)?;
let prompt = format!(
"You are solving this math problem: {}\n\nHere are two proposed solutions:\n\nSolution A:\n{}\n\nSolution B:\n{}\n\nPick the correct answer. Show your reasoning briefly. End with: ANSWER: <number>",
question.clone(), text_a, text_b
);
ctx.system(SYSTEM_PROMPT);
ctx.user(&prompt);
ctx.cue();
tasks.push(async move {
let text = ctx
.generate(Sampler::TopP { temperature: 0.6, p: 0.95 })
.max_tokens(aggregation_tokens)
.collect_text()
.await?;
Ok((text, ctx))
});
}
// Odd item out: context dropped, KV pages freed.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the default proposal count to gauge how often N<4 is hit.
rg -nP 'fn default_proposal_tokens' -A6 inferlets/graph-of-thought/src/lib.rs

Repository: pie-project/pie

Length of output: 541


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the aggregation logic and its caller.
nl -ba inferlets/graph-of-thought/src/lib.rs | sed -n '1,220p'

echo
echo "---- call sites ----"
rg -n "run_hierarchical_aggregation|final_results|default_proposal_tokens" inferlets/graph-of-thought/src/lib.rs

Repository: pie-project/pie

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant slice with line numbers using available tools.
sed -n '1,220p' inferlets/graph-of-thought/src/lib.rs | cat -n

echo
echo "---- call sites ----"
rg -n "run_hierarchical_aggregation|final_results|default_proposal_tokens" inferlets/graph-of-thought/src/lib.rs

Repository: pie-project/pie

Length of output: 9088


Carry the unpaired item into the next aggregation level
With custom proposal_tokens lengths of 2 or 3, the leftover item is dropped in launch_aggregation_pairs, and the second pass can return an empty final_results, so main prints nothing. Forward the unpaired item instead of discarding it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/graph-of-thought/src/lib.rs` around lines 65 - 89, The pairing loop
in launch_aggregation_pairs is dropping the leftover proposal when iterating
with iter.next(), which can leave final_results empty on the next pass. Preserve
the unpaired item by carrying it forward into the next aggregation level instead
of discarding it, and make sure the logic around the aggregation task creation
still handles the existing Context/new prompt flow for paired items.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@driver/cuda/CMakeLists.txt`:
- Around line 435-438: The PIE_CUDA_BUILD_MOE=OFF branch in CMake adds a source
file that does not exist, causing default OFF builds to fail. Either add the
missing src/ops/flashinfer_moe_stub.cpp implementation with the three
ops::flashinfer_cutlass_moe_* stub symbols, or remove/update the
target_sources(pie_driver_cuda_lib ...) reference in driver/cuda/CMakeLists.txt
so it no longer points to a nonexistent file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 39ec1d44-da4a-4b11-b827-dee662dff609

📥 Commits

Reviewing files that changed from the base of the PR and between f712de7 and 03d6683.

📒 Files selected for processing (1)
  • driver/cuda/CMakeLists.txt

Comment on lines +435 to +438
else()
message(STATUS "PIE_CUDA_BUILD_MOE=OFF: skipping FlashInfer/CUTLASS MoE kernels (Nemotron-H only)")
target_sources(pie_driver_cuda_lib PRIVATE src/ops/flashinfer_moe_stub.cpp)
endif()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify stub exists and implements all three interface functions
fd flashinfer_moe_stub.cpp
cat driver/cuda/src/ops/flashinfer_moe_stub.cpp

Repository: pie-project/pie

Length of output: 229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== files =='
git ls-files | rg 'flashinfer_moe|nemotron_h_forward|CMakeLists.txt$' || true

echo
echo '== search symbols =='
rg -n 'flashinfer_cutlass_moe_enabled|flashinfer_cutlass_moe_bf16_relu2|flashinfer_cutlass_moe_workspace_bytes|flashinfer_moe_stub' driver . || true

echo
echo '== relevant CMake excerpt =='
sed -n '420,450p' driver/cuda/CMakeLists.txt

Repository: pie-project/pie

Length of output: 4843


Add the missing src/ops/flashinfer_moe_stub.cpp file
driver/cuda/CMakeLists.txt wires in src/ops/flashinfer_moe_stub.cpp when PIE_CUDA_BUILD_MOE=OFF, but that file is not present. Default OFF builds will fail as soon as CMake tries to compile it. Either add the stub with the three ops::flashinfer_cutlass_moe_* symbols or stop referencing it here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@driver/cuda/CMakeLists.txt` around lines 435 - 438, The
PIE_CUDA_BUILD_MOE=OFF branch in CMake adds a source file that does not exist,
causing default OFF builds to fail. Either add the missing
src/ops/flashinfer_moe_stub.cpp implementation with the three
ops::flashinfer_cutlass_moe_* stub symbols, or remove/update the
target_sources(pie_driver_cuda_lib ...) reference in driver/cuda/CMakeLists.txt
so it no longer points to a nonexistent file.

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.

2 participants