Engineering
A default that is inverted between two files
When a model's config does not mention tie_word_embeddings, phi3 assumes false and llama assumes true. Copy either one and you get a model that is right on one family and quietly wrong on the other.
Updated 2026-09-12Measured 2026-09-06
Here is a defect that is impossible to find by testing one model, and trivial to find by testing two.
In mlx-lm, when a model's config.json does not mention tie_word_embeddings, the default
differs by architecture file. phi3.py assumes false. llama.py assumes true. Both are
correct for the models they were written for, and neither is a general rule.
So if you are porting an architecture and you copy the default from whichever file you read first, you will be right on that family and wrong on the other. And "wrong" here does not mean an exception.
What the flag actually controls
A language model has an embedding table that turns token identifiers into vectors at the input, and a projection that turns vectors back into scores over the vocabulary at the output. Those two are the same shape, transposed.
Tied means they are the same weights: the output projection is the transpose of the input
embedding, and the file contains one matrix. Untied means they are separate, learned
independently, and the file contains two — the second usually called lm_head.weight.
Tying saves a large amount of memory on a small model, where the vocabulary matrix can be a substantial share of the parameters. Untying gives the model more capacity. Different families made different choices, which is why the flag exists.
Why getting it wrong is quiet
If you assume tied when the model is untied, you use the input embedding as the output projection. The matrix has the right shape. The multiplication succeeds. You get logits.
They are logits computed with the wrong matrix, so the model's output distribution is not the one it was trained to produce — but it is not noise either, because the two matrices are related in a model that was trained with them separate. The text stays fluent. It just gets worse in a way that reads as "this quantisation is a bit lossy" or "this model is not very good".
The reverse direction is louder: assuming untied when the weights are tied means looking for
lm_head.weight and not finding it, which at least fails.
The fix is to stop guessing
The reliable answer is not a better default. It is to look at the weights.
If lm_head.weight is present in the safetensors, the embeddings are untied — because an untied
model has to ship that matrix, and a tied model has no reason to. If it is absent, they are tied.
That check is not a heuristic. It is the fact itself: the presence of the tensor is what untied means at the file level. A config field is a claim about the file, and where the two disagree the file wins.
So the order is: read the config field if it exists, and if it does not, look for the tensor. Never fall back to a constant.
Checking a model in thirty seconds
Since the answer is in the weights, here is the practical recipe, which does not require downloading anything.
Open the model on Hugging Face and look at the file list. If you see lm_head.weight in a
safetensors index, or a file whose name suggests it, the embeddings are untied. On a sharded
model, the index file — model.safetensors.index.json — has a weight_map listing every tensor
name, and it is a plain JSON file you can read in the browser.
Then open config.json and look for tie_word_embeddings. Three outcomes:
- It says
trueand there is nolm_head.weight— consistent, tied, nothing to think about. - It says
falseandlm_head.weightexists — consistent, untied, likewise. - It says nothing — this is the common case, and the one where the two libraries disagree. The weights decide.
The fourth combination, where the config and the weights contradict each other, is rare and worth treating as a broken repository rather than a puzzle to solve.
The general pattern
This is one instance of something worth recognising, because it repeats across every model runtime.
Reference implementations are written per family, and their defaults are local knowledge. A
value in llama.py is not a statement about language models; it is a statement about llama.
Reading one file and generalising from it is how you inherit an assumption that was never
intended to travel.
The same shape shows up elsewhere in the same codebase. Attention biases, for example: qwen2 has
them on the query, key and value projections and declares them nowhere, because in mlx-lm they
live in the architecture rather than the config. Llama makes them optional through
attention_bias. A port that reads only the config gets qwen2 wrong; a port that always adds
biases gets llama wrong. Looking at whether the tensor exists answers both.
Which is the same rule as before, and it generalises:
When the configuration and the weights disagree about what a model is, the weights are the model.
How to catch it before shipping
The only reason this was found rather than shipped is that correctness here is checked against a reference rather than by reading the output.
mlx-lm generates oracles: for a given model and prompt, the exact token sequence and the raw
logits. A port has to reproduce them. A wrong output projection changes the logits immediately
and visibly — in numbers, not in vibes — and the test fails on the first token.
If you are porting architectures and you do not have that, it is worth building before the second family rather than after the tenth. The alternative is reading generated text and forming an impression, and an impression cannot distinguish "wrong matrix" from "small model".
A caveat on comparing logits, since it is the same afternoon's work: the tolerance cannot go below one step of the logit type. Qwen3 produces logits in bfloat16, where at a magnitude of 16 one step is 0.125 — so demanding 0.05 is demanding bit-for-bit equality while calling it a tolerance.
What it costs when it is right
Worth a paragraph, because the flag is not free either way and the trade is real.
On a small model the vocabulary matrix is a large share of everything. A model with a 128,000-token vocabulary and a hidden size of 2,048 carries about 262 million parameters in that matrix alone — which on a one-billion-parameter model is roughly a quarter of it. Tying halves that cost, and on a model that small the memory saved is the difference between fitting comfortably and not.
On a large model the same matrix is a rounding error, and the extra capacity from untying is worth more than the memory. Which is why the choice correlates with size, and why a default that was right for one family is wrong for the other — the two libraries were not being careless. They were each describing their own models.
The short version
tie_word_embeddingsdefaults tofalsein phi3 andtruein llama, in the same library.- Getting it wrong produces fluent, worse text, not an error.
- Do not pick a default: check whether
lm_head.weightis in the weights. - Same rule for attention biases, and for anything else the config can omit.
One last thing, since it is the question this always raises: no, you cannot tell from the model's name, its parameter count, or its family. Two models from the same publisher, released a month apart, can differ. The file list is the only place the answer lives.
Checked against mlx-lm 0.31.3 on 6 September 2026.