Skip to content

Read DeepSeek-V3 checkpoints in the converter - #26

Merged
marcobambini merged 2 commits into
sqliteai:mainfrom
fab2s:deepseek-convert
Aug 12, 2026
Merged

Read DeepSeek-V3 checkpoints in the converter#26
marcobambini merged 2 commits into
sqliteai:mainfrom
fab2s:deepseek-convert

Conversation

@fab2s

@fab2s fab2s commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Read DeepSeek-V3 checkpoints in the converter

Two commits, both needed before convert.py can produce a container from a
DeepSeek-V3 family checkpoint (V3, R1, Kimi-K2). Independent of #27, which
fixes a separate defect in the engine's attention path — that one builds and tests
without this, but reproducing it needs a container, so this comes first.

fp8 block scales. These checkpoints store weights as F8_E4M3 with a per-tile
_scale_inv companion. Both safetensors readers now apply it — mxfp4.ST and
convert.py's own ShardReader, which is a second reader and was easy to miss.
The tile size is read from quantization_config.weight_block_size rather than
inferred from the weight and scale shapes: inferring looks possible and is wrong
whenever a dimension is not a multiple of the tile, and a compatible-but-wrong
size passes the shape check while placing every scale on the wrong rows.

MoE tensor names. Mixtral and Kimi use
block_sparse_moe.experts.E.w1/w3/w2; DeepSeek uses
mlp.experts.E.gate_proj/up_proj/down_proj. The layout is detected from what is
on disk and normalised to one spelling, so the engine still sees one name. Without
it the expert probe misses on every layer and a run reports 0 MB [missing] 60
times after the download has already completed.

MoE config keys. The same split one level up, and this half fails differently:
src/model.c reads num_experts, a DeepSeek config only spells it
n_routed_experts, so cfg_from_json yields 0 experts and the bank loop in
waste_model_load refuses the finished container with no diagnostic. Also
num_experts_per_tok against ..._per_token, which is one letter and leaves
top_k at 0. Normalised into the manifest where the tensor names already are, and
written only when absent so a config using the canonical spelling wins.

moe_renormalize is handled separately because model.c keys it on the field
being present rather than on its value — a plain alias of norm_topk_prob
would enable renormalisation for a checkpoint that sets it false. Emitted only
when true.

Testing

make check with WASTE_REF_MODEL pointed at a default VQ3R Kimi-Linear
container. tools/../test_fp8_blocks.py covers the block mapping on synthetic
tensors (aligned tiles, partial tiles on both axes, a missing companion, a gross
size mismatch) and cross-checks one real tensor bit-exactly against an
index-array dequant; it also documents the case that cannot be caught, a
compatible-but-wrong tile size, which is why the value is read from config.

The tests/test_convert_resume.py change is in the first commit rather than its
own: that file stubs mxfp4 with a types.ModuleType, so the new import has to
resolve there or every check in the file fails at module load, and the commit
would not be green on its own.

Verified end to end by converting Kimi-K2-Instruct — 61 layers, 384 experts
top-8, VQ3R, 354 GB expert set, 6.9 GB trunk — and opening it with waste info:
1.03 T total, 31.69 B active per token.

Nothing outside tools/ and that one test file changes. docs/LEARNED.md,
CHANGELOG.md and WASTE_VERSION_* are deliberately untouched.

fab2s added 2 commits August 6, 2026 00:26
fp8 checkpoints (DeepSeek V3/R1, Kimi-K2) store weights as F8_E4M3 with a
per-tile `_scale_inv` companion. Both safetensors readers now apply it. The tile
size comes from `quantization_config.weight_block_size` rather than being
inferred from the two shapes: inferring looks possible and is wrong whenever a
dimension is not a multiple of the tile, and a compatible-but-wrong size is
undetectable by shape alone.

MoE tensor names differ across the family. Mixtral and Kimi use
`block_sparse_moe.experts.E.w1/w3/w2`, DeepSeek uses
`mlp.experts.E.gate_proj/up_proj/down_proj`. The layout is detected from what is
on disk and normalised to one spelling, so the engine sees one name. Without it a
conversion finds no experts and reports `0 MB [missing]` for every layer, after
the download has already run.

`tests/test_convert_resume.py` stubs `mxfp4` with a `types.ModuleType`, which has
no `__file__`, so the new import has to resolve there or every check in that file
fails at module load.
The two families disagree on MoE config keys as well as on tensor names, and this
half fails differently. The engine reads `num_experts`; a DeepSeek config only
spells it `n_routed_experts`, so `cfg_from_json` yields 0 experts and the bank
loop in `waste_model_load` refuses the container with no diagnostic — after the
conversion has completed. `num_experts_per_tok` against `..._per_token` is one
letter and leaves `top_k` at 0.

The manifest is WASTE's format rather than HF's, so the keys are normalised where
the tensor names already are, and written only when absent so a config that
already uses the canonical spelling wins.

`moe_renormalize` is keyed on the field being present rather than on its value, so
a plain alias of DeepSeek's `norm_topk_prob` would turn renormalisation on for a
checkpoint that sets it false. It is emitted only when true.
@fab2s fab2s changed the title Deepseek convert Read DeepSeek-V3 checkpoints in the converter Aug 5, 2026
fab2s pushed a commit to fab2s/waste that referenced this pull request Aug 7, 2026
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
marcobambini merged commit 2431d42 into sqliteai:main Aug 12, 2026
9 checks passed
@marcobambini

