Yvinec, Dapogny & Bailly (Sorbonne / Datakalab), 2023

Shrinking a Model with a Look-Up Table
JLCM: Jointly Learnable Codebooks & Mappings

Stop storing 16-bit weights. Store a tiny table of values once, then replace every weight with a 2-bit index that points into it — and learn both the table and the pointers end-to-end. A Llama 7B that needs 14 GB drops to 2 GB and loads on a 5-year-old phone.

Prerequisites: Neural net weights + Gradient descent + a little k-means
10
Chapters
6
Simulations

Chapter 0: The Problem

You have a trained Llama 7B. It is brilliant. It is also 7 billion floating-point numbers. Stored at the default fp16, each number is 2 bytes, so the weights alone weigh 14 gigabytes. The biggest consumer GPU at the time of this paper had 24 GB of VRAM — and fp32 (28 GB) wouldn't even fit. On a phone with 2 GB of RAM, you are not even close.

So the model sits on disk. And here is the part people forget: loading it is the bottleneck, not computing with it. The paper measures it (Table 2): running a forward pass of Llama 7B from RAM takes about 53 ms on an A100. Loading those same weights from disk first takes 23,982 ms — almost 24 seconds. That is a 450× penalty, paid every time the weights don't fit in fast memory.

The core question: If the weights are 94%+ of a model's memory footprint, can we re-encode them so aggressively that the whole network fits in RAM — without retraining and without losing more than ~1% accuracy?

Why focus on the weights, not the activations?

A network's inference memory is two pieces: the weights (the learned parameters, fixed) and the activations (the intermediate feature maps that flow through, transient). The paper measures the split (Table 1) and it is lopsided:

ModelWeights MWActivations MATotal (MB)
ResNet 1894.5%5.5%87
MobileNet v295.7%4.3%121
ViT b1699.0%1.0%434

For a ViT, the weights are 99% of the footprint. Compressing activations is rearranging deck chairs. The win is in the weights. That single observation sets the entire agenda of the paper.

Why not just retrain in low precision?

The strongest accuracy comes from Quantization-Aware Training (QAT) — retrain the whole network while simulating low precision. But QAT needs the full training set, many epochs, and a lot of compute. You cannot QAT a 7B model on a laptop. The realistic regime is post-training quantization (PTQ): take an already-trained model and a few hundred unlabeled examples (a "calibration set"), and squeeze it down cheaply. JLCM lives here.

Why does the paper compress weights rather than activations?

Chapter 1: The Key Insight

Plain integer quantization rounds every weight onto an evenly-spaced grid (e.g. 256 levels for 8-bit). But weights are not evenly distributed — they pile up near zero with rare large outliers. An even grid wastes most of its levels in empty regions.

The codebook idea fixes this. Instead of a fixed grid, you keep a small table of learned representative values — the codebook C = (c0, c1, …, cK−1) — and replace every weight with the index of its nearest table entry. The table can put its values exactly where the weights actually cluster. This is just k-means on the weights, also called vector quantization or weight sharing.

The insight: A weight no longer needs to be a number — it only needs to point at one. If the table has 4 entries, every weight is a 2-bit index. The expensive 16-bit values are stored once, in the table; the billions of weights become 2-bit pointers. That is the look-up-table (LUT) trick.

JLCM takes this old idea and removes its two blockers, which become the spine of this lesson:

Problem 1 — granularity
One codebook for a whole tensor is too coarse; multiple codebooks normally cost extra memory to say which table each weight uses. JLCM removes that overhead by reordering neurons so the table index is implicit.
Problem 2 — learning
You want to learn the table and the pointers by gradient descent. But the naive gradient pushes pointers toward the largest-magnitude table entry, not the nearest one. JLCM rewrites the gradient so the search is proximal — it prefers nearby values.

Get those two right and you can push transformers to nearly ternary (3-value) weights with under 1% accuracy loss. That is the whole paper.

In a codebook (LUT) scheme, what is actually stored per weight?

Chapter 2: LUT Basics — the memory math

Let us be exact about why a look-up table saves memory, with a worked example. Take one weight matrix W ∈ ℝno×ni with Ω(W) total scalars. We notate the codebook size Ω(C)=K.

Baseline cost (fp16)

Every scalar stored at 16 bits: 16 × Ω(W) bits. For a 4096×4096 layer that is 16 × 16.78M ≈ 268 Mbit ≈ 33.5 MB.

Codebook cost

