Skip to content

test: VRAM precondition gate for integration suites with opt-in Ollama auto-evacuate - #726

Open
brettdavies wants to merge 3 commits into
tobi:mainfrom
brettdavies:feat/test-vram-precondition
Open

brettdavies wants to merge 3 commits into
tobi:mainfrom
brettdavies:feat/test-vram-precondition

Conversation

@brettdavies

@brettdavies brettdavies commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

VRAM precondition gate for GPU-dependent integration suites

Why

The GPU-dependent integration sub-suites in llm.test.ts, mcp.test.ts, store.test.ts, eval.test.ts, and sdk.test.ts load real GGUF models and exercise embed, rerank, and expandQuery against a real node-llama-cpp runtime. When another workload is holding most of the GPU (Ollama parking a large model, a sibling daemon mid-job, a stale qmd serve), those allocations fail and the suites produce a cascade of Failed to create any rerank context failures that look like code regressions but are not.

The existing describe.skipIf(!!process.env.CI) gates handle CI correctly but leave a dev box exposed: the suites run, fail loudly, and obscure the genuine regressions the suite is supposed to catch. Asking developers to chase ghost failures every time a co-resident model is loaded is the wrong default.

This PR adds a one-shot VRAM probe at module load and gates ten integration sub-suites that perform real allocations on whether the GPU has enough headroom. Suites that do not need a real LLM (BM25 search, MCP HTTP transport, path utilities, store creation, chunking metadata) continue to run unconditionally.

What this PR adds

A helper at test/_helpers/vram-precondition.ts, wired into the existing describe.skipIf cascades:

File Suite Why gated
test/llm.test.ts LlamaCpp Integration Real embed / rerank / expandQuery
test/llm.test.ts LLM Session Management Lifecycle test using real LLM
test/mcp.test.ts searchVec (vector similarity) Real embedding for the query
test/mcp.test.ts hybridQuery (expansion + reranking) Real expandQuery and rerank
test/mcp.test.ts MCP HTTP Transport End-to-end suite including reranking
test/store.test.ts Token-based Chunking Real tokenizer load
test/store.test.ts LlamaCpp Integration Real embed + vector search
test/eval.test.ts Vector Search Real embedding for the eval corpus
test/eval.test.ts Hybrid Search (RRF) Real embed + rerank for the eval corpus
test/sdk.test.ts with LLM query expansion Real expandQuery

The helper decides whether to skip:

  1. Cached result if present. Suites that import the helper a second time hit the cache.
  2. QMD_SKIP_GPU_INTEGRATION=1 short-circuits to false without probing. Useful for fast unit-loop iteration on a healthy box when you just want the non-GPU tests.
  3. First VRAM probe via getLlama().getVramState(). Threshold is 4 GB free, which covers embed (~0.7 GB) + rerank (~1.5 GB) + generate (~1.5 GB) + slack. CPU mode (no GPU) returns true without probing.
  4. If the first probe is insufficient AND QMD_TEST_EVACUATE_VRAM=1 is set, the helper asks Ollama to unload its resident models via POST /api/generate {keep_alive: 0} for each model listed by GET /api/ps, then re-probes once. Off by default: test infrastructure silently terminating another process's loaded models would be astonishing without explicit consent. OLLAMA_HOST overrides the default http://127.0.0.1:11434 endpoint.
  5. Probe failure fails open (returns true) so a genuine code failure surfaces in the test rather than being silently masked by a skip.

The eviction step is Ollama-only. Other VRAM consumers (qmd serve, sibling daemons) are deliberately out of scope: killing a user-facing daemon is too astonishing even behind the opt-in flag. All steps run best-effort with explicit timeouts (2 s for /api/ps, 5 s per unload). Connection failures, non-OK responses, and per-model unload errors are logged but do not abort the re-probe.

Dependencies on node-llama-cpp and the eviction HTTP calls are injected through options.getLlama and options.evacuate so the unit tests at test/vram-precondition.test.ts drive the gate without making real GPU calls or HTTP requests.

Behavior on the merged result

