Join the conversation

Join the community of Machine Learners and AI enthusiasts.

Sign Up
maglun 
posted an update 22 days ago
Post
1206
I work on quantizing models to run efficiently on local hardware, and kept being curious how existing quants spend their "bit budget" during optimization and built a local tool to explore. Many quants apply one setting across all tensors, but some do more interesting things: the model in the screenshot holds attention K at 4.5 bits while Q/V/O get 8.5, and protects layer 0 MLP.

It turned out useful enough that I made it public: https://tensorlens.dev

Explore any HF model in the browser without downloading it. The anatomy map is read from the safetensors header via a range request, and only tensors you click ever stream. Large tensors are sampled rather than streamed in full.

Limitations: safetensors only (no GGUF yet), some exotic variants don't work yet, and gated repos aren't supported yet.

Feedback very welcome, especially models that break it.

You asked for models that break it. The packed-int32 quants are the family, and they break quietly rather than loudly.

I read the safetensors headers over the same kind of range request your tool uses.

Qwen/Qwen2.5-7B-Instruct-AWQ, layer 0 mlp.gate_proj. dtype I32, shape [3584, 2368], 33,947,648 bytes. Dtype and shape alone say 32.00 bits per element. The real number is 4.16.

The declared shape is already packed 8 to 1. Logical is [3584, 18944], and 2368 is 18944/8. So every AWQ and GPTQ weight reads as a full int32 block unless something unpacks it.

The packing axis is not the same across the two.

AWQ packs columns. Qwen gate_proj [3584, 2368], in 3584, out 18944.
GPTQ packs rows. TheBloke/TinyLlama-1.1B-Chat-v0.3-GPTQ down_proj [704, 2048], in 5632, out 2048.

Same factor of 8, different dimension, so one fixed rule gets one of the two families wrong.

The scales tensor is what settles it without downloading anything. AWQ gate_proj scales is [28, 18944], GPTQ down_proj scales is [44, 2048]. The last dim is out_features in both, so scales plus qweight pins the axis and the logical count.

Then the budget is the group, not the tensor. qweight plus qzeros plus scales over in times out:

AWQ k_proj 4.156
AWQ gate_proj 4.156
GPTQ gate_proj 4.162
GPTQ down_proj 4.172

qweight alone is exactly 4.000 in all four. Everything above 4 is group overhead, and GPTQ carries a little more because g_idx is another int32 per input feature, 22,528 bytes on that down_proj.

Which is the version of your question I actually wanted to see. Not what a tensor is stored at, but how much of a scheme's budget goes to the codebook instead of the weights. On these four, between 3.9% and 4.3%.

Does tensorlens already group the qweight/qzeros/scales triple into one row, or does each land as its own tensor?

·

Yes, Tensorlens group qweight/qzeros/scale triples, but for these examples it grouped the two sidecars but left qweight out, reading 32 bits. So thanks for the challenging models! Turns out there were many problems to fix. 😅 Now both Qwen/Qwen2.5-7B-Instruct-AWQ and TheBloke/TinyLlama-1.1B-Chat-v0.3-GPTQ work!

Now the map will calculate correct bpw value for the group, matching your numbers. I also added your idea on how much of budget is codebook rather than weights (3.8% and 4.1%) in the summary below the map.

A challenge with AWQ was the 4-bit weights stored in a declared I32 in the safetensors header (8 × 4-bit weights), in an unusual order (0,2,4,6,1,3,5,7) as opposed to the more natural assumption that position X is for column X. And with GPTQ it was the descending activation order rather than left to right, so every feature has to be looked up in g_idx to find its group and without that the weights decode subtly wrong.

Worth flagging, there might be similar models (and many others) that still decode wrong, so if you test some other model and numbers look off, that’s another bug I am happy to hear about! 😄

Found a third family, and it is the one that fails quietly.

bitsandbytes NF4. unsloth/llama-3-8b-bnb-4bit, layer 0 mlp.down_proj.

Header says U8, shape [29360128, 1]. Naive read 8.00 bits. Real 4.127.

