Skip to content

QVAC-22629 feat: run an opt-in advisory llama.cpp fit check before loadModel - #4010

Draft
simon-iribarren wants to merge 6 commits into
mainfrom
feat/qvac-22629-advisory-fit-supervisor
Draft

QVAC-22629 feat: run an opt-in advisory llama.cpp fit check before loadModel#4010
simon-iribarren wants to merge 6 commits into
mainfrom
feat/qvac-22629-advisory-fit-supervisor

Conversation

@simon-iribarren

@simon-iribarren simon-iribarren commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Draft. The dependency floor is now @qvac/model-fit: ^0.7.0, which is
published — 0.6.0 was cut by #4008 but its publish run failed on a Windows
prebuild and never reached npm, so 0.7.0 is the first published version
carrying process protocol v2. Everything below is verified against that
published package. Still draft because #3595 moved the engine from
packages/sdk into packages/inference after this branch was cut; see
Re-target required at the bottom.

🎯 What problem does this PR solve?

  • PR QVAC-22630 feat[api]: add config normalization to model-fit addon #3930 added process protocol v2 to @qvac/model-fit, but nothing in the SDK calls it: loadModel still has no way to ask whether the llama.cpp load it is about to run is projected to fit.
  • The approved isolated-fit supervisor from QVAC-22631 was written against protocol v1, lived under client/, and was detached from any load path.

📝 How does it solve it?

  • Adapts the supervisor to protocol v2 (encodeFitLlamaProcessRequest, explicit loadKind, FitLlamaResult) and moves it from client/model-fit to server/bare/model-fit, where the call site and the resolved flat load config both live. The #model-fit-environment import map moves with it.
  • Builds the fit request from the same transform the real load uses — transformLlmConfig for completion, and transformEmbedConfig, extracted out of the embedding plugin into llamacpp-embedding/transform.ts so both callers share one function.
  • Forwards only the load settings model-fit reads as evidence, drops the known non-memory ones, and refuses any load carrying a setting the SDK cannot classify. This partitioning is required, not cosmetic: model-fit rejects anything outside its own allowlist as unrecognized llama load setting, and a completion load config always carries sampling/generation/presentation keys (temp, top_k, predict, image_tile_mode, …). Forwarding the whole map would return unsupported-config for every real load. Refusing on an unclassified key means a memory-affecting key added later cannot be silently dropped into a wrong answer.
  • Pins nCtxMin to the requested ctx_size when positive, so the fitter answers for the exact configuration about to run rather than one it is free to retreat to. Left unset for ctx_size: 0; embedding sends no context and applyEmbeddingContextPolicy pins it inside the package.
  • Refuses mobile, sharded, multimodal-projection, LoRA, and non-llama.cpp loads without starting a child. Value-level policy (device names, symbolic GPU selection, context bounds) stays inside model-fit.
  • Invokes the check in server/bare/ops/load-model.ts after schema defaults, resolveConfig, and path validation, and before plugin.createModel() / model.load(false), so the child never competes with the native load for device memory.
  • Advisory and fail-open. fit, does-not-fit, unsupported-config, model-unreadable, no-backend-device, crash, signal, timeout, cancellation, malformed/oversized output, and any internal error all continue the ordinary load path. runAdvisoryFitCheck never throws and never rejects; a failing logger cannot turn an advisory check into a load failure. Neither verdict is called fitsUnchanged — duplicated policy can drift and the real loader still neither consumes nor verifies the fitted plan.
  • Opt-in, off by default, behind QVAC_ADVISORY_MODEL_FIT (1/true/on/yes), added to the worker env schema as a free-form optional string so a typo cannot fail worker startup. Nothing consumes the result yet, and enabling it costs a disposable child process plus a full ggml backend registration on every completion/embedding load. Every runtime dependency loads lazily, so a disabled check costs one env read and loads no Bare process machinery.
  • Adds examples/advisory-model-fit.ts: enables the check, loads one configuration projected to fit and one projected not to fit, and reprints the verdicts off the SDK server log stream. Its header records what does and does not fit on a 24 GiB Apple M4 Pro, measured rather than estimated.
  • Reports "no fit evidence" at info rather than debug. The check only runs when explicitly enabled to gather evidence, so the reason there was no verdict is the most useful thing it can say; at debug the default logger hid it and the feature looked inert.
  • Routes bun:test files in the unit aggregator through bun test. They were previously executed with bun run, which cannot load them — the supervisor suite was silently failing test:unit and now runs there for the first time.

