mztyxbf
← Research

Tensor-Product Presentation of NTT and Constant-Geometry NTT

#math #hardware


Most iterative NTT hardware paper eventually hits the same wall: the butterflies are trivial, and the addressing is where all the design effort goes. Write an NTT as a nest of for loops and you get index expressions like j + (i << (k-l)) scattered across the datapath, changing shape at every stage. The loops are correct but they are not analyzable — you cannot look at them and see which memory access pattern falls out.

Tensor products fix this. They let you write the whole transform as a product of structured matrices, where each factor names one concrete hardware resource: a bank of butterflies, a permutation network, a ROM read. Once the algorithm is an algebraic object, restructuring the dataflow becomes matrix manipulation rather than index bookkeeping.

This note builds that machinery from scratch and then uses it to derive the constant-geometry NTT — the variant where the wiring between stages is literally the same permutation every stage. I follow the derivation in Zhang et al. (FCCM 2023), which presents this in an FHE accelerator context, and I flag at the end where the structure is actually much older than that paper.

Everything below I checked numerically (symbolic DFT over C\mathbb{C} up to N=64N=64, plus integer NTT mod qq); the figures are generated from the sparsity patterns of the actual matrices, not drawn by hand.

1. The two patterns that matter

For square matrices AM×MA_{M\times M} and BL×LB_{L\times L}, the tensor (Kronecker) product AMBLA_M \otimes B_L replaces every entry aija_{ij} of AA with the block aijBa_{ij}B, giving a matrix of order MLML.

The entire formalism rests on the fact that two special cases mean two different machines. Let x\mathbf{x} have length MLML.

(ILAM)xparallel processing(I_L \otimes A_M)\mathbf{x} \quad\longleftrightarrow\quad \textbf{parallel processing}

ILAMI_L \otimes A_M is block-diagonal with LL copies of AMA_M. To see what that demands of memory, you have to say explicitly how x\mathbf{x} is cut into the blocks the copies act on. Partition it into LL contiguous segments of length MM:

x=[x(0)x(1)x(L1)],x(p)=(xpM,  xpM+1,  ,  xpM+M1)T\mathbf{x} = \begin{bmatrix}\mathbf{x}^{(0)}\\ \mathbf{x}^{(1)}\\ \vdots\\ \mathbf{x}^{(L-1)}\end{bmatrix}, \qquad \mathbf{x}^{(p)} = \bigl(x_{pM},\;x_{pM+1},\;\dots,\;x_{pM+M-1}\bigr)^{\mathsf T}

so segment pp starts at offset pMpM and walks with stride 11. Under that blocking the product is literally the segments processed one by one:

(ILAM)x=[AMx(0)AMx(1)AMx(L1)]y[pM+i]=j=0M1A[i,j]x[pM+j](I_L \otimes A_M)\,\mathbf{x} = \begin{bmatrix}A_M\mathbf{x}^{(0)}\\ A_M\mathbf{x}^{(1)}\\ \vdots\\ A_M\mathbf{x}^{(L-1)}\end{bmatrix} \qquad\Longleftrightarrow\qquad y[pM + i] = \sum_{j=0}^{M-1} A[i,j]\,x[pM + j]

The index pp never appears inside the sum. Segment pp is closed under the operator — PE pp reads only x(p)\mathbf{x}^{(p)}, writes only x(p)\mathbf{x}^{(p)}, and never talks to its neighbours. That is LL processors doing the same job on adjacent data, and if you give each PE its own memory bank holding one segment, no two PEs ever address the same bank.

The second pattern is easier to build in the opposite direction: start from the operation you want, and let the matrix fall out of it.

Cut x\mathbf{x} the other way — not LL segments of length MM, but MM subvectors of length LL:

u(j)=(xjL,  xjL+1,  ,  xjL+L1)T,j=0,,M1\mathbf{u}^{(j)} = \bigl(x_{jL},\;x_{jL+1},\;\dots,\;x_{jL+L-1}\bigr)^{\mathsf T}, \qquad j = 0,\dots,M-1

