Skip to content

Numerics

PPy gives the same answers as CPython for overflow, floor division, and the sign of the remainder, cases where several other native compilers differ.

Run it

python  numerics.ppy
ppy     numerics.ppy
ppy run numerics.ppy

Overflow falls back

@ppy.pure
@ppy.opt(3)
def may_overflow(n: int) -> int:
    result: int = 1
    for i in range(1, n + 1):
        result *= i
    return result

Each result *= i is an overflow-checking multiply. may_overflow(20) runs twenty of them natively. may_overflow(30) sets the flag on the twenty-first, the function returns to its Python body, and CPython finishes with arbitrary precision: the 33-digit number Python prints.

Under ppy run and ppy build the guards are on by default. --unsafe on either produces a wrap-semantics artifact.

Floor, not truncation

@ppy.pure
@ppy.opt(3)
def floor_semantics(a: int, b: int) -> int:
    return a // b

C rounds toward zero. Python rounds toward negative infinity and gives the remainder the divisor's sign. The IR marks the operation rounding = "floor", and the LLVM backend emits the sign-corrected sequence, or a single arithmetic shift when the divisor is a power of two. The shift is one reason the collatz kernel keeps up with its C twin.

floor_semantics(-7, 2) is -4 and modulo_semantics(7, -2) is -1 on every path.

Compared with Numba, Codon, Mojo, C, and Rust

The same three functions, written for five other compilers, in compare/: semantics_numba.py, semantics_codon.py, semantics.mojo, semantics.c, and semantics.rs.

There is nothing to time here. The question is what each prints for may_overflow(30), floor_semantics(-7, 2), and modulo_semantics(7, -2). The answers Python gives are 265252859812191058636308480000000, -4, and -1.

may_overflow(30) -7 // 2 7 % -2 what the code says
Python, PPy on every path 265252859812191058636308480000000 -4 -1 int is an integer
Numba @njit -8764578968847253504 -4 -1 64-bit wrap; Python's floor and sign
Mojo 1.0 Int -8764578968847253504 -4 -1 64-bit wrap; Python's floor and sign
Codon -8764578968847253504 -3 1 64-bit wrap; C's truncation and sign
C long long (gcc, -O0 and -O3) -8764578968847253504 -3 1 signed overflow is undefined; truncation
Rust i64, release -8764578968847253504 -3 1 wraps; truncation
Rust i64, debug panics: attempt to multiply with overflow -3 1 checked, then aborts
  • Numba and Mojo keep Python's division and remainder and wrap the multiply silently.
  • Codon, C, and Rust in release keep C's rounding and wrap.
  • Rust in debug is the only other one that notices the overflow, and its answer is to stop.

PPy notices and continues in Python: the same function, the same source, the number Python prints. --unsafe on ppy run or ppy build buys the wrap-semantics row, and says so.

Numba 0.67.0 on CPython 3.12.13, Codon 0.19.6, Mojo 1.0.0, gcc 13.3, rustc 1.95.0.

What it prints

python numerics.ppy, ppy numerics.ppy, ppy run numerics.ppy

1000000
2432902008176640000
265252859812191058636308480000000
-4 3
1 -1

Read on: Arbitrary precision ยท The IR

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

11_numerics/numerics.ppy

import ppy


@ppy.pure
@ppy.opt(3)
def machine_range(a: int, b: int) -> int:
    return a * b


@ppy.pure
@ppy.opt(3)
def may_overflow(n: int) -> int:
    result: int = 1
    for i in range(1, n + 1):
        result *= i
    return result


@ppy.pure
@ppy.opt(3)
def floor_semantics(a: int, b: int) -> int:
    return a // b


@ppy.pure
@ppy.opt(3)
def modulo_semantics(a: int, b: int) -> int:
    return a % b


def main() -> None:
    print(machine_range(1000, 1000))
    print(may_overflow(20))
    print(may_overflow(30))
    print(floor_semantics(-7, 2), floor_semantics(7, 2))
    print(modulo_semantics(-7, 2), modulo_semantics(7, -2))


main()

Counterpart programs

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

semantics.c (C)
/* The same three questions put to C: signed overflow is undefined, division truncates. */
#include <stdio.h>

static long long may_overflow(long long n) {
    long long result = 1;
    for (long long i = 1; i <= n; i++) result *= i;
    return result;
}

static long long floor_semantics(long long a, long long b) { return a / b; }
static long long modulo_semantics(long long a, long long b) { return a % b; }

int main(void) {
    printf("%lld\n", may_overflow(20));
    printf("%lld\n", may_overflow(30));
    printf("%lld %lld\n", floor_semantics(-7, 2), floor_semantics(7, 2));
    printf("%lld %lld\n", modulo_semantics(-7, 2), modulo_semantics(7, -2));
    return 0;
}
semantics.mojo (Mojo)
"""The same three questions put to Mojo 1.0: `Int` is a machine word."""


def may_overflow(n: Int) -> Int:
    var result = 1
    for i in range(1, n + 1):
        result *= i
    return result


def floor_semantics(a: Int, b: Int) -> Int:
    return a // b


def modulo_semantics(a: Int, b: Int) -> Int:
    return a % b


def main():
    print(may_overflow(20))
    print(may_overflow(30))
    print(floor_semantics(-7, 2), floor_semantics(7, 2))
    print(modulo_semantics(-7, 2), modulo_semantics(7, -2))
semantics.rs (Rust)
//! The same three questions put to Rust: `i64` panics on overflow in debug, wraps in release.

fn may_overflow(n: i64) -> i64 {
    let mut result: i64 = 1;
    for i in 1..=n {
        result *= i;
    }
    result
}

fn floor_semantics(a: i64, b: i64) -> i64 {
    a / b
}

fn modulo_semantics(a: i64, b: i64) -> i64 {
    a % b
}

fn main() {
    println!("{}", may_overflow(20));
    println!("{}", may_overflow(30));
    println!("{} {}", floor_semantics(-7, 2), floor_semantics(7, 2));
    println!("{} {}", modulo_semantics(-7, 2), modulo_semantics(7, -2));
}
semantics_codon.py (Python)
"""The same three questions put to Codon: Python syntax, a 64-bit `int`."""


def may_overflow(n: int) -> int:
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result


def floor_semantics(a: int, b: int) -> int:
    return a // b


def modulo_semantics(a: int, b: int) -> int:
    return a % b


print(may_overflow(20))
print(may_overflow(30))
print(floor_semantics(-7, 2), floor_semantics(7, 2))
print(modulo_semantics(-7, 2), modulo_semantics(7, -2))
semantics_numba.py (Python)
"""The three questions of `numerics.ppy` put to Numba: `@njit` on a machine integer."""

from numba import njit


@njit
def may_overflow(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result


@njit
def floor_semantics(a, b):
    return a // b


@njit
def modulo_semantics(a, b):
    return a % b


print(may_overflow(20))
print(may_overflow(30))
print(floor_semantics(-7, 2), floor_semantics(7, 2))
print(modulo_semantics(-7, 2), modulo_semantics(7, -2))

Source: examples/11_numerics.