Value classes¶
An all-scalar @dataclass has no boxed form in native code. Vec3 is
three floats, and native code treats it that way: distance2(a, b) passes
six doubles, not two object pointers, and a.norm2() is a native function
whose self is three scalars.
Run it¶
Flattened at the ABI, guarded on the class¶
@dataclass
class Vec3:
x: float
y: float
z: float
@ppy.pure
@ppy.opt(3)
def norm2(self) -> float:
return self.x * self.x + self.y * self.y + self.z * self.z
The generated boundary reads the fields, checks the class is exactly
Vec3, and calls the native function with scalars.
distance2(Tracked(1.0, 2.0, 3.0), b) shows the guard: Tracked is a
subclass, the exact-class check refuses it, and the Python body answers. A
class too wide for the ABI stays boxed, and ppy explain says so.
Eight float operations cost about 50 ns per call from Python here, most of
it the boundary. steps runs a 200-iteration loop over a Ray's two fields
at about 200 ns per call, with the Ray never leaving registers.
Operators dispatch statically¶
Inside native code a + b on a value class calls the class's own
__add__, lowered like any other native function. There is no fallback to
Python's dynamic dispatch and no __radd__ search. A class without a native
operator is refused with the reason.
Compared with Numba's @jitclass¶
A million distance2 calls from Python, and a native loop over a Ray's
two fields, in compare/:
vectors_bench.ppy (under ppy run and, the
same file, under python) and
vectors_numba.py. Milliseconds, best of five,
over five processes.
PPy is a @dataclass and functions over it. Numba's nearest thing is a
@jitclass with a typed field list, whose instances are Numba's own
objects rather than Python ones:
@dataclass
class Ray:
origin: float
direction: float
@ppy.pure
@ppy.opt(3)
def travel(ray: Ray, count: int) -> float:
position: float = ray.origin
total: float = 0.0
for i in range(count):
position = position * 0.999999 + ray.direction * (i % 3)
total += position
return total
@jitclass([("origin", float64), ("direction", float64)])
class Ray:
def __init__(self, origin, direction):
self.origin = origin
self.direction = direction
@njit
def travel(ray, count):
position = ray.origin
total = 0.0
for i in range(count):
position = position * 0.999999 + ray.direction * (i % 3)
total += position
return total
PPy ppy run |
CPython, the same file | Numba @jitclass |
|
|---|---|---|---|
| distance2, a million calls from Python | 62.18 ± 0.47 | 63.13 ± 0.71 | 349.84 ± 7.47 |
| travel, eight million steps over a Ray natively | 11.16 ± 0.19 | 262.98 ± 2.15 | 10.97 ± 0.17 |
Inside a native loop the two are the same code: the Ray is two doubles in
registers on both. The difference is the boundary.
Eight float operations are too little work to see past it. A PPy value
class is still a Python dataclass: the generated boundary reads its fields
and passes scalars, and a million calls cost what CPython takes to run the
body itself. A @jitclass instance crosses into an @njit function through
Numba's dispatcher and its own object layout, several times that per call.
The native loop is where the work is, and there the two agree. The loop here takes the class as a parameter.
Intel Core Ultra 9 386H; Numba 0.67.0 on CPython 3.12.13, PPy on CPython 3.14.5, from a checkout on a native filesystem.
Limitations¶
A value class can be built inside a native loop: Vec3(float(i), 1.0, 0.5)
is a struct of three doubles, with no object behind it. Two things keep a
function in Python:
- assigning a field in place (
v.x = 1.0), since native code holds a value class by value and the write would not reach the object Python sees - building one without naming every field, since native code does not evaluate a dataclass's defaults
What it prints¶
python value_classes.ppy
14.0 25.0 25.0 7.0
25.0
# native: False
distance2 (8 float ops) 58.8 ns/call
steps (200 iters) 4551.9 ns/call
ppy value_classes.ppy
14.0 25.0 25.0 7.0
25.0
# native: False
distance2 (8 float ops) 60.7 ns/call
steps (200 iters) 4451.3 ns/call
ppy run value_classes.ppy
14.0 25.0 25.0 7.0
25.0
# native: False
distance2 (8 float ops) 58.4 ns/call
steps (200 iters) 322.6 ns/call
Read on: Generics · Native data
value_classes.ppy is hand-written; there is no .py source and no
conversion step.
13_value_classes/value_classes.ppy¶
import time
from dataclasses import dataclass
import ppy
@dataclass
class Vec3:
x: float
y: float
z: float
@ppy.pure
@ppy.opt(3)
def norm2(self) -> float:
return self.x * self.x + self.y * self.y + self.z * self.z
@ppy.pure
@ppy.opt(3)
def dot(self, other: "Vec3") -> float:
return self.x * other.x + self.y * other.y + self.z * other.z
@dataclass
class Ray:
origin: float
direction: float
@ppy.pure
@ppy.opt(3)
def distance2(a: Vec3, b: Vec3) -> float:
dx: float = a.x - b.x
dy: float = a.y - b.y
dz: float = a.z - b.z
return dx * dx + dy * dy + dz * dz
@ppy.pure
def at(ray: Ray, t: float) -> float:
return ray.origin + ray.direction * t
@ppy.pure
@ppy.opt(3)
def steps(start: Ray, limit: int) -> int:
position: float = start.origin
taken: int = 0
while position < 1000000.0 and taken < limit:
position = position * 1.000001 + start.direction
taken += 1
return taken
class Tracked(Vec3):
pass
def main() -> None:
a = Vec3(1.0, 2.0, 3.0)
b = Vec3(4.0, 6.0, 3.0)
print(a.norm2(), a.dot(b), distance2(a, b), at(Ray(1.0, 2.0), 3.0))
print(distance2(Tracked(1.0, 2.0, 3.0), b))
print("# native:", getattr(Vec3.norm2, "__ppy_native__", None) is not None)
ray = Ray(1.0, 0.5)
for _i in range(3000):
distance2(a, b)
steps(ray, 200)
rounds: int = 200000
start: float = time.perf_counter()
for _i in range(rounds):
distance2(a, b)
print(f"distance2 (8 float ops) {(time.perf_counter() - start) / rounds * 1e9:8.1f} ns/call")
rounds = 20000
start = time.perf_counter()
for _i in range(rounds):
steps(ray, 200)
print(f"steps (200 iters) {(time.perf_counter() - start) / rounds * 1e9:8.1f} ns/call")
if __name__ == "__main__":
main()
Counterpart programs¶
The programs the comparison above measured, each written the way its tool expects. The PPy one is first.
vectors_bench.ppy (PPy)
"""Value classes two ways: a million `distance2` calls from Python, and a native loop over a
`Ray`'s fields that never boxes it. The same file under `python` is the CPython column."""
import time
from dataclasses import dataclass
import ppy
@dataclass
class Vec3:
x: float
y: float
z: float
@ppy.pure
@ppy.opt(3)
def norm2(self) -> float:
return self.x * self.x + self.y * self.y + self.z * self.z
@ppy.pure
@ppy.opt(3)
def distance2(a: Vec3, b: Vec3) -> float:
dx: float = a.x - b.x
dy: float = a.y - b.y
dz: float = a.z - b.z
return dx * dx + dy * dy + dz * dz
@dataclass
class Ray:
origin: float
direction: float
@ppy.pure
@ppy.opt(3)
def travel(ray: Ray, count: int) -> float:
"""A native loop over a value class's fields: the `Ray` is two doubles in registers."""
position: float = ray.origin
total: float = 0.0
for i in range(count):
position = position * 0.999999 + ray.direction * (i % 3)
total += position
return total
def main() -> None:
a = Vec3(1.0, 2.0, 3.0)
b = Vec3(0.5, 0.25, 0.125)
ray = Ray(1.0, 0.001)
travel(ray, 1000)
best_calls = 1e9
best_loop = 1e9
for _ in range(5):
started = time.perf_counter()
acc = 0.0
for _i in range(1_000_000):
acc += distance2(a, b)
best_calls = min(best_calls, (time.perf_counter() - started) * 1000.0)
started = time.perf_counter()
total = travel(ray, 8_000_000)
best_loop = min(best_loop, (time.perf_counter() - started) * 1000.0)
print(f"# distance2, a million calls from Python: {best_calls:.2f} ms")
print(f"# travel, eight million steps over a Ray natively: {best_loop:.2f} ms")
print(f"{acc:.6f} {total:.3f}")
main()
vectors_numba.py (Python)
"""The same work under Numba: `@jitclass`es of float fields, `@njit` functions over them."""
import time
from numba import float64, njit
from numba.experimental import jitclass
@jitclass([("x", float64), ("y", float64), ("z", float64)])
class Vec3:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def norm2(self):
return self.x * self.x + self.y * self.y + self.z * self.z
@njit
def distance2(a, b):
dx = a.x - b.x
dy = a.y - b.y
dz = a.z - b.z
return dx * dx + dy * dy + dz * dz
@jitclass([("origin", float64), ("direction", float64)])
class Ray:
def __init__(self, origin, direction):
self.origin = origin
self.direction = direction
@njit
def travel(ray, count):
position = ray.origin
total = 0.0
for i in range(count):
position = position * 0.999999 + ray.direction * (i % 3)
total += position
return total
def main():
a = Vec3(1.0, 2.0, 3.0)
b = Vec3(0.5, 0.25, 0.125)
distance2(a, b)
ray = Ray(1.0, 0.001)
travel(ray, 1000)
best_calls = 1e9
best_loop = 1e9
for _ in range(5):
started = time.perf_counter()
acc = 0.0
for _i in range(1_000_000):
acc += distance2(a, b)
best_calls = min(best_calls, (time.perf_counter() - started) * 1000.0)
started = time.perf_counter()
total = travel(ray, 8_000_000)
best_loop = min(best_loop, (time.perf_counter() - started) * 1000.0)
print(f"# distance2, a million calls from Python: {best_calls:.2f} ms")
print(f"# travel, eight million steps over a Ray natively: {best_loop:.2f} ms")
print(f"{acc:.6f} {total:.3f}")
main()
Source: examples/13_value_classes.