Splitting works, but it leaves your data in a peculiar order and it keeps recomputing
the same angles. Both have surprisingly elegant fixes — and one of them is genuinely
beautiful. Let's tune in.
What You'll Build
Two tables that turn the recursive idea into fast in-place code: a bit-reversal permutation and
a twiddle-factor lookup table. Plus a measurement of exactly what the table buys you.
Learning Objectives
Discover that repeated even/odd splitting produces bit-reversed order
Build a bit-reversal permutation table
Reorder an array in place using only swaps
Precompute twiddle factors as roots of unity
Measure the speedup from table lookup versus recomputation
Explain why hoisting work out of a loop is the most reliable optimization
Concepts Introduced
ID
Concept
373
Bit Reversal Permutation
374
Index Reversal
375
Permutation Table
376
Roots Of Unity
377
Twiddle Factor Table
378
Precomputation
379
Lookup Table
380
Loop Invariant Hoisting
381
In Place Reordering
382
Swap Operation
383
Interleaved Storage
Background
The strange order
Split [0..7] into evens and odds, then split again, and again:
Final order: 0, 4, 2, 6, 1, 5, 3, 7. Looks arbitrary. It is not.
It's bit reversal
Write each index in binary and read it backwards:
index
binary
reversed
value
0
000
000
0
1
001
100
4
2
010
010
2
3
011
110
6
4
100
001
1
0, 4, 2, 6, 1, 5, 3, 7 — exactly the split order.
That's a gift. Instead of recursively slicing lists (allocating, copying, and generally being
slow), we reorder once at the start with a handful of swaps and then work in place forever
after.
Why bit reversal, intuitively
Each split sorts by one bit: the first asks "is the lowest bit 0 or 1?" (even or odd).
The next asks about the next bit up. After all the splits, the ordering is by lowest
bit first, highest bit last — which is exactly the ordinary ordering with the bits
reversed.
Roots of unity
The twiddle factors are evenly spaced points around the unit circle:
1
W_N^k = cos(-2πk/N) + i·sin(-2πk/N)
For an N-point FFT you need only N/2 of them. Compute them once; look them up forever.
# Lab 18: Bit Reversal and Twiddle Factors## Lab 17 showed that splitting into evens and odds saves half the work, and# that doing it repeatedly saves almost everything. But repeated splitting# leaves the data in a strange order -- and we would rather not shuffle# lists around at every level.## It turns out the final order has a beautiful description: each index is# the ORIGINAL index with its bits written backwards. So instead of# splitting the data over and over, we reorder ONCE at the start and then# work in place.## The other half of this lab: stop calling sin() and cos() millions of times.# There are only a few distinct angles. Compute them once, keep them in a# table, look them up. That is a TWIDDLE FACTOR TABLE.importconfigimportmathimporttime# =========================================================================# PART 1 -- where the strange order comes from# =========================================================================print("=== PART 1: what repeated splitting does to the order ===")N=8order=list(range(N))print("start :",order)level=1current=[order]whilelen(current[0])>1:nxt=[]forgroupincurrent:# MicroPython has no slice steps, so pick out evens and odds by handnxt.append([group[i]foriinrange(0,len(group),2)])# evensnxt.append([group[i]foriinrange(1,len(group),2)])# oddscurrent=nxtflat=[xforgincurrentforxing]print("after split %d : %s"%(level,flat))level+=1final=[xforgincurrentforxing]# =========================================================================# PART 2 -- that order IS bit reversal# =========================================================================print()print("=== PART 2: the pattern ===")bits=int(math.log(N,2)+0.5)print("%6s%10s%12s%8s"%("index","binary","reversed","value"))computed=[]foriinrange(N):b="".join(str((i>>(bits-1-p))&1)forpinrange(bits))r="".join(str((i>>p)&1)forpinrange(bits))# same bits, backwardsv=int(r,2)computed.append(v)print("%6d%10s%12s%8d"%(i,b,r,v))print()print("split order :",final)print("bit-reversed :",computed)print("same? :",final==computed)print()print("Write the index in binary, read it backwards, and you have the")print("position it belongs in. No recursion, no list shuffling -- just a")print("permutation we can do once, in place.")# =========================================================================# PART 3 -- doing the reorder in place# =========================================================================defbit_reverse_table(n):"""Precompute where each element belongs."""bits=0while(1<<bits)<n:bits+=1table=[]foriinrange(n):r=0x=ifor_inrange(bits):r=(r<<1)|(x&1)x>>=1table.append(r)returntabledefbit_reverse_in_place(data,table):"""Swap elements into bit-reversed order without copying the list."""swaps=0foriinrange(len(data)):j=table[i]ifj>i:# only swap each pair oncedata[i],data[j]=data[j],data[i]swaps+=1returnswapsprint()print("=== PART 3: reordering in place ===")data=list(range(N))table=bit_reverse_table(N)swaps=bit_reverse_in_place(data,table)print("after in-place reorder:",data)print("swaps performed : %d (not %d -- half the entries stay put or"%(swaps,N))print(" pair up with one already moved)")fornin(64,512):t=bit_reverse_table(n)s=sum(1foriinrange(n)ift[i]>i)print("N=%4d needs %3d swaps for %4d elements (%.0f%%)"%(n,s,n,100*s/n))# =========================================================================# PART 4 -- twiddle factors, computed once# =========================================================================print()print("=== PART 4: the twiddle factor table ===")print()print("A twiddle factor is a point on the unit circle:")print(" W_N^k = cos(-2*pi*k/N) + i*sin(-2*pi*k/N)")print()n=8print("The %d twiddle factors for N=%d:"%(n//2,n))print("%4s%10s%10s%10s"%("k","angle","cos","sin"))forkinrange(n//2):angle=-2*math.pi*k/nprint("%4d%10.4f%10.4f%10.4f"%(k,angle,math.cos(angle),math.sin(angle)))print()print("They march evenly around the circle -- these are the ROOTS OF UNITY.")print("For an N-point FFT you need only N/2 of them, ever.")deftwiddle_table(n):"""cos and sin for every twiddle we will need, computed once."""half=n//2tw_re=[0.0]*halftw_im=[0.0]*halfforkinrange(half):angle=-2*math.pi*k/ntw_re[k]=math.cos(angle)tw_im[k]=math.sin(angle)returntw_re,tw_im# =========================================================================# PART 5 -- how much does a lookup table buy?# =========================================================================print()print("=== PART 5: table lookup vs recomputing ===")n=256reps=20start=time.ticks_ms()for_inrange(reps):forkinrange(n//2):angle=-2*math.pi*k/nc=math.cos(angle)s=math.sin(angle)compute_ms=time.ticks_diff(time.ticks_ms(),start)tw_re,tw_im=twiddle_table(n)start=time.ticks_ms()for_inrange(reps):forkinrange(n//2):c=tw_re[k]s=tw_im[k]lookup_ms=time.ticks_diff(time.ticks_ms(),start)print("N = %d, %d passes over %d twiddles"%(n,reps,n//2))print(" recomputing sin/cos : %5d ms"%compute_ms)print(" table lookup : %5d ms"%lookup_ms)iflookup_ms>0:print(" speedup : %.1fx"%(compute_ms/lookup_ms))print()print("Building the table costs one pass. After that every lookup is free.")print("Move work OUT of the inner loop -- this is the single most reliable")print("optimization there is, and it costs only memory.")print()print("Bonus: remember Lab 15's precision problem? Recomputing")print("2*pi*k*t/N gave angles up to ~390 radians, which single-precision")print("floats handle badly. A table only ever holds angles in one turn of")print("the circle. Faster AND more accurate.")
Part 1 splits repeatedly; Part 2 shows the same order arising from reversing bits. The program
compares them and prints same? : True.
Step 2 — Reorder with swaps
123
after in-place reorder: [0, 4, 2, 6, 1, 5, 3, 7]
swaps performed : 2
N= 512 needs 240 swaps for 512 elements (47%)
Only about 47% of positions need a swap — the rest either map to themselves (0 and 7 above) or
have already been handled by their partner. The if j > i test is what stops us swapping every
pair twice and undoing our own work.
Swap each pair exactly once
Drop the if j > i and the loop swaps (1,4) and later (4,1), putting everything back
where it started. The array ends up unchanged and your FFT produces confident nonsense.
A classic, silent, deeply annoying bug.
Step 3 — Build the twiddle table
12345
k angle cos sin
0 0.0000 1.0000 0.0000
1 -0.7854 0.7071 -0.7071
2 -1.5708 0.0000 -1.0000
3 -2.3562 -0.7071 -0.7071
Four twiddles for an 8-point FFT, marching a quarter-turn at a time around the circle.
Step 4 — Measure the payoff
1234
N = 256, 20 passes over 128 twiddles
recomputing sin/cos : 131 ms
table lookup : 24 ms
speedup : 5.5x
5.5× on the twiddle work alone, for the cost of one setup pass and 128 floats of memory.
Step 5 — The bonus you already met
Remember Lab 15's precision problem? Recomputing 2πkt/N gave angles up to ~390 radians, which
single-precision floats handle badly.
A twiddle table only ever holds angles within one turn of the circle. So precomputation is
faster and more accurate. That's rare — most optimizations trade one for the other.
Reversal without a table. Write a function that bit-reverses an index directly with
shifts and masks. Compare its speed against the table for N = 512.
Memory cost. How many bytes does a 512-point twiddle table use as float32? As float64?
Set that against the 485 KB of RAM from Lab 3.
Interleaved storage. Store twiddles as [re0, im0, re1, im1, …] instead of two lists.
Why might that be faster on real hardware? (Lab 30 answers this properly.)
Check Your Understanding
What order does repeated even/odd splitting leave the data in?
Bit-reverse index 5 in a 16-element array.
Why does the swap loop need if j > i?
How many twiddle factors does a 512-point FFT need?
Name two separate benefits of precomputing the twiddle table.
The bookkeeping is done
Reorder once, look up the twiddles, work in place. All that's left is the operation at
the centre of it all — and it's only four multiplies.