Now treat each subvector as though it were a single number, and combine the whole set of them using the coefficients of AMA_M. Output subvector ii is the weighted sum of all MM input subvectors, the weights being row ii of AA:

z(i)  =  j=0M1A[i,j]u(j),i=0,,M1()\mathbf{z}^{(i)} \;=\; \sum_{j=0}^{M-1} A[i,j]\,\mathbf{u}^{(j)}, \qquad i = 0,\dots,M-1 \tag{$*$}

There is no matrix product here yet — ()(*) is just MM scalar-times-vector products added together, done MM times over. Note the roles: MM is the size of the transform, and LL is how many vectors ride through it together, the width of the machine.

To turn ()(*) into a single operator on the stacked x\mathbf{x}, use the one observation that does all the work: scaling a length-LL vector by the scalar A[i,j]A[i,j] is the same as multiplying it by the L×LL \times L matrix A[i,j]ILA[i,j]\,I_L. So one output block is a row of such matrices applied to x\mathbf{x},

z(i)=[A[i,0]ILA[i,1]ILA[i,M1]IL]x\mathbf{z}^{(i)} = \begin{bmatrix} A[i,0]\,I_L & A[i,1]\,I_L & \cdots & A[i,M-1]\,I_L \end{bmatrix} \mathbf{x}

and stacking all MM block rows gives the whole thing:

[z(0)z(M1)]=[A[0,0]ILA[0,M1]ILA[M1,0]ILA[M1,M1]IL][u(0)u(M1)]\begin{bmatrix}\mathbf{z}^{(0)}\\[2pt] \vdots\\[2pt] \mathbf{z}^{(M-1)}\end{bmatrix} = \begin{bmatrix} A[0,0]\,I_L & \cdots & A[0,M-1]\,I_L\\[2pt] \vdots & \ddots & \vdots\\[2pt] A[M-1,0]\,I_L & \cdots & A[M-1,M-1]\,I_L \end{bmatrix} \begin{bmatrix}\mathbf{u}^{(0)}\\[2pt] \vdots\\[2pt] \mathbf{u}^{(M-1)}\end{bmatrix}

Now look at what that matrix is. Every entry A[i,j]A[i,j] of AMA_M has been replaced by the block A[i,j]ILA[i,j]\,I_L — which is precisely the tensor product defined at the top of this section, taken with B=ILB = I_L. The operator that forms weighted sums of whole subvectors is the tensor product:

(AMIL)xvector processing(A_M \otimes I_L)\mathbf{x} \quad\longleftrightarrow\quad \textbf{vector processing}

Read by index, ()(*) says

z[iL+p]=j=0M1A[i,j]x[jL+p]z[iL + p] = \sum_{j=0}^{M-1} A[i,j]\,x[jL + p]

and the position pp is untouched — it sits identically on both sides of the sum. Element pp of every subvector is combined only with element pp of the others, never with the elements beside it. Collect those matching positions and you get LL vectors of length MM,

vp  =  (u(0)[p],  u(1)[p],  ,  u(M1)[p])T  =  (xp,  xp+L,  ,  xp+(M1)L)T\mathbf{v}_p \;=\; \bigl(\mathbf{u}^{(0)}[p],\;\mathbf{u}^{(1)}[p],\;\dots,\;\mathbf{u}^{(M-1)}[p]\bigr)^{\mathsf T} \;=\; \bigl(x_{p},\;x_{p+L},\;\dots,\;x_{p+(M-1)L}\bigr)^{\mathsf T}

one for each p=0,,L1p = 0,\dots,L-1. Run that relation backwards and it says the same thing from the other side: subvector u(j)\mathbf{u}^{(j)} collects the jj-th element of each of the LL vectors — the first subvector is built from the first element of every vp\mathbf{v}_p, the second from every second element, and so on.

In those terms the whole operation is just AMvp=vpA_M\mathbf{v}_p = \mathbf{v}'_p, one vector at a time. Concretely at p=0p=0: gather the first element of each of the MM subvectors, multiply that length-MM vector by each row of AMA_M in turn, and the MM numbers that come out are the first elements of the MM output subvectors. Then repeat for p=1,,L1p=1,\dots,L-1. So LL separate MM-point operations — one per vector, each reading with stride LL.

