Skip to content

Performance and native types

Where the time goes

A Python value in p2w is a (ref null eq), a reference that can point to any runtime object. An integer in the 31-bit range is an i31ref, which needs no allocation, but adding two of them still means casting both, extracting the values, adding, checking for overflow, and boxing the result. In a tight loop, that overhead is most of the work.

So, without annotations, a compiled program runs at roughly CPython's speed. The speed comes from letting the compiler prove that a value can live in a raw WebAssembly local instead.

Native types

The compiler keeps a variable native, as an unboxed i32, i64 or f64 local, when it knows the variable's type and the variable does not escape: it is not passed to a function, stored in a container, returned, or read as an attribute. Three things tell it the type:

Native annotations
i32, i64 and f64 are WebAssembly's own types. A variable annotated with one is always native, and arithmetic on it is WebAssembly arithmetic: an i32 wraps around at 32 bits, as WebAssembly's does, where a Python int would not.
float
A Python float is an IEEE 754 double, so a non-escaping float variable becomes an f64 with no change of meaning.
Loop counters
for i in range(n) over a range whose bounds fit makes i a native i32.

The names i32, i64 and f64 exist only for the compiler. Start the module with from __future__ import annotations and CPython never evaluates them, so the same file runs under both:

from __future__ import annotations


def mandel(cr: f64, ci: f64, limit: i32) -> i32:
    zr: f64 = 0.0
    zi: f64 = 0.0
    n: i32 = 0
    while n < limit and zr * zr + zi * zi <= 4.0:
        zr, zi = zr * zr - zi * zi + cr, 2.0 * zr * zi + ci
        n += 1
    return n

Container annotations such as list[float] or dict[str, int] declare what the elements are, so a value read from the container can be unboxed once and used natively.

Slotted classes

Without __slots__, an instance keeps its attributes in a list of name/value pairs, scanned on every access. A class with __slots__ compiles to a WebAssembly GC struct with one field per slot, and obj.x to a single struct.get. On pystone this took p2w from 3.4× to 7.4× CPython.

class Point:
    __slots__ = ("x", "y")

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

Inlining

Before analysis, small non-recursive functions with a single-expression body are inlined at their call sites. Each argument is evaluated once, into a temporary. Besides removing a call, inlining lets type inference see through the helper, so sq(x) on a native x stays native.

The benchmark suite

programs/benchmarks/run_benchmarks.py times each program under CPython and as compiled by p2w (with Node.js), and checks that both print the same output. The figures below are session #45, taken on 2026-10-08 with 2 warm-up runs and 5 timed runs; the times are medians. The fibonacci, primes, mandelbrot, sieve, fannkuch and matmul rows use the annotated variants (*_native.py).

Benchmark CPython (ms) p2w (ms) Speed-up
fibonacci 27.9 0.7 127×
primes 44.5 1.3 34.7×
mandelbrot 594.4 20.2 29.7×
sieve 24.5 1.3 19.4×
pystone 32.7 4.4 7.9×
fannkuch 430.0 60.1 7.2×
spectralnorm 1336.5 237.8 5.8×
matmul 287.6 65.7 4.2×
binarytrees 1077.5 447.1 2.4×
nbody 2287.0 922.2 2.1×
fasta 53.1 25.5 2.1×
Geometric mean 9.13×

Every row's output matched CPython's.

The gap between the top and the bottom of the table is the gap between native and boxed code. fibonacci and primes are integer loops that run entirely in native locals. matmul and nbody work on lists of lists of numbers, whose elements are boxed references: each a[i][k] * b[k][j] reads two boxed values and multiplies them boxed. Unboxed numeric lists, which would close that gap, are planned. binarytrees measures allocation, which is the host engine's garbage collector.

See Benchmarking for how to run the suites.