feat(mtmd): multimodal prefill through the kernel + batched fan-out coverage - #49
feat(mtmd): multimodal prefill through the kernel + batched fan-out coverage#49lloyal-research wants to merge 52 commits into
Conversation
…r tests
Thread image+text inference through the native layer. mtmd (llama.cpp's
multimodal library) is consumed only by the binding worker; liblloyal stays
mtmd-free (see liblloyal 42e5951 for the decode::embd / prefill_embd
substrate).
Build:
- add_subdirectory(tools/mtmd) with EXCLUDE_FROM_ALL (skips its CLI
targets) + LLAMA_INSTALL_VERSION set locally (directory-scoped inside
llama.cpp, empty here otherwise) + MTMD_VIDEO OFF (compile-def only).
- mtmd joins target_link_libraries (static into the .node on Windows) and
the POST_BUILD SONAME foreach — platform packages and the DL pack pick
libmtmd up through the existing globs, zero script changes.
Binding:
- createContext: mmprojPath (fail-loud — a configured mmproj that cannot
load never silently degrades to text-only), imageMinTokens /
imageMaxTokens pass-through, mtmd freed before the model in dtor+dispose.
- _storePrefillMultimodal(handles, sepTokens, prompts, bitmaps) →
per-branch {tokensDecoded, positionAdvance}. The worker owns prompt
strings + copied image bytes and walks mtmd_tokenize chunks in order:
TEXT → decode_scatter (token rail), IMAGE → encode → prefill_embd
(embedding rail, section-major M-RoPE positions), AUDIO → explicit error.
Tokenize flags {add_special: false, parse_special: true} match the text
path exactly (no mid-conversation BOS around an image).
- supportsVision() / supportsAudio() probes.
Tests (two tiers, all assertions green):
- Qwen3.5-4B + mmproj (M-RoPE): grounded answer on q4_0 KV; cells vs
position decouple (233 cells / 51 positions); fork ×2 adds zero cells
and both children answer over the shared image; release recovers exact
cell counts; marker-terminal prefill; duplicate-handle and
marker/bitmap-mismatch rejections; 3-frame timestamped prefill with a
grounded temporal answer (the video-carrying contract).
- SmolVLM-256M (plain positions, CI tier): same mechanics,
positionAdvance == tokensDecoded.
- matrix.json: SmolVLM pair as multimodal sidecars; models cache key → v2
(actions/cache never re-saves on hit).
- test/fixtures/red-square-blue-circle.png: synthetic, assertable content.
The worker walked mtmd chunks itself: encode, position packing, per-segment
dispatch and cell bookkeeping, with BranchState::position crossing the
binding boundary. Any other SessionContext implementation (Nitro/JSI, a
platform encoder) would have had to reimplement the same walk.
That logic now lives in liblloyal as BranchStore::decode_segments +
MtmdSource, so the worker constructs a source and makes one kernel call:
lloyal::MtmdSource source(_mtmd, _prompts[i], _bitmapBytes[i],
std::span<const llama_token>(_sepStorage[i]), _nEmbdInp);
const auto r = _store.decode_segments(_handles[i], source);
Net -142/+15 in the worker, and no CMake change was needed — the tell that
the boundary is in the right place, since the addon already linked mtmd.
Tests: adds the batched fan-out case to the multimodal suite. The existing
spine-share block proves forks are free but drives its two children one at a
time with the same question; this drives four children together, each asking
a different question, through the PUBLIC BranchStore surface — 16 batched
store.commit() dispatches, and the JS -> N-API marshalling of parallel
handle/token arrays that the kernel-level test cannot reach.
fork x4 over the image adds zero cells
costs only the text suffixes (96), never the image again
16 batched dispatches, each carrying up to 4 branches
"Describe the red object" -> "The red object is a solid square located in
the upper left corner of the image"
"Describe the blue object" -> "The blue object is a solid circle located in
the bottom right corner of the image"
The spatial answers only hold if the M-RoPE grid survives the fork.
Requires liblloyal#36.
There was a problem hiding this comment.
Pull request overview
This PR adds first-class multimodal (vision) prefill support to the Node addon by loading an mmproj (mtmd) context at createContext time and exposing a dedicated _storePrefillMultimodal entrypoint, alongside new integration coverage that validates batched fan-out behavior over a shared image prefix.
Changes:
- Build/link llama.cpp’s mtmd library and load
mmprojvia newcreateContextoptions (mmprojPath,imageMinTokens,imageMaxTokens). - Add N-API surfaces for multimodal prefill and capability probes (
_storePrefillMultimodal,supportsVision,supportsAudio), plus integration tests for sustained batched fan-out. - Update CI model matrix/cache key and README documentation for multimodal usage.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/matrix.json | Adds SmolVLM + mmproj artifacts for multimodal CI tier. |
| test/integration.ts | Adds multimodal integration suite including batched fan-out coverage. |
| src/SessionContext.hpp | Declares mtmd ownership hook and new multimodal/probe methods. |
| src/SessionContext.cpp | Implements mmproj loading, mtmd lifecycle, and multimodal prefill worker + probes. |
| README.md | Documents multimodal setup and APIs; updates llama.cpp version badge. |
| CMakeLists.txt | Builds/links mtmd from llama.cpp tools and ships it with shared builds. |
| .github/workflows/release.yml | Bumps model cache key to include new multimodal CI models. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
src/SessionContext.cpp:2655
- The bitmap marshalling path accepts any TypedArray but treats its backing bytes as an image payload; this will silently accept non-Uint8 typed arrays (e.g. Int16Array) even though the error message says "Uint8Array". Restrict TypedArray inputs to Uint8Array (or update messaging) to avoid misinterpreting data.
Napi::Value v = jsImgs.Get(j);
if (!v.IsBuffer() && !v.IsTypedArray()) {
throw Napi::Error::New(env,
"_storePrefillMultimodal: bitmaps must be Buffer/Uint8Array");
}
src/SessionContext.cpp:1941
- CreateContext allocates mtmdCtx (and ctx) before constructing the SessionContext JS object; if ctor.New({}) or unwrap throws, these native resources leak because they are not yet owned by SessionContext. Add an exception-safe guard (RAII or try/catch) to free mtmdCtx/ctx on any error before ownership transfer.
// Create SessionContext instance
Napi::Function ctor = env.GetInstanceData<Napi::FunctionReference>()->Value();
Napi::Object instance = ctor.New({});
SessionContext* obj = SessionContext::Unwrap(instance);
| const bytes = fs.readFileSync("./photo.jpg"); // jpg/png/bmp/gif | ||
| const [{ tokensDecoded, positionAdvance }] = | ||
| await ctx._storePrefillMultimodal([handle], [[]], [prompt], [[bytes]]); | ||
| // ...then produce/commit as usual — the branch attends the image |
There was a problem hiding this comment.
Fixed in 6fde0ae — the snippet now creates the branch with _branchCreate before prefilling.
| - `createContext(options)` — load a GGUF model, return a `SessionContext`; | ||
| `mmprojPath` loads the multimodal projector beside it | ||
| - `_storePrefillMultimodal(...)` — image+text prefill into a branch's KV | ||
| (the embedding rail); `supportsVision()` / `supportsAudio()` probes |
There was a problem hiding this comment.
Fixed in 6fde0ae, as a documentation note rather than a code change — this is a publish-ordering issue, not a defect. createContext is typed through @lloyal-labs/sdk's ContextOptions, and the installed SDK carries no mmprojPath; only the unreleased workspace copy does. The README now states that these options and members typecheck once an SDK carrying multimodal ContextOptions is installed, and that the runtime accepts them regardless. The real fix is publishing that SDK.
e1fb5dc added two boot-scoped stderr writes without bumping the count, so the allowlist gate failed on ubuntu-latest: initializeMultimodal: "mmproj attached (vision=..., audio=...)" CreateContext: "Loading mmproj: <path>" Both fire once at boot, which is what the allowlist exists to permit. 13 -> 15.
Addresses Copilot review on #49, and picks up liblloyal d84f249. - CreateContext held ctx and mtmdCtx raw until initializeContext / initializeMultimodal took ownership. Anything throwing in between — a missing mmproj, a failed projector load, ctor.New({}), Unwrap — leaked them; the two error paths hand-rolled llama_free(ctx) and the rest did not. An RAII guard now makes cleanup unconditional and both manual frees are gone. The leak predates multimodal (8df3392); mtmd widened it. - _storePrefillMultimodal accepted ANY typed array while the error message said Buffer/Uint8Array, so an Int32Array passed the guard and was reinterpreted as raw bytes. Now checks napi_uint8_array explicitly. - _storePrefillMultimodal did not reject duplicate handles. decode_scatter guards against them, but this path dispatches one handle at a time so the pair never meets there — the same branch would be prefilled twice, each advancing its position. Now fails loud like _storePrefill/_storeCommit. - README: the multimodal snippet used `handle` without creating it, and the documented options are typed through @lloyal-labs/sdk's ContextOptions, so they only typecheck once an SDK carrying them is installed. Says so. Suite: multimodal 22/22 including the fan-out. The one failure is testRerankLargeCorpus, pre-existing and unrelated — fixed in #50.
Picks up the non-causal single-dispatch requirement, the CausalGuard RAII restore, segment-geometry validation, the stale-logits clear, and the stub-tier slack coverage.
Embedding-width validation against the resident model, empty-segment rejection so terminality is correct by construction, and audio rejected in MtmdSource's constructor before any dispatch.
Diagram-forward README with the multimodal architecture section, plus three non-compiling code samples fixed (wrong item type, a reseed_chain overload that does not exist, wrong CMake target).
Slot/lease model corrected (allocate takes both atomically; n_seq_max is the real bound), MtmdSource drawn as implementing SegmentSource rather than feeding it, decode_each's one-dispatch claim qualified, and two API instructions fixed (nonexistent v0.1.0 tag, ForkOpts on branch::fork).
…oken samples
Same treatment as liblloyal: say what the layer is FOR, diagram only the
genuinely hard structure, and verify every claim against source instead of
restating it.
Five things in the old README did not survive checking:
- examples/best-of-n/ does not exist. Only chat, embed and entropy do, so the
one runnable command in the file did not run.
- The CI table listed "Ministral 3B / mistral". test/matrix.json has GLM-Edge.
- formatChat takes a JSON STRING; the sample passed an object. The SDK's own
docs call the parameter messagesJson throughout.
- loadBinary takes the variant directly — loadBinary("cuda") — not
loadBinary({ gpuVariant: "cuda" }).
- Branch.produce() is async, so the Quick Start's { b, ...b.produce() } spread
a Promise and produced neither token nor isStop. That cohort loop wants
produceSync(), which is the point of batching: sample everyone, commit once.
New material where the old file was thin:
- "Which binary loads" — the resolution order is what people actually hit when
the wrong binary loads, and it was undocumented. Diagrammed, with the four
environment variables and the deliberate no-fallthrough on a corrupt cache.
- "Who owns what" — the layer stack, and the seam at lloyal.node that lets
nitro-llama serve React Native from the same kernel.
- Multimodal now explains why positionAdvance < tokensDecoded rather than only
showing the call, and carries the SDK-version caveat.
Platform count (13) verified against release.yml's matrix legs.
Rendered it and looked: five chained decision diamonds sprawled down the page
with node text clipped ('never fal', 'the devi'). An ordered fallback chain is
a sequence — a numbered table says it in a fraction of the height and carries
the failure semantics per step, which the diagram could not.
Dropped the environment-variable table and the closing sentence with it: both
restated rows the ordered table already covers.
BranchResult.rc on the multimodal cohort (per-entry, set from DecodeError, surfaced beside `error`); an `rc` property attached to the rejected error object on the token and commit workers (single catch arm, dynamic_cast — no catch-order hazard; OnError sets the property on the JS object it constructs). Feeds the self-healing ladder (hdk docs/self-healing.md): 1/-1 mean the branch is intact, 2/<-1 mean poison.
3.2.0-alpha.1. The recorded submodule pointer still named cf6a666 while the binding catches lloyal::decode::DecodeError (2e6688b/0aaae86) — CI checks out the pointer, so the 13-target build would not compile. Publishing an alpha is shipping; the pointer ships with it.
There was a problem hiding this comment.
🟡 Changes recommended
The new multimodal integration test’s local typing for _storePrefillMultimodal is inconsistent with how the test uses the returned error field, which is likely to break TypeScript compilation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 11/13 changed files
- Comments generated: 3
- Review effort level: Lite
| _storePrefillMultimodal( | ||
| handles: number[], seps: number[][], prompts: string[], bitmaps: Buffer[][], | ||
| ): Promise<Array<{ tokensDecoded: number; positionAdvance: number }>>; |
| # ============================================================================= | ||
| # mtmd (multimodal: clip/projector encode + media tokenization) | ||
| # ============================================================================= | ||
| # tools/mtmd is not built by llama.cpp under add_subdirectory (LLAMA_BUILD_TOOLS | ||
| # is OFF for non-standalone builds), so add the library directly. | ||
| # - EXCLUDE_FROM_ALL: mtmd's CMakeLists also declares CLI targets | ||
| # (llama-mtmd-cli etc.); only the linked `mtmd` library gets built. | ||
| # - LLAMA_INSTALL_VERSION is a normal variable scoped inside llama.cpp's | ||
| # directory — empty here, and mtmd's set_target_properties(VERSION ...) | ||
| # would receive "". Provide it before the add. | ||
| # - MTMD_VIDEO is a compile definition only (video decoding shells out to an | ||
| # ffmpeg binary at runtime); keep it OFF for a deterministic surface. | ||
| set(LLAMA_INSTALL_VERSION 0.0.0) | ||
| set(MTMD_VIDEO OFF CACHE BOOL "mtmd video support (runtime ffmpeg)" FORCE) | ||
| add_subdirectory(${LLAMA_CPP_DIR}/tools/mtmd mtmd EXCLUDE_FROM_ALL) |
| /** | ||
| * Multimodal prefill: per-branch sep tokens + templated prompt (with | ||
| * media markers) + image bytes. mtmd tokenizes the prompt, the worker | ||
| * walks TEXT/IMAGE chunks in order (token rail / embedding rail). | ||
| * Args: (handles: number[], sepTokens: number[][], prompts: string[], | ||
| * bitmaps: Buffer[][]) | ||
| * Returns: Promise<{tokensDecoded, positionAdvance}[]> | ||
| */ |
The kernel's DecodeError now says whether earlier chunks of a failed
decode landed — rc alone cannot, since llama_decode restores only the
call it rejects and the chunked paths keep what came before. The two
decode workers attach `partial` to the rejected JS error next to `rc`
(same catch arm, same OnError crossing), and the multimodal cohort's
per-entry result carries it next to its `rc`. The rule the SDK gates
on: intact iff rc == 1 && !partial; anything else, prune and replay.
Also from the review: the integration test's multimodal result type
carries error/rc/partial (build:test did not compile), and the
_storePrefillMultimodal docstring names the actual walk — MtmdSource
yields segments, BranchStore::decode_segments places them.
Submodule pointer: dd4667e (DecodeError{rc, partial}, one logits
capture). Addon rebuilt against it; tsc green.
There was a problem hiding this comment.
🟡 Changes recommended
_storePrefillMultimodal’s duplicate-handle check compares DoubleValue() but later coerces handles via Uint32Value(), allowing duplicates to slip through and prefill the same branch twice in a single call.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/SessionContext.cpp:2783
- Duplicate-handle detection uses DoubleValue() and compares doubles, but the actual handles are later coerced with Uint32Value(). That means non-integer numbers (e.g. 1.2 and 1.7) can bypass the duplicate check while both coerce to the same uint32 handle, causing the same branch to be prefilled twice in one call.
std::vector<double> seen;
seen.reserve(n);
for (uint32_t i = 0; i < n; i++) {
const double h = jsHandles.Get(i).As<Napi::Number>().DoubleValue();
for (uint32_t j = 0; j < seen.size(); j++) {
- Files reviewed: 11/13 changed files
- Comments generated: 1
- Review effort level: Lite
| * @param variant GPU variant: 'cuda', 'vulkan', or undefined for CPU | ||
| * @returns Native binary module with createContext method | ||
| * @throws Error if no binary available for the current platform | ||
| * @throws Error if no binary is available for the current platform, or if a | ||
| * DIFFERENT addon image is already loaded in this thread — see {@link | ||
| * claimImage} for why a second image is fatal rather than merely wasteful. |
…s like the marshal; liblloyal → cee612d The last hop of the rc/partial contract, asserted on real weights from the JS side. test/integration.ts gains testDecodeFailure(): a cohort whose third scatter chunk finds no KV slot rejects with rc 1 and partial true — the landed children moved and were charged, the refused one did not and still prefills, prune returns the pool to zero; a 300-token prefill that dies after four 64-token chunks reports partial with its books unmoved, and a fresh branch prefills after prune. Both run on the default model and, when present, on Qwen3.5-4B (the production default, a Gated DeltaNet hybrid). The media rail is asserted per entry: an image costing 576 cells against a 256-cell pool comes back with error, rc 1 and partial true, and prune reclaims the branch. nSeqMax stays at 4 or 2. _storePrefillMultimodal's duplicate-handle guard compared DoubleValue() while the marshal coerced with Uint32Value(), so 5 and 5.5 passed the guard and named one branch. The guard coerces the same way now; the integration test asserts the fractional duplicate is rejected. tsconfig.test.json emitted JavaScript beside the sources on every build:test — outDir "." pulled the two src/ modules the unit test imports into the emit. The gate is noEmit: nothing consumed the output (the suites run through tsx). loadBinary's `variant` doc matches GpuVariant. Submodule pointer: cee612d (create refuses a no-vocab model; rc -1 restores too; the real-weights failure suite). Addon rebuilt against it.
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces substantial native binding + build-system changes (mtmd lifecycle, new async workers, and new external library target) that warrant final human review despite strong test additions.
Review details
- Files reviewed: 12/14 changed files
- Comments generated: 1
- Review effort level: Lite
| std::vector<uint32_t> seen; | ||
| seen.reserve(n); | ||
| for (uint32_t i = 0; i < n; i++) { | ||
| const uint32_t h = jsHandles.Get(i).As<Napi::Number>().Uint32Value(); | ||
| for (uint32_t j = 0; j < seen.size(); j++) { |
…n.js 6962f5c made tsconfig.test.json noEmit on the belief that nothing consumed the emitted JS. The GPU deploy does: it runs /app/test/integration.js and /app/test/examples.js in the container, and the L4 job failed with 'Cannot find module'. Emission is back exactly as it was. The reason it was turned off stays fixed another way: src/backend-pack.js and src/verify.js appeared beside their sources because test/backend-pack-unit.ts imported ../src/…, pulling those modules into the test program's emit. It imports ../dist/… now, as integration.ts already does, so the source tree stays clean and the unit test runs over the built output it ships.
…rule; liblloyal → 261200d The duplicate guard was a nested scan over a second coercion of the handles — the binding's own statement of a rule the kernel owns, made only because this path dispatches one branch at a time and decode_scatter never sees the pair. It is gone. The handles are marshaled first, once, and lloyal::branch::require_distinct_handles runs over exactly the values that will be dispatched — the same one-pass rule decode_each and decode_scatter now apply. _storeCommit needs nothing here: the kernel's decode_each refuses a repeated handle itself now, and the worker's catch forwards it. Submodule pointer: 261200d. Addon rebuilt; the fractional-duplicate and partial assertions pass on real weights.
…efusal tested; CI runs the embedding-rail failure case)
…is built; embedding-rail case fits every projector)
…s integration.ts always has tests.yml's matrix job ran test:unit straight after npm install --ignore-scripts, before any build. That only ever worked because tsx compiled the test's ../src imports in memory; 6962f5c moved those imports to ../dist so the compile gate stops emitting .js beside the sources, and this job had no dist to import. Every other workflow already builds before testing.
There was a problem hiding this comment.
🟡 Changes recommended
Pending multimodal work can race context disposal, and several documentation and coverage assertions are currently inaccurate.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/15 changed files
- Comments generated: 5
- Review effort level: Balanced
| // Free mtmd first — it holds a reference to the model | ||
| if (_mtmdContext) { | ||
| mtmd_free(_mtmdContext); | ||
| _mtmdContext = nullptr; |
There was a problem hiding this comment.
Real, and it is not specific to the new multimodal workers — every one of the 19 AsyncWorkers in SessionContext.cpp holds a raw llama_context/BranchStore/mtmd_context, and dispose()/~SessionContext free unconditionally with no in-flight tracking. Witnessed on real models: a pre-existing StorePrefillWorker (branch.prefill then dispose) SIGSEGVs, and CellsMultimodalWorker is killed. It is reachable above the binding because Effection 4.1.0 halt() returns with a call(nativePromise) still unsettled while initAgents runs ctx.dispose() on scope exit.
Scheduled as two units. The reachable case is closed on the runtime side first: hdk 7cda0ad (feat/mtmd) adds a waitUntilSettled combinator so the agent pool lets an in-flight decode settle before it prunes or disposes. This PR then gets the binding-level tripwire as its own follow-up after it merges: a SessionWorker base holding an ObjectReference and an in-flight count, with dispose()/_branchPrune/_branchFork/_storeRetainOnly refusing while work is in flight — fail loud rather than defer, since deferring hides the ordering bug. Not deferring the fix; deferring the second, binding-level half to its own change.
| .map((p) => { p.b.accept(p.token); return [p.b, p.token]; }); | ||
| if (items.length) await store.commit(items); // N branches, 1 llama_decode() |
There was a problem hiding this comment.
Fixed in 64195a0. Confirmed against StoreCommitWorker (src/SessionContext.cpp:611-616): phase 2 calls accept_token for every item, so the README's Branch.accept() advanced grammar, sampler and metrics a second time. This predated the PR (it was on main at line 54); the SDK's own store loop never calls accept. Dropped it — commit does the accept once.
| let ticks = 0; | ||
| for (let step = 0; step < 16; step++) { | ||
| const entries: Array<[InstanceType<typeof Branch>, number]> = []; | ||
| for (let i = 0; i < kids.length; i++) { | ||
| if (fanToks[i].length && fanToks[i][fanToks[i].length - 1] === -1) continue; | ||
| const { token, isStop } = await kids[i].produce(); | ||
| if (isStop) { fanToks[i].push(-1); continue; } | ||
| fanToks[i].push(token); | ||
| entries.push([kids[i], token]); | ||
| } | ||
| if (!entries.length) break; | ||
| await store.commit(entries); | ||
| ticks++; | ||
| } | ||
| // >1 is the point: several consecutive ticks each carrying every live | ||
| // branch in ONE llama_decode is continuous tree batching. A single | ||
| // dispatch would satisfy "it batched" without showing it sustains. | ||
| assert(ticks > 1, | ||
| `fan-out: ${ticks} batched dispatches, each carrying up to ${kids.length} branches`); |
There was a problem hiding this comment.
Fixed in 64195a0. The test now records each dispatch width and asserts more than one dispatch carried several branches, so a lone survivor decoding serially no longer passes. On the real run the widths were 4,4,4,4,4,2,2,... — five full-width dispatches.
|
|
||
| **What it re-exports**, so one install is enough — these are [HDK](https://github.com/lloyal-ai/hdk) packages, documented there: | ||
|
|
||
| - from `@lloyal-labs/sdk`: `Branch`, `BranchStore`, `Session`, `Rerank`, `formatChat`, `parseChatOutput`, `jsonSchemaToGrammar`, per-token metrics |
There was a problem hiding this comment.
Fixed in 64195a0. Verified: neither src/index.ts nor @lloyal-labs/sdk exports formatChat, parseChatOutput, jsonSchemaToGrammar or the metric functions as top-level values — they are methods on the context and branch (the native-surface block already shows them). The list now names what is actually re-exported (buildUserDelta, buildToolResultDelta, and the sampling/chat/rerank types).
| return Napi::Buffer<uint8_t>::Copy( | ||
| env, reinterpret_cast<const uint8_t*>(text.data()), text.size()); |
There was a problem hiding this comment.
Fixed in 64195a0. testTokenizer now asserts a Buffer per token, that at least one piece ends mid-character, and that Buffer.concat equals detokenize(). Text chosen after surveying four tokenizers (SmolLM2, Llama-3.2, Phi-3.5, Qwen3.5 all tear); Phi keeps the SentencePiece prefix space on piece one, hence the "" + detok" arm. On SmolLM2: 34 of 36 pieces torn, concat equal.
There was a problem hiding this comment.
🔵 Needs a closer look
The new asynchronous multimodal workers can race with context disposal and dereference freed native state.
Review details
Suppressed comments (4)
README.md:57
BranchStore.commit()already accepts each token before decoding it (seeStoreCommitWorkerinsrc/SessionContext.cpp:611-616). CallingBranch.accept()here accepts every sampled token twice, which can advance grammar/sampler/metrics state incorrectly. Pass the sampled pairs directly tostore.commit().
.map((p) => { p.b.accept(p.token); return [p.b, p.token]; });
test/integration.ts:2562
ticks > 1only proves that at least one child survives for a second dispatch; the other three may stop after the first tick. In the non-strict CI tier, the later checks require only one token per child, so this can pass without the sustained four-way batching the test is intended to cover. Record eachentries.lengthand require at least two ticks withkids.lengthentries (or equivalently require every child to produce more than one token).
assert(ticks > 1,
`fan-out: ${ticks} batched dispatches, each carrying up to ${kids.length} branches`);
src/SessionContext.cpp:804
- This worker keeps raw references to the session's
BranchStoreandmtmd_contextafter returning control to JavaScript.ctx.dispose()can then synchronously drain the store and free both_mtmdContextand the llama context whileExecute()is queued or running, causing a race/use-after-free during multimodal prefill. Keep the owningSessionContextalive and coordinatedispose()with in-flight workers (merely pinning the JS object would not prevent explicit disposal).
lloyal::branch::BranchStore& _store;
mtmd_context* _mtmd;
int32_t _nEmbdInp;
src/SessionContext.cpp:2738
_cellsMultimodal()also queues work with only a rawmtmd_context*. A caller can save the returned promise and immediately callctx.dispose(), which frees that context before or duringMtmdSourceconstruction and leaves this worker dereferencing freed memory. Track this operation as in-flight and defer disposal/freeing until it completes, while retaining the owning session against GC.
private:
Napi::Promise::Deferred _deferred;
mtmd_context* _mtmd;
int32_t _nEmbdInp;
- Files reviewed: 13/15 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…tion, cover tokenToBytes BranchStore.commit already accepts every token before decoding, so the README's Branch.accept() advanced grammar, sampler and metrics twice. Drop it and let the commit do the accept once, matching the SDK's own store loop. The re-export list named formatChat, parseChatOutput, jsonSchemaToGrammar and per-token metrics as top-level values; those are methods on the context and branch, not exports. List what src/index.ts actually re-exports so a consumer following it does not hit missing exports. The fan-out test asserted ticks > 1, which a lone survivor decoding serially would satisfy without the sustained four-way batching under test. Record each dispatch width and require more than one dispatch that carried several branches. tokenToBytes, new on this branch, had no integration coverage. Assert a Buffer per token, that at least one piece ends mid-character, and that the bytes concatenate back to detokenize() — the byte API's contract, which tokenToText cannot hold for split multibyte characters.
`test/integration.ts` called `ctx.tokenToBytes`, which the linked hdk workspace's SessionContext declares but the `^3` devDependency CI installs (sdk 3.1.0) does not — TS2339 on the L4 job while `npm run build:test` was green locally, because the local package is a symlink to the workspace. The call is typed structurally, the convention this file already uses for `_storePrefill*`: binding surface added ahead of the sdk pin is named at the test site until the pin catches up. Reproduced CI's exact error against published 3.1.0 before the change; green under both types afterwards.
The set id moves from alpha.1 to alpha.2 for this arc's second cut. The main package and every optionalDependency on a platform package carry the same version, kept in step by scripts/sync-versions.js, so npm resolves one coherent set. `latest` is untouched: the Release workflow maps an -alpha version to the alpha dist-tag.
….0-alpha.3 dist/index.js re-exports values from @lloyal-labs/sdk and @lloyal-labs/lloyal-agents, so both are real peers. They were declared as `>=3.0.0`, and a plain range never admits a prerelease: semver matches one only when a comparator names that exact major.minor.patch with a prerelease tag. Every `npm install` that pinned the alpha set beside this binding — the scaffold's own install included — was refused (ERESOLVE), root-declared or not. The ranges now name the tuples: `>=3.0.0 || >=4.0.0-0 <5.0.0` on sdk and `>=3.0.0 || >=6.0.0-0 <7.0.0` on agents. Each admits the stable a user has today, this arc's prerelease, and the stable it becomes. A unit test holds that, red first, checked with the semver library npm resolves with; it runs under `npm run test:unit` on every pull request. The version moves to 3.2.0-alpha.3 with the platform packages in step. Follow-up for the next release, not this cut: nothing imports those re-exports (the templates and hdk import only createContext and loadBinary), so removing them removes the load-time requires, the peers, and the bare-install landmine in one move.
Multimodal (vision) prefill:
mmprojload, the embedding rail, and the branch fan-out on top of it.Depends on lloyal-ai/liblloyal#36 — the submodule pointer here targets that branch and must be repointed at liblloyal
mainonce it merges.Why
e1fb5dclanded the feature with the mtmd pipeline inside the N-API worker: chunk walking,mtmd_encode_chunk, section-major position packing, per-segment dispatch and cell bookkeeping — withBranchState::positioncrossing the binding boundary. KV state belongs behindbranch.hpp, and any otherSessionContextimplementation (Nitro/JSI, or a platform encoder like CoreML) would have had to reimplement the same walk.What
The walk moved into liblloyal as
BranchStore::decode_segments+MtmdSource. The worker now constructs a source and makes one kernel call:−142/+15 in the worker — the tell that the boundary is right. The CMake side is one block: llama.cpp does not build
tools/mtmdunderadd_subdirectory(LLAMA_BUILD_TOOLSis off), so the addon adds that library itself,EXCLUDE_FROM_ALL.Tests
Adds the batched fan-out case. The existing spine-share block proves forks over an image are free, but drives its two children sequentially with the same question. This drives four children together, each asking a different question, through the public
BranchStoresurface — which also covers the JS→N-API marshalling of parallel handle/token arrays that the kernel-level test in liblloyal#36 bypasses entirely.The children report spatial facts — upper-left vs bottom-right — which only hold if the M-RoPE positional grid survived the fork. Four branches reading different regions of one encode, sharing a KV prefix, decoded together.
Answers are deliberately short sentences rather than single words: a one-word answer emits its token and stops, so the loop would do a single dispatch and prove nothing about sustained batching. This is the difference between 1 tick and 16.
Suite: 267 passed, 0 failed.
Known unrelated failure
testRerankLargeCorpusfails onmainand on this branch — the relevant document ranks 5th instead of top-3. It is pre-existing and unrelated: every function the reranker's text path executes (Scratch::as_batch,decode::scatter,decode::each,BranchStore::decode_scatter,decode_each) is byte-identical to HEAD in liblloyal#36, and the two that did change (release,retainOnly) only write the passivecells_used_gauge.Root cause measured: it stacks two lossy quant axes (q4_k_m weights × q4_0 KV), under which the pointwise judge returns negative for every document — the ranking is noise among rejects, not a near-miss. Fixed separately in #50.