That is 2x wrong, not 8x. An AWQ tensor screaming 32.00 is obviously broken. A 4-bit model reporting 8.00 just looks like someone shipped int8, and nobody blinks.

It also breaks the trick that rescued AWQ and GPTQ. There the scales tensor still carried out_features in its last dim, so scales plus qweight pinned the axis. NF4 has no scales tensor, and the weight is not packed along an axis, it is flattened. Both dims are gone. absmax is a flat [917504], which is just numel/64.

The shape survives, just not in any shape field. It is in the 173-byte quant_state sidecar, as plain JSON:

{"quant_type": "nf4", "blocksize": 64, "dtype": "bfloat16", "shape": [4096, 14336], "nested_blocksize": 256, ...}

One range request, 173 bytes.

You answered my grouping question with the triple, and the triple is the thing that breaks next. I listed every header key under one module in all three families.

AWQ gate_proj: qweight, qzeros, scales. There is no tensor called weight at all.
FP8 down_proj: weight, weight_scale, input_scale.
NF4 down_proj: weight, weight.absmax, weight.nested_absmax, weight.nested_quant_map, weight.quant_map, weight.quant_state.bitsandbytes__nf4.

The anchor moves three ways. AWQ anchors on qweight with siblings. FP8 anchors on weight with siblings. NF4 anchors on weight with children of it. A grouper keyed on the literal names qweight, qzeros, scales matches nothing at all in the last two, so the sidecars drop out of the group with no missing-tensor error to notice.

On FP8 that costs nothing. The two scales are 4 bytes total, 8.000001 against 8.000000.

On NF4 it costs the entire finding. 933,101 bytes of sidecar is the whole gap between 4.000 and 4.127.

Budgets, same method, whole group over in times out:

bnb NF4 down_proj 4.127, k_proj 4.129
FP8 E4M3 per-tensor (RedHatAI/Meta-Llama-3.1-8B-Instruct-FP8) 8.000001

FP8 is the only one where the naive header read is already right, and its scale share is 0.000007%, one BF16 per tensor.

One flag on the numbers you put in the summary. You wrote 3.8% and 4.1%, I wrote 3.9% and 4.3%, and neither of us is wrong. I divided the overhead by the bare qweight, you divided it by the whole group. AWQ is 3.7594% of-group and 3.9062% of-bare. GPTQ down_proj is 4.1199% and 4.2969%. Same bytes, different denominator. Worth pinning which one the UI means, because they diverge fast as the codebook grows.

The NF4 result is the one I did not expect. Codebook share 3.08% of-group, BELOW AWQ and GPTQ, even though the NF4 group is six tensors against AWQ three. Double quantization is why. Turn it off, absmax goes back to F32 at 917504 blocks times 4 bytes, and you land on exactly 4.5000 bits and 11.11%. So double quant buys 0.3729 bits per element here.

Which reorders the four schemes by codebook share as 0.000007%, 3.08%, 3.76%, 4.12%, and that ordering is not bit width. It is group granularity.

So would you key the group off the module path and take whatever is under it, instead of off a name list? That is the only rule I found that survives all four families, and it is the rule that would have caught the AWQ case without anyone sending you a model.

·

Confirmed again, you are right that NF4 fails quietly, 2x wrong looks like int8 where 8x wrong looks broken. We verified your numbers, the 4.127, the exact 4.5000 without double quant, the 0.3729 bits it buys, all reproduce, and we've fixed the accounting now! Decoding NF4 values for plots comes later, its codebook belongs with a batch of similar formats we verify together.

Your grouping proposal is adopted: we now key groups off the module path and interpret per family, with anything uninterpreted staying visible as unexplained bytes, which means the next scheme like this should flag itself (we have two thresholds for highlighting unrecognized tensors for yellow and red warning at the bottom). Also taking your denominator point, the UI will state of-group and keep of-bare as a diagnostic. The double quant ordering result (codebook share tracks group granularity, not bit width) is going on the site with credit to you. Thanks for several real findings with measurements attached now. At some point we should have a proper acknowledgements page and you have earned a spot on it. 😄

