DEV Community

pm25coder
pm25coder

Posted on

The model card said 0.5B. The checkpoint stores 494M. Both are right.

Qwen2.5-0.5B's config declares its vocabulary-by-hidden matrix twice: once as the embedding table, once as the output head. Its checkpoint stores that matrix once. Both statements are true, and the distance between them is 136,134,656 parameters, which is 27.6% of everything in the repo.

I went and measured that this morning, because a reader asked whether safetensors.total — the parameter count Hugging Face reports for a repo — can be trusted. The post he was replying to divides bytes on disk by that number. If the denominator is decoration, so is the quotient.

So: six checkpoints, two of them with tied embeddings, two of them mixture-of-experts, each one's stored tensor count recomputed from its own config.json and compared against what the API reports.

checkpoint safetensors.total recomputed from config residual
Qwen/Qwen2.5-0.5B 494,032,768 494,032,768 0
Qwen/Qwen2.5-7B 7,615,616,512 7,615,616,512 0
HuggingFaceTB/SmolLM2-135M 134,515,008 134,515,008 0
microsoft/Phi-3-mini-4k-instruct 3,821,079,552 3,821,079,552 0
Qwen/Qwen1.5-MoE-A2.7B 14,315,784,192 14,315,784,192 0
mistralai/Mixtral-8x7B-v0.1 46,702,792,704 46,702,792,704 0

Zero on all six. The field is not the part that lies.

Three counts fit in one checkpoint

The part that lies is the comparison, because "how many parameters" has three answers and a checkpoint makes it very easy to hold one answer in each hand.

Stored is what the tensors in the repo add up to. That is what safetensors.total reports, it is what you download, and it is the only count that describes the bytes.

Architectural is what the config implies. A model with tied embeddings computes its logits by reusing the embedding matrix instead of storing a second copy, so the architecture names that matrix twice while the file stores it once. Nothing is missing and nothing is wrong; the two numbers answer different questions.

Active per token is what one forward pass actually reads. In a mixture-of-experts layer every expert is stored and few are used, so this count is much smaller than the checkpoint.

Here is all three for the same six repos:

checkpoint stored architectural gap one token reads stored / active
Qwen/Qwen2.5-0.5B 494,032,768 630,167,424 +27.6% 494,032,768 1.00x
HuggingFaceTB/SmolLM2-135M 134,515,008 162,826,560 +21.0% 134,515,008 1.00x
Qwen/Qwen2.5-7B 7,615,616,512 7,615,616,512 0.0% 7,615,616,512 1.00x
microsoft/Phi-3-mini-4k-instruct 3,821,079,552 3,821,079,552 0.0% 3,821,079,552 1.00x
Qwen/Qwen1.5-MoE-A2.7B 14,315,784,192 14,315,784,192 0.0% 2,689,173,504 5.32x
mistralai/Mixtral-8x7B-v0.1 46,702,792,704 46,702,792,704 0.0% 12,879,925,248 3.63x

Two things fall out of that table. The tied models are the only ones where the stored count and the architectural count disagree, and they disagree by 21% and 27.6%. The MoE models are the only ones where the stored count and the active count disagree, and they disagree by 5.3x and 3.6x.

And then there is the name. Mixtral-8x7B sounds like 56 billion parameters. The repo stores 46,702,792,704 and a token reads 12,879,925,248, so the name is a description of the architecture, not of the artifact. It is not wrong to say it — it is wrong to divide by it.

The check itself

Fifteen lines, one file each for the config and the reported total. Nothing here needs the weights.

import json, urllib.request

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "curl/8"})
    return json.loads(urllib.request.urlopen(req, timeout=30).read())

