Skip to content

model: make the trunk kernel per-model, not per-process (fixes #68) - #75

Open
mfethe1 wants to merge 3 commits into
sqliteai:mainfrom
mfethe1:fix/per-model-trunk-kernel
Open

mfethe1 wants to merge 3 commits into
sqliteai:mainfrom
mfethe1:fix/per-model-trunk-kernel

Conversation

@mfethe1

@mfethe1 mfethe1 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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), upstream 09fcff3: load a non-Qwen container, prefill 16 tokens, keep it open, load a Qwen container, then waste_model_reset the first model and replay the identical tokens.

build: WASTE 0.8.1 (container v0, backend NEON, crc32 armv8, arm64)
WASTE_TRUNK_KERNEL env: (unset)
A loaded: kimi.waste, 4 layers, 8 experts, vocab 256
A prefill ok, vocab 256
B loaded: qwen.waste, arch_qwen=1
logits differing: 256 / 256
max |delta|: 0.423375845
first differing index: 0
argmax before: 209 (13.046713)   after: 209 (13.053581)
VERDICT: DIVERGED - opening B changed A's logits

Controls, so the diff means something

A bare diff would not distinguish this from prefill nondeterminism. Three controls, same binary, same fixtures:

control result
B is a second non-Qwen container instead of Qwen 0 / 256 differ, IDENTICAL
B is Qwen, but WASTE_TRUNK_KERNEL pinned 0 / 256 differ, IDENTICAL
pinned WASTE_TRUNK_KERNEL=2 (TK_I8MM) from the start 0 / 256, and A's before logit is 13.053581

The third one is the identification: 13.053581 is exactly the after value from the uncontrolled run. The post-Qwen non-Qwen model is computing i8mm arithmetic.

Cause

src/model.c:2698

if (m->cfg.arch_qwen && !trunk_kern_env) waste_model_set_sdot4(TK_I8MM, sdot4_sg);

trunk_kern and sdot4_sg are file-static and read by matvec_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_sg on waste_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:

qwen.waste  arch_qwen=1 trunk_kern=2 sdot4_sg=32     (TK_I8MM, perf preserved)
kimi.waste  arch_qwen=0 trunk_kern=0 sdot4_sg=32
after loading qwen.waste:  A.trunk_kern=0   B.trunk_kern=2

Also adds waste_model_set_kernel(m, mode, sg) so kernel_kl.c / sweep.c can retune one model without touching others. waste_model_set_sdot4() is unchanged and still sets the process default.

Verification

  • Bug case after the fix: 0 / 256, max |delta|: 0. Reverse load order also identical.
  • tests/run.sh: 97 passed, 0 failed, 16 skipped, including PASS a Qwen load leaves an open non-Qwen model's kernel and logits alone.
  • Those 16 skips are not from this patch. I built a pristine worktree at 09fcff3 and ran it: also 96 passed, 0 failed, 16 skipped (96 being 97 minus the new test). The delta against your 103/0/11 is K3_DIR / DS41_DIR releases I don't have locally.
  • make asan on both trees: 94 passed, 0 failed, 18 skipped each, and a diff of every PASS|FAIL|SKIP line between patched and pristine is empty.
  • The new test was checked for the failure it claims to detect: compiled against the unpatched library it exits 3 with 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.

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 marcobambini left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_DOTPROD is an ARM flag, so the result is TK_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.
  • $MODEL can be a real container. It's loaded with cache_bytes = 0 (automatic budget, so a fully resident preload on Kimi-Linear) just to run 10 tokens; honouring WASTE_CACHE_MB as test_forward does, or passing a small explicit cache, would keep the check cheap. If $MODEL is 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.

mfethe1 and others added 2 commits September 19, 2026 04:14
…_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.
@mfethe1

mfethe1 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Guard added in 77d624e. Thanks for catching the self-comparison — you were right that it was the worst possible shape for that bug, and I want to be precise about why the fix is the shape it is.