That is the whole difference. Both forms are LL independent copies of AMA_M; the only question is which MM elements one copy sees — offset pMpM with stride 11 above, offset pp with stride LL here.

Left: the parallel form is block-diagonal and groups x into contiguous segments. Right: the vector form scatters, grouping x with stride L. Both are L independent copies of A_M; only the grouping differs.

The figure makes the point better than the algebra does. Both panels contain exactly LL copies of AMA_M and do exactly the same arithmetic. What changes is the access pattern: contiguous runs on the left, interleaved vectors on the right. Same operator AA, same data, completely different memory behaviour — and on hardware that difference is a conflict-free bank read versus a strided gather. The whole game is choosing which form you want at each stage.

One more identity we need for scalability. If you have fewer than LL processors, split the identity:

ILAM=IL1(IL2AM),L=L1L2(1)I_L \otimes A_M = I_{L_1} \otimes (I_{L_2} \otimes A_M), \qquad L = L_1 L_2 \tag{1}

Read right to left: the inner IL2AMI_{L_2}\otimes A_M is a chunk of work, and you hand L1L_1 of those chunks to L1L_1 physical processors. This single line is why the resulting architecture scales to any PE count — more on that in §7.

2. Stride permutation

The data movement operator is the stride permutation P(N,M)P(N,M), with N=MLN = ML. Conventions differ between authors, so let me pin mine down explicitly:

(P(N,M)x)[iL+j]=x[jM+i],0i<M,  0j<L\bigl(P(N,M)\,\mathbf{x}\bigr)[\,iL + j\,] = \mathbf{x}[\,jM + i\,], \qquad 0\le i< M,\; 0\le j< L

In words: read x\mathbf{x} with stride MM, LL elements at a time. Output block ii is (xi,xM+i,x2M+i,)(x_i, x_{M+i}, x_{2M+i},\dots). For N=8,M=2N=8, M=2 this is the perfect shuffle — evens then odds:

P(8,2):(x0,,x7)    (x0,x2,x4,x6,  x1,x3,x5,x7)P(8,2):\quad (x_0,\dots,x_7) \;\longmapsto\; (x_0,x_2,x_4,x_6,\;x_1,x_3,x_5,x_7)

Three properties carry the derivation.

Factorization. A stride splits into two strides:

P(N,M)=P(N,M1)P(N,M2),M=M1M2(2)P(N,M) = P(N,M_1)\,P(N,M_2), \qquad M = M_1M_2 \tag{2}

Inverse. Taking M1=MM_1 = M, M2=N/MM_2 = N/M and noting P(N,N)=IP(N,N)=I:

P(N,M)P(N,N/M)=INP(N,M)\,P(N,N/M) = I_N

so strides MM and N/MN/M undo each other. This is what makes the twiddle matrix stay diagonal in §5.

Commutation theorem. This is the bridge between the two processing styles:

P(N,M)(ILAM)=(AMIL)P(N,M),N=ML(3)P(N,M)\,(I_L \otimes A_M) = (A_M \otimes I_L)\,P(N,M), \qquad N = ML \tag{3}

Parallel form and vector form compute the same thing up to a stride permutation. Any algorithm expressed in tensor products can therefore be retargeted — pushed toward parallel or toward vector — by moving permutations around. That is the lever we pull in §4.

3. NTT as a matrix factorization

The NTT of length NN over Zq\mathbb{Z}_q maps coefficients (x0,,xN1)(x_0,\dots,x_{N-1}) by

y(m)=n=0N1xnω0mnmodqy(m) = \sum_{n=0}^{N-1} x_n\,\omega_0^{mn} \bmod q

with ω0\omega_0 a primitive NN-th root of unity mod qq (so ω0N1\omega_0^N \equiv 1). Collecting this into a matrix, with k=log2Nk=\log_2 N:

