Skip to content

Apply rotary to MLA's rope dims on models that are not NoPE - #27

Open
fab2s wants to merge 4 commits into
sqliteai:mainfrom
fab2s:deepseek-rope
Open

Apply rotary to MLA's rope dims on models that are not NoPE#27
fab2s wants to merge 4 commits into
sqliteai:mainfrom
fab2s:deepseek-rope

Conversation

@fab2s

@fab2s fab2s commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Apply rotary to MLA's rope dims on models that are not NoPE

src/ implemented no rotary. Every occurrence of rope was qk_rope used as a
width, and rope_theta, rope_scaling and mla_use_nope were read nowhere.
Correct for the Kimi models, which set mla_use_nope and pass those dims through
unrotated; wrong for DeepSeek-V3, which sets no such flag and ships rope_theta
with YaRN. In MLA those dims are the only positional signal, so a V3 container
attended over an unordered sequence.

Why it is quiet

Lexically determined answers still come out right, which is why this survives
casual use. Kimi-K2 at 3-bit VQ3R, single user turn, greedy:

"The capital of France is" -> "Paris."

Add a second turn boundary and it collapses. Top-1 next token after
<|im_assistant|>assistant<|im_middle|>, 13-token prompt (system: A,
user: Hi):

top-1 top-2 top-3
as shipped <|im_end|> 0.968 Hi 0.014 I 0.005
with rotation Hi 0.491 Hello 0.483 Hey 0.025

<|im_end|> at 0.968 is an empty assistant turn: the model cannot tell which
turn came first, so it takes the highest-prior continuation. It is not a
degraded answer, it is an unordered one. Two user turns and no system turn
reproduce it identically (0.953), so it is the turn boundaries and not the
system role.

Isolation

Held identical between a reproducing and a non-reproducing model, and each
cleared:

  • prompt format and every special-token id — all 17 verified against the
    checkpoint's tokenizer_config.json, none absent, none extra
  • 3-bit VQ3R experts — same recipe on both models; ~19.5% per-tensor
    reconstruction error is the engine's normal operating point
  • prompt length — 20 and 38 tokens on both
  • chunked vs sequential prefilltest_forward with WASTE_CHUNK=0 and
    =1 give argmax 163586, max 15.9099 bit-identically. Both paths call
    mla_layer, which is also why they agree.
  • trunk precision — Q4G bulk, Q8G embed and lm_head, F32 for 1-D, i.e.
    --trunk-bits default

Control, same container recipe and the same two prompts, Kimi-Linear-48B:
Paris at p=0.761 (20 tok) and p=0.716 (38 tok) — stable across the added
turn. It answers correctly and does not care, because NoPE is right for it.

The change

  • rope_init reads mla_use_nope, rope_theta and rope_scaling, builds
    inv_freq with YaRN's ramp, and takes mscale_all_dim squared onto the
    attention scale — following DeepseekV3YarnRotaryEmbedding in the checkpoint's
    own modeling_deepseek.py.
  • rope_tables / rope_apply rotate a slice in place. The angles depend only on
    (pos, j), not on the head, so the tables are built once per token per layer and
    all heads reuse them.
  • mla_layer rotates the query's rope dims per head and the k-side slice
    before it enters the latent cache, because a cached entry is reused by every
    later query and carries its own token's position. The absorbed kv_b_proj
    identity touches only the nope half and is unaffected.

Two details that do not survive paraphrase, both called out in comments:

  • The rotation is GPT-J interleaved, pairing x[2j] with x[2j+1]. Upstream
    reaches the same arithmetic by de-interleaving before a half-split rotate, so
    writing the half-split form directly pairs dim j with j + qk_rope/2 — finite,
    weight-shaped and wrong.
  • YaRN rescales inv_freq globally, so it applies from position 0 and a short
    prompt does not avoid it. K2 carries beta_fast = beta_slow = 1.0 rather than
    HF's 32/1, which collapses the correction range to dims 19–20.

