Lab 27: The Abstraction Ladder
Time: ~45 minutes | Prerequisites: Lab 26 | Hardware: Pico 2
What does convenience cost?
Same algorithm, same answer, five ways of expressing it — and a 46× range. This isn't an
argument for writing everything in assembly. It's about knowing what each layer costs so
you can spend it deliberately. Let's tune in.
What You'll Build
The same loop written four ways — pure Python, @native, @viper, assembly — measured against
each other, plus the real FFT at both ends of the ladder.
Learning Objectives
- Distinguish bytecode interpretation, native compilation and machine types
- Explain what boxed and unboxed values are
- Measure the speedup at each rung
- Explain why viper doesn't rescue a float-heavy FFT
- Compare MicroPython, C and assembly as engineering choices
Concepts Introduced
| ID | Concept |
|---|---|
| 467 | Bytecode Interpretation |
| 468 | Native Code Emitter |
| 469 | Viper Code Emitter |
| 470 | Boxed Values |
| 471 | Unboxed Values |
| 472 | Type Annotation |
| 473 | Machine Types |
| 474 | Abstraction Cost |
| 475 | Language Tradeoff Analysis |
| 476 | Calling C From MicroPython |
| 477 | Library Over Handwritten Code |
Background
| Rung | What changes |
|---|---|
| pure Python | every operation interpreted; every value a heap object |
@native |
compiled to machine code; values still heap objects |
@viper |
compiled and using raw machine types (integers) |
| assembly | you choose the instructions |
Boxed values are the key idea. In normal Python x = 3 isn't a machine word — it's a
pointer to an object carrying a type tag and a reference count. Every arithmetic operation
unwraps two objects, does one instruction of real work, and wraps the result.
@native removes the interpreter. @viper removes the boxes.
Procedure
Step 1 — Predict
Rank the four rungs, and guess the speedup from pure Python to assembly.
Step 2 — Race them
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | |
1 2 3 4 5 | |
All four return the same number — a fair race.
Note where the jumps are. @native gives 1.6×; @viper gives 2.6×. Assembly gives 46×.
Most of the cost was never the interpreter — it was the object layer, and only assembly escapes
it entirely.
Step 3 — The real FFT
1 2 3 4 5 6 7 | |
From 353% of the budget to 2.2%. That's the whole journey: Lab 16's DFT was 530× over, Lab 20's Python FFT 3.6× over, and assembly finishes with 97.8% of the frame to spare.
Why not just put @viper on the FFT?
Because viper's native types are integer types. It has ptr8, ptr16 and ptr32 —
and no float pointer at all. An FFT is float arithmetic on float arrays, so viper can
type the loop counters while every multiply still goes through the object layer.
Viper is excellent for integer and bit work. This simply isn't that.
Step 4 — Where C fits
C sits between viper and assembly: real machine types, real float hardware, and a compiler that optimizes for you. For an FFT it lands close to hand-written assembly, often within a few percent.
We don't use it here for one practical reason: C on the Pico needs a cross-compiler, CMake and a
firmware rebuild, while assembly runs from a plain .py file on stock MicroPython.
The honest summary for real work:
| Tool | When |
|---|---|
| MicroPython | write it here first — clarity beats speed |
| C | when you need speed and portability |
| assembly | the last 10%, or an instruction C can't express |
Reading beats writing
Almost nobody writes production FFTs in assembly. They use a library someone wrote in
assembly once, and tested exhaustively. The durable skill is reading it — knowing
what the machine is really doing, so you can tell a good library from a bad one and
explain why the fast one is fast. That's what Module 7 is for.
Step 5 — Predict, then measure
Take the
sum_viperfunction and remove the: intannotations. What happens to its speed, and why?
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Viper slower than native | Annotations missing or wrong | n: int and -> int are what enable machine types |
| Viper raises on float data | No float pointer type | Use array('i') for viper, floats elsewhere |
| Assembly returns nonsense | Wrong argument order | Arguments arrive in r0, r1, r2, r3 |
| Results differ between rungs | Not the same computation | It isn't a fair race unless outputs match |
Challenges
- Float viper. Try writing the sum over
array('f')in viper. Where exactly does it fight you? - Native the FFT. Add
@micropython.nativetofftlab.FFT.run. Measure it. Does it match the 1.6× from the simple loop? Why not? - Price the boxes. Using Lab 25's per-operation numbers, estimate what fraction of pure Python's FFT time is object handling rather than arithmetic.
Check Your Understanding
- What's the difference between a boxed and an unboxed value?
- What does
@nativeremove, and what does@viperremove on top of that? - Why doesn't viper help a float-heavy FFT much?
- When would you reach for C rather than assembly?
- Why is reading assembly more valuable than writing it, for most engineers?
Module 6 complete
You can measure precisely, measure honestly, and price every layer between Python and
the metal. Module 7 is where you go get that 157× yourself.
Next: Lab 28: Does Your CPU Have an FPU? | Previous: Lab 26