Skip to content

Lab 27: The Abstraction Ladder

Time: ~45 minutes | Prerequisites: Lab 26 | Hardware: Pico 2

What does convenience cost?

Echo waving welcome 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
# Lab 27: The Abstraction Ladder
#
# Same algorithm. Same operations. Same answer. Five different ways of
# telling the machine to do it -- and a speed range of over 150x.
#
#   pure Python  -> every operation interpreted, every value a boxed object
#   @native      -> compiled to machine code, values still boxed objects
#   @viper       -> compiled AND using raw machine types (integers only)
#   assembly     -> you choose the instructions
#   C            -> discussed, not run (see the lab notes)
#
# This lab measures the cost of abstraction. It is not an argument for
# writing everything in assembly -- it is an argument for knowing what each
# layer costs so you can spend it deliberately.

import gc
import machine
import math
import micropython
import fft_asm
from fftlab import FFT

machine.mem32[0xE000EDFC] = machine.mem32[0xE000EDFC] | (1 << 24)
machine.mem32[0xE0001000] = machine.mem32[0xE0001000] | 1
FREQ = machine.freq()


def rd():
    return machine.mem32[0xE0001004]


N = 512
signal = [math.sin(2 * math.pi * 40 * i / N) for i in range(N)]


# =========================================================================
# A single butterfly-like inner loop, written four ways.
# We use a simple weighted sum so viper's integer types can play fairly.
# =========================================================================
def sum_python(data, n):
    total = 0
    for i in range(n):
        total += data[i] * 3
    return total


@micropython.native
def sum_native(data, n):
    total = 0
    for i in range(n):
        total += data[i] * 3
    return total


@micropython.viper
def sum_viper(data, n: int) -> int:
    total = 0
    for i in range(n):
        total += int(data[i]) * 3
    return total


@micropython.asm_thumb
def sum_asm(r0, r1):
    # r0 = address of an array('i'), r1 = count
    mov(r2, 0)                  # total
    mov(r3, 0)                  # index
    label(LOOP)
    lsl(r4, r3, 2)              # index * 4 bytes
    add(r4, r0, r4)
    ldr(r5, [r4, 0])            # load data[i]
    mov(r6, 3)
    mul(r5, r6)                 # * 3
    add(r2, r2, r5)             # accumulate
    add(r3, 1)
    cmp(r3, r1)
    blt(LOOP)
    mov(r0, r2)                 # return total


from array import array
from uctypes import addressof

COUNT = 2000
data_list = [i for i in range(COUNT)]
data_arr = array("i", data_list)

print("=== The same loop, four ways ===")
print("Summing %d integers, each multiplied by 3." % COUNT)
print()


def timeit(fn, *args):
    fn(*args)                                   # warm-up
    best = None
    for _ in range(5):
        s = rd()
        r = fn(*args)
        c = (rd() - s) & 0xFFFFFFFF
        if best is None or c < best:
            best = c
    return best, r


results = []
c, r = timeit(sum_python, data_list, COUNT)
results.append(("pure Python", c, r))
c, r = timeit(sum_native, data_list, COUNT)
results.append(("@native", c, r))
c, r = timeit(sum_viper, data_arr, COUNT)
results.append(("@viper", c, r))
c, r = timeit(sum_asm, addressof(data_arr), COUNT)
results.append(("assembly", c, r))

base = results[0][1]
print("%-14s %12s %10s %14s" % ("version", "cycles", "speedup", "result"))
for label, cycles, value in results:
    print("%-14s %12d %9.1fx %14d" % (label, cycles, base / cycles, value))

print()
print("All four produce the same number, so this is a fair race.")


# =========================================================================
# The same ladder, on the real FFT
# =========================================================================
print()
print("=== The real thing: a 512-point FFT ===")
print()

py = FFT(N)
asm = fft_asm.FFT(N)

pre, pim = py.buffers()
are, aim = asm.make_buffers()


def load(re, im):
    for i in range(N):
        re[i] = signal[i]
        im[i] = 0.0