NTT(x)=F(2k)xmodq(4)\mathrm{NTT}(\mathbf{x}) = F(2^k)\,\mathbf{x} \bmod q \tag{4}

It is the DFT with e2πi/Ne^{-2\pi i/N} swapped for ω0\omega_0 — same algebra, no rounding error. So every Cooley–Tukey-style factorization transfers verbatim; only the coefficient ring changes.

For the twiddles, define the diagonal matrix

DR(S)=diag(1,ω,ω2,,ωR1)(6)D_R(S) = \mathrm{diag}(1,\omega,\omega^2,\dots,\omega^{R-1}) \tag{6}

where ω\omega is a primitive SS-th root of unity. The general Cooley–Tukey construction stacks S/RS/R powers of DR(S)D_R(S) down the diagonal, but radix 2 never needs that generality. The only twiddle matrix (5) below ever instantiates is T2kl(2kl+1)T_{2^{k-l}}(2^{k-l+1}), and 2kl+1=22kl2^{k-l+1} = 2\cdot 2^{k-l}, so S=2RS = 2R at every stage and there are always exactly two blocks:

TR(2R)=diag(IR,  DR(2R))=diag(1,  1,  ,  1R,  1,  ω,  ,  ωR1R)(7)T_R(2R) = \mathrm{diag}\bigl(I_R,\;D_R(2R)\bigr) = \mathrm{diag}(\,\underbrace{1,\;1,\;\dots,\;1}_{R},\;\underbrace{1,\;\omega,\;\dots,\;\omega^{R-1}}_{R}\,) \tag{7}

That is just the twiddle structure of a decimation-in-time stage written out: the first half of the coefficients pass through untouched, the second half pick up ω0,,ωR1\omega^0,\dots,\omega^{R-1}.

The decimation-in-time factorization into radix-2 butterflies F(2)=[1111]F(2) = \left[\begin{smallmatrix}1&1\\1&-1\end{smallmatrix}\right] then reads

F(2k)=[l=1k(I2l1F(2)I2kl)(I2l1T2kl(2kl+1))]Q(2k)(5)F(2^k) = \left[\prod_{l=1}^{k}\bigl(I_{2^{l-1}} \otimes F(2) \otimes I_{2^{k-l}}\bigr)\bigl(I_{2^{l-1}} \otimes T_{2^{k-l}}(2^{k-l+1})\bigr)\right] Q(2^k) \tag{5}

with Q(2k)Q(2^k) the bit-reversal permutation applied to the input. Write Z0(l)=I2l1T2kl(2kl+1)Z_0(l) = I_{2^{l-1}} \otimes T_{2^{k-l}}(2^{k-l+1}) for the stage-ll twiddle matrix.

The product runs l=1kl=1\ldots k left to right, so applied to a vector the stage l=kl=k executes first. Keep this in mind — it is why the figures below run l=3,2,1l=3,2,1.

A worked example: N=8N = 8

Take k=3k = 3, so three stages. Everything below stays in terms of ω0\omega_0 alone — a primitive 88-th root of unity mod qq — so none of it depends on which qq you pick.

At stage ll we have R=2klR = 2^{k-l}, S=2RS = 2R, and the order-SS root is ω=ω02l1\omega = \omega_0^{2^{l-1}}. Writing out (6) and (7) stage by stage:

llrunsRRSSω\omegaTR(2R)T_R(2R)Z0(l)=I2l1TR(2R)Z_0(l) = I_{2^{l-1}} \otimes T_R(2R)
31st12ω04\omega_0^4(1,  1)(1,\;1)(1,1,1,1,1,1,1,1)(1,1,1,1,1,1,1,1)
22nd24ω02\omega_0^2(1,1,  1,ω02)(1,1,\;1,\omega_0^2)(1,1,1,ω02,    1,1,1,ω02)(1,1,1,\omega_0^2,\;\;1,1,1,\omega_0^2)
13rd48ω0\omega_0(1,1,1,1,  1,ω0,ω02,ω03)(1,1,1,1,\;1,\omega_0,\omega_0^2,\omega_0^3)(1,1,1,1,  1,ω0,ω02,ω03)(1,1,1,1,\;1,\omega_0,\omega_0^2,\omega_0^3)

