This document walks through the full path from an unquantized Qwen3.5 checkpoint to a
running, verified 4-bit model: quantize_q4 (plain Q4_0), quantize_quarot (Hadamard-rotated
Q4_0, ADR-044), loading the result, and the perplexity-based check that tells you whether the
conversion actually preserved model quality. Every command below was run against
~/.lattice/models/qwen3.5-0.8b in this repository to confirm the exact flags and output shape.
For the underlying design (why rotation helps 4-bit quantization, the forward-equivalence
gate, the acceptance threshold), see
docs/adr/ADR-044-quarot-rotated-quantization.md.
This document is the practical companion: what to type and what the output means.
| Tool | Input | Output | Rotation | Use when |
|---|---|---|---|---|
bin/quantize_q4 |
BF16/F16/F32 safetensors | Q4_0 .q4/.f16 + index |
no | Fast, simple 4-bit conversion |
bin/quantize_quarot |
BF16/F16/F32 safetensors | Hadamard-rotated Q4_0 .q4/.f16 + index |
yes | Lower quantization error at the same bit width (ADR-044) |
Both write one file per tensor (<sanitized_name>.q4 for quantized tensors, <sanitized_name>.f16
for tensors kept at higher precision) plus a quantize_index.json that records, per tensor, its
output file, whether it was quantized, its original shape, and element count. This directory
layout is what MetalQwen35State::from_q4_dir (the Metal loader used by lattice serve,
lattice chat, chat_metal, and eval_perplexity --q4-dir) expects.
cargo build --release -p lattice-inference --bin quantize_q4
./target/release/quantize_q4 \
--model-dir ~/.lattice/models/qwen3.5-0.8b \
--output-dir ~/.lattice/models/qwen3.5-0.8b-q4quantize_q4 has no --help — passing an unrecognized flag, or omitting a required one, prints
usage to stderr and exits 1:
$ ./target/release/quantize_q4
--model-dir is required
Usage: quantize_q4 --model-dir <DIR> --output-dir <DIR> [--dry-run]
--model-dir directory containing model.safetensors[.index.json]
--output-dir directory to write .q4 and index files
--dry-run read tensors but skip writing output
--model-dir and --output-dir are both required; --dry-run reads and quantizes every tensor
in memory (so you see the same per-tensor log and summary) but skips every disk write, which is
useful as a smoke test before committing to a large conversion. There is no --seed — plain Q4_0
quantization is a deterministic per-block min/max scale-and-round, so there is nothing to seed.
A real run against the 0.8B checkpoint (which also carries a vision tower and an MTP head, hence 488 tensors rather than just the text-transformer's share):
=== quantize_q4: SafeTensors → Q4_0 ===
Model dir: ~/.lattice/models/qwen3.5-0.8b
Output dir: ~/.lattice/models/qwen3.5-0.8b-q4
Tensors: 488
[1/488] F16 model.language_model.embed_tokens.weight shape=[151936, 1024] 296.8MB dtype=BF16 0.128s
[2/488] Q4_0 model.language_model.layers.0.self_attn.q_proj.weight shape=[4096, 1024] 8.0MB→2.5MB 0.02s
...
[488/488] F16 mtp.pre_fc_norm_hidden.weight shape=[1024] 0.0MB dtype=BF16 0.000s
Index written: ~/.lattice/models/qwen3.5-0.8b-q4/quantize_index.json
=== Summary ===
Tensors processed: 488
Quantized (Q4_0): 271
Kept (F16): 217
Input size: 1.63 GB
Output size: 0.51 GB
Ratio: 3.20x (31.3%)
Total time: 6.2s
271 of 488 tensors are quantized; the other 217 (norms, biases, GDN's A_log/dt_bias/conv1d
weights, and the embedding table in this run) are kept at their original width and written as
.f16. The should_quantize rule in quantize_q4.rs targets the large projection/MLP/embedding
matrices and explicitly excludes norms, biases, and Mamba/GDN-specific scalars — quantizing those
would save almost no memory and would hurt accuracy disproportionately.
This is the single most important thing in this document. quantize_q4 never writes a
config.json — its output directory holds only .q4/.f16 tensor files and
quantize_index.json. This differs from quantize_quarot (below), whose output directory
does include one. Every consumer of a Q4 directory that isn't quantize_q4 itself assumes
config.json might be present, but they disagree on what happens when it's not:
lattice serve --model <q4-dir>andlattice chat --model <q4-dir>(inlattice.rs) call a helper (load_q4_config) that falls back to a hardcoded Qwen3.6-27B default config whenconfig.jsonis missing, printing only a one-line warning to stderr:Warning: <dir> has no config.json; falling back to the Qwen3.6-27B default config.For any checkpoint that isn't actually the 27B model — including the 0.8B checkpoint used throughout this document — this silently loads the wrong architecture (wrong layer count, hidden size, attention/GDN layer pattern). It does not fail; it fails wrong, and only a warning line distinguishes that from a normal load.eval_perplexity --q4-dir <dir>(see Step 3) hard-errors instead:ERROR: Q4 dir <dir> is missing config.jsonand exits 1. No silent fallback.lattice doctor --model <q4-dir>(see "Verify before you load", below) also ends up using the same Qwen3.6-27B fallback internally, which causes it to expect ~500 additional tensors that a smaller checkpoint doesn't have. It does fail closed (exit 1,Result: NOT READY), but the reported reason — "531 required tensor(s) missing" — points at the symptom, not the cause.
The fix is the same in all three cases: copy config.json from the source model directory into
the quantize_q4 output directory before loading it with anything else.
cp ~/.lattice/models/qwen3.5-0.8b/config.json ~/.lattice/models/qwen3.5-0.8b-q4/config.jsonquantize_quarot (Step 2) does not have this problem — it writes its own config.json
automatically as part of its output.
QuaRot (Ashkboos et al., NeurIPS 2024) applies a Hadamard rotation to the residual stream before
quantizing, spreading outlier magnitude across channels so that per-block min/max quantization has
less dynamic range to cover. quantize_quarot implements ADR-044 step 3c: rotate, quantize, then
verify the rotation didn't change the model's forward output beyond a tight numerical tolerance
before writing anything.
Unlike quantize_q4, this tool has real --help:
$ ./target/release/quantize_quarot --help
usage: quantize_quarot --model-dir <PATH> --output-dir <PATH> --seed <U64> [OPTIONS]
QuaRot Q4_0 converter for Qwen3.5 (ADR-044 step 3c).
required:
--model-dir <PATH> Input directory with config.json + safetensors.
--output-dir <PATH> Output directory (created if absent).
--seed <U64> Hadamard rotation seed (decimal or 0x... hex).
options:
--tolerance <F64> Forward-equivalence tolerance. Default 1e-5.
--num-probe-tokens <USIZE> Chain-probe sample size. Default 4.
--dry-run Run pipeline + gate; skip disk writes.
-h, --help Print this help and exit.
The converter refuses to write any output if the forward-equivalence
gate fails (delta > tolerance) — this protects against silently shipping
a model whose logits diverged during conversion.
--seed is required and has no default: converted artifacts from different seeds are not
interchangeable (the rotation matrix is seed-derived), so the project's convention is to record
the seed used, not rely on an implicit default. It accepts decimal or 0x-prefixed hex, with
optional _ separators exactly like a Rust integer literal (0xCAFE_BABE_DEAD_BEEF).
cargo build --release -p lattice-inference --bin quantize_quarot
./target/release/quantize_quarot \
--model-dir ~/.lattice/models/qwen3.5-0.8b \
--output-dir ~/.lattice/models/qwen3.5-0.8b-q4-quarot \
--seed 0xC0FFEEReal output from that command:
=== quantize_quarot: QuaRot Q4_0 converter ===
Model dir: ~/.lattice/models/qwen3.5-0.8b
Output dir: ~/.lattice/models/qwen3.5-0.8b-q4-quarot
Seed: 0x0000000000c0ffee
Tolerance: 0.00001
Probe toks: 4
Mode: WRITE
=== Summary ===
Tied input: true
Quantized (Q4_0): 188
Kept (F16): 148
Input bytes: 1.44 GB
Output bytes: 0.62 GB
Compression: 2.30x (43.4%)
Forward-equiv: max_abs=7.017e-14, mean_abs=1.050e-14 (tol=1e-5, probes=[17156, 85503, 9161, 94570])
Wall time: 77.6s
Tied input: true means this checkpoint ties its embedding and lm_head weights (one tensor,
two roles) — the converter detected and preserved that. The Forward-equiv line is the
correctness gate: it runs the unrotated and rotated model forward on --num-probe-tokens
sample token chains and reports the max/mean absolute logit difference. Here max_abs=7e-14
against a tol=1e-5 tolerance is nine orders of magnitude inside the bound — the rotation is
mathematically exact up to floating-point noise, as it should be (a Hadamard rotation is
orthogonal; it changes representation, not the function computed). If this gate fails, the tool
writes nothing — ConversionReport is never returned, main prints ERROR: ... and returns
ExitCode::FAILURE (exit 1), and the output directory is left without a completed conversion.
There is no partial/corrupt output state to clean up.
Note the different tensor counts (188 quantized + 148 kept = 336) versus plain quantize_q4's
271 + 217 = 488 on the same source directory. The two exclusions behind that gap are different
kinds of things, and it's worth being precise about which is which
(crates/inference/src/quant/quarot/convert.rs):
- Vision tower — excluded, never read. Qwen3.5-0.8B's checkpoint on disk is multimodal (it
ships a
vision_config); QuaRot's converter never reads or rewrites those tensors on either the input or output side. They simply aren't part of this pipeline. - MTP tensors — copied, not rotated or quantized. When the config has
mtp_num_hidden_layers > 0(true for Qwen3.5-0.8B, which ships 1 MTP layer),write_mtp_weights_quarotcopies each MTP tensor into the output directory as an unrotated, unquantized.f16file. They're part of the 336-tensor output — counted in the 148 "kept (F16)" — not silently dropped. Rotating and quantizing MTP tensors is deferred to a later phase (see the ADR-051 note inconvert.rs); today they ride along as plain f16 copies.
If your workflow needs the QuaRot path specifically for text generation (chat/serve), this is expected — the language-model tensors are the ones rotated and quantized, and MTP still loads and runs (as f16), it's just not yet part of the rotation; it is not a partial-conversion bug.
A checkpoint that loads and generates something is not evidence the quantization preserved
quality — Q4_0 and especially a botched rotation can produce fluent-looking but degraded text.
eval_perplexity (ADR-044 step 4) is the actual acceptance check: it scores a held-out text
corpus and reports perplexity (PPL), and in its dual-Q4 mode it computes the PPL delta between an
unrotated and a rotated checkpoint against a threshold.
$ ./target/release/eval_perplexity --help
(flags summarized from the binary's own doc comment, crates/inference/src/bin/eval_perplexity.rs)
| Flag | Meaning |
|---|---|
--model-dir <PATH> |
CPU baseline mode: safetensors checkpoint via Qwen35Model::from_safetensors. |
--q4-dir <PATH> |
Metal Q4 mode: a quantize_q4 output directory. |
--quarot-q4-dir <PATH> |
Metal Q4 mode: a quantize_quarot output directory. |
--q4-dir + --quarot-q4-dir together |
Dual-Q4 mode: runs both, prints both reports, then the delta and the ADR-044 acceptance verdict. |
--tokenizer-dir <PATH> |
Required for Metal modes — Q4 directories don't ship tokenizer.json, so point this at the original safetensors directory. |
--corpus-file <PATH> |
UTF-8 text file, tokenized end-to-end. |
--window / --stride |
Context window / stride in tokens. Default 512 / 256. |
--max-tokens |
Cap on tokens scored, for a fast smoke run. Default: no cap (scores the whole corpus). |
--delta-threshold |
Dual-Q4 mode only. Default 0.5. |
Exit code is 0 when PPL is computed and (in dual-Q4 mode) the delta is below the threshold; 1 on any error, or on a dual-Q4 delta at or above the threshold (acceptance fail).
The repository ships a small held-out corpus at docs/bench_results/wiki.test.raw for exactly
this purpose. A real dual-Q4 run against the two checkpoints produced above (remember: --q4-dir
needs config.json copied in per the gotcha above; --quarot-q4-dir already has its own):
./target/release/eval_perplexity \
--q4-dir ~/.lattice/models/qwen3.5-0.8b-q4 \
--quarot-q4-dir ~/.lattice/models/qwen3.5-0.8b-q4-quarot \
--tokenizer-dir ~/.lattice/models/qwen3.5-0.8b \
--corpus-file docs/bench_results/wiki.test.raw \
--max-tokens 4096=== Perplexity Report (unrotated Q4) ===
PPL: 18.349821
Tokens scored: 4095
Windows: 15
=== Perplexity Report (QuaRot Q4) ===
PPL: 20.608386
Tokens scored: 4095
Windows: 15
=== Acceptance Gate (ADR-044 step 4) ===
Unrotated Q4 PPL: 18.349821
QuaRot Q4 PPL: 20.608386
PPL delta: +2.258565 (quarot - unrotated)
Threshold: < 0.500000
Verdict: FAIL (delta >= threshold)
Read this result carefully before concluding anything is broken: 15 windows is far too small a
sample to make an accept/reject call. --max-tokens 4096 is useful for confirming the whole
pipeline runs end to end (both checkpoints load, both score, the gate logic itself works), not for
a real quality verdict — treat a FAIL at this scale as "rerun on more data," not "the conversion is
bad." The project's own published reference numbers, at a still-modest but larger 2048-token
window count (docs/bench_results/perplexity.tsv, regenerated 2026-07-07 and cited in the
README), show a delta of the same sign and similar magnitude: lattice q4 16.589166 vs
lattice q4-quarot 19.007144 — +2.42, far over the nominal 0.5 threshold. That is a documented
property of the current offline QuaRot pipeline, not something unique to a fresh from-scratch
conversion: offline QuaRot v0 is net-negative by design at this stage (the Hadamard rotation
forces symmetric Q4 with a worse fidelity floor; the missing recovery mechanism is online R3/R4,
tracked in #703 — see the provenance
comments in the TSV itself). A FAIL whose delta lands near the reference's +2.42 therefore says
your conversion matches known pipeline behavior; a delta far beyond it is the signal worth
investigating. Reproduce the project's own reference numbers with ./scripts/bench_quality.sh,
or omit --max-tokens entirely (scores the full corpus, 310,034 tokens for wiki.test.raw —
expect this to take considerably longer than the smoke-test run above) before treating either a
PASS or a FAIL as a real acceptance decision.
eval_perplexity also emits a machine-readable line per report on stdout, independent of the
human-readable summary on stderr:
@@lattice {"ev":"perplexity","label":"unrotated Q4","ppl":18.349821,"nll":2.90962,"tokens":4095,"windows":15,"ms":14090}
Once you have a Q4 directory with config.json present (native for quantize_quarot, copied
manually for quantize_q4), it loads through the same CLI entry points as any other checkpoint —
lattice auto-detects the format from the directory contents (presence of any *.q4 file selects
the Q4/Metal path; model.safetensors/.index.json selects the CPU/safetensors path):
# Interactive, one-shot-per-line REPL (no cross-turn conversation history)
./target/release/lattice chat \
--model ~/.lattice/models/qwen3.5-0.8b-q4-quarot \
--tokenizer-dir ~/.lattice/models/qwen3.5-0.8b
# OpenAI-compatible HTTP server — see docs/serve-http-api.md for the full API
./target/release/lattice serve \
--model ~/.lattice/models/qwen3.5-0.8b-q4-quarot \
--tokenizer-dir ~/.lattice/models/qwen3.5-0.8b \
--port 8080--tokenizer-dir is required for a Q4 directory in both cases (Q4 output never includes
tokenizer.json) and should point at the original safetensors directory. Both commands require
the metal-gpu feature at build time (cargo build --release -p lattice-inference --bin lattice --features metal-gpu,f16); without it, a Q4 model directory is rejected with a clear
"requires the metal-gpu feature" error rather than attempting a CPU fallback (Q4 inference in this
codebase is Metal-only).
lattice doctor --model <dir> --tokenizer-dir <dir> is a preflight check: it reports the detected
format, weight memory footprint, KV-cache cost per token, and whether the checkpoint's tensors and
your system's memory are actually sufficient to load it, without spending the time to load and run
the model. Run it before chat/serve on any new Q4 output, including QuaRot outputs.
doctor exits 0 with Result: OK when the checkpoint is loadable and fits comfortably; exits 1 with
Result: NOT READY and a specific reason otherwise. If doctor reports dozens or hundreds of
"missing required tensors" with layer indices well beyond what you'd expect for your model size
(for example, layers in the 20s-40s for what you know is a small checkpoint), the near-certain
real cause is a missing config.json in that directory (see the gotcha in Step 1) rather than a
corrupted or incomplete conversion — doctor inherits the same Qwen3.6-27B config fallback as
lattice serve/lattice chat, so a missing config makes it expect a 27B-shaped tensor set against
your smaller checkpoint's actual tensors. Copy config.json in and re-run doctor before assuming
the conversion itself is broken.
The bounded, fail-closed read of quantize_index.json is centralized in one place: the
lattice_inference::quant::q4_manifest module (crates/inference/src/quant/q4_manifest.rs).
doctor's tensor inventory and QuaRot rotation-seed detection (used by both doctor and
the Metal Q4 loader's QuaRot-flavor detection) both read the file through this module's
bounded reader, rather than each call site re-deriving its own copy of the read contract.
A genuinely absent manifest is Ok(None) — a legitimate legacy/no-manifest state that
callers fall back from (a directory scan for doctor, the legacy config.json field for
the rotation seed). A present manifest that is unreadable (including a dangling
symlink), exceeds the size cap, or fails to parse fails closed as an error; it is never
silently treated as absent, since doing so could load a QuaRot-rotated checkpoint under the
wrong (or no) rotation.
Shape normalization is deliberately not fully unified, because doctor's inventory and
the QuaRot seed loader have different accept/reject contracts: doctor only ever reads
name/file per tensor and tolerates a minimal entry; the seed loader requires every field
of an object-form manifest and rejects a partial entry as corruption. The module recognizes
both quantize_index.json manifest shapes for doctor's tolerant inventory:
quantize_q4writes a bare JSON array of tensor entries.quantize_quarotwrites an object with atensorsarray and metadata such asquarot_seed.
Both shapes normalize to the same tensor inventory for doctor; the QuaRot seed field is
populated only for the object form, and is None (not an error) when absent from either
shape. The seed loader keeps its own strict, shape-specific parse on top of the shared
bounded reader. Older output captured before issues
#626 and
#627 may show an invalid type: map, expected a sequence manifest error for QuaRot directories. That failure mode is historical for current
lattice doctor builds.
quantize_q4 --model-dir SRC --output-dir Q4_DIR— fast, no rotation, noconfig.jsonwritten.quantize_quarot --model-dir SRC --output-dir QUAROT_DIR --seed 0x...— rotated, includes its ownconfig.json, refuses to write output if its internal forward-equivalence check fails.- Copy
config.jsonfromSRCintoQ4_DIR(not needed forQUAROT_DIR). lattice doctor --model <dir> --tokenizer-dir SRCbefore loading anything for real.eval_perplexity --q4-dir Q4_DIR --quarot-q4-dir QUAROT_DIR --tokenizer-dir SRC --corpus-file <corpus>(full corpus, no--max-tokens) as the actual quality gate — exit code 0 plus a delta below the threshold is the real "it worked" signal, not just successful loading.lattice chat/lattice serve --model <dir> --tokenizer-dir SRCto actually use the result.