Buffers and JIT¶
Borrowed memory, reassociation, and specialization each set how fast a numeric function runs, and each is chosen once in a signature or a decorator.
Run it¶
Buffer[T] is borrowed, a list is copied¶
@ppy.pure
@ppy.opt(3)
def total_view(values: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(values)):
result += values[i]
return result
total_list takes a list[float] and is copied into a buffer on every
call. total_view takes a Buffer[float] (a memoryview over an
array.array) and reads the caller's memory in place. Same loop, same sum,
about 10× apart on 8,192 elements, because one of them pays for 8,192 boxed
floats per call.
A buffer is borrowed unless the signature says otherwise. The callee may not
keep it (E1612) or write through it without Mut[...] (E1613).
Reassociation is opt-in¶
dot_strict and dot_relaxed are the same dot product. The strict one
keeps CPython's accumulation order and matches it bit for bit. The
@ppy.fastmath one lets LLVM vectorize the reduction and differs in the
last bits; the program prints the gap. You make the choice per function, and
the default is the exact one.
@ppy.jit specializes on the values it sees¶
@ppy.jit(threshold=4, max_specializations=4)
@ppy.pure
@ppy.opt(3)
def digest_jit(values: Buffer[int], modulus: int) -> int:
After four calls with the same modulus, a version specialized to that
constant is compiled, and % 1000003 becomes multiply-and-shift instead of
a division. The guard on the value is in the generated C wrapper. A call
with a different modulus falls back to the generic version or compiles
another specialization, up to four.
Compared with Numba, Cython, NumPy, and C¶
The three kernels over 8,192 elements: a sum, a dot product in order and
reassociated, and a modular digest. Each call is timed as the mean of 2,000
calls, best of five rounds, milliseconds per call over five processes. The
programs are in compare/: kernels_bench.ppy,
kernels_numba.py, kernels_cython.pyx,
kernels_numpy.py, kernels.c.
At this size a call is a few microseconds, so the table is as much about the
call boundary as about the loop.
PPy is the loop as written, with a Buffer[float] borrowed from an
array.array and @ppy.fastmath where reassociation is allowed:
@ppy.pure
@ppy.opt(3)
def dot(a: Buffer[float], b: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(a)):
result += a[i] * b[i]
return result
Numba is the same loop under @njit, with no annotations, NumPy arrays,
and fastmath=True for the relaxed dot. Its dispatcher types the arguments
on every call:
Cython is typed memoryviews and cdef locals in a .pyx, built into an
extension by cythonize -i. boundscheck=False is what makes it a C loop,
and there is no relaxed dot to write:
cpdef double dot(double[::1] a, double[::1] b):
cdef double result = 0.0
cdef Py_ssize_t i
for i in range(a.shape[0]):
result += a[i] * b[i]
return result
NumPy has no loop: np.dot is BLAS, np.sum is pairwise, and
(counts % m).sum() builds a temporary.
C is the loop with no Python around it, timed inside the process: the floor.
PPy ppy run |
Numba @njit |
Cython | NumPy | C (the loop alone) | |
|---|---|---|---|---|---|
| total | 0.0035 ± 0.0000 | 0.0036 ± 0.0000 | 0.0036 ± 0.0001 | 0.0021 ± 0.0001 | 0.0034 ± 0.0001 |
| dot | 0.0035 ± 0.0000 | 0.0036 ± 0.0000 | 0.0036 ± 0.0000 | 0.0012 ± 0.0001 | 0.0035 ± 0.0001 |
| dot_relaxed | 0.0011 ± 0.0001 | 0.0011 ± 0.0000 | 0.0036 ± 0.0001 | 0.0012 ± 0.0001 | 0.0034 ± 0.0000 |
| digest | 0.0072 ± 0.0000 | 0.0112 ± 0.0001 | 0.0078 ± 0.0001 | 0.0199 ± 0.0002 | 0.0045 ± 0.0001 |
The ordered sums sit on the C loop in PPy, Numba, and Cython alike, a
microsecond of call above it. The reassociated dot is where @ppy.fastmath
and Numba's fastmath=True let the vectorizer in, and Cython, with no such
switch, stays scalar.
NumPy's dot is BLAS and the fastest row. Its digest pays for an
8,192-element temporary and is the slowest. The C digest is the loop's
floor; what stands between it and the others is the price of a call through
the interpreter.
Intel Core Ultra 9 386H; Numba 0.67.0, Cython 3.3.0, NumPy 2.5.3 on CPython 3.12.13, gcc 13.3, PPy on CPython 3.13.13, from a checkout on a native filesystem.
What it prints¶
python buffers_and_jit.ppy
list[float] copied 104.387 us
Buffer[float] borrowed 158.837 us 0.66x, same sum: True
dot, strict order 277.649 us
dot, @ppy.fastmath 257.140 us 1.08x, differs by 0.00e+00
digest, generic 263.962 us
digest, @ppy.jit 260.730 us 1.01x, same: True
ppy buffers_and_jit.ppy
list[float] copied 102.127 us
Buffer[float] borrowed 162.701 us 0.63x, same sum: True
dot, strict order 261.217 us
dot, @ppy.fastmath 259.543 us 1.01x, differs by 0.00e+00
digest, generic 270.856 us
digest, @ppy.jit 268.401 us 1.01x, same: True
ppy run buffers_and_jit.ppy
list[float] copied 11.790 us
Buffer[float] borrowed 3.726 us 3.16x, same sum: True
dot, strict order 4.196 us
dot, @ppy.fastmath 0.849 us 4.94x, differs by 4.07e-10
digest, generic 11.656 us
digest, @ppy.jit 8.874 us 1.31x, same: True
Read on: Directives and markers · Algorithms
buffers_and_jit.ppy is hand-written; there is no .py source and no
conversion step.
12_buffers_and_jit/buffers_and_jit.ppy¶
import array
import time
import ppy
from ppy import Buffer
@ppy.pure
@ppy.opt(3)
def total_list(values: list[float]) -> float:
result: float = 0.0
for i in range(len(values)):
result += values[i]
return result
@ppy.pure
@ppy.opt(3)
def total_view(values: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(values)):
result += values[i]
return result
@ppy.pure
@ppy.opt(3)
def dot_strict(a: Buffer[float], b: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(a)):
result += a[i] * b[i]
return result
@ppy.pure
@ppy.fastmath
@ppy.opt(3)
def dot_relaxed(a: Buffer[float], b: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(a)):
result += a[i] * b[i]
return result
@ppy.jit(threshold=4, max_specializations=4)
@ppy.pure
@ppy.opt(3)
def digest_jit(values: Buffer[int], modulus: int) -> int:
result: int = 0
for i in range(len(values)):
result += values[i] % modulus
return result
@ppy.pure
@ppy.opt(3)
def digest_generic(values: Buffer[int], modulus: int) -> int:
result: int = 0
for i in range(len(values)):
result += values[i] % modulus
return result
def report(label: str, us: float, note: str) -> None:
print(f"{label:24s} {us:8.3f} us {note}")
def main() -> None:
size: int = 8192
raw: list[float] = [float(i) * 0.001 for i in range(size)]
x = array.array("d", raw)
y = array.array("d", [float(i) * 0.002 for i in range(size)])
counts = array.array("q", [i * 7919 for i in range(size)])
rounds: int = 2000
for _i in range(400):
total_list(raw)
total_view(x)
dot_strict(x, y)
dot_relaxed(x, y)
digest_jit(counts, 1000003)
digest_generic(counts, 1000003)
start: float = time.perf_counter()
for _i in range(rounds):
copied = total_list(raw)
copied_us: float = (time.perf_counter() - start) / rounds * 1e6
start = time.perf_counter()
for _i in range(rounds):
borrowed = total_view(x)
borrowed_us: float = (time.perf_counter() - start) / rounds * 1e6
report("list[float] copied", copied_us, "")
ratio = copied_us / borrowed_us
report("Buffer[float] borrowed", borrowed_us, f"{ratio:.2f}x, same sum: {copied == borrowed}")
start = time.perf_counter()
for _i in range(rounds):
strict = dot_strict(x, y)
strict_us: float = (time.perf_counter() - start) / rounds * 1e6
start = time.perf_counter()
for _i in range(rounds):
relaxed = dot_relaxed(x, y)
relaxed_us: float = (time.perf_counter() - start) / rounds * 1e6
report("dot, strict order", strict_us, "")
ratio = strict_us / relaxed_us
gap = abs(strict - relaxed)
report("dot, @ppy.fastmath", relaxed_us, f"{ratio:.2f}x, differs by {gap:.2e}")
start = time.perf_counter()
for _i in range(rounds):
generic = digest_generic(counts, 1000003)
generic_us: float = (time.perf_counter() - start) / rounds * 1e6
start = time.perf_counter()
for _i in range(rounds):
specialized = digest_jit(counts, 1000003)
specialized_us: float = (time.perf_counter() - start) / rounds * 1e6
report("digest, generic", generic_us, "")
ratio = generic_us / specialized_us
report("digest, @ppy.jit", specialized_us, f"{ratio:.2f}x, same: {generic == specialized}")
if __name__ == "__main__":
main()
Counterpart programs¶
The programs the comparison above measured, each written the way its tool expects. The PPy one is first.
kernels_bench.ppy (PPy)
"""The kernels of `buffers_and_jit.ppy` on 8,192 elements, microseconds per call: the PPY side."""
import array
import time
import ppy
from ppy import Buffer
@ppy.pure
@ppy.opt(3)
def total(values: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(values)):
result += values[i]
return result
@ppy.pure
@ppy.opt(3)
def dot(a: Buffer[float], b: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(a)):
result += a[i] * b[i]
return result
@ppy.pure
@ppy.fastmath
@ppy.opt(3)
def dot_relaxed(a: Buffer[float], b: Buffer[float]) -> float:
result: float = 0.0
for i in range(len(a)):
result += a[i] * b[i]
return result
@ppy.jit(threshold=4, max_specializations=4)
@ppy.pure
@ppy.opt(3)
def digest(values: Buffer[int], modulus: int) -> int:
result: int = 0
for i in range(len(values)):
result += values[i] % modulus
return result
def main() -> None:
size = 8192
x = array.array("d", [float(i) * 0.001 for i in range(size)])
y = array.array("d", [float(i) * 0.002 for i in range(size)])
counts = array.array("q", [i * 7919 for i in range(size)])
for _ in range(50):
total(x)
dot(x, y)
dot_relaxed(x, y)
digest(counts, 1000003)
rounds = 2000
best = [1e9, 1e9, 1e9, 1e9]
for _ in range(5):
started = time.perf_counter()
for _i in range(rounds):
total(x)
best[0] = min(best[0], (time.perf_counter() - started) * 1000.0 / rounds)
started = time.perf_counter()
for _i in range(rounds):
dot(x, y)
best[1] = min(best[1], (time.perf_counter() - started) * 1000.0 / rounds)
started = time.perf_counter()
for _i in range(rounds):
dot_relaxed(x, y)
best[2] = min(best[2], (time.perf_counter() - started) * 1000.0 / rounds)
started = time.perf_counter()
for _i in range(rounds):
digest(counts, 1000003)
best[3] = min(best[3], (time.perf_counter() - started) * 1000.0 / rounds)
labels = ["total", "dot", "dot_relaxed", "digest"]
for label, took in zip(labels, best, strict=True):
print(f"# {label}: {took:.4f} ms")
relaxed = round(dot_relaxed(x, y), 3)
print(round(total(x), 6), round(dot(x, y), 6), relaxed, digest(counts, 1000003))
main()
kernels.c (C)
/* The same kernels in C, the loops alone: what the machine does with no call boundary. */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static double total(const double *values, int64_t n) {
double result = 0.0;
for (int64_t i = 0; i < n; i++) result += values[i];
return result;
}
static double dot(const double *a, const double *b, int64_t n) {
double result = 0.0;
for (int64_t i = 0; i < n; i++) result += a[i] * b[i];
return result;
}
static int64_t digest(const int64_t *values, int64_t n, int64_t modulus) {
int64_t result = 0;
for (int64_t i = 0; i < n; i++) result += values[i] % modulus;
return result;
}
/* `round(v, places)` printed the way Python prints it: no trailing zeros. */
static const char *shortest(char *out, double value, int places) {
snprintf(out, 64, "%.*f", places, value);
char *end = out + strlen(out) - 1;
while (*end == '0') *end-- = 0;
if (*end == '.') *end = 0;
return out;
}
static double now(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (double)t.tv_sec + (double)t.tv_nsec * 1e-9;
}
#define TIMED(label, expr, rounds, sink) \
do { \
double best = 1e9; \
for (int r = 0; r < 5; r++) { \
double started = now(); \
for (int i = 0; i < (rounds); i++) { sink = (expr); asm volatile("" : : "g"(&sink) : "memory"); } \
double took = (now() - started) / (rounds); \
if (took < best) best = took; \
} \
printf("# %s: %.4f ms\n", label, best * 1000.0); \
} while (0)
int main(void) {
const int64_t n = 8192;
double *x = malloc(n * sizeof *x), *y = malloc(n * sizeof *y);
int64_t *counts = malloc(n * sizeof *counts);
for (int64_t i = 0; i < n; i++) {
x[i] = (double)i * 0.001;
y[i] = (double)i * 0.002;
counts[i] = i * 7919;
}
volatile double d;
volatile int64_t k;
TIMED("total", total(x, n), 2000, d);
TIMED("dot", dot(x, y, n), 2000, d);
TIMED("dot_relaxed", dot(x, y, n), 2000, d);
TIMED("digest", digest(counts, n, 1000003), 2000, k);
char a[64], b[64], c[64];
printf("%s %s %s %lld\n", shortest(a, total(x, n), 6), shortest(b, dot(x, y, n), 6),
shortest(c, dot(x, y, n), 3), (long long)digest(counts, n, 1000003));
free(x); free(y); free(counts);
return 0;
}
kernels_cython.pyx (Cython)
# cython: language_level=3, boundscheck=False, wraparound=False
"""The same kernels in Cython: typed memoryviews and C locals, built by `cythonize`."""
import time
cpdef double total(double[::1] values):
cdef double result = 0.0
cdef Py_ssize_t i
for i in range(values.shape[0]):
result += values[i]
return result
cpdef double dot(double[::1] a, double[::1] b):
cdef double result = 0.0
cdef Py_ssize_t i
for i in range(a.shape[0]):
result += a[i] * b[i]
return result
cpdef long long digest(long long[::1] values, long long modulus):
cdef long long result = 0
cdef Py_ssize_t i
for i in range(values.shape[0]):
result += values[i] % modulus
return result
def timed(label, run, rounds):
best = 1e9
answer = None
for _ in range(5):
started = time.perf_counter()
for _i in range(rounds):
answer = run()
best = min(best, (time.perf_counter() - started) / rounds)
print(f"# {label}: {best * 1000:.4f} ms")
return answer
def main():
import array
size = 8192
x = array.array("d", [float(i) * 0.001 for i in range(size)])
y = array.array("d", [float(i) * 0.002 for i in range(size)])
counts = array.array("q", [i * 7919 for i in range(size)])
timed("total", lambda: total(x), 2000)
timed("dot", lambda: dot(x, y), 2000)
timed("dot_relaxed", lambda: dot(x, y), 2000)
timed("digest", lambda: digest(counts, 1000003), 2000)
print(round(total(x), 6), round(dot(x, y), 6), round(dot(x, y), 3), digest(counts, 1000003))
if __name__ == "__main__":
main()
kernels_numba.py (Python)
"""The same kernels under Numba: `@njit` over NumPy arrays."""
import time
import numpy as np
from numba import njit
@njit(cache=False)
def total(values):
result = 0.0
for i in range(len(values)):
result += values[i]
return result
@njit(cache=False)
def dot(a, b):
result = 0.0
for i in range(len(a)):
result += a[i] * b[i]
return result
@njit(cache=False, fastmath=True)
def dot_relaxed(a, b):
result = 0.0
for i in range(len(a)):
result += a[i] * b[i]
return result
@njit(cache=False)
def digest(values, modulus):
result = 0
for i in range(len(values)):
result += values[i] % modulus
return result
def timed(label, run, rounds):
best = 1e9
answer = None
for _ in range(5):
started = time.perf_counter()
for _i in range(rounds):
answer = run()
best = min(best, (time.perf_counter() - started) / rounds)
print(f"# {label}: {best * 1000:.4f} ms")
return answer
def main():
size = 8192
x = np.arange(size, dtype=np.float64) * 0.001
y = np.arange(size, dtype=np.float64) * 0.002
counts = np.arange(size, dtype=np.int64) * 7919
for _ in range(50):
total(x)
dot(x, y)
dot_relaxed(x, y)
digest(counts, 1000003)
timed("total", lambda: total(x), 2000)
timed("dot", lambda: dot(x, y), 2000)
timed("dot_relaxed", lambda: dot_relaxed(x, y), 2000)
timed("digest", lambda: digest(counts, 1000003), 2000)
print(round(total(x), 6), round(dot(x, y), 6), round(dot_relaxed(x, y), 3), digest(counts, 1000003))
main()
kernels_numpy.py (Python)
"""The same kernels as NumPy array expressions: no loop to write."""
import time
import numpy as np
def timed(label, run, rounds):
best = 1e9
answer = None
for _ in range(5):
started = time.perf_counter()
for _i in range(rounds):
answer = run()
best = min(best, (time.perf_counter() - started) / rounds)
print(f"# {label}: {best * 1000:.4f} ms")
return answer
def main():
size = 8192
x = np.arange(size, dtype=np.float64) * 0.001
y = np.arange(size, dtype=np.float64) * 0.002
counts = np.arange(size, dtype=np.int64) * 7919
timed("total", lambda: float(np.sum(x)), 2000)
timed("dot", lambda: float(np.dot(x, y)), 2000)
timed("dot_relaxed", lambda: float(np.dot(x, y)), 2000)
timed("digest", lambda: int((counts % 1000003).sum()), 2000)
print(
round(float(np.sum(x)), 6),
round(float(np.dot(x, y)), 6),
round(float(np.dot(x, y)), 3),
int((counts % 1000003).sum()),
)
main()
Source: examples/12_buffers_and_jit.