Two things fall straight out. The stage that executes first has R=1R = 1, so T1(2)=I2T_1(2) = I_2 — no twiddles at all, which is the familiar fact that the opening decimation-in-time stage is pure add/subtract. And the twiddle set doubles every stage until the last one carries the full ω00,,ω03\omega_0^0,\dots,\omega_0^3.

The butterfly operator changes shape just as fast. I2l1F(2)I2klI_{2^{l-1}} \otimes F(2) \otimes I_{2^{k-l}} pairs coefficients at distance 2kl2^{k-l}:

lloperatorpairsstride
3I4F(2)I_4 \otimes F(2)(0,1)  (2,3)  (4,5)  (6,7)(0,1)\;(2,3)\;(4,5)\;(6,7)1
2I2F(2)I2I_2 \otimes F(2) \otimes I_2(0,2)  (1,3)  (4,6)  (5,7)(0,2)\;(1,3)\;(4,6)\;(5,7)2
1F(2)I4F(2) \otimes I_4(0,4)  (1,5)  (2,6)  (3,7)(0,4)\;(1,5)\;(2,6)\;(3,7)4

Bit reversal Q(8)Q(8) first puts the input in the order

xˉ=(x0,  x4,  x2,  x6,  x1,  x5,  x3,  x7)T\bar{\mathbf{x}} = (x_0,\;x_4,\;x_2,\;x_6,\;x_1,\;x_5,\;x_3,\;x_7)^{\mathsf T}

and because stage l=3l=3 carries no twiddles, it is nothing but four 2-point transforms on adjacent pairs:

(x0+x4,    x0x4,    x2+x6,    x2x6,    x1+x5,    x1x5,    x3+x7,    x3x7)(\,x_0{+}x_4,\;\; x_0{-}x_4,\;\; x_2{+}x_6,\;\; x_2{-}x_6,\;\; x_1{+}x_5,\;\; x_1{-}x_5,\;\; x_3{+}x_7,\;\; x_3{-}x_7\,)

Stages l=2l=2 and l=1l=1 then merge those four 2-point transforms into two 4-point ones and finally a single 8-point one, picking up ω02\omega_0^2 and then ω0,ω02,ω03\omega_0,\omega_0^2,\omega_0^3 along the way. That last column of the second table — stride 1,2,41, 2, 4 — is the whole difficulty, and it is what the next section has to get rid of.

Now look at the operator in the middle:

I2l1F(2)I2klI_{2^{l-1}} \otimes F(2) \otimes I_{2^{k-l}}

This is neither of our two clean patterns. It is a mixed three-factor product: 2l12^{l-1} independent copies of the vector operation F(2)I2klF(2)\otimes I_{2^{k-l}}. Worse, both exponents move with ll. At l=1l=1 it is one wide vector operation; at l=kl=k it is N/2N/2 independent scalar butterflies. A fixed piece of hardware cannot be both.

4. Flattening to pure parallelism

Getting rid of the mixed form is exactly what stride permutations are for. The three-factor identity is

IN1AN2IN3=P(N,N1N2)(IN1N3AN2)P(N,N3)(8)I_{N_1} \otimes A_{N_2} \otimes I_{N_3} = P(N,N_1N_2)\,\bigl(I_{N_1N_3} \otimes A_{N_2}\bigr)\,P(N,N_3) \tag{8}

with N=N1N2N3N = N_1N_2N_3. The mixed operator becomes a pure parallel operator sandwiched between two permutations. Substituting into (5) with N1=2l1N_1 = 2^{l-1}, N2=2N_2 = 2, N3=2klN_3 = 2^{k-l}:

NTT(x)=[l=1kP(2k,2l)(I2k1F(2))P(2k,2kl)Z0(l)]xˉmodq(9)\mathrm{NTT}(\mathbf{x}) = \left[\prod_{l=1}^{k} P(2^k,2^l)\bigl(I_{2^{k-1}} \otimes F(2)\bigr)P(2^k,2^{k-l})\,Z_0(l)\right]\bar{\mathbf{x}} \bmod q \tag{9}

