3.5 The NTT, step by step
The transform that makes ML-KEM fast: why it exists, how the butterflies work, and a version small enough to check by hand.
Why ML-KEM needs a fast multiplication
In ML-KEM every public-key and ciphertext element is a polynomial with 256 coefficients mod . Key generation computes , where is a matrix of such polynomials. That is 9 polynomial products for ML-KEM-768 before any encryption happens. These are the Module-LWE equations, and their cost is dominated by polynomial multiplication.
Multiplying two degree-255 polynomials coefficient by coefficient takes products. The NTT turns the multiplication into cheap pointwise work after a transform that costs about operations. The same idea powers the FFT for real numbers, but everything stays exact because all arithmetic is modulo .
Negacyclic convolution: what the ring does to products
The ring is . Because , any term that overflows past degree wraps around to the bottom with its sign flipped:
This is called a negacyclic convolution. A plain FFT computes a cyclic convolution, where the wrapped terms keep their sign. The negacyclic sign comes for free if we evaluate at roots of rather than roots of .
Intuition
A polynomial is fully determined by its values at enough points. Multiplying two polynomials is hard coefficient by coefficient, but easy value by value: . The NTT evaluates at cleverly chosen points (the roots of mod ), you multiply the values, and the inverse NTT turns them back into coefficients. The cleverness is in choosing points so that evaluating at all of them at once reuses almost all the work.
Roots of unity mod q
An element is a primitive -th root of unity if and no smaller positive power equals 1. Such an element exists exactly when divides . To split into linear factors you need a primitive -th root , because the roots of are the odd powers .
ML-KEM’s modulus
For ML-KEM, . So a primitive 256th root exists, and FIPS 203 fixes (, ). A 512th root does not exist, so the best available factorization is into 128 quadratics:
By the Chinese remainder theorem, . The NTT of a polynomial is its list of 128 remainders, each a degree-1 polynomial stored as two coefficients.
A worked example you can check by hand
Shrink everything: , . Here is a primitive 8th root of unity (powers: 1, 2, 4, 8, 16, 15, 13, 9). Multiply
Schoolbook first: . Since , the term cancels the constant and .
Full NTT: evaluate at the four roots
The roots of mod 17 are the odd powers of 2: 2, 8, 15 and 9. Evaluate and multiply:
| root r | a(r) | b(r) | a(r)·b(r) mod 17 | c(r) = r + r³ |
|---|---|---|---|---|
| 2 | 3 | 9 | 10 | 10 |
| 8 | 9 | 3 | 10 | 10 |
| 15 | 16 | 10 | 7 | 7 |
| 9 | 10 | 16 | 7 | 7 |
The pointwise products match at every root, so interpolating them back gives exactly . That is the whole idea; the rest is doing it quickly.
Incomplete NTT: stop one layer early, like ML-KEM
Pretend 17 had no 8th root, only the 4th root (). Then and one butterfly layer computes both remainders:
For this gives and . For both remainders are . Now multiply each pair modulo its own quadratic with the base-case rule, where :
- Mod : . Check: .
- Mod (): . Check: .
ML-KEM does exactly this with 7 layers instead of 1, ending in 128 quadratics . FIPS 203 calls the pairwise step BaseCaseMultiply (Algorithm 12) and applies it through MultiplyNTTs (Algorithm 11).
Butterflies: how the layers work
Each layer splits every factor into . For a block , the two remainders are . Computed in place, that is the Cooley–Tukey butterfly:
ML-KEM’s forward NTT (FIPS 203 Algorithm 9) runs this with block half-lengths 128, 64, …, 2. That is 7 layers of 128 butterflies, 896 multiplications in total. The inverse (Algorithm 10) runs the layers in reverse with the Gentleman–Sande butterfly, which undoes one split:
Each inverse layer doubles the values, so the result is finally multiplied by .
Why the zetas are in bit-reversed order
An in-place Cooley–Tukey transform leaves its outputs in bit-reversed order. Instead of shuffling the array, FIPS 203 keeps the output in that order and indexes the twiddle factors by , where reverses the 7-bit binary expansion of . The first entries are 1, 1729, 2580, 3289, …. The NTT output is only ever consumed by MultiplyNTTs and the inverse NTT, so the permuted order is harmless as long as all three agree. ML-KEM even samples the matrix directly in the NTT domain, so it is never transformed at all.
Reference implementation in Python
This follows FIPS 203 Algorithms 9–12 line for line. It is written for clarity, not speed or constant time, and is useful as a test oracle for an optimized implementation.
Q = 3329
F = 3303 # 128^-1 mod Q
def bitrev7(i):
return int(f"{i:07b}"[::-1], 2)
ZETAS = [pow(17, bitrev7(i), Q) for i in range(128)] # FIPS 203 Alg. 9/10
GAMMAS = [pow(17, 2 * bitrev7(i) + 1, Q) for i in range(128)] # FIPS 203 Alg. 11
def ntt(f):
f, k, length = list(f), 1, 128
while length >= 2:
for start in range(0, 256, 2 * length):
zeta = ZETAS[k]; k += 1
for j in range(start, start + length):
t = zeta * f[j + length] % Q # Cooley-Tukey butterfly
f[j + length] = (f[j] - t) % Q
f[j] = (f[j] + t) % Q
length //= 2
return f
def intt(f):
f, k, length = list(f), 127, 2
while length <= 128:
for start in range(0, 256, 2 * length):
zeta = ZETAS[k]; k -= 1
for j in range(start, start + length):
t = f[j] # Gentleman-Sande butterfly
f[j] = (t + f[j + length]) % Q
f[j + length] = zeta * (f[j + length] - t) % Q
length *= 2
return [x * F % Q for x in f]
def multiply_ntts(a, b):
c = [0] * 256
for i in range(128):
a0, a1, b0, b1 = a[2*i], a[2*i+1], b[2*i], b[2*i+1]
c[2*i] = (a0 * b0 + a1 * b1 * GAMMAS[i]) % Q # mod X^2 - gamma
c[2*i + 1] = (a0 * b1 + a1 * b0) % Q
return c
# product of a and b in Z_q[X]/(X^256 + 1):
# intt(multiply_ntts(ntt(a), ntt(b)))Test it the obvious way: for random and , check that and that the NTT product equals a schoolbook negacyclic convolution. Both hold for the code above.
Montgomery and Barrett reduction
Production code never calls % on secret data, because hardware division can be slow and variable-time. Two reductions do the job with multiplications and shifts:
Montgomery
Keep values as with . A product is reduced to using . The reference Kyber code stores its zetas in Montgomery form so each butterfly needs one Montgomery multiplication.
Barrett
Approximate as with , then subtract that multiple of . It is used to pull coefficients back into range between layers, where lazy additions let values grow.
With Montgomery-form data, the final of the inverse NTT is merged with the factors into one constant. That is why the reference code uses a different number there than FIPS 203’s 3303.
Common implementation bugs
- Twiddles in natural order. Using instead of gives a transform that round-trips but multiplies wrongly.
- Wrong in the base case. The quadratic factors use odd powers, , and alternate in sign between neighbours.
- Missing or doubled scaling. Forgetting 3303, or applying it on top of a Montgomery constant that already includes it, scales the whole product.
- Overflow in lazy reduction. Skipping reductions between layers is a standard optimization. Bound the growth per layer, or 16-bit lanes overflow silently.
- Mixing domains. Adding a normal-domain polynomial to an NTT-domain one compiles fine and produces garbage. Keep the domain in the type.
- Only testing round-trips. passes for several of the bugs above. Always compare products with a schoolbook oracle and the official test vectors.
Constant-time considerations
The NTT itself has a fixed memory-access pattern that does not depend on data, which makes it easy to run in constant time. The risks are in the arithmetic around it: % or / on secrets, conditional subtractions written as branches, and compilers turning branch-free code back into branches. The KyberSlash timing issues (2023–2024) came from a division by on secret-dependent values in the compression code, not the NTT, which shows where to look. Use Barrett or Montgomery reduction everywhere, and check the compiled output with a tool such as dudect or valgrind-based checks for secret-dependent branching.
Comparison: the NTT in ML-DSA
ML-DSA (Dilithium) uses the same ring shape with . There , so a primitive 512th root exists (FIPS 204 uses ). ML-DSA therefore runs a complete 8-layer NTT down to 256 linear factors and multiplies purely pointwise, with no base case. Its coefficients need 32-bit arithmetic, while ML-KEM’s fit in 16 bits. The small modulus is the price of that compactness, and it is why ML-KEM’s transform stops one layer early.
Check Your Understanding
Q1. Why is the ML-KEM NTT “incomplete”, stopping at 128 degree-1 pieces instead of 256 single values?
Q2. In , what does the term become?
Q3. You multiply two polynomials by calling NTT on both, multiplying pointwise and calling the inverse NTT, but the result is wrong by a constant factor. What is the most likely bug?
Q4. Why does Dilithium / ML-DSA get a full 8-layer NTT while ML-KEM does not?
Further reading
- NIST, FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard, Section 4.3 and Algorithms 9–12.
- Longa and Naehrig, Speeding up the Number Theoretic Transform for Faster Ideal Lattice-Based Cryptography (2016).
- Lyubashevsky and Seiler, NTTRU: Truly Fast NTRU Using NTT (2019), on incomplete NTTs.
- The pq-crystals/kyber reference code:
ntt.candreduce.c.
Next, see how these products assemble into key generation and encapsulation in the ML-KEM chapter, or how the modulus is chosen in parameter selection.