Conversation
waste_model_load() on a Qwen container called waste_model_set_sdot4(TK_I8MM, ...), which writes the file-static trunk_kern/sdot4_sg read by matvec_t_inner(). Any model already open in the same process silently switched arithmetic mid-session. Reproduced on an M4 (FEAT_I8MM=1): load a non-Qwen container, prefill, then load a Qwen container in the same process and replay the identical tokens on the first model -- 256/256 logits change, max |delta| 0.423375845, argmax logit 13.046713 -> 13.053581. Pinning WASTE_TRUNK_KERNEL, or loading a second non-Qwen container instead, leaves the logits bit-identical; pinning TK_I8MM from the start reproduces the post-Qwen value exactly, which is what identifies the i8mm kernel as the cause rather than prefill nondeterminism. Carry trunk_kern/sdot4_sg on waste_model. A fresh load still inherits the process default (env/waste_model_set_sdot4), so existing behaviour is unchanged for single-model processes, and Qwen containers still select i8mm. matvec_t_inner() reads the choice off the model. Adds waste_model_set_kernel(m, mode, sg) so sweepers can retune one model without touching the others; waste_model_set_sdot4() is kept. Adds tests/test_kernel_isolation.c, registered in run.sh. It fails on the unpatched tree (exit 3, 256/256 logits changed) and passes here. Fixes sqliteai#68
marcobambini
left a comment
There was a problem hiding this comment.
Thanks for this. The diagnosis is right and the writeup is excellent: the three controls, and especially pinning TK_I8MM from the start to reproduce the post-Qwen value exactly, are what turn a logit diff into an identification. Moving trunk_kern / sdot4_sg onto waste_model is the right shape for the fix, and I reproduced the isolation here (M5 Pro, FEAT_I8MM=1): 256/256 logits bit-identical.
It can't go in as is, though. Two things are broken, one of them silently.
1. kernel_kl and sweep now compare a kernel against itself
Both load their models once and flip the kernel between steps with waste_model_set_sdot4() (tests/kernel_kl.c:244,250, tests/sweep.c:192). After this change that call only moves the default for future loads, so the open models never switch. The PR adds waste_model_set_kernel() for exactly this purpose but doesn't move either tool onto it.
Same synthetic container, kernel_kl <tiny> ids 16 0 2,3 32:
| k2 KL max | k2 relL2 | |
|---|---|---|
main @ 09fcff3 |
1.18e-08 | 1.09e-05 |
| this PR | 0.00e+00 | 0.00e+00 |
That's the worst way for this to fail: it reads as "the kernels agree perfectly". kernel_kl is how LEARNED §83 was measured, and it's the documented way to compare two trunk kernels.
Fix: after loading, waste_model_set_kernel(ma, ka, sg) and waste_model_set_kernel(mb[i], kb[i], sg) (once each; the per-step calls go away), and waste_model_set_kernel(&m, arm[a], sdot4_sg_env) in sweep's is_sdot4 arm. A check in run.sh that kernel_kl ... 0 2 is non-zero on a CPU that has a second kernel would keep this from coming back.
2. The new test assumes an i8mm CPU
CI fails the new check on linux-x86_64, windows-x86_64, asan (x86) and macos-arm64, and passes only on linux-arm64. The test requires qwen.trunk_kern == 2, but kern_clamp() degrades TK_I8MM:
- x86:
WASTE_CPU_DOTPRODis an ARM flag, so the result isTK_F32(0); - the macOS runner has no i8mm, so the result is
TK_SMLAL(3).
So "Qwen containers still select i8mm" holds on an M4, not in general. The expected kernel should come from waste_cpu_features(), the same way kern_clamp computes it. If that equals the plain model's kernel (always on x86), there's nothing to isolate and the check should report SKIP, per the repo rule that a missing prerequisite is never a pass. Exiting with a distinct code such as 2 for "nothing to test" lets run.sh call sk. The same applies when WASTE_TRUNK_KERNEL is set, since Qwen then doesn't pick i8mm by design. Please use TK_I8MM rather than the literal 2.
3. Smaller things
- SPDX header is missing on
tests/test_kernel_isolation.c, which is why build guards fails. tests/run.sh: the new block sits between the "Chunked prefill against sequential decode…" comment and the code it describes. Please move it above that comment. On failure, print$TMP/kiso.log(e.g.tail -5) like the neighbouring checks do: CI currently gives no clue which assertion fired.$MODELcan be a real container. It's loaded withcache_bytes = 0(automatic budget, so a fully resident preload on Kimi-Linear) just to run 10 tokens; honouringWASTE_CACHE_MBastest_forwarddoes, or passing a small explicit cache, would keep the check cheap. If$MODELis itself a Qwen container the test exits 1 and shows as FAIL; that should be a SKIP.- Stale comment: the one above the load site (
src/model.c, "The kernel is one setting for the whole process, so a process that loads Qwen and then another architecture keeps i8mm for both…") now describes the behaviour this PR removes.
With 1 and 2 addressed this is a clear improvement. Happy to re-review.
…_isolation Addresses marcobambini's CHANGES_REQUESTED review on sqliteai#75: 1. kernel_kl.c called waste_model_set_sdot4(ka, sg) / (kb[i], sg) inside the per-step loop, on process-global state that the now-per-model API no longer honors as a global default at that point -- both arms ended up reading the same trunk_kern the load already picked, so the KL comparison degenerated to a model compared against itself (KL max 0.00e+00 on kernels 2 vs 3, reproduced before this fix). Fixed by calling waste_model_set_kernel(ma, ka, sg) / (mb[i], kb[i], sg) once after load, before the step loop. Verified: kernel 2 vs 3 on tiny.waste now reports KL max 2.22e-08 (nonzero, consistent with the ~1e-8 order of magnitude on main pre-sqliteai#68). 2. sweep.c's trunk sweep key called the same removed waste_model_set_sdot4(arm[a], sdot4_sg_env) against a single shared model m -- switched to waste_model_set_kernel(&m, arm[a], sdot4_sg_env), matching the new per-model API. Verified: sweep with trunk=0,1,2,3 on tiny.waste completes without error across all 4 kernel arms. 3. test_kernel_isolation.c hardcoded TK_I8MM(2) as a private local duplicate; moved TK_F32/TK_SDOT/TK_I8MM/TK_SMLAL to model.h so both model.c and the test share one definition. Added: SPDX header (CI's "build guards / SPDX headers" job greps for it and was going to fail); a WASTE_CACHE_MB env passthrough so the test respects the harness's cache-size knobs like test_forward does; and, per review, two SKIP (exit 2) paths instead of a hard FAIL -- when WASTE_TRUNK_KERNEL pins the kernel, and when a non-i8mm CPU means a Qwen load would never have picked TK_I8MM to begin with, so there is nothing to isolate from and a bare FAIL would be a false negative on x86-64/older-arm64 CI runners. 4. run.sh: moved the isolation check above the chunked-prefill comment block it had been left under, wired WASTE_CACHE_MB=512 through, and split exit codes -- 0 ok, 2 sk (with the SKIP reason printed), else no with a tail -5 of the captured log so a real failure isn't silently swallowed into a one-line "no". 5. model.c: updated the load-path comment that still described the kernel as "one setting for the whole process" -- stale since this PR made it per-model; now describes the actual per-model behavior (sqliteai#68). Verified locally on M4 (arm64, i8mm+dotprod present): - make clean && make: 0 errors, 0 warnings - tests/kernel_kl, tests/sweep, tests/test_kernel_isolation built with -Wall -Wextra: 0 warnings - kernel_kl 2 vs 3 on tiny.waste: KL max 2.22e-08 (was 0.00e+00 before this fix, confirming the review's core objection reproduced and is now resolved) - test_kernel_isolation tiny.waste qwen.waste: ok, plain.trunk_kern=0 held, qwen.trunk_kern=2, 256 logits bit-identical - WASTE_TRUNK_KERNEL=0 test_kernel_isolation ...: SKIP (exit 2), as intended - sweep tiny.waste ... trunk=0,1,2,3 1: completes, all 4 arms run Not run: full tests/run.sh suite (needs the repo's Kimi-Linear/Qwen model fixtures, not available in this sandbox) -- the four files it exercises for this fix were validated directly against the isolated binaries above instead.
The zero KL you caught was not the kernels agreeing, it was both arms running the same kernel after the second load overwrote the first. That is the worst shape for a regression: a perfect score that means the tool stopped measuring. run.sh now reads the KL mean back from a k0-vs-k2 run and fails on a non-positive value, so the self-comparison cannot return silently. Gated on the kernel-isolation test passing, which is exactly the condition "this CPU selected TK_I8MM" — a second kernel exists to compare against. Where i8mm is absent kern_clamp() folds k2 onto another arm and a zero is honest, so the check skips rather than lying. Only the sign is asserted; the magnitude is fixture-dependent. Verified by rebuilding kernel_kl with the pre-fix process-global setter: the guard fails on it (0.00e+00) and passes on the fix (3.07e-09). Full suite 98 passed, 0 failed.
|
Guard added in What the guard asserts. Verified against the bug, not just against green. A guard that has never failed is an assumption. I rebuilt
So the check demonstrably discriminates; it is not green-by-construction. Why it is gated on the isolation test rather than a CPU probe.
Your M5 Pro reproduction and the CI arm64 runner independently land on the same order of magnitude ( One pair I considered and rejected. CI is 11/11 SUCCESS on Not claimed: this is still the 0.4 MB synthetic container, so it establishes that the two arms are distinct, not that either is numerically right on a real checkpoint. Ready for another look when you have time. |
|
Items 2 and 3 are addressed as well — all of them are in the branch now, and CI is green on all 11 checks including the four that were failing. 2. The test no longer assumes an i8mm CPU. The expected kernel now comes from
That last one was a FAIL before, which was wrong: a missing prerequisite is never a pass, and it isn't a failure either. Observed across this run: 3. The smaller items.
On the Full CI: 11/11 green, including |
A Qwen load sets the trunk kernel for the whole process, so a Kimi context already open switches to i8mm (#68).
Reproduced
Two contexts in one process on an M4 (
hw.optional.arm.FEAT_I8MM: 1), upstream09fcff3: load a non-Qwen container, prefill 16 tokens, keep it open, load a Qwen container, thenwaste_model_resetthe first model and replay the identical tokens.Controls, so the diff means something
A bare diff would not distinguish this from prefill nondeterminism. Three controls, same binary, same fixtures:
0 / 256differ, IDENTICALWASTE_TRUNK_KERNELpinned0 / 256differ, IDENTICALWASTE_TRUNK_KERNEL=2(TK_I8MM) from the start0 / 256, and A's before logit is13.053581The third one is the identification:
13.053581is exactly the after value from the uncontrolled run. The post-Qwen non-Qwen model is computing i8mm arithmetic.Cause
src/model.c:2698trunk_kernandsdot4_sgare file-static and read bymatvec_t_inner(), so this is a process-wide write. The comment just above it already notes the behaviour; #68 is the case where it becomes wrong.Fix
Carry
trunk_kern/sdot4_sgonwaste_model. A fresh load still inherits the process default, so single-model processes and the env override behave exactly as before, and Qwen containers still select i8mm — verified, not assumed:Also adds
waste_model_set_kernel(m, mode, sg)sokernel_kl.c/sweep.ccan retune one model without touching others.waste_model_set_sdot4()is unchanged and still sets the process default.Verification
0 / 256,max |delta|: 0. Reverse load order also identical.tests/run.sh: 97 passed, 0 failed, 16 skipped, includingPASS a Qwen load leaves an open non-Qwen model's kernel and logits alone.09fcff3and ran it: also 96 passed, 0 failed, 16 skipped (96 being 97 minus the new test). The delta against your 103/0/11 isK3_DIR/DS41_DIRreleases I don't have locally.make asanon both trees: 94 passed, 0 failed, 18 skipped each, and adiffof everyPASS|FAIL|SKIPline between patched and pristine is empty.FAIL 256/256 logits changed after loading qwen.waste.Test fixtures come from
tools/make_test_container.py(plain and--qwen), so the test needs nothing outside the repo.