This is real progress: the compute operator I2k1F(2)I_{2^{k-1}} \otimes F(2) is now the same at every stageN/2N/2 independent radix-2 butterflies, the ideal parallel form.

But the addressing got worse. Each stage now carries two permutations, and both depend on ll:

Dataflow of equation (9) for N=8: the permutation network differs at every stage.

Three stages, three different wiring patterns, two readdressing passes each. In hardware that means either a general-purpose interconnect (expensive, and it degrades badly as NN and PE count grow) or reconfiguring the address generation per stage. This is the memory-access bottleneck that dominates scalable NTT designs.

5. Deriving constant geometry

Here is the trick. Unfold (9) along ll and look at the boundaries between adjacent stages:

NTT(x)=  [P(2k,2)(IF(2))P(2k,2k1)Z0(1)][P(2k,2l)(IF(2))P(2k,2kl)Z0(l)][P(2k,2k)(IF(2))P(2k,20)Z0(k)]xˉmodq(10)\begin{aligned} \mathrm{NTT}(\mathbf{x}) = \;&\bigl[P(2^k,2)(I\otimes F(2))P(2^k,2^{k-1})Z_0(1)\bigr]\cdots\\ &\bigl[P(2^k,2^{l})(I\otimes F(2))P(2^k,2^{k-l})Z_0(l)\bigr]\cdots\\ &\bigl[P(2^k,2^{k})(I\otimes F(2))P(2^k,2^{0})Z_0(k)\bigr]\,\bar{\mathbf{x}} \bmod q \end{aligned} \tag{10}

The leading permutation of stage ll is P(2k,2l)P(2^k,2^l). Split it with rule (2):

P(2k,2l)=P(2k,2l1)P(2k,2)P(2^k,2^l) = P(2^k,2^{l-1})\,P(2^k,2)

Only P(2k,2)P(2^k,2) — a plain perfect shuffle, no ll in it — stays with stage ll. The leftover P(2k,2l1)P(2^k,2^{l-1}) is pushed left, out of stage ll and onto the tail of stage l1l-1. Do this at every boundary and the shifted factors telescope:

NTT(x)=  [P(2k,2)(IF(2))P(2k,2k1)Z0(1)P(2k,2)][P(2k,2)(IF(2))P(2k,2kl)Z0(l)P(2k,2l)][P(2k,2)(IF(2))P(2k,20)Z0(k)P(2k,2k)]xˉmodq(11)\begin{aligned} \mathrm{NTT}(\mathbf{x}) = \;&\bigl[P(2^k,2)(I\otimes F(2))P(2^k,2^{k-1})Z_0(1)P(2^k,2)\bigr]\cdots\\ &\bigl[P(2^k,2)(I\otimes F(2))P(2^k,2^{k-l})Z_0(l)P(2^k,2^{l})\bigr]\cdots\\ &\bigl[P(2^k,2)(I\otimes F(2))P(2^k,2^{0})Z_0(k)P(2^k,2^{k})\bigr]\,\bar{\mathbf{x}} \bmod q \end{aligned} \tag{11}

Every stage now has an identical prefix P(2k,2)(I2k1F(2))P(2^k,2)(I_{2^{k-1}}\otimes F(2)), and the ll-dependence has been squeezed into the region around the twiddle matrix. Absorb it:

  NTT(x)=[l=1kP(2k,2)(I2k1F(2))Z2k,l]Q(2k)xmodq  (12)\boxed{\;\mathrm{NTT}(\mathbf{x}) = \left[\prod_{l=1}^{k} P(2^k,2)\,\bigl(I_{2^{k-1}} \otimes F(2)\bigr)\,Z_{2^k,l}\right] Q(2^k)\,\mathbf{x} \bmod q\;} \tag{12} Z2k,l=P(2k,2kl)Z0(l)P(2k,2l)(13)Z_{2^k,l} = P(2^k,2^{k-l})\,Z_0(l)\,P(2^k,2^{l}) \tag{13}

