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 pp, you need about

bits(p)=−log⁡2p\text{bits}(p) = -\log_2 p

questions. A fair coin, p=12p = \tfrac12: 1 bit. A face of a die, p=16p = \tfrac16: 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.

start
1 bit
2 bits

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.

Every halving of the range is one yes-or-no answer: one bit. To point at a slice of width p you need about −log₂ p halvings. A token GPT-2 gave 93% needs a tenth of a bit; one it gave 1 in 8,000 needs 13.

Entropy: the floor no code can beat

If symbols come from a distribution pp, the average cost of coding them is at least the entropy, the expected number of bits:

H(p)=∑ipi⋅(−log⁡2pi)H(p) = \sum_i p_i \cdot \left(-\log_2 p_i\right)

A fair coin has H=1H = 1 bit. A coin that lands heads 90% of the time has H=0.469H = 0.469 bits: 1,000 of its tosses fit in about 469 bits.

1 bitp = 0p = 1
Entropy H(p)
0.469 bits
The model pays
0.522 bits
1,000 tosses
522 bits vs 1,000
A coin that lands heads with probability p. The curve is its entropy: the fewest bits per toss any lossless code can average. A model that believes q pays more, and the gap is the KL divergence. That gap is the whole game: a better model pays less.
Deep diveCross-entropy, KL divergence, and why a better model is a smaller file

A compressor never knows the true distribution pp of its data. It codes with a model qq, so each symbol ii costs −log⁡2qi-\log_2 q_i bits while it occurs with probability pip_i. The average cost is the cross-entropy:

H(p,q)=−∑ipilog⁡2qiH(p, q) = -\sum_i p_i \log_2 q_i

Add and subtract ∑ipilog⁡2pi\sum_i p_i \log_2 p_i:

H(p,q)=−∑ipilog⁡2pi⏟H(p)  +  ∑ipilog⁡2piqi⏟KL(p ∥ q)H(p, q) = \underbrace{-\sum_i p_i \log_2 p_i}_{H(p)} \; + \; \underbrace{\sum_i p_i \log_2 \frac{p_i}{q_i}}_{\mathrm{KL}(p\,\|\,q)}

