predictor: draw the sampling tail in vocabulary order again - #20
Open
localai-bot wants to merge 1 commit into
Open
predictor: draw the sampling tail in vocabulary order again#20localai-bot wants to merge 1 commit into
localai-bot wants to merge 1 commit into
Conversation
The unrolled frame graph walks the cdf over the top_k candidates in descending probability order, so a given philox u picks a different token than sample_top_k_p in sampling.h, which walks the F32 running sum in vocabulary order and crosses at acc >= u * sum (the same rule tests/cossim_common.py patched_multinomial replicates). Both draws have the same marginal distribution, but the realised sub-talker sequence changed at every stochastic step: over 2000 random 2048 wide rows at top_k 50, 1950 draws land on a different code than the host sampler returns for the same u. Greedy was unaffected, which is why the greedy parity checks stayed green. Downstream it reads as synthesis that never returns. A clone mode request that used to stop at 28 frames now runs to max_new_tokens: the LocalAI qwen3-tts-cpp suite went from about 5 minutes to a 20 minute test timeout (issue ServeurpersoCom#19, mudler/LocalAI#11286). Keep the fused frame graph and the u = 0 greedy shortcut, and put the draw back on the reference rule. argsort still supplies the rank order, but only two ranks are read out of it: rank 0 is the row max, rank k - 1 is the top_k cutoff. The mask keeps everything at or above the cutoff, the weights are the unnormalised exp(logit - max) the host sampler builds, and the cumsum crossing at u * total runs over the vocabulary. Greedy slots pin the cutoff at rank 0, so only the argmax survives the mask and any draw lands on it. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5
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.
Fixes #19.
What broke
26dd8ad("predictor: unroll the frame into one cgraph and sample in standard ops") moved the sub-talker draw intosrc/sampling-graph.h. The graph tail keeps thetop_kcandidates in descending probability order and crosses the cdf of that list againstu.The host sampler it replaced walks the cdf in vocabulary order:
and
tests/cossim_common.py::patched_multinomialreplicates exactly that arithmetic ("The C++ sampler draws u in [0, 1) and compares against acc/sum implicitly via acc >= u*sum. We replicate that exact arithmetic.").Both rules are valid multinomial samplers with the same marginal distribution, so nothing looks wrong in isolation. But they map a given
uto different tokens, so every stochastic acoustic code changed. Greedy was unaffected, which is why the greedy parity claim in26dd8adheld.Measured with a standalone harness that drives
sampler_tail_buildon random[2048, N]logit rows and compares againstsample_top_k_pfed the sameu(temperature 0.9, top_k 50):sample_top_k_pabab6b3Why it looks like a hang
An off-reference acoustic trajectory feeds the talker off-manifold and it stops emitting
codec_eos, so generation runs tomax_new_tokens. In the LocalAI e2e suite the clone spec went from 28 frames to the 2048 frame cap, which from the C ABI caller's side is a synthesis call that does not come back inside the 20 minute test timeout. Their CI log ends with[Pipeline] Generation done : 2048 framesimmediately before the Go panic, so it is a runaway, not a deadlock.I want to be straight about the causal claim: a different-but-valid draw is not by itself proof of a defect, and I am not claiming the descending walk produces an invalid distribution. What I am claiming is that it silently stopped reproducing this project's own reference sampler, and that the observed regression disappears exactly when parity is restored.
The fix
Keep the fused frame graph, keep the
u = 0greedy shortcut, put the draw back on the reference rule:argsortstill supplies the rank order, but only two ranks are read out of it: rank 0 (the row max) and rankk - 1(thetop_kcutoff).logits[i] < threshold -> -inf, ties included.exp(logit - max)the host sampler builds, the cumsum crossing is atu * total, and the walk runs over the vocabulary, so the crossing index is the token id and the candidate remap disappears.u = 0as before;step(u)pins the cutoff at rank 0, so only the argmax survives the mask and any draw lands on it.Cost is a few extra elementwise ops plus a cumsum over
n_vocab(2048) instead of overtop_k(50), against a 5 layer transformer pass per step. One file, no ABI change, no change toSamplerInputsor to the upload path.Verified
Linux x86_64, CPU backend,
qwen-talker-0.6b-base-Q4_K_M+qwen-tokenizer-12hz-Q4_K_M, built both with the default flags and with LocalAI's fallback flags (-DBUILD_SHARED_LIBS=OFF -DGGML_NATIVE=OFF -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off):abab6b3: 11 / 20 / 2048 frames, with[Sample]traces byte identical to their CI log (step=0 c0=1995,step=1 c0=215on the clone spec). On35ebe537: 17 / 21 / 28 frames.[Sample]traces identical to35ebe537.35ebe537on the clone path for seeds 1..12 and 42,--max-new 300: 13 / 13 WAVs match by md5.--greedy): md5 identical across35ebe537,abab6b3and this branch.--sub-temp 0, talker stochastic): md5 identical between35ebe537andabab6b3, which is how I established that the predictor logits themselves never changed and the difference is purely the draw.--codec-fusedstreaming still runs and agrees with the non-fused streaming path on frame count and output size.Not verified
ggml_exp,ggml_neg,ggml_step,ggml_scale_bias,ggml_cumsum,ggml_argsort,ggml_get_rows,ggml_sum_rowsandggml_cast; all of those have kernels in the vendored ggml for CPU, CUDA, Vulkan, SYCL and Metal, but "has a kernel" is not the same as "I ran it".ggml_exp,ggml_negandggml_addare the ops that are new to this file.tests/debug-*-cossim.pyneeds the HF checkpoints, torch and the 1.7B VoiceDesign GGUFs, none of which I have here. Thetests/*.loggrid refreshed inabab6b3was captured with the descending draw, so the stochastic entries will move again with this patch. Regenerating that grid is yours to do; I did not touch those files.max_batch > 1end to end throughtts-server, only the tail at N > 1 in isolation.Alternative you may prefer
If you would rather keep the descending walk (it is cheaper on the cumsum), the equivalent fix is to update
sampling.handtests/cossim_common.py::patched_multinomialto the same convention so the three stay in sync. I went with restoring the graph tail because it is the one that moved, and because it puts the released behaviour back byte for byte.