scramjit
Python at native speed.
The same function on one clock, native on arm64. scramjit is capped at one fractal frame per GIF frame; uncapped, it does 21x the iterations. Script.
Usage
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.zeros_like(x)
ema(x, y, 0.1) # first call: compiles for these argument types, about 0.1 s
ema(x, y, 0.1) # 3 ms; undecorated, 250 ms
scramjit is a JIT written in pure Python. On the first call for a combination of argument types, it generates C specialized to those types and compiles it in-process with cimo, a C compiler that is also pure Python. It needs no LLVM, C compiler or binary wheels. Arrays are passed without copying, and compiled code releases the GIL.
Install
pip install scramjit numpy
Requires CPython 3.9 or newer. numpy is only needed for your arrays.
Live demo
The demo runs Pyodide, scramjit and cimo in the browser, with no compile server, and targets WebAssembly. The code is editable and recompiles as you type.
| Demo | Loop | Speedup over Pyodide's CPython |
|---|---|---|
| Terrain | Hydraulic erosion, each droplet following what the last carved | ~240x |
| Flock | 1,200 boids, all pairs | ~180x |
| Sand | Cellular automaton, updated in place | ~90x |
| Fractal | Mandelbrot, one escape loop per pixel | ~45x |
Examples
examples/ has runnable scripts: n-body gravity, edit distance, union-find, Karplus-Strong
audio, a Mandelbrot renderer, Monte Carlo option pricing, threads, and what falling back looks like.
Benchmarks
Arm64 Linux, from bench/kernels.py and bench/m1.py. The
hand-written C is compiled by cimo too.
| Kernel | CPython | scramjit | Hand-written C | Speedup |
|---|---|---|---|---|
| Gray-Scott reaction-diffusion, 256×256 | 120 ms | 0.22 ms | 0.20 ms | 540x |
| Flood fill, 512×512 | 248 ms | 3.6 ms | 2.3 ms | 69x |
| Viewshed, 96×96 | 117 ms | 10.8 ms | 5.8 ms | 11x |
| Exponential moving average, per element | 272 ns | 3.11 ns | 3.09 ns | 88x |
| Edit distance, per cell | 468 ns | 4.33 ns | 108x | |
| Collatz, per step | 41 ns | 2.39 ns | 17x |
Viewshed pays for exact math semantics; with parity=False it takes 7.3 ms. On Gray-Scott and
flood fill, scramjit's C compiled by gcc -O3 matches hand-written C compiled the same way, so the
remaining gap there is cimo's backend (bench/gcc.py). Four threads ran four calls
3.5x faster than running them one after another.
How it works
function ──▶ syntax tree ──▶ type inference ──▶ C ──▶ cimo ──▶ machine code
from the first call's arm64, x86-64,
argument types WebAssembly
The generated C implements Python's semantics, including floor division, negative indices and bounds
checks that raise IndexError. range, zip, enumerate, map, filter and comprehensions are
lowered to plain loops. Later calls with the same types go straight to the compiled code.
What compiles
| Compiled | Runs as Python |
|---|---|
for, while, break, continue, if, conditional expressions |
Assigning to a slice: a[1:] = 0 |
Views: m[i], x[a:b], x[::-1], m[:, j], for row in m |
Returning a view |
range, enumerate, zip, map, filter, comprehensions |
numpy array expressions: a * 2 |
sum(gen, 0.0), min, max, abs, **, tuple unpacking |
Calls to other Python functions |
Numbers and n-D numpy arrays, including dtypes like uint8 |
Strings, objects |
All of math, natively and in Pyodide (float sumprod excepted) |
Lists that grow with append |
| Dicts passed in, reading and writing keys it names | Dicts that gain keys, or built in the function |
numpy functions on single numbers: np.sqrt(x[i]), np.sin, np.minimum |
numpy calls that make arrays: np.sqrt(x), x.sum(axis=0) |
numpy reductions to one number: x.sum(), np.max(m[i]), x.mean(), x.argmax() |
np.dot, np.linalg |
** compiles. Where Python's result would change type, it raises ValueError: a negative k in
n ** k (Python gives a float) and a negative float x in x ** 0.5 (Python gives a complex).
A function that can't be compiled runs as Python, with a FallbackWarning giving the reason:
import warnings
import numpy as np
from scramjit import scram, CompileError
@scram
def count(a):
seen = {}
for i in range(len(a)):
seen[a[i]] = i
return len(seen)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
print(count(np.arange(3.0))) # 3
print(caught[0].message) # count(float64[:]) runs as Python: the Dict expression
@scram(fallback=False) # raise CompileError instead
def strict(a):
return {0: a[0]}[0]
try:
strict(np.arange(3.0))
except CompileError as e:
print(e)
Preflight
scramjit preflight shows what compiles and which lines stop it, without importing your code:
$ scramjit preflight src/
src/sim.py
✓ ema compiled
✓ grid compiled
✗ summarize runs as Python
22 │ names = {}
│ ^^ dicts aren't compiled
│ hint: if the keys are small integers, use an array indexed by key; otherwise do this
│ outside the compiled function
2 compiled, 1 runs as Python
It also lists undecorated functions with a loop that would compile if decorated, assuming their
arguments are numbers or arrays. scramjit preflight --run app.py (or --run -m pytest) reports what happened with the real types:
calls, compile time, and time in native code and in Python. Both exit non-zero if a function isn't
compiled, so they work as a CI check, and --json gives the report to tools. Add --counts for how often each line ran and
each branch was taken, and --live to watch it happen. scramjit preflight FILE:FUNCTION or f.preflight() annotates one function line by line, including which bounds checks
were proven, hoisted out of the loop or kept. scramjit c FILE:FUNCTION prints the generated C, and
SCRAMJIT_LOG=1 logs each compile and fallback. scramjit --help has the rest.
Compiled code is cached on disk: compiling took 0.53 s in the first process and 0.06 s in later
ones on a Gray-Scott kernel. @scram(cache=False) writes nothing.
CPython parity
Compiled functions are tested against the same functions running undecorated, including on randomly
generated expressions. This covers floor division and modulo on negatives, ZeroDivisionError,
IndexError, math domain errors and numpy's scalar rules.
There are three exceptions: integers are 64-bit and wrap, and the two ** cases above raise
ValueError. Compiled calls also skip numpy's warnings and errstate, are invisible to debuggers and
coverage, and cost about 1.4 µs each, so decorate the function with the loop, not a small helper. See
Gotchas.
| Option | Default | Effect |
|---|---|---|
parity |
True |
False gives IEEE results for float division by zero and math, and uses cimo's libm |
boundscheck |
True |
False removes index checks |
fallback |
True |
False raises CompileError instead of falling back |
Compiled code runs with the full privileges of the Python process, like an extension module. There is no sandbox.
Status
Pre-release.
- About 1,600 tests pass. Developed and benchmarked on arm64 Linux and in Pyodide, and run by hand on arm64 Windows and on x86-64 Windows (emulated), where a NaN's sign can differ. macOS is a cimo target that scramjit hasn't been run on, and none of these run in CI yet.
- A function that doesn't compile whole has its loops compiled on their own, where they fit.
- Dicts compile when passed in with keys that don't change. Next, the rest of the data structures: see
TODO.md. - Next: see
TODO.md.
Development
pip install -e .[dev] # with cimo installed, or checked out next to this repository
python -m pytest tests
python bench/kernels.py
MIT licensed.
Metadata
Release files for scramjit 0.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| scramjit-0.0.2.tar.gz | 159.1 kB | Without provenance | Details for scramjit-0.0.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| scramjit-0.0.2-py3-none-any.whl | Python 3 | none | any | Details for scramjit-0.0.2-py3-none-any.whl |
Total release size: 269.0 kB
Release files / scramjit-0.0.2.tar.gz
| Download URL | scramjit-0.0.2.tar.gz |
|---|---|
| Size | 159.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
43864c435bc9f9410f031d0ea1f70a01f01d2713615264b0b38d1848b28dbd19
|
|
BLAKE2b-256 checksum How to use checksums |
b73ead7d194c9b40d4eb09973c9b76cdc2c2483f6456351c5267a6f9b36ed7a4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.12
|
Release files / scramjit-0.0.2-py3-none-any.whl
| Download URL | scramjit-0.0.2-py3-none-any.whl |
|---|---|
| Size | 109.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
23e50f59794205a198393b720b5338bc452223b60239ef55686d2f7b51dd2c72
|
|
BLAKE2b-256 checksum How to use checksums |
de5f6ddf2862142dd3b1f77354889ca459f0620baf19e80ecfb7dc6c92d1c01e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.12
|
Security for scramjit 0.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.