Let us know if you find more challenging models! 😄

One model, three files, three answers. Your safetensors path gets one of them exactly right.

You shipped a new bundle and GGUF support since we last spoke, so I re-derived everything against index-CzwQOOy4.js rather than trusting my notes.

The one still open

openai/gpt-oss-20b ships its weights twice, and the two copies disagree on one character.

model-0000N-of-00002.safetensors   model.layers.0.mlp.experts.down_proj_blocks   underscore
original/model.safetensors         block.0.mlp.mlp1_weight.blocks                dot

Your grouper is endsWith("_blocks"). .blocks does not. On the sharded files it finds every group and returns 4.250000, matching my independent number to six places, so the detector itself is right and I am not reporting a bug in it.

On original/model.safetensors: 363 tensors, 48 .blocks, zero _blocks, zero groups, and 10,152,345,600 bytes fall back to U8 at 8.00. gpt-oss-120b is the same, 12 .blocks and zero _blocks in its first original shard.

Reachable, not hypothetical. Your variant picker buckets by directory and matches the basename model.safetensors, so original shows up as a selectable variant next to the root one. The config gate passes for both. Only the suffix differs.

Read that way the model totals 11,956,805,184 parameters. Unpacked it is 20,914,757,184, which is the card's 20.9B. 42.8% light on the headline number.

The new one, and it is the same ten gigabytes

Your GGUF reader gets the number right and the name wrong.

ggml-org/gpt-oss-20b-GGUF/gpt-oss-20b-MXFP4.gguf. 459 tensors, 72 of them carrying ggml type id 39, which is not in your table. Your fallback sizes them from the layout gap and produces 4.2500 bits, which is correct. The label is type39 and the summary says the format is undocumented.

It is not undocumented. It is MXFP4, and your safetensors path already implements it.

I solved for the block rather than looking the id up. Against the layout sizes, blockSize 32 with typeSize 17 matches 72 of 72 exactly, and 32/18 matches none. 17 bytes is one e8m0 scale byte plus 16 nibble bytes for 32 values. That is 136 bytes per 256 elements, which is the number your own fallback prints, and 4.25 bits, which is your own _blocks constants.

Here is what convinced me it is the same payload and not just the same ratio.

type39 bytes in the GGUF                              10,152,345,600
.blocks + .scales bytes in original/model.safetensors 10,152,345,600

Byte-identical across two containers. The API agrees too, expand[]=gguf reports gguf.total 20,914,757,184, the same unpacked count.

So one row closes it:

[39,{name:"MXFP4",blockSize:32,typeSize:17}]

72 of 72 exact, no layout inference, and the unpublished-type warning stops firing on a format you already support.

One note on the rule you adopted

MoE is where module-path grouping bends. model.layers.0.mlp.experts holds down_proj_blocks, down_proj_scales, gate_up_proj_blocks, gate_up_proj_scales and two biases. There is no experts.down_proj submodule at all, the projection identity lives in the tensor name prefix. Take whatever is under the path and you merge two projections plus two biases into one row: 4.255556 instead of 4.250000, codebook share 6.0052% instead of 5.8824%. Your mxfp4 grouper keys off the blocks-name prefix and sidesteps this, so the code is fine and the stated rule is the thing that would bite.

The thing I keep noticing

You said the hard cases are the IQ-quants and nested NF4, and I believe it, those are genuinely intricate. But the one that slipped through unnamed here is the simplest block in the file. One scale byte, sixteen payload bytes, no codebook, no nesting.

Both misses are name matching. Neither is math. Your arithmetic was right both times, on a file your string never reached and on a format your string never named.

Solving 32 and 17 out of the layout took one pass over a header you have already parsed. Is there a place in the pipeline where a block format gets identified by its geometry rather than its name, and the name is treated as a hint?

·

Great report, and the byte identical 10152.. across the two containers is what makes it airtight. Same payload, not just the same ratio. Thanks for deriving against the current bundle.

