Repository navigation
fix: stop a cancelled multimodal prompt ingest between mtmd chunks - #600
Merged
Merged
Conversation
This was referenced Sep 23, 2026
Contributor
|
Chat app preview removed for |
This was referenced Sep 23, 2026
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.
Summary
For #599. A llama.cpp cancel only sets the per-generation cancel byte, and the worker read it only at the top of the token loop. An image or audio prompt was ingested by one
mtmd_helper_eval_chunkscall, so a cancel during the ingest waited for every remaining chunk.The multimodal ingest now runs that chunk loop in Dart (
lib/src/backends/llama_cpp/mtmd_chunk_eval.dart) and reads the byte:mtmd_encode_chunk, before its embedding decode.Text chunks still go through
mtmd_helper_eval_chunk_single, withnew_n_pastseeded first because its text branch adds to it. Image and audio chunks callmtmd_encode_chunk,mtmd_get_output_embdandmtmd_helper_decode_image_chunkwith the argumentsmtmd_helper_eval_chunk_singlepasses them. On a cancel the loop returns withn_pastat the last complete chunk.generatethen continues through the existing path:llama_get_logitssynchronizes, and the token loop's first check ends the generation with no tokens. A nonzero native result still throws the existing error, which now also names the failed call and chunk, for exampleMultimodal prompt evaluation failed: 1 (failed to decode audio chunk 4)._MtmdApi. It now also looks up the seven chunk-level symbols as one all-or-nothingMtmdChunkEvalApi. If any is missing, that fallback keeps the singlemtmd_helper_eval_chunkscall. Primary or fallback is chosen from_mtmdPrimarySymbolsUnavailable, which the precedingmtmd_tokenizehas already settled, so a chunk is never evaluated twice.llama_cpp_raw_bindings.dart. The generated enum wrapper throws on unknown values; the raw value sends an unknown type tomtmd_helper_eval_chunk_single, as upstream does.Not included: an early return after a cancelled ingest. Returning from
generatestraight after the ingest skips thellama_get_logitssynchronize. Built that way, the e2e below saw a generate that decoded 172 prompt tokens reportpromptEvalTokens1. With a synchronize added, deleting the return turned no test red, because the token loop's first check already ends the generation. So the return was dropped.Measured
Apple M4 Max, macOS arm64, llamadart-native v0.4.1. Qwen3-ASR-0.6B Q8_0 with its Q8_0 mmproj,
jfk.wav(11 s). Stock isaed0bd2b5; this PR is85ccb4fd8, and later commits change only tests, test-only service methods and dartdoc.SpeechToTextTask.cancel()is called f × (previous full transcription) aftertranscribe()returns; at f=0 the harness awaits a zero-length timer and then cancels. Both heavy-work locks were held, and stock and PR runs alternated. The batch first waited for the 1-min loadavg to drop below 6. Recorded after each cancel, it was then 6.18-10.40. GPU utilization read 0% before every run.A cancel issued synchronously right after
await transcribe()ended the taskcancelledin 0.58-1.04 ms on stock and ona14ffbc80(n=4 each, CPU).cancelGeneration()issued synchronously afterlisten()is dropped on both: a stories15Mgenerate()gave the uncancelled output (n=3 each, #602).Cancel latency in ms, as min-max (median):
cancelled.engine.createstreams were cancelled withcancelGeneration(), n=2 per cell, and all 16 ended withonDoneand no error. CPU f 0.1/0.3: stock 336.3-340.0 / 222.5-225.4 ms, this PR 105.7-112.3 / 85.3-89.8 ms. Metal f 0.05/0.15: stock 47.1-47.4 / 31.1-33.9 ms, this PR 13.1-14.3 / 13.4-16.5 ms.generatewith the byte already set, n=4 per cell, loadavg 7.60-9.03:promptEvalTokens: stock 172, this PR 1. Both returned empty output with no error.validation.dart speech --pack stt, CPU and Metal, loadavg 8.29-11.76:functional_passon both trees. Apart from timings (elapsed_ms,first_partial_ms,real_time_factorand the preparation checksum times), every field is identical between stock and this PR, including all 13 checks, the 4 edge fixtures and the transcripts.website/static/img/social-card.png, twice. Loadavg 11.32-12.26.What remains is the native call in flight when the byte is set. The CPU leads were 58.8-62.3 ms (f=0.1), 174.5-180.6 ms (f=0.3) and 289.1-300.1 ms (f=0.5). In one stock CPU run at native log level
info, the two audio encodes took 165 and 74 ms, and their embedding decodes 96 and 49 ms. At Metal f=0.3 the two ranges overlap.Production-readiness scope
SpeechToTextTask.cancel(),cancelGeneration()) now stops at the next chunk boundary, or between a media chunk's encode and its decode.llama_cpp_service.dartis native-only.llama_decode.cb_evalstops an in-flight STT or vision clip encode on a cancel byte.cachedPromptTokensstale. It predates this PR.cancelGeneration()right afterlisten()is dropped. It predates this PR, which does not fix it.mtmd_helper_eval_chunk_singleand no chunk goes throughmtmd_helper_eval_chunks. So these lines oftools/mtmd/mtmd-helper.cppatb29c606e28are no longer printed:encoding %s slice...(:249) and%s slice encoded in N ms(:258).failed to encode %s slice(:253), the secondfailed to decode %s(:263) andfailed to eval chunk %zu(:295).no chunks to eval(:285), for an empty chunk list.encode,decodeoreval), the media type forencodeanddecode, and the chunk index. Measured with a 256-token context, CPU, native log levelwarn:failed to decode audiotwice andfailed to eval chunk 4, and threwMultimodal prompt evaluation failed: 1. The active context window …. This PR printedfailed to decode audioonce (:179) and threwMultimodal prompt evaluation failed: 1 (failed to decode audio chunk 4). The active context window ….imageand chunk 7.failed to decode text(:238). Stock also printedfailed to eval chunk 3; this PR threw… 1 (failed to eval chunk 3). ….LlamaEngineat its default native log level (none), neither tree printed a native line. TheLlamaInferenceExceptionreadGeneration failed (Exception: Multimodal prompt evaluation failed: 1 (failed to decode audio chunk 4). …)on this PR, against… failed: 1. …on stock.info, stock printed both slice lines for each audio chunk. This PR printed neither, and still printeddecoding audio batch 1/1, …andaudio decoded (batch 1/1) in N ms.promptEvalTokens: with the byte already set, this PR reports 1 and stock 172.Completeness checklist
## Unreleasedentries are added toCHANGELOG.mdandwebsite/docs/changelog/recent-releases.md.PR type guidance
test/unit/backends/llama_cpp/mtmd_chunk_eval_test.dart(VM, no model): 13 tests drive the loop through a fake that applies upstream'snew_n_pastrules; one checks thatMtmdChunkEvalApi.tryLoadreturns null without the symbols. The failure tests also check the reported call, media type, chunk index and native result.test/integration/backends/llama_cpp/native_symbol_integration_test.dartchecks one library per platform: the split mtmd library on Linux (libmtmd*.so) and Windows (mtmd*.dll), andlibllamadart.dylibon macOS, which has no split mtmd library. The library must export the seven symbols, and_MtmdApi.tryLoad(library), the call_resolveMtmdFallbackApimakes, must return chunk-level functions. It runs on CI's Linux, macOS and Windows jobs.test/e2e/backends/speech_to_text_e2e_test.dart(local-only): two tests, each run on three routes.promptEvalTokens≤ 1). The fallback without chunk-level functions ingests all 172 tokens, as before.Multimodal prompt evaluation failed: 1 (failed to decode audio chunk 4). …on the primary route and the wrapper fallback, and the stockMultimodal prompt evaluation failed: 1. …without chunk-level functions.debugUseWrapperMtmdFallbackForTestingforces the fallback. With chunk-level functions it calls_MtmdApi.tryLoad(library)with the default argument, as_resolveMtmdFallbackApidoes. In an instrumented run on macOS, the seam opened.dart_tool/lib/libllamadart.dylib,mtmd_tokenizeand the ingest went through that fallback, and the mtmd library lookup never ran. So the new fallback typedefs run end to end.Test Plan
dart run tool/prepare_workspace.dart: tracked files unchanged.dart format --output=none --set-exit-if-changed .: 681 files, 0 changed.dart analyze --fatal-infos: no issues.dart test -p vm -j 1 --exclude-tags local-only(macOS arm64,GGML_METAL_DEVICES=0): 2524 passed, 78 skipped, 0 failed. Coverage check: 81.48% line coverage (70% threshold).dart test -p chrome --exclude-tags local-only: run by CI (Test Web (Chrome)), not locally; the changed library is native-only.run_local_e2e.dart --scenario speech-to-text-smoke: 8/8, including the new e2e tests.test/e2e/backends/cancel_dispose_e2e_test.dart(local-only): 2 passed, 1 skipped.engine_integration_test.dartandstop_sequences_test.dartpassed in the VM run.validation.dart speech --pack stton CPU and Metal.Mutation proofs. Each was applied alone and reverted, and each turned tests red:
new_n_pastbefore a text chunklogits_laston every chunktryLoaddoes not catch a missing symbolnew_n_paston an early stopn_pastmtmd_encode_chunkintryLoadVerify mtmd chunk-level eval symbols are resolvable_MtmdApi.tryLoaddefaultsloadChunkEvalto falseVerify mtmd chunk-level eval symbols are resolvable; e2e fallback: cancel (172) and error messagemtmd_helper_eval_chunkspromptEvalTokens172) and error messagegeneratepasses a fresh zero byte to the ingestMatrix Evidence
static-format-analyzeAnalyze & Lintroot-vmroot-chromeTest Web (Chrome); native-only changecoverage-libvalidation-speechsttpackfunctional_passboth; all non-timing fields identical to stockaed0bd2b5speech-to-text-smokerun_local_e2e.dartvalidation-speech-sttsttpack with #594's 500 ms cancel-latency budget3ec86add0with this PR's85ccb4fd8merged. Two runs, 20/20 PASS each.cancel_latency_boundworst 137.568 and 134.186 ms against 500. The run without this PR (#594 ata992f1711) was on an Intel Broadwell VM: 628.652-703.982 ms, FAIL. So the before/after comparison crosses CPUs. Later commits change only tests, test-only service methods and dartdoc.macos-arm64-runtime-smokelinux-x64-ci-runtimeTest Linux VM; real-model row abovewindows-x64-ci-runtimeTest Native (windows-latest)native-prompt-reuse-paritydocs-siteDocs Build Checkhigh-risk-exact-head-independent-qaa14ffbc80, baseaed0bd2b5Review Notes
a14ffbc80bbd7c7edff20cc0bf72e35547081ecd; see below.a14ffbc80bbd7c7edff20cc0bf72e35547081ecd: 23 passed, 5 skipped, 0 failed.Test Linux VM,Test Native (macos-latest)andTest Native (windows-latest)ran and passed the unit tests and the chunk-level symbol check, which now includes the fallback loader default.High-risk regression review
git diff --name-only aed0bd2b5...HEAD | dart run tool/testing/classify_high_risk_changes.dartreports surfacebackendRuntime; the only applicable row ishigh-risk-exact-head-independent-qa.9b3e3dabc,b429093e9,0f780a337,85ccb4fd8,290405b40,a14ffbc80a14ffbc80/aed0bd2b5: 0 blocking findings. Both reviews of this head were by fresh reviewers from the maintainer's operator session with no part in writing this PR.mtmd_context_params.cb_evalis one. The body now links llamadart-native#89 for it.85ccb4fd8found no P1 and rejected on one item: no test pinned_MtmdApi.tryLoad'sloadChunkEvaldefault, which gives the Windows fallback its chunk-level functions.290405b40pins it.a14ffbc80bbd7c7edff20cc0bf72e35547081ecd/aed0bd2b52c0f41b4b0baf734d5b456878000629(GitHubbaseRefOid).mainis one commit ahead, at49ee24594(#594's squash merge), which changes only the validation package and its doc; this branch merges onto it cleanly._ingestMultimodalPromptandgenerate; theloadChunkEvalrow mutates the default_resolveMtmdFallbackApirelies on. Re-run ata14ffbc80for the readiness evidence, each alone and reverted: the check before each chunk (3 unit red), the check between encode and decode (2 unit), the fresh zero byte (2 e2e), and theloadChunkEvaldefault against the integration test only (red). Each matches the table. A new one, the rawmtmd_input_chunk_get_typebinding naming a missing symbol, turned 4 e2e tests red: both primary-route tests and both public-API transcriptions. The fallback routes stayed green.dart run tool/testing/high_risk_readiness.dart, with repository, PR, author, head and base from a fresh GitHub API query: exit 2,unverifiedPrerequisites(externalPrerequisitesUnavailable). That is the best local result: the evidence is internally consistent, and the external prerequisites do not exist. Withmain(49ee24594) as the base it rejects withbaseMismatch, because that commit is not an ancestor of the head.MTMD_API, likemtmd_tokenize, at the pin. A primary bundle that exportedmtmd_tokenizebut not one of the seven would fail the generation with the binding'sArgumentErrorinstead of falling back.