No public SDK API, export, RPC, or plugin-interface change. No llm-llamacpp or embed-llamacpp change. No fit evidence or plan is passed to any addon.

🧪 How was it tested?

All from packages/sdk, with packages/model-fit linked into packages/sdk/node_modules (protocol v2 is unpublished — see the note at the top).

  • bun test test/unit/model-fit/ — 68 pass, 0 fail: 42 supervisor cases, 10 request-builder cases, 10 advisory-orchestration cases, plus 2 smoke tests that really compile and load the environment specifier through the moved import map.
  • bun run test:model-fit-process — 8 pass, 0 fail. Real Node-parent and Bare-parent processes against protocol v2 for completed, abnormal exit, hang/timeout, and abort.
  • bun run test:unit — the only failures are the 16 pre-existing Cannot find package '#rpc' load errors, unchanged in count. No not ok.
  • bun run typecheck, bun run lint, bun run format — no new findings. Five files fail typecheck and two fail lint on the baseline; the identical set fails on a clean checkout of this branch's base (stale local node_modules against main's newer addon floors).

Two defects were found and fixed during verification:

  1. The ported supervisor suite hung indefinitely. With the workspace package linked, @qvac/model-fit/process and the test's relative ../../../../model-fit/process.js resolve to the same file, so mock.module replaced the live bindings the mock's own pass-through called — unbounded self-recursion. The test now imports the package's codec source (model-fit/src/process.ts), a distinct module; generated-vs-source parity is enforced inside the model-fit package.
  2. All three model-fit test files were silently failing test:unit (the aggregator routing described above). The two new suites are written in the repo's dominant brittle style; the ported suite needs mock.module, hence the aggregator fix.

Not verified here: bun run build (blocked by the baseline lint/type errors, not by this change; the moved import map is what the smoke tests cover), real GGUF fitting through the SDK path (needs published 0.6.0, and in-process fitting on this machine is still blocked by the known Metal init crash), and Linux/Windows process behavior — Windows overlapped stdio remains unit-tested only.

🔬 Verified on real hardware

Apple M4 Pro, 24 GiB unified memory, @qvac/model-fit@0.7.0, models already cached.

End to end through loadModel, with QVAC_ADVISORY_MODEL_FIT=1:

▸ Loading Qwen3.5 0.8B @ 4k
   ⟶ [INFO] [advisory-fit:llamacpp-completion:7c82…] projected to fit (advisory only)
           — nCtx 4096, nGpuLayers 99 across 1 GPU device(s)
▸ Completion still works normally

▸ Loading gpt-oss-20B @ 128k with an f32 KV cache
   ⟶ [WARN] [advisory-fit:llamacpp-completion:6d7d…] projected not to fit
           (advisory only — the load continues unchanged)
▸ Load returned 6d7da91bfe3d04c9 — the advisory verdict did not block it

A 15-configuration fit matrix ran in 360–510 ms per fit. All six structural
refusals (multimodal, LoRA, sharded, unclassified key, non-llama type, mobile)
resolve without starting a child.

The verdicts are not reliable in either direction on this hardware. Tested
against real inference, not against load success:

Configuration Verdict Loads? Runs? Verdict was
gpt-oss-20B @128k, f32 KV, idle machine does-not-fit yes yes, 69 tok/s false negative
Gemma 4 31B @1k, gpu_layers: 99 does-not-fit yes no — decode fails true positive
Gemma 4 31B @1k, gpu_layers: 48 fits (margin 0) yes no — decode fails false positive
gpt-oss-20B @128k, f32 KV, 0.5 GiB model resident does-not-fit yes no — decode fails right, wrong reason

