Skip to content

feat(llm): --low-vram mode with on-demand reclaim on embed VRAM pressure - #662

Open
brettdavies wants to merge 9 commits into
tobi:mainfrom
brettdavies:feat/llama-low-vram
Open

brettdavies wants to merge 9 commits into
tobi:mainfrom
brettdavies:feat/llama-low-vram

Conversation

@brettdavies

@brettdavies brettdavies commented May 19, 2026

Copy link
Copy Markdown
Contributor

--low-vram: share a GPU with another large model

Why

qmd query (and any other path that runs the full pipeline) loads three GGUF models into a single process: embed (~320 MB), generate (~2 GB), rerank (~2.3 GB). Once all three are resident, peak VRAM sits around 5.4 GB. On a GPU that's already shared with another model (say a 24 GB card running a ~20 GB Ollama), there isn't enough free VRAM left, and rerank context creation fails with Failed to create any rerank context. That's the failure mode tracked in #275 (closed during the v2.5.1 backlog cleanup; reproducible on hardware ranging from a 2 GB GTX 960M to a 6 GB RTX 3060 per reporters there, plus the 24 GB / Ollama-coexistence case below).

The three pipeline stages (expand → embed → search → rerank) are inherently sequential, so the win is straightforward: keep the tiny embed model resident, dispose the heavy generate and rerank models after each use, reload them on demand. Peak drops to ~2.6 GB. The cost is per-stage load latency.

Measured on an RTX 3090 Ti running alongside Ollama Gemma 4 26B (~20.3 GB VRAM):

Mode Query time Peak qmd VRAM Coexists with 20 GB Ollama?
Bare qmd query (cold) 33 s ~3 GB (churning) partially; CPU offload thrash + occasional rerank failures
qmd query (warm, default) 3.0 s 5.4 GB no (25.7 GB total > 24 GB free)
qmd query --low-vram 5.6 s 2.6 GB yes (22.6 GB total, headroom)

22K-file collection, hybrid query, full pipeline (expand + vec + rerank).

What this PR does

Adds lowVram to LlamaCppConfig, surfaced as a global --low-vram CLI flag (any subcommand) and a QMD_LOW_VRAM=1 env var. Naming follows the existing engine-knob pattern (QMD_FORCE_CPU, QMD_LLAMA_GPU).

When enabled, LlamaCpp:

  1. Disposes the generate model in a finally block after each expandQuery call.
  2. Disposes the rerank model and ranking contexts in a finally block after each rerank call.
  3. Serializes overlapping expandQuery calls through an internal promise chain so a dispose call can never race with another caller's in-flight use of the same model. Same for rerank.
  4. Leaves embed and embedBatch running in parallel as before: the embed model stays resident.
  5. Adaptive context sizing. Before allocating rerank contexts, probes getVramState(). Free VRAM at or above 1.5 GB keeps the 4096-token default; below that shrinks to 2048 tokens (~568 MB allocation cost instead of ~1146 MB). The QMD_RERANK_CONTEXT_SIZE env override still wins when set. CPU mode keeps the 4096 default. Document truncation in rerankImpl uses the actually allocated size, not a static constant, so chunks are never under-truncated when the context was shrunk under pressure.
  6. Two-pass on-demand reclaim. If embed, expandQuery, or rerank fails with an insufficient-VRAM error from node-llama-cpp, the wrapper drains the unrelated chains, disposes everything that isn't needed for the failing operation (generate model, rerank model, embed contexts), and retries once. If the retry also fails for VRAM AND the failing operation is not embed, a second pass drops the embed model itself (~568 MB) and retries one more time. Outside lowVram mode this is a pass-through, so default-path callers see zero overhead. Detail in the reclaim section below.

The two chains are independent: expandQuery and rerank against their separate heavy models can run in parallel against each other.

Because the flag is global and the constructor reads QMD_LOW_VRAM, it works everywhere LlamaCpp is constructed (qmd query, qmd embed, qmd mcp --http --daemon, qmd vsearch, etc.) without per-subcommand plumbing.

Two-pass reclaim

The reclaim path addresses the case where the catch-after-throw approach by itself does not free enough VRAM in one shot. The original observation that the lib's own pre-allocation VRAM check (InsufficientMemoryError: "A context size of N is too large for the available VRAM") is the right signal to react to still applies — we do not race to predict it. What is new is what we do after catching it.