Why Z2k,lZ_{2^k,l} is still diagonal. Because 2kl2l=2k2^{k-l}\cdot 2^{l} = 2^k, the inverse property from §2 gives P(2k,2l)=P(2k,2kl)1P(2^k,2^{l}) = P(2^k,2^{k-l})^{-1}. So (13) is a similarity transform of a diagonal matrix by a permutation:

PDP1=diag(Pd)for D=diag(d)P D P^{-1} = \mathrm{diag}(P\mathbf{d}) \quad\text{for } D = \mathrm{diag}(\mathbf{d})

Conjugating a diagonal matrix by a permutation just reorders its diagonal — it can never introduce off-diagonal mass. Z2k,lZ_{2^k,l} is diagonal, therefore it moves no data. It is a pure element-wise multiply.

That is the whole point. All stage-dependent data movement has been converted into stage-dependent twiddle-factor selection. Coefficients now flow through the same fixed shuffle every stage; only the ROM addresses change.

Dataflow of equation (12) for N=8: identical P(2^k,2) wiring at every stage, with twiddle exponents annotated.

Compare against the previous figure: same butterflies, same number of stages, but one wiring pattern instead of three. This is a constant-geometry algorithm.

The trade is favourable because it is asymmetric: coefficient movement costs you on both the read and the write side every stage, whereas twiddle selection is a read-only lookup in the multiplier stage that never has to relocate anything in memory.

6. Collapsing the twiddle storage

Constant geometry is only a win if the twiddle addressing does not become the new mess. It doesn’t — it collapses to one line of index arithmetic.

Let z0\mathbf{z}_0 be the diagonal of Z0(l)Z_0(l). From (6) and (7) it is 2l12^{l-1} repetitions of a T2kl(2kl+1)T_{2^{k-l}}(2^{k-l+1}) block:

z0=(  1,1,,12kl,  1,ω,ω2,,ω2kl12kl,    )(14)\mathbf{z}_0 = (\;\underbrace{1,1,\dots,1}_{2^{k-l}},\;\underbrace{1,\omega,\omega^2,\dots,\omega^{2^{k-l}-1}}_{2^{k-l}},\;\dots\;) \tag{14}

with ω\omega primitive of order 2kl+12^{k-l+1}. By the similarity argument the diagonal of Z2k,lZ_{2^k,l} is P(2k,2kl)z0P(2^k,2^{k-l})\mathbf{z}_0 — segment z0\mathbf{z}_0 into 2l2^{l} groups and deal one element from each group round-robin. The result interleaves into

z=(  1,,12l,  1,ω,,1,ω2l,  ,  1,ω2kl1,2l  )(15)\mathbf{z} = (\;\underbrace{1,\dots,1}_{2^{l}},\;\underbrace{1,\omega,\dots,1,\omega}_{2^{l}},\;\dots,\;\underbrace{1,\omega^{2^{k-l}-1},\dots}_{2^{l}}\;) \tag{15}

Two things fall out.

One root for all stages. ω\omega has order 2kl+12^{k-l+1} while ω0\omega_0 has order 2k2^k, so

ωω02l1(modq)\omega \equiv \omega_0^{2^{l-1}} \pmod q

Every stage’s twiddles are powers of the same ω0\omega_0. No per-stage root tables.

A closed-form address. Every even-indexed coefficient pairs with ω0=1\omega^0 = 1. For odd index tt at stage ll, the required exponent is

Zr=t2l2l1(16)Z_r = \left\lfloor \frac{t}{2^l} \right\rfloor 2^{l-1} \tag{16}

Since Zr<2k1Z_r < 2^{k-1} always, a single table of N/2N/2 powers ω00,,ω0N/21\omega_0^0,\dots,\omega_0^{N/2-1} — stored once, in order, never rewritten — serves the entire transform. The exponents in the second figure are computed from (16); I verified it reproduces the exact diagonal of (13) for all stages up to N=64N=64.

So the “hard” part of the memory access pattern reduces to a shift and a mask.

7. What this buys in hardware

Scalable PE count. Split the butterfly bank using identity (1). With α\alpha PEs and β=2k1/α\beta = 2^{k-1}/\alpha:

I2k1F(2)=Iα(IβF(2))I_{2^{k-1}} \otimes F(2) = I_\alpha \otimes \bigl(I_\beta \otimes F(2)\bigr)

Each PE handles β\beta butterflies. α\alpha is now a free parameter — the same algorithm covers a 1-PE area-minimal design and a 64-PE throughput design with no restructuring. Each stage takes β\beta cycles, so latency is N2αlog2N\approx \frac{N}{2\alpha}\log_2 N butterfly slots.

Fixed interconnect. P(2k,2)P(2^k,2) never changes, so read/write addressing is generated once at elaboration time from NN and α\alpha. The control unit shrinks to a counter plus (16) — which is what actually makes routing tractable as α\alpha grows, since a stage-varying permutation network is what usually caps the achievable frequency.

Ping-pong memory. The transform is not in-place under a fixed shuffle, so you need two banks, alternating by the parity of ll. That is the honest cost of constant geometry: 2N2N coefficient storage instead of NN. You are buying wiring simplicity with memory.

The paper’s Algorithm 1, which is (12) written as loops:

k = log2(N);  beta = 2^(k-1) / alpha
x = bit_reverse(x)
for l = k down to 1:
    for i = 0 to alpha-1:          # parallel across PEs
        for j = 0 to beta-1:       # sequential within a PE
            d  = i*beta + j
            w  = w0 ^ ( floor((2*d+1) / 2^l) * 2^(l-1) )    # eq (16)
            v[d]           = x[2*d] + x[2*d+1] * w   mod q
            v[d + 2^(k-1)] = x[2*d] - x[2*d+1] * w   mod q
    x = v
return x

The write pattern d and d + 2^(k-1) is P(2k,2)P(2^k,2) — the shuffle never appears as an explicit permutation step, it is just where the two butterfly outputs get written. I implemented this against a naive O(N2)O(N^2) NTT mod qq for N{8,16,32,64}N \in \{8,16,32,64\} and α{1,2,N/2}\alpha \in \{1,2,N/2\}; all match.

8. Two caveats

This is Pease’s algorithm. Zhang et al. state theirs is “the first work to apply tensor products to NTT designs.” The tensor-product formalism for FFTs is standard and long-established — Tolimieri, An & Lu is the canonical treatment, and it is the paper’s own reference [25]; the SPIRAL project is built on it. More specifically, equation (12) is the Pease FFT (1968), transplanted from C\mathbb{C} to Zq\mathbb{Z}_q: constant geometry with a fixed stride permutation and bit-reversed input is precisely what Pease derived. That does not diminish the accelerator — applying it to FHE-scale parameters with a scalable PE count and getting good silicon out is real work — but the derivation in §4–§6 is classical FFT theory, and it is worth knowing that so you can read the older literature for the variants.

This is the cyclic NTT, not the negacyclic one. Algorithm 1 advertises Z[x]/(xN+1)\mathbb{Z}[x]/(x^N+1) but computes the plain length-NN cyclic transform — I confirmed this by testing against the cyclic definition, which is what it matches. RLWE-based FHE needs negacyclic convolution, which requires folding in powers of ψ\psi with ψ2=ω0\psi^2 = \omega_0: pre-scale xnψnxnx_n \mapsto \psi^n x_n, post-scale by ψm\psi^{-m}, or use the standard merged-ψ\psi butterflies that absorb the scaling into the twiddle tables. That last option is what real designs do, and it changes the twiddle table contents but not the geometry — so §5 survives intact. Worth stating explicitly, because the omission is easy to miss.

Where I want to take this next: the same manipulation with a different split point gives the four-step / six-step decompositions, which is how you get NTTs whose working set fits in on-chip memory at FHE parameter sizes. Same algebra, different factorization — which is the real argument for the tensor-product view.

References