Environment Probe outcome Suites
CI (CI=1) not run skipped (unchanged)
Healthy local box (≥ 4 GB free GPU VRAM) sufficient run
CPU-only or node-llama-cpp not built probe fails open run (existing fail-loud behavior preserved)
QMD_SKIP_GPU_INTEGRATION=1 short-circuits skipped
Contested local box (< 4 GB free), no opt-in insufficient skipped with operator-readable warning
Contested local box, QMD_TEST_EVACUATE_VRAM=1, eviction frees enough sufficient on re-probe run
Contested local box, QMD_TEST_EVACUATE_VRAM=1, eviction insufficient still insufficient skipped with post-eviction message

Measured locally

Same contested GPU for both runs (RTX 3090 Ti, Ollama holding ~20 GB of gemma4:26b, ~3.1 GB free).

Default (skip path, no opt-in)

The probe sees 2.81 GB free, prints the operator-readable warning, and skips the gated suites.

Before this PR: 389 pass /  0 skip /  8 fail
After this PR:  354 pass / 62 skip /  0 fail

The eight integration failures previously surfaced as Failed to create any rerank context cascades are now clean skips with a single warning line:

⚠ Integration tests skipped: only 2.81 GB free GPU VRAM (need ≥ 4 GB).
  Set QMD_TEST_EVACUATE_VRAM=1 to attempt eviction first,
  or QMD_SKIP_GPU_INTEGRATION=1 to silence this probe.

Evacuate path (QMD_TEST_EVACUATE_VRAM=1)

The probe sees 2.81 GB free, hits the eviction branch, logs the eviction attempt, and asks Ollama to unload its loaded model. Re-probe shows headroom restored and the gated suites then run.

⚠ Insufficient VRAM (2.81 GB free, need ≥ 4 GB);
  QMD_TEST_EVACUATE_VRAM=1, attempting to evict known consumers...
⚠ Eviction: asking Ollama to unload 1 model(s): gemma4:26b-a4b-it-q4_K_M
✓ Eviction succeeded; 22.31 GB free, integration tests will run.

Whole suite (29 files): 876 pass / 80 skip / 0 fail against the gated suites.

Two caveats worth knowing:

  1. The integration tests that run after eviction can still hit individual rerank context allocation failures on a bare tobi:main checkout because the vanilla single-pass reclaim path is the weak link, not the headroom this gate restores. This is the same failure mode that feat(llm): --low-vram mode with on-demand reclaim on embed VRAM pressure #662 (lowVram two-pass reclaim) is designed to fix. Stacking feat(llm): --low-vram mode with on-demand reclaim on embed VRAM pressure #662 on top of this PR turns the post-eviction integration suite green; this PR alone restores the headroom but does not change reclaim behavior.
  2. Ollama may re-load a model between the eviction call and the actual test allocations if another client (an MCP agent, another ollama run, a residual keep_alive from a recent request) re-requests the same model during the suite run. The gate is a probe-once mechanism; it does not pin Ollama into a quiet state for the duration of the suite.

The eviction path is most useful on developer machines where the operator opts in deliberately and expects the test session to take priority over background Ollama state for a few minutes.

Test coverage

19 unit tests on the helper (test/vram-precondition.test.ts), all driven through injected getLlama and evacuate so no real GPU or HTTP request runs in this suite:

  • CPU mode and CPU-offload-forced skip the VRAM probe entirely.
  • Plentiful VRAM (20 GB), exactly at threshold (4 GB), just below threshold (boundary behavior).
  • Ollama-hogged scenario (~1.4 GB free) returns false and emits the operator-readable warning.
  • QMD_SKIP_GPU_INTEGRATION=1 forces a skip without probing.
  • getLlama and getVramState throwing both fail open.
  • Result is cached so repeated calls do not re-probe.
  • Skip warning logs at most once per process.
  • Eviction does not run when QMD_TEST_EVACUATE_VRAM is unset.
  • Eviction runs when QMD_TEST_EVACUATE_VRAM=1 and first probe insufficient, then re-probes.
  • Eviction runs but does not free enough → returns false with post-eviction message.
  • Eviction throws → still re-probes (best-effort), the throw is logged.
  • First probe already sufficient → eviction is not called even when env=1.
  • QMD_SKIP_GPU_INTEGRATION=1 precedence wins over QMD_TEST_EVACUATE_VRAM=1.
  • Red-team: env values other than the exact string "1" ("0", "false", "true", "yes", "") do not trigger skip or eviction.
  • Default eviction path against an unreachable Ollama logs the connection failure and the re-probe still runs.