mscale also names two different factors: cos/sin carry mscale / mscale_all_dim,
which is 1.0 when the two are equal, while the attention scale carries
mscale_all_dim squared, which on K2 is 1.8133.

Nothing changes for Kimi-Linear or K3

rope_init returns early when mla_use_nope is set, leaving no table and
att_mul at 1, so those models take the same path as before by construction rather
than by a runtime branch. A container that needs rotation on a slice wider than
2 * WASTE_MAX_ROPE_HALF is refused at load — running unrotated is not a degraded
result but an unordered one.

Both prefill paths are covered by one site: chunked prefill calls mla_layer per
token, which is also why the two paths agreed bit-for-bit before the fix.

Verification

tools/deepseek_ref.py is included because the fix's credibility rests on it.
tools/kimi_ref.py cannot serve a V3 config — it indexes linear_attn_config
unconditionally and applies no rotary — so this is a companion on the same
contract: weights read from the container, so quantization error cancels and a diff
measures arithmetic only.

It reproduces the engine before contradicting it. Layer 0 residual stream, 7168
dims, 13-token prompt, against WASTE_DUMP_HIDDEN:

reference mode rel L2 vs engine
--no-rope --no-mscale vs pre-fix engine 0.0001%
default (rotation on) vs post-fix engine 0.0001%

Full depth, all 61 layers, engine-equivalent mode: top-1 163586 at p=0.967985
against the engine's own 0.968.

make check is 36 passed / 0 failed / 7 skipped with WASTE_REF_MODEL pointed at
a default VQ3R Kimi-Linear container, and that container's next-token distribution
is unchanged on both a 20- and a 38-token prompt (113476 at p=0.761 and p=0.716,
identical to before).

Scope

Not K2-specific. DeepSeek-V3, R1 and K2 all set no mla_use_nope and all ship
rope_theta with YaRN, so any V3-architecture container is affected. Nothing
changes for Kimi-Linear or K3.

Container used: Kimi-K2-Instruct, 61 layers, 384 experts top-8, VQ3R 3-bit,
354 GB expert set, 6.9 GB trunk, converted with tools/convert.py --stages 3.
Building one needs fp8 block-scale reading and the DeepSeek MoE tensor and config
names, which are #26 — that is a prerequisite for reproducing this, not
for building or testing the change here.

docs/LEARNED.md, CHANGELOG.md and WASTE_VERSION_* are deliberately untouched.

`src/` implemented no rotary: every occurrence of `rope` was `qk_rope` used as a
width, and `rope_theta`, `rope_scaling` and `mla_use_nope` were read nowhere.
That is correct for the Kimi models, which set `mla_use_nope` and pass those dims
through unrotated, and wrong for a DeepSeek-V3 checkpoint, which sets no such flag
and ships `rope_theta` with YaRN scaling. In MLA those dims are the only
positional signal — the nope dims are position-free by construction — so a
V3-family container attended over an unordered sequence.

It is quiet: lexically determined answers still come out right, so a single-turn
factual prompt looks correct. Add a second turn boundary and the model emits an
empty assistant turn, `<|im_end|>` at p=0.968 on Kimi-K2, because it cannot order
the prompt.

`rope_init` builds `inv_freq` with YaRN's ramp and takes `mscale_all_dim` squared
onto the attention scale, following `DeepseekV3YarnRotaryEmbedding`. Two details
do not survive paraphrase: the rotation is GPT-J interleaved, pairing `x[2j]` with
`x[2j+1]` — upstream reaches the same arithmetic by de-interleaving before a
half-split rotate, so applying the half-split form directly pairs the wrong dims
and still yields finite, weight-shaped output — and YaRN rescales `inv_freq`
globally, so it applies from position 0 rather than only at long context.

The k-side slice is rotated before it enters the latent cache, because a cached
entry is reused by every later query and carries its own token's position. The
absorbed `kv_b_proj` identity touches only the nope half and is unaffected.
Rotation is skipped entirely when `mla_use_nope` is set, so Kimi-Linear and K3 are
untouched by construction. A container needing rotation on a slice wider than
`2 * WASTE_MAX_ROPE_HALF` is refused at load rather than run unrotated.

