Skip to content

fix(qwen4exp): validate prefix snapshot budget - #942

Open
gaurang2456 wants to merge 4 commits into
Luce-Org:mainfrom
gaurang2456:fix/894-qwen4exp-prefix-cache-budget
Open

gaurang2456 wants to merge 4 commits into
Luce-Org:mainfrom
gaurang2456:fix/894-qwen4exp-prefix-cache-budget

Conversation

@gaurang2456

@gaurang2456 gaurang2456 commented Nov 4, 2026 •

Copy link
Copy Markdown

Summary

Fixes #894, where Qwen4Exp prefix caching could silently become ineffective under concurrent workloads because the available snapshot budget was insufficient to hold a viable checkpoint.

Changes

  • Add a minimum viable snapshot allowance to qwen4exp_fit_chunk().
  • Calculate the minimum checkpoint size dynamically using qwen4exp_snapshot_bytes(), accounting for restore-point spacing and context length.
  • Reject snapshot reservations that cannot accommodate a complete checkpoint, allowing the remaining memory to be used for prefill chunk sizing.
  • Clamp automatic and explicitly configured concurrent prefix-cache budgets against the backend snapshot allowance.
  • Disable concurrent prefix caching when the engine does not support prefix storage or the effective snapshot budget is zero.
  • Preserve single-sequence prefix-cache behavior.
  • Add unit tests covering snapshot budget boundaries, concurrent budget clamping, and cache disabling.

Testing

  • Added unit tests in test_qwen4exp_chunk.cpp and test_server_unit.cpp.
  • git diff --check passes.
  • Native C++ compilation and unit tests were not run locally because the required C++ toolchain is unavailable in the development environment.

CI validation is needed for native compilation and test execution.

View guided diff

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/src/qwen4exp/qwen4exp_backend.cpp">

<violation number="1" location="server/src/qwen4exp/qwen4exp_backend.cpp:280">
P2: This minimum is applied to single-sequence snapshots too, so a short valid prefix capture is disabled whenever a 4096-token checkpoint does not fit. Preserve the single-sequence path by applying this minimum only to the concurrent budget, or derive its minimum from the actual single-sequence capture boundary.</violation>

<violation number="2" location="server/src/qwen4exp/qwen4exp_backend.cpp:280">
P2: `min_snapshot` undercounts the checkpoint size that `snapshot_fits()` actually enforces. When the spare memory falls between the raw cache-copy size and the full estimate, this leaves a nonzero allowance but every checkpoint capture fails; calculate the minimum with the same complete checkpoint estimate used by `snapshot_fits()`.</violation>
</file>

Reply to a comment to ask cubic a question or push back. It learns from your replies.

View guided diff | Re-trigger cubic

Comment thread server/src/server/scheduler.cpp Resolved
Comment thread server/src/server/prefix_cache.h Resolved
plan->layer_bytes = (size_t) slots * layer;
if (plan->required > available) return 0; // the context fit moves on without searching smaller chunks
}
const size_t min_snapshot = snapshot_budget

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This minimum is applied to single-sequence snapshots too, so a short valid prefix capture is disabled whenever a 4096-token checkpoint does not fit. Preserve the single-sequence path by applying this minimum only to the concurrent budget, or derive its minimum from the actual single-sequence capture boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At server/src/qwen4exp/qwen4exp_backend.cpp, line 280:

<comment>This minimum is applied to single-sequence snapshots too, so a short valid prefix capture is disabled whenever a 4096-token checkpoint does not fit. Preserve the single-sequence path by applying this minimum only to the concurrent budget, or derive its minimum from the actual single-sequence capture boundary.</comment>

<file context>
@@ -277,8 +277,10 @@ int qwen4exp_select_chunk(ggml_backend_t backend, const Qwen4ExpWeights & w,
         plan->layer_bytes = (size_t) slots * layer;
         if (plan->required > available) return 0;   // the context fit moves on without searching smaller chunks
     }
