1

decorator: the whole user-facing API

@scram
0

native dependencies: standard library plus cimo, both pure Python

from scramjit import scram
0

Python objects in generated code: arguments are unboxed at the call

double *, int64_t
3.9+

CPython, plus Pyodide, JupyterLite, marimo and PyScript

micropip.install("scramjit")
Usage

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.
Motivation

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.

Where it shines

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)
Also a fit

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.

first

Python in the browser

Pyodide, JupyterLite, marimo and PyScript. Existing Python compilers don't run there.

plugins

Embedded Pythons

QGIS, Blender, Houdini, Maya, Nuke, FreeCAD, KiCad: hosts where compiled dependencies are banned or painful.

day one

New platforms

New CPython releases, free-threaded builds and Windows on arm64, before other JITs get there.

libraries

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.

How it works

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.

1Parse

The function's source, with the standard ast module.

2Infer types

Over the whole function, from the call's argument types and annotations.

3Typed IR

Every value has a concrete C type. No PyObject anywhere.

4Lower

Builtins become loops, lists become arrays, types narrow where proven safe. What can't compile stays Python.

5Emit C

Readable C, with #line pointing back at your Python.

6cimo

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.

python
total = sum(x * x for x in a if x > 0)
c
double total = 0;
for (int64_t i = 0; i < a_len; i++)
    if (a[i] > 0)
        total += a[i] * a[i];
Visibility

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.

compiled everything nativepartly compiled some loops nativePython nothing compiled
1Before you run

scramjit preflight src/

Reads every @scram function without running anything, and shows its state and which lines stay in Python, and why.

terminal
$ 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
2While it runs

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.

terminal
$ 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
3One function

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.

terminal · python
>>> 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.

Semantics

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.

TopicPythonCscramjit
// and %round toward −∞truncate toward 0Python's rule
Negative indicescount from the endout of boundsPython's rule
Float orderas writtenmay reassociateas written no FMA unless opted in
Integersunboundedfixed widthint64 unless narrowed; wraps
Bounds checksalwaysneverPython's rule IndexError; opt out with boundscheck=False
math functionsplatform libm or its own codewhatever libm is linkedbit for bit the same code CPython runs, on every platform
Division by 0raisesinf or a crashPython's rule ZeroDivisionError

Code outside the subset runs as plain Python, with a FallbackWarning naming the line; @scram(fallback=False) makes it an error.

Platforms

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.

PlatformCompiled code runs asLoaded with
Linux arm64 / x86-64native arm64, amd64mmap + mprotect (W^X)
macOS, Apple siliconnative arm64MAP_JIT
Windows x64native amd64VirtualAlloc + VirtualProtect
Pyodide, JupyterLite, marimoWebAssemblya 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.