Skip to content

Tuples

A tuple of known length and scalar elements is passed and returned unboxed. tuple[float, float] is two doubles in the ABI. Passed to a native function it is two arguments, returned it is two result slots, and the boundary boxes the pair back into a Python tuple only on the way out.

Run it

python  tuples.ppy
ppy     tuples.ppy
ppy run tuples.ppy

Pairs without a heap object

@ppy.pure
@ppy.opt(3)
def divmod_pair(a: int, b: int) -> tuple[int, int]:
    return (a // b, a % b)

midpoint allocates nothing on the native path. divmod_pair returns a quotient and a remainder without a heap object between them. divmod_pair(-17, 5) is (-4, 3): floor division and a divisor-signed remainder, as Python's.

The rule is exact. These stay boxed:

  • a homogeneous tuple[int, ...], which has no known length
  • a tuple wider than the ABI allows

Compared with Numba

A million divmod_pair calls from Python, and a native loop that unpacks a tuple parameter and walks eight million steps, in compare/: pairs_bench.ppy (under ppy run and, the same file, under python) and pairs_numba.py. Milliseconds, best of five, over five processes.

Tuples are native in both. PPy spells the types; Numba infers them at the first call:

@ppy.pure
@ppy.opt(3)
def walk(start: tuple[float, float], count: int) -> float:
    x, y = start
    for i in range(count):
        x, y = (x + float(i)) / 2.0, (y + 1.0) / 2.0
    return x + y
@njit
def walk(start, count):
    x, y = start
    for i in range(count):
        x, y = (x + float(i)) / 2.0, (y + 1.0) / 2.0
    return x + y
PPy ppy run CPython, the same file Numba @njit
divmod_pair, a million calls from Python 32.78 ± 1.40 47.86 ± 0.33 107.03 ± 5.77
walk, eight million steps from a tuple natively 11.26 ± 0.12 316.30 ± 2.14 11.25 ± 0.19

The loop is the same machine code on both: two doubles in registers, nothing allocated. The call from Python is where they differ, and the difference is the boundary. PPy's generated wrapper unpacks two ints and boxes a pair on the way out; Numba's dispatcher types the arguments on every call.

The loop here takes the tuple as a parameter and rebuilds it in place; see Limitations for why.

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 tuple handed from one native function to another does not lower yet. midpoint(point, ...) in a native loop keeps the loop in Python (ppy explain says a tuple result cannot be forwarded between native calls yet), so the loop in the comparison takes the tuple as a parameter and rebuilds it in place.

What it prints

python tuples.ppy, ppy tuples.ppy, ppy run tuples.ppy

(2.0, 3.0)
25.0
(3, 2) (-4, 3)

Read on: Native data · Numerics

tuples.ppy is hand-written; there is no .py source and no conversion step.

14_tuples/tuples.ppy

import ppy


@ppy.pure
@ppy.opt(3)
def midpoint(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]:
    return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)


@ppy.pure
@ppy.opt(3)
def norm2(point: tuple[float, float]) -> float:
    return point[0] * point[0] + point[1] * point[1]


@ppy.pure
@ppy.opt(3)
def divmod_pair(a: int, b: int) -> tuple[int, int]:
    return (a // b, a % b)


def main() -> None:
    print(midpoint((0.0, 0.0), (4.0, 6.0)))
    print(norm2((3.0, 4.0)))
    print(divmod_pair(17, 5), divmod_pair(-17, 5))


main()

Counterpart programs

The programs the comparison above measured, each written the way its tool expects. The PPy one is first.

pairs_bench.ppy (PPy)
"""Tuples two ways: a million `divmod_pair` calls from Python, and a native loop that unpacks a
tuple parameter and walks eight million steps without allocating. The same file under `python` is
the CPython column."""

import time

import ppy


@ppy.pure
@ppy.opt(3)
def divmod_pair(a: int, b: int) -> tuple[int, int]:
    return (a // b, a % b)


@ppy.pure
@ppy.opt(3)
def walk(start: tuple[float, float], count: int) -> float:
    x, y = start
    for i in range(count):
        x, y = (x + float(i)) / 2.0, (y + 1.0) / 2.0
    return x + y


def main() -> None:
    walk((0.0, 0.0), 1000)
    best_calls = 1e9
    best_loop = 1e9
    for _ in range(5):
        started = time.perf_counter()
        acc = 0
        for i in range(1_000_000):
            q, r = divmod_pair(i, 7)
            acc += q - r
        best_calls = min(best_calls, (time.perf_counter() - started) * 1000.0)
        started = time.perf_counter()
        far = walk((0.0, 0.0), 8_000_000)
        best_loop = min(best_loop, (time.perf_counter() - started) * 1000.0)
    print(f"# divmod_pair, a million calls from Python: {best_calls:.2f} ms")
    print(f"# walk, eight million steps from a tuple natively: {best_loop:.2f} ms")
    print(f"{acc} {far:.3f}")


main()
pairs_numba.py (Python)
"""The same work under Numba: tuples are native there too, in `@njit` functions."""

import time

from numba import njit


@njit
def divmod_pair(a, b):
    return (a // b, a % b)


@njit
def walk(start, count):
    x, y = start
    for i in range(count):
        x, y = (x + float(i)) / 2.0, (y + 1.0) / 2.0
    return x + y


def main():
    divmod_pair(1, 7)
    walk((0.0, 0.0), 1000)
    best_calls = 1e9
    best_loop = 1e9
    for _ in range(5):
        started = time.perf_counter()
        acc = 0
        for i in range(1_000_000):
            q, r = divmod_pair(i, 7)
            acc += q - r
        best_calls = min(best_calls, (time.perf_counter() - started) * 1000.0)
        started = time.perf_counter()
        far = walk((0.0, 0.0), 8_000_000)
        best_loop = min(best_loop, (time.perf_counter() - started) * 1000.0)
    print(f"# divmod_pair, a million calls from Python: {best_calls:.2f} ms")
    print(f"# walk, eight million steps from a tuple natively: {best_loop:.2f} ms")
    print(f"{acc} {far:.3f}")


main()

Source: examples/14_tuples.