+    const size_t min_snapshot = snapshot_budget
+        ? qwen4exp_snapshot_bytes(backend, cache, std::min(cache.max_ctx, 4096)) : 0;
     const int chunk = qwen4exp_fit_chunk(cache.max_ctx, available, fixed, chunk_workspace, snapshot_budget,
</file context>

plan->layer_bytes = (size_t) slots * layer;
if (plan->required > available) return 0; // the context fit moves on without searching smaller chunks
}
const size_t min_snapshot = snapshot_budget

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: min_snapshot undercounts the checkpoint size that snapshot_fits() actually enforces. When the spare memory falls between the raw cache-copy size and the full estimate, this leaves a nonzero allowance but every checkpoint capture fails; calculate the minimum with the same complete checkpoint estimate used by snapshot_fits().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At server/src/qwen4exp/qwen4exp_backend.cpp, line 280:

<comment>`min_snapshot` undercounts the checkpoint size that `snapshot_fits()` actually enforces. When the spare memory falls between the raw cache-copy size and the full estimate, this leaves a nonzero allowance but every checkpoint capture fails; calculate the minimum with the same complete checkpoint estimate used by `snapshot_fits()`.</comment>

<file context>
@@ -277,8 +277,10 @@ int qwen4exp_select_chunk(ggml_backend_t backend, const Qwen4ExpWeights & w,
         plan->layer_bytes = (size_t) slots * layer;
         if (plan->required > available) return 0;   // the context fit moves on without searching smaller chunks
     }
