Repository navigation
[Feature Request] Support compressed-tensors per-channel FP8 checkpoints for Qwen3.5 MoE #252
Description
Summary
I would like to request support for loading compressed-tensors FP8 checkpoints that use per-channel weight scaling, specifically for models such as:
ornith-ai/Ornith-1.5-35B-A3B-FP8
The model architecture itself appears to already be supported by FreeToken through the existing Qwen3_5MoeForConditionalGeneration / qwen3_5_moe implementation.
The missing part seems to be support for this checkpoint's FP8 serialization and quantization scheme.
Model
Hugging Face model:
ornith-ai/Ornith-1.5-35B-A3B-FP8
Architecture:
"architectures": [
"Qwen3_5MoeForConditionalGeneration"
],
"model_type": "qwen3_5_moe"The checkpoint uses compressed-tensors with FP8 weights:
"quant_method": "compressed-tensors",
"format": "float-quantized"The relevant quantization scheme is approximately:
- FP8 weights
- static per-channel weight scaling
- dynamic per-token activation scaling
- selected modules excluded from quantization
This differs from the FP8 formats currently handled by the Qwen3.5 MoE loader, such as block FP8 and ModelOpt-style FP8.
Current behavior
FreeToken already has support for the Qwen3.5 MoE model architecture, so no new model implementation should be required.
However, the current quantization detection and weight-loading paths do not appear to recognize compressed-tensors per-channel FP8 checkpoints.
As a result, this checkpoint cannot be loaded using its intended FP8 representation.
Requested support
It would be useful to add a dedicated loading path for compressed-tensors FP8 checkpoints with per-channel weight scales.
A possible initial scope could be:
-
Detect
compressed-tensorsFP8 checkpoints withfloat-quantizedformat. -
Support FP8 tensors accompanied by per-channel
weight_scaletensors. -
Correctly handle scale broadcasting according to the stored tensor shape rather than assuming per-tensor or block scaling.
-
Support Qwen3.5 MoE expert weights, including stacked expert banks.
-
Respect the checkpoint's quantization ignore/exclusion rules.
-
Support weight and scale tensors located in different safetensors shards.
Possible incremental implementation
A first implementation does not necessarily need native per-channel FP8 MoE execution.
For correctness-first support, FreeToken could initially load:
FP8 weight + per-channel scale
|
v
dequantize to BF16
|
v
existing BF16 operators /
MoE expert backend
This would allow the checkpoint to run without requiring a new FP8 MoE kernel immediately.
Native storage and execution could then be added separately:
FP8 expert weights
+ per-channel scales
+ dynamic activation scaling
|
v
native FP8 MoE expert backend
That would preserve the memory savings of the original checkpoint.
Likely affected areas
The main changes appear to be around the existing Qwen3.5 MoE quantization and weight-loading code, for example:
python/freetoken/models/qwen3_5_moe/config.py
python/freetoken/models/qwen3_5_moe/weight.py
The existing model architecture, attention implementation, and registry should not need major changes.
Suggested tests
Possible regression coverage:
- Detect
compressed-tensorsper-channel FP8 config correctly. - Load a synthetic FP8 weight with a 1D per-output-channel scale.
- Verify dequantized weights against a reference implementation.
- Verify weight and scale lookup across different safetensors shards.
- Verify stacked MoE expert loading.
- Compare logits against a Hugging Face / reference implementation on a small deterministic input.
Motivation
FreeToken already supports the underlying Qwen3.5 MoE architecture, and supporting this additional FP8 checkpoint format would make models such as Ornith-1.5-35B-A3B-FP8 usable without requiring a separate architecture implementation.
It may also benefit other Qwen3.5 MoE checkpoints distributed using the same compressed-tensors per-channel FP8 format.
Activity
twinkle311 commentedon Sep 3, 2026
目前freetoken 可以使用 Ornith-1.5-35B 么?
yongjer commentedon Sep 6, 2026
It works on Ornith-1.5-35B-A3B, but not Ornith-1.5-35B-A3B-FP8
salekseev commentedon Sep 8, 2026
We added support for this checkpoint class and we tested it on hardware. We found three separate problems. Each problem hides the next one, so we give all three here.
Problem 1: the detector does not accept
strategy: "channel"_attn_quantinmodels/qwen3_5_moe/config.pyreturns"none"for a per-channel FP8 group.On
main(af71ba4) this function has no compressed-tensors branch. PR #390 adds the branch, but the branch testsstrategy == "tensor". A per-channel checkpoint still gets"none".We checked this with
unsloth/Qwen3.6-35B-A3B-NVFP4-Fast:nonenonenonenoneThis value is a routing key.
iter_weightscalls_iter_weights_attn_fp8only when the value is"fp8_pertensor". With a wrong value, the loader uses a different path. That path cannot read this checkpoint. We saw aKeyErrorfor the fusedshared_expert.gate_up_proj.The correction is one token:
strategy in ("tensor", "channel").Problem 2: the loader cannot read a per-channel weight scale
_per_row_scaleinmodels/qwen3_5_moe/weight.pydoesscale.reshape(1). This is correct for a single scalar. A per-channel scale holds one scalar for each output row, and it arrives as[rows, 1]. The reshape then fails:Fp8PerTensorLinear.weight_scaleis already per output row, so the correction is small. Reshape to[-1], then use the element count to select the action. One element broadcasts. A count ofrowspasses through. Any other count must raise an error.The error is the important part. A wrong count that broadcasts applies the scale of row 0 to every output row. The model then loads, and it gives fluent but wrong output.
Problem 3: an FP8
lm_headcannot loadThe FP8 branch in
_iter_weights_attn_fp8emits every FP8 weight in native form, and this includeslm_head. But the model builds the bf16ParallelLMHeadfor every head that is not NVFP4. That class has noweight_scalebuffer, so the load stops:No existing head class can use a head that is already FP8 on disk.
Nvfp4LMHeadis FP4. The FP8 heads inglm_moe_dsaandglm5_nextare subclasses of the bf16ParallelLMHead, and they quantize at load time.A native FP8 head is 0.474 GiB for a
[248320, 2048]matrix. The bf16 form is 0.947 GiB. On a 16 GiB card this difference is more than decode traffic. The bf16 head left 118 MiB free, and the GDN chunked-prefill workspace then caused an out-of-memory failure at 8192 prompt tokens.Results
Hardware: one RTX 4080 SUPER, 16 GiB, sm_89, PCIe 4.0 x16, TP=1.
The build also carries an unrelated FP8 KV cache change, so these throughput numbers do not come from a clean tree.
About
ornith-ai/Ornith-1.5-35B-A3B-FP8We did not test this checkpoint. Its shape differs from ours. We read its config:
format: "float-quantized", not"mixed-precision"strategy: "channel"lm_headinignoreWarning
Corrected. This paragraph originally claimed the checkpoint needs only the fixes for problems 1 and 2. That was wrong -- I had read the config but never run the detectors against it. It needs strictly more than that. See the correction further down this thread.
Offer
We have the three changes on separate branches, each with tests in the style of
tests/models/test_glm5_next_config.py.Tell us how you want the work split, and we will open the pull requests. The
channeltoken may belong in #390 instead of a separate change. That is your decision.Branches
The branches are below, in case you want to read or cherry-pick the commits. Each branch holds one commit for each concern. The history is linear, so each commit cherry-picks on its own.
165931cmainaf71ba4upstream/ct-fp8-per-channel-scalea8df84dupstream/fp8-lm-headf4e3270upstream/ct-fp8-channel-strategybc27359upstream/ct-fp8-packed-shared-expertNotes on the bases:
main. They do not need fix(models): better support for mixed-precision compressed-tensors NVFP4 #390.Commits 1 and 2 apply to
main, but they are not reachable there. Onmainthe only checkpoints that reach_iter_weights_attn_fp8are ModelOpt checkpoints, and those use a per-tensor scale and an NVFP4 head. So onmainthese two commits are hardening, and they become necessary when a per-channel checkpoint can route. We merged commits 1 and 2 into the #390 head with no conflict.Commit 2 also skips an
lm_headthat the checkpoint lists inignore. compressed-tensors givesignoreprecedence over a group'stargets, and such a head is bf16 on disk.Each commit adds tests in the style of
tests/models/test_glm5_next_config.py. The new files aretests/models/test_qwen3_5_moe_config.py,test_qwen3_5_moe_weight.pyandtest_qwen3_5_moe_fp8_lm_head.py. There were notest_qwen3_5_moe_*files before, so nothing is replaced.We are happy to open the pull requests in any order you prefer, or to combine them. We can also leave the branches here if you would rather apply the changes yourself.
yongjer commentedon Sep 8, 2026
Thanks for investigating this and for providing the branches. I’m only the issue author and not a FreeToken maintainer, so I’m not in a position to decide how the changes should be split or merged.
salekseev commentedon Sep 8, 2026
@yongjer that's completely fair -- and honestly it was the wrong question to put to you. Thanks for saying so plainly rather than letting it sit.
Turned out two of the four commits don't overlap with #390 or with @Romeriz's parallel work, so I've opened those on their own instead of waiting for someone to adjudicate a split:
weight_scalefix (problem 2 above)lm_head(problem 3)Holding the other two back: the
strategy: "channel"token belongs in #390 rather than in a PR of mine, and the packed-shared-expert commit depends on how the MoE routing question between #390 and Romeriz resolves.Correction -- your checkpoint needs more than I said
I need to walk back something from my first comment. I claimed
ornith-ai/Ornith-1.5-35B-A3B-FP8would need only the per-channel scale fix plus acceptingstrategy: "channel". I'd read the config but never actually run the detectors against it. When I did, it came back allnone-- even with every one of my four commits applied:The blocker isn't the strategy at all. It's
targets:"Linear"is a class-name target -- in compressed-tensors it matches everynn.Linearin the model, minusignore. But_attn_quant(both onmainand in #390) decides by looking for the literal substring.self_attnorlinear_attnin each target string. A class-name target contains neither, so the group is skipped and the checkpoint never routes, regardless of"channel". Making"channel"work was necessary but nowhere near sufficient, and I'm sorry for the confident-sounding wrong answer.There's a second, larger consequence I missed. With
targets: ["Linear"]and only 7 entries inignore, your experts are per-channel FP8 as well. FreeToken's expert offload banks are keyed by format -- currentlybf16,fp8_block,q4_0,awq,ct_int4,ct_int4_asym,nvfp4,mxfp4,ds_fp4-- and there's no per-channel FP8 entry. So supporting this checkpoint means a new expert bank format, not a small loader patch. That's a different and much bigger piece of work than anything in #415/#416.I also can't test it here even if it were supported: per-channel FP8 experts come to roughly 30 GiB of pinned host RAM for 40 x 256 experts, and this box has 31 GB total.
So to be straight with you: #415 and #416 do not close this issue. They're both still needed for a per-channel FP8 dense side, and #415 in particular is a prerequisite for any of it, but the class-name-target resolution and the expert bank format are the parts that actually stand between FreeToken and your model. I didn't want to leave my earlier estimate sitting there looking authoritative.
For what it's worth,
primitive-ai/Ornith-1.5-35B-A3B-mixed-NVFP4-FP8does resolve correctly today (attn='fp8_pertensor',expert='nvfp4') because its targets are real module regexes and its experts are NVFP4. I'm pulling that one now to benchmark, since it's a clean test of #390 on hardware. Different checkpoint from yours, though -- I won't pretend otherwise this time.