micropython-optimizacion-native-viper

Optimizing MicroPython with @native and @viper

  • 5 min

The @micropython.native and @micropython.viper decorators are tools to compile critical functions into faster code.

One of the criticisms we always hear about MicroPython is: “It’s slow because it’s interpreted”. And, in part, they are right.

Under normal operation, MicroPython compiles your .py code into Bytecode. Then, a Virtual Machine (VM) reads that bytecode and tells the processor what to do. This intermediary provides flexibility but steals CPU cycles.

But MicroPython has a trick up its sleeve. We can bypass the Virtual Machine and ask the compiler to generate Real Machine Code (binary instructions that the processor understands directly).

For this, we use two special decorators: @micropython.native and @micropython.viper.

@micropython.native: The Easy Accelerator

The native emitter is the simplest way to gain speed. We simply place the decorator above our function.

What it does is take the function body and compile it into assembly code for the architecture we are using (ARM Thumb for RP2040/STM32, or Xtensa for ESP32).

Characteristics

  • Speed: Approximately 2x faster than normal code.
  • Compatibility: Supports almost all Python features (loops, with, exception handling, etc.).
  • Cost: The compiled code uses more RAM than the bytecode (approximately double the size).

Usage Example

Let’s imagine an expensive function, like calculating the Fibonacci sequence recursively (inefficient on purpose to notice the load).

import time

# Standard version (Bytecode)
def fib_normal(n):
    if n < 2: return n
    return fib_normal(n-1) + fib_normal(n-2)

# Native version
@micropython.native
def fib_native(n):
    if n < 2: return n
    return fib_native(n-1) + fib_native(n-2)

# --- Benchmark ---
start = time.ticks_us()
fib_normal(20)
print("Normal:", time.ticks_diff(time.ticks_us(), start), "us")

start = time.ticks_us()
fib_native(20)
print("Native:", time.ticks_diff(time.ticks_us(), start), "us")
Copied!

If you run this, you’ll see the native version takes about half the time. Just by adding one line!

@micropython.viper: Absurd Speed

If native is a sports car, viper is a Formula 1 with no brakes or seatbelt.

Viper (Variable Integers PointERs) optimizes the code by assuming data types. While in Python everything is a dynamic object, Viper tries to use pure integer types and memory pointers, eliminating almost all Python overhead.

Characteristics

  • Speed: Can be between 5x and 50x faster. It approaches C speed.
  • Typing: Requires you to specify the data types that go in and out (Type Hints).
  • Limitations:
    • Works with 31-bit integers (the 32nd bit is used to mark it as an integer and not an object).
    • Does not support all Python functions inside.
    • Allows direct handling of Pointers (ptr).

Viper Syntax

In Viper, we must define the argument types. If we don’t, it assumes they are generic objects, and we lose speed.

@micropython.viper
def fast_sum(a: int, b: int) -> int:
    return a + b
Copied!

Pointer Access (ptr)

Viper’s true beast mode appears when we want to manipulate GPIOs or memory buffers directly, bypassing the abstraction layers of machine.Pin.

Let’s imagine we want to change the state of a pin (bit-banging) as fast as possible.

import machine

# Conceptual example (Addresses depend on the chip)
# GPIO_OUT_REG is the memory address where pins are written
GPIO_OUT_REG = 0x3FF44004 # Example for ESP32 (Check datasheet!)

@micropython.viper
def extreme_blink():
    # Create a pointer to a 32-bit integer (ptr32)
    # pointing to the register address
    p = ptr32(GPIO_OUT_REG)
    
    # Frenetic loop
    for i in range(1000000):
        p[0] = 1 # Write directly to memory (High)
        p[0] = 0 # Write directly to memory (Low)
Copied!

Be careful with Viper! Viper disables many safety protections. If you access an incorrect memory address using pointers, or go out of bounds on an array, you can cause a Segmentation Fault and reboot the microcontroller instantly. It’s “bare-metal” programming.

Performance Comparison

Let’s do a simple test: sum numbers in a loop one million times.

import time

MAX = 1000000

def normal_loop():
    x = 0
    for i in range(MAX):
        x += 1
    return x

@micropython.native
def native_loop():
    x = 0
    for i in range(MAX):
        x += 1
    return x

@micropython.viper
def viper_loop():
    x = 0
    # In viper, range must be handled carefully or use while
    # for maximum pure speed with integers
    i = 0
    while i < MAX:
        x += 1
        i += 1
    return x

# Typical results on an ESP32 at 240MHz:
# Normal:  ~4500 ms
# Native:  ~2600 ms (x1.7 faster)
# Viper:   ~150  ms (x30 faster)
Copied!

The difference is abysmal. Viper turns the loop into a few assembly instructions.

When to use each one?

  1. Normal Code: 95% of your program. Configuration, WiFi, high-level logic, interfaces.
  2. @micropython.native: When you have a function that does many mathematical calculations and you notice it’s a bit slow. It’s the first optimization step because it’s safe and easy.
  3. @micropython.viper:
    • LCD/TFT screen drivers (moving pixels fast).
    • Software signal generation (bit-banging).
    • Reading sensors at very high speed.
    • Heavy mathematical calculations (FFT, digital filters).

Disadvantages to Consider

Nothing is free. Using these emitters has a cost:

  1. RAM Usage: Machine code takes up more space than bytecode. If you are very tight on RAM on an ESP8266, you may not be able to use native.
  2. Portability: Viper code with direct register pointers is not portable. If you write to the 0x3FF... register on the ESP32, that code will not work on a Raspberry Pi Pico.
  3. Compilation Time: When importing the module, it takes a little longer to start because it has to compile at that moment.

MicroPython is flexible, but when you need raw power, native and viper let you take the “training wheels” off the bicycle and accelerate the critical parts of your code.