First pass, on any insufficient-VRAM error:

  1. Drain the other operations' serialization chains so their finally-dispose has run.
  2. Dispose the heavy models we don't need for the failing operation: generate (for non-expandQuery callers), rerank (for non-rerank callers).
  3. Dispose embed contexts (not the model) when the failing operation is not embed itself. Contexts are ~143 MB each at the embed default; dropping them is cheap. The model stays resident because reload cost dominates the marginal VRAM win on cards with reasonable headroom.
  4. Retry the operation once.

If the retry also fails with an insufficient-VRAM error AND the failing operation is not embed, a second pass evicts the embed model itself (~568 MB) and retries one final time. Embed reclaim short-circuits the second pass — it cannot make progress without the model resident. Worst-case attempt count is capped at three (initial + first-pass retry + second-pass retry); no infinite retry loop is possible.

Each dispose runs best-effort: a failure is logged with the dispose label, but the remaining disposes still run. A retry against a partial reclaim is still more likely to succeed than no retry at all, and the original VRAM error is more useful than a downstream dispose error.

Per-context VRAM cost model in computeParallelism now derives from the actually chosen rerank context size (~0.28 MB/token, measured at both 2048 and 4096) instead of the previous 1000 MB hardcode that assumed 2048. Parallelism estimates scale correctly across both sizes.

isInsufficientVramError matches node-llama-cpp's "too large for the available VRAM" template (single-sequence and multi-sequence variants), our own "Failed to create any (embedding|rerank) context" wrapper messages, generic "out of memory" (CUDA / GGML), and "insufficient VRAM" safety-check variants. Conservative on purpose — too broad and we'd evict on unrelated failures; too narrow and we'd miss the case the feature targets. Pinned against a curated list of real lib strings plus a red-team negative list (model-not-found, unsupported-architecture, network errors, substring-greediness cases) so a future lib message change is caught as a unit-test failure before it ships.

Test coverage

File What it covers
test/llm-low-vram.test.ts Concurrency: lowVram serialization prevents dispose-mid-use races, two-pass reclaim event ordering for all three callers, second-pass cap (max 3 attempts), best-effort dispose continues when a helper throws, non-VRAM errors short-circuit, post-first-pass non-VRAM errors are terminal, in-flight chains drain before dispose.
test/llm-adaptive-rerank.test.ts Adaptive sizing: env override wins, CPU mode skips probe, boundary behavior at threshold, Ollama-hogged scenario picks 2048, probe failure falls back to default, rerankImpl uses the chosen size, computeParallelism budget reflects the chosen size, lifecycle reset on dispose.
test/llm-vram-error-pinning.test.ts Tripwire: positives the regex must catch (InsufficientMemoryError template variants, synthetic codebase strings, CUDA / GGML OOM), red-team negatives that must NOT match (model-not-found, wrong-architecture, network errors, substring greediness), input-shape handling.
test/llm-reclaim-integration.test.ts Opt-in real-GPU integration (QMD_RECLAIM_INTEGRATION=1). Oversized context (1 M tokens) forces a real allocation failure, asserts the 3-attempt cap holds and isInsufficientVramError classifies the live InsufficientMemoryError. Missing-model-file case asserts non-VRAM failures do not retry.

The integration test is gated because it downloads the rerank model on first run and exercises real GPU allocation; it cannot meaningfully execute in CI. All synthetic tests run unconditionally.

Architecture

d21ddd3  Brett  test(llm): single-step assertions to satisfy the anti-slop lint fence
9af2ed0  Brett  test(llm): isolate low-vram suite from ambient CI and QMD_LOW_VRAM
515d3af  Brett  test(llm): opt-in real-GPU integration test for the two-pass reclaim path
493dc3c  Brett  feat(llm): adaptive rerank context size with correct parallelism cost
a7a3230  Brett  feat(llm): two-pass VRAM reclaim with embed-model escalation
32cfefb  Brett  test(llm): pin isInsufficientVramError against real node-llama-cpp error strings
249d592  Brett  feat(llm): extend reclaim to expandQuery and rerank
868972e  Brett  feat(llm): reclaim heavies on embed VRAM pressure
e490614  Brett  feat(llm): add low-vram mode that loads one heavy model at a time

Changelog

