Skip to content

fix(engine-cuda): bound Qwen3 shape-plan cache with a 2-entry LRU - #16

Open
Qiiks wants to merge 2 commits into
cortexkit:masterfrom
Qiiks:feat/qwen3-shape-lru-cache
Open

Qiiks wants to merge 2 commits into
cortexkit:masterfrom
Qiiks:feat/qwen3-shape-lru-cache

Conversation

@Qiiks

@Qiiks Qiiks commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Problem

QwenContext retains every distinct (batch, seq) ShapePlan for the worker's lifetime. Each plan owns a persistent arena (scales with batch*seq*hidden + batch*heads*seq^2, see #2314-style arithmetic), up to 64 MiB of cuBLASLt workspace, a cudaGraph and a cudaGraphExec. A long-lived worker that embeds diverse batch shapes therefore accumulates one arena per distinct shape.

Observed live: the production gateway serving AFT/MC traffic logged repeated cudaMalloc ... out of memory engine crashes (cuda_family_common.cuh:73) after accumulating shapes like 64x172 (arena 646 MB), 16x1117 (1.59 GB), and dozens of 1xN shapes — on a 6 GiB RTX 4050, where persistent f16 weights alone are ~1.1 GiB.

Fix

Bound the cache: retain at most two warm plans per context (max_plans = 2, LRU order).

  • Evict before allocate: a cache miss with a full cache evicts the LRU victim before allocating the new arena, so a third full arena never raises the peak.
  • Evict while idle: eviction happens after cudaStreamSynchronize on the context's single stream, so victim buffers are freed while no kernel references them; the just-executed plan is promoted to MRU and never the victim.
  • RAII teardown: erasing a map entry destroys the unique_ptr<ShapePlan>, whose destructor chain frees graph exec, graph, and every DeviceAllocation (cudaFree). Eviction is what bounds VRAM.
  • Also restores the plans member declaration that the eviction edit had dropped (the tree previously failed to compile with 7 undefined-identifier errors).

Verification (live RTX 4050, production gateway lane)

  • Full release build of synapse-worker-cuda (--features cuda) green.
  • Four distinct shapes (1x34, 1x66, 1x98, 1x130) each captured a plan (captured_exact=true in worker logs).
  • Replay of the evicted first shape after the fourth returned a bit-identical vector (max abs delta 0.0), norms all 1.0 — correctness preserved across eviction.
  • Steady serving VRAM ~1.9 GiB with no growth across shape churn (previously unbounded, ultimately OOM-crashing).

Notes


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Bounds the Qwen3 shape-plan cache to two entries with LRU eviction, preventing VRAM exhaustion on workers that see many distinct batch shapes.

  • Evicts the least-recently-used plan before allocating a new one, after the CUDA stream is idle, so no kernel references the freed buffers.
  • Tracks plans in the LRU before run() executes, so a failed run can't orphan an arena.
  • Restores the plans member declaration that was accidentally dropped, fixing a compile error.

Written for commit 5b6685b. Summary will update on new commits.

Review in cubic

Every distinct (batch,seq) ShapePlan allocates a persistent arena plus
up to 64 MiB of cuBLASLt workspace, a cudaGraph and a cudaGraphExec,
retained for the worker's lifetime. A long-lived worker embedding
diverse batch shapes therefore accumulated one arena per shape and
could OOM the 6 GiB RTX 4050 (observed out-of-memory crashes in the
live gateway log at cuda_family_common.cuh:73).

Retain at most two warm plans per context. Eviction happens before
allocation of a new plan and after cudaStreamSynchronize, so a victim's
buffers are freed while no kernel references them; the just-executed
plan is always promoted to most-recently-used and never the victim.
Deleting a plan destroys its DeviceAllocation members (cudaFree) via
destructors, so eviction is what bounds VRAM.

Also restores the plans member declaration that the eviction edit had
dropped (build previously failed with 7 undefined-identifier errors).
Verified live: four distinct shapes captured, replay of the evicted
first shape bit-identical (max_abs_delta=0), steady serving VRAM
~1.9 GiB on the RTX 4050 lane.
Copilot AI lite review requested due to automatic review settings September 17, 2026 00:08

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 1 file

Re-trigger cubic

@synapse-alfonso synapse-alfonso 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.

Confirmed the defect on master: plans is unbounded, keyed by (batch, seq), and each entry holds a captured graph plus a full arena, so variable-length traffic grows VRAM without a ceiling. The fix is the right shape, and the ordering argument in the comments holds — run() returns after cudaStreamSynchronize, so an eviction after it frees nothing in flight.

One defect in the fix, reachable on the failure path. The map and the LRU list are updated at different times: emplace at insert, touch_plan only after run() returns. forward catches and returns -1 on a throw, so the process survives, and after a run() throw the plan is in plans but never in plan_lru. Consequences:

  • One throw: that plan becomes a zombie — never touched, never a victim — and the cache holds 1 live entry plus a permanent arena. The bound is silently 3, not 2.
  • Two consecutive throws on distinct new shapes: plans.size() == 2 with plan_lru empty, and the next miss calls plan_lru.back() on an empty list, which is undefined behaviour rather than an error.

Both go away if the list and the map are kept in step: call touch_plan(key) immediately after emplace (before run), then again after run as you do now. Every entry in plans is then always in plan_lru, the eviction victim is always a real key, and the bound holds regardless of what run() does. That also makes the plans.find(key) == end() guard in touch_plan unreachable in practice, which is fine to keep as a defensive check.

One question, not a blocker: is 2 the right constant for the module's traffic? A miss costs initialize_and_verify, which runs a full verification forward, so thrash is expensive. Interactive queries and background batches on this lane tend to be exactly two shapes, so 2 is probably right today — but it is worth a short comment saying why 2, so the next person who sees thrash in the perf log knows which number to move.

Happy to merge on the touch-on-insert change; I will re-verify on a rented 4090 alongside #17/#18 rather than three separate rentals.

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 1 file

Re-trigger cubic

@synapse-alfonso synapse-alfonso 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.

That is the fix: touch on insert, touch after run retained for hit promotion, so every entry in plans is always in plan_lru and the miss-path .back() is always a real key. Verified by reading the resulting forward body rather than the diff.

Merging after a hardware pass that I have folded into the rental already running for #17/#18: four shapes in sequence against a pr-16 worker to observe the eviction and the rebuild, with the rebuilt plan's output byte-compared against the first forward and against a master-built worker. The LRU changes retention, not arithmetic, so byte-identity is the expected result; if a rebuilt plan produced different bytes that would be the finding.

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