`tools/kimi_ref.py` cannot serve a V3 config — it indexes `linear_attn_config`
unconditionally and applies no rotary — so `tools/deepseek_ref.py` is the oracle
for this path, on the same contract: weights read from the container, so
quantization error cancels and a diff measures arithmetic. With `--no-rope
--no-mscale` it reproduces the engine's pre-fix layer-0 residual to 0.0001% rel
L2, and its full-depth top-1 to p=0.967985 against 0.968.
The suite stayed green through the whole window in which `src/` applied no
rotary, and it would have stayed green after a fix that pairs the wrong dims.
Both have the same cause: every container the suite can reach is a Kimi, every
Kimi sets `mla_use_nope`, and so nothing in `tests/` ever entered `rope_init` or
`rope_apply`. This is the missing half of the previous commit.

`make_test_container.py --rope` writes a DeepSeek-V3 at the 1/18 scale the file
already builds a Kimi-Linear at: no `mla_use_nope`, `rope_theta` and the YaRN
block copied from Kimi-K2-Instruct's config, and no `linear_attn_config` at all,
which is what makes every layer MLA. All-MLA is deliberate twice over — it
exercises the rotation at depth rather than in the single full-attention layer
the Kimi mix leaves, and it is the shape `deepseek_ref.py` can read, since not
indexing `linear_attn_config` is exactly what separates it from `kimi_ref.py`.
K2's rope block rather than V3's because `beta_fast == beta_slow == 1.0`
collapses YaRN's correction range to a two-dim ramp, which is the more awkward
of the two to get right.

The checks build their own container instead of using `$MODEL`, so they run on
every host and do not wait on weights nobody can convert yet — sqliteai#26 is what makes
a real V3 container, and the shape is what the engine branches on.

  - rotated MLA against the PyTorch oracle
  - chunked prefill == token-at-a-time with rotation, which holds by
    construction today because `mla_layer` is per-token on both paths, and is
    exactly the "by construction" a later batched MLA would break quietly
  - a rope slice wider than `WASTE_MAX_ROPE_HALF` is refused at load

The first takes the same two-source shape as the Kimi oracle above it:
generate from `deepseek_ref.py` where `uv` exists, fall back to a fixture where
it does not, so the Linux image without `uv` runs it rather than skipping it.
Unlike that one the fixture ships, because this container is generated rather
than converted and so is byte-reproducible at `--seed 0` — the sidecar carries a
digest of the container it was made from, so a later change to the generator's
weights reads as "regenerate me" and not as an engine bug. The fixture is the
reference's logits, never the engine's.

`deepseek_ref.py` grows the `--dump` that `kimi_ref.py` already had, so the diff
is over whole logit vectors and not a printed top-k.

Verified by reverting `src/model.c` and `src/model.h` to their pre-fix state
with `tests/` and `tools/` left alone: the oracle check and the refusal check
both fail, which is the property that makes them worth having. Both fallback
paths were exercised directly — `uv` off `PATH` passes against the fixture, and
a corrupted digest skips with the regenerate message instead of reporting a
divergence.

`set -o pipefail` sank the refusal check on the first run, because a refused
load exits non-zero and that is the point; the output is read into a variable
now, with a comment saying why.

Suite on this commit: 46 passed, 0 failed, 2 skipped against Kimi-Linear and K3
(43/0/2 before), 39/0/9 on the synthetic path CI takes, and `make asan` 33/0/14.
Fuzzer and the 168 serve checks unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marcobambini

Copy link
Copy Markdown
Member

Reviewed this properly. Verification first, then two things I'd like your read on, then the test coverage.

Verification

