Guide: what compiles¶
PyRust compiles a numeric subset of Python. Anything outside it still runs, as plain Python, and fn.explain() tells you which line stopped compilation and how to change it.
Values¶
| Type | Notes |
|---|---|
int |
64-bit, overflow-checked. Results that don't fit replay in Python's big integers. |
float |
IEEE double, no fast-math: same results as CPython, including -0.0, NaN and infinities. |
bool |
True/False, comparisons, and/or/not; bools in arithmetic act as 0/1 like Python. |
| NumPy arrays | 1-D and 2-D float64, int64 and float32 (float32 needs NumPy 2). |
| lists | of floats, ints or strings: [], append, pop, extend, sort, slicing, comprehensions, sorted(), zip(). |
str, bytes |
strings are values: slicing, split, strip, find, replace, join, lower, +, f-strings, int(s), float(s). bytes are read-only. |
| dicts and sets | dict[str or int, int/float/bool/str] and set[str or int]: get/set/in/del, loops over .items(), set & | - ^. |
| pandas Series | float64 / int64 columns with the default 0..n-1 index, read-only: s[i], s.iloc[i], len(s), for x in s. |
| tuples | for unpacking (a, b = b, a + b) and returning several values. |
Annotations are optional. A function is compiled once for each combination of argument types it is called with.
Statements and expressions¶
- Arithmetic:
+ - * / // % **,& | ^ ~ << >>, comparisons including chaineda < b < c. - Control flow:
if/elif/else,foroverrange(...), over an array (for x in a),enumerate(a),prange(...),while,break,continue, earlyreturn,x if c else y. - Builtins:
abs,min,max(also of a list, andmax(d, key=d.get)),sum,round(x),divmod,pow,int,float,bool,len,ord,chr,str,list,set,dict,sorted,zip,reversed(range(...)). - Module-level
frozensets and tuples of strings, andstrconstants (for example a stop-word set). random:random(),uniform(a, b),randint(a, b),randrange(stop),randrange(start, stop),gauss(mu, sigma),choice(seq).math:sqrt exp log log2 log10 sin cos tan asin acos atan atan2 sinh cosh tanh fabs copysign fmod floor ceil trunc isnan isinf isfinite pi e tau inf nan.- Module-level
int/float/boolconstants. - Calls to your own functions, with keyword and default arguments, including recursion (annotate the return type:
-> int).
Arrays¶
@optimize
def normalize(values: F64Array) -> None:
total = sum(values)
for i in range(len(values)):
values[i] /= total
@optimize
def outer(a, b):
out = np.zeros((len(a), len(b)))
for i in range(len(a)):
for j in range(len(b)):
out[i, j] = a[i] * b[j]
return out
Writes go straight into your array, with an undo log: if the call fails halfway, the writes are undone and the call replays in Python, so you see exactly what plain Python would do.
float32¶
import numpy as np
from pyrust_engine import optimize
@optimize
def dot(a, b): # float32 arrays, e.g. embeddings
total = np.float32(0.0)
for i in range(len(a)):
total += a[i] * b[i] # float32 * float32 stays float32, like NumPy
return total # an np.float32, as in Python
PyRust follows NumPy 2's rules exactly. float32 op float32 is float32. A Python number (a literal, an int/float argument, len(...), a math result) is converted to float32 first. Mixing float32 with np.float64 values from a float64 array isn't compiled, because NumPy decides that result type from where the value came from. Write float(x) to compute in float64, or np.float32(x) to stay in float32. Division by zero replays in Python, where NumPy returns inf with a warning.
Lists¶
@optimize
def running_max(values: list[int]) -> list[int]:
out = [0] * len(values)
best = values[0]
for i, x in enumerate(values):
best = max(best, x)
out[i] = best
return out # a normal Python list
Lists hold one type: floats, ints or strings. Local lists can be built with [] and append, [0.0] * n, literals like [1, 2, 3], comprehensions ([x * x for x in values if x > 0]), slicing and sorted(), and changed with append, pop, extend, sort(reverse=...), reverse, clear and del lst[i]. index, count, in, min, max, + and zip work too, and they can be returned.
Lists passed in are copied for the call:
- Lists of numbers can be written (
lst[i] = x,lst.sort()), and the new values are copied back into the same list object after the call succeeds. Each element keeps its type, so a list of floats only accepts float values (lst[i] = float(x)). - Lists of strings are read-only.
- No list passed in can grow or shrink. Build a new one and return it.
sum() of a list of floats isn't compiled, because Python 3.12+ adds Python floats with an extra-precise algorithm; write the loop yourself.
Two names for one list (b = a) aren't compiled, because Python would share the object. Write b = list(a) for a copy. Lists cost one copy per call; for very large numeric data called often, NumPy arrays avoid it.
Strings and bytes¶
@optimize
def edit_distance(a: str, b: str) -> int:
prev = [0] * (len(b) + 1)
cur = [0] * (len(b) + 1)
for j in range(len(b) + 1):
prev[j] = j
for i in range(1, len(a) + 1):
cur[0] = i
for j in range(1, len(b) + 1):
cost = 0 if a[i - 1] == b[j - 1] else 1
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
for j in range(len(b) + 1):
prev[j] = cur[j]
return prev[len(b)]
@optimize
def vowels(text: str) -> int:
count = 0
for ch in text:
if ch.lower() in "aeiou":
count += 1
return count
Strings work with any Unicode, including emoji.
- Reading:
len(s),s[i](negative too), slicings[a:b],for ch in s,enumerate(s), comparisons (==,<, … by code point, like Python), andin(a character or a substring). - Methods:
find,rfind,index,count(with start/end),startswith,endswith,split()andsplit(sep),strip,lstrip,rstrip(with or without characters),replace,join,lower,upper, andisdigit,isalpha,isalnum,isspaceon characters and whole strings. - Building:
+,s * n,s += t(grows in place like CPython), f-strings with ints, bools and strings, andstr(int). - Parsing:
int(s)andfloat(s).
These follow CPython's rules exactly, including whitespace for split()/strip() and the index rules for find. lower/upper and the is... tests are exact for ASCII. For other characters the call replays in Python, so results still match. Float formatting (f"{x:.2f}", str(1.5)) isn't compiled: format floats outside the function.
bytes work like a read-only list of ints: b[i] and for x in b give ints.
Dicts and sets¶
STOP = frozenset({'the', 'a', 'of'})
@optimize
def word_counts(text: str) -> dict[str, int]:
counts = {}
for word in text.lower().split():
if word not in STOP:
counts[word] = counts.get(word, 0) + 1
return counts # a normal dict, in Python's insertion order
@optimize
def jaccard(a: str, b: str) -> float:
sa = set(a.split())
sb = set(b.split())
return len(sa & sb) / len(sa | sb) if sa | sb else 1.0
Dicts have str or int keys, and int, float, bool or str values (one type each).
- Compiled:
d[k],d[k] = v,d[k] += v,d.get(k, default),d.pop(k[, default]),d.setdefault(k, v),del d[k],k in d,len(d),d.clear(), loops overd,d.keys(),d.values()andd.items(),sorted(d),max(d, key=d.get)anddict(d). - Order: dicts keep insertion order exactly like Python.
- Changing size inside a loop: adding or removing keys while looping over the dict replays in Python, which raises the same
RuntimeError.
Sets have str or int elements.
- Compiled:
set(),{a, b},set(words),add,discard,remove,update,clear,in,len,&,|,-,^,sorted(s), and returning a set. - Not compiled: looping over a set, because Python's set order can't be reproduced. Loop over
sorted(s)instead.
General rules:
- Lists of strings, dicts and sets passed in are read-only in compiled code; build a new one and return it.
- A helper function you call can fill a dict or list you made:
helper(words, counts). - Module-level constant collections must be
frozensets or tuples, because a globalset/dictcould change between calls.
pandas¶
@optimize
def mean_gap(prices) -> float: # pass a column: df["price"]
total = 0.0
for i in range(1, len(prices)):
total += abs(prices[i] - prices[i - 1])
return total / (len(prices) - 1)
A float64, int64 or float32 Series with the default index (0..n-1) is passed without copying. In Python, s[i] looks up the label i; with the default index that is position i, so s[-1] raises KeyError exactly as in Python. Use s.iloc[i] for positions, including negative ones. A Series with another index runs as Python with a hint (use .reset_index(drop=True)). Series are read-only in compiled code; write results into a NumPy array or list. For whole columns, vectorize (below) is usually simpler.
Text columns (pandas 3). pandas 3 stores text in the str dtype. A Series of strings with no missing values compiles too (pandas 2 object columns of strings as well):
@optimize
def count_valid(emails) -> int: # df["email"]
n = 0
for e in emails:
if e.find('@') > 0 and e.endswith('.com'):
n += 1
return n
s[i], len(s), loops over s, and sorted/list/set/min/max/str.join of s compile. Operators on the whole Series (s + s, 'x' in s, s == 'a') mean something different for a Series (element by element, or the index), so those functions run as Python, with a hint to pass s.tolist() instead. Columns with missing values (NaN, None) run as Python.
polars¶
A polars Series without nulls is accepted where a list is: floats (Float64, Float32) and ints (Int64 and smaller, UInt32 and smaller) are read without copying where possible, and String columns work like a list of str. Like a list, s[-1] is the last value. Every value is exactly what Python sees when it reads the Series (Python float, int, str).
For per-row functions, use @vectorize (below) instead of map_elements: about 20× faster on number columns (read without copying) and 1.5–2× on text columns:
import polars as pl
df = df.with_columns(pl.col("email").map_batches(domain_length).alias("domain_len"))
Random numbers¶
import random
@optimize
def monte_carlo_pi(n: int) -> float:
inside = 0
for _ in range(n):
x = random.random()
y = random.random()
if x * x + y * y <= 1.0:
inside += 1
return 4.0 * inside / n
random.seed(42)
monte_carlo_pi(1_000_000) # the same answer plain Python gives after seed(42)
PyRust copies the state of Python's own random generator into the native call, runs the same Mersenne Twister algorithm, and writes the new state back when the call succeeds. So random.seed() works, results are identical to plain Python, and the next random.random() after the call continues the same sequence. Supported: random(), uniform, randint, randrange (one or two arguments), gauss and choice, called as random.x() or imported with from random import x. Not inside prange (Python draws numbers in order). Separate random.Random() instances and numpy.random aren't compiled.
Threads
The generator state is copied in and written back, so another thread drawing random numbers from the shared generator during a compiled call has its draws overwritten. Give each thread its own work, or draw numbers on one thread.
vectorize: one-value functions over whole columns¶
from pyrust_engine import vectorize
@vectorize
def score(price: float, qty: int) -> float:
return price * qty * (0.9 if qty > 10 else 1.0)
score(prices, quantities) # NumPy arrays or lists -> NumPy array
score(df["price"], df["qty"]) # pandas columns -> Series with the same index
score(pl_df["price"], pl_df["qty"]) # polars columns -> polars Series with the same name
score(prices, 3) # plain numbers are reused for every element
@vectorize
def domain_length(email: str) -> int:
at = email.find("@")
return len(email) - at - 1 if at >= 0 else 0
domain_length(df["email"]) # str column (pandas 3 'str', object, polars String, list of str)
@vectorize
def clean(text: str) -> str: # str results too
return text.strip().lower()
A tiny function called once per value from Python is dominated by call overhead, even natively. vectorize generates the loop, compiles it together with your function, and runs everything in one native call. It replaces df.apply(lambda row: ...), Series.map and list comprehensions over columns: about 20× faster on number and bool columns and 3–4× on text columns (a text column is passed as one string, and each row is a view into it). Every element gets exactly the value your function returns in Python. Array arguments must have the same length. @vectorize(parallel=True) splits the elements across CPU cores.
| Column type | Compiled | The function receives |
|---|---|---|
float64, int64 (NumPy, pandas, polars, lists) |
yes | the values |
float32 (NumPy 2) |
yes | numpy.float32 values |
bool |
yes | True / False |
str (pandas 3 str, object of str, polars String, list of str) |
yes | str |
polars Int32, Int16, Int8, UInt32, ... |
yes | int |
| missing values, dates, categories, mixed lists | no: same loop in Python | the values |
Results can be numbers, bools or strings (a NumPy array, or a Series as above). Also available as @optimize.vectorize.
Classes and methods¶
Methods compile when they use their objects through attributes: reading self.x, assigning self.x = ... and self.x += ....
from dataclasses import dataclass
from pyrust_engine import optimize
@dataclass
class Particle:
x: float
vx: float
steps: int = 0
@optimize
def advance(self, dt: float, n: int) -> None:
for _ in range(n):
self.x += self.vx * dt
self.steps += 1
p = Particle(0.0, 2.0)
p.advance(0.01, 1_000_000) # runs natively; p.x and p.steps are updated
Each attribute the method uses is read before the call and passed to the native code as its own value. Assigned attributes are written back with setattr only after the native call succeeds. If anything goes wrong (overflow, an error, a value the native code can't handle), Python runs the method from the start on the untouched object, so the result and the object's state are exactly what plain Python gives. The same works for plain functions that take objects (def transfer(a, b, amount)).
| Attribute | Read | Assign |
|---|---|---|
int, float |
yes | yes (int stays int, float stays float) |
bool, str, bytes |
yes | no (runs as Python) |
NumPy array (self.data[i] = v writes into it) |
yes | no rebinding |
| anything else (lists, dicts, other objects) | runs as Python | runs as Python |
Instance attributes, class attributes (rate = 0.01 in the class body), __slots__ and dataclasses all work. These run as Python, with the same result:
- properties and other descriptors, custom
__getattribute__or__setattr__, frozen dataclasses (assigning raisesFrozenInstanceErrorexactly as in Python); - calling a method on the object (
self.helper()), passing the object on, comparing it,del self.x:explain()suggests making the helper a plain function that takes the values; - the same object passed twice when attributes are assigned.
Web apps and threads¶
A compiled call runs without holding Python's global interpreter lock, so CPU-heavy work in a web endpoint can run in a worker thread while the event loop keeps serving requests:
import asyncio
from fastapi import FastAPI
app = FastAPI()
@app.get("/similarity")
async def similarity(a: str, b: str):
distance = await asyncio.to_thread(edit_distance, a, b) # compiled with @optimize
return {"distance": distance}
Several threads can run compiled functions at the same time on different cores. For servers without Rust, build the libraries ahead of time (see Deployment).
Free-threaded Python (3.14t). On the free-threaded build there is no GIL at all, so the Python code between compiled calls runs in parallel too. PyRust loads no C extension, so it never turns the GIL back on; CI runs the full test suite on 3.14t and checks that the GIL stays off. python -m pyrust_engine doctor says which build you are on. pyrust_engine.ai.batch(fn, items) runs any function over many items on a thread pool.
Call cost. A function whose arguments are numbers, NumPy arrays, str or bytes and whose result is a number, bool or None is called by CPython directly: a C-level entry point checks the argument types (exact int/float/bool/str/bytes/numpy.ndarray, dtype, C order, alignment, writability, no overlapping writes), the function's code and every global it uses, runs the native code without the GIL, and builds the result, in about 0.2 µs for numbers and 0.3–0.5 µs with arrays or strings (since 0.7.4 for numbers, 0.7.5 for arrays and strings). Anything else takes the normal path, so results never change. fn.report()['dispatch'] is 'direct' when this is in use; PYRUST_DIRECT_CALLS=0 turns it off. Lists, dicts, objects and returned strings or arrays cost 1–3 µs per call. Functions without a loop still run as plain Python by default (a Python call costs about 0.05 µs).
Parallel loops¶
from pyrust_engine import optimize, prange
@optimize
def transform(values, out) -> int:
positive = 0
for i in prange(len(values)):
out[i] = math.sqrt(abs(values[i])) * 2.0
if values[i] > 0:
positive += 1 # int totals are allowed: they add up exactly in any order
return positive
Rules that keep results identical to Python: each iteration writes only its own element (out[i]), arrays it writes are read only at [i], and the only shared updates are += on int totals. Float totals are rejected because their result depends on the order of additions.
What runs as Python¶
Classes and methods, closures, lambdas, generators, try/with, print(), float formatting, NumPy slices (they are views), mixed-type lists/dicts, float dict keys, loops over sets, global mutable set/dict/list, round(x, ndigits), math.hypot, numpy.random. Each comes with a hint in explain(), or use python -m pyrust_engine suggest file.py.
NumPy scalars
In plain Python, a[i] and for x in a give NumPy scalars (np.float64). PyRust returns plain float/int with the same value. Comparisons between ints larger than 2**53 and floats replay in Python, because NumPy and Python compare them differently.