Generics¶
Generic functions use the Python 3.12 type-parameter syntax, and PPy infers the type arguments at each call and checks them against their bounds. Native code monomorphizes: one instance per tuple of type arguments.
Run it¶
Bounds lend what they promise¶
class Named(Protocol):
def name(self) -> str: ...
def label[T: Named](thing: T) -> str:
return "at " + thing.name()
largest(1, 2) is an int and largest(1.5, 2.5) a float. The call
infers T, checks it against the bound int | float (E1721 otherwise),
and gets the declared return type with T substituted.
- An unbounded
Thas no operators at all. - A bound that is a
Protocollends its methods, solabelmay callname(). Point, whose members coverNamed's, is an instance ofNamedwithout declaring so.
One instance per tuple of type arguments¶
def sweep(n: int) -> float:
total = 0.0
for i in range(n):
total += clamp(i * 0.25, 1.0, 10.0) + largest(i, 3)
return total
sweep is native and calls clamp with floats and largest with ints.
Each generic is lowered once per tuple of type arguments, under a name that
spells them. ppy emit ir shows the instances, marked ppy.generic. The
calls inside sweep go straight to those instances, and the generics keep
their Python bodies for every other caller.
Limits on specialization¶
[tool.ppy.generics] bounds the process:
max-specializations: instances per generic.max-depth: depth of a type argument.
A generic that calls itself with its own parameter wrapped in a type is
refused outright (E1723), because its specializations would never end.
Compared with Numba¶
sweep over eight million values, in compare/:
generic_bench.ppy, underppy runand, the same file, underpythongeneric_numba.py
Times are milliseconds, best of five, over five processes.
PPy is the generics as Python 3.12 spells them, with bounds, called from
a plain function. Numba is the same three functions under @njit,
generic by dispatch: each is compiled once per tuple of argument types it
meets. That is the same instance-per-type-tuple rule without the
declaration.
def largest[T: int | float](a: T, b: T) -> T:
return a if a > b else b
def clamp[T: int | float](x: T, lo: T, hi: T) -> T:
return largest(lo, x) if x < hi else hi
@njit
def largest(a, b):
return a if a > b else b
@njit
def clamp(x, lo, hi):
return largest(lo, x) if x < hi else hi
PPy ppy run |
CPython, the same file | Numba @njit |
|
|---|---|---|---|
| sweep, eight million clamps and comparisons | 5.87 ± 0.03 | 428.92 ± 9.67 | 5.09 ± 0.06 |
Both compile largest twice (once for ints, once for floats) and call the
instances directly from the loop, and the loop is the same code either way.
- PPy adds a check at the source. The bound is written, so
largest("a", 1)is refused before anything runs, and the file is still the filepythonruns. - Numba asks you to write nothing. The types are whatever arrives first.
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.
What it prints¶
python generic.ppy, ppy run generic.ppy
ppy emit ir generic.ppy
97 lines
ppyir 1
module @generic
dialect core 1
func @generic_sweep(%n: i64) -> f64 attrs {effects = ["may_raise"], ppy.abi = "ppy", ppy.qualname = "generic.sweep", ppy.releases_gil = true, ppy.symbol = "ppy_generic_sweep"} loc("examples/40_generics/generic.ppy":33:0) {
^entry:
%n_addr = core.alloca : ptr<i64, stack> loc("examples/40_generics/generic.ppy":33:0)
core.store %n, %n_addr
%0 = core.const 0.0 : f64 loc("examples/40_generics/generic.ppy":34:4)
%total_addr = core.alloca : ptr<f64, stack>
core.store %0, %total_addr
%1 = core.load %n_addr : i64 loc("examples/40_generics/generic.ppy":35:4)
%n_entry = core.load %n_addr : i64
%2 = core.const 0 : i64
%3 = core.const 1 : i64
%i_addr = core.alloca : ptr<i64, stack>
%4 = core.const 3 : i64
core.store %2, %i_addr loc("examples/40_generics/generic.ppy":35:4)
core.br ^for.head3
^for.head3:
%5 = core.load %i_addr : i64 loc("examples/40_generics/generic.ppy":35:4)
%6 = core.cmp.lt %5, %1 : bool
core.cond_br %6, ^for.body4, ^for.end6
^for.body4:
%7 = core.load %total_addr : f64 loc("examples/40_generics/generic.ppy":36:8)
%8 = core.load %i_addr : i64
%9 = core.const 0.25 : f64
%10 = core.cast %8 : f64
%11 = core.mul %10, %9 : f64
%12 = core.const 1.0 : f64
%13 = core.const 10.0 : f64
%14 = core.call %11, %12, %13 {callee = @generic_clamp__float} : f64
%15 = core.load %i_addr : i64
%16 = core.call %15, %4 {callee = @generic_largest__int} : i64
%17 = core.cast %16 : f64
%18 = core.add %14, %17 : f64
%19 = core.add %7, %18 : f64
core.store %19, %total_addr
%20 = core.load %i_addr : i64
%21 = core.add %20, %3 {overflow = "python"} : i64
core.store %21, %i_addr
core.br ^for.head3
^for.end6:
%22 = core.load %total_addr : f64 loc("examples/40_generics/generic.ppy":37:4)
core.ret %22
}
func @generic_clamp__float(%x: f64, %lo: f64, %hi: f64) -> f64 attrs {effects = [], ppy.abi = "ppy", ppy.generic = "generic.clamp", ppy.qualname = "generic.clamp__float", ppy.releases_gil = true, ppy.symbol = "ppy_generic_clamp__float", ppy.type_arguments = ["float"]} loc("examples/40_generics/generic.ppy":29:0) {
^entry:
%x_addr = core.alloca : ptr<f64, stack> loc("examples/40_generics/generic.ppy":29:0)
core.store %x, %x_addr
%lo_addr = core.alloca : ptr<f64, stack>
core.store %lo, %lo_addr
%hi_addr = core.alloca : ptr<f64, stack>
core.store %hi, %hi_addr
%0 = core.load %x_addr : f64 loc("examples/40_generics/generic.ppy":30:4)
%1 = core.load %hi_addr : f64
%2 = core.cmp.lt %0, %1 : bool
%3 = core.load %lo_addr : f64
%4 = core.load %x_addr : f64
%5 = core.call %3, %4 {callee = @generic_largest__float} : f64
%6 = core.load %hi_addr : f64
%7 = core.select %2, %5, %6 : f64
core.ret %7
}
func @generic_largest__float(%a: f64, %b: f64) -> f64 attrs {effects = [], ppy.abi = "ppy", ppy.generic = "generic.largest", ppy.qualname = "generic.largest__float", ppy.releases_gil = true, ppy.symbol = "ppy_generic_largest__float", ppy.type_arguments = ["float"]} loc("examples/40_generics/generic.ppy":17:0) {
^entry:
%a_addr = core.alloca : ptr<f64, stack> loc("examples/40_generics/generic.ppy":17:0)
core.store %a, %a_addr
%b_addr = core.alloca : ptr<f64, stack>
core.store %b, %b_addr
%0 = core.load %a_addr : f64 loc("examples/40_generics/generic.ppy":18:4)
%1 = core.load %b_addr : f64
%2 = core.cmp.gt %0, %1 : bool
%3 = core.load %a_addr : f64
%4 = core.load %b_addr : f64
%5 = core.select %2, %3, %4 : f64
core.ret %5
}
func @generic_largest__int(%a: i64, %b: i64) -> i64 attrs {effects = [], ppy.abi = "ppy", ppy.generic = "generic.largest", ppy.qualname = "generic.largest__int", ppy.releases_gil = true, ppy.symbol = "ppy_generic_largest__int", ppy.type_arguments = ["int"]} loc("examples/40_generics/generic.ppy":17:0) {
^entry:
%a_addr = core.alloca : ptr<i64, stack> loc("examples/40_generics/generic.ppy":17:0)
core.store %a, %a_addr
%b_addr = core.alloca : ptr<i64, stack>
core.store %b, %b_addr
%0 = core.load %a_addr : i64 loc("examples/40_generics/generic.ppy":18:4)
%a_entry = core.load %a_addr : i64
%1 = core.load %b_addr : i64
%b_entry = core.load %b_addr : i64
%2 = core.cmp.gt %0, %1 : bool
%3 = core.load %a_addr : i64
%4 = core.load %b_addr : i64
%5 = core.select %2, %3, %4 : i64
core.ret %5
}
Read on: Generics · Value classes
generic.ppy is hand-written; there is no .py source and no conversion step.
40_generics/generic.ppy¶
from typing import Protocol
class Named(Protocol):
def name(self) -> str: ...
class Point:
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
def name(self) -> str:
return f"({self.x}, {self.y})"
def largest[T: int | float](a: T, b: T) -> T:
return a if a > b else b
def label[T: Named](thing: T) -> str:
return "at " + thing.name()
def first[T](items: list[T]) -> T:
return items[0]
def clamp[T: int | float](x: T, lo: T, hi: T) -> T:
return largest(lo, x) if x < hi else hi
def sweep(n: int) -> float:
total = 0.0
for i in range(n):
total += clamp(i * 0.25, 1.0, 10.0) + largest(i, 3)
return total
def main() -> None:
print(largest(1, 2), largest(1.5, 2.5), label(Point(3, 4)), first([7, 8, 9]))
print(clamp(15, 0, 10), clamp(-2.5, 0.0, 1.0), sweep(1000))
main()
Counterpart programs¶
The programs the comparison above measured, each written the way its tool expects. The PPy one is first.
generic_bench.ppy (PPy)
"""`sweep` over eight million values: two generics instantiated once each per type
argument, called from a native loop. The same file under `python` is the CPython column."""
import time
def largest[T: int | float](a: T, b: T) -> T:
return a if a > b else b
def clamp[T: int | float](x: T, lo: T, hi: T) -> T:
return largest(lo, x) if x < hi else hi
def sweep(n: int) -> float:
total = 0.0
for i in range(n):
total += clamp(i * 0.25, 1.0, 10.0) + largest(i, 3)
return total
def main() -> None:
sweep(1000)
best = 1e9
for _ in range(5):
started = time.perf_counter()
total = sweep(8_000_000)
best = min(best, (time.perf_counter() - started) * 1000.0)
print(f"# sweep, eight million clamps and comparisons: {best:.2f} ms")
print(f"{total:.1f}")
main()
generic_numba.py (Python)
"""The same work under Numba: an `@njit` function is generic by dispatch, compiled once
per argument types it meets, which is the same instance-per-type-tuple rule."""
import time
from numba import njit
@njit
def largest(a, b):
return a if a > b else b
@njit
def clamp(x, lo, hi):
return largest(lo, x) if x < hi else hi
@njit
def sweep(n):
total = 0.0
for i in range(n):
total += clamp(i * 0.25, 1.0, 10.0) + largest(i, 3)
return total
def main():
sweep(1000)
best = 1e9
for _ in range(5):
started = time.perf_counter()
total = sweep(8_000_000)
best = min(best, (time.perf_counter() - started) * 1000.0)
print(f"# sweep, eight million clamps and comparisons: {best:.2f} ms")
print(f"{total:.1f}")
main()
Source: examples/40_generics.