Added

  • --low-vram global flag and QMD_LOW_VRAM=1 env var. Disposes the generate and rerank models after each use while keeping the embed model resident. Peak VRAM drops from ~5.4 GB to ~2.6 GB at the cost of per-stage load latency. Intended for shared GPUs (Ollama coexistence) and small-VRAM cards where loading all three models at once would fail.
  • Two-pass on-demand reclaim when embed, expandQuery, or rerank hits an insufficient-VRAM error from node-llama-cpp. First pass disposes the other models and the embed contexts; second pass evicts the embed model itself. Worst-case three attempts. Off outside --low-vram.
  • Adaptive rerank context size. Picks 2048 instead of the 4096 default when free GPU VRAM is below ~1.5 GB. The QMD_RERANK_CONTEXT_SIZE env override still wins.
  • isInsufficientVramError exported from src/llm.ts for direct unit-testing.

Changed

  • computeParallelism budgets each rerank context against its actual chosen size (~0.28 MB/token) instead of a fixed 1000 MB-per-context estimate. Parallelism scales correctly across both 2048 and 4096 sizes.
  • Long-document truncation in rerankImpl now uses the actually allocated context size, so chunks are never under-truncated when the context was shrunk under VRAM pressure.

Fixed

  • In --low-vram mode, a VRAM failure creating the first rerank context propagates to withReclaim (which evicts heavies and retries) instead of falling into the warn-and-skip path fix(llm): surface the real error when rerank context creation fails #866 added for the default mode. The original error is rethrown as-is; isInsufficientVramError recognizes the underlying node-llama-cpp messages directly (pinned by test/llm-vram-error-pinning.test.ts). Default-mode behavior is unchanged: reranking still degrades gracefully with a warning.

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix
  • BREAKING CHANGE: Breaking API change

Related Issues/Stories

Testing

  • Unit tests added/updated
  • Integration tests added/updated (opt-in real-GPU)
  • Manual testing completed
  • All tests passing

Test Summary:

  • test/llm-low-vram.test.ts: 23 cases (concurrency, two-pass reclaim, best-effort, non-VRAM termination).
  • test/llm-adaptive-rerank.test.ts: 18 cases (env override, CPU mode, boundary, lifecycle).
  • test/llm-vram-error-pinning.test.ts: 21 cases (positive catalog, red-team negatives, input shapes).
  • test/llm-reclaim-integration.test.ts: 2 opt-in real-GPU cases gated by QMD_RECLAIM_INTEGRATION=1. Verified locally: 2 pass / 0 fail in 794 ms with the rerank model pre-cached.

Files Modified

Modified:

  • src/llm.ts — adaptive context sizing, two-pass reclaim, disposeEmbedContexts / disposeEmbedModel / bestEffort helpers, dead-flash-attention-retry removal, isInsufficientVramError export, rerankContextSize lifecycle reset on the three dispose sites.

Created:

  • test/llm-adaptive-rerank.test.ts
  • test/llm-vram-error-pinning.test.ts
  • test/llm-reclaim-integration.test.ts

Renamed:

  • None.

Deleted:

  • None.

Breaking Changes

  • No breaking changes.

Deployment Notes

  • No special deployment steps required.

@brettdavies
brettdavies marked this pull request as ready for review May 20, 2026 21:40
@brettdavies
brettdavies force-pushed the feat/llama-low-vram branch from c9d6c27 to 29073bc Compare May 26, 2026 03:39
@brettdavies brettdavies changed the title feat(llm): --low-vram mode for memory-constrained GPUs feat(llm): add --low-vram to share a GPU with another large model May 26, 2026
@brettdavies

Copy link
Copy Markdown
Contributor Author

Hi @tobi, friendly nudge on this one when you have a review cycle.

Single opt-in flag with no behaviour change in the default path: LlamaCppConfig.lowVram defaults to false, so existing callers, indexes, and the qmd query / qmd embed / qmd mcp paths run identically when the flag isn't set. The lowVram code paths are only entered when --low-vram / QMD_LOW_VRAM=1 is explicitly passed.

The win is concrete and matches the case in the body: on a 24 GB GPU sharing space with a ~20 GB Ollama, qmd query currently fails the rerank stage with Failed to create any rerank context. With --low-vram, peak VRAM drops from 5.4 GB to 2.6 GB and the full pipeline runs (3.0s → 5.6s; table in the body has measurements). Same fix applies to the smaller-card scenarios reported on #275.

Rebased onto current main (commit 29073bc); tsc --noEmit clean; six new concurrency tests in test/llm-low-vram.test.ts pass against a stubbed LlamaCpp so they run without a GPU and cover the dispose-mid-call race the chain is meant to prevent. The same engine flag underpins the qmd serve --low-vram passthrough in #663, but this PR stands alone.

