Essay22 October 2026 · 23 min read
How Taco works: a language model, a range coder, and no floats
A course-style walk through Taco: why a likely token costs almost nothing, how a range coder turns probabilities into bytes, why float neural networks cannot be decoded on another machine, and how Taco runs GPT-2 with integers only, down to the bits of a float32.
Taco is a lossless compressor. A language model guesses what comes next in a file, and a range coder stores only what the model did not guess. A file Taco compresses on my Mac comes back, bit for bit, on a Linux PC with an NVIDIA GPU. That last part is the hard part, and it is why GPT-2 runs inside Taco on integer arithmetic only.
This is a walk through the whole thing, the way a course would teach it: information theory, coding, floating point, the maths of a neural network, and then how every step of that maths becomes integer arithmetic. The figures compute live in your browser; the integer ones use exact integer arithmetic with the same rules as Taco's code. Facts and measurements come from the Taco repository: its design plan, decision records, research notes and primer.
Part 1. Information: why a likely token costs few bits
A bit is one halving
One yes-or-no question halves the possibilities. Two questions leave a quarter, eight questions pick one of 256. To single out something of probability , you need about
questions. A fair coin, : 1 bit. A face of a die, : 2.58 bits. One byte of 256: 8 bits. And in the other direction: if something is very likely, it takes almost no questions to confirm it. Each halving of the probability adds exactly one bit.
p = 0.167, so the cost is 2.58 bits = −log₂ 0.167. The red slice is p; the gold bars are the halvings it takes to reach it.
Entropy: the floor no code can beat
If symbols come from a distribution , the average cost of coding them is at least the entropy, the expected number of bits:
A fair coin has bit. A coin that lands heads 90% of the time has bits: 1,000 of its tosses fit in about 469 bits.
- Entropy H(p)
- 0.469 bits
- The model pays
- 0.522 bits
- 1,000 tosses
- 522 bits vs 1,000
Deep diveCross-entropy, KL divergence, and why a better model is a smaller file
A compressor never knows the true distribution of its data. It codes with a model , so each symbol costs bits while it occurs with probability . The average cost is the cross-entropy:
Add and subtract :
The Kullback–Leibler divergence is never negative (Gibbs' inequality) and is zero only when . So the average size of a compressed file is the entropy of the data plus the model's error, in bits. For the 90% coin and a model that believes 80%:
Shannon showed in 1948 that is a hard floor (the source coding theorem), and that arithmetic-style codes can get within a few bits of for a whole message. Everything Taco does to the model is about keeping small; everything it does to the arithmetic is about keeping the coder's own overhead near zero.
A language model is a probability machine
GPT-2 reads the tokens so far and gives a probability for each of its 50,257 tokens. Here it is on a real sentence, with the same GPT-2 weights Taco uses:
Before A, GPT-2 expected:
- ↵6.2%
- The3.8%
- "2.4%
- A1.9%
- I1.8%
- This token costs
- 5.69 bits
- −log₂ 0.019
- So far
- 5.7 bits vs 8 raw
The second "bit" in "bit for bit" is the idea in one token: GPT-2 gives it 93%, so it costs 0.10 bits. "compressed" right after "A file" surprised it: 18 bits. The whole 59-byte sentence needs about 100 bits, about 12.6 bytes.
Part 2. Coding: turning probabilities into bytes
Arithmetic coding by hand
Start with . For each symbol, split the current interval in proportion to the probabilities and keep the slice of the symbol that came. The width left is the product of the probabilities, so the bits needed are
exactly the sum of the symbol costs from Part 1. Here is BAC with , , , first with fractions, then with integers:
With fractions
Start: [0.000, 1.000), width 1.000
Deep diveTaco's range coder, exactly
Fractions do not work for long messages: after a few dozen symbols the interval is thinner than any float can tell apart. A range coder keeps the interval as two integers and renormalises. Taco's coder (ADR 0003) is LZMA-style, with byte output:
| State | Type | Meaning |
|---|---|---|
low | u128, below | bits 0 to 63 are the start of the interval; bit 64 is a carry |
range | u64 | the width of the interval |
cache, pending | byte, count | bytes held back because a carry could still change them |
A frequency table has total . To code symbol with start and frequency :
(the last symbol takes everything left, so no range is wasted). Then renormalise: while range is below , shift the top byte of low out (holding it if a carry might still reach it) and multiply low and range by 256.
Because range stays at or above and , is at least , so the floor in range >> b loses less than of a symbol. Measured on 270 windows of text: 379,340 bytes written against an ideal 379,259.4 bytes with the same tables, 0.021% overhead.
With a model, the table changes every step
A language model gives a new table for every token, built from the tokens before it. The decoder has already decoded those tokens, so it can run the same model and build the same table. Here is the real GPT-2 table zooming into the sentence from Part 1:
After 3 tokens the interval is 2.87e-11 wide, so 35.0 bits are enough to name a number inside it.
Every token keeps a frequency of at least 1
The coder needs whole-number frequencies. Taco turns the model's weights (Part 5 shows where they come from) into a table with total :
and gives the remainder to the token with the largest weight. A frequency of 0 would make a token impossible to code; with a minimum of 1, any byte string can be compressed and no token costs more than about 32 bits. This mattered: GPT-2 gives some tab characters less than , and one tab cost 119 bits in the float model.
The decoder needs the exact same table
The decoder never sees the symbols, only the number. It decides each symbol by which slice the number falls in, so its slices must be the encoder's slices exactly. One unit off and it can pick a wrong symbol, feed it to the model, and get every later table wrong:
Step 1: the value is 0.6200
Step 2: the value is 0.6000
Step 3: the value is 0.5000
Decoded: BBB — the message was BAC.
With , one unit is of probability. The decoder needs the same integers, not similar numbers. That is the requirement the rest of this post is about.
Part 3. Floating point: why two machines disagree
What a float actually is
IEEE 754 float32 packs a number into 32 bits: 1 sign bit , 8 exponent bits and 23 fraction bits . For normal numbers:
Multiply out the fraction and this is an integer times a power of two:
So a float32 is never "about 0.1". It is an exact binary fraction. Typing 0.1 stores the nearest one, . Flip the bits and see:
- Sign s
- 0 → positive
- Exponent E
- 123 → 2^(123 − 127) = 2^-4
- Fraction f
- 5,033,165 → 1 + f / 2²³ = 1.600000024
- As an exact integer form
- 13,421,773 × 2^-27
- Exact decimal value
- 0.100000001490116119384765625
- Shown as
- 0.10000000149011612
Keep this in mind for Part 6: because every float32 is exactly , arithmetic on floats can be done exactly with integers. That observation is how Taco makes its model conversion exact.
Deep diveRounding, ties to even, and the ulp
Between two neighbouring float32 values there is a gap called the ulp (unit in the last place). For numbers in the ulp is ; in , around , it is 8. Every float operation computes the exact result and then rounds it to the nearest representable value, so it can be off by up to half an ulp. When the exact result is exactly halfway, IEEE 754 rounds to the neighbour whose last bit is 0: round half to even.
That is why in float32 (each addition is an exact tie and rounds back down to 1), while . A fused multiply-add rounds once instead of twice; a compiler may or may not use it, and the result differs. And exp, log and tanh are not required to be correctly rounded: libraries differ by an ulp. Taco measured 347 of 200,000 exp results with different last bits on the Mac and the Linux box.
Order matters
Each addition rounds, so two orders of the same additions are two different chains of roundings. These are exact float32 computations:
(1 + 2⁻²⁴) + 2⁻²⁴
1
1 + (2⁻²⁴ + 2⁻²⁴)
1.00000012
Different results. 1 + 2⁻²⁴ is exactly halfway between two float32 values; ties go to even, so it rounds back to 1. Twice. But 2⁻²⁴ + 2⁻²⁴ is exact, and 1 + 2⁻²³ is a float32.
A neural network sums millions of products per token. The order depends on the CPU's vector width, the BLAS library (Apple Accelerate, OpenBLAS, cuBLAS), the thread count, the batch size, the compiler, and on a GPU, on which thread finishes first. Here is one long sum in three orders:
What it did to a float compressor
Taco measured it. The float GPT-2 computed the same four windows of text on the Mac (ARM, Accelerate) and on the Linux box (x86, OpenBLAS). The largest logit difference was 0.034 and the largest probability difference 0.00006; the compressed sizes matched to within a byte. And 0 of 4 windows decoded across the machines, in either direction. The tables differed at all 4,096 steps, from the very first token. Changing only the BLAS thread count on the Linux box broke every window too.
Floats are deterministic for one exact setup and different between setups. A float language-model compressor can pass every test on one machine and fail on the first file opened on another. Only a cross-machine decode shows it.
Part 4. The maths of a neural network
A layer: y = σ(Wx + b)
In course notation, a layer with input vector computes
where is an weight matrix (one row per neuron), a bias vector, and a non-linear activation function applied to each element. Each output is one dot product. Without , a stack of layers would collapse into one matrix; the non-linearity is what lets the network bend.
| 1 | 0 | 2 |
| 0 | -1 | 1 |
| 3 |
| 4 |
| 5 |
| · |
| · |
Press Next step to start the first dot product.
GPT-2 uses GELU as its activation: , where is the standard normal distribution function, computed in GPT-2 with the approximation
It is close to 0 for negative inputs, close to for large positive ones, and smooth in between: , .
Softmax: scores to probabilities
The last layer gives one score per token, a logit . Softmax makes them positive and summing to 1:
The second form gives the same values, keeps every exponent at zero or below, and is what later lets an integer network use a table for .
| Token | logit − max | e^(logit − max) | p = e / sum | Cost |
|---|---|---|---|---|
| token 1 | 0.0 | 1.000 | 0.659 | 0.60 bits |
| token 2 | -1.0 | 0.368 | 0.242 | 2.04 bits |
| token 3 | -1.9 | 0.150 | 0.099 | 3.34 bits |
| sum | 1.517 | 1.000 |
Attention: a weighted average of other tokens
For each token, three matrices make a query , a key and a value . A token scores every earlier token by , softmaxes the scores into weights, and outputs the weighted sum of their values:
weights [0.665, 0.090, 0.245]
output = Σ wᵢ·valueᵢ = [0.910, 0.335]
Deep diveGPT-2 small, layer by layer
GPT-2 small has channels, 12 blocks, 12 attention heads of 64 channels each, an MLP width of 3,072, a vocabulary of 50,257 tokens and a context of 1,024 tokens: about 124 million parameters.
For a token, the input is its token embedding plus a position embedding: a 768-vector , the residual stream. Each block does
with (768 to 3,072 to 768). LayerNorm rescales one token's vector to mean 0 and standard deviation 1, then applies a learned scale and shift:
After 12 blocks, a final LayerNorm and a product with the token embedding matrix give the 50,257 logits, and softmax gives the probabilities. Every one of these operations is a multiply, an add, a division, a square root, an exponential or GELU. Part 5 turns each into integers.
Part 5. From a float network to an integer network
The idea: an integer and a scale
Store a real number as an integer and an agreed scale , so that . Money does this: $3.25 is 325 cents, with scale . The weights of each row get their own scale: the row's largest weight becomes , and every weight becomes , an 8-bit integer.
scale = max|w| / 127 = 0.01000
| w | w / scale | q (i8) | q × scale | error |
|---|---|---|---|---|
| 0.423 | 42.30 | 42 | 0.4200 | 0.0030 |
| -1.270 | -127.00 | -127 | -1.2700 | 0.0000 |
| 0.051 | 5.10 | 5 | 0.0500 | 0.0010 |
| 0.897 | 89.70 | 90 | 0.9000 | 0.0030 |
Largest error 0.0030, at most half a step (0.0050).
Now the key algebra. If and , then
The scales come out of the sum. The whole dot product is integer multiply-adds into an accumulator, and the accumulator means times its value. The bias is stored in the same units, , so it is just added to the accumulator.
To hand the result to the next layer, which expects its own scale , multiply by the ratio . is a real number, so HardShell writes it as an integer over a power of two, , and computes
an integer multiply, an add and an arithmetic shift. The makes it round half up.
Follow one network through, both ways
Here is a whole small network computed both ways: the float network, and the integer network built with exactly these rules. Click a neuron to see every number it computes: the integer weights and inputs, the accumulator, the bias, the rescale with its and , the GELU table, and at the end the logits, the exp table and the frequencies the coder would get.
Hidden neuron h1
- z = Σ wᵢ·xᵢ + b
- 0.82×0.500 − 0.41×-1.200 + 0.30×0.800 + 0.10 = 1.242
- h = GELU(z)
- GELU(1.242) = 1.109
- Row scale
- s_w = max|row| / 127 = 0.006457
- Weights q_w = round(w / s_w)
- [127, -63, 46]
- Inputs (units of s_x)
- [32, -76, 51]
- Bias in accumulator units
- round(0.10 / (s_w·s_x)) = 983
- acc = Σ q_w·q_in + q_b (i32)
- 127×32 − 63×-76 + 46×51 + 983 = 12,181
- acc means
- 12,181 × s_w × s_x = 1.239
- Rescale to GELU input (units 4/32767)
- m = 1,788,722,465, s = 31: (acc·m + 2^30) >> 31 = 10,146
- GELU table[g] (i8, units 4/127)
- table[10,146] = 35
- h means
- 35 × 4/127 = 1.102 (float: 1.109)
Float
Integer
Things to notice. The integer accumulator is an exact integer, so it is the same on every machine. Its meaning, accumulator × scales, lands within a fraction of a percent of the float neuron. The GELU output is a table entry, not a computation. And the final probabilities agree closely, but the integer ones come out as exact frequencies out of 65,536, ready for the coder.
Deep diveChoosing m and s, and proving nothing overflows
The multiplier. For a ratio , the converter chooses the shift so that lies in . Then has 31 significant bits, and the relative error of is below . A linear layer gets one per output row (each row has its own weight scale) and one shift per layer.
The accumulator. An i8 weight times an i8 activation is at most in size. The longest dot product in GPT-2 has 3,072 terms, so
which fits in an i32 with room to spare. The product is computed in 128 bits, where , so no step can overflow:
pub const fn rescale(x: i64, m: i32, s: u8) -> i128 {
// INVARIANT: |x| <= 2^63 and |m| <= 2^31,
// so |x * m| <= 2^94 and no step overflows i128.
let product = (x as i128) * (m as i128);
shift_round(product, s, RESCALE_MAX_SHIFT)
}The other rules. Signed division is always floor division (a.div_euclid(b), never /), a square root is the integer square root computed digit by digit, and a table index is clamped to the table. Every one of these has exactly one result for every input, on every machine. In the code, every place that could overflow carries an INVARIANT: comment with the bound that shows it cannot.
Here is the dot product and rescale on their own, with a shift you can change: a small gives a coarse , a large a fine one.
1. Dot product in i32
| q_w | 42 | -127 | 5 | 90 |
| q_x | 10 | 20 | -5 | 3 |
| sum |
Running sum: 0
LayerNorm in integers
LayerNorm needs a mean, a variance, a square root and a division. HardShell does each with integers. For a row of integers from the residual stream:
is as a fixed-point number: , so it carries fractional bits. The inside the square root keeps extra bits for small variances.
- x, in units of 1/4096[4,096, 8,192, 12,288, 24,576]
Deep diveWhy K = 48 and J = 8
sets the precision of ; it is limited by overflow: must stay below for every i32 input, and is the largest value that guarantees it. gives the square root enough bits at small standard deviations, leaving fractional bits for .
Measured error on rows of width 768 with two outlier channels, in units of the output's last bit (0.5 is the best possible, the rounding itself):
| Row standard deviation | ||
|---|---|---|
| units | 0.991 | 3.399 |
| 0.524 | 0.545 | |
| 0.501 | 0.501 | |
| to | 0.500 | 0.500 to 1.000 |
The error at small spreads comes from the integer mean, which can be off by one unit. So Taco's converter scales the residual stream until a typical row has a standard deviation of at least units.
Activation functions become tables
GELU has no integer formula, and it does not need one. Its input is an integer with a fixed range, so there are only so many possible inputs, and HardShell simply stores the output for every one of them. The table is computed once, before publication, and shipped in the model file. HardShell's GELU input is an i16: 65,536 entries per layer, with a measured maximum error of 0.502 of the output's last bit. The tiny network above uses the same kind of table.
- x
- 0.60
- index (i8)
- 8
- table → GELU
- 6 → 0.4724
- error
- +0.470 LSB
Softmax and the exponential, as tables
After subtracting the maximum, every exponent in softmax is zero or negative, and with scores in whole steps of there are only so many gaps below the top. So is a table too:
A gap of more than 8,191 steps takes the last entry. Each entry is below and a row has at most scores, so the sum stays below .
| Score units | Gap i | attn_exp[i] | p (units 2⁻³⁰) | p | Float p |
|---|---|---|---|---|---|
| 512 | 0 | 1,073,741,824 | 714,297,037 | 0.665240956 | 0.665240956 |
| 0 | 512 | 145,315,154 | 96,669,591 | 0.090030572 | 0.090030573 |
| 256 | 256 | 395,007,542 | 262,775,194 | 0.244728470 | 0.244728471 |
Σe = 1,614,064,520. KL divergence from the float softmax: 2.69e-9 bits.
The output logits use a second, wider table, in 64-bit integers, to turn each of the 50,257 logits into a weight; the weights become frequencies with the rule from Part 2. With every entry at most and fewer than tokens, the weight sum stays below .
| Token | Logit | Index i | Weight ⌊2⁴⁰e^(−i/256)⌉ | Frequency |
|---|---|---|---|---|
| ␣bit | 9.1 | 0 | 1099511627776 | 3959 |
| ␣file | 5.3 | 973 | 24577714936 | 89 |
| ␣little | 4.2 | 1255 | 8168437446 | 30 |
| ␣a | 3.6 | 1408 | 4493452717 | 17 |
| ␣the | -40.0 | 8191 | 0 | 1 |
The exp table: 8,192 entries, one for each gap of 1/256 below the largest logit. Points are this table's tokens. Sum of frequencies: 4096.
Deep diveHow fine should the tables be?
The score unit and the precision were chosen by measurement: the mean KL divergence of a softmax row against the float softmax, for random scores of standard deviation 3.
| Table | |||
|---|---|---|---|
| , 512 entries, | bits | ||
| , 8,192 entries, | |||
| , 8,192 entries, |
loses probability mass in the floor division of long rows, so . For the logits, the same unit gives bits per token against the float softmax.
The whole integer GPT-2
Put together, one step of HardShell is this chain, with no floating-point operation anywhere in it:
- Tokenid
- Embeddingi32 residual
- LayerNormi32 → i8
- Attentioni8 · i8 → i32
- MLPi8 · i8 → i32
- Logitsi32
- Weightsu64
- Frequenciesu32, total 2³²
- Range coderbytes
Attention× 12 layers
Scores are integer dot products. Softmax uses an exp table of 8,192 entries, round(2³⁰·e^(−i/256)), and P = 30.
Deep diveCalibration, and the 67% surprise
The activation scales are chosen before publication, by running the float model on sample text and recording the range at each point. The first rule was the obvious one: map each tensor's largest value to 127. It made files 67% larger. GPT-2 has a few outlier channels that reach 295 while a typical channel used only one or two of the 127 levels, so almost all the precision went to a handful of channels.
The fix, the clip recipe, sets the range from the typical channel at the places where outliers dominate, and lets the few outliers saturate at . A second fix corrects a constant error from rounding the LayerNorm output before adding : the converter folds it into the next layer's bias. With both, the integer GPT-2 gives 1.871 bits per byte on 16 held-out text files against 1.867 for the float model: +0.2%.
Part 6. How the idea came about
Testing the idea before building it
Language-model compressors already existed, and they shared one limit: they decode only on the same hardware and software that encoded. A published test of one decoded 0 files when the encoder was an M2 Pro and the decoder an M4 Max. Before writing any of Taco, I tested the idea with a small, pure-Python integer context model, no neural network:
| Check | Result |
|---|---|
| Round trips over 8 input classes and 500 random inputs | 508 of 508 |
| Compressed files from the Mac (ARM) and the Linux box (x86) | 8 of 8 byte-identical |
| The same decoder using float32 arithmetic | 50 of 50 decodes failed |
| The same decoder with a change in its probabilities | 13 of 50 decodes failed |
The last row is the worrying one: a change of a billionth breaks a quarter of the decodes, sometimes thousands of bytes in. A short test can miss it.
Integers only
That decided the rule (ADR 0002): no floating-point arithmetic anywhere between the model file and the frequency table. The cost is a little precision and integer versions of LayerNorm, GELU and softmax. The benefit is that integer addition and multiplication give one result in any order, so every machine computes the same table. GPT-2 small was the model: open weights, a byte-level tokenizer that can encode any byte string, and only operations that published integer-only methods (I-BERT) already cover. The float GPT-2 measurement in Part 3 came next and confirmed it: 0 of 4 windows across the machines.
The last float: the converter
With HardShell running on integers, one float step was left: the converter that turns GPT-2's published float weights into the integer model file. The decoder never runs it, but if it could give different results on different machines, two people could build two different "GPT-2" models and fail to read each other's files. A weight whose scaled value is minus a hair on one machine and plus a hair on another rounds to 89 on one and 90 on the other.
The way out was in Part 3: a float32 is not an approximation of anything. Its bits say exactly . So the converter does not compute with floats at all. It reads each weight's bits as the integer and the exponent , computes as an exact fraction of integers, and rounds once, half to even:
- float32 bits of |w|
- 0 01111011 10011001100110011001101
- |w| exactly
- 13,421,773 × 2^-27
- max exactly
- 8,388,608 × 2^-24
- 127 · |w| / max
- 1,704,565,171 / 67,108,864 = 25.4000003784…
- q = round, half to even
- 25
Deep diveExact tables, ties, and the proof
The tables (GELU, the two exp tables) cannot be computed from bits, so they are computed with 50-digit decimal arithmetic and rounded with the same rule; the converter stops with an error if any entry lands within of a tie, so no table entry can depend on precision either.
Exact ties do occur among the weights: 4 of GPT-2's 124,337,664 weights land exactly halfway, and round half to even decides them the same way everywhere. The converter checks the SHA-256 of its output against the published hash and refuses to write a different file.
The result: the exact converter on the Mac and on the Linux box writes gpt2.hsm, 127,237,568 bytes, with the same SHA-256, which is also the same file the first float converter happened to make. Every .taco file stores that hash, and the decoder refuses a model whose hash differs; with a SHA-256 of the original data checked after decoding, a wrong result is always an error, never a silent wrong file.
What it gets, and what it costs
All measured.
| File | Original | Taco | xz -9e | zstd -19 |
|---|---|---|---|---|
| English book, 32 KiB | 32,768 | 8,432 | 12,400 | 12,463 |
| HTML page | 24,603 | 4,759 | 7,652 | 7,713 |
| Server log, 16 KiB | 16,384 | 3,129 | 3,164 | 3,139 |
On alice29.txt Taco gives 2.05 bits per byte, against 2.55 for xz. On 16 server logs, a profile that mixes GPT-2 with a match model is 10.8% smaller than xz. And the point of it all: 12 of 12 test files decode across the Mac and the Linux box with byte-identical .taco files either way; native Windows matches; the CPU, CUDA and wgpu backends give the same bytes.
It is slow: about 85 to 129 KB/s to compress English on an RTX 3060, 26 to 31 KB/s to decompress, 0.3 to 2 KB/s on the CPU alone, where zstd does hundreds of MB/s. And it is not the best language-model compressor: Fabrice Bellard's ts_zip, with a larger model, makes alice29.txt 44% smaller. What Taco adds is the combination: an integer specification of GPT-2 anyone can read, a conversion anyone can repeat, several implementations, and tests that show they agree to the bit. It is an alpha. Keep a second copy of anything you fold with it.