Concurrent qmd invocations each load models independently — exhausts system RAM on CPU-only hosts (refiling #147 with 2.8.3 repro)
Problem
Every qmd invocation loads its own models. Nothing coordinates across processes, so N concurrent invocations means N independent model loads. On a CPU-only host this is enough to exhaust system memory, and no single call is at fault — each one is individually reasonable.
Refiling per the closing note on #147:
Closing as part of the post-v2.5.1 backlog cleanup [...] If it still reproduces on v2.5.1 or newer, please open a fresh focused issue with current repro steps.
It still reproduces on 2.8.3. This report adds the dimension #147 lacked: #147 was GPU/unified-memory on Apple Silicon, this is CPU-only Linux system RAM, same failure class.
Reproduction
Host: 16 GB RAM, CPU-only, no GPU. Four qmd invocations started concurrently.
Each invocation was wrapped in its own systemd scope so the peaks could be attributed per process rather than guessed:
systemd-run --user --scope --collect \
-p MemoryAccounting=yes -p MemoryHigh=4G -p MemoryMax=6G -p MemorySwapMax=2G \
qmd query -c <collection> "<question>"Measured, one row per scope:
| Wall time | CPU time | Peak RSS | Peak swap |
|---|---|---|---|
| 46.2s | 24.1s | 4.5 GB | 307 MB |
| 2m00.1s | 17.6s | 4.6 GB | 638 MB |
| 3m00.1s | 19.3s | 4.5 GB | 618 MB |
| 4m12.5s | 25.4s | 4.4 GB | 614 MB |
Roughly 4.5 GB per concurrent invocation. Four at once demands about 18 GB on a 16 GB host. A single call is comfortable; four are not, and nothing in qmd signals or prevents the difference.
A separate narrow query with default settings hit a 120s timeout while reranking only 14 chunks, producing no recoverable payload.
What the containment run demonstrates
Under MemoryMax=6G there was no OOM kill. MemoryHigh was exceeded during model load and the cgroup reclaimed, but the ceiling held. Serialized calls with --no-rerank -C 10 completed reliably; embedding generation took about 3.6s on the successful runs.
So the failure is not a leak and not an unreasonable per-call footprint. It is unbounded concurrent model residency.
Cost ladder observed
Documenting this because it is not obvious from the CLI surface, and it is what made the workaround possible:
| Command | Models loaded |
|---|---|
qmd search |
none (BM25) |
qmd vsearch |
embed |
structured qmd query --no-rerank |
embed |
structured qmd query |
embed + rerank |
bare qmd query "<sentence>" |
embed + rerank + expansion (~1.1 GB) |
Supplying lex:/vec:/hyde: explicitly skips auto-expansion and avoids the largest single model. Agent integrations that pass a raw user sentence to qmd query therefore land on the most expensive rung by default, which is how the concurrent load above was reached in the first place.
Question
Is qmd serve (#662, #663, #927) the intended answer here, since a resident server loads models once instead of once per process? If so, that direction fully addresses this and the request reduces to documenting the multi-agent guidance.
If the plain CLI path is expected to stay viable for concurrent agent use, then a cross-process guard seems necessary — an advisory lock or a small semaphore around model-backed commands, so the Nth caller waits instead of loading models the host cannot hold. There is precedent in-tree for the single-process version of this in #874 (serialize rerank context creation on cold start) and #903 (serialize concurrent FTS table create on cold open); this is the same idea one level up.
Either resolution works from here. Filing so the CPU-only measurements exist somewhere, since the current workaround is external containment plus manual serialization.
Environment
- qmd 2.8.3 (npm
@tobilu/qmd) - Linux, Ubuntu 26.04, x86_64
- 16 GB RAM, CPU-only, no GPU
- Node.js 24
- Invoked from both the CLI and the MCP server
Source: tobi/qmd