The Teensy 4.0 runs one sample through my 50-tap FIR filter in 676 nanoseconds. The MSP430FR2433 runs the same filter, written in double, in 56 milliseconds. That's not a typo... it's about 18 samples per second, on a part people put in sensor nodes.
A month ago I couldn't have told you that number. My benchmark firmware had a 12-second deadline, and the MSP430 and the PIC16F17146 blew through it on every floating-point build. Twenty cells in the results just said FAIL exp002: firmware error: deadline. This week I had an agent raise the deadline to 300 seconds and rerun them, and all twenty finished. The slowest complete run (MSP430, double, -O0) needed 92 seconds of firmware time to get through a 256-sample test vector.
So the story isn't "two boards can't do float." It's that emulated floating point on a small part doesn't miss your budget by a percentage. It misses by three or four orders of magnitude -- and if your benchmark has a timeout, you might never see the number at all.
What's a FIR Filter?
So digital filters are a way to perform a bunch of math on a potentially noisy signal to isolate only a small part of it. A FIR is a finite impulse response digital filter, which means it only looks back a little bit in the past. The "50-tap" part means it only looks at the last 50 samples. It essentially is a series of multiplications and additions with specially designed numbers to get the desired effect. The "lowpass" part means we only care about the things that change slowly.
For embedded systems, we live in a very noisy world and we want to only look at a specific part of the data coming in. Using a FIR filter is one tool we have. It's pure software, so it doesn't add cost for each unit.
In this case, we used Python to design the filter (essentially generating the coefficients we need to multiply/add together).
The Setup
Same rig as the arithmetic article: 16 build targets on 15 boards across six ISA families (Cortex-M0+/M4F/M33/M7, RISC-V, Xtensa, AVR, MSP430, and Microchip's PIC16/PIC18/PIC24/dsPIC33A), all flashed and measured automatically. Every board has a page at /fleet.
The workload this time is one lowpass FIR filter: 50 taps, 100 Hz cutoff at a 1 kHz sample rate, with a 256-sample test signal compiled into the firmware. One call is one input sample through the whole filter.
To explain it another way, a FIR filter is basically a weighted average. Every time a new sample comes in, the filter multiplies the last 50 samples by 50 fixed numbers (the taps) and adds up the products. That's the whole algorithm: 50 multiplies and 50 adds per sample. The taps are picked specifically to remove higher frequency parts of the signal. Here's what that does to a signal:
Same 50 taps as the benchmark, 100 Hz cutoff at 1 kHz. The input is a 3 Hz + 7 Hz waveform with a 300 Hz whine and random noise added, quantized to 8 bits. RMS distance from the clean waveform: 18.3 counts in, 3.9 out. The output lags the input by 24.5 ms, half the filter length.
That's the payoff: a signal you can actually threshold, integrate, or feed to a control loop. The price is those 50 multiply-adds on every single sample, and a delay of 25 samples (half the filter length) between a change on the input and the output catching up. How much the multiply-adds cost depends entirely on what kind of number you do them in, which is what this article is about. I built the filter seven ways:
doubleandfloati32: 32-bit coefficients, 32-bit accumulatori16m32: 16-bit coefficients, 32-bit accumulatori16: 16-bit coefficients, 16-bit accumulatori8m16: 8-bit coefficients, 16-bit accumulatori8: 8-bit everything, with each product truncated to 8 bits before it's added
I rebuilt each of them at all optimization levels (default, -O0, -Os, -O2, and -O3) and captured the cycle counts for every board. The results are in the chart below.
The Chart
The Teensy's Cortex-M7 does the integer filter in 405 cycles, float in 580, and double in 990. Then comes the 32-bit pack, and the pattern is basically: does this core have an FPU?
On the boards that do (both Xtensa ESP32s, the XIAO MG24, the Feather M4, the Pico 2W's Cortex-M33, the Nucleo G474RE, the Teensy, the dsPIC), float costs at most 1.4x the best integer variant. 580 to 983 cycles per call. Float is essentially free on those parts, and I wouldn't bother with fixed point.
Three 32-bit boards don't have an FPU: the Pi Pico's Cortex-M0+, the ESP32-C6's RISC-V core, and the Pico 2W's RISC-V build. On those, float is 12 to 16x the cost of the integer filter. The Pico 2W against itself is the clean comparison: same chip, same clock, same C. float costs 719 cycles on the Arm build and 7,705 on the RISC-V build (10.7x), while the integer filter is actually 7% faster on the RISC-V side. The ISA switch flips which representation you should pick.
Then the small parts. The Uno does its best integer variant in 1,265 cycles (79 µs) and float in 15,871 (992 µs), so 12.5x. The PIC16 and PIC18 are 12 to 13x. The PIC24 is 26x. And the MSP430 is 75x: 162 µs for its fastest fixed-point build, 12.2 ms for float, and 55.8 ms for double.
The full spread, before clock speed even enters into it, is 405 cycles per call on the Teensy to 893,004 on the MSP430's double. That's 2,200x for the same math. In wall-clock terms it's 676 ns to 55.8 ms, about 82,000x.
The Three Missing Doubles
You'll notice three boards have no double bar: the Arduino Uno, the PIC16F17146, and the PIC18F57Q84. Those builds ran fine. The problem is that the toolchains I used can't produce a 64-bit double at all, so what they built was float with a longer name, and the numbers came out identical to float, to the cycle. I dropped them rather than plot a double that isn't one.
Specifically: XC8's double is 24 or 32 bits, and that's the ceiling. The avr-gcc 7.3 that ships with Arduino only has a 32-bit double too.
Two more boards almost went the same way. XC16 and XC-DSC (the PIC24 and dsPIC33A compilers) default double to 32 bits, and my first capture used the default. They do have a 64-bit option, -fno-short-double, so I had those cells rebuilt with it and recaptured. The PIC24 has no floating-point hardware, so a real double costs it 1.9x its float (74,369 cycles per call, 2.3 ms). The dsPIC33A's FPU handles 64-bit in hardware, so its double is only 1.14x its float.
I still measured double vs float on 13 boards. On an FPU that handles doubles (the Teensy's M7), double is 1.7x slower than float. On an FPU that only supports floats (the ESP32-S3), it's 17x slower. I think the practical lesson is to check sizeof(double) before you trust it. If your compiler quietly builds double as float, your filter may be much less accurate than you expect!
Where the Accuracy Cliff Actually Is
Speed is half the question. Every step down from double gives up some accuracy, but the losses are nowhere near evenly spaced. These are the offline model results against the binary64 reference, measured after the 50-tap history fills, in counts of an 8-bit signal (so they're a property of the arithmetic, not of any one chip):
| Variant | Coefficient / accumulator bits | RMS error (counts) | Effective bits | Worst stopband lobe |
|---|---|---|---|---|
double | 64 / 64 | 0 (reference) | -- | -57.5 dB |
float | 32 / 32 | 0.0000178 | 23.8 | -57.5 dB |
i32 | 32 / 32 | 0.268 | 9.9 | -57.5 dB |
i16m32 | 16 / 32 | 0.268 | 9.9 | -57.5 dB |
i16 | 16 / 16 | 8.60 | 4.9 | -26.7 dB |
i8m16 | 8 / 16 | 8.60 | 4.9 | -26.7 dB |
i8 | 8 / 8 | 10.5 | 4.6 | -26.7 dB |
Because the filter uses an 8-bit input, our input data (in bits) is relatively low. If you added up all the numbers in the last 50 values, you'd still be under 16 bits. So these numbers are pretty favorable for integer math. If your input was already a float, the impacts of bit depth become a lot more noticeable.
Three things jump out at me:
floattoi32costs nothing you'd see at sensor resolution. 9.9 effective bits from an 8-bit input.i32toi16m32costs literally nothing. Narrowing the coefficient storage from 32 to 16 bits produces bit-exact output on all 256 samples.i16m32toi16is the cliff. To prove a 16-bit accumulator can't overflow, the coefficients have to drop to a 2^-6 grid. RMS error goes from 0.27 to 8.6 counts, the worst stopband lobe rises 31 dB, and the DC gain is off by 6.25% because the coarse coefficients sum to 1.0625. It isn't really a lowpass filter anymore.
So the rule that fell out: coefficient width is cheap, accumulator width is everything. 16-bit coefficients with a 32-bit accumulator sit right on the designed response.
The catch is what that 32-bit accumulator costs on an 8-bit core. Compare i16m32 to i16 on the chart: 2.1x on the Uno, 3x on the PIC16, and 7.9x on the PIC18. On the 16-bit PIC24 it's 1.3x and on the MSP430 it's basically free. That's a real trade on the 8-bit parts... but the 16-bit-accumulator version has a -26.7 dB stopband, so what you're trading away is the filter.
What the Optimization Flag Buys
The flag here is a per-function attribute on the filter kernel; the rest of the firmware stays at the board's default. Turning optimization off hurts, obviously: of the 105 -O0 cells, 91 were slower than the default build, the worst by +364% (ESP32, i16m32).
Turning it up barely does anything. -Os is within ±0.1% of the default build on 73 of 112 cells. The biggest win anywhere from -O2 or -O3 is -20% (Pi Pico, i32), and it can go backwards: ESP32 float at -O2 is 43% slower than the default build. -O2 and -O3 are within ±0.1% of each other on 97 of 112 cells. On the PIC16 and PIC18, every level measured exactly the same, to the cycle -- I'd guess XC8 ignores the per-function attribute, but I didn't chase it. The Optimization control on the chart lets you flip through all of this yourself.
And then the Uno at -O0. All 14 builds compiled, uploaded... and crashed or hung before the serial protocol finished. Unoptimized, they use 2,005 to 2,009 of the Uno's 2,048 bytes of static RAM (97 to 98%, versus 35% at the default level), which leaves about 40 bytes for the stack. On a big chip, -O0 is slow. On a 2 KB chip, it's the difference between a product that boots and one that doesn't.
While I was on the Uno: since it only has 2 KB of SRAM, I also built every variant with the coefficients in flash via PROGMEM. That saves 46 to 196 bytes depending on the variant and costs between 1% (float) and 17% (i16) in speed. On a 2 KB part I'd usually take that trade.
My read: your representation choice swings performance by 10 to 100x. Your optimization flag swings it by maybe 20%, occasionally the wrong way. Spend your attention accordingly.
How I'd Pick
Ideally, benchmark exactly the filter you need on your hardware. These numbers give some guidance, but they are not a substitute for real measurement on your hardware. If you can't, here are the steps I would take:
- Do you have a full-speed FPU? Go with it! Maybe understand what a double is on your platform, but a float is likely fine for most filters.
- Do you have a 32-bit multiplier on your part? If so, I'd go with 32-bit coefficients and a 32-bit accumulator. If you don't have a 32-bit multiplier, then I'd go with 16-bit coefficients and a 32-bit accumulator.
- No matter what, I would fully simulate the impacts of fixed vs floating (quantize) in Python or MATLAB outside of your hardware.
Later in this series: I streamed real samples through every board over serial, at 8 to 128 taps, and some of the boards with the best floating-point hardware were too accurate.
I deal with fixed vs float in real products all the time. If you're stuck, please reach out!
Every board in this article has a page at /fleet, and the methodology page spells out what these numbers can and can't support.
All product names and trademarks are the property of their respective owners.