+    const size_t min_snapshot = snapshot_budget
+        ? qwen4exp_snapshot_bytes(backend, cache, std::min(cache.max_ctx, 4096)) : 0;
     const int chunk = qwen4exp_fit_chunk(cache.max_ctx, available, fixed, chunk_workspace, snapshot_budget,
</file context>

@davide221 davide221 left a comment

Copy link
Copy Markdown
Contributor

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.

Copy to agent

Use the copy button in the top-right corner of the block, then paste into your agent.

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.

Repository: Luce-Org/lucebox
Reviewed commit: c2b17aa6bef9e63089302d55fff288fac135259c
Verify the findings against the current code, run relevant checks for any fixes, and explain any findings you reject.

<file name="server/src/qwen4exp/qwen4exp_backend.cpp">

<violation number="1" line="280" end_line="283">
min_snapshot sized at the 4096-token spacing zeroes allowances that could still hold shorter checkpoints

`min_snapshot` is `qwen4exp_snapshot_bytes(..., min(max_ctx, 4096))`, i.e. the cost of a checkpoint at a full restore-point spacing. But restore points are not all ≥4096 tokens: `spaced_restore_points()` keeps the first boundary wherever it falls, and the server also reserves cuts at PPP pins and chat boundaries (a system/tools head of a few hundred tokens is a first-class checkpoint, see `reserve_inline_snap`'s `forced_cut`/`restore_point_spacing` handling). On a memory-tight device where `spare` lands between `bytes(short head)` and `bytes(4096)`, the old code granted `allowance = spare`, and captures of short heads succeeded (the seq engine checks each capture against its actual token count, `estimate_prefix_store_bytes(ticket.checkpoint.tokens)`). With this change the reservation is rejected, `snapshot_budget_` becomes 0, `snapshot_allowance_bytes()` returns 0, and the scheduler disables the prefix cache entirely — including the single-sequence path (slots=1, 3 snapshots requested in `plan_chunk`), which the PR claims to preserve. Caching that previously worked for short prefixes is silently turned off. The minimum viable checkpoint should be derived from the smallest boundary the server can actually cut at (or the check should accept an allowance that holds any restorable checkpoint), not from the spacing constant.

<suggestion>
const size_t min_snapshot = snapshot_budget
    ? qwen4exp_snapshot_bytes(backend, cache, std::min(cache.max_ctx, restore_point_spacing())) : 0;
</suggestion>
</violation>

</file>
Issue overview
  1. min_snapshot sized at the 4096-token spacing zeroes allowances that could still hold shorter checkpoints · likely (server/src/qwen4exp/qwen4exp_backend.cpp:280-283)

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

Comment on lines +280 to +283
const size_t min_snapshot = snapshot_budget
? qwen4exp_snapshot_bytes(backend, cache, std::min(cache.max_ctx, 4096)) : 0;
const int chunk = qwen4exp_fit_chunk(cache.max_ctx, available, fixed, chunk_workspace, snapshot_budget,
floor_rows, max_rows);
floor_rows, max_rows, min_snapshot);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

min_snapshot sized at the 4096-token spacing zeroes allowances that could still hold shorter checkpoints · likely

min_snapshot is qwen4exp_snapshot_bytes(..., min(max_ctx, 4096)), i.e. the cost of a checkpoint at a full restore-point spacing. But restore points are not all ≥4096 tokens: spaced_restore_points() keeps the first boundary wherever it falls, and the server also reserves cuts at PPP pins and chat boundaries (a system/tools head of a few hundred tokens is a first-class checkpoint, see reserve_inline_snap's forced_cut/restore_point_spacing handling). On a memory-tight device where spare lands between bytes(short head) and bytes(4096), the old code granted allowance = spare, and captures of short heads succeeded (the seq engine checks each capture against its actual token count, estimate_prefix_store_bytes(ticket.checkpoint.tokens)). With this change the reservation is rejected, snapshot_budget_ becomes 0, snapshot_allowance_bytes() returns 0, and the scheduler disables the prefix cache entirely — including the single-sequence path (slots=1, 3 snapshots requested in plan_chunk), which the PR claims to preserve. Caching that previously worked for short prefixes is silently turned off. The minimum viable checkpoint should be derived from the smallest boundary the server can actually cut at (or the check should accept an allowance that holds any restorable checkpoint), not from the spacing constant.

Checked (78%): The mechanism checks out: restore points can be much shorter than 4096 tokens (spaced_restore_points keeps the first boundary wherever it falls, prefix_cache.cpp:329-336, and unspaced forced_cut cuts are added at :346-348), captures are charged by actual token count (qwen4exp_seq_engine.cpp:320), and min_snapshot is sized at min(max_ctx, 4096) (qwen4exp_backend.cpp:280-281). When spare memory lands between bytes(short head) and bytes(4096), qwen4exp_chunk.h:48-51 zeroes the allowance that the old code granted, making snapshot_allowance_bytes() 0 (qwen4exp_backend.h:74) so the scheduler disables the prefix cache entirely (scheduler.cpp:110) and supports_prefix_store() goes false (qwen4exp_seq_engine.h:59), killing the slots=1 path too. The only assumption left is that such an in-between memory state arises in practice; the PR's own stated intent is only to reject allowances that cannot hold a viable checkpoint, and short-head checkpoints were viable before this change.

Suggested change
const size_t min_snapshot = snapshot_budget
? qwen4exp_snapshot_bytes(backend, cache, std::min(cache.max_ctx, 4096)) : 0;
const int chunk = qwen4exp_fit_chunk(cache.max_ctx, available, fixed, chunk_workspace, snapshot_budget,
floor_rows, max_rows);
floor_rows, max_rows, min_snapshot);
const size_t min_snapshot = snapshot_budget
? qwen4exp_snapshot_bytes(backend, cache, std::min(cache.max_ctx, restore_point_spacing())) : 0;

@davide221

Copy link
Copy Markdown
Contributor

@gaurang2456 thanks for the contribution. Can you validate bugs and resolve conflicts

@davide221

Copy link
Copy Markdown
Contributor

CI failed on c2b17aa6

New unit test calls a private method and a file-local helper, so the C++ tests no longer compile

The pull request's new test in test_server_unit.cpp calls HttpServer::concurrent_prefix_budget, which is a private member, and budgetable_memory_bytes(), which exists only inside http_server.cpp and is not visible to other files. Both the CPU and GPU build jobs fail while compiling that test file. The production code itself builds fine; only the added test code is at fault.

How to fix: Expose budgetable_memory_bytes() and route the test's private calls through SchedulerTestHarness

  1. In server/src/server/http_server.h, declare size_t budgetable_memory_bytes(); next to auto_concurrent_prefix_budget.
  2. In server/src/server/http_server.cpp, drop the static from budgetable_memory_bytes()'s definition.
  3. In server/test/unit/test_server_unit.cpp, add a concurrent_prefix_budget accessor to SchedulerTestHarness (it is already a friend of HttpServer).
  4. Change the four server.concurrent_prefix_budget(...) calls in the new test to SchedulerTestHarness::concurrent_prefix_budget(server, ...).
  5. Rebuild and run: cmake --build server/build --target test_server_unit -j$(nproc) && ./server/build/test_server_unit.

compile error in new unit test · high confidence · run

Details and evidence

Failed: Build (cmake + uv sync --extra megakernel) › Build and run C++ CPU unit tests; GPU tests (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121)

Both failed jobs (Build / "Build and run C++ CPU unit tests", and GPU tests / "Build focused NVIDIA verifier tests") fail compiling server/test/unit/test_server_unit.cpp, added by this PR. Two distinct errors:

  1. 'budgetable_memory_bytes' was not declared in this scope at test_server_unit.cpp:17421 and :17442. budgetable_memory_bytes() is defined as static in server/src/server/http_server.cpp:2192, so it is invisible outside that translation unit.
  2. 'size_t luce::common::HttpServer::concurrent_prefix_budget(size_t, int) const' is private within this context at test_server_unit.cpp:17422, 17432, 17443, 17453. The method is declared private at server/src/server/http_server.h:1258.

The test file already has a sanctioned way to reach private members: SchedulerTestHarness (test_server_unit.cpp:162), which is a friend of HttpServer (http_server.h:982). The new test bypassed it. The PR description notes native compilation was not run locally, which is how this slipped through.

Other things to try

  • Alternative: drop the direct concurrent_prefix_budget calls and assert the clamping only through the already-public resolve_prefix_cache_budget(config, backend) (used in cases 7-8), at the cost of less direct coverage of the auto-budget path.
  • Reproduce locally before pushing: cmake --build server/build --target test_server_unit -j$(nproc) — the PR notes the toolchain was unavailable, which is why this compiled nowhere before CI.
Patch (3 files)

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

diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp
index 21c05f6a..75f87cb5 100644
--- a/server/src/server/http_server.cpp
+++ b/server/src/server/http_server.cpp
@@ -2189,7 +2189,7 @@ static json model_list(const ServerConfig & config, bool codex_schema) {
 // Memory the auto budget takes a quarter of: what is available once the
 // model has loaded (a container's cgroup v2 limit included; Windows too),
 // else total physical memory where the probe cannot read it.
-static size_t budgetable_memory_bytes() {
+size_t budgetable_memory_bytes() {
     if (const auto available = available_kv_offload_memory()) return *available;
 #if !defined(_WIN32)
     const long pages = sysconf(_SC_PHYS_PAGES);
diff --git a/server/src/server/http_server.h b/server/src/server/http_server.h
index bf7b2bb3..4f7e0645 100644
--- a/server/src/server/http_server.h
+++ b/server/src/server/http_server.h
@@ -867,6 +867,9 @@ struct PrefixCacheBudget {
 // per_checkpoint bytes, at least ServerConfig::kConcurrentPrefixBudgetFloor
 // and, above that, at most memory / 4 when memory is known (non-zero).
 size_t auto_concurrent_prefix_budget(size_t per_checkpoint, int slots, size_t memory);
+// Memory the auto budget takes a quarter of; exposed for tests that check
+// the auto budget against the backend snapshot allowance.
+size_t budgetable_memory_bytes();
 
 PrefixCacheBudget resolve_prefix_cache_budget(const ServerConfig & config,
                                               const ModelBackend & backend);
diff --git a/server/test/unit/test_server_unit.cpp b/server/test/unit/test_server_unit.cpp
index 6c1d6179..fd8c3c53 100644
--- a/server/test/unit/test_server_unit.cpp
+++ b/server/test/unit/test_server_unit.cpp
@@ -177,6 +177,11 @@ struct SchedulerTestHarness {
         server.scheduler_loop(engine);
     }
 
+    static size_t concurrent_prefix_budget(
+            const HttpServer & server, size_t per_checkpoint, int slots) {
+        return server.concurrent_prefix_budget(per_checkpoint, slots);
+    }
+
     static void stop(HttpServer & server) {
         server.stopping_.store(true, std::memory_order_relaxed);
         server.queue_cv_.notify_all();
@@ -17419,7 +17424,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
         const size_t auto_budget = auto_concurrent_prefix_budget(1237 * MiB, 4, budgetable_memory_bytes());
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == auto_budget);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == auto_budget);
     }
 
     // 2. Finite allowance on UMA: clamps when auto_budget exceeds device allowance
@@ -17429,7 +17434,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         LuceEngine engine(std::move(backend));
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == 2 * GiB);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == 2 * GiB);
     }
 
     // 3. Finite allowance larger than auto budget: preserves auto budget