I checked the arithmetic against modeling_deepseek.py itself rather than against deepseek_ref.py, so the two are independent: I built a synthetic non-NoPE container, and an oracle whose yarn_* helpers, rotate_half, apply_rotary_pos_emb and the _set_cos_sin_cache body are exec'd verbatim out of the file downloaded from the DeepSeek-V3 repo.

vs upstream-sourced oracle vs no-rope oracle
this branch, 6 tok 0.000023% 0.162%
this branch, 16 tok 0.000027% 0.0568%
main, 6 tok 0.162% 0.000023%

main is the exact mirror image, so the comparison discriminates rather than just agreeing with everything. Also checked: 4 greedy decode steps match the oracle token-for-token, which is the real test of rotating k before the latent cache; WASTE_CHUNK=1 agrees with sequential to 1.4e-6; ASan/UBSan clean across the rotation, decode, chunked prefill and the refusal. make check against a real Kimi-Linear container is 43 passed / 0 failed / 2 skipped, and a Kimi-Linear forward is byte-identical between main and this branch — as it must be, since att_mul is exactly 1.0f and rope_init returns before touching anything.

Both of the "do not survive paraphrase" claims hold. modeling_deepseek.py:364 really does q.view(b, h, s, d//2, 2).transpose(4, 3).reshape(...) before rotate_half, so the pairing is interleaved; upstream never re-interleaves afterwards, but q and k take the same permutation and the dot product is invariant to it. And K2's config really carries beta_fast == beta_slow == 1.0, which puts the correction range at dims 19–20 and mscale_all_dim² at 1.8133, both as you describe.

Two things

1. mla_use_nope is tested for presence, not value. js_get(...) >= 0 in rope_init means a container carrying "mla_use_nope": false silently skips the rotation — I built one, and its logits are byte-identical to the pre-fix result, i.e. exactly the bug this PR fixes, with no error. convert.py copies the source config verbatim, so an upstream checkpoint that writes the flag out as false reaches it. It is the house idiom in cfg_from_json, but for the other flags a misread costs a feature and here it costs the sequence order. json.h already parses JS_BOOL and keeps the token start, so a js_bool() helper is a few lines.

2. Unsupported rope_scaling shapes degrade silently rather than refusing. A type that is not "yarn""linear", "dynamic", or configs that spell the key rope_type — falls through to plain RoPE, and mscale != mscale_all_dim is ignored where upstream puts the ratio onto cos/sin. Nothing shipping is affected: V3, R1 and K2 all set type: yarn with mscale == mscale_all_dim == 1.0. But your own argument for refusing a too-wide slice — not a degraded result but an unordered one — applies here too.

Test coverage

Nothing in tests/ reaches the new code, because every container the suite can build sets mla_use_nope. That is the same blind spot that let the rotation be absent through green runs, and it would equally let a later "simplification" to the LLaMA half-split form through.

I wrote the missing half:

  • make_test_container.py --rope builds a DeepSeek-V3 at the same 1/18 scale the file already builds a Kimi-Linear at — no mla_use_nope, K2's rope_theta and YaRN block, and no linear_attn_config at all, which is what makes every layer MLA. All-MLA is deliberate twice: it exercises the rotation at depth rather than in the one full-attention layer the Kimi mix leaves, and it is the shape deepseek_ref.py can read, since not indexing linear_attn_config is precisely what separates it from kimi_ref.py. A --qk-rope N knob builds the over-wide slice.
  • Three checks: rotated MLA vs the oracle, chunked == token-at-a-time under rotation, and the over-wide refusal. They build their own container rather than using $MODEL, so they run everywhere and do not wait on weights nobody can convert until Read DeepSeek-V3 checkpoints in the converter #26.
  • The oracle check takes the same two-source shape as the Kimi one above it — generate from deepseek_ref.py where uv exists, fall back to a shipped 1 KB fixture where it does not, so the Linux image without uv runs it instead of skipping. Unlike the Kimi fixture this one can ship, because the container is generated rather than converted and so is byte-reproducible at --seed 0; a sidecar carries a digest of the container it came from, so a later change to the generator reads as "regenerate me" and not as an engine bug. The fixture is the reference's logits, never the engine's.
  • deepseek_ref.py gains the --dump that kimi_ref.py already had, so the diff is over whole logit vectors rather than a printed top-k.

Verified the way that matters: reverting only src/model.c and src/model.h to their pre-fix state, with tests/ and tools/ left alone, makes the oracle check and the refusal check fail. Both fallback paths were exercised directly too — uv off PATH passes against the fixture, and a corrupted digest skips with the regenerate message instead of reporting a divergence.

Suite goes 43 → 46 passed / 0 failed / 2 skipped, 39/0/9 on the synthetic path CI takes, make asan 33/0/14, fuzzer and the 168 serve checks unchanged.

It is one commit on rope-test, sitting directly on top of your f0364d1, so it should apply cleanly:

git fetch https://github.com/sqliteai/waste.git rope-test && git cherry-pick 90b111d

src/ is untouched by it — only tests/run.sh, tools/make_test_container.py, tools/deepseek_ref.py and the two fixture files.

Happy for that to land as a follow-up instead, if you would rather keep this PR to the fix itself.

…implement

Two ways the rotation could still be skipped with nobody told.

`mla_use_nope` was read by presence, which is the idiom cfg_from_json uses
for its other flags. A container carrying it as `false` therefore loaded as
NoPE and produced exactly the pre-fix result: unrotated logits, finite and
weight-shaped, no error. convert.py copies the source config verbatim, so a
checkpoint that writes the flag out rather than omitting it reaches that
path. js_bool() reads the token instead — `false` is false, a missing key is
still the default. The other flags keep the presence idiom, where a misread
costs a feature rather than the sequence order.

A rope_scaling shape the ramp does not implement fell through to plain RoPE
just as quietly. Any `type` but yarn landed there, and so did
mscale != mscale_all_dim, where upstream puts the ratio on cos/sin and this
does not. Nothing shipping is affected — V3, R1 and K2 all set `type: yarn`
with both mscales at 1.0 — but the argument for refusing an over-wide slice
is the same argument, so rope_init leaves a reason in cfg.rope_err and the
load refuses on it. The over-wide check moves into that mechanism, leaving
one refusal point rather than a condition re-derived at the call site.

Two shapes are read rather than refused. `rope_type` is HF's rename of
`type`, so it is an alias: a config that only spells it that way is rotated,
not turned away. And factor <= 1 stays a fall-through, because YaRN's ramp is
the identity there and both mscales collapse to 1 — plain RoPE is the right
answer, not a degraded one.

tools/deepseek_ref.py refuses the same two shapes rather than approximating
them, so it remains an oracle for exactly what the engine accepts.

Three checks over make_test_container.py's new knobs: a container built at
the same seed with `mla_use_nope: false` gives logits byte-identical to the
same model without the key, and each unimplemented rope_scaling is refused at
load. 37 passed, 0 failed, 12 skipped on the synthetic path (34/0/12 before),
`make asan` 36/0/13 with the six rotary checks passing and no sanitizer
report, fuzzer 400 cases 0 crashed. A full Kimi-K2 container still loads and
reports unchanged.
@fab2s

fab2s commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Both taken. Your test commit is on the branch unchanged — it sat directly on
f0364d1, so it fast-forwarded and 90b111d is byte-for-byte yours. The two
fixes are one commit on top, measured against your three checks.

1. mla_use_nope by value

js_bool() in json.h, used by rope_init. false is false; a missing key,
or a value that is not a JSON boolean, is the default.

The other flags keep the presence idiom, on your own distinction — a misread
there costs a feature, here it costs the sequence order. One line each if you
want them changed, but no container I can reach writes either as false, so
it would be a change with nothing to measure it against.

New check: make_test_container.py --rope --nope-false --seed 0 is the same
model with the flag spelled out, and its logits must be byte-identical to the
same model without the key. On f0364d1 they differ.

2. Unsupported rope_scaling

rope_init leaves a reason in cfg.rope_err and the load refuses on it. The
over-wide slice check moved into the same mechanism, so there is one refusal
point rather than a condition re-derived at the call site.

Refused: a type that is not yarn, and mscale != mscale_all_dim. The
second catches more than configs that set both — HF's defaults are mscale=1,
mscale_all_dim=0, so a config that omits mscale_all_dim needs the ratio
too and now says so.

Read rather than refused, both on the same test — does upstream compute
something other than what is already here?

  • rope_type is HF's rename of type. Alias, so a config spelling it that
    way is rotated rather than turned away.
  • factor <= 1 stays a fall-through: YaRN's ramp is the identity and
    yarn_get_mscale returns 1.0 for both mscales, so plain RoPE is the answer.

I did not implement the cos/sin ratio. Three lines in rope_tables and three
in the reference, but nothing ships a container that exercises it, so both
would be written against a formula and not against a number — the same
condition the missing rotation survived under. --mscale already builds the
container that would hold it honest; say so and I will write it against that.

tools/deepseek_ref.py refuses the same two shapes rather than approximating
them, so it stays an oracle for what the engine accepts.

Numbers

37 passed, 0 failed, 12 skipped on the synthetic path (34/0/12 at 90b111d),
make asan 36/0/13 with all six rotary checks passing and no sanitizer
report, fuzzer 400 cases / 0 crashed / 0 hung. Reverting src/ to 90b111d
with tests/ and tools/ left alone fails all three new checks. A 355 GB
Kimi-K2 container still opens and waste info reports it unchanged — the case
with a rope_scaling and no mla_use_nope at all.

Not this PR

compute pool binds to a cpu list is flaky here: six failures in twelve suite
runs, four of nine on this branch and two of three on 90b111d, every one
test_cpus.c:198, distinct > 1. ./test_cpus bind alone passed 8 of 8
idle and failed 1 of 40 with 24 spinners running.
waste_parallel_for(nthreads * 4, 1, ...) hands out 8 chunks of 1 and the
calling thread can drain all 8 before the second worker is scheduled; then
distinct == 1 reads as "the pool did not participate" when it only did not
need to. Neither commit touches the pool or that test. A rendezvous before the
ranges go out, or a chunk count tied to the thread count, would make it say
what it means — separate patch if you want it, and it may not reproduce off a
24-thread box.

@marcobambini

Copy link
Copy Markdown
Member

Both points landed, and the second one landed better than I asked for: moving the
over-wide check into cfg.rope_err leaves one refusal point instead of a condition
re-derived at the call site, which is the version I should have proposed.

The two shapes you read rather than refuse are right. factor <= 1 in particular —
YaRN's ramp is the identity there and yarn_get_mscale returns 1.0 for both mscales,
so plain RoPE is the answer and not a degraded one.

Verification

CI is 8/8 on b8c872c. Against real containers, make check with WASTE_REF_MODEL
on a default VQ3R Kimi-Linear is 49 passed / 0 failed / 2 skipped, K3 checks included.

The one that matters, since js_bool moved a line every NoPE container reaches: a
Kimi-Linear forward is byte-identical between main and this branch (8 tokens,
test_forward ... 0). And "mla_use_nope": true is still read as NoPE, so the new
accessor does not disturb what ships.

I also checked the mscale claim against the configs I had not covered last round —
DeepSeek-V2, DeepSeek-V2-Lite and DeepSeek-Coder-V2-Lite-Instruct all carry
mscale == mscale_all_dim == 0.707, so the V2 family passes the new refusal and gets
att_mul = 1.5896, which is what upstream computes. The refusal turns away nothing
that exists.

One real defect: "rope_scaling": null is refused, and should not be

waste: rope_scaling type "" is not implemented, only yarn

js_get returns a valid token for a JSON null, but it is not a JS_OBJ, so both
type and rope_type come back -1, js_str leaves the buffer empty, and the
strcmp refusal fires. The cost is specific: by that point rope_init has already
built the correct plain-RoPE inv_freq
in the loop above, and then throws it away.

It is not a hypothetical spelling. null is how HF configs say "no scaling", and
convert.py copies the config verbatim — every Kimi-Linear container I have on disk
carries "rope_scaling": null today. They are only saved by returning early on
mla_use_nope. Worth noting too that tools/deepseek_ref.py gets this shape right
(if not sc: return freq_extra, 1.0), so as it stands the oracle and the engine
disagree on exactly one input.

One line, and js_size already returns 0 for a non-container token:

-    if (rs < 0) return;                     /* plain RoPE, computed above */
+    if (rs < 0 || js_size(d, rs) == 0) return;   /* plain RoPE, computed above */

Measured with that applied:

  • "rope_scaling": null and {} load, and their logits are byte-identical to the
    same container with the key removed
  • {"factor": 40, "beta_fast": 1, "beta_slow": 1} — an object with no type — is
    still refused
  • all six rotary checks still pass, and the synthetic path stays green

Not a blocker: nothing shipping is affected, since every non-NoPE MLA checkpoint I can
find ships YaRN. It bites the first variant that drops it.

Two smaller ones

The default direction of js_bool on mla_use_nope. A value that is present but
not a JSON boolean now takes dflt = 0 and rotates. I built the two cases:
"mla_use_nope": 1 and "mla_use_nope": "true" both rotate a NoPE model, where the
presence idiom read them as NoPE. Nothing writes either — Kimi's configs and every
container I have use the boolean — so this is theoretical. But the direction of the
failure is the silent one this PR exists to remove, and there is already a mechanism
for it: present-but-not-boolean could leave a reason in rope_err and refuse, the
same way an unimplemented rope_scaling does.

The message for a rope_scaling with no type key reads as "the type is the empty
string" rather than "there is no type". Cosmetic, but it is the message someone will be
debugging from.

The flaky cpu-list check

Agreed, and it is not yours — neither commit touches the pool. Your reading matches
what I see: waste_parallel_for(nthreads * 4, 1, ...) hands out 8 chunks of 1 and the
calling thread can drain them all before a second worker is scheduled, so distinct == 1
reports "the pool did not participate" when it only did not need to. Separate issue,
separate patch.


Happy to take the js_size line as a follow-up commit here, or land the PR as it is and
fix it separately — your call. The rest is ready to merge from my side.

@marcobambini

Copy link
Copy Markdown
Member

Filed the cpu-list flake as #30. Two corrections to the diagnosis, since they change
which of your two suggested fixes is the right one.

The chunk arithmetic. chunk is ceil(n / nthreads), not min_chunk:

int chunk = (n + g_pool.nthreads - 1) / g_pool.nthreads;   /* (8 + 1) / 2 == 4 */
if (chunk < min_chunk) chunk = min_chunk;

so waste_parallel_for(nthreads * 4, 1, ...) with nthreads == 2 hands out 2 chunks
of 4
, not 8 of 1 — exactly one per thread. The caller only has to beat the worker to
the second one. Which means "a chunk count tied to the thread count" is already what
happens, and would not move anything.

The test already has the mitigation, and it is the part that is failing.
note_range burns 2M iterations per element on purpose:

/* ...then burns enough time that the next chunk is taken
 * by a different thread rather than by this one coming back for it. */

That is a wall-clock assumption — the caller must still be spinning in range 1 when the
worker wakes — and it is what stops holding under load. It also explains your numbers
better than a plain race would: 8/8 idle, 1/40 with spinners, 6/12 inside a full suite
run, where the suite is itself the load.

So your other suggestion is the right one. #30 proposes the rendezvous, with a timeout
rather than a bare pthread_barrier_wait so that a pool which genuinely does not
participate still fails the check instead of hanging the suite — and since there are
exactly nthreads chunks, a barrier of nthreads is the precise assertion. The fixed
spin can go with it.

Nothing here is yours to fix in this PR — it reproduces on 90b111d and on main, and
neither commit touches the pool. Thanks for spotting it and for saying so rather than
re-running until it went green.

… boolean

`"rope_scaling": null` was refused. js_get returns a token for a JSON null but
it is not a JS_OBJ, so `type` and `rope_type` both came back absent, js_str
left the buffer empty and the type refusal fired — on a container whose
correct plain-RoPE inv_freq the loop above had already built and then threw
away. null is how an HF config says "no scaling" and convert.py copies configs
verbatim, so it is the shape most containers on disk carry; the Kimi ones are
saved from it only by returning early on mla_use_nope. js_size is 0 for a null
and for {} alike, so one condition covers both. tools/deepseek_ref.py already
read this shape correctly, so the two agree again.

An mla_use_nope that is present but not a boolean took js_bool's default and
rotated. Nothing writes `1` or `"true"`, but defaulting picks the sequence
order out of a manifest that never said which one it wanted, and picks it
silently — the failure this file exists to remove. js_typeof() tells "absent"
from "present but not that", and present-but-not-boolean now leaves a reason
in rope_err the way an unimplemented rope_scaling does.

A rope_scaling carrying no type says so, rather than reporting the type as the
empty string. That is the message someone debugs from.

--nope takes a JSON value instead of being a --nope-false switch, and
--rope-scaling builds the null / {} / absent / no-type shapes. Three checks
over them: null and {} load with logits byte-identical to the same container
with no key at all, and the other two are refused at load.

40 passed, 0 failed, 12 skipped on the synthetic path (37/0/12 before),
`make asan` 39/0/13 with all nine rotary checks passing and no sanitizer
report, fuzzer 400 cases 0 crashed 0 hung. The shipped rotary fixture's
container digest is unchanged, so CI's no-uv path still compares. A full
Kimi-K2 container still opens and reports unchanged.
@fab2s

fab2s commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

All three taken.

"rope_scaling": null

Yours, and it reproduces exactly as you describe — the plain-RoPE inv_freq is
already built by the time the type refusal throws it away. js_size is 0 for a
null and for {} alike, so the one condition covers both:

if (rs < 0 || js_size(d, rs) == 0) return;

New check: null, {} and no key at all produce byte-identical logits, built
from make_test_container.py --rope --rope-scaling {null,empty,drop}. On
b8c872c the first two are refused. notype — an object with a factor and no
type — is still refused, and now says so:

waste: rope_scaling carries no type string, and only yarn is implemented

That covers the type key being absent and being present but not a string, since
js_str yields "" for both.

js_bool's default direction

Refused rather than defaulted, which is the direction you are right about:
defaulting picks the sequence order out of a manifest that never said which one
it wanted, and picks it silently. js_typeof() tells "absent" from "present but
not that", so an absent key still defaults and only a present non-boolean
refuses.

waste: mla_use_nope is present but is not true or false

1, "true" and null all land there; true and false are unaffected.
--nope now takes a JSON value rather than being a --nope-false switch, so
the check is over the same shapes you built.

Numbers

40 passed, 0 failed, 12 skipped on the synthetic path (37/0/12 at b8c872c),
make asan 39/0/13 with all nine rotary checks passing and no sanitizer
report, fuzzer 400 cases / 0 crashed / 0 hung. The rotary fixture's container
digest is unchanged, so CI's no-uv path still compares rather than skipping. A
355 GB Kimi-K2 container still opens and reports unchanged.

Thanks for checking the V2 family — mscale == mscale_all_dim == 0.707 giving
att_mul = 1.5896 is the case I could not test here, and it is the one that
says the refusal turns away nothing that exists.

On #30

My chunk arithmetic was wrong: ceil(n / nthreads) gives 2 chunks of 4, not 8
of 1, so "a chunk count tied to the thread count" is already what happens and
would have moved nothing. Your reading is the right one — the fixed spin in
note_range is a wall-clock assumption, and a barrier of nthreads is the
precise assertion, with the timeout so a pool that genuinely does not
participate fails rather than hangs.

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