From 86b2237f3bb255007e9a0c610af0f2c78c74e70a Mon Sep 17 00:00:00 2001 From: Michael Fethe Date: Fri, 18 Sep 2026 13:46:07 -0400 Subject: [PATCH 1/3] model: make the trunk kernel per-model, not per-process 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 #68 --- Makefile | 5 +- src/model.c | 55 +++++++++++++------- src/model.h | 13 +++++ tests/run.sh | 9 ++++ tests/test_kernel_isolation.c | 96 +++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 tests/test_kernel_isolation.c diff --git a/Makefile b/Makefile index 0b97ea88b..6309520b1 100644 --- a/Makefile +++ b/Makefile @@ -261,7 +261,7 @@ waste$(EXE): cli/main.o libwaste.a TESTNAMES := test_kda test_container test_forward test_tokenizer test_k3parts \ test_qwenparts test_state test_vision test_vision_glm \ test_vision_ds41 test_image test_memory test_cpus test_lock sweep \ - kernel_kl test_qsa_pick test_qsa_attn + kernel_kl test_qsa_pick test_qsa_attn test_kernel_isolation TESTBINS := $(addsuffix $(EXE),$(TESTNAMES)) test: $(TESTBINS) @@ -278,6 +278,9 @@ test_kda$(EXE): tests/test_kda.o libwaste.a # what says the two agree. test_container$(EXE): tests/test_container.o src/crc32.o $(CC) $(CFLAGS) -o $@ $^ $(LDLIBS) +test_kernel_isolation$(EXE): tests/test_kernel_isolation.o libwaste.a + $(CC) $(CFLAGS) -o $@ $^ $(LDLIBS) + test_forward$(EXE): tests/test_forward.o libwaste.a $(CC) $(CFLAGS) -o $@ $^ $(LDLIBS) diff --git a/src/model.c b/src/model.c index 88394988c..5e40525aa 100644 --- a/src/model.c +++ b/src/model.c @@ -943,20 +943,21 @@ static void matvec_t_inner(waste_model *m, float *y, const waste_tensor *t, if (!t->q) { matvec(y, t->data, x, out, in); return; } const int g = t->group, ng = (in + g - 1) / g; const int mc = mv_chunk(out, t->rowbytes); - if (trunk_kern != TK_F32 && t->bits == 4 && (g & 31) == 0) { + const int tk = m->trunk_kern, sg4 = m->sdot4_sg; + if (tk != TK_F32 && t->bits == 4 && (g & 31) == 0) { mvq4_arg a = { y, (const uint8_t *)t->q, t->qs, m->xq, m->xs, - in, ng, g, sdot4_sg, g / sdot4_sg, t->rowbytes }; + in, ng, g, sg4, g / sg4, t->rowbytes }; waste_range_fn fn = NULL; const double tq0 = prof_on ? pnow() : 0; - if (trunk_kern == TK_SDOT && g % sdot4_sg == 0) { - quant_act4(x, in, g, sdot4_sg, m->xq, m->xs); + if (tk == TK_SDOT && g % sg4 == 0) { + quant_act4(x, in, g, sg4, m->xq, m->xs); fn = mvq4_rows_sdot; #if defined(__ARM_NEON) || defined(__aarch64__) - } else if (trunk_kern == TK_I8MM) { + } else if (tk == TK_I8MM) { quant_act4_mm(x, in, g, m->xq, m->xs); fn = waste_mvq4_rows_i8mm; #endif - } else if (trunk_kern == TK_SMLAL) { + } else if (tk == TK_SMLAL) { quant_act4_16(x, in, g, m->xq, m->xs); fn = mvq4_rows_smlal; } @@ -1040,7 +1041,7 @@ static void matvec_t_batch(waste_model *m, const float *x, int in, const mvb_item *it, int n) { #if defined(__ARM_NEON) || defined(__aarch64__) - const int shared = trunk_kern == TK_I8MM && !trunk_check && n > 1 && + const int shared = m->trunk_kern == TK_I8MM && !trunk_check && n > 1 && n <= MVB_MAX && it[0].t && it[0].t->q; const int g = shared ? it[0].t->group : 0; mvb_arg a; @@ -1053,7 +1054,8 @@ static void matvec_t_batch(waste_model *m, const float *x, int in, if (shared && t && t->q && t->bits == 4 && t->group == g && (g & 31) == 0) { const int k = a.n++; a.a[k] = (mvq4_arg){ it[i].y, (const uint8_t *)t->q, t->qs, m->xq, m->xs, - in, (in + g - 1) / g, g, sdot4_sg, g / sdot4_sg, + in, (in + g - 1) / g, g, m->sdot4_sg, + g / m->sdot4_sg, t->rowbytes }; a.mc[k] = mv_chunk(it[i].out, t->rowbytes); a.out[k] = it[i].out; @@ -1096,13 +1098,13 @@ static void matvec_t_batch(waste_model *m, const float *x, int in, * the projection. `prequant_ok` says whether `t` reads planes laid out that * way: i8mm, four bits, a group the kernel takes, and `span` — the size of * the caller's pieces — a whole number of groups. */ -static int prequant_ok(const waste_tensor *t, int span) +static int prequant_ok(const waste_model *m, const waste_tensor *t, int span) { #if defined(__ARM_NEON) || defined(__aarch64__) - return t && t->q && trunk_kern == TK_I8MM && !trunk_check && t->bits == 4 && + return t && t->q && m->trunk_kern == TK_I8MM && !trunk_check && t->bits == 4 && t->group > 0 && (t->group & 31) == 0 && span % t->group == 0; #else - (void)t; (void)span; + (void)m; (void)t; (void)span; return 0; #endif } @@ -1114,7 +1116,8 @@ static void matvec_t_prequant(waste_model *m, float *y, const waste_tensor *t, const double t0 = prof_on ? pnow() : 0; const int g = t->group; mvq4_arg a = { y, (const uint8_t *)t->q, t->qs, m->xq, m->xs, - in, (in + g - 1) / g, g, sdot4_sg, g / sdot4_sg, t->rowbytes }; + in, (in + g - 1) / g, g, m->sdot4_sg, g / m->sdot4_sg, + t->rowbytes }; waste_parallel_for_work(out, mv_chunk(out, t->rowbytes), waste_mvq4_rows_i8mm, &a, (size_t)out * t->rowbytes); if (prof_on) { @@ -2694,8 +2697,10 @@ int waste_model_load(waste_model *m, const char *dir, int kv_cap, * (LEARNED §83). The kernel is one setting for the whole process, so a * process that loads Qwen and then another architecture keeps i8mm for * both; the variable pins it either way. */ + m->trunk_kern = trunk_kern; + m->sdot4_sg = sdot4_sg; if (m->cfg.arch_qwen && !trunk_kern_env) - waste_model_set_sdot4(TK_I8MM, sdot4_sg); + waste_model_set_kernel(m, TK_I8MM, m->sdot4_sg); /* rope_init leaves no table for a shape it does not implement. Running * anyway would apply no rotation, which is not a degraded result but an * unordered one, so refuse instead. */ @@ -6303,15 +6308,31 @@ void waste_model_set_lookahead(int n) { lookahead_n = n < 0 ? 0 : n; } /* For tests/sweep.c: the SDOT trunk path is chosen once from the * environment, and an arm has to be able to flip it inside one process — * two arms in two processes are two computers (docs/LEARNED.md §33). */ -void waste_model_set_sdot4(int mode, int sg) +/* The same clamp both setters need: a kernel the CPU cannot run is not an + * error, it degrades to one it can. */ +static int kern_clamp(int mode) { const uint32_t f = waste_cpu_features(); if ((mode == TK_SDOT || mode == TK_I8MM) && !(f & WASTE_CPU_DOTPROD)) mode = TK_F32; if (mode == TK_I8MM && !(f & WASTE_CPU_I8MM)) mode = TK_SMLAL; if (mode < 0 || mode > TK_SMLAL) mode = TK_F32; - trunk_kern = mode; + return mode; +} + +/* Process default, inherited by subsequent loads. It deliberately does not + * reach into models already open — that reach was #68. */ +void waste_model_set_sdot4(int mode, int sg) +{ + trunk_kern = kern_clamp(mode); if (sg == 32 || sg == 64 || sg == 128) sdot4_sg = sg; } + +void waste_model_set_kernel(waste_model *m, int mode, int sg) +{ + if (!m) return; + m->trunk_kern = kern_clamp(mode); + if (sg == 32 || sg == 64 || sg == 128) m->sdot4_sg = sg; +} /* For tests/sweep.c: the size above which a matvec goes to the device. * 0 sends everything, a very large value sends nothing — which is how one * process measures both arms of "is the GPU worth it here". */ @@ -7638,7 +7659,7 @@ static void qwen_hc_mix_t(waste_model *m, float *hyper, float *normed = m->tmp; float *lo = normed + H; float *gate = lo + rank; - const int pq = prequant_ok(down, hid); + const int pq = prequant_ok(m, down, hid); { hcn_arg na = { normed, hyper, nw->data, hid, c->eps, cblock, inj_prev, pq ? down->group : 0, H, m->xq, m->xs }; @@ -7892,7 +7913,7 @@ static void qwen_gdn_layer(waste_model *m, int L, const float *in, float *out) if (Dv <= GDN_SCRATCH && tnw && tnw->data) { /* `normed` is `mixed`: in_proj_qkv's output, which nothing reads * after the conv, so the heads can write it while they run. */ - const int pq = prequant_ok(top, Dv); + const int pq = prequant_ok(m, top, Dv); gdnf_arg fa = { { Hk, Hv, Dk, Dv, q, k, v, m->gdn_g, b, m->S[L], core }, z, tnw->data, c->eps, mixed, pq ? top->group : 0, Hv * Dv, m->xq, m->xs }; diff --git a/src/model.h b/src/model.h index 8dce26ce0..620fd18e8 100644 --- a/src/model.h +++ b/src/model.h @@ -458,6 +458,14 @@ typedef struct { * counter are the only things it writes. Everything else it touches — * the bank table, the expert shapes, `verify` — is fixed at load. */ pthread_mutex_t fetch_mu; + /* The trunk kernel is per-model, not per-process. A Qwen load used to + * write the file-static default, so a Kimi context already open in the + * same process silently switched to i8mm arithmetic mid-session + * (sqliteai/warp#68). These two carry the choice with the model that + * made it; the file-static values remain the process default that a + * fresh load inherits. */ + int trunk_kern; /* TK_* for this model */ + int sdot4_sg; /* TK_SDOT activations per int8 scale */ } waste_model; /* Everything the load needs that is not in the container. These are @@ -499,6 +507,11 @@ void waste_model_reset(waste_model *m); int waste_model_resize_cache(waste_model *m, size_t cache_bytes); void waste_model_set_lookahead(int n); void waste_model_set_sdot4(int on, int sg); +/* Per-model form of the above: sets the kernel for `m` alone and leaves + * every other open model untouched. Callers that want to sweep kernel arms + * on one model must use this — the global form only moves the default that + * subsequent loads inherit. */ +void waste_model_set_kernel(waste_model *m, int mode, int sg); void waste_model_set_device_min_kb(long kb); void waste_model_set_metal_moe(int on); void waste_model_set_vq8(int on); diff --git a/tests/run.sh b/tests/run.sh index 820af9d86..1eb23540d 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1698,6 +1698,15 @@ else # Chunked prefill against sequential decode, the check that has caught # every state bug in this engine: the two share no code above the layer # loop and must agree bit for bit. + # A Qwen load picks the i8mm trunk kernel. That choice must stay on the + # model that made it: a non-Qwen context already open in this process + # keeps its own kernel and its own logits (#68). + if ./test_kernel_isolation "$MODEL" "$QWENC" >"$TMP/kiso.log" 2>&1; then + ok "a Qwen load leaves an open non-Qwen model's kernel and logits alone" + else + no "a Qwen load leaves an open non-Qwen model's kernel and logits alone" + fi + WASTE_CHUNK=1 ./test_forward "$QWENC" 3,7,11 "$TMP/qwen_chunk.bin" 0 \ >/dev/null 2>&1 if [ ! -s "$TMP/qwen_chunk.bin" ]; then diff --git a/tests/test_kernel_isolation.c b/tests/test_kernel_isolation.c new file mode 100644 index 000000000..6befc0c23 --- /dev/null +++ b/tests/test_kernel_isolation.c @@ -0,0 +1,96 @@ +/* test_kernel_isolation — the trunk kernel belongs to a model, not to the + * process (sqliteai/warp#68). + * + * Opening a Qwen container used to write a file-static default that every + * model already open then read from, so a non-Qwen context mid-session + * switched from the f32 trunk to i8mm and its logits moved underneath it. + * The fixtures are tiny, so the drift is small; on a real checkpoint it is + * an unannounced change of arithmetic in a live session. + * + * usage: test_kernel_isolation + * exit 0 pass, 1 harness failure, 3 leak observed + */ +#include +#include +#include + +#include "../src/model.h" + +static const int PROMPT[] = { 3, 17, 42, 8, 99, 5, 61, 23, 77, 12 }; +enum { NTOK = (int)(sizeof PROMPT / sizeof PROMPT[0]) }; + +/* Reset, replay the same tokens, return a private copy of the final logits. */ +static float *replay(waste_model *m, int *vocab) +{ + waste_model_reset(m); + const float *lg = NULL; + for (int i = 0; i < NTOK; i++) lg = waste_model_step(m, PROMPT[i], i, NULL); + if (!lg) return NULL; + *vocab = m->cfg.vocab; + float *copy = malloc((size_t)*vocab * sizeof *copy); + if (copy) memcpy(copy, lg, (size_t)*vocab * sizeof *copy); + return copy; +} + +int main(int argc, char **argv) +{ + if (argc < 3) { fprintf(stderr, "usage: %s \n", argv[0]); return 1; } + + waste_load_opts lo; memset(&lo, 0, sizeof lo); lo.direct_io = 1; + waste_model plain, qwen; + int rc = 1; + float *before = NULL, *after = NULL; + + if (waste_model_load(&plain, argv[1], 4096, &lo)) { + fprintf(stderr, "load %s failed\n", argv[1]); return 1; + } + if (plain.cfg.arch_qwen) { + fprintf(stderr, "fixture %s is a Qwen container; test needs a non-Qwen one\n", argv[1]); + waste_model_free(&plain); return 1; + } + + int v1 = 0, v2 = 0; + const int kern_before = plain.trunk_kern; + if (!(before = replay(&plain, &v1))) { fprintf(stderr, "first replay failed\n"); goto out1; } + + /* The event under test. */ + if (waste_model_load(&qwen, argv[2], 4096, &lo)) { + fprintf(stderr, "load %s failed\n", argv[2]); goto out1; + } + if (!qwen.cfg.arch_qwen) { + fprintf(stderr, "fixture %s is not a Qwen container\n", argv[2]); goto out2; + } + + if (!(after = replay(&plain, &v2))) { fprintf(stderr, "second replay failed\n"); goto out2; } + + if (v1 != v2) { printf("FAIL vocab moved %d -> %d\n", v1, v2); rc = 3; goto out2; } + + if (plain.trunk_kern != kern_before) { + printf("FAIL trunk_kern of the open model moved %d -> %d when %s was loaded\n", + kern_before, plain.trunk_kern, argv[2]); + rc = 3; goto out2; + } + if (memcmp(before, after, (size_t)v1 * sizeof *before) != 0) { + int n = 0; + for (int i = 0; i < v1; i++) if (memcmp(&before[i], &after[i], sizeof *before)) n++; + printf("FAIL %d/%d logits changed after loading %s\n", n, v1, argv[2]); + rc = 3; goto out2; + } + + /* The isolation must not have been bought by denying Qwen its kernel. */ + if (qwen.trunk_kern != 2 /* TK_I8MM */) { + printf("FAIL Qwen model did not select i8mm (trunk_kern=%d)\n", qwen.trunk_kern); + rc = 3; goto out2; + } + + printf("ok plain.trunk_kern=%d held, qwen.trunk_kern=%d, %d logits bit-identical\n", + plain.trunk_kern, qwen.trunk_kern, v1); + rc = 0; + +out2: + waste_model_free(&qwen); +out1: + waste_model_free(&plain); + free(before); free(after); + return rc; +} From 61365f8cf52846553d6d81b63092bd60c560ce77 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sat, 19 Sep 2026 04:14:13 -0400 Subject: [PATCH 2/3] model: fix kernel_kl/sweep to set kernel per-model; guard test_kernel_isolation Addresses marcobambini's CHANGES_REQUESTED review on #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-#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 (#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. --- src/model.c | 16 +++++++------- src/model.h | 7 ++++++ tests/kernel_kl.c | 4 ++-- tests/run.sh | 15 +++++++++---- tests/sweep.c | 2 +- tests/test_kernel_isolation.c | 41 +++++++++++++++++++++++++++++++---- 6 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/model.c b/src/model.c index 5e40525aa..838f60ed5 100644 --- a/src/model.c +++ b/src/model.c @@ -208,11 +208,11 @@ static inline float dotf(const float *a, const float *b, int n) static int q8_off = 1; /* 1 = keep the trunk stored as int8 */ static int sdot_on = 0; /* 1 = also quantize activations (SDOT path) */ -/* Which kernel the Q4G trunk matvec uses. The trunk is 28.0 GB of Q4G on - * K3 and every byte is read once per token, so this one choice is ~46% of - * a decode step (docs/EXP1.md §1). See model_opts_init for what each mode - * costs in accuracy. */ -enum { TK_F32 = 0, TK_SDOT = 1, TK_I8MM = 2, TK_SMLAL = 3 }; +/* TK_F32/TK_SDOT/TK_I8MM/TK_SMLAL are now declared in model.h (public, + * so tests can compare against the CPU-clamped expectation). The trunk + * is 28.0 GB of Q4G on K3 and every byte is read once per token, so this + * one choice is ~46% of a decode step (docs/EXP1.md §1). See + * model_opts_init for what each mode costs in accuracy. */ static int trunk_kern = TK_F32; /* WASTE_TRUNK_KERNEL */ static int trunk_kern_env = 0; /* set explicitly; waste_model_load */ static int sdot4_sg = 32; /* TK_SDOT only: activations per int8 scale */ @@ -2694,9 +2694,9 @@ int waste_model_load(waste_model *m, const char *dir, int kv_cap, * K3 kind that a recurrence carries forward: against f32 over 5,918 * tokens of real text, perplexity 3.712 against 3.698, no growth past * QSA's 2,048-token selection budget, for 7.57 -> 9.59 tok/s - * (LEARNED §83). The kernel is one setting for the whole process, so a - * process that loads Qwen and then another architecture keeps i8mm for - * both; the variable pins it either way. */ + * (LEARNED §83). The kernel is per-model (sqliteai/warp#68): a model + * already open when a Qwen container loads keeps whichever kernel it + * started with, and only this new model gets i8mm. */ m->trunk_kern = trunk_kern; m->sdot4_sg = sdot4_sg; if (m->cfg.arch_qwen && !trunk_kern_env) diff --git a/src/model.h b/src/model.h index 620fd18e8..a652eeb4d 100644 --- a/src/model.h +++ b/src/model.h @@ -19,6 +19,13 @@ #include "ecache.h" #include "tokenizer.h" +#include "waste_backend.h" + +/* Which kernel the Q4G trunk matvec uses. Public so callers (tests, + * benchmarks) can compare a model's trunk_kern against the CPU-clamped + * expectation rather than a literal; see src/model.c for what each mode + * costs in accuracy. */ +enum { TK_F32 = 0, TK_SDOT = 1, TK_I8MM = 2, TK_SMLAL = 3 }; /* Public image requests are decoded before resize. Keep the source-image * allocation finite so the memory planner can include its true worst case. */ diff --git a/tests/kernel_kl.c b/tests/kernel_kl.c index 7358acc0c..7beb87671 100644 --- a/tests/kernel_kl.c +++ b/tests/kernel_kl.c @@ -217,12 +217,14 @@ int main(int argc, char **argv) fprintf(stderr, "load failed\n"); return 1; } + waste_model_set_kernel(ma, ka, sg); for (int i = 0; i < nb; i++) { mb[i] = (waste_model *)calloc(1, sizeof *mb[i]); if (!mb[i] || waste_model_load(mb[i], argv[1], kv, &lo)) { fprintf(stderr, "load failed\n"); return 1; } + waste_model_set_kernel(mb[i], kb[i], sg); } const int V = ma->cfg.vocab, L = ma->cfg.n_layers, K = ma->cfg.top_k; float *A = (float *)malloc((size_t)V * sizeof(float)); @@ -241,13 +243,11 @@ int main(int argc, char **argv) t0 = now(); for (int pos = 0; pos < n + n_gen; pos++) { const int tok = pos < n ? ids[pos] : cur; - waste_model_set_sdot4(ka, sg); const float *la = waste_model_step(ma, tok, pos, ra); if (!la) { fprintf(stderr, "kernel a step %d failed\n", pos); return 1; } memcpy(A, la, (size_t)V * sizeof(float)); cur = argmax(A, V); for (int i = 0; i < nb; i++) { - waste_model_set_sdot4(kb[i], sg); const float *lb = waste_model_step(mb[i], tok, pos, rb); if (!lb) { fprintf(stderr, "kernel %d step %d failed\n", kb[i], pos); return 1; } score(A, lb, V, pos, pos + 1 < n ? ids[pos + 1] : -1, &w[i]); diff --git a/tests/run.sh b/tests/run.sh index 1eb23540d..b972c518a 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1695,18 +1695,25 @@ else no "QSA block pooling is wrong (3 tok: '$q3'; 4 tok: '$q4')" fi - # Chunked prefill against sequential decode, the check that has caught - # every state bug in this engine: the two share no code above the layer - # loop and must agree bit for bit. # A Qwen load picks the i8mm trunk kernel. That choice must stay on the # model that made it: a non-Qwen context already open in this process # keeps its own kernel and its own logits (#68). - if ./test_kernel_isolation "$MODEL" "$QWENC" >"$TMP/kiso.log" 2>&1; then + WASTE_CACHE_MB=512 ./test_kernel_isolation "$MODEL" "$QWENC" >"$TMP/kiso.log" 2>&1 + kiso_rc=$? + if [ "$kiso_rc" -eq 0 ]; then ok "a Qwen load leaves an open non-Qwen model's kernel and logits alone" + elif [ "$kiso_rc" -eq 2 ]; then + sk "a Qwen load leaves an open non-Qwen model's kernel and logits alone" \ + "$(tail -1 "$TMP/kiso.log")" else no "a Qwen load leaves an open non-Qwen model's kernel and logits alone" + tail -5 "$TMP/kiso.log" fi + # Chunked prefill against sequential decode, the check that has caught + # every state bug in this engine: the two share no code above the layer + # loop and must agree bit for bit. + WASTE_CHUNK=1 ./test_forward "$QWENC" 3,7,11 "$TMP/qwen_chunk.bin" 0 \ >/dev/null 2>&1 if [ ! -s "$TMP/qwen_chunk.bin" ]; then diff --git a/tests/sweep.c b/tests/sweep.c index 5d01a542f..485d67b14 100644 --- a/tests/sweep.c +++ b/tests/sweep.c @@ -189,7 +189,7 @@ int main(int argc, char **argv) } else if (is_dev) { waste_model_set_device_min_kb(arm[a]); } else if (is_sdot4) { - waste_model_set_sdot4(arm[a], sdot4_sg_env); + waste_model_set_kernel(&m, arm[a], sdot4_sg_env); } else if (is_look) { waste_model_set_lookahead(arm[a]); } else if (is_depth) { diff --git a/tests/test_kernel_isolation.c b/tests/test_kernel_isolation.c index 6befc0c23..efb3e52dd 100644 --- a/tests/test_kernel_isolation.c +++ b/tests/test_kernel_isolation.c @@ -1,3 +1,6 @@ +/* SPDX-License-Identifier: Apache-2.0 + * Copyright 2026 SQLite Cloud, Inc. + */ /* test_kernel_isolation — the trunk kernel belongs to a model, not to the * process (sqliteai/warp#68). * @@ -7,14 +10,23 @@ * The fixtures are tiny, so the drift is small; on a real checkpoint it is * an unannounced change of arithmetic in a live session. * + * The isolation must not be bought by denying Qwen its kernel: the test + * checks that the Qwen model picked the same kernel kern_clamp() would + * give it on this CPU. On a CPU without i8mm (all non-arm64 CI runners, + * and any arm64 without SMMLA), that is not i8mm — there is nothing to + * isolate from, and the repo rule is that a missing prerequisite is never + * a pass. WASTE_TRUNK_KERNEL, if set, changes what "would have selected + * i8mm" means, so it also routes to SKIP rather than a false PASS/FAIL. + * * usage: test_kernel_isolation - * exit 0 pass, 1 harness failure, 3 leak observed + * exit 0 pass, 1 harness failure, 2 skip (no i8mm CPU or kernel pinned), 3 leak observed */ #include #include #include #include "../src/model.h" +#include "../src/waste_backend.h" static const int PROMPT[] = { 3, 17, 42, 8, 99, 5, 61, 23, 77, 12 }; enum { NTOK = (int)(sizeof PROMPT / sizeof PROMPT[0]) }; @@ -36,7 +48,28 @@ int main(int argc, char **argv) { if (argc < 3) { fprintf(stderr, "usage: %s \n", argv[0]); return 1; } + /* Whether a fresh Qwen load on this CPU, with the env unset, would + * select i8mm at all. If not, there is nothing here to isolate: the + * check would either report a false FAIL (isolation looks broken + * because both kernels are already the same one) or, worse, a false + * PASS bought by comparing a kernel against itself. Same when + * WASTE_TRUNK_KERNEL is set, since Qwen then doesn't pick i8mm by + * load-time detection at all. */ + if (getenv("WASTE_TRUNK_KERNEL")) { + printf("SKIP WASTE_TRUNK_KERNEL is set; Qwen load-time kernel selection is overridden\n"); + return 2; + } + const uint32_t cpu = waste_cpu_features(); + if (!(cpu & WASTE_CPU_DOTPROD) || !(cpu & WASTE_CPU_I8MM)) { + printf("SKIP this CPU has no i8mm (features=0x%x); a Qwen load would not select TK_I8MM here\n", cpu); + return 2; + } + waste_load_opts lo; memset(&lo, 0, sizeof lo); lo.direct_io = 1; + { + const char *cmb = getenv("WASTE_CACHE_MB"); + lo.cache_bytes = (size_t)(cmb ? atoi(cmb) : 0) << 20; + } waste_model plain, qwen; int rc = 1; float *before = NULL, *after = NULL; @@ -45,8 +78,8 @@ int main(int argc, char **argv) fprintf(stderr, "load %s failed\n", argv[1]); return 1; } if (plain.cfg.arch_qwen) { - fprintf(stderr, "fixture %s is a Qwen container; test needs a non-Qwen one\n", argv[1]); - waste_model_free(&plain); return 1; + printf("SKIP fixture %s is a Qwen container; test needs a non-Qwen one\n", argv[1]); + waste_model_free(&plain); return 2; } int v1 = 0, v2 = 0; @@ -78,7 +111,7 @@ int main(int argc, char **argv) } /* The isolation must not have been bought by denying Qwen its kernel. */ - if (qwen.trunk_kern != 2 /* TK_I8MM */) { + if (qwen.trunk_kern != TK_I8MM) { printf("FAIL Qwen model did not select i8mm (trunk_kern=%d)\n", qwen.trunk_kern); rc = 3; goto out2; } From 77d624e2959893e7ebb260a025cfad3b275d2863 Mon Sep 17 00:00:00 2001 From: mfethe1 Date: Sat, 19 Sep 2026 11:13:21 -0400 Subject: [PATCH 3/3] tests: assert kernel_kl really compares two kernels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/run.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/run.sh b/tests/run.sh index b972c518a..60c4eb071 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1710,6 +1710,33 @@ else tail -5 "$TMP/kiso.log" fi + # kernel_kl is only meaningful if the two arms really are two kernels. + # When it set the kernel process-wide, the second load overwrote the + # first and it compared a kernel against itself: KL came out exactly + # 0.00e+00, which reads as "the kernels agree perfectly" — a passing + # number for a tool that had stopped measuring anything. A zero here is + # therefore not a strong result, it is the signature of that bug. + # + # Gated on kiso_rc 0, which is exactly the condition "this CPU selected + # TK_I8MM", i.e. a second kernel exists to compare against. On a CPU + # without i8mm, kern_clamp() folds k2 onto another arm and a zero would + # be honest rather than broken. + if [ "$kiso_rc" -eq 0 ] && [ -x ./kernel_kl ]; then + printf '3 7 11 5 9 13 2 17 4 8 19 23 6 29 12 31\n' >"$TMP/kkl_ids.txt" + kkl=$(./kernel_kl "$QWENC" "$TMP/kkl_ids.txt" 0 0 2 2>&1 | grep '^everything') + kkl_mean=$(printf '%s' "$kkl" | sed -n 's/.*KL mean \([0-9.e+-]*\).*/\1/p') + # Any nonzero mean is enough: the arms are distinct. The magnitude + # is fixture-dependent and deliberately not asserted. + if [ -n "$kkl_mean" ] && awk -v v="$kkl_mean" 'BEGIN{exit !(v+0>0)}'; then + ok "kernel_kl compares two distinct kernels (KL mean $kkl_mean)" + else + no "kernel_kl reported KL mean '$kkl_mean' between k0 and k2 — a zero means both arms ran the same kernel, not that the kernels agree (#68)" + printf '%s\n' "$kkl" + fi + elif [ "$kiso_rc" -eq 0 ]; then + sk "kernel_kl compares two distinct kernels" "kernel_kl not built" + fi + # Chunked prefill against sequential decode, the check that has caught # every state bug in this engine: the two share no code above the layer # loop and must agree bit for bit.