QVAC-22629 feat: run an opt-in advisory llama.cpp fit check before loadModel - #4010
Draft
simon-iribarren wants to merge 6 commits into
Draft
QVAC-22629 feat: run an opt-in advisory llama.cpp fit check before loadModel#4010simon-iribarren wants to merge 6 commits into
simon-iribarren wants to merge 6 commits into
Conversation
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.
Contributor
License compliance — cleanNo 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):
|
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎯 What problem does this PR solve?
@qvac/model-fit, but nothing in the SDK calls it:loadModelstill has no way to ask whether the llama.cpp load it is about to run is projected to fit.client/, and was detached from any load path.📝 How does it solve it?
encodeFitLlamaProcessRequest, explicitloadKind,FitLlamaResult) and moves it fromclient/model-fittoserver/bare/model-fit, where the call site and the resolved flat load config both live. The#model-fit-environmentimport map moves with it.transformLlmConfigfor completion, andtransformEmbedConfig, extracted out of the embedding plugin intollamacpp-embedding/transform.tsso both callers share one function.model-fitreads 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-fitrejects anything outside its own allowlist asunrecognized 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 returnunsupported-configfor every real load. Refusing on an unclassified key means a memory-affecting key added later cannot be silently dropped into a wrong answer.nCtxMinto the requestedctx_sizewhen positive, so the fitter answers for the exact configuration about to run rather than one it is free to retreat to. Left unset forctx_size: 0; embedding sends no context andapplyEmbeddingContextPolicypins it inside the package.model-fit.server/bare/ops/load-model.tsafter schema defaults,resolveConfig, and path validation, and beforeplugin.createModel()/model.load(false), so the child never competes with the native load for device memory.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.runAdvisoryFitChecknever throws and never rejects; a failing logger cannot turn an advisory check into a load failure. Neither verdict is calledfitsUnchanged— duplicated policy can drift and the real loader still neither consumes nor verifies the fitted plan.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.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.inforather thandebug. 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; atdebugthe default logger hid it and the feature looked inert.bun:testfiles in the unit aggregator throughbun test. They were previously executed withbun run, which cannot load them — the supervisor suite was silently failingtest:unitand now runs there for the first time.No public SDK API, export, RPC, or plugin-interface change. No
llm-llamacpporembed-llamacppchange. No fit evidence or plan is passed to any addon.🧪 How was it tested?
All from
packages/sdk, withpackages/model-fitlinked intopackages/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-existingCannot find package '#rpc'load errors, unchanged in count. Nonot 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 localnode_modulesagainstmain's newer addon floors).Two defects were found and fixed during verification:
@qvac/model-fit/processand the test's relative../../../../model-fit/process.jsresolve to the same file, somock.modulereplaced 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.test:unit(the aggregator routing described above). The two new suites are written in the repo's dominantbrittlestyle; the ported suite needsmock.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 — Windowsoverlappedstdio 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, withQVAC_ADVISORY_MODEL_FIT=1: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:
gpu_layers: 99gpu_layers: 48Four things follow, and together they are the argument for keeping this
advisory:
loadModelreturning an id does not mean the model is usable. Gemma 431B loads in 25–29 s and then fails at the first decode with
[TextLlm] failed to decode next token. Ondevice: 'cpu'the same model decodes at0.1 tok/s, so this is a memory-placement failure, not a broken GGUF.
gpu_layers: 48is a configuration the fitteraccepted 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.
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.
recommendedMaxWorkingSetSize(19069 MiB here) as a hard ceiling; the realloader allocated 19678 MiB and the load succeeded. That premise comes from
common_fit_paramsin qvac-fabric viaggml_backend_dev_memory, so it livesupstream of
@qvac/model-fit.device: 'cpu'verdicts carried no information at all — the fitter treats hostmemory 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 theprocess. The example unloads last to work around it.
#3595 moved the engine out of
@qvac/sdkinto@qvac/inferenceafter this branch was cut. Every productionfile this PR touches has moved:
packages/sdk/server/bare/ops/load-model.tspackages/inference/src/plugins/ops/load-model.tspackages/sdk/server/bare/plugins/llamacpp-*packages/inference/src/plugins/builtin/llamacpp-*packages/sdk/server/env.tspackages/inference/src/runtime/env.tsThe behaviour and the evidence above carry over unchanged; the code has to move
package. Consequences:
@qvac/inferenceis Bare-only, so the bare/nodeenvironment split and the
#model-fit-environmentimport map can be dropped;the
@qvac/model-fitfloor moves topackages/inference/package.json; and thatpackage tests with
brittle-bareover compiled output, so thebun:testsupervisor suite needs rewriting and the aggregator change here becomes
unnecessary.