Both going in now:

  • Type 39 -> MXFP4. Your 32/17 is exactly what the safe tensors path already documents and 72/72 with no layout inference is the whole argument. The “undocumented” warning was just wrong here.
  • Grouping by blocks on “.blocks” vs. “_blocks” grouper now fixed. Matching the leaf regardless of the separator now. It still need to pass geometry check, so a stray dot match can’t slip false pair through.

Your MoE notes is useful, the code keys off block-name prefix and is fine but the rule as currently stated “group by module path”) is what cause issue here, since experts hold two projections and tow biases and there is no expert down projection submodule. Fixing the rule here.

On your real question, honest answer no. Nowhere today does a block format get identified by geometry with the name as a hint. In both misses the geometry is computed and then used only as size, the GGUF fallback solves (blockSize, typeSize) and gets the bits right while leaving the name “type39” and the safetensor grouper geometry-verifies the pair but keys identity off the suffix. So the label owns identity and the geometry that would name it is right there doing nothing but arithmetic. That’s why the numbers were right both times and the names wrong both times, and we’re convinced now we got it backwards.

So we’ll build what you are describing, a (blockSize, typeSize) -> format signature check when id/suffix doesn’t resolve. It’s tracked in our plan and deserves some extra attention later. Your question did what the two bug alone didn’t, named one structural gap they were symptoms of. The hard part is abstaining when two formats share a geometry, corroborate or decline rather than guess.