The Kullback–Leibler divergence is never negative (Gibbs' inequality) and is zero only when q=pq = p. 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%:

H(p,q)=−0.9log⁡20.8−0.1log⁡20.2=0.522 bitsKL(p ∥ q)=0.522−0.469=0.053 bits per toss\begin{aligned} H(p, q) &= -0.9\log_2 0.8 - 0.1\log_2 0.2 = 0.522 \text{ bits} \\ \mathrm{KL}(p\,\|\,q) &= 0.522 - 0.469 = 0.053 \text{ bits per toss} \end{aligned}

Shannon showed in 1948 that H(p)H(p) is a hard floor (the source coding theorem), and that arithmetic-style codes can get within a few bits of H(p,q)H(p, q) for a whole message. Everything Taco does to the model is about keeping KL\mathrm{KL} 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
Real predictions from GPT-2 small on one sentence. Click a token. Predictable tokens cost almost nothing; surprising ones cost many bits. The whole 59-byte sentence needs about 100 bits.

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 [0,1)[0, 1). 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

−log⁡2∏tp(xt)=∑t−log⁡2p(xt)-\log_2 \prod_t p(x_t) = \sum_t -\log_2 p(x_t)

exactly the sum of the symbol costs from Part 1. Here is BAC with P(A)=0.5P(A) = 0.5, P(B)=0.3P(B) = 0.3, P(C)=0.2P(C) = 0.2, first with fractions, then with integers:

With fractions

Start: [0.000, 1.000), width 1.000

ABC
Step 1 of 10
Coding the message BAC by hand, with A = 0.5, B = 0.3, C = 0.2 (from Taco's primer). Each symbol keeps its share of the current interval. The final width is 0.3 × 0.5 × 0.2 = 0.03, so about −log₂ 0.03 = 5.06 bits. Then the same thing with integers, as a range coder does it.
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:

StateTypeMeaning
lowu128, below 2652^{65}bits 0 to 63 are the start of the interval; bit 64 is a carry
rangeu64the width of the interval
cache, pendingbyte, countbytes held back because a carry could still change them

A frequency table has total T=2bT = 2^b. To code symbol ss with start csc_s and frequency fsf_s:

r=range≫b,low+=r⋅cs,range=r⋅fsr = \text{range} \gg b, \qquad \text{low} \mathrel{+}= r \cdot c_s, \qquad \text{range} = r \cdot f_s

(the last symbol takes everything left, so no range is wasted). Then renormalise: while range is below 2562^{56}, 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 2562^{56} and T≤232T \le 2^{32}, rr is at least 2242^{24}, so the floor in range >> b loses less than 2−242^{-24} 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:

Token 1: Awidth 1.00e+0 → 1.94e-2
↵
Token 2: ␣filewidth 1.94e-2 → 8.35e-6
Token 3: ␣compressedwidth 8.35e-6 → 2.87e-11
␣photo␣picture

After 3 tokens the interval is 2.87e-11 wide, so 35.0 bits are enough to name a number inside it.

Range coding the first tokens of the same sentence. Each row is the interval left after the rows above it, split by GPT-2's probabilities (top 5, the rest grey). The coder keeps the red slice. A likely token keeps a wide slice, which costs few bits.

Every token keeps a frequency of at least 1

The coder needs whole-number frequencies. Taco turns the model's weights wiw_i (Part 5 shows where they come from) into a table with total T=232T = 2^{32}:

fi=1+⌊wi (T−V)W⌋,W=∑iwi,V=50,257f_i = 1 + \left\lfloor \frac{w_i\,(T - V)}{W} \right\rfloor, \qquad W = \sum_i w_i, \quad V = 50{,}257

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 2−322^{-32}, 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

ABC

Step 2: the value is 0.6000

ABC

Step 3: the value is 0.5000

ABC

Decoded: BBB — the message was BAC.

The decoder reads the number 0.62 and finds BAC, but only with the encoder's exact table. Move one unit of frequency from B to C and it reads BBB. In a real compressor the wrong symbol also goes into the model's context, so every table after it is wrong too.

With T=232T = 2^{32}, one unit is 2.3×10−102.3 \times 10^{-10} 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 ss, 8 exponent bits EE and 23 fraction bits ff. For normal numbers:

x=(−1)s×(1+f223)×2E−127x = (-1)^s \times \left(1 + \frac{f}{2^{23}}\right) \times 2^{E - 127}

Multiply out the fraction and this is an integer times a power of two:

x=(−1)s×(223+f)⏟m, 24 bits×2E−150x = (-1)^s \times \underbrace{(2^{23} + f)}_{m,\ \text{24 bits}} \times 2^{E - 150}

So a float32 is never "about 0.1". It is an exact binary fraction. Typing 0.1 stores the nearest one, 13,421,773×2−27=0.10000000149011611938476562513{,}421{,}773 \times 2^{-27} = 0.100000001490116119384765625. Flip the bits and see:

signexponent (8 bits)fraction (23 bits)
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
A float32 is 32 bits: a sign, an 8-bit exponent and a 23-bit fraction. Click any bit, or type a number. Whatever you type, what is stored is an integer m (24 bits) times a power of two, exactly. So 0.1 is stored as 13,421,773 × 2⁻²⁷, which is not 0.1, but is an exact number that any machine can compute with exactly.

Keep this in mind for Part 6: because every float32 is exactly m×2em \times 2^e, 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 [1,2)[1, 2) the ulp is 2−232^{-23}; in [226,227)[2^{26}, 2^{27}), around 10810^8, 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 (1+2−24)+2−24=1(1 + 2^{-24}) + 2^{-24} = 1 in float32 (each addition is an exact tie and rounds back down to 1), while 1+(2−24+2−24)=1+2−231 + (2^{-24} + 2^{-24}) = 1 + 2^{-23}. A fused multiply-add rounds a×b+ca \times b + c 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.

Two orders of the same float32 sum, computed exactly in your browser. Every float addition rounds, so a different order is a different chain of roundings. A dot product of 768 numbers is a long chain.

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:

Your browser adds the same 100,000 numbers three ways. Float addition is not associative, so the order shows in the last digits. CPUs, GPUs, BLAS libraries and thread counts all pick different 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 x∈Rnx \in \mathbb{R}^n computes

z=Wx+b,zj=∑i=1nWji xi+bj,y=σ(z)z = Wx + b, \qquad z_j = \sum_{i=1}^{n} W_{ji}\, x_i + b_j, \qquad y = \sigma(z)

where WW is an m×nm \times n weight matrix (one row per neuron), bb a bias vector, and σ\sigma a non-linear activation function applied to each element. Each output is one dot product. Without σ\sigma, a stack of layers would collapse into one matrix; the non-linearity is what lets the network bend.

102
0-11
×
3
4
5
=
·
·

Press Next step to start the first dot product.

Step 1 of 7
A layer of 2 neurons with 3 inputs. Each output is a dot product: multiply a row by the input, add as you go. GPT-2's MLP layers are 3,072 rows of 768, and almost all of its time goes into exactly this.

GPT-2 uses GELU as its activation: GELU(z)=z Φ(z)\mathrm{GELU}(z) = z\,\Phi(z), where Φ\Phi is the standard normal distribution function, computed in GPT-2 with the approximation

GELU(z)≈12z(1+tanh⁡ ⁣(2/π (z+0.044715 z3)))\mathrm{GELU}(z) \approx \tfrac12 z \left(1 + \tanh\!\left(\sqrt{2/\pi}\,(z + 0.044715\,z^3)\right)\right)

It is close to 0 for negative inputs, close to zz for large positive ones, and smooth in between: GELU(−1)=−0.159\mathrm{GELU}(-1) = -0.159, GELU(1)=0.841\mathrm{GELU}(1) = 0.841.

Softmax: scores to probabilities

The last layer gives one score per token, a logit ℓi\ell_i. Softmax makes them positive and summing to 1:

pi=e ℓi∑je ℓj=e ℓi−ℓmax⁡∑je ℓj−ℓmax⁡p_i = \frac{e^{\,\ell_i}}{\sum_j e^{\,\ell_j}} = \frac{e^{\,\ell_i - \ell_{\max}}}{\sum_j e^{\,\ell_j - \ell_{\max}}}

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 exe^x.

Tokenlogit − maxe^(logit − max)p = e / sumCost
token 10.01.000 0.6590.60 bits
token 2-1.00.368 0.2422.04 bits
token 3-1.90.150 0.0993.34 bits
sum1.5171.000
Softmax, step by step. Subtracting the largest logit first changes nothing in the result, keeps every exponent at zero or below, and is the step that later lets Taco use a table. Each probability is also a cost: −log₂ p bits if that token comes next.

Attention: a weighted average of other tokens

For each token, three matrices make a query qq, a key kk and a value vv. A token scores every earlier token by q⋅kq \cdot k, softmaxes the scores into weights, and outputs the weighted sum of their values:

Attention(Q,K,V)=softmax ⁣(QK⊤dk)V\mathrm{Attention}(Q, K, V) = \mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V

weights [0.665, 0.090, 0.245]

output = Σ wᵢ·valueᵢ = [0.910, 0.335]

One token attends to three earlier tokens. Its query scores each of their keys; softmax turns the scores into weights; the output is the weighted mix of their value vectors (the black arrow). Raise a score and the output swings toward that token's value.
Deep diveGPT-2 small, layer by layer

GPT-2 small has d=768d = 768 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 xx, the residual stream. Each block does

a=x+Attn(LN1(x)),x′=a+MLP(LN2(a))a = x + \mathrm{Attn}(\mathrm{LN}_1(x)), \qquad x' = a + \mathrm{MLP}(\mathrm{LN}_2(a))

with MLP(u)=W2 GELU(W1u+b1)+b2\mathrm{MLP}(u) = W_2\,\mathrm{GELU}(W_1 u + b_1) + b_2 (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:

LN(x)i=γixi−μσ2+ε+βi,μ=1n∑ixi,σ2=1n∑i(xi−μ)2\mathrm{LN}(x)_i = \gamma_i \frac{x_i - \mu}{\sqrt{\sigma^2 + \varepsilon}} + \beta_i, \qquad \mu = \frac1n \sum_i x_i, \quad \sigma^2 = \frac1n \sum_i (x_i - \mu)^2

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 vv as an integer qq and an agreed scale ss, so that v≈s⋅qv \approx s \cdot q. Money does this: $3.25 is 325 cents, with scale 1/1001/100. The weights of each row get their own scale: the row's largest weight becomes ±127\pm127, and every weight becomes q=round⁡(w/sw)q = \operatorname{round}(w / s_w), an 8-bit integer.

-127-640641271234

scale = max|w| / 127 = 0.01000

ww / scaleq (i8)q × scaleerror
0.42342.30420.42000.0030
-1.270-127.00-127-1.27000.0000
0.0515.1050.05000.0010
0.89789.70900.90000.0030

Largest error 0.0030, at most half a step (0.0050).

One row of weights becomes four small integers and one scale. The largest weight lands on ±127, the others on the nearest step. The error is at most half a step, and, more important, it is the same error on every machine.

Now the key algebra. If wi≈swqw,iw_i \approx s_w q_{w,i} and xi≈sxqx,ix_i \approx s_x q_{x,i}, then

∑iwixi  ≈  ∑i(swqw,i)(sxqx,i)  =  swsx∑iqw,i qx,i⏟integers only\sum_i w_i x_i \;\approx\; \sum_i (s_w q_{w,i})(s_x q_{x,i}) \;=\; s_w s_x \underbrace{\sum_i q_{w,i}\, q_{x,i}}_{\text{integers only}}

The scales come out of the sum. The whole dot product is integer multiply-adds into an accumulator, and the accumulator means swsxs_w s_x times its value. The bias is stored in the same units, qb=round⁡(b/(swsx))q_b = \operatorname{round}(b / (s_w s_x)), so it is just added to the accumulator.

To hand the result to the next layer, which expects its own scale sys_y, multiply by the ratio M=swsx/syM = s_w s_x / s_y. MM is a real number, so HardShell writes it as an integer mm over a power of two, M≈m/2sM \approx m / 2^s, and computes

qy=rescale⁡(acc,m,s)=⌊acc⋅m+2s−12s⌋q_y = \operatorname{rescale}(\text{acc}, m, s) = \left\lfloor \frac{\text{acc} \cdot m + 2^{s-1}}{2^s} \right\rfloor

an integer multiply, an add and an arithmetic shift. The +2s−1+2^{s-1} 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 mm and ss, the GELU table, and at the end the logits, the exp table and the frequencies the coder would get.

0.500q 32-1.200q -760.800q 511.109q 350.015q 0-0.157q -50.990q 312.080L 5300.077L 16-0.170L -43cat80.6% · 80.6%dog10.9% · 10.8%taco8.5% · 8.6%input xh = GELU(W₁x + b₁)logits = W₂h + b₂p

Hidden neuron h1

Float

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

Integer

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)
A network you can trace: 3 inputs, 4 hidden neurons with GELU, 3 output tokens. Gold edges are positive weights, red negative, thicker is larger. Each neuron shows its float value and, in gold, its integer value. Click a neuron for its exact arithmetic, both ways. Move the inputs and watch the two versions agree.

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 MM, the converter chooses the shift ss so that m=round⁡(M⋅2s)m = \operatorname{round}(M \cdot 2^s) lies in [230,231)[2^{30}, 2^{31}). Then mm has 31 significant bits, and the relative error of m/2sm / 2^s is below 2−312^{-31}. A linear layer gets one mm 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 127×128127 \times 128 in size. The longest dot product in GPT-2 has 3,072 terms, so

∣acc∣≤127×128×3,072<226|\text{acc}| \le 127 \times 128 \times 3{,}072 < 2^{26}

which fits in an i32 with room to spare. The product acc⋅m\text{acc} \cdot m is computed in 128 bits, where ∣x⋅m∣≤263⋅231=294|x \cdot m| \le 2^{63} \cdot 2^{31} = 2^{94}, 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 ss gives a coarse mm, a large ss a fine one.

1. Dot product in i32

q_w42-127590
q_x1020-53
sum

Running sum: 0

Step 1 of 5
An integer dot product, then a rescale. The sum is exact integer arithmetic. To move it to the next layer's units it is multiplied by m and shifted right by s: a fraction m / 2^s close to 1/50. A larger shift makes m a closer fraction. The true value is −37.5, exactly half, and the rule rounds half up.

LayerNorm in integers

LayerNorm needs a mean, a variance, a square root and a division. HardShell does each with integers. For a row xx of nn integers from the residual stream:

μ=⌊∑xn⌋,σ2=⌊∑(x−μ)2n⌋inv=⌊2K⌊σ2⋅22J+ε⌋⌋y=rescale⁡((x−μ)⋅inv⋅γ)+β\begin{aligned} \mu &= \left\lfloor \frac{\sum x}{n} \right\rfloor, \qquad \sigma^2 = \left\lfloor \frac{\sum (x-\mu)^2}{n} \right\rfloor \\[4pt] \text{inv} &= \left\lfloor \frac{2^{K}}{\left\lfloor\sqrt{\sigma^2 \cdot 2^{2J} + \varepsilon}\right\rfloor} \right\rfloor \\[4pt] y &= \operatorname{rescale}\big((x-\mu)\cdot\text{inv}\cdot\gamma\big) + \beta \end{aligned}

inv\text{inv} is 1/σ1/\sigma as a fixed-point number: 2K/(σ⋅2J)=2K−J/σ2^K / (\sigma \cdot 2^J) = 2^{K-J}/\sigma, so it carries K−JK - J fractional bits. The 22J2^{2J} inside the square root keeps JJ extra bits for small variances.

  1. x, in units of 1/4096[4,096, 8,192, 12,288, 24,576]
Step 1 of 7
LayerNorm with integers only: an integer mean, an integer variance, an integer square root and one floor division, with HardShell's constants K = 48 and J = 8. The output is an i8 in steps of 1/32. Compare it with the float LayerNorm on the right: the difference is the final rounding, nothing more.
Deep diveWhy K = 48 and J = 8

KK sets the precision of 1/σ1/\sigma; it is limited by overflow: (x−μ)⋅inv⋅γ(x - \mu)\cdot\text{inv}\cdot\gamma must stay below 2952^{95} for every i32 input, and K=48K = 48 is the largest value that guarantees it. J=8J = 8 gives the square root enough bits at small standard deviations, leaving K−J=40K - J = 40 fractional bits for inv\text{inv}.

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 deviationK=48,J=8K=48, J=8K=32,J=0K=32, J=0
242^4 units0.9913.399
282^80.5240.545
2122^{12}0.5010.501
2162^{16} to 2242^{24}0.5000.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 2122^{12} 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
GELU as a table. Every possible input has one stored output, so there is nothing to compute and nothing to disagree about. With an i8 input the steps are visible; HardShell uses an i16 input, 65,536 entries, with a measured maximum error of 0.502 of the last bit.

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 2−82^{-8} there are only so many gaps below the top. So exe^x is a table too:

attn_exp[i]=round⁡ ⁣(230 e−i/256),0≤i<8192,pj=⌊ej⋅230∑kek⌋\text{attn\_exp}[i] = \operatorname{round}\!\left(2^{30}\, e^{-i/256}\right), \quad 0 \le i < 8192, \qquad p_j = \left\lfloor \frac{e_j \cdot 2^{30}}{\sum_k e_k} \right\rfloor

A gap of more than 8,191 steps takes the last entry. Each entry is below 2312^{31} and a row has at most 2312^{31} scores, so the sum stays below 2622^{62}.

Score unitsGap iattn_exp[i]p (units 2⁻³⁰)pFloat p
51201,073,741,824714,297,0370.6652409560.665240956
0512145,315,15496,669,5910.0900305720.090030573
256256395,007,542262,775,1940.2447284700.244728471

Σe = 1,614,064,520. KL divergence from the float softmax: 2.69e-9 bits.

The attention softmax with integers. Scores become whole numbers of 1/256; each gap below the top score indexes the exp table; the probabilities are a floor division in units of 2⁻³⁰. Against the float softmax the difference is around a billionth: Taco measured a mean of 1.5 × 10⁻⁶ bits per row of 1,024 scores.

The output logits use a second, wider table, round⁡(240e−i/256)\operatorname{round}(2^{40} e^{-i/256}) 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 2402^{40} and fewer than 2202^{20} tokens, the weight sum stays below 2602^{60}.

TokenLogitIndex iWeight ⌊2⁴⁰e^(−i/256)⌉Frequency
␣bit9.1010995116277763959
␣file5.39732457771493689
␣little4.21255816843744630
␣a3.61408449345271717
␣the-40.0819101

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.

Logits to frequencies with integers only. Drag a logit: the gap to the largest logit indexes an exp table, the weights are scaled to a total of 4096, and every token keeps a frequency of at least 1, even when its weight rounds to 0.
Deep diveHow fine should the tables be?

The score unit δ\delta and the precision PP were chosen by measurement: the mean KL divergence of a softmax row against the float softmax, for random scores of standard deviation 3.

Tablen=16n = 16n=128n = 128n=1,024n = 1{,}024
δ=2−4\delta = 2^{-4}, 512 entries, P=24P = 241.1×10−41.1 \times 10^{-4} bits1.8×10−41.8 \times 10^{-4}2.5×10−42.5 \times 10^{-4}
δ=2−8\delta = 2^{-8}, 8,192 entries, P=24P = 241.2×10−61.2 \times 10^{-6}6.2×10−66.2 \times 10^{-6}4.3×10−54.3 \times 10^{-5}
δ=2−8\delta = 2^{-8}, 8,192 entries, P=30P = 304.8×10−7\mathbf{4.8 \times 10^{-7}}8.0×10−7\mathbf{8.0 \times 10^{-7}}1.5×10−6\mathbf{1.5 \times 10^{-6}}

P=24P = 24 loses probability mass in the floor division of long rows, so P=30P = 30. For the logits, the same unit gives 5.9×10−75.9 \times 10^{-7} 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:

  1. Tokenid
  2. Embeddingi32 residual
  3. LayerNormi32 → i8
  4. Attentioni8 · i8 → i32
  5. MLPi8 · i8 → i32
  6. Logitsi32
  7. Weightsu64
  8. Frequenciesu32, total 2³²
  9. 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.

One step of HardShell, the integer engine. The three blocks in red repeat 12 times. Pick any station: there is no float anywhere on the path.
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 ±127\pm127. A second fix corrects a constant error from rounding the LayerNorm output before adding β\beta: 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:

CheckResult
Round trips over 8 input classes and 500 random inputs508 of 508
Compressed files from the Mac (ARM) and the Linux box (x86)8 of 8 byte-identical
The same decoder using float32 arithmetic50 of 50 decodes failed
The same decoder with a 10−910^{-9} change in its probabilities13 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 89.589.5 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 m×2em \times 2^e. So the converter does not compute with floats at all. It reads each weight's bits as the integer mm and the exponent ee, computes 127⋅w/max⁡127 \cdot w / \max 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
Exact conversion. The bits of a float32 are read as an integer m and a power of two e, so the weight is exactly m × 2^e. The quantised weight 127 · w / max is then an exact fraction of integers, rounded once, half to even. Any machine gets the same integer.
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 10−3010^{-30} 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.

FileOriginalTacoxz -9ezstd -19
English book, 32 KiB32,7688,43212,40012,463
HTML page24,6034,7597,6527,713
Server log, 16 KiB16,3843,1293,1643,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.