Copy link
Copy Markdown
Member

Merged.

Verified before landing: CI 9/9, clean merge onto main, and locally
tests/test_convert_resume.py passes with the mxfp4.unblock_scale stub in
place — putting that stub in the first commit rather than its own was correct,
the file would not have been green otherwise.

The check that mattered for the models this repo already ships is that none of
this reaches them. normalise_cfg run against an existing Kimi-Linear
manifest's config returns an identical dict, no keys added. moe_layout
probes the Mixtral spelling first, so K3 and Kimi-Linear take the branch they
always did, and trunk_rename is the identity unless .mlp.experts. is in the
index. Purely additive, which is what makes it safe to land ahead of a release.

The reasoning I want on the record is the tile size. Reading it from
quantization_config.weight_block_size instead of inferring it from the two
shapes is right for exactly the reason you give: 300 rows against 3 scale rows
admits both 128 and 100, both pass the shape check, and only one is true. That
is a silent-wrong-answer failure — it would have surfaced downstream as VQ
reconstruction error nobody could attribute, on a conversion that takes hours.
The same goes for making a missing _scale_inv an error rather than a
fallback.

One thing is missing. The PR body describes test_fp8_blocks.py — aligned
tiles, partial tiles on both axes, a missing companion, a gross size mismatch,
and a bit-exact cross-check of one real tensor against an index-array dequant —
and it is not in the diff. That is the only test covering the path with the
silent failure mode, and by your own account it is already written. A follow-up
PR putting it under tests/ is enough; nothing else is outstanding.

Landed before #27 so that the reproduction path for the rotary fix is available
from a single checkout. Both are in the 0.6.8 section of CHANGELOG.md.

marcobambini added a commit that referenced this pull request Aug 12, 2026
Add opt-in exclusive model-container ownership, and cut 0.6.8.

The PR wrote its entry under 0.6.7, which shipped from main while it was in
review. Retargeted here to 0.6.8, with the section rewritten to cover what the
release actually contains — the DeepSeek converter (#26), the MLA rotary fix
(#27) and this lock — and one ABI paragraph naming every struct that moved
rather than only waste_cfg. WASTE_VERSION_PATCH goes with it, per CLAUDE.md's
rule that the changelog moves in the same commit as the version.

The added paragraph on the lock is the part of the review that was not written
down anywhere in the tree: the mechanism is host policy, container identity is
a poor proxy for memory oversubscription, and the budget question stayed open
as #31.

Verified on the merge, against real containers rather than the synthetic path:
tests/run.sh with WASTE_REF_MODEL on a default VQ3R Kimi-Linear is 54 passed,
0 failed, 2 skipped, K3 checks included, with the ownership check among them;
test_lock passes against a real container; the serve suite is 211/211.
@marcobambini

Copy link
Copy Markdown
Member

Follow-up on the one outstanding item from the merge review: the test_fp8_blocks.py your description covers is now tracked as #39, so it does not sit in a closed PR.

Nothing new is being asked there beyond what you already described. It adds two suggestions — the both-axes-partial corner, where the two independent repeat_interleave ceilings and the [:M, :N] crop meet, and a check that ST and ShardReader agree, since each parses weight_block_size separately and your own description is what pointed out that the second reader was easy to miss.

@fab2s no obligation — if you would rather not, say so on #39 and I will write it from your description.

marcobambini added a commit to tomatotomata/warp that referenced this pull request Aug 13, 2026
Two things on top of tomatotomata's checks, which are unchanged and were
right: the mutants I planted in unblock_scale — returning q untouched,
transposing the block dims, dropping the [:M, :N] crop, removing the shape
guard — were all caught, and expected_dequant being an independent index-array
implementation rather than a second call to the code under test is what gives
it that.

It never ran. run.sh invoked it as bare `python3`, and torch is not a system
package on the machines that test this: CLAUDE.md says it is never a repo
dependency and every other torch checker goes through uv. So the whole thing
reported "torch not installed" and skipped everywhere, CI included — where the
Linux job is the one that installs uv, which is to say the one place it does
get to run. Through run_uv now, with the guard on uv rather than python3,
since without uv run_uv exits 127 and the catch-all would report FAIL where a
SKIP is meant. 56 passed / 0 failed / 2 skipped becomes 57 / 0 / 2.

And the missing-companion check exercised ST only. Deleting ShardReader's own
`raise` in convert.py, so it returns the tensor unscaled instead, kept the
suite green — a silent wrong answer in the reader sqliteai#26's description called out
as "a second reader and was easy to miss". Both readers now, and that mutant
is killed.

The same mutation against ST survives, and should: without its guard, raw()
still raises KeyError naming weight_scale_inv, so the contract the test asserts
— refuses, and says which tensor — holds either way. That guard buys a better
message, not a different decision, and asserting its exact prose would test the
wording rather than the behaviour.

Also wrote down the case nothing here can catch, which is the reason the tile
size is read from config rather than inferred: 300 rows against 3 scale rows
admits both 128 and 100, both pass every shape check, and the wrong one applies
each scale to the wrong rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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