The helper test harness explicitly snapshots and clears QMD_SKIP_GPU_INTEGRATION, QMD_TEST_EVACUATE_VRAM, and OLLAMA_HOST in beforeEach / afterEach, so the helper suite passes identically under each of (no env vars), QMD_TEST_EVACUATE_VRAM=1, and QMD_SKIP_GPU_INTEGRATION=1. Without that isolation the tests would inherit the operator's chosen env at the surrounding process level and false-fail.

tsc --noEmit clean on the helper and its tests.

Backwards compatibility

  • Purely additive. Suites that already had describe.skipIf(!!process.env.CI) keep the CI short-circuit and add the VRAM precondition via boolean-OR.
  • Default behavior on a healthy local box is unchanged: the probe returns true, the suites run.
  • Default behavior in CI is unchanged: CI=1 short-circuits before the probe matters.
  • The two new env vars (QMD_SKIP_GPU_INTEGRATION, QMD_TEST_EVACUATE_VRAM) are opt-in and read precisely (only the string "1" triggers). OLLAMA_HOST is read only when eviction is opted into.

Changelog

Added

  • VRAM precondition helper at test/_helpers/vram-precondition.ts that probes free GPU VRAM once per process and reports whether the integration suites can run meaningfully. Gates ten GPU-dependent sub-suites across test/llm.test.ts, test/mcp.test.ts, test/store.test.ts, test/eval.test.ts, and test/sdk.test.ts.
  • QMD_SKIP_GPU_INTEGRATION=1 env var forces the integration suites to skip without probing the GPU.
  • QMD_TEST_EVACUATE_VRAM=1 env var opts into asking Ollama to unload its resident models when the initial VRAM probe shows insufficient headroom, then re-probing. Off by default.
  • OLLAMA_HOST env var overrides the default http://127.0.0.1:11434 endpoint used by the eviction step.

Type of Change

  • test: Adding or updating tests
  • feat: New feature
  • BREAKING CHANGE: Breaking API change

Related Issues/Stories

Testing

  • Unit tests added (test/vram-precondition.test.ts, 19 cases)
  • Integration tests added (the gated suites themselves now skip cleanly when GPU is contested)
  • Manual testing completed (verified against an Ollama-hogged GPU, both skip and evacuate paths)
  • All tests passing

Files Modified

Modified:

  • test/llm.test.ts: VRAM precondition import + probe + skipIf extension on two integration sub-suites.
  • test/mcp.test.ts: same on three integration sub-suites.
  • test/store.test.ts: same on two integration sub-suites.
  • test/eval.test.ts: same on two integration sub-suites.
  • test/sdk.test.ts: same on one integration sub-suite.

Created:

  • test/_helpers/vram-precondition.ts: the helper itself.
  • test/vram-precondition.test.ts: unit tests for the helper (19 cases including red-team).

Renamed:

  • None.

Deleted:

  • None.

Breaking Changes

  • No breaking changes.

Deployment Notes

  • No special deployment steps required. The new env vars are opt-in; default behavior on a healthy local box and in CI is unchanged.

Adds `test/_helpers/vram-precondition.ts` and wires it into the ten `describe.skipIf` gates across `test/llm.test.ts`, `test/mcp.test.ts`, `test/store.test.ts`, `test/eval.test.ts`, and `test/sdk.test.ts`. Each integration suite was previously gated only on `CI` (a few also `QMD_REMOTE_URL`). On a dev box where Ollama or another workload is parking most of the GPU, the suites ran against contested VRAM and produced eighteen `Failed to create any rerank context` failures that did not represent code regressions. The new gate probes free VRAM once per process and skips the suites when the headroom is insufficient.

The helper:

