Skip to content

fix: let a cancel stop the Qwen3-TTS audio decode inside a step - #87

Merged
leehack merged 4 commits into
mainfrom
fix/86-tts-cancel-mid-decode
Sep 23, 2026
Merged

leehack merged 4 commits into
mainfrom
fix/86-tts-cancel-mid-decode

Conversation

@leehack

@leehack leehack commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

For #86. llama_dart_tts_step checked for a cancel only on entry. The step that fills a 72-frame window and the step that ends speech run the whole code2wav decode in one upstream call, so a cancel landing there waited it out.

Change

  • llama_dart_tts_eval_callback (new export) for mtmd_context_params.cb_eval, the one upstream hook on clip's scheduler. While llama_dart_tts_step runs on the calling thread, it ends a scheduler chunk at the first MUL_MAT once the work since the last chunk end reaches 2.5e9 (multiply-adds for MUL_MAT, elements for other nodes). Once the task is cancelled, the compute stops at the next chunk end. Outside a step it asks for nothing.
    • Chunks end only after a MUL_MAT. No CPU or Metal fusion continues past one, so uncancelled output stays byte-identical.
    • No break before the step's first chunk end. The code predictor runs first, feeds sampled indices to get_rows, and at 1.7B never reaches the budget; a scheduler break stops only the current split.
  • The step re-checks the cancel after mtmd_helper_gen_audio_step_gen and mtmd_helper_gen_audio_get_output and returns CANCELLED. The scheduler reports success after a break.
  • The cancel latches: the first read that sees it, through llama_dart_tts_cancel or the flag, cancels the task for good. No step returns OK after a break.
  • llama_dart_tts_set_cancel_flag (new export) attaches a caller-owned byte. Once a step reads it as nonzero, the task behaves as after llama_dart_tts_cancel, even if the byte returns to zero. llamadart's worker isolate is inside the FFI step when the cancel arrives, so the main isolate needs to cancel without the task pointer. It is not a request field: llama_dart_tts_start rejects a smaller struct_size, which would break older callers.
  • LLAMA_DART_TTS_API_VERSION stays 1; both exports are additive. tools/validate_exports.py requires them. No llama.cpp change.

Measurements

M4 Max, Qwen3-TTS-12Hz-1.7B-Base Q4_K_M + mmproj Q8_0, Release, GGML_OPENMP=OFF, both heavy-work locks held, a fresh process per run, 1-min loadavg 6.27-10.69. before is v0.4.1 (a4ee6b9fa7). after is this branch with the callback installed; half its cancels go through llama_dart_tts_cancel, half through the flag. Time from the cancel to the return of the step it landed in. Metal decode steps last 359-395 ms, so Metal offsets end at 320 and 340 ms:

backend cancel lands in offset into the step before after
CPU end-of-speech decode 100-1250 ms 134.6-1303.1 ms, median 710.7, n=12; step returns COMPLETED 1.8-121.7 ms, median 72.2, n=24; CANCELLED
CPU frame-72 window decode 100-1250 ms 168.5-1289.1 ms, median 757.8, n=12; next step CANCELLED 12.4-132.6 ms, median 27.5, n=24; CANCELLED
Metal end-of-speech decode 100-320 ms 39.2-263.1 ms, median 149.3, n=12; COMPLETED 4.6-48.1 ms, median 18.1, n=24; CANCELLED
Metal frame-72 window decode 100-340 ms 39.1-278.7 ms, median 158.8, n=12; next step CANCELLED 6.2-52.1 ms, median 17.1, n=24; CANCELLED

A cancel waits for the running chunk to end, so the decode's longest chunk caps that wait; a window step then still runs one backbone decode before it returns. Longest chunk in the uncancelled runs below (5 end-of-speech and 2 window decodes per backend): CPU 128.7-148.6 ms and 131.4-135.4 ms, Metal 48.8-49.3 ms and 52.8-53.0 ms. In one CPU end-of-speech decode (1-min loadavg 18.18), the two longest chunks (131.2 and 136.1 ms) each hold 28 nodes, including 2 MUL_MATs (1.0e10 multiply-adds together) and 2 IM2COLs.

Uncancelled output, fresh processes, 1-min loadavg 10.21-12.35: every branch run (10 with the callback, 4 without) and every v0.4.1 repeat (6) is byte-identical to the first v0.4.1 run of its text and backend (cmp on raw float32 PCM; CPU and Metal; 27-38 and 120-124 frames). With the callback, the Metal end-of-speech decode takes 363.5-365.8 ms against 359.9-362.8 ms before; the CPU ranges overlap (1314.9-1426.8 ms against 1319.1-1416.1 ms).