Happy to rename the flag (the body's open question on --low-vram vs --sequential) or adjust scope.

@brettdavies
brettdavies force-pushed the feat/llama-low-vram branch 2 times, most recently from ee99ba4 to 837100e Compare June 4, 2026 05:07
@brettdavies
brettdavies force-pushed the feat/llama-low-vram branch from dbf013a to 837100e Compare June 10, 2026 05:52
@brettdavies brettdavies changed the title feat(llm): add --low-vram to share a GPU with another large model feat(llm): --low-vram mode with on-demand reclaim on embed VRAM pressure Jun 10, 2026
@brettdavies
brettdavies force-pushed the feat/llama-low-vram branch from 13c9ed5 to 57edb82 Compare June 11, 2026 23:41
@brettdavies
brettdavies force-pushed the feat/llama-low-vram branch from 57edb82 to 2331f38 Compare June 24, 2026 19:22
Adds `lowVram` to `LlamaCppConfig` (also `--low-vram` CLI flag and
`QMD_LOW_VRAM=1` env). When enabled, the heavy generate (~2 GB) and
rerank (~2.3 GB) models are disposed immediately after each use,
while the tiny embed model (~320 MB) stays resident. Peak VRAM drops
from ~5.4 GB to ~2.6 GB at the cost of per-stage load latency
(~3 s → ~5.6 s on a typical GPU).

This makes qmd usable on GPUs where loading all three models at once
exhausts free VRAM — for example, sharing a 24 GB GPU with a ~20 GB
Ollama instance. Addresses the failure mode tracked in tobi#275 across
all entry points that construct an LlamaCpp instance: `qmd query`,
`qmd mcp`, and the upcoming `qmd serve`.

The pipeline stages (expand → embed → search → rerank) are inherently
sequential, so disposing between them only costs reload time, not
correctness.

Concurrency: when lowVram is on, expandQuery and rerank calls
serialize through per-method promise chains so a dispose can never
race with another caller's in-flight use of the same model. embed
and embedBatch remain parallel. The two chains are independent —
expand and rerank against their separate heavy models can run in
parallel against each other.

The flag is global (`--low-vram` works on any subcommand that
constructs an LlamaCpp), so qmd query, qmd mcp, and other one-shot
commands all benefit — not just long-lived daemons. Naming follows
the existing engine-knob pattern (QMD_FORCE_CPU, QMD_LLAMA_GPU).
In lowVram mode, wrap LlamaCpp.embed and embedBatch with a catch-and-retry path: when the underlying ensureEmbedContext(s) fails because node-llama-cpp pre-allocation refused for lack of VRAM, drain the generate and rerank chains (so any in-flight expand/rerank completes its own finally-dispose), defensively dispose both heavy models, and retry the operation once. Outside lowVram mode this is a pass-through, so default-path callers see zero overhead.

isInsufficientVramError matches node-llama-cpp's 'too large for the available VRAM' string, our own 'Failed to create any (embedding|rerank) context' wrapper messages, and the bare 'out of memory' / 'insufficient VRAM' variants. Conservative on purpose: too broad and we'd evict on unrelated failures, too narrow and we'd miss the case the feature targets.

The chain-drain via Promise.allSettled is what makes the dispose race-free against in-flight expand/rerank callers: by the time their chain settles, each caller has already run its own finally-dispose, so our subsequent disposeGenerateModel / disposeRerankModel calls land on null models and are no-ops. The retry then succeeds because the embed context can allocate into the freed VRAM.

5 new tests in test/llm-low-vram.test.ts cover: embed and embedBatch recover from VRAM error and return correct results, non-VRAM errors do not trigger eviction, lowVram=false skips the reclaim path entirely, and reclaim awaits in-flight generate/rerank chains before disposing.
Generalize embedWithReclaim into a single withReclaim(needs, fn) helper, then wire it into the expandQuery and rerank lowVram chain handlers. The 'needs' argument names which model the operation requires (so it's never disposed) and identifies which OTHER chain to drain. expandQuery uses needs='generate' (drains rerankChain, disposes rerank); rerank uses needs='rerank' (drains generateChain, disposes generate); embed continues to use needs='embed' (drains both, disposes both).

The needs discrimination also prevents a self-await deadlock, because a caller inside generateChain cannot await generateChain without blocking on its own current link. By restricting drains/disposes to the OTHER chains, the reclaim path is always safe.

Three new tests in test/llm-low-vram.test.ts: expandQuery retries after VRAM error and disposes rerank (not generate); rerank retries after VRAM error and disposes generate (not rerank); expand reclaim awaits an in-flight rerank chain before disposing rerank. Existing 11 lowVram tests still pass.

Practical impact: a qmd query that previously failed with 'A context size of 2048 is too large for the available VRAM' on the expand stage now recovers if the only thing standing between it and its budget is a resident rerank model (or vice versa). External VRAM contention (e.g., a co-resident Ollama eating the budget) is still unrecoverable: that's the next layer the maintainers may want.
…ror strings

Adds a tripwire test suite for `isInsufficientVramError`, the function that gates the two-pass reclaim path in `withReclaim`. The regex catalog inside the function has to match the real error strings that node-llama-cpp emits under VRAM pressure or the reclaim never fires when it should, and the user just gets a raw `Failed to create any rerank context` instead of a recovered query.

Exports the function (was previously module-local) and pins it against two curated lists of strings.

Positives that must match:

| Error string | Source |
| --- | --- |
| `A context size of N is too large for the available VRAM` | `node-llama-cpp/dist/evaluator/LlamaContext/LlamaContext.js:30` single-sequence `InsufficientMemoryError` template |
| `A context size of N with M sequences is too large for the available VRAM` | Same template, multi-sequence form |
| `Failed to create any (rerank\|embedding) context` | Synthetic strings this codebase throws when the inner allocation loops give up |
| `CUDA error: out of memory` | Generic CUDA OOM |
| `ggml_cuda_compute_forward: out of memory` | GGML OOM under load |
| `ran out of memory while allocating buffer` | Lowercase variant |
| `Insufficient VRAM to load the model` | Safety-check variant |

Negatives that must NOT match. False positives would chew through retries on errors the reclaim cannot fix:

| Error string | Why it must not match |
| --- | --- |
| `model file not found` | IO error, not VRAM |
| `Computing rankings is not supported for this model.` | Wrong architecture for the call |
| `fetch failed: connect ECONNREFUSED ...` | Network error during model download |
| `Invalid GGUF magic bytes` | File corruption |
| `Something else went wrong` | Generic runtime error with no memory-related keywords |
| `Loaded model into memory successfully` | Mentions "memory" but is not OOM (red-team: substring greediness) |
| `VRAM usage: 12.3 GB / 24 GB` | Mentions "VRAM" but is a status line (red-team) |

Input-shape tests round out the suite. The function must handle null, undefined, empty string, a bare string with a positive message, an arbitrary object that string-coerces to a positive message, and case-insensitivity across the catalog.

Adding a positive case here that fails before updating the regex is the preferred way to fix a "reclaim didn't fire when it should have" report. The failing test pins the behavior, the regex update makes it pass, and future regressions are caught.

Twenty-one tests pass on the new file.

## Changelog

### Added
- `isInsufficientVramError` is now exported from `src/llm.ts` so the reclaim trigger function can be unit-tested directly against curated error strings.
- `test/llm-vram-error-pinning.test.ts` pins the reclaim trigger against a sourced list of real node-llama-cpp error messages plus codebase-internal synthetic strings, and against a red-team list of similar-looking errors that must not trigger.
Hardens lowVram mode's `withReclaim` for cards where shared workloads (Ollama, gbrain, etc.) leave qmd only a few GB of headroom. The previous single-pass reclaim only freed generate and rerank. Neither helped when the rerank context allocation itself was the failing call and the embed model was the next-largest qmd-side consumer.

The new flow on a non-embed reclaim:

1. First pass: drain the other chains, dispose generate model, dispose rerank model, dispose embed *contexts* (~143 MB × N). Retry once.
2. Second pass: if the retry also hits a VRAM error, drop the embed *model* (~568 MB). Retry once more.
3. Embed reclaim short-circuits the second pass because it cannot make progress without the model.

Each dispose runs best-effort: a failure is logged with the dispose label and the remaining disposes still run, so a single broken dispose does not poison the retry. The original VRAM error is more useful than a downstream dispose error, and a retry against partial reclaim is still more likely to succeed than no retry at all.

Also removes a misleading "retry without flash attention" branch in `ensureRerankContexts`: both `createRankingContext` calls passed identical options, and `LlamaRankingContextOptions` does not accept `flashAttention` (the option is silently dropped at `LlamaRankingContext.js:160-168`). The synthetic `Failed to create any rerank context` message is preserved so `isInsufficientVramError` continues to recognize it.

Worst-case attempt count is capped at 3 (initial + first-pass retry + second-pass retry).

Test coverage (`test/llm-low-vram.test.ts`):

- First-pass reclaim symmetry for `embed`, `expandQuery`, `rerank` (existing tests updated for new dispose:embed-contexts event).
- Second-pass escalation for `rerank` and `expandQuery` (3-attempt success path).
- Second-pass cap for `rerank` and `expandQuery` (3-attempt give-up path).
- `embed` reclaim never disposes embed contexts or model under any retry pattern.
- Reclaim continues retry when a dispose helper itself throws (best-effort, warning logged).
- Reclaim ignores non-VRAM errors (no dispose, no retry).
- Reclaim treats a post-first-pass non-VRAM error as terminal (no escalation).

23 tests pass on the file. Wider suite delta is unchanged (the remaining 19 wider failures are pre-existing GPU-contention integration tests; addressed separately).

- Low-VRAM reclaim now escalates to disposing the embed model when an initial dispose pass plus retry still hit a VRAM error, with all dispose calls running best-effort so a single failing dispose does not abort the retry.

- Removed a no-op "retry without flash attention" fallback in `ensureRerankContexts` whose retry passed identical options to the failing call and whose `flashAttention` flag is silently dropped by `node-llama-cpp`'s `createRankingContext`.
Picks the rerank context size based on free VRAM at allocation time and budgets `computeParallelism` against the actual chosen size. The previous code allocated a fixed 4096-token context (~1146 MB with flash attention) and budgeted parallelism at a hardcoded 1000 MB/context, which both over-allocated on contested cards and skewed the parallelism estimate.

Resolution order in `resolveRerankContextSize`:

1. `QMD_RERANK_CONTEXT_SIZE` env override (read at call time, not class-load) always wins. Falls back to adaptive logic on missing / non-positive / non-numeric values.
2. CPU mode (offload forced or no GPU) keeps the 4096 default. CPU RAM is plentiful and the per-token cost model does not apply.
3. GPU probe via `getVramState`. Free VRAM below 1500 MB shrinks to 2048; at or above the threshold stays at 4096. A probe failure conservatively falls back to 4096, where the caller's existing retry path will catch a too-tight allocation.

Threshold derivation: the 4096-token rerank context with flash attention costs roughly 1.15 GB. The 1500 MB threshold reserves the context plus ~350 MB margin for the rerank model and concurrent allocations. The per-token cost model (0.28 MB/token) was measured against real loads: 4096 ≈ 1146 MB, 2048 ≈ 568 MB.

The chosen size is stored on the instance as `rerankContextSize` so `rerankImpl` truncates documents against the same size that was actually allocated, never a static guess that would under-truncate when the context was shrunk. The field resets to `null` in `disposeRerankModel`, `unloadIdleResources`, and `dispose`, so a re-allocation re-probes VRAM.

`computeParallelism` now receives `Math.ceil(contextSize * 0.28)` as the per-context MB estimate. At the 4096 default this is roughly 1146 MB (was 1000 MB hardcoded); at the 2048 fallback it is roughly 573 MB. Parallelism scales accordingly.

Test coverage (`test/llm-adaptive-rerank.test.ts`):

- Env override returns user-supplied size regardless of VRAM, including when VRAM is plentiful.
- Invalid env override (0, negative, non-numeric, empty) falls back to adaptive logic.
- CPU mode and CPU-offload-forced skip the VRAM probe entirely.
- Plentiful VRAM returns 4096; exactly at threshold returns 4096; just below returns 2048.
- Ollama-hogged GPU scenario (~1.4 GB free) returns 2048.
- `getVramState` throwing falls back to 4096.
- `ensureRerankContexts` stores the resolved size on `rerankContextSize`.
- `computeParallelism` budget reflects the actual chosen size, not the old 1000 MB hardcode.
- `disposeRerankModel` and `dispose` clear `rerankContextSize` so the next call re-probes.

18 tests pass on the new file; pre-existing lowVram concurrency tests on this branch are unaffected.

- Rerank context size now adapts to free VRAM at allocation time, picking 2048 instead of the 4096 default when free VRAM drops below ~1.5 GB. The `QMD_RERANK_CONTEXT_SIZE` env override still wins when set.
- Parallelism for rerank context creation is now budgeted against the actual chosen context size instead of a fixed 1000 MB-per-context estimate that assumed the older 2048 default.

- Long-document truncation in `rerankImpl` now uses the actually allocated context size, not a static constant, so documents are never under-truncated when the context was shrunk under VRAM pressure.
…path

Adds an end-to-end test that drives the two-pass reclaim against a real `node-llama-cpp` runtime instead of monkey-patched fakes. The synthetic tests in `llm-low-vram.test.ts` prove the orchestration of `withReclaim`. This file fills the gap they leave:

- The real dispose helpers actually run against a live model and contexts.
- The actual `InsufficientMemoryError` thrown by `node-llama-cpp` exercises the live `isInsufficientVramError` regex (B6 pins it string-by-string, this throws it).
- The three-attempt cap holds against a real failing allocation, not just a synthetic throw.

The recovery path (reclaim disposes other models and the retry succeeds) needs precise VRAM ballast and tear-down that is hard to make deterministic on a shared GPU, so it stays covered by the synthetic suite.

Gated behind `QMD_RECLAIM_INTEGRATION=1`. The tests are skipped by default because:

- They download the rerank model on first run (one-off, can take minutes).
- They exercise real GPU allocation, so CI and remote-server-routed runs cannot meaningfully execute them.
- They pair naturally with B3's VRAM precondition: the suite does not care about free VRAM, it only needs enough headroom to load the rerank model (~400 MB).

Two cases:

1. **Oversized rerank context → reclaim gives up cleanly after 3 attempts.** Reflectively swap the static `RERANK_CONTEXT_SIZE` to 1 048 576 tokens (~290 GB context). The static is captured at class load, so `process.env` mutation alone would have no effect; the reflective swap is the only honest way to force a real allocation that no GPU can satisfy. Spy on `rerankImpl` to count attempts (wrap, do not replace, so the real implementation runs). Verify the call rejects, the spy ran exactly three times, and the surfaced error is recognized by `isInsufficientVramError`. If that last assertion ever fails, the lib reworded its error and the regex needs a new alternative — add a positive case to `llm-vram-error-pinning.test.ts` first, then update the regex.

2. **Non-VRAM rerank failure does not retry (missing model file).** Build a `LlamaCpp` pointed at `/nonexistent/path/to/no-such-model.gguf`, attempt rerank, verify exactly one attempt was made and the error is NOT classified as VRAM. Catches the inverse failure: if `isInsufficientVramError` ever started matching non-VRAM errors, the reclaim would chew through retries for nothing and bury the real diagnostic.

Each test owns a fresh `LlamaCpp` and disposes it in `finally` so a failure cannot leak GPU resources into the next test.

Verified locally:

```
QMD_RECLAIM_INTEGRATION=1 bun test --preload ./src/test-preload.ts test/llm-reclaim-integration.test.ts
2 pass / 0 fail / 6 expect() calls in 794ms
```

(Models pre-cached on the dev box; first-run download would dominate the timing.)

## Changelog

### Added
- `test/llm-reclaim-integration.test.ts` real-GPU tests for the two-pass reclaim path, gated behind `QMD_RECLAIM_INTEGRATION=1` so they do not run in CI or remote-LLM-routed environments. Covers the give-up branch (oversized context forces real allocation failure, asserts the 3-attempt cap and `isInsufficientVramError` classification) and the no-retry branch (missing model file must not be misclassified as VRAM).
The low-vram tests monkey-patch the *Impl methods to exercise the reclaim and serialization wrappers without loading real models. Two ambient env vars distort them: CI trips LlamaCpp's _ciMode guard, which throws before the wrapper runs, and a leaked QMD_LOW_VRAM=1 flips the lowVram=false cases to true. Neutralize both in beforeAll and restore in afterAll so the suite is deterministic regardless of environment.
@brettdavies

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (post-v2.8.3). Scope is unchanged (engine-only, 8 commits), but two things are worth calling out:

Everything else rebased clean, including onto main's node-llama-cpp 3.20 bump and the async sequence-dispose changes.

The oxlint fence on main (anti-slop/no-chained-type-assertions) rejects
the `x as unknown as T` chains the low-vram suites used to reach private
members. A widen() helper moves the unknown-widening to the test
boundary, so each site narrows with one assertion and keeps its precise
structural type.
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.

Low-VRAM GPUs: evict idle models before loading reranker

1 participant