def time_py():
    load(pre, pim)
    s = rd()
    py.run(pre, pim)
    return (rd() - s) & 0xFFFFFFFF


def time_asm():
    load(are, aim)
    s = rd()
    asm.run(are, aim)
    return (rd() - s) & 0xFFFFFFFF


time_py()
py_best = min(time_py() for _ in range(3))
time_asm()
asm_best = min(time_asm() for _ in range(10))

print("%-16s %14s %12s %10s" % ("implementation", "cycles", "microseconds", "speedup"))
print("%-16s %14d %12.1f %9.1fx"
      % ("pure Python", py_best, py_best * 1e6 / FREQ, 1.0))
print("%-16s %14d %12.1f %9.1fx"
      % ("assembly", asm_best, asm_best * 1e6 / FREQ, py_best / asm_best))

budget_us = N / 12800 * 1e6
print()
print("real-time budget for %d samples: %.0f us" % (N, budget_us))
print("  pure Python uses %.0f%% of it" % (py_best * 1e6 / FREQ / budget_us * 100))
print("  assembly uses    %.1f%% of it" % (asm_best * 1e6 / FREQ / budget_us * 100))


# =========================================================================
# Why viper does not save the FFT
# =========================================================================
print()
print("=== Why not just put @viper on the FFT? ===")
print()
print("Viper's speed comes from using raw machine types instead of Python")
print("objects. But its native types are INTEGER types -- it has ptr8,")
print("ptr16 and ptr32, and no float pointer at all.")
print()
print("An FFT is float arithmetic on float arrays. Viper can type the loop")
print("counters, but every multiply and every array element still goes")
print("through the object layer. You get a little back, not a lot.")
print()
print("Viper is excellent for integer and bit work. This is not that.")
print("It is worth knowing WHICH tool fits, not just which is fastest.")


# =========================================================================
# Where C fits
# =========================================================================
print()
print("=== And what about C? ===")
print()
print("C sits between viper and assembly: real machine types, real float")
print("hardware, and a compiler that optimises for you. For an FFT it lands")
print("close to hand-written assembly -- often within a few percent.")
print()
print("We do not use it in this course, for one practical reason: C on the")
print("Pico needs a cross-compiler, CMake, and a firmware rebuild, while")
print("assembly runs from a plain .py file on stock MicroPython.")
print()
print("The honest summary for real work:")
print("  * MicroPython   -- write it here first. Clarity beats speed.")
print("  * C             -- when you need speed and portability.")
print("  * assembly      -- when you need the last 10%%, or the instruction")
print("                     you want has no C equivalent.")
print()
print("Almost nobody writes production FFTs in assembly. They use a library")
print("someone else wrote in assembly, once, and tested exhaustively.")
print("The skill that matters is READING it -- knowing what the machine is")
print("actually doing, so you can tell a good library from a bad one and")
print("know why the fast one is fast.")
1
2
3
4
5
version              cycles    speedup         result
pure Python         1217693       1.0x        5997000
@native              780135       1.6x        5997000
@viper               472973       2.6x        5997000
assembly              26470      46.0x        5997000

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
implementation           cycles microseconds    speedup
pure Python            21161372     141075.8       1.0x
assembly                 134206        894.7     157.7x

real-time budget for 512 samples: 40000 us
  pure Python uses 353% of it
  assembly uses    2.2% of it

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?

Echo thinking 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

Echo offering a tip 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_viper function and remove the : int annotations. 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

  1. Float viper. Try writing the sum over array('f') in viper. Where exactly does it fight you?
  2. Native the FFT. Add @micropython.native to fftlab.FFT.run. Measure it. Does it match the 1.6× from the simple loop? Why not?
  3. 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

  1. What's the difference between a boxed and an unboxed value?
  2. What does @native remove, and what does @viper remove on top of that?
  3. Why doesn't viper help a float-heavy FFT much?
  4. When would you reach for C rather than assembly?
  5. Why is reading assembly more valuable than writing it, for most engineers?

Module 6 complete

Echo celebrating 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