Native & Viper Code Emitters¶
MicroPython normally compiles your source to bytecode, interpreted at
runtime — that interpretation overhead is where most of a hot loop's
time goes. Two decorators let you trade portability and Python
semantics for speed: @micropython.native compiles a function straight
to machine code instead of bytecode, and @micropython.viper goes
further with a stricter, C-like type system. This module is reviewed
against MicroPython's documented emitter behavior — there's no
hardware here to benchmark against, so every timing figure is the
range MicroPython's own docs and community benchmarks report, called
out as such.
@micropython.native — same semantics, compiled¶
import micropython
@micropython.native
def sum_squares(n):
total = 0
for i in range(n):
total += i * i
return total
native keeps full Python semantics — the same object model, the same
exception behavior, int still transparently promotes to bignum on
overflow. All it changes is the emitter: instead of bytecode
interpreted by the VM, the function body is compiled directly to native
machine code for the target architecture at import/compile time. The
official MicroPython documentation puts the typical speedup at roughly
2x, because you still pay for boxed objects and dynamic dispatch — you
just skip the bytecode dispatch loop itself.
native functions can call regular Python functions and be called by
them freely; there's no calling-convention boundary to worry about,
which makes it the easy first thing to try on a hot function before
reaching for viper.
@micropython.viper — a different type system¶
import micropython
@micropython.viper
def sum_squares_viper(n: int) -> int:
total: int = 0
i: int = 0
while i < n:
total += i * i
i += 1
return total
Viper is where the real speedups live — commonly cited as 10x or more over plain bytecode for numeric loops — but it comes with a genuinely different type system, not just annotations for documentation's sake:
intin viper is a machine word, not Python's arbitrary-precisionint. It silently wraps on overflow instead of promoting to bignum. A viperintthat overflows does not raise and does not become correct — it wraps like a Cint, which is a real correctness trap if you assume Python's normal big-int behavior carries over.uintis the unsigned counterpart.ptr8,ptr16,ptr32are typed pointers for direct, unchecked memory access — think of them as abytearray/buffer viewed as raw memory, with no bounds checking whatsoever.- Untyped local variables and any object types other than the above fall back to being treated as generic Python objects (boxed, interpreted normally) — mixing typed and untyped code in one viper function is legal but only the typed parts get the speedup.
Pointer types — power without a safety net¶
import micropython
@micropython.viper
def fill_buffer(buf, value: int) -> int:
p = ptr8(buf) # view an existing buffer as raw bytes
n: int = int(len(buf))
i: int = 0
while i < n:
p[i] = value
i += 1
return n
ptr8(buf) doesn't copy buf — it's a raw, bounds-unchecked view over
the same memory. p[i] = value for i >= len(buf) does not raise an
IndexError the way bytearray.__setitem__ would; it writes past the
buffer into whatever memory is next, which on a microcontroller with no
memory protection unit for regular RAM can silently corrupt unrelated
data or crash the interpreter in a way that's hard to trace back to
this line. There is no way to make ptr8/ptr16/ptr32 access
bounds-checked — the entire point is skipping that check. Every viper
function using raw pointers needs its bounds reasoned about by the
programmer, by hand, every time.
ptr16 and ptr32 index in units of their width, not bytes:
@micropython.viper
def read_word(buf, idx: int) -> int:
p = ptr32(buf)
return int(p[idx]) # reads bytes [idx*4 : idx*4+4], not [idx:idx+4]
Forgetting that ptr32 indexing is word-granular, not byte-granular,
is a common source of "reads the wrong data" bugs when porting code
between pointer widths.
Typed integers and function boundaries¶
Return types and parameter types matter more in viper than in native:
declaring -> int means the compiler emits code assuming a machine
word comes back, and calling a viper function that returns int from
regular Python code gets a normal boxed Python int back at the
boundary — the conversion happens automatically, but only at the
function boundary, not inside the function body.
@micropython.viper
def clamp(x: int, lo: int, hi: int) -> int:
if x < lo:
return lo
if x > hi:
return hi
return x
# Called from plain Python — looks completely normal at the call site
print(clamp(500, 0, 255)) # 255, viper's int/return conversion is transparent here
Benchmarking the speedup — methodology¶
Because these decorators only matter when a function is genuinely hot,
always benchmark with time.ticks_us()/ticks_diff (see the
profiling module) comparing three versions of the same function —
plain, native, viper — over enough iterations to average out noise,
on the actual target board. Desktop CPython has no equivalent of these
decorators (they're MicroPython-specific compiler features), so there
is nothing meaningful to run here to reproduce the ratios; the
comparison only exists on-device.
How It Actually Works¶
The three emitters (bytecode, native, viper) are three different code generators sharing one compiler front-end — the difference between them is entirely about what the compiler's back-end targets and what type information it's given to work with.
- The bytecode emitter compiles to a stack-based virtual instruction
set — each source-level operation like
total += i * ibecomes several opcodes (LOAD_FAST,LOAD_FAST,BINARY_OP MULTIPLY,LOAD_FAST,BINARY_OP ADD,STORE_FAST) that the VM's dispatch loop decodes and executes one at a time, each requiring a jump through a dispatch table (or a giant switch statement) plus boxed-object arithmetic that checks operand types at runtime — every+genuinely asks "what kind of thing is this?" before deciding how to add it. That per-opcode dispatch and per-operation type check is the interpretation overhead this whole module exists to escape. @micropython.nativecompiles the same bytecode-level operations directly to the target's native instruction set (ARM Thumb, Xtensa, RISC-V depending on the port) at import time, but keeps every object boxed and every operation's semantics identical. A native-compiledtotal += i * istill calls the same boxed-integer-arithmetic runtime functions bytecode would have called — it just reaches those calls through direct machine-code branches instead of a dispatch-loop-and-opcode fetch, which is why the speedup is real but modest (~2x): you've removed the dispatch overhead, not the boxing/dynamic-typing overhead underneath.@micropython.viperis a genuinely different compiler mode that emits code operating on raw machine words instead of boxedmp_obj_tvalues. A viperintis compiled to occupy an actual CPU register or raw memory slot holding a native 32-bit value — no boxing, no dynamic type check, no runtime dispatch to a generic "add" function;total += i * ibecomes literal ADD/MUL machine instructions on registers, exactly like C would generate. This is precisely why it's 10x+ and precisely why it silently wraps on overflow: a genuine machine-wordinthas no bignum-promotion path at all, because Python's arbitrary-precision integer semantics simply were not compiled in for that variable.ptr8/ptr16/ptr32compile to a bare pointer dereference with no runtime bounds check inserted, because inserting one is exactly the Python-level safety machinery viper exists to strip away. A regularbytearray[i]goes throughmp_obj_tdispatch to a subscript handler that explicitly comparesiagainst the buffer's stored length before reading — that comparison is Python-level object-model code viper's typed pointers never generate, which is also whyptr32indexing is word-granular: the compiler emitsbase_address + i * 4, the same raw address arithmetic a C compiler would produce forint32_t *p; p[i], with no notion of "byte offset" surviving into the generated code at all.
Cheat sheet¶
| Emitter | Typical speedup | Type system | Bounds-checked | Risk |
|---|---|---|---|---|
| (default, bytecode) | 1x baseline | Full Python | Yes | None beyond normal Python bugs |
@micropython.native |
~2x | Full Python | Yes | Low — same semantics, just compiled |
@micropython.viper |
~10x+ | Machine int/uint, typed pointers |
No, for ptr* access |
High — overflow wraps silently, ptr* has no bounds checks |
Exercise¶
You cannot run @micropython.viper code outside MicroPython, so this
exercise is a design/review task. Write out, in a comment block, a
viper version of a function checksum(buf) -> int that XORs every byte
of a bytearray together using ptr8. Then write, in prose comments
beneath it, three specific bugs a programmer could introduce in this
function that would compile fine but behave wrong or unsafely on
target hardware: one involving the int overflow/wraparound behavior,
one involving an off-by-one on the pointer bounds, and one involving
mixing an untyped Python object into the typed loop without realizing
it falls back to boxed/interpreted handling.