· 9 min read

The compiler moved off the board

CircuitPython Turbo gets native-code speed from tiny microcontrollers by moving code generation to the host and leaving the board with an architecture-aware loader.

Four small microcontroller development boards arranged side by side on a measurement grid.
Mignon Pelletier, CC BY-SA 2.0

CircuitPython Turbo looks like a speed story: put a decorator on a hot function, compile it, and a tiny microcontroller suddenly spends less time interpreting Python. The more interesting compiler story is where the compiler ended up. It is not on the board.

Adafruit published CircuitPython Turbo on September 11, 2026. The same day, Turbo support landed in the newest official CircuitPython builds for RP2040 and RP2350 boards; the guide explicitly notes that the stable 10.3.0 build predates it. Selected functions are compiled ahead of time on your computer with mpy-cross, then imported on the board as native.mpy modules.

That split is the part I like. A microcontroller can afford to execute machine code without also affording the machinery that produces it. Turbo keeps code generation on the big computer, adds a small native-code loader to CircuitPython, and uses a runtime shim to pick an artifact that matches the board. The compiler did not become tiny. It left.

The decorator is a build instruction#

The source-facing API is deliberately boring. A module can mark one function with @turbo.native or @turbo.viper while the rest of the program stays ordinary CircuitPython.

A selected hot functionpython
import turbo@turbo.nativedef energy(samples):    total = 0    for value in samples:        total += value * value    return total

The decorator is not an on-device JIT trigger. Turbo's own repository documentation describes it as a marker. The host CLI reads the source, rewrites a temporary copy from @turbo.native or @turbo.viper to MicroPython's corresponding compiler decorator, runs mpy-cross for the target architecture, and leaves the original file alone.

The compiler path is mostly off-device
src/pixels.py    @turbo.viper         │         │ host rewrites a temporary copy         ▼    @micropython.viper         │         │ mpy-cross -march=<target>         ▼lib/turbo/<arch>/pixels.viper.mpy         │         │ installed as pixels.mpy         ▼CircuitPython imports machine code

CircuitPython already had the relevant native and Viper emitters in its MicroPython ancestry. Turbo's trick is not inventing a new machine-code backend. It connects an existing cross-compiler to boards that historically shipped as bytecode-only environments, then makes the resulting native module practical to install and import.

The project documents the firmware side as roughly a 2–3 KB native loader enabled with CIRCUITPY_LOAD_NATIVE=1, with no compiler living on the board. That is a very different resource bargain from shipping a JIT or a complete native compiler into flash and RAM just so one pixel loop can stop paying interpreter overhead.

The import path becomes part of the compiler#

Moving compilation to another machine creates a boring-sounding problem that is actually the center of the design: which binary should this board import? A native .mpy is no longer merely "compiled Python." It is compiled Python for a specific machine architecture and a compatible .mpy ABI.

Turbo's shim reads the architecture field from sys.implementation._mpy, looks for an artifact directory such as /lib/turbo/armv6m/, and places the matching directory ahead of the preserved source on sys.path. The exact code does more checking; the useful mental model is this:

Architecture-aware import selection, simplifiedpython
# Simplified from Turbo's runtime shim.arch_id = sys.implementation._mpy >> 10arch = arch_name(arch_id)if native_loader_available() and exists(f"/lib/turbo/{arch}"):    sys.path.insert(0, f"/lib/turbo/{arch}")sys.path.insert(1, "/src")

The separate /src directory is not aesthetic tidying. CircuitPython's importer can prefer a same-named .py file over a .mpy file in one directory. If Turbo left pixels.py beside pixels.mpy, the readable source could silently shadow the native artifact and your benchmark would become a very confusing meditation on nothing happening.

Instead, turbo init gives source its own home and the shim chooses the native directory first. If the board has no matching architecture directory, or the firmware lacks native loading, the same import can fall back to /src/pixels.py. That fallback is not error recovery for broken native code; it is an import-time portability path for a missing native option.

There is one sharp boundary: Turbo's documentation says code.py itself cannot be accelerated this way because CircuitPython executes it directly rather than importing it as a module. The coordinator stays Python source. The computational leaf functions are the things you move behind imports and compile. That limitation nudges a project toward a pretty healthy architecture anyway.

Native and Viper are different deals#

"Native code" can sound like one switch. MicroPython actually exposes two materially different contracts. Native mode compiles control flow to machine code while still leaning heavily on Python objects and runtime helpers. Viper goes further: it has machine-word integer types and typed pointers, which gives the compiler enough information to make tight loops much less Python-like internally.

Viper can trade safety for a tighter looppython
import turbo@turbo.viperdef xor_bytes(data):    p = ptr8(data)    acc: int = 0    for i in range(len(data)):        acc ^= p[i]    return acc

That pointer is a real semantic change, not a cute type hint. MicroPython's current speed guide warns that Viper pointer access does not perform bounds checking, and writing through a pointer to read-only storage has undefined behavior. Viper also has language restrictions, including no default argument values, and its floating-point operations are not specially optimized.

So the progression is not "Python, but faster" followed by "Python, but even faster." Native tries to keep more of the Python object model while removing interpreter dispatch. Viper asks you to expose enough low-level structure that the compiler can stop treating every value like a boxed Python object. If a hot loop is mostly buffer access and integer arithmetic, that can be a great trade. If the loop wants dynamic Python everywhere, Viper is probably telling you to stop forcing it.

The artifact has coordinates now#

Cross-compilation makes the build artifact more honest. A native .mpy has at least three useful coordinates: the source that produced it, the target CPU architecture, and the .mpy/runtime ABI it expects. MicroPython's native-module documentation makes the architecture dimension explicit: native modules are built for targets such as armv6m, armv7m, Xtensa, or RISC-V variants rather than for an abstract "MicroPython machine."

Turbo turns those coordinates into deployment behavior. Its doctor command inspects the board, identifies the architecture and .mpy version, and selects a compatible mpy-cross. The repository's recorded Metro RP2040 session, for example, identifies an armv6m target and mpy v6.3 before it builds anything. Change the architecture or cross the relevant ABI boundary and "I already compiled this" is no longer a sufficient answer.

This is the same reason native packages on desktop Python grow platform tags, and why shader caches include more than source text. Once compilation happens away from execution, the artifact needs a precise statement of where it is allowed to run. The tiny-board version just makes the rule impossible to ignore because loading the wrong machine instructions is less forgiving than missing a wheel.

The deployment tool is part compiler driver, part package manager#

Turbo also blurs a boundary that compiler diagrams often pretend is clean. mpy-cross performs code generation, but that alone does not give a usable feature. Something still has to choose native versus Viper, compile for the board's architecture, arrange files so imports resolve correctly, preserve source for fallback, and reject artifacts that do not match the runtime.

In other words, the interesting unit is the toolchain, not the compiler executable. A great backend that emits perfect ARM instructions is useless if the board imports the adjacent .py first. A perfect deployment script is useless if it cannot identify the ABI the native module targets. The performance feature exists in the joins between those pieces.

The official Adafruit guide makes that operational split visible. Its prepared demo ships already compiled modules so a user can run them without host compiler tools, while the later walkthrough installs the host-side tooling when the user wants to change and rebuild a selected function. Ahead-of-time compilation means build and run can happen on different computers and at different times; the board only needs the final artifact and enough loader logic to connect it back to CircuitPython's runtime.

The benchmark is real, but it is not the architecture#

Adafruit's September 8 Metro RP2040 Mandelbrot record reports the same checksum for bytecode, Native, and Viper, with Viper taking 422.9 ms versus 8,335.3 ms for bytecode: 19.71× for that measured computation. The guide is unusually careful about the scope: those are saved benchmark results reviewed for the guide, not a fresh rerun, and the number does not include display refresh, file I/O, sensors, or the rest of an application. I did not rerun the hardware benchmark for this article either.

That caveat matters because compiler speedups love escaping their denominator. A 20× faster inner loop does not make a program 20× faster when half its time is waiting for a display bus. Turbo is useful precisely because it lets the programmer choose a small compute-heavy boundary instead of pretending the whole runtime should become native code.

It also gives you a clean correctness question. If bytecode, Native, and Viper do not produce equivalent outputs for the same inputs, the faster result is not automatically an optimization. Viper's lower-level semantics make that check more important, not less.

The best optimization was refusing to ship a compiler#

None of the individual ideas here are new in isolation. Cross-compilers are ancient. Ahead-of-time native modules are established MicroPython machinery. Architecture-tagged artifacts are normal systems engineering. What feels fresh is the product boundary CircuitPython Turbo draws around them for boards whose default appeal is "edit a Python file on the USB drive and hit reset."

A JIT would make compilation an on-device runtime feature. A traditional embedded C workflow would make the whole application a host-built firmware image. Turbo picks a narrower middle: the friendly interpreter remains the operating environment, while a few imported functions become cross-compiled artifacts. You keep the REPL, Python orchestration, quick edits, and source fallback. The hot loop gets a different execution contract.

There are real costs. Native artifacts multiply by architecture. Firmware and .mpy compatibility become part of deployment. Viper can put pointer bugs into code that visually resembles Python. Functions that spend their time in I/O will not care. And if every module becomes a Viper science project, you have probably recreated an embedded systems language with worse tooling.

But the compiler-placement lesson survives those limits. Tiny runtimes do not need to own every expensive phase of the programming model they expose. Sometimes the right way to add a compiler feature under a brutal resource budget is to put almost all of the compiler somewhere else, leave a precise artifact contract at the boundary, and make the loader smart enough to say yes only to the binary that actually belongs on this board.