@@ -17440,7 +17445,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
         const size_t auto_budget = auto_concurrent_prefix_budget(1237 * MiB, 4, budgetable_memory_bytes());
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == auto_budget);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == auto_budget);
     }
 
     // 4. Zero allowance: returns 0 bytes
@@ -17450,7 +17455,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         LuceEngine engine(std::move(backend));
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == 0);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == 0);
     }
 
     // 5. PrefixCache::disable() explicitly disables and clears cap, and blocks lookup/reservation
Prompt for a coding agent
A GitHub Actions run failed in Luce-Org/lucebox. Please fix it.

- Pull request #942 (branch `fix/894-qwen4exp-prefix-cache-budget`), commit c2b17aa6bef9e63089302d55fff288fac135259c
- Workflow: CI (https://github.com/Luce-Org/lucebox/actions/runs/41902135460)
- Failed: Build (cmake + uv sync --extra megakernel) › Build and run C++ CPU unit tests; GPU tests (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121)

Diagnosis:
Both failed jobs (Build / "Build and run C++ CPU unit tests", and GPU tests / "Build focused NVIDIA verifier tests") fail compiling `server/test/unit/test_server_unit.cpp`, added by this PR. Two distinct errors:

1. `'budgetable_memory_bytes' was not declared in this scope` at test_server_unit.cpp:17421 and :17442. `budgetable_memory_bytes()` is defined as `static` in server/src/server/http_server.cpp:2192, so it is invisible outside that translation unit.
2. `'size_t luce::common::HttpServer::concurrent_prefix_budget(size_t, int) const' is private within this context` at test_server_unit.cpp:17422, 17432, 17443, 17453. The method is declared private at server/src/server/http_server.h:1258.

The test file already has a sanctioned way to reach private members: `SchedulerTestHarness` (test_server_unit.cpp:162), which is a `friend` of HttpServer (http_server.h:982). The new test bypassed it. The PR description notes native compilation was not run locally, which is how this slipped through.

Proposed fix: Expose budgetable_memory_bytes() and route the test's private calls through SchedulerTestHarness
1. In `server/src/server/http_server.h`, declare `size_t budgetable_memory_bytes();` next to `auto_concurrent_prefix_budget`.
2. In `server/src/server/http_server.cpp`, drop the `static` from `budgetable_memory_bytes()`'s definition.
3. In `server/test/unit/test_server_unit.cpp`, add a `concurrent_prefix_budget` accessor to `SchedulerTestHarness` (it is already a friend of HttpServer).
4. Change the four `server.concurrent_prefix_budget(...)` calls in the new test to `SchedulerTestHarness::concurrent_prefix_budget(server, ...)`.
5. Rebuild and run: `cmake --build server/build --target test_server_unit -j$(nproc) && ./server/build/test_server_unit`.

Suggested patch (a starting point):
```diff
diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp
index 21c05f6a..75f87cb5 100644
--- a/server/src/server/http_server.cpp
+++ b/server/src/server/http_server.cpp
@@ -2189,7 +2189,7 @@ static json model_list(const ServerConfig & config, bool codex_schema) {
 // Memory the auto budget takes a quarter of: what is available once the
 // model has loaded (a container's cgroup v2 limit included; Windows too),
 // else total physical memory where the probe cannot read it.
-static size_t budgetable_memory_bytes() {
+size_t budgetable_memory_bytes() {
     if (const auto available = available_kv_offload_memory()) return *available;
 #if !defined(_WIN32)
     const long pages = sysconf(_SC_PHYS_PAGES);
diff --git a/server/src/server/http_server.h b/server/src/server/http_server.h
index bf7b2bb3..4f7e0645 100644
--- a/server/src/server/http_server.h
+++ b/server/src/server/http_server.h
@@ -867,6 +867,9 @@ struct PrefixCacheBudget {
 // per_checkpoint bytes, at least ServerConfig::kConcurrentPrefixBudgetFloor
 // and, above that, at most memory / 4 when memory is known (non-zero).
 size_t auto_concurrent_prefix_budget(size_t per_checkpoint, int slots, size_t memory);
+// Memory the auto budget takes a quarter of; exposed for tests that check
+// the auto budget against the backend snapshot allowance.
+size_t budgetable_memory_bytes();
 
 PrefixCacheBudget resolve_prefix_cache_budget(const ServerConfig & config,
                                               const ModelBackend & backend);
diff --git a/server/test/unit/test_server_unit.cpp b/server/test/unit/test_server_unit.cpp
index 6c1d6179..fd8c3c53 100644
--- a/server/test/unit/test_server_unit.cpp
+++ b/server/test/unit/test_server_unit.cpp
@@ -177,6 +177,11 @@ struct SchedulerTestHarness {
         server.scheduler_loop(engine);
     }
 
+    static size_t concurrent_prefix_budget(
+            const HttpServer & server, size_t per_checkpoint, int slots) {
+        return server.concurrent_prefix_budget(per_checkpoint, slots);
+    }
+
     static void stop(HttpServer & server) {
         server.stopping_.store(true, std::memory_order_relaxed);
         server.queue_cv_.notify_all();
@@ -17419,7 +17424,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
         const size_t auto_budget = auto_concurrent_prefix_budget(1237 * MiB, 4, budgetable_memory_bytes());
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == auto_budget);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == auto_budget);
     }
 
     // 2. Finite allowance on UMA: clamps when auto_budget exceeds device allowance
@@ -17429,7 +17434,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         LuceEngine engine(std::move(backend));
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == 2 * GiB);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == 2 * GiB);
     }
 
     // 3. Finite allowance larger than auto budget: preserves auto budget
@@ -17440,7 +17445,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
         const size_t auto_budget = auto_concurrent_prefix_budget(1237 * MiB, 4, budgetable_memory_bytes());
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == auto_budget);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == auto_budget);
     }
 
     // 4. Zero allowance: returns 0 bytes
@@ -17450,7 +17455,7 @@ TEST_CASE(ServerUnitFixture, test_concurrent_prefix_budget_allowance_clamping) {
         LuceEngine engine(std::move(backend));
         ServerConfig config;
         HttpServer server(engine, tokenizer, config);
-        TEST_ASSERT(server.concurrent_prefix_budget(1237 * MiB, 4) == 0);
+        TEST_ASSERT(SchedulerTestHarness::concurrent_prefix_budget(server, 1237 * MiB, 4) == 0);
     }
 
     // 5. PrefixCache::disable() explicitly disables and clears cap, and blocks lookup/reservation
```

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.

@gaurang2456

Copy link
Copy Markdown
Author

Hi @davide221 Davide, I’ve addressed the review comments and the CI compilation issues you pointed out.

  • Updated the min_snapshot calculation to use the backend’s restore_point_spacing().
  • Added the explicit disable path when the estimated checkpoint size is 0.
  • Made the prefix-cache state thread-safe.
  • Fixed the test_server_unit compilation issues by exposing budgetable_memory_bytes() and adding SchedulerTestHarness::concurrent_prefix_budget(...) so the affected tests no longer call the private HttpServer method directly.
    -Merged the latest main and pushed the updated branch.

@gaurang2456
gaurang2456 requested a review from davide221 November 6, 2026 18:04
@davide221

Copy link
Copy Markdown
Contributor

CI failed on b626e63b

Build fails: fprintf passes std::atomic cap_ directly as a %d argument

The pull request changed PrefixCache's cap_ member from a plain int to std::atomic for thread safety, but three debug log lines still hand cap_ to fprintf as a variadic %d argument. std::atomic cannot be copied, so every compiler (GCC, MSVC, and the GPU runner's GCC) rejects the file and all three build jobs fail. The change under test is at fault; loading the atomic's value before printing fixes it.