Four things follow, and together they are the argument for keeping this
advisory:

  1. loadModel returning an id does not mean the model is usable. Gemma 4
    31B loads in 25–29 s and then fails at the first decode with [TextLlm] failed to decode next token. On device: 'cpu' the same model decodes at
    0.1 tok/s, so this is a memory-placement failure, not a broken GGUF.
  2. Errors go both ways. gpu_layers: 48 is a configuration the fitter
    accepted and it still cannot decode. So this is not one-directional
    conservatism that a looser budget would fix — a looser budget would add false
    positives.
  3. The fit projects one model onto an idle machine. It has no notion of
    what is already resident: the identical gpt-oss configuration runs at 69
    tok/s alone and fails to decode with a 0.5 GiB model still loaded.
  4. The budget premise is too tight. The fitter treats Metal's
    recommendedMaxWorkingSetSize (19069 MiB here) as a hard ceiling; the real
    loader allocated 19678 MiB and the load succeeded. That premise comes from
    common_fit_params in qvac-fabric via ggml_backend_dev_memory, so it lives
    upstream of @qvac/model-fit.

device: 'cpu' verdicts carried no information at all — the fitter treats host
memory as unlimited — so the SDK now refuses them before spawning a child.

Also found, unrelated to this PR: after the first unloadModel,
loggingStream({ id: SDK_LOG_ID }) silently stops delivering for the rest of the
process. The example unloads last to work around it.

⚠️ Re-target required before merge

#3595 moved the engine out of
@qvac/sdk into @qvac/inference after this branch was cut. Every production
file this PR touches has moved:

This PR Now lives at
packages/sdk/server/bare/ops/load-model.ts packages/inference/src/plugins/ops/load-model.ts
packages/sdk/server/bare/plugins/llamacpp-* packages/inference/src/plugins/builtin/llamacpp-*
packages/sdk/server/env.ts packages/inference/src/runtime/env.ts

The behaviour and the evidence above carry over unchanged; the code has to move
package. Consequences: @qvac/inference is Bare-only, so the bare/node
environment split and the #model-fit-environment import map can be dropped;
the @qvac/model-fit floor moves to packages/inference/package.json; and that
package tests with brittle-bare over compiled output, so the bun:test
supervisor suite needs rewriting and the aggregator change here becomes
unnecessary.

Adds the SDK-internal supervisor that runs @qvac/model-fit in one disposable
Bare child over process protocol v2, without wiring it into any load path yet.

- Sends a v2 request with an explicit completion/embedding loadKind and the
  flat load params, and decodes the single response with the package codec.
- Refuses to resolve or spawn the packaged runner on Android, iOS, and browser
  hosts, where no disposable process boundary exists.
- Bounds the lifecycle: deadline, SIGTERM/SIGKILL escalation, post-exit drain,
  strict single-line framing, 1 MiB response cap, and a 16 KiB stderr tail.
- Every failure mode settles as a structured unknown; exit code and stderr stay
  diagnostic and never become an admission decision.
- Routes bun:test files in the unit aggregator through `bun test`, so the
  supervisor suite runs as part of `test:unit` instead of failing to load.
…adModel

Runs the isolated fit as advisory information in front of a completion or
embedding load. No outcome changes the load: fit, does-not-fit, unsupported,
crashed, timed out, malformed, and internal-error all continue the ordinary
path.

- Builds the request from the same transform the real load uses, so fit sees
  the resolved load state rather than a second interpretation of it.
- Forwards only the load settings model-fit reads as evidence, drops the known
  non-memory ones, and refuses any load carrying a setting the SDK cannot
  classify — an unclassified key must not silently change the question asked.
- Pins the requested context as the reduction floor so the fitter answers for
  the configuration about to run, and leaves it unset for an auto context.
- Refuses mobile, sharded, multimodal, LoRA, and non-llama.cpp loads without
  starting a child; value-level policy stays inside model-fit.