- Probes `getLlama().getVramState()` once and caches the result for the process lifetime.
- Threshold: 4 GB free covers a meaningful run of all three models (embed + rerank + generate ≈ 3.5 GB peak plus margin).
- `QMD_SKIP_GPU_INTEGRATION=1` env override forces a skip without probing, useful for fast unit-loop iteration on a healthy box. Only the exact string `"1"` triggers; any other value (including truthy strings like `"true"`) is treated as unset.
- CPU mode (`gpu === false`) returns true without probing. Per-token VRAM cost model does not apply.
- Probe failure fails open (returns true) so a genuine code failure surfaces in the test rather than being silently masked by a skip.
- Loud one-shot warning when a skip is recorded, with the actual free-GB value and the override hint. Helps the operator diagnose why integration suites were silent in a CI tail.
- Dependency injection seam (`options.getLlama`, `options.minFreeGB`, `options.log`) so the unit tests of the helper run without loading the real node-llama-cpp runtime.

Test coverage (`test/vram-precondition.test.ts`, 12 cases):

- CPU mode returns true without probing.
- Plentiful VRAM, exactly at threshold, and just below threshold (boundary behavior).
- Ollama-hogged scenario (~1.4 GB free) returns false and emits the warning.
- `QMD_SKIP_GPU_INTEGRATION=1` forces a skip without probing.
- `getLlama` and `getVramState` throwing both fail open.
- Result is cached so repeated calls do not re-probe.
- Skip warning logs at most once per process.
- Red-team: env values other than the exact string `"1"` (e.g. `"0"`, `"false"`, `"true"`, `"yes"`, `""`) do not force a skip.
- Concurrent first-call behavior documented (current implementation does not coalesce racing probes; cached value wins after first resolve).

Wider-suite impact under the actual Ollama-hogged GPU state where this was developed (2.8 GB free of 24 GB):

- Before the gate: 898 pass / 18 fail (all `LlamaCpp Integration > rerank ...`-style failures from contested VRAM).
- After the gate: 855 pass / 82 skip / 0 fail. The integration suites skip with a single-line warning instead of producing a wall of unrelated red.

`CI=true` and `QMD_REMOTE_URL=...` continue to short-circuit before the probe, so CI and remote-server runs are unchanged.

- `test/_helpers/vram-precondition.ts` helper that probes free GPU VRAM and gates the GPU-dependent integration test suites when headroom is insufficient.
- `QMD_SKIP_GPU_INTEGRATION=1` env var to force-skip the integration suites without probing the GPU.

- Integration suite gates in `test/llm.test.ts`, `test/mcp.test.ts`, `test/store.test.ts`, `test/eval.test.ts`, and `test/sdk.test.ts` now include the VRAM precondition alongside the existing `CI` and `QMD_REMOTE_URL` checks.
… suites

Extends the VRAM precondition helper with an opt-in eviction step. When the initial probe shows insufficient free VRAM AND `QMD_TEST_EVACUATE_VRAM=1` is set, the helper asks Ollama to unload its currently resident models, re-probes once, and runs the integration suites if the eviction freed enough headroom.

Off by default. Test infrastructure silently terminating another process's loaded models would be astonishing without explicit consent, even on a contested dev box. Operators who want the integration suites to power through a stuck-Ollama state opt into the behavior with a single env-var.

Scope of eviction:

- Ollama only. The helper hits `GET /api/ps` to list loaded models, then issues `POST /api/generate` with `keep_alive: 0` for each one. The `OLLAMA_HOST` env var overrides the default `http://127.0.0.1:11434` endpoint.
- qmd-serve and gbrain are deliberately out of scope. Killing a user-facing daemon is too astonishing even behind the opt-in flag; if a stale qmd-serve is holding VRAM, the operator stops it manually.

All steps run best-effort with explicit timeouts (2s for `/api/ps`, 5s per unload). Connection failures, non-OK responses, and per-model unload errors are logged through the same `log` channel as the rest of the helper. A throwing eviction step still triggers the re-probe so a partial recovery (some models unloaded before the failure) still helps.

The dependency on the network call is injected through `options.evacuate` so the unit tests of the helper drive the env-gating, sequencing, and best-effort semantics without making real HTTP requests. One test exercises the real default eviction path by pointing `OLLAMA_HOST` at a port nothing listens on, asserting that the connection failure is swallowed and the re-probe still runs.

Resolution order in the helper now:

1. Cached result if present.
2. `QMD_SKIP_GPU_INTEGRATION=1` short-circuits to false without probing or evicting. This env var wins over `QMD_TEST_EVACUATE_VRAM=1` so an operator can override a stale opt-in.
3. First VRAM probe. If sufficient, return true.
4. `QMD_TEST_EVACUATE_VRAM=1` → run eviction, re-probe once, return the second probe's result.
5. Otherwise return false with the loud skip warning.