How to fix: Load cap_ with .load(std::memory_order_relaxed) at the three fprintf call sites in prefix_cache.cpp

  1. In server/src/server/kv/prefix_cache.cpp, change the three fprintf calls that print cap_ (lines 134, 137, 530) to pass cap_.load(std::memory_order_relaxed) instead.
  2. Rebuild to verify: cmake --build build --target luce_common (or push and let CI compile).
  3. Push the fix; all three build jobs should compile past prefix_cache.cpp.

compile error · 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 (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121)

All three failed jobs (Linux cmake build, Windows MSVC build, GPU sm_121 verifier build) fail compiling server/src/server/kv/prefix_cache.cpp with the same root cause:

prefix_cache.cpp:133:21: error: use of deleted function 'std::atomic<int>::atomic(const std::atomic<int>&)'

and on MSVC:

prefix_cache.cpp(134): error C2280: 'std::atomic<int>::atomic(const std::atomic<int> &)': attempting to reference a deleted function
prefix_cache.cpp(133): warning C4477: 'fprintf' : format string '%d' requires an argument of type 'int', but variadic argument 1 has type 'std::atomic<int>'

The PR (commit b626e63) changed the members in server/src/server/kv/prefix_cache.h:336-337 from bool disabled_ / int cap_ to std::atomic<bool> disabled_ / std::atomic<int> cap_. Reads in boolean/integer contexts still compile because std::atomic<int> implicitly converts via load(), but three std::fprintf calls pass cap_ through the variadic ..., which requires copying the (non-copyable) atomic:

  • server/src/server/kv/prefix_cache.cpp:134 — "[pc] enabled: cap=%d family=%s resident_budget=%zu MiB\n", cap_, ...
  • server/src/server/kv/prefix_cache.cpp:137 — "[pc] enabled: cap=%d family=%s\n", cap_, ...
  • server/src/server/kv/prefix_cache.cpp:530 — "[pc] inline snap skipped ... cap=%d ...", ..., cap_, ...