- Gated on QVAC_ADVISORY_MODEL_FIT and off by default: nothing consumes the
  result yet, so no load should pay a child process and a full ggml backend
  registration for a log line. Every runtime dependency loads lazily, so a
  disabled check costs one env read.
- Extracts transformEmbedConfig out of the embedding plugin so both the fit
  request and the real load share one transform.
@github-actions

Copy link
Copy Markdown
Contributor

License compliance — clean

No new dependency license findings in this PR.

Warn-only (shadow) mode — this check does not block merges yet.

Updated automatically by the canonical license compliance workflow.

NOTICE presence (advisory)

Missing NOTICE (advisory, does not block):

  • ./.github/actions/release-merge-guard
  • ./docs/website
  • ./packages/ggml-coload-smoke
  • ./packages/fabric/test/integration
  • ./packages/inference-addon-cpp/mobile
  • ./packages/sdk/e2e
  • ./packages/llm-llamacpp/benchmarks/performance
  • ./packages/llm-llamacpp/benchmarks/server
  • ./packages/vla-ggml/sim/server
  • ./packages/embed-llamacpp/benchmarks/performance
  • ./packages/embed-llamacpp/benchmarks/server
  • ./packages/asr-ggml/benchmarks/server

…sible

Adds `examples/advisory-model-fit.ts`, which enables the check, loads a
configuration projected to fit and one projected not to fit, and reprints the
verdicts from the SDK server log stream. Its header records what does and does
not fit on a 24 GiB Apple M4 Pro, measured with the addon.

- Report "no fit evidence" at info rather than debug. The check only runs when
  it has been explicitly enabled to gather evidence, so the reason there was no
  verdict is the most useful thing it can say; at debug the default logger hid
  it and the feature looked inert.
- Raise the `@qvac/model-fit` floor to ^0.7.0. 0.6.0 was cut but its publish run
  failed on a Windows prebuild and never reached npm; 0.7.0 is the first
  published version carrying process protocol v2.

The example unloads both models at the end rather than between phases: calling
`unloadModel` mid-run currently silences the SDK server log stream for the rest
of the process, which would hide the second verdict.
…o evidence

llama.cpp's fitter constrains device memory but treats host memory as
unlimited, so a CPU load is always projected to fit regardless of size.
Measured on a 24 GiB M4 Pro: an 18.3 GiB model at 32k context reports `fits`
on `device: 'cpu'`.

Refused alongside the other structural cases, before a child process is spent
producing an answer that says nothing.
Loading is not usability. The example now runs a completion after the
`does-not-fit` load and reports throughput, so it demonstrates whether the
verdict was right rather than assuming a returned model id means success.

On this hardware gpt-oss-20B @128k with an f32 KV cache reaches 69 tok/s, so
that verdict is a measured false negative. Gemma 4 31B is the opposite: it
loads and then fails at the first decode.

Each phase now unloads before the next and resubscribes to the log stream. The
fit projects a single model onto an idle machine with no notion of what is
already resident, so holding the first model loaded changed the second phase's
outcome without changing its verdict.
The fit child is a fresh process, and Metal reports free memory as
`recommendedMaxWorkingSetSize - currentAllocatedSize` per process
(ggml-metal-device.m), so the child sees an idle device regardless of what the
worker holds resident. Measured on a 24 GiB M4 Pro: Qwen3.5 9B Q6_K @131k is
projected to fit both on an idle machine and with 11 GiB of gpt-oss resident,
and the second projection admits a load that cannot decode.

Sum the on-disk weight sizes of every registered model and add them to the
request's `marginMiB` on top of the package's 1024 default. Weight size is a
lower bound - resident KV and compute buffers are not counted - so verdicts
stay optimistic, but strictly less so than ignoring residency.

Verified end to end: the identical request now reads `fits` idle and
`does-not-fit` with gpt-oss resident. Fail-open as ever - a failing stat or
registry probe contributes zero rather than an error.
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