The skip message now mentions both env vars so the operator can pick the response that matches the situation.

Test coverage added to `test/vram-precondition.test.ts` (eight new cases on top of the existing twelve):

- Eviction does not run when `QMD_TEST_EVACUATE_VRAM` is unset, regardless of probe result.
- Eviction runs and the re-probe succeeds → returns true, both probes counted.
- Eviction runs but the re-probe is still insufficient → returns false, post-eviction message logged.
- Eviction throws → re-probe still runs (best-effort), the throw is logged.
- First probe already sufficient → eviction is not called even when the env var is set.
- `QMD_SKIP_GPU_INTEGRATION=1` precedence wins over `QMD_TEST_EVACUATE_VRAM=1`.
- Red-team: env values other than the exact string `"1"` (`"0"`, `"false"`, `"true"`, `"yes"`, `""`) do not trigger eviction.
- Default eviction path against an unreachable Ollama logs the connection failure and the re-probe still runs.

Twenty tests pass on the file. The existing skip-gate wiring across `test/llm.test.ts`, `test/mcp.test.ts`, `test/store.test.ts`, `test/eval.test.ts`, and `test/sdk.test.ts` is unchanged: the helper's public surface is the same `hasSufficientFreeVramForIntegration()` returning a boolean. Suites that already gated on it now transparently benefit from the eviction option.

## Changelog

### Added
- `QMD_TEST_EVACUATE_VRAM=1` opts the VRAM-precondition helper into asking Ollama to unload its resident models when the initial free-VRAM probe shows insufficient headroom, then re-probing. Off by default to avoid side effects on user-facing daemons.
- `OLLAMA_HOST` env var overrides the default `http://127.0.0.1:11434` endpoint used by the eviction step.

### Changed
- The skip warning emitted by the VRAM precondition helper now lists both `QMD_TEST_EVACUATE_VRAM=1` and `QMD_SKIP_GPU_INTEGRATION=1` as available operator responses.
…AMA_HOST env

The first `describe` block of `test/vram-precondition.test.ts` cleared `QMD_SKIP_GPU_INTEGRATION` in its `beforeEach` but not `QMD_TEST_EVACUATE_VRAM` or `OLLAMA_HOST`. When the surrounding process set `QMD_TEST_EVACUATE_VRAM=1` (which is how an operator would exercise the eviction path against a real contested GPU), the helper's default `defaultEvacuate` fired against the actual Ollama daemon inside tests that expected to assert the skip-only path:

- `returns false on the Ollama-hogged scenario (~1.4 GB free)` injected a low-VRAM probe and expected the result to be `false`. With the leaked env var, the post-probe code path triggered the real eviction, freed VRAM at Ollama, then re-probed against the test's injected `getLlama`. The same injected probe still returned 1.4 GB free on the second call, so the helper surfaced eviction-related warnings the test did not expect to count.
- `only logs the skip message once across calls` asserted exactly one log message; the leaked env path emits additional eviction-attempt log lines, breaking the count.

Same risk applies to `OLLAMA_HOST`: a leaked override would point the default eviction step at the wrong endpoint and silently change what the helper observed.

The fix is symmetric isolation. Both `describe` blocks now snapshot and clear `QMD_SKIP_GPU_INTEGRATION`, `QMD_TEST_EVACUATE_VRAM`, and `OLLAMA_HOST` in `beforeEach`, then restore in `afterEach`. The second block already cleared the first two; this adds `OLLAMA_HOST` symmetry. The first block adds all three.

The helper itself is unchanged. The bug was purely in how the test harness handled inherited environment.

Verified by running the helper suite under three env states:

- No env vars set: 20 pass / 0 fail.
- `QMD_TEST_EVACUATE_VRAM=1`: 20 pass / 0 fail (previously 18 pass / 2 fail).
- `QMD_SKIP_GPU_INTEGRATION=1`: 20 pass / 0 fail.

## Changelog

(none: test-only fix, internal isolation)
@brettdavies
brettdavies force-pushed the feat/test-vram-precondition branch from 95b6e6c to 8f558b9 Compare June 24, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant