Four labs of pieces, and today they click together into a real FFT. Then we do the part
that actually matters: check it against the DFT you already proved correct, and measure
what all that cleverness bought. Time to transform!
What You'll Build
A complete iterative radix-2 FFT in about twenty lines — validated against your Lab 15 test
suite and benchmarked against the Lab 16 DFT.
Learning Objectives
Assemble bit reversal, twiddle tables and butterflies into a working FFT
Validate a new implementation against one already proven correct
Measure speedup across several sizes
Explain why the measured gain exceeds the operation-count prediction
Distinguish an algorithm problem from a language problem
deffft(re,im,rev,tw_re,tw_im):n=len(re)# Lab 18: reorder once, in placeforiinrange(n):j=rev[i]ifj>i:re[i],re[j]=re[j],re[i]im[i],im[j]=im[j],im[i]# Lab 19: log2(n) stages of butterflieshalf=1whilehalf<n:step=n//(half*2)k=0whilek<n:j=0whilej<half:wr=tw_re[j*step]wi=tw_im[j*step]i1,i2=k+j,k+j+halftr=wr*re[i2]-wi*im[i2]ti=wr*im[i2]+wi*re[i2]ar,ai=re[i1],im[i1]re[i1],im[i1]=ar+tr,ai+tire[i2],im[i2]=ar-tr,ai-tij+=1k+=half*2half*=2
Three nested loops — stage, block, butterfly — wrapped around the eight lines from Lab 19.
That's it. That's the algorithm that made real-time signal processing possible.
Where does step come from?
Early stages use few distinct twiddles spaced far apart in the table; later stages use
many, spaced closely. step = n // (half*2) picks the right stride so each stage reads
exactly the twiddles it needs from one shared table.
# Lab 20: A Complete Python FFT## Time to put it together. Everything here you have already built:## Lab 17 split evens and odds, recombine with a twiddle# Lab 18 bit-reverse once, then work in place; precompute twiddles# Lab 19 the butterfly, and how stages are arranged## The whole FFT is about twenty lines. Then we do the important part:# check it against the DFT we already proved correct in Lab 15, and measure# how much faster it is than the one we timed in Lab 16.## Correctness first. Speed only counts if the answer is right.importconfigimportmathimporttime# =========================================================================# The FFT# =========================================================================defmake_tables(n):"""Precompute the bit-reversal permutation and the twiddle factors."""bits=0while(1<<bits)<n:bits+=1rev=[]foriinrange(n):r=0x=ifor_inrange(bits):r=(r<<1)|(x&1)x>>=1rev.append(r)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)returnrev,tw_re,tw_imdeffft(re,im,rev,tw_re,tw_im):"""In-place iterative radix-2 FFT. Modifies re and im directly."""n=len(re)# --- Lab 18: reorder once, in place -----------------------------------foriinrange(n):j=rev[i]ifj>i:re[i],re[j]=re[j],re[i]im[i],im[j]=im[j],im[i]# --- Lab 19: log2(n) stages of butterflies ----------------------------half=1whilehalf<n:step=n//(half*2)# how far apart the twiddles we need arek=0whilek<n:# for each blockj=0whilej<half:# for each butterfly in the blockwr=tw_re[j*step]wi=tw_im[j*step]i1=k+ji2=i1+halftr=wr*re[i2]-wi*im[i2]ti=wr*im[i2]+wi*re[i2]ar,ai=re[i1],im[i1]re[i1]=ar+trim[i1]=ai+tire[i2]=ar-trim[i2]=ai-tij+=1k+=half*2half*=2# =========================================================================# The DFT from Lab 14, kept as our reference# =========================================================================defdft(signal):n=len(signal)real,imag=[],[]forkinrange(n):rr=ii=0.0fortinrange(n):angle=2*math.pi*k*t/nrr+=signal[t]*math.cos(angle)ii-=signal[t]*math.sin(angle)real.append(rr)imag.append(ii)returnreal,imagdefmagnitudes(real,imag):return[math.sqrt(real[k]**2+imag[k]**2)forkinrange(len(real))]defrun_fft(signal):n=len(signal)rev,tw_re,tw_im=make_tables(n)re=list(signal)im=[0.0]*nfft(re,im,rev,tw_re,tw_im)returnre,im# =========================================================================# PART 1 -- does it agree with the DFT?# =========================================================================N=64RATE=config.SAMPLE_RATEBIN=RATE/Nsignal=[math.sin(2*math.pi*3*BIN*(i/RATE))+0.5*math.sin(2*math.pi*7*BIN*(i/RATE))foriinrange(N)]dft_mag=magnitudes(*dft(signal))fft_mag=magnitudes(*run_fft(signal))print("=== PART 1: FFT vs the DFT we already trust ===")print("%4s%12s%12s%12s"%("bin","DFT","FFT","difference"))worst=0.0forkinrange(N//2+1):d=abs(dft_mag[k]-fft_mag[k])ifd>worst:worst=difdft_mag[k]>0.5:print("%4d%12.4f%12.4f%12.2e"%(k,dft_mag[k],fft_mag[k],d))print()print("largest difference across all bins: %.3e"%worst)peak=max(dft_mag)print("as a fraction of the peak (%.1f): %.2e"%(peak,worst/peak))print()print("AGREEMENT CONFIRMED"ifworst/peak<1e-3else"*** MISMATCH ***")# =========================================================================# PART 2 -- the validation suite from Lab 15, now on the FFT# =========================================================================print()print("=== PART 2: the Lab 15 tests, run against the FFT ===")REL=1e-3checks=[]checks.append(("silence",[0.0]*N,lambdam:all(v<RELforvinm)))checks.append(("constant 0.5",[0.5]*N,lambdam:abs(m[0]-0.5*N)<REL*0.5*N))imp=[0.0]*Nimp[0]=1.0checks.append(("impulse",imp,lambdam:all(abs(v-1.0)<RELforvinm)))alt=[1.0ifi%2==0else-1.0foriinrange(N)]checks.append(("alternating +/-1",alt,lambdam:abs(m[N//2]-N)<REL*N))checks.append(("sine at bin 5",[math.sin(2*math.pi*5*i/N)foriinrange(N)],lambdam:abs(m[5]-N/2)<REL*N/2))passed=0forname,sig,checkinchecks:ok=check(magnitudes(*run_fft(sig)))print(" %-20s%s"%(name,"PASS"ifokelse"*** FAIL ***"))passed+=1ifokelse0print(" %d/%d passed"%(passed,len(checks)))# =========================================================================# PART 3 -- how much faster?# =========================================================================print()print("=== PART 3: speed ===")print("%6s%12s%12s%10s"%("N","DFT (ms)","FFT (ms)","speedup"))fornin(32,64,128,256):sig=[math.sin(2*math.pi*5*i/n)foriinrange(n)]start=time.ticks_ms()dft(sig)dft_ms=time.ticks_diff(time.ticks_ms(),start)rev,twr,twi=make_tables(n)re=list(sig)im=[0.0]*nstart=time.ticks_ms()fft(re,im,rev,twr,twi)fft_ms=time.ticks_diff(time.ticks_ms(),start)ratio=("%.0fx"%(dft_ms/fft_ms))iffft_ms>0else">100x"print("%6d%12d%12d%10s"%(n,dft_ms,fft_ms,ratio))# =========================================================================# PART 4 -- the deadline, revisited# =========================================================================print()print("=== PART 4: can we hit the deadline now? ===")n=512rev,twr,twi=make_tables(n)sig=[math.sin(2*math.pi*40*i/n)foriinrange(n)]re=list(sig)im=[0.0]*nstart=time.ticks_ms()fft(re,im,rev,twr,twi)ms=time.ticks_diff(time.ticks_ms(),start)budget=512/RATE*1000print("512-point FFT in pure Python : %d ms"%ms)print("real-time budget : %.0f ms"%budget)print("Lab 16's DFT estimate : 21196 ms")print()print("improvement over the DFT : %.0fx"%(21196/ms))ifms>budget:print("still over budget by : %.1fx"%(ms/budget))print()print("Enormous progress -- but pure Python is still too slow for")print("real time. The algorithm is now right; what remains is the")print("cost of the LANGUAGE. That is Module 6 and Module 7.")else:print("WE MADE IT with %.0f ms to spare."%(budget-ms))
12345678
bin DFT FFT difference
3 32.0000 32.0000 1.91e-06
7 16.0000 16.0000 1.91e-06
largest difference across all bins: 3.110e-05
as a fraction of the peak (32.0): 9.72e-07
AGREEMENT CONFIRMED
Two completely different algorithms, same answer to seven digits. That agreement is your
evidence. A fast FFT that disagrees with a proven DFT is just a fast way to be wrong.
Step 2 — Re-run the Lab 15 suite
123456
silence PASS
constant 0.5 PASS
impulse PASS
alternating +/-1 PASS
sine at bin 5 PASS
5/5 passed
The tests you wrote for the DFT work unchanged on the FFT, because both compute the same thing.
That's what a good test suite gets you — it outlives the implementation it was written for.
Notice the speedup grows with N: 12× at 32, 75× at 256. The DFT gets four times slower per
doubling; the FFT only about twice. The gap widens forever.
Step 4 — The deadline, revisited
123456
512-point FFT in pure Python : 145 ms
real-time budget : 40 ms
Lab 16's DFT estimate : 21196 ms
improvement over the DFT : 146x
still over budget by : 3.6x
From 530× too slow to 3.6× too slow. A 146× improvement from restructuring alone,
without a faster chip or a single line of assembly.
And yet — still short.
Being 3.6× away is a completely different problem
530× short means your approach is wrong. 3.6× short means your approach is right and
something else is costing you. That something is the language: every one of those
multiplies is an interpreted MicroPython operation. Modules 6 and 7 go after it, and
by Lab 31 this same algorithm runs in under a millisecond.
Step 5 — Predict, then measure
The operation count in Lab 17 predicted 57×. We measured 146×.
Prediction: why is the measured gain more than twice the predicted one?
Think about what else changed between the DFT and the FFT. (Hint: Lab 18 measured a separate
5.5× on one specific thing.)
Expected Output
See the tables above. Your milliseconds will vary; agreement with the DFT and the general shape
of the speedup should not.
Troubleshooting
Symptom
Likely cause
Fix
Peaks in the wrong bins
Bit reversal skipped or wrong
Reorder before the stages, and use if j > i
Magnitudes right, spectrum scrambled
Twiddle stride wrong
step = n // (half*2)
Output equals the input
Stage loop never ran
half must start at 1 and double
Disagrees only at high bins
Twiddle table too small
You need N/2 entries
MemoryError at N=512
Too many lists alive
Reuse buffers; don't rebuild tables per call
Challenges
Inverse FFT. Flip the twiddle sign and divide by N. Transform a signal, invert it, and
check you get the original back. Round-trip tests are powerful.
Hoist the tables. The program rebuilds tables on every call. Build them once and reuse.
How much does that save at N = 512?
Find the crossover. At what N does the FFT first beat the DFT? Below that size, is the
extra complexity worth it?
Check Your Understanding
What are the three nested loops in the FFT, from outside in?
Why validate the FFT against the DFT rather than just checking it looks right?
Why does the speedup grow as N grows?
The count predicted 57× and we measured 146×. Give one reason for the difference.
We're 3.6× from real time. Is that an algorithm problem or something else?
Module 4 complete — you built a real FFT
From "what's a frequency bin?" in Lab 14 to a working, validated, 146× faster FFT here.
Next module points it at the microphone — and you finally get to whistle at your
Pico and watch the peak move. That's the payoff.