def stored_params(cfg):
    h, heads = cfg["hidden_size"], cfg["num_attention_heads"]
    kv = cfg.get("num_key_value_heads") or heads
    hd = h // heads
    vocab = cfg["vocab_size"]
    attn = 2 * h * h + 2 * h * kv * hd
    if cfg.get("attention_bias", cfg.get("model_type", "").startswith("qwen2")):
        attn += h + 2 * kv * hd
    n = cfg.get("num_experts") or cfg.get("num_local_experts") or 0
    if n:
        w = cfg.get("moe_intermediate_size") or cfg["intermediate_size"]
        mlp = n * 3 * h * w + h * n
        if cfg.get("moe_intermediate_size") and cfg.get("shared_expert_intermediate_size"):
            mlp += 3 * h * cfg["shared_expert_intermediate_size"] + h
    else:
        mlp = 3 * h * cfg["intermediate_size"]
    return (cfg["num_hidden_layers"] * (attn + mlp + 2 * h) + h
            + vocab * h * (1 if cfg.get("tie_word_embeddings") else 2))

repo = "Qwen/Qwen2.5-0.5B"
cfg = get(f"https://huggingface.co/{repo}/raw/main/config.json")
reported = get(f"https://huggingface.co/api/models/{repo}")["safetensors"]["total"]
print(reported, stored_params(cfg), reported - stored_params(cfg))
Enter fullscreen mode Exit fullscreen mode

The residual is the useful part, and it is useful in the direction you might not expect: a non-zero residual means the model does something your formula has not accounted for yet, so the check tells you about your own code rather than about the repo. Which is how I learned what the next section says.

The residuals before they were zero

My first pass used one formula for six architectures and got three non-zero residuals. All three were my arithmetic, none were the field.

  • 27,648 on Qwen2.5-0.5B. That is 24 layers times (896 + 128 + 128), the q, k and v bias vectors. Qwen2 has attention_bias on by default and its config does not mention it.
  • 1,048,576 on Mixtral. That is 32 layers times 4096 times 8, the router gate: one 4096-wide row per expert for choosing experts. The same pass also had an entire dense MLP per layer that Mixtral does not have, because there intermediate_size is the width of an expert, not of a shared MLP. That mistake was worth 39.5 billion and could not hide; the router gate was 0.002% and hid perfectly.
  • 196,608 on Qwen1.5-MoE. The q, k and v biases again, plus shared_expert_gate, a per-layer projection the config never names.

So the ratio to keep in mind: the number the file reports came out exact on six architectures, and the number I computed needed three corrections to get there. When those two disagree, the derived one is the suspect.

What I did not measure

Two of the six ship their weights in a single file, so there is no model.safetensors.index.json to read and I could not check the tensor names directly (that URL answers with 15 bytes, not JSON). For those two, "the matrix is stored once" is arithmetic rather than an inventory: 494,032,768 matches the tied count to the byte and is exactly one vocab-by-hidden matrix below the untied count. For the four sharded repos I did read the weight map, and lm_head.weight is a stored tensor in every untied model and in neither tied one.

I have not measured GGUF or other quantized repos. safetensors.total describes safetensors files; a repo that ships only GGUF does not have that field, and I do not know what the API returns for one.

I have also not measured the case where a repo keeps a second copy of the weights in a subfolder. That is a different failure — the sum closes on the wrong number — and it makes any total wrong in the same direction, which is why the filter in the last post mattered more than I gave it credit for.

The rule

For bits per parameter, the pair that means anything is bytes on disk over parameters stored, because both numbers describe the same artifact. The architectural count answers a different question, and it is a fine question; it just is not this one. If you need the active count, name it out loud — "14.3B stored, 2.7B active" is a sentence, and "14.3B" alone is how a name like 8x7B survives as if it were a measurement.

If you want to run this on a checkpoint of your own: the config, the model endpoint, and the function above are the whole apparatus. A residual of zero means the field and your model of the model agree. Anything else means you have just found the interesting part.

Top comments (6)

Collapse
 
ahmetozel profile image
Ahmet Özel •

The zero residual across all six checkpoints is a useful check on the denominator. I would pin the repository revision before fetching config.json and the model endpoint metadata, because both URLs in the example currently follow a mutable head independently.

If a maintainer updates the checkpoint between those two reads, a nonzero residual can describe a mixed observation rather than a missing term in the formula. Recording one commit identifier with both artifacts makes the result reproducible and gives the residual the interpretation you intend. That is especially helpful when comparing measurements collected on different days.

Collapse
 
pm25coder profile image
pm25coder •

You are right about the snippet as published, and it is not only a theoretical race: raw/main/config.json and api/models/<repo> are two requests, both following main, so the residual compares two documents with no shared revision between them.

I went back and measured the fix on SmolLM2-135M-Instruct. The model endpoint already returns sha — 12fd25f77366fa6b3b4b768ec3050bf629380bac — inside the same payload as safetensors.total (134,515,008), so one request fixes the revision. That payload's config field is thinner than I had assumed, though: it carries architectures, model_type and tokenizer_config, and none of vocab_size, num_hidden_layers or hidden_size, which are the three the recomputation actually needs. Those still come from config.json. Fetching it at the pinned revision answers 200 with the same file — 861 bytes, vocab 49,152, 30 layers, 576 hidden — and api/models/<repo>/revision/<sha> returns the same total. So pinning costs one extra request rather than a redesign: read the sha once, then take the config at that sha, and the pair cannot straddle a revision.

From then on the residual means what the post says it means. Thank you — this is the kind of hole that only shows up when someone reads the code instead of the table.

Collapse
 
ahmetozel profile image
Ahmet Özel •

The measured response shapes make the fix much clearer: the summary supplies the revision and total, while config.json still supplies the dimensions. Pinning the second request to that SHA establishes the shared revision without assuming the summary contains the full configuration.

I would retain one failure fixture where the pinned configuration cannot be fetched. The code should report an incomplete measurement rather than quietly falling back to main; otherwise an availability workaround could reintroduce the same cross-revision comparison that the successful-path fix removed.

Collapse
 
pm25coder profile image
pm25coder • • Edited

On your follow-up: you are right that the fallback is the dangerous half, and trying to write that fixture is what changed my mind about where it has to bite.

The unfetchable case has two shapes and my code answers both the same way. A revision that does not exist and a revision that exists but predates config.json both return 404 with the body "Entry not found", 15 bytes. The second is real: Qwen/Qwen2.5-0.5B at 1ca8d4d881c1 and facebook/opt-125m at c41bd9f4e994 answer 200 from api/models//revision/ and 404 from raw//config.json, so the summary check passes and the file still cannot be had.

The code as published does not fall back: get() has no except branch, so an unfetchable pin raises HTTPError 404 and the run dies. That is loud, but it never says which revision was wanted, and a config that predates the file 404s exactly like a mistyped sha.

So I wrote the fallback deliberately, to see what a test could catch, and on these checkpoints it caught nothing. It printed residual 0 and exited 0 on the three repos whose config the formula can read, giving the same numbers as the run that pinned correctly. A crossed pair is no better: config from the oldest revision of SmolLM2-135M-Instruct and of Mixtral-8x7B-v0.1 with the total from head also gave residual 0, and stored_params came out identical at both revisions. Mixtral's configs differ in five keys, model_type among them, 'mistral' against 'mixtral', and none of them touches the arithmetic.

Which means a fixture written against the residual cannot fail here, and proves nothing about the fallback. What can fail is the revision actually used, reported beside the number.

Collapse
 
ahmetozel profile image
Ahmet Özel •

Your crossed-pair result rules out the residual as a provenance assertion. I should have made that distinction explicit: unchanged parameter arithmetic cannot expose a forbidden fallback when both revisions happen to produce the same count.

A transport-level fixture could deliberately return identical payloads while failing on any unpinned config URL. That makes raw/main itself the forbidden behavior, independently of the numeric result. The separate missing-revision and missing-config cases can then assert their requested revision and error category without needing real checkpoints whose counts differ.

Thread Thread
 
pm25coder profile image
pm25coder •

On your last clause — "assert their requested revision and error category": I re-ran all three and there is no category to assert. A revision that does not exist, a commit that resolves at 200 in the API, and a commit with no config.json there all answer 404 with a 15-byte body reading "Entry not found". Same status, text and size; what separates them is the URL I asked for.

So the revision is assertable and the category is not. Your fixture knows which revision it requested — that is what makes the first half work. The second half would assert something the server never said.