This document describes five progressively optimized architectures for a 100-tap FIR filter targeting FPGA implementation. All five compute the same mathematical function:
They differ in how the computation is scheduled across time and hardware, trading off between resource usage and throughput.
| Architecture | Multipliers | Cycles per Output | Relative Throughput |
|---|---|---|---|
| Serial | 1 | 102 | 1x |
| L=2 Parallel (reduced complexity) | 3 | 26 | ~3.9x |
| L=3 Parallel (reduced complexity) | 6 | 12 | ~8.5x |
| Pipelined + L=3 Parallel | 6 | ~12.3 (at higher f_clk) | Highest at f_clk |
| Systolic Array | 100 | 1 | Highest throughput |
Each design is organized in its own directory under rtl/ and includes a top-level wrapper (*_top.sv) that instantiates all sub-modules (filter core, coefficient ROM, etc.) with internal wiring, exposing only the user-facing I/O. All designs share a common coefficient ROM (coeff_rom.sv) that stores 100 quantized coefficients in Q1.15 signed fixed-point format, initialized directly in the SystemVerilog source via an initial block. Each design directory contains its own copy of the ROM.
The filter is designed entirely in MATLAB using the designfilt function with the equiripple (Parks-McClellan) method. The target specifications are:
| Parameter | Value |
|---|---|
| Filter type | Lowpass FIR |
| Filter order | 99 (100 taps) |
| Passband edge | 0.2 (normalized, i.e. 0.2 pi rad/sample) |
| Stopband edge | 0.23 (normalized, i.e. 0.23 pi rad/sample) |
| Stopband attenuation | 80 dB |
| Sample rate | 2 (normalized Nyquist = 1) |
The equiripple method distributes the approximation error uniformly across both the passband and stopband, producing a minimax-optimal design for the given order and band edges.
The figure below shows the magnitude and phase response of the 100-tap ideal (floating-point) filter as produced by MATLAB's fvtool:
The design achieves the target 80 dB stopband attenuation with a sharp transition band between 0.2 pi and 0.23 pi rad/sample. The filter coefficients are symmetric (linear phase), resulting in a purely linear phase response across the passband.
For hardware implementation, the floating-point coefficients must be quantized to a fixed-point format. The project uses Q1.15 signed fixed-point:
- 1 sign bit + 15 fractional bits (16 bits total)
- Scale factor: 2^15 = 32768
- Representable range: [-1.0, +0.999969...]
- Resolution (LSB): ~3.05 x 10^-5
The quantization process in the MATLAB script:
- Scale: multiply each floating-point coefficient by 2^15
- Round: round to the nearest integer
- Saturate: clamp to the signed 16-bit range [-32768, +32767]
- Convert back: divide by 2^15 to obtain the quantized floating-point equivalent
These integer values are exactly what is embedded in the hardware coefficient ROM (coeff_rom.sv).
The figure below overlays the ideal and quantized frequency responses, with a separate subplot showing the quantization error magnitude across frequency:
The top subplot shows that the quantized (Q1.15) response closely tracks the ideal response — the two curves are visually indistinguishable in the passband and transition band. The bottom subplot isolates the quantization error, confirming that the 16-bit representation introduces negligible degradation relative to the 80 dB stopband specification.
The script exports two CSV files:
filter_taps.csv— ideal floating-point coefficients (64-bit precision)filter_taps_quantized.csv— quantized Q1.15 coefficients (as floating-point values)
The quantized CSV can be cross-checked against the integer values in the hardware ROM to verify consistency between the MATLAB model and the RTL implementation.
The serial architecture is the simplest possible implementation. It uses one multiplier and one accumulator (a single MAC unit) and processes all 100 taps sequentially, one per clock cycle.
┌─────────────┐
data_in ───────> │ Shift Reg │ (100 entries)
│ (Delay Line)│
└──────┬──────┘
│ delay_line[tap_cnt]
v
┌──────────────┐ ┌───────────┐
│ Pipeline │ │ Coeff ROM │
│ Register │ │ (100 x 16)│
└──────┬───────┘ └─────┬─────┘
│ │ h[tap_cnt]
v v
┌──────────────────────────┐
│ Multiplier │
└────────────┬─────────────┘
│
v
┌──────────────────────────┐
│ Accumulator (39 bits) │
└────────────┬─────────────┘
│
v
data_out (16 bits)
A finite state machine with three states controls the process:
-
S_IDLE: Waits for
data_valid. On assertion, shifts the new sample into the delay line, clears the accumulator, and starts the MAC counter. -
S_MAC: The counter
tap_cntincrements from 0 toNUM_TAPS. On each cycle, the ROM is addressed withtap_cnt, and the corresponding delay line entry is registered. Due to the ROM's 1-cycle read latency, valid multiply-accumulate operations begin attap_cnt = 1. This means:- At
tap_cnt = 0: address 0 is sent to ROM,delay_line[0]is registered. - At
tap_cnt = 1:h[0]arrives from ROM, multiplied with the registereddelay_line[0], accumulated. - At
tap_cnt = N: the last producth[N-1] * delay_line[N-1]is accumulated.
- At
-
S_DONE: The accumulator is truncated to
DATA_WIDTHbits by extracting bits[30:15](equivalent to right-shifting byCOEFF_WIDTH - 1 = 15), outputting the result in the same Q1.15 format as the input.
- Latency:
NUM_TAPS + 2 = 102clock cycles per output sample. - Throughput: 1 output every 102 cycles.
- Resources: 1 multiplier, 1 accumulator, 100-entry delay line, 1 ROM.
The following reports are from Xilinx Vivado targeting the AMD Artix 7 FPGA (AC701 Evaluation Kit, xc7a200tfbg676-2).
Utilization:
Timing:
Power:
In a standard (serial) FIR filter, one input sample produces one output sample. In an L-parallel FIR filter, L input samples are accepted simultaneously and L output samples are produced simultaneously in each computation block.
The input and output sequences are split into L interleaved streams using polyphase decomposition. For L=2:
where
Similarly, the input is split:
A naive L-parallel implementation directly computes all L^2 cross-terms between the L input streams and L coefficient subsets, requiring L^2 subfilter convolutions.
| L | Naive subfilters | Reduced-complexity subfilters | Saving |
|---|---|---|---|
| 2 | 4 | 3 | 25% |
| 3 | 9 | 6 | 33% |
Reduced complexity exploits algebraic identities (similar to Karatsuba multiplication) to recover the cross-products from fewer subfilter computations. The key insight is:
If we know
$A \cdot B$ ,$C \cdot D$ , and$(A+C) \cdot (B+D)$ , we can compute the cross-terms$A \cdot D + C \cdot B$ as$(A+C)(B+D) - AB - CD$ without any additional multiplications.
This trades pre-additions and post-additions (cheap in hardware) for subfilter multiplications (expensive in hardware).
Three subfilter convolutions replace the naive four:
| Subfilter | Coefficients | Data | Taps |
|---|---|---|---|
| F0 | H0 = {h[0], h[2], ...} | x0 = x[2k] | 50 |
| F1 | H1 = {h[1], h[3], ...} | x1 = x[2k+1] | 50 |
| F2 | H0 + H1 | x0 + x1 | 50 |
Pre-addition (before the delay lines):
Coefficient sum (combinational from two ROM outputs):
Post-combination (after all subfilters complete):
The f1_prev across computation blocks.
x0 ───────────────────┬──────> Delay Line F0 ──> MAC(H0) ──> accum_f0
│ │
x1 ────────────┬──────┼──────> Delay Line F1 ──> MAC(H1) ──> accum_f1
│ │ │
│ v │
└───> (+) ────> Delay Line F2 ──> MAC(H0+H1) ──> accum_f2
│
Post-Combination
│
y0, y1 outputs
Two instances of coeff_rom (both containing all 100 coefficients) are addressed with different stride patterns:
- ROM_A: addresses 0, 2, 4, ..., 98 (even indices, for H0)
- ROM_B: addresses 1, 3, 5, ..., 99 (odd indices, for H1)
The sum coefficient
- Subfilter length: 50 taps (half of the original 100)
- MAC cycles: 50 + 1 = 51 (same pipeline as serial, but over 50 taps)
- Total latency: 50 + 2 = 52 cycles for 2 outputs = 26 cycles per output
- Speedup over serial: 102/26 = ~3.9x
Utilization:
Timing:
Power:
The 3-way polyphase decomposition splits the filter into three subsets:
Six subfilter convolutions replace the naive nine:
| Subfilter | Coefficients | Data | Purpose |
|---|---|---|---|
| P0 | H0 | x0 | Base |
| P1 | H1 | x1 | Base |
| P2 | H2 | x2 | Base |
| P3 | H0 + H1 | x0 + x1 | Recovers H0x1 + H1x0 |
| P4 | H1 + H2 | x1 + x2 | Recovers H1x2 + H2x1 |
| P5 | H0 + H2 | x0 + x2 | Recovers H0x2 + H2x0 |
Cross-product recovery:
Post-combination:
Three ROM instances, each addressed at stride 3:
- ROM_A: 3k (for H0)
- ROM_B: 3k+1 (for H1)
- ROM_C: 3k+2 (for H2)
Coefficient validity gating: Since 100 is not divisible by 3, the subfilter lengths differ (H0: 34 taps, H1 and H2: 33 taps). At the last iteration (k=33), addresses 100 and 101 exceed the ROM range. A registered validity flag forces the coefficient to zero when the address is out of bounds.
Instead of storing three separate previous-block values (P1, P2, P4), the design pre-computes and stores only two registers:
prev_cross = P4 - P1 - P2(for y0)prev_p2 = P2(for y1)
- Subfilter length: ceil(100/3) = 34 taps
- MAC cycles: 34 + 1 = 35
- Total latency: 34 + 2 = 36 cycles for 3 outputs = 12 cycles per output
- Speedup over serial: 102/12 = ~8.5x
Utilization:
Timing:
Power:
Pipelining and parallel processing are complementary optimization techniques:
| Technique | What it does | Effect |
|---|---|---|
| Parallel processing | Processes L samples per block using L-way decomposition | Increases throughput by factor ~L |
| Pipelining | Inserts registers to break long combinational paths into shorter stages | Increases achievable clock frequency |
In the non-pipelined L=3 design, each subfilter MAC has a 2-stage pipeline:
Stage 1: ROM read + delay register (1 cycle)
Stage 2: Multiply + Accumulate (combinational, same cycle)
The critical path of Stage 2 is T_multiply + T_accumulate, which limits the maximum clock frequency. On a typical FPGA, a 16x16 multiplication followed by a 41-bit addition can be the timing bottleneck.
Combined pipelining adds a register between the multiplier and accumulator, creating a 3-stage pipeline:
Stage 1: ROM read + delay register (1 cycle)
Stage 2: Multiply, REGISTER product (1 cycle) <-- NEW pipeline cut
Stage 3: Accumulate from registered product (1 cycle)
This reduces the critical path from T_multiply + T_accumulate to max(T_multiply, T_accumulate), enabling a higher clock frequency.
Neither technique alone achieves maximum throughput:
- Parallel processing alone (L=3): Produces 3 outputs every 36 cycles, but the clock is limited by the multiply+accumulate critical path.
- Pipelining alone: Allows a faster clock, but still processes only 1 output per ~102 cycles.
- Combined: Produces 3 outputs every 37 cycles, but at a significantly higher clock frequency. The net throughput (outputs per second) is the highest of all four designs.
The only structural change from the non-pipelined L=3 to the pipelined version is the addition of 6 pipeline registers (mult_r0..mult_r5):
NON-PIPELINED (FIR_L3_reduced/fir_filter_L3):
coeff ───┐
├──> Multiplier ──────────────> Accumulator
delay_r ───┘ (combinational path: T_mult + T_accum)
PIPELINED (FIR_L3_combined/fir_filter_pipelined_L3):
coeff ───┐
├──> Multiplier ──> [REG] ──> Accumulator
delay_r ───┘ T_mult T_accum
(critical path = max of the two)
The extra pipeline stage shifts the accumulation phase by one cycle:
| Phase | Non-pipelined (tap_cnt) | Pipelined (tap_cnt) |
|---|---|---|
| Address + delay reg | 0 .. ST-1 | 0 .. ST-1 |
| Multiply register | (combinational) | 1 .. ST |
| Accumulate | 1 .. ST | 2 .. ST+1 |
| Total MAC cycles | ST + 1 = 35 | ST + 2 = 36 |
Where ST = SUBFILTER_TAPS = 34.
- MAC cycles: 34 + 2 = 36 (one more than non-pipelined)
- Total latency: 34 + 3 = 37 cycles for 3 outputs
- Cycles per output: ~12.3 (marginally more than non-pipelined)
- Clock frequency: Significantly higher due to shorter critical path
- Net throughput: Highest of all four architectures
Utilization:
Timing:
Power:
The previous architectures (serial, L=2/L=3 parallel) all share a fundamental limitation: they are block-based. Each block of L input samples requires tens of MAC cycles before the next block can be accepted. The filter is idle between blocks if upstream data arrives faster than it can be processed.
A systolic array takes a fundamentally different approach. Instead of time-multiplexing a few multipliers across many taps, it dedicates one multiplier per tap and arranges them in a pipelined chain. The result is the highest possible throughput: one output per clock cycle in steady state.
The design reasoning behind choosing a systolic array:
-
Throughput ceiling: The serial and parallel designs are bounded by
cycles_per_output >= ceil(NUM_TAPS / (L * num_multipliers)). The only way to reach 1 cycle/output is to have NUM_TAPS multipliers working simultaneously. -
Regular, local interconnect: Unlike a parallel multiplier bank with a global adder tree, a systolic array uses only nearest-neighbor connections. Each PE talks only to its immediate left and right neighbors. This is ideal for FPGA routing and ASIC physical design because there are no long wires or fan-out bottlenecks.
-
Short critical path: Each PE performs exactly one multiply-add and one register stage. The critical path is
T_mult + T_addwithin a single PE, regardless of the total number of taps. Adding more taps increases latency (pipeline depth) but does not affect clock frequency. -
Natural separation of concerns: The coefficient ROM, the systolic array, and the controller are cleanly decoupled. The ROM is read once at startup; the array runs autonomously during streaming. This modularity makes the design easy to verify and reuse.
There are several systolic FIR topologies. The transposed direct form was chosen because it avoids a fundamental timing problem that affects other topologies:
The problem with data-flowing + partial-sum-flowing designs:
If both data and partial sums flow in the same direction through registered PEs, they arrive at each PE with the same delay. PE[k] receives data sample x[t-k] and partial sum h[0]*x[t-k] + ... + h[k-1]*x[t-k] — all terms reference the same time index t-k. The result is (h[0] + h[1] + ... + h[k]) * x[t-k], which is a coefficient sum, not a convolution. This is fundamentally incorrect.
Fixing this requires either:
- Making the partial sum path combinational (semi-systolic), creating a critical path that spans all N PEs
- Pre-skewing the input data with extra delay stages, adding complexity and latency
Why the transposed form works:
In the transposed form, input x[n] is broadcast to all PEs simultaneously (no data delay chain). Each PE multiplies x[n] with its stored coefficient and adds the result to a registered partial sum from the right neighbor:
cascade_out[k] <= cascade_in[k] + h[k] * x[n]
Because the cascade registers introduce delay between PEs, the partial sum arriving at PE[k] from PE[k+1] was computed one cycle earlier — when the input was x[n-1]. This natural delay means PE[k]'s output accumulates products from different time steps:
PE[0] output at time n = h[0]*x[n] + h[1]*x[n-1] + h[2]*x[n-2] + ... + h[N-1]*x[n-N+1]
This is exactly the FIR convolution, achieved with purely local connections and no data skewing.
Trade-off: The input x[n] is broadcast as a global signal (fan-out to all PEs), which is a long wire on FPGA. However, FPGA clock distribution networks handle high-fanout signals well, and synthesis tools automatically insert buffer trees. The partial sum chain — the timing-critical path — remains purely local.
The design is split into three modules for clean separation of concerns:
fir_filter_systolic_top.sv Top-level wrapper (user-facing I/O)
|
+-- fir_filter_systolic.sv Controller: reads ROM, loads PEs, streams data
|
+-- coeff_rom.sv Existing ROM with 100 embedded coefficients
|
+-- systolic_array.sv Chain of NUM_PES processing elements
|
+-- systolic_pe.sv [0] PE[0]: outputs y[n]
+-- systolic_pe.sv [1]
+-- ...
+-- systolic_pe.sv [99] PE[99]: cascade_in = 0
Each PE contains:
data_in (broadcast x[n])
|
v
+-----------+
coeff_shift | coeff_r | <-- stored coefficient (loaded via shift chain)
in/out | (16-bit) |
+-----+-----+
|
v
[ Multiplier ] <-- combinational: coeff_r * data_in (32-bit product)
|
v
cascade_in ---> [ Adder ] ---> [ Register ] ---> cascade_out
(from right) (to left)
Coefficient shift chain: When coeff_load_en is asserted, each PE shifts its current coefficient to the right neighbor and loads a new value from its left neighbor. This forms a daisy chain:
ROM data --> PE[0] --> PE[1] --> ... --> PE[N-1]
By pushing h[N-1] first and h[0] last, after N load cycles PE[k] holds h[k].
Only two states are needed:
S_LOAD_COEFFS (startup, N+1 cycles):
Cycle 0: ROM addr = N-1 (no data yet)
Cycle 1: ROM addr = N-2, shift h[N-1] into PEs
Cycle 2: ROM addr = N-3, shift h[N-2] into PEs
...
Cycle N: (done), shift h[0] into PEs --> S_RUN
S_RUN (continuous streaming):
- When
data_validis high: feeddata_into the systolic array - When
data_validis low: feed 0 (treated as a zero-valued sample) - A validity shift register (NUM_PES bits) tracks pipeline latency:
each cycle,
data_validis shifted in;data_out_validis the bit that emerges after NUM_PES stages
No flush state is needed because all cascade registers are cleared to zero on reset. The first NUM_PES-1 outputs include contributions from these initial zeros, which the validity shift register correctly suppresses.
| Parameter | Value |
|---|---|
| Coefficient load time | NUM_TAPS + 1 = 101 cycles (one-time at startup) |
| Pipeline latency | NUM_PES = 100 cycles (first valid output after 100 input samples) |
| Steady-state throughput | 1 output per clock cycle |
| Critical path | T_mult + T_add (within a single PE, independent of NUM_TAPS) |
| Multipliers (DSP blocks) | 100 |
| Delay line registers | 0 (delay is implicit in the cascade chain) |
The systolic array represents the opposite end of the resource/throughput spectrum from the serial design:
| Property | Serial | Systolic |
|---|---|---|
| Multipliers | 1 | 100 |
| Throughput | 1 output / 102 cycles | 1 output / cycle |
| Explicit delay line | 100 registers | None (cascade chain is the delay) |
| Coefficient storage | ROM (read each cycle) | PE registers (loaded once) |
| Control complexity | FSM with counter | Simple 2-state FSM |
| Data flow | Time-multiplexed | Spatially distributed |
The serial filter reuses one multiplier across all taps in time. The systolic array distributes the taps across space (one per PE) and processes them simultaneously. The L=2/L=3 parallel designs occupy the middle ground.
Utilization:
Timing:
Power:
| Serial | L=2 | L=3 | Pipelined L=3 | Systolic | |
|---|---|---|---|---|---|
| Multipliers | 1 | 3 | 6 | 6 | 100 |
| ROM instances | 1 | 2 | 3 | 3 | 1 |
| Delay line entries | 100 | 3 x 50 | 6 x 34 | 6 x 34 | 0 |
| Accumulators | 1 | 3 | 6 | 6 | 100 (cascade) |
| Pipeline registers | 0 | 0 | 0 | 6 (mult_r) | 0 |
| z⁻¹ block regs | 0 | 1 | 2 | 2 | 0 |
| Cycles / output | 102 | 26 | 12 | ~12.3 | 1 |
| Critical path | T_m + T_a | T_m + T_a | T_m + T_a | max(T_m, T_a) | T_m + T_a (1 PE) |
| Startup latency | 0 | 0 | 0 | 0 | N+1 (load) + N (fill) |
-
Serial: Minimum hardware, maximum latency. Ideal for understanding the basic FIR operation and for applications where throughput is not critical.
-
L=2 Parallel: 3x the multipliers for ~4x throughput improvement. The 25% multiplier savings over naive (4 multipliers) comes from the pre-addition / post-subtraction identity.
-
L=3 Parallel: 6x the multipliers for ~8.5x throughput. The 33% savings over naive (9 multipliers) follows the same algebraic principle extended to 3 streams.
-
Pipelined + L=3: Same multiplier count as L=3, but adds pipeline registers to break the critical path. The 1-cycle latency increase is negligible; the real benefit is the higher achievable clock frequency, which translates to the highest throughput in samples per second.
-
Systolic array: Maximum hardware (100 multipliers) for maximum throughput (1 output/cycle). The regular, local interconnect structure is highly efficient on FPGAs and ASICs. The one-time startup cost (loading coefficients + pipeline fill) is amortized over continuous streaming operation. Best suited for high-throughput real-time applications.
All five architectures share:
- Q1.15 signed fixed-point data and coefficient format
- Synchronous ROM with 1-cycle read latency (infers BRAM on FPGA)
- Output scaling: Extract bits
[DATA_WIDTH + COEFF_WIDTH - 2 : COEFF_WIDTH - 1]from the accumulator, equivalent to right-shifting byCOEFF_WIDTH - 1 = 15bits - Parameterized design:
NUM_TAPS,DATA_WIDTH,COEFF_WIDTHare all configurable
Each filter design is organized in its own subdirectory under rtl/, containing the filter core, a local copy of the coefficient ROM, and a top-level wrapper that wires everything together. Testbenches instantiate the _top modules.
rtl/
FIR_Serial/
coeff_rom.sv Coefficient ROM (100 x Q1.15)
fir_filter_serial.sv Serial filter core (external ROM interface)
fir_filter_serial_top.sv Top: connects serial filter + ROM
FIR_L2_reduced/
coeff_rom.sv Coefficient ROM
fir_filter_L2.sv L=2 reduced-complexity filter (ROM internal)
fir_filter_L2_top.sv Top: wraps L=2 filter
FIR_L3_reduced/
coeff_rom.sv Coefficient ROM
fir_filter_L3.sv L=3 reduced-complexity filter (ROM internal)
fir_filter_L3_top.sv Top: wraps L=3 filter
FIR_L3_combined/
coeff_rom.sv Coefficient ROM
fir_filter_pipelined_L3.sv Pipelined L=3 filter (ROM internal)
fir_filter_pipelined_L3_top.sv Top: wraps pipelined L=3 filter
Systolic Filter/
coeff_rom.sv Coefficient ROM
fir_filter_systolic.sv Systolic controller (ROM + array internal)
fir_filter_systolic_top.sv Top: wraps systolic filter
systolic_array.sv Chain of NUM_PES processing elements
systolic_pe.sv Single processing element
tb/
fir_filter_serial_tb.sv Testbench for serial filter (uses _top)
fir_filter_L2_tb.sv Testbench for L=2 filter (uses _top)
fir_filter_L3_tb.sv Testbench for L=3 filter (uses _top)
fir_filter_pipelined_L3_tb.sv Testbench for pipelined L=3 (uses _top, with L=3 reference)
fir_filter_systolic_tb.sv Testbench for systolic filter (uses _top)
Reports/
FIR_Serial/
Utilization_Report_FIR_serial.png
Timing_Report_FIR_serial.png
Power_Report_FIR_serial.png
FIR_L2_reduced/
Utilization_Report_FIR_L2.png
Timing_Report_FIR_L2.png
Power_Report_FIR_L2.png
FIR_L3_reduced/
Utilization_Report_FIR_L3.png
Timing_Report_FIR_L3.png
Power_Report_FIR_L3.png
FIR_L3_combined/
Utilization_Report_FIR_L3_Pipelined.png
Timing_Report_FIR_L3_Pipelined.png
Power_Report_FIR_L3_Pipelined.png
Systolic Filter/
Utilization_Report_FIR_systolic.png
Timing_Report_FIR_systolic.png
Power_Report_FIR_systolic.png
Matlab Implementation/
FIR_Filter.m Filter design, quantization, and comparison script
Filter_Response.svg Ideal filter magnitude/phase response
Comparison_quantized_unquantized.svg Ideal vs Q1.15 quantized response overlay
filter_taps.csv Exported ideal coefficients
filter_taps_quantized.csv Exported Q1.15 quantized coefficients
scripts/
quantize_coeffs.py CSV to quantized SystemVerilog ROM generator
coeffs/
coefficients.csv 100-tap lowpass FIR coefficients (floating point)
docs/
architecture_overview.md This document














