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.
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];
}
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: machine learning (use PyTorch or JAX), 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/sim.py flood compiled ema compiled step partly compiled 8 loop stays in Python: line 11 grows a listsrc/report.py summarize Python 18 iterates a dict 2 compiled · 1 partly · 1 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 · 4.21 s run · 61% native function calls compile native pythonflood 120 0.14 s 0.22 s –ema ×2 2 0.07 s 0.02 s –step 2,000 disk 2.35 s 1.52 s hints step, line 11: events.append keeps the loop on line 8 in Python; collect the events after it with np.flatnonzero(y > 1.0) ema: a arrives as int and float; annotate a: float 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[:], list)partly compiled · sim.py · compiled in 38 ms───────────────────────────────────────────── 6 for i in range(len(x)): native, lifted 7 x[i] = x[i] * 0.99 + 0.01 bounds proven 8 for i in range(1, len(x)): Python: line 11 9 y[i] = 0.9 * y[i - 1] + …10 if y[i] > 1.0:11 events.append(i) grows a list─────────────────────────────────────────────hint collect the events after the loop with np.flatnonzero(y > 1.0)
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, except that integers are 64-bit. 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 |
| 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.