Skip to content

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

python  value_classes.ppy
ppy     value_classes.ppy
ppy run value_classes.ppy

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.