The PR already does this correctly in PrefixCache::stats() (prefix_cache.cpp:1230: cap_.load(std::memory_order_relaxed)); the three fprintf sites were missed. The PR description notes native compilation was not run locally, which is why this slipped through.

Other things to try

  • Alternative: hoist const int cap = cap_.load(std::memory_order_relaxed); into a local at the top of the constructor and the lambda and print that — same result, arguably clearer.
  • Reproduce locally before pushing: cmake --build build --target luce_common (or just compile the one TU) to catch variadic-atomic issues the author couldn't see without a toolchain.
  • The -Wmaybe-uninitialized warnings in qwen3_backend.cpp and the C4996 getenv warnings are pre-existing noise, unrelated to this failure.
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/src/server/kv/prefix_cache.cpp b/server/src/server/kv/prefix_cache.cpp
index 4589546d..cf292e87 100644
--- a/server/src/server/kv/prefix_cache.cpp
+++ b/server/src/server/kv/prefix_cache.cpp
@@ -131,10 +131,10 @@ PrefixCache::PrefixCache(int cap, const Tokenizer & tokenizer,
     disabled_ = false;
     if (max_resident_bytes_ > 0) {
         std::fprintf(stderr, "[pc] enabled: cap=%d family=%s resident_budget=%zu MiB\n",
-                     cap_, markers_.family.c_str(), max_resident_bytes_ / (1024 * 1024));
+                     cap_.load(std::memory_order_relaxed), markers_.family.c_str(), max_resident_bytes_ / (1024 * 1024));
     } else {
         std::fprintf(stderr, "[pc] enabled: cap=%d family=%s\n",
-                     cap_, markers_.family.c_str());
+                     cap_.load(std::memory_order_relaxed), markers_.family.c_str());
     }
 }
 