What the guard asserts. run.sh now reads the KL mean back from a k0 vs k2 run and fails on a non-positive value. Only the sign is asserted. The magnitude is fixture-dependent and I deliberately did not pin it — a threshold tuned to the synthetic container would be a second thing that passes for the wrong reason.

Verified against the bug, not just against green. A guard that has never failed is an assumption. I rebuilt kernel_kl with the pre-fix process-global setter (waste_model_set_sdot4 in place of waste_model_set_kernel at both call sites) and ran the guard against both binaries:

  • pre-fix binary → KL mean 0.00e+00 → guard fails, exit 1
  • fixed binary → KL mean 3.07e-09 → guard passes, exit 0

So the check demonstrably discriminates; it is not green-by-construction.

Why it is gated on the isolation test rather than a CPU probe. kiso_rc == 0 is already exactly the condition "this CPU selected TK_I8MM", so there is a second kernel to compare against. Where i8mm is absent, kern_clamp() (src/model.c:6313) folds k2 onto another arm and a zero would be honest rather than broken — asserting there would produce a false failure. That is borne out across CI on this run:

  • linux-arm64 — guard ran: PASS kernel_kl compares two distinct kernels (KL mean 3.08e-09)
  • linux-x86_64 — skipped, features=0x700, no i8mm
  • macos-arm64 — skipped, features=0x3, no i8mm

Your M5 Pro reproduction and the CI arm64 runner independently land on the same order of magnitude (3.07e-09 local, 3.08e-09 on the runner), which is a better cross-check on the measurement than anything I could assert alone.

One pair I considered and rejected. k0 vs k3 (TK_F32 vs TK_SMLAL) survives kern_clamp on every CPU and would have needed no skip at all — attractive, but mvq4_rows_smlal is inside #if defined(__ARM_NEON) || defined(__aarch64__) and compiles to a no-op on x86, so that pair would silently compare a kernel against itself on exactly the platforms the skip was meant to serve. Same bug, new home. The i8mm gate is the narrower but truthful option.

CI is 11/11 SUCCESS on 77d624e. Full local suite: 98 passed, 0 failed.

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.

@mfethe1

mfethe1 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

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 waste_cpu_features(), the same path kern_clamp() uses, and the literal 2 is gone in favour of TK_I8MM. There are three distinct SKIP routes, all exiting 2 so run.sh calls sk:

  • WASTE_TRUNK_KERNEL set — Qwen's load-time selection is overridden by design
  • no i8mm on this CPU — a Qwen load would not select TK_I8MM here, so there is nothing to isolate
  • $MODEL is itself a Qwen container — the fixture can't serve as the non-Qwen arm

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: linux-arm64 runs the check, linux-x86_64 skips at features=0x700, macos-arm64 skips at features=0x3. The claim is now "Qwen containers select i8mm where the CPU has it", which is the claim the code actually supports.

3. The smaller items.

  • SPDX header added to tests/test_kernel_isolation.cbuild guards passes.
  • run.sh block moved above the chunked-prefill comment, and failures now tail -5 "$TMP/kiso.log" like the neighbouring checks. The SKIP path prints tail -1 so the reason is visible rather than silent.
  • $MODEL now honours WASTE_CACHE_MB (run.sh passes 512), so the check no longer triggers a fully resident preload on a real container just to run ten tokens.
  • Stale comment in src/model.c removed — it described the process-global behaviour this PR deletes.

On the run.sh guard you suggested. I took it, and verified it against the bug rather than against green: rebuilding kernel_kl with the pre-fix process-global setter makes the guard fail (KL mean 0.00e+00, exit 1), and the fixed binary passes (3.07e-09, exit 0). A guard that has never failed is an assumption, so I wanted it to have failed at least once on purpose.

Full CI: 11/11 green, including linux-x86_64, windows-x86_64, asan + fuzz and macos-arm64, which were the four failing before. Ready for re-review whenever you have time — and thanks for the level of detail in the first pass, item 1 especially was a real bug and the table made it obvious.

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.

2 participants