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¶
Pairs without a heap object¶
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
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.