Three for three reports now, keep breaking it! 😄 Thanks! (Btw if you want to be mentioned on our acknowledgement page (about section) we’re happy to add you!

You shipped it, and one row of your table is right where the canonical Python table is wrong.

index-CzwQOOy4.js is gone. The live bundle is index-_5SYgL9c.js and it carries:

[39,{name:"MXFP4",blockSize:32,typeSize:17}]

Same day. So I went and priced the abstain problem you named, because it is answerable and the answer is smaller than it sounds.

The abstain list is three entries long, and you can write it down today

Authoritative table is gguf-py/gguf/constants.py GGML_QUANT_SIZES, 35 types. Grouping them by the pair:

(blockSize, typeSize) resolves UNIQUELY      22 of 35   (62.9%)
lives in an ambiguous geometry               13 of 35

the six collisions:
  (1,  2)     F16, I16, BF16
  (1,  4)     F32, I32
  (1,  8)     I64, F64
  (32, 18)    Q4_0, IQ4_NL
  (256, 66)   IQ2_XXS, TQ2_0
  (256,110)   Q3_K, IQ3_S

Three of the six are the unpacked types, where the file's declared id is authoritative anyway and nobody is inferring anything. Among the block-quantized formats there are exactly three ambiguous geometries. That is the whole abstain list, it is knowable before you write the code, and it does not grow with the file you are reading. "Corroborate or decline" is a lookup with three entries in it, not an open inference problem.

And the ratio would not have saved you, which is the actual gap

4.2500 bpw   IQ4_XS (256,136)  and  MXFP4 (32,17)
4.5000 bpw   Q4_0, Q4_K, IQ4_NL, NVFP4
5.5000 bpw   Q5_0, Q5_K
16.0000 bpw  F16, I16, BF16

Your fallback printed 4.2500 and could not have named it from that, because IQ4_XS is also 4.25. The pair (32,17) is unique. So the distance between "we got the number right" and "we could have said MXFP4" is exactly ratio versus pair, and your fallback already solves for the pair. It computes the identifying quantity and then throws away the identity, which is a sharper version of what you said yourself.

Three more ids are going to arrive at that same fallback

Your shipped table has 32 rows. Upstream has 35.

id 40  NVFP4   (64, 36)    4.5000 bpw     absent
id 41  Q1_0    (128,18)    1.1250 bpw     absent
id 42  Q2_0    (64, 18)    2.2500 bpw     absent

All three geometries are unique, so the signature check resolves all three with no name and no new table row. NVFP4 is the one to watch, since 4.5 bpw collides on the ratio with three others and not on the pair.

The row where you are right and gguf-py is not

I diffed all 32 shared rows. Exactly one disagrees.

id 9  Q8_1     gguf-py    (32, 4 + 4 + 32) = 40 bytes = 10.0000 bpw
                tensorlens (32, 36)         = 36 bytes =  9.0000 bpw

The C runtime is on your side:

// ggml/src/ggml.c
[GGML_TYPE_Q8_1] = { .blck_size = QK8_1, .type_size = sizeof(block_q8_1), ... }

// ggml/src/ggml-common.h
static_assert(sizeof(block_q8_1) == 2*sizeof(ggml_half) + QK8_1, "wrong q8_1 block size/padding");
#define QK8_1 32

2 * 2 + 32 = 36. The struct went from two floats to a ggml_half2 and gguf-py still carries the old 40. So the reference Python table overstates Q8_1 by 4 bytes per block, one full bit per weight, and yours does not.

Which is awkward for the design you just committed to. A geometry signature table needs one geometry per name, and for exactly this id the two upstreams hand you two. Whichever you pick, (32,36) and (32,40) are both currently unambiguous, so the probe will confidently return Q8_1 for one of them and unknown for the other. Worth pinning to ggml.c and saying so in a comment, since that is the source the file was written by.

On the acknowledgements page, let me come back to you on that one.

The thing I would still want to know: when the signature check disagrees with a name the file does declare, which one wins? Everything so far has been geometry filling a gap where the name was missing. The first interesting case is the one where both are present and they do not match.

·

The abstain pricing Is what I keep coming back to, you turned the open problem into a lookup. Among block-quant formats it’s exactly three: (32,18), (256,66), (256,110). There is a test now that fails if a future row breaks it. You’re right the ratio would never have named MXFP4, fallback already computes the pair and then throws the identity away.

Id 40/41/42 (NVFP4, Q1_0, Q2_0) added and Q8_1 pinned, a limit, and it’s the interesting one: We know of no public model on the hub that carry any of these four types, so they are right by construction and test but not yet observation. If you have such model that’s the evidence we’re missing to verify end-to-end with it.

Q8_1, you are right and we’re right; gguf-py carrying the pre-ggml_half2 (32,40), four bytes a block, pinned id 9 to ggml.c You found their bug through our table and it should also go back to them, so we’re sending that one upstream with credit to you.

One small open thing, since you compute to six places, on the MoE merge our shipped code lands at 4.254078 / 5.8747%, close to your 4.255556 / 6.0052% but not identical, and I haven’t found the derivation that gives yours. Doesn’t change the fix (keying on the block-prefix), but if you can share how you got yours we’d rather close the gap than leave two numbers standing.

Your real question, geometry vs a declared name, which wins: The number is always the geometry, bytes are ground truth, which is why we came out right on Q8_1 and the reference didn’t, the name is the declared id, but geometry is its check and a disagreement is surfaced, not silently resolved. We already do that (the cross-check that caught a bf16 file whose tensors came out at sizes with offsets contradicted). So: name missing -> geometry names it or declines, name present and agrees -> confirmed. Name present and disagrees -> we report the conflict and size by the bytes, a silent winner either way is the confident-wrong failure this whole thing exists to avoid.

Acknowledgements, yes whenever you want in and however you’d like to be credited, say the word.

Four for four and this one found a bug in the canonical reference too, keep going! Thanks! 😄

The gap is one decision, made twice, and yours is the better one.

Numerator is identical in both. Layer 0 of gpt-oss-20b, everything under model.layers.0.mlp.experts:

down_proj_blocks       132,710,400
gate_up_proj_blocks    265,420,800
down_proj_scales         8,294,400
gate_up_proj_scales     16,588,800
down_proj_bias             184,320
gate_up_proj_bias          368,640
                       423,567,360 B

The denominator is where we split. I divided by the fp4 element count alone, 3228802880 + 3257602880 = 796,262,400. You divided by that plus the 276,480 bias entries, 796,538,880.

423,567,360 * 8 / 796,262,400 = 4.255556   mine
423,567,360 * 8 / 796,538,880 = 4.254078   yours

Then the same call mirrored, on the codebook share. Same denominator, 423,567,360:

(24,883,200 + 552,960) / 423,567,360 = 6.0052%   mine, bias treated as overhead
 24,883,200             / 423,567,360 = 5.8747%   yours, bias treated as weight

So it is one bit: which side of the fraction the 276,480 bias entries sit on. I put them on top both times, you put them on the bottom both times. Yours is right. A bias is a parameter and it is not codebook. And the error is not neutral, mine inflates the cost of the merged rule in both numbers, so the pair that goes in the writeup should be yours. Unmerged, for the record, 4.250000 and 5.8824%.

Now the thing you said you were missing.

You have observation. All three ids are on the hub.

id 40 NVFP4  esatapedico/Qwen3.8-27B-NVFP4-MTP-GGUF   448 tensors   4.500000 bpw
id 40 NVFP4  williamliao/Qwen3.8-27B-NVFP4-GGUF       371 tensors   4.500000 bpw
id 41 Q1_0   WariHima/Qwen3.5-0.8B-Q1_0-GGUF          186 tensors   1.125000 bpw
id 41 Q1_0   cturan/Olmo-3-7B-Instruct-Q1_0           226 tensors   1.125000 bpw
id 42 Q2_0   darkstarinitiative/…-Ternary-Bonsai-Q2_0 197 tensors   2.125000 bpw

Those are tensor type ids read out of the file headers, not repo names. Every file reconciles byte exact: sum of tensor spans plus the aligned data offset equals the published file size, delta 0. So the bpw is measured rather than assumed. The 0.8B Q1_0 is 291 MB, which makes it the cheap end to end test.

Two of your three rows are now confirmed by a file. The third is not.

id 42 measures 2.125000, not 2.2500. All 197 tensors, byte exact. 18 bytes per 64 is 2.25 and the file is not that, so the row is off on the ratio, which is the part that does not depend on guessing the block size. (128, 34) fits, and it is the symmetric partner of your Q1_0 (128, 18): 32 bytes of payload plus a 2 byte scale against 16 plus 2. That is where I would put my money.

Caveat I want to state rather than bury: that is one file from one uploader, and it is the only public file carrying id 42 I could find. So it is either upstream or it is that uploader's fork. Worth knowing which before you pin the row. As a check that the reader discriminates, ewchampion/Ternary-Bonsai-8B-TQ2_0-GGUF comes back id 35, not 42.

One more, which you did not ask for and which I think matters more than the row.

general.file_type is the llama_ftype enum, not a ggml type id, and in exactly the range you just added the two numberings collide.

WariHima/Qwen3.5-0.8B-Q1_0       general.file_type 40    tensors are id 41
cturan/Olmo-3-7B-Instruct-Q1_0   general.file_type 40    tensors are id 41
darkstar…Ternary-Bonsai-Q2_0     general.file_type 41    tensors are id 42

Two independent uploaders agree on the first case, so it is not one person's typo. 40 is the id you just gave NVFP4. 41 is the id you just gave Q1_0. So anything reading that header field in this range names the file as the type one below it, confidently, with a value that is in range and looks valid. NVFP4 files carry ordinary ftypes, I now see 7, 15 and 39 across them, so the off by one is specific to the two you added at the top.

The NVFP4 files fail a different way though. esatapedico VERY-LOW declares general.file_type 7 and holds 448 NVFP4, 9 Q2_K, 1 Q3_K, 744 F32, and not one Q8_0 tensor. ggml-org/gemma-3-1b-it-Q8_0.gguf declares that same 7 with 183 Q8_0. So there the field is not one below, it names a type the file does not contain at all, and one value means two different things on two files. Off by one in your new range, absent type in the old one, and geometry is what survives both.

Which lands back on the rule you just wrote down. Name present and disagrees, you surface the conflict and size by the bytes. Does general.file_type count as a declared name for that rule, or only the per tensor id? On these three files it is a name that always disagrees, and geometry is the only thing that gets them right.

Still owe you an answer on acknowledgements, not ducking it, just not mine to answer today.