Tests

  • tests/tts_eval_test.cpp (new, runs in wrapper-contract): the chunk rule on synthetic tensors; the cancel latch; and, on a real CPU ggml_backend_sched graph of MUL_MAT, RMS_NORM, MUL layers, chunked output equals unchunked, a cancel stops the scheduler at the chunk end, and an early cancel still runs to the first chunk end.
  • tests/tts_api_test.c: the callback is a no-op outside a step; the flag export rejects a null task.
  • tools/tts_smoke.cpp (local, model-backed): PCM is byte-identical with the callback; frame steps stay whole; no final-decode chunk exceeds a quarter of the decode; a cancel a quarter into the end-of-speech decode (llama_dart_tts_cancel) and into the frame-72 window decode (flag) returns CANCELLED from that step within a third of the decode; so do flag cancels at both decodes when the byte drops to zero right after the break; no step returns OK after a break; flag validation; a raised flag from an ended task does not cancel the next one. Passes 3 of 3 on CPU and on Metal.

Mutation proofs, each reverted after its run:

mutation reddens
cancel not latched tts_eval_test; smoke: both lowered-flag probes return OK after the break
no re-check after get_output smoke: end-of-speech step returns OK after a break
no re-check after step_gen smoke: window step returns OK after a break
flag never read smoke: the lowered-flag probes never break
set_cancel_flag accepts an idle task / a null flag smoke: flag validation
flag kept after the task ends smoke: next synthesis cancelled
callback without a step scope tts_api_test crashes
no eval scope in the step smoke: the final decode runs as one chunk
chunk end never breaks tts_eval_test; smoke: in-step cancel over its bound
boundary on any node tts_eval_test (the smoke stays green)
cancel ends a chunk before the first boundary / never early tts_eval_test
uncomputed nodes weighed; work not reset; MUL_MAT or views mis-weighed tts_eval_test
budget 1e9 smoke: a frame step split into chunks
budget 1e11 smoke: an 865.7 ms chunk in a 1365.8 ms decode
callback ignores the cancel smoke: in-step cancel over its bound

Smoke rows ran on CPU; the latch, re-check, flag-read, flag-kept, scope, never-break, and any-node rows also ran on Metal, with the same result.

ASan reports nothing across ctest, the smoke on CPU and Metal, and 12 cancel runs (llama_dart_tts_cancel, flag, and flag lowered after the break, at both decodes, on both backends, each followed by a synthesis). TSan reports nothing across 5 CPU cancel runs and the CPU smoke. CI passes 18 of 18 checks, including tts_eval_test in both wrapper-contract lanes.

Follow-up in llamadart (not here)

After a wrapper-only release: set cb_eval in createMultimodalContext when the symbol resolves, attach a per-task flag the main isolate owns, and bump the pin. Without the flag, the cancel still waits for the worker isolate.

Not verified

  • Only CPU and Metal, where the TTS graph is one split. On a multi-split backend a break stops the current split and each later split computes one node.
  • CUDA fuses groups that continue past a MUL_MAT (MUL_MAT, ADD, MUL_MAT, ADD, GLU), so a chunk end can split one and change output. Only CUDA's graph-uid fast path needs a nonzero uid; scheduler views have uid 0 and fall through to the per-node comparison.
  • The callback is installed on every clip context, including vision and audio encoders; outside a step each split runs as one view plus a sync. Encoder overhead is unmeasured.
  • It relies on the scheduler calling back on the stepping thread, which ggml_backend_sched_graph_compute does today.
  • A cancel that lands after the decode but before the step returns now yields CANCELLED instead of COMPLETED.

llama_dart_tts_step checked for a cancel only on entry, so a cancel that
landed in a step running the code2wav decode waited out the whole decode:
1.3-1.4 s on CPU and 0.36 s on Metal on an M4 Max.

Export llama_dart_tts_eval_callback for mtmd_context_params.cb_eval.
Inside a step it splits mtmd graph computes into chunks of about 2.5e9
multiply-adds, ends chunks only after a MUL_MAT, and stops at the next
chunk end once the task is cancelled. The step re-checks the cancel after
frame generation and audio output and returns CANCELLED.

Export llama_dart_tts_set_cancel_flag so a caller can cancel through a
byte it owns without sharing the task pointer with the cancelling thread.

Refs #86
The prompt step of the long text runs about 216 ms on CPU and the first
frame step about 82 ms cold on Metal, close to a quarter of the decode.
Waiting for a frame keeps the cancel on the decode step.
Lowering the cancel flag byte after the eval callback broke a decode let
the step return OK with the truncated decode's PCM. The first read that
sees a cancel now latches it for the rest of the task.

The smoke lowers the byte right after the break at the end-of-speech and
frame-72 decodes, and fails any step that returns OK after a break.
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