Two parts. (1) The table itself: K values at 16 bits = 16K bits — paid once. (2) The indices: each of the Ω(W) weights needs ⌈log2K⌉ bits to name its table slot.

MW =  16·K  +  log2(K) · Ω(W)

The teal term is the codebook (small, fixed). The warm term is the mapping (the indices — billions of them). For any real layer the warm term dominates utterly, because Ω(W) is in the millions and K is tiny. So the lever that controls memory is log2K: the bits per index.

Worked number. Same 4096×4096 layer, codebook of K=8 (so 3 bits/index). Table: 16·8 = 128 bits ≈ 0 MB. Indices: 3 · 16.78M = 50.3 Mbit ≈ 6.3 MB. Compression ratio α = 33.5 / 6.3 ≈ 5.3× — exactly the "fp16 → int3" setting the paper reports.

The compression goal α

The paper defines one knob: α = Mref / M, the target compression ratio (reference fp16 footprint ÷ final footprint). Everything else — codebook size, number of codebooks — is derived from α. The recommended table size is

K = 2⌊16/α⌋

Read it: a bigger α (more compression) means fewer bits per index, hence a smaller table. At α=5.33 you get 2⌊3⌋=8 entries (3-bit). At α=7.5 you get 2⌊2.13⌋=4 entries (2-bit, nearly ternary). One hyperparameter sets the whole regime.

Look-Up Table Decode — one weight = one pointer

Left: the stored stream of indices (each a few bits). Middle: the shared codebook. Right: the reconstructed fp16 weight matrix. Click to decode one index at a time — every weight is just table[index]. Slide the bit-width: fewer bits = smaller table, coarser values.

In MW = 16K + log₂(K)·Ω(W), why does shrinking K help so much?

Chapter 3: The Granularity Problem

One codebook for an entire weight tensor assumes all the weights share one distribution. They don't. Different output neurons (rows of W) often have wildly different scales — one row might live in [−0.01, 0.01], another in [−2, 2]. Force them through the same 8-entry table and the small-scale row gets crushed to near-zero garbage.

So you'd like finer granularity. Two natural fixes — and the paper studies both side by side.

Option A — per-channel scales
One shared codebook, plus a learned scale factor si per row: Wi = si·⟨C; Ii. Cheap (one extra number per row), but still assumes all rows have the same shape up to scaling.
Option B — multiple codebooks
Different rows get different tables Ck. Far more expressive — but normally you must store, per weight, which table it uses. That extra index inflates the dominant warm term.
The trap in Option B. Naively, a multi-codebook scheme stores two things per weight: a codebook index (which table?) and a value index (which slot in that table?). That doubles the per-weight bits — and the per-weight term is exactly the one that dominates memory. So expressiveness costs you the compression you came for. This is the granularity problem in one sentence.

The notation, to anchor it. For a single codebook the reconstruction is Wi,j = ⟨C; Ii,j — read ⟨C; I⟩ as "the entry of table C selected by one-hot index I." The cost was 16K + log2(K)·Ω(W). Adding a separate codebook-id index pushes that to log2(K·k_books)·Ω(W) — strictly worse. We need Option B's expressiveness at Option A's price. Chapter 4 is the trick that gets it.

One table for everyone — the granularity failure

Two rows of weights with different scales (blue = big-scale row, green = small-scale row), and the codebook levels (orange ticks). Widen the gap: with one shared table, the small-scale row's weights all snap to the same level — total information loss. Toggle to per-row codebooks and watch each row get levels that fit it.

Why is a naive multi-codebook scheme memory-expensive?

Chapter 4: The Reordering Trick — free multi-codebooks

Here is the move that makes multiple codebooks free. If a weight's row index already tells you which codebook it uses, you don't need to store a codebook-id at all. The codebook index is implicit in position.

Formally, split the no rows into k contiguous blocks and assign codebook C⌊i·k/no to row i:

Wi,j = ⟨ C⌊i·k/no ; Ii,j

Read it: rows 0…(block boundary) use table 0, the next block uses table 1, and so on. The decoder computes the table id from the row number with a floor-divide — zero extra bits stored. You get Option B's expressiveness at Option A's price. Granularity problem solved.

But there's a catch, and its fix is the elegant part. Assigning tables by raw position only helps if neighboring rows actually have similar distributions — and in a trained network they don't; similar neurons are scattered all over the matrix. So first reorder the rows so that similarly-distributed neurons sit next to each other. Then contiguous blocks are homogeneous, and the implicit-index trick pays off.

How do you reorder without breaking the network?