@@ -527,7 +527,8 @@ PrefixCache::InlineReservation PrefixCache::reserve_inline_snap(
         std::fprintf(stderr,
             "[pc] inline snap skipped, %s: entries=%zu protected=%d "
             "in_flight=%zu cap=%d restore_slot=%d cut=%d\n",
-            reason, entries_.size(), pins, active_reservations_.size(), cap_,
+            reason, entries_.size(), pins, active_reservations_.size(),
+            cap_.load(std::memory_order_relaxed),
             restore_source_slot, target_cut);
     };
     if ((int)entries_.size() + reserved_free_slots >= cap_) {
Prompt for a coding agent
A GitHub Actions run failed in Luce-Org/lucebox. Please fix it.

- Pull request #942 (branch `fix/894-qwen4exp-prefix-cache-budget`), commit b626e63b345919729d598a413abe1ec5b012f280
- Workflow: CI (https://github.com/Luce-Org/lucebox/actions/runs/42265443488)
- Failed: Build (cmake + uv sync --extra megakernel) › Build luce (smoke + server); Build Windows (MSVC + CUDA, library + server targets) › Build Windows targets; GPU tests (self-hosted DGX GB10, sm_121 verifier) › Build focused NVIDIA verifier tests (sm_121)

Diagnosis:
All three failed jobs (Linux cmake build, Windows MSVC build, GPU sm_121 verifier build) fail compiling `server/src/server/kv/prefix_cache.cpp` with the same root cause:

```
prefix_cache.cpp:133:21: error: use of deleted function 'std::atomic<int>::atomic(const std::atomic<int>&)'
```
and on MSVC:
```
prefix_cache.cpp(134): error C2280: 'std::atomic<int>::atomic(const std::atomic<int> &)': attempting to reference a deleted function
prefix_cache.cpp(133): warning C4477: 'fprintf' : format string '%d' requires an argument of type 'int', but variadic argument 1 has type 'std::atomic<int>'
```

The PR (commit b626e63b) changed the members in `server/src/server/kv/prefix_cache.h:336-337` from `bool disabled_` / `int cap_` to `std::atomic<bool> disabled_` / `std::atomic<int> cap_`. Reads in boolean/integer contexts still compile because `std::atomic<int>` implicitly converts via `load()`, but three `std::fprintf` calls pass `cap_` through the variadic `...`, which requires copying the (non-copyable) atomic:

- `server/src/server/kv/prefix_cache.cpp:134` — `"[pc] enabled: cap=%d family=%s resident_budget=%zu MiB\n", cap_, ...`
- `server/src/server/kv/prefix_cache.cpp:137` — `"[pc] enabled: cap=%d family=%s\n", cap_, ...`
- `server/src/server/kv/prefix_cache.cpp:530` — `"[pc] inline snap skipped ... cap=%d ...", ..., cap_, ...`

The PR already does this correctly in `PrefixCache::stats()` (`prefix_cache.cpp:1230`: `cap_.load(std::memory_order_relaxed)`); the three fprintf sites were missed. The PR description notes native compilation was not run locally, which is why this slipped through.

Proposed fix: Load cap_ with .load(std::memory_order_relaxed) at the three fprintf call sites in prefix_cache.cpp
1. In `server/src/server/kv/prefix_cache.cpp`, change the three `fprintf` calls that print `cap_` (lines 134, 137, 530) to pass `cap_.load(std::memory_order_relaxed)` instead.
2. Rebuild to verify: `cmake --build build --target luce_common` (or push and let CI compile).
3. Push the fix; all three build jobs should compile past prefix_cache.cpp.

Suggested patch (a starting point):
```diff
diff --git a/server/src/server/kv/prefix_cache.cpp b/server/src/server/kv/prefix_cache.cpp
index 4589546d..cf292e87 100644
--- a/server/src/server/kv/prefix_cache.cpp
+++ b/server/src/server/kv/prefix_cache.cpp
@@ -131,10 +131,10 @@ PrefixCache::PrefixCache(int cap, const Tokenizer & tokenizer,
     disabled_ = false;
     if (max_resident_bytes_ > 0) {
         std::fprintf(stderr, "[pc] enabled: cap=%d family=%s resident_budget=%zu MiB\n",
-                     cap_, markers_.family.c_str(), max_resident_bytes_ / (1024 * 1024));
+                     cap_.load(std::memory_order_relaxed), markers_.family.c_str(), max_resident_bytes_ / (1024 * 1024));
     } else {
         std::fprintf(stderr, "[pc] enabled: cap=%d family=%s\n",
-                     cap_, markers_.family.c_str());
+                     cap_.load(std::memory_order_relaxed), markers_.family.c_str());
     }
 }
 
@@ -527,7 +527,8 @@ PrefixCache::InlineReservation PrefixCache::reserve_inline_snap(
         std::fprintf(stderr,
             "[pc] inline snap skipped, %s: entries=%zu protected=%d "
             "in_flight=%zu cap=%d restore_slot=%d cut=%d\n",
-            reason, entries_.size(), pins, active_reservations_.size(), cap_,
+            reason, entries_.size(), pins, active_reservations_.size(),
+            cap_.load(std::memory_order_relaxed),
             restore_source_slot, target_cut);
     };
     if ((int)entries_.size() + reserved_free_slots >= cap_) {
```

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.

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.

qwen4exp: prefix cache silently disables itself with --max-concurrency > 1 and --verify-width > 1

2 participants