decorator: the whole user-facing API
@scramnative dependencies: standard library plus cimo, both pure Python
from scramjit import scramPython objects in generated code: arguments are unboxed at the call
double *, int64_tCPython, plus Pyodide, JupyterLite, marimo and PyScript
micropip.install("scramjit")Plain Python in, C out
On the first call scramjit reads the argument types, generates C specialized to them, compiles it with cimo and caches it. Later calls with the same types run native code directly.
- Still Python. Without the decorator the function runs unchanged in CPython, debuggable and testable as Python.
- Never changes results. Every compiled function is tested against CPython running it undecorated.
- No GIL while it runs. Threads calling a compiled function run in parallel.
- Arrays in place. numpy arrays are passed by pointer, shape and strides, never copied.
- numpy on numbers.
np.sqrt(x[i]),np.sin,np.minimumand friends compile, with numpy's result and type. - Settings in a dict. A dict passed in compiles if its keys don't change, and a loop reading
p['k']runs as fast as one reading a local.
import numpy as np
from scramjit import scram
@scram
def ema(x, y, a: float):
y[0] = x[0]
for i in range(1, len(x)):
y[i] = a * x[i] + (1 - a) * y[i - 1]
x = np.random.rand(1_000_000)
y = np.empty_like(x)
ema(x, y, 0.1) # compiled here, native from now on
#line 5 "ema.py"
void ema(const double *x, int64_t x_len,
double *y, int64_t y_len, double a)
{
y[0] = x[0];
for (int64_t i = 1; i < x_len; i++)
y[i] = a * x[i] + (1 - a) * y[i - 1];
}
Watch Python get fast
Four simulations, each a loop numpy can't vectorize, written in plain Python and running in this page in Pyodide. Turn scramjit on: the same function is parsed, typed, turned into C and compiled to WebAssembly by cimo, all in Python, in your browser.
Loads Pyodide (7 MB), numpy, cimo and scramjit. Then the whole toolchain runs right here: Python, scramjit turning it into C, cimo compiling the C to WebAssembly. There's no compile server.
Edit the code and it reloads as you type. Write something scramjit can't compile and the C tab shows why it runs as Python instead.
Where the interpreter is the bottleneck
numpy is fast when the work fits one array expression. Much real code doesn't: each step depends on the last, or on a branch, or on what was found so far. In CPython every iteration of that loop pays the interpreter, and threads take turns on the GIL.
Loops that don't vectorize
Recurrences, data-dependent branches, flood fills, dynamic programming, simulations, scans over byte buffers. Compiled, they run as C, with the GIL released so threads run in parallel.
y[i] = f(y[i-1])if grid[j][i] == seed:while lo < hi:best = min(best, d[k] + w)Vectorizable code, pure-Python only
Array math written as plain loops, for places that allow only pure Python or have no numpy. scramjit makes the loop fast without a binary dependency.
Wherever Python runs
Python JITs today bring LLVM or need a C toolchain on the target. scramjit and its compiler are plain Python files, so they go where those can't.
Python in the browser
Pyodide, JupyterLite, marimo and PyScript. Existing Python compilers don't run there.
Embedded Pythons
QGIS, Blender, Houdini, Maya, Nuke, FreeCAD, KiCad: hosts where compiled dependencies are banned or painful.
New platforms
New CPython releases, free-threaded builds and Windows on arm64, before other JITs get there.
Pure-Python packages
Fast hot paths with no wheel matrix to build, test and release.
Not for: code that is already one vectorized numpy or pandas expression where numpy runs, or anyone already happy with an existing JIT on a standard desktop or server.
Cold source in, hot code out
Like the engine it is named after, scramjit has no moving parts of its own: the compiler is cimo, and scramjit is the intake that shapes Python into C cimo optimizes well.
The function's source, with the standard ast module.
Over the whole function, from the call's argument types and annotations.
Every value has a concrete C type. No PyObject anywhere.
Builtins become loops, lists become arrays, types narrow where proven safe. What can't compile stays Python.
Readable C, with #line pointing back at your Python.
Machine code in the process, or WebAssembly in Pyodide.
Builtins are lowering rules, not functions
No builtin exists at runtime. range, zip, enumerate, comprehensions and generator expressions fuse into one loop, and a result is materialized only when stored.
total = sum(x * x for x in a if x > 0)
double total = 0;
for (int64_t i = 0; i < a_len; i++)
if (a[i] > 0)
total += a[i] * a[i];Preflight shows what didn't compile, and why
Code scramjit can't compile runs as plain Python, so nothing breaks. Every decorated function is in one of three states, shown down to the line.
scramjit preflight src/
Reads every @scram function without running anything, and shows its state and which lines stay in Python, and why.
$ scramjit preflight src/src/report.py summarize runs as Python 6 totals = {} dicts aren't compiledsrc/sim.py ema compiled settle compiled step partly compiled (native: lines 20-21) 25 events.append(i) 2 compiled, 1 partly compiled, 1 runs as Python
scramjit preflight --run app.py
Runs your program, or your tests with --run -m pytest, and reports what happened with the real argument types: state, compile time, time in native code and in Python, and how much of the run was native. It fails CI if anything fell back.
$ scramjit preflight --run simulate.py… simulate.py output … scramjit · 1.15 s run · 84% native function calls compile native pythonema 1 0.02 s 0.00 s –settle 2,000 0.04 s 0.96 s –step 40 0.02 s 0.00 s 0.03 s step, line 18: an argument of type list is not compiled failed: step is partly compiled
f.preflight()
Prints the function with each line marked native or Python and what the compiler decided: types, bounds checks, which loops were optimized. In a REPL or notebook, or from the command line with scramjit preflight sim.py:step.
>>> step.preflight()step(float64[:], float64[:], ?)partly compiled · sim.py─────────────────────────────────────────────20 for i in range(len(x)): compiled on its own21 x[i] = x[i] * 0.99 + 0.0122 for i in range(1, 3000):23 y[i] = 0.9 * y[i - 1] + x[i]24 if y[i] > 1.0:25 events.append(i)─────────────────────────────────────────────
Each fallback also raises a FallbackWarning once, naming the line. @scram(fallback=False) makes it an error for code that must be native.
Same results as CPython
Where Python and C disagree, scramjit does what Python does, with a few documented exceptions. Every compiled function is tested against CPython running it undecorated, including on randomly generated programs.
| Topic | Python | C | scramjit |
|---|---|---|---|
// and % | round toward −∞ | truncate toward 0 | Python's rule |
| Negative indices | count from the end | out of bounds | Python's rule |
| Float order | as written | may reassociate | as written no FMA unless opted in |
| Integers | unbounded | fixed width | int64 unless narrowed; wraps. Gotcha |
n ** k, negative variable k | a float | depends on the code | raises ValueError. Gotcha |
x ** 0.5, negative float x | a complex | nan | raises ValueError. Gotcha |
| Bounds checks | always | never | Python's rule IndexError; opt out with boundscheck=False |
math functions | platform libm or its own code | whatever libm is linked | bit for bit the same code CPython runs, on every platform |
| Division by 0 | raises | inf or a crash | Python's rule ZeroDivisionError |
Code outside the subset runs as plain Python, with a FallbackWarning naming the line; @scram(fallback=False) makes it an error.
Supported platforms
The same decorated function runs unchanged everywhere cimo does. scramjit generates the same C; cimo compiles it for the machine it is on.
| Platform | Compiled code runs as | Loaded with |
|---|---|---|
| Linux arm64 / x86-64 | native arm64, amd64 | mmap + mprotect (W^X) |
| macOS, Apple silicon | native arm64 | MAP_JIT |
| Windows x64 | native amd64 | VirtualAlloc + VirtualProtect |
| Pyodide, JupyterLite, marimo | WebAssembly | a side module linked into Pyodide's memory |
CPython 3.9 or newer. Compiled code runs with the full privileges of your Python process, like an extension module. There is no sandbox.
Contributing
scramjit is developed in the open. Bug reports, benchmarks and pull requests are welcome, and so is the loop you want faster.