Skip to content

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

python  generic.ppy
ppy run generic.ppy
ppy emit ir generic.ppy

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 T has no operators at all.
  • A bound that is a Protocol lends its methods, so label may call name().
  • Point, whose members cover Named's, is an instance of Named without 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/:

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 file python runs.
  • 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

2 2.5 at (3, 4) 7
10 0.0 509303.5

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.