This is the data-flow subtlety. You can't just shuffle one layer's output neurons — the next layer expects them in the original order. The fix: permute the output rows of layer l by a permutation σ, and apply the same permutation to the input columns of layer l+1. The two permutations cancel; the network computes exactly the same function. (This works cleanly when there's no skip connection in between — a documented assumption.)

The permutation σ itself comes from clustering the rows: treat each row (a length-ni vector) as a sample and run a clustering method C on the no rows, then order rows so cluster-mates are contiguous. The final reconstruction folds the permutation in: Wi = ⟨ C⌊σi·k/no ; Ii.

Reorder rows → contiguous blocks become homogeneous

Each row of the weight matrix is colored by its true distribution-type. Before reordering, types are scrambled — any contiguous block of rows is a mess, so a per-block codebook can't fit it. Click to cluster & reorder: now each block is one color, and one small codebook nails it. The dashed lines show the block→codebook boundaries.

Worked permutation cancellation

# Reorder output neurons of layer l, undo it on layer l+1's inputs.
# sigma: a permutation of the n_o output neurons (from clustering).
W_l_new  = W_l[sigma, :]        # permute ROWS  (output neurons)
b_l_new  = b_l[sigma]           # permute bias too
W_l1_new = W_l1[:, sigma]      # permute next layer's COLUMNS to match
# For input x:  W_l1_new @ act(W_l_new @ x + b_l_new)  ==  original output.
# The two permutations cancel: zero accuracy change, but now contiguous
# row-blocks share a distribution, so per-block codebooks are nearly free.
Why does reordering rows let multiple codebooks cost no extra memory?

Chapter 5: Jointly Learning table + pointers

Initialization by clustering is a good start, but it minimizes the wrong thing — it minimizes weight reconstruction error, not output error. What you actually care about is that the compressed layer produces the same activations as the original on real inputs. So JLCM optimizes the codebook C and the mapping I jointly, layer by layer, on a small calibration set — the same self-distillation recipe as AdaRound/GPTQ.

The obstacle: an index is discrete

"Pick table slot 2" is not differentiable — you can't take a gradient through an argmax. The standard relaxation: replace the hard one-hot pick with a soft assignment, softmax(I), a probability vector over the K table slots. The reconstructed weight is then a soft blend C·softmax(I), which is differentiable. We push the softmax toward a hard one-hot over training so the final, stored mapping is a clean integer index.

The objective minimizes the gap between the compressed layer's output ƒ̃(X̃) and the original Y, plus two regularizers:

L(C,I) = ‖ƒ̃(X̃)−Y‖² + L1(C,I) + λ·L2(I)
Why two regularizers, not one? L1 controls where the value lands (near the true weight); L2 controls how committed the assignment is (one slot, not a blur). Without L2 you'd ship a soft blend you can't store as an integer index; without L1 the layer overfits the 256 calibration samples and generalizes badly.

Data flow, with shapes

# One layer, calibration batch. Shapes for a 4096->4096 linear.
W      # (4096, 4096) fp16, frozen original
C      # (K,) learnable table, e.g. K=8
I      # (4096, 4096, K) learnable logits  ->  softmax over last axis
soft   = softmax(I, axis=-1)        # (4096,4096,K) soft assignment
W_hat  = (soft * C).sum(-1)        # (4096,4096) reconstructed weights
Y_hat  = act(X @ W_hat.T)          # X:(B,4096) -> (B,4096)
loss   = mse(Y_hat, Y) + mse(W_hat, W) + lam * decisiveness(soft)
# At export: index = argmax(I, -1)  ->  store as ceil(log2 K)-bit ints.

Note the cost: I is K× bigger than W during optimization (you keep logits for every slot of every weight). That is fine — it is transient, per-layer, on a calibration set; only the final argmax indices ship.

And yet — it fails as written. The paper is candid: with this exact objective, joint learning of C and I actually hurts accuracy (Table 4: learning C&I drops ResNet18 from 22.1% to 20.3%). The bug is not in the loss — it is in the gradient of the index. That is Chapter 6, the real heart of the paper.
What is the role of the L₂ = 1 − |2·softmax(I) − 1|β regularizer?

Chapter 6: The Proximal Gradient Fix

Why does naive joint learning fail? Walk through the gradient on the mapping I. By the chain rule, the gradient of the loss w.r.t. I contains the factor C · ∂softmax(I)/∂I. That C factor is the problem: the update to "move to slot j" is scaled by the value cj. Large-magnitude table entries therefore attract the largest gradients — regardless of whether they are close to the current weight.

The paper's own example. A weight currently sits at value 0.12, mapped to slot 2 of a ternary codebook C = (−2, 0, 0.15, 1.3) with one-hot I=(0,0,1,0). A positive gradient should nudge it from 0.15 toward the neighbor 0 — a tiny, proximal move. But the chain rule, scaled by C, instead most strongly favors jumping to the slot holding −2 (largest magnitude). The optimizer leaps to the far corner of the table. Search becomes wild, not proximal — so it diverges.

The fix: borrow the STE idea

Quantization's classic trick is the straight-through estimator (STE): the forward pass uses a non-differentiable op (rounding), and the backward pass swaps in a hand-designed gradient that behaves how we want. JLCM does the same for the index. It replaces the troublesome C·∂softmax/∂I term with a custom matrix D that is a decreasing function of distance between codewords:

Di,j = sign(ca − cj) / (1 + |ca − cj|) − (identity)a,j

where a = argmax(Ii) is the weight's current slot. Decode it slowly:

The codebook gradient (first row of the update) is left untouched — it was already well-behaved. Only the index gradient is surgically replaced. With this single change, joint learning finally works: Table 4 shows it lifting ResNet18 from 20.3% to 41.8% and ViT from 57.8% to 70.0% at the same compression.

Naive vs proximal gradient — which slot wins?

The ternary codebook (−2, 0, 0.15, 1.3) on a number line; the white marker is the current weight. Bars show the gradient pull toward each slot. Naive: pull scales with |value|, so −2 dominates — a wild jump. Toggle to Proximal: pull scales with 1/(1+distance), so the nearest slot wins. Drag the weight and watch which slot the optimizer would jump to.

What was wrong with the naive index gradient?

Chapter 7: Showcase — Compress a Layer End-to-End

Now put every piece together on one weight matrix. This is the JLCM pipeline you can drive: pick a compression goal α (which sets the table size K = 2⌊16/α⌋), choose how many codebooks to split the rows into, optionally reorder, and watch the live memory budget, the reconstructed matrix, and the resulting error.

JLCM Layer Compressor — the full method, live
 

Left: original 16×16 weight matrix (color = value). Right: the reconstruction after compression. The readout shows live K, bits/index, the memory split (table vs indices), the achieved α, and reconstruction error. Toggle Reorder to group similar rows before splitting into per-block codebooks; toggle Proximal learn to run a few proximal-gradient refinement steps and watch the error drop. Push α high and the matrix gets blocky — that's the accuracy/size tradeoff made visible.

Read the tradeoff in your hands. Crank α up: bits/index falls, the table shrinks, and the right-hand matrix loses detail. Add codebooks + reorder: detail comes back at almost no extra memory (the codebook id is implicit). Hit proximal-learn: the error drops further still, because the table and indices co-adapt to minimize output error, not just weight error. Those three knobs are the paper.
In the showcase, adding more codebooks + reordering improves the reconstruction with almost no memory cost. Why?

Chapter 8: Experiments & what they prove

The ablation (Table 4, α=7.5) is the cleanest evidence, because it isolates each contribution. Read it top to bottom — each row adds one component:

learn Clearn C & IproximalResNet18ViT b16
20.660.7
22.163.9
20.357.8
41.870.0

The story is dramatic. Learning only the codebook helps a little (+1.5 / +3.2). Naively adding the mapping hurts (the wild-gradient failure from Ch 6). The proximal gradient then nearly doubles ResNet18 accuracy. The headline number isn't joint learning — it's fixing the gradient so joint learning is possible.

Initialization matters too (Table 3). Random row clustering gives ~0.1% accuracy — useless. K-means and hierarchical clustering recover 64–80%. And the two granularity options specialize: per-channel scales win on CNNs (conv kernels care about relative scale), while multiple codebooks win on transformers (fully-connected layers care about scalar value nuance). So JLCM uses multi-scaling for CNNs and multi-codebooks for transformers.

Versus the field, and the payoff

On ImageNet at α≈5.33 (fp16→int3), JLCM beats data-free methods (DFQ, SQuant, PowerQuant) by large margins and is competitive with data-driven AdaRound/BrecQ/NUPES — while targeting memory, not latency. The flagship result: Llama 7B compressed ~7.2× fits in 2 GB, retaining ~95% of its common-sense-reasoning performance — enough to load on an iPhone 8 or Galaxy A10. A Stable Diffusion model holds its CLIP score under 4× compression too.

Ablation as a story — each component's contribution

Step through the ablation. Watch the ViT and ResNet bars: codebook-only helps, naive joint learning dips, and proximal search leaps. The dip is the whole reason the proximal gradient exists.

What does the ablation's third row (naive learn C&I, no proximal) demonstrate?

Chapter 9: Connections & Cheat Sheet

JLCM sits at the intersection of three lineages. Vector quantization / weight sharing (Deep Compression, Han 2015) gave the codebook idea. Gradient-based PTQ (AdaRound, BrecQ, GPTQ) gave the per-layer self-distillation recipe. Straight-through estimation (Bengio 2013) gave the trick of hand-designing a backward pass. JLCM's novelty is the implicit-codebook-via-reordering and the proximal index gradient — two small ideas that unlock the combination.

Where it fits in the compression map

Uniform integer (int8/int4)
Even grid. Hardware-friendly arithmetic, but wastes levels on empty weight regions; breaks below ~4 bits.
↓ non-uniform
Codebook / LUT (JLCM, Deep Compression)
Learned table of values + indices. Best memory compression; decode is a table look-up, not arithmetic.
↓ vs
GPTQ / AWQ (LLM PTQ)
Also per-layer calibration, but targets low-bit integer matmul for latency; JLCM targets footprint and stores a LUT.
The footprint-vs-latency fork. This is the key mental model. Integer quantization (int4 matmul) optimizes compute — faster multiplies. Codebook/LUT compression optimizes memory — you still decode to fp16 to compute, but you store almost nothing. When the bottleneck is "the model won't fit / won't load" (the disk→RAM 450× wall from Chapter 0), LUT compression is the right tool. When the bottleneck is "the matmul is too slow," integer quantization is.

Cheat Sheet

SymbolMeaningPlain words
Ccodebook, K entriesthe shared table of allowed weight values
Imapping / index tensorwhich table slot each weight points at
K = Ω(C)codebook size = 2⌊16/α⌋number of distinct values; sets bits/index
α = Mref/Mcompression goalthe one knob; how much smaller you want it
MW16K + log₂(K)·Ω(W)weight memory = table + indices
σrow permutationreorder neurons so blocks are homogeneous
softmax(I)soft assignmentdifferentiable stand-in for the hard index
Dproximal gradient termswaps in a backward pass that prefers nearby slots
λsharpness weightannealed up to force soft → hard assignment

Reference implementation of the core insight

import numpy as np

def proximal_index_grad(C, cur_slot, upstream):
    """The paper's fix: gradient on the index that prefers NEAR codewords.
    C: (K,) codebook values.  cur_slot: int, the weight's current slot.
    upstream: scalar dL/d(reconstructed weight). Returns pull per slot (K,)."""
    ca = C[cur_slot]
    dist = np.abs(ca - C)                 # |c_a - c_j| for every slot
    D = np.sign(ca - C) / (1.0 + dist)  # DECREASING in distance -> proximal
    D[cur_slot] = 0.0                    # identity subtraction: don't reward staying
    return upstream * D                    # near slots get the strongest pull

# Compare to the NAIVE gradient, which the paper shows diverges:
def naive_index_grad(C, cur_slot, upstream):
    return upstream * C   # scaled by codeword VALUE -> favors extreme |c_j|, not nearby

C = np.array([-2.0, 0.0, 0.15, 1.3])     # the paper's ternary example
# weight=0.12 sits in slot 2 (value 0.15); a +grad SHOULD nudge it to slot 1 (value 0).
print("proximal", np.round(proximal_index_grad(C, 2, 1.0), 2))
# -> [ 0.31  0.87  0.    0.47]  : slot 1 (the NEIGHBOR) gets the biggest pull. Correct.
print("naive   ", np.round(naive_index_grad(C, 2, 1.0), 2))
# -> [-2.    0.    0.15  1.3 ] : slot 0 (value -2) dominates by magnitude. Wild jump.
The whole paper in one breath. Replace 16-bit weights with indices into a tiny learned table (LUT) → use multiple tables for granularity but make the table id free by reordering rows → learn table and indices jointly on a calibration set → and crucially, fix the index gradient so the search stays proximal. Result: near-ternary transformers, a Llama 7B in 2 GB.
When is LUT/codebook compression the right tool versus integer quantization?
"...a Llama 7B can be compressed down to 2Go and loaded on 5-year-old smartphones."
— Yvinec, Dapogny & Bailly, JLCM (2023)