Skip to content

The toolbox

One small program, run through each tool that looks inside its build: the IR at each stage, the source backends, the optimization report, the sanitizers, and profile-guided optimization. The program is ordinary (a loop with a branch, an indexed read, a function nobody calls), so the tools have something to say.

Run it

python  toolbox.ppy
ppy run toolbox.ppy
ppy inspect toolbox.ppy --stage analysis
ppy emit ir toolbox.ppy
ppy emit c toolbox.ppy
ppy build toolbox.ppy --report-opt
ppy run --sanitize bounds,overflow toolbox.ppy
ppy run --profile toolbox.ppy
ppy build --pgo toolbox.ppyprof toolbox.ppy --report-opt

What it prints

python toolbox.ppy, ppy run toolbox.ppy, ppy run --sanitize bounds,overflow toolbox.ppy, ppy run --profile toolbox.ppy

975000 798

ppy inspect toolbox.ppy --stage analysis

; ---- toolbox [analysis] ----
module toolbox
  toolbox.compute: (list[int]) -> int
    effects: MayRaise[ZeroDivisionError]
    native: eligible
    boundary: bound (takes a buffer)
  toolbox.pick: (list[int], int) -> int
    effects: MayRaise[IndexError]
    native: eligible
    boundary: bound (takes a buffer)
  toolbox.unused: (int) -> int
    effects: none
    native: eligible
    boundary: native callers only (the boundary crossing costs more than the body saves)
  toolbox.main: () -> NoneType
    effects: Alloc, IO, MayRaise[IndexError, KeyError, TypeError, ValueError, ZeroDivisionError], WriteObject
    native: stays in Python: has effects that must run on CPython: IO

ppy emit ir toolbox.ppy

82 lines
ppyir 1
module @toolbox
dialect core 1

func @toolbox_compute(%xs: buffer<i64> {ownership = "owned", ppy.kind = "list"}) -> i64 attrs {effects = ["may_raise", "read_memory"], ppy.abi = "ppy", ppy.qualname = "toolbox.compute", ppy.releases_gil = true, ppy.symbol = "ppy_toolbox_compute"} loc("examples/42_toolbox/toolbox.ppy":1:0) {
^entry:
    %0 = core.const 0 : i64 loc("examples/42_toolbox/toolbox.ppy":2:4)
    %total_addr = core.alloca : ptr<i64, stack>
    core.store %0, %total_addr
    %xs_i_addr = core.alloca : ptr<i64, stack> loc("examples/42_toolbox/toolbox.ppy":3:4)
    %1 = core.const 0 : i64
    core.store %1, %xs_i_addr
    %x_addr = core.alloca : ptr<i64, stack>
    %2 = core.buffer_len %xs : index
    %3 = core.cast %2 : i64
    %4 = core.const 4 : i64
    %5 = core.const 0 : i64
    %6 = core.const 1 : i64
    core.br ^each.head1 loc("examples/42_toolbox/toolbox.ppy":3:4)
^each.head1:
    %7 = core.load %xs_i_addr : i64 loc("examples/42_toolbox/toolbox.ppy":3:4)
    %8 = core.cmp.lt %7, %3 : bool
    core.cond_br %8, ^each.body2, ^each.end4
^each.body2:
    %9 = core.load %xs_i_addr : i64 loc("examples/42_toolbox/toolbox.ppy":3:4)
    %10 = core.buffer_load %xs, %9 : i64
    core.store %10, %x_addr
    %11 = core.load %x_addr : i64 loc("examples/42_toolbox/toolbox.ppy":4:8)
    %12 = core.mod %11, %4 {overflow = "python", rounding = "floor"} : i64
    %13 = core.cmp.eq %12, %5 : bool
    core.cond_br %13, ^then5, ^else6
^each.end4:
    %14 = core.load %total_addr : i64 loc("examples/42_toolbox/toolbox.ppy":8:4)
    core.ret %14
^then5:
    %15 = core.load %total_addr : i64 loc("examples/42_toolbox/toolbox.ppy":5:12)
    %16 = core.load %x_addr : i64
    %17 = core.add %15, %16 {overflow = "python"} : i64
    core.store %17, %total_addr
    core.br ^endif7
^else6:
    %18 = core.load %total_addr : i64 loc("examples/42_toolbox/toolbox.ppy":7:12)
    %19 = core.sub %18, %6 {overflow = "python"} : i64
    core.store %19, %total_addr
    core.br ^endif7
^endif7:
    %20 = core.const 1 : i64 loc("examples/42_toolbox/toolbox.ppy":7:12)
    %21 = core.load %xs_i_addr : i64
    %22 = core.add %21, %20 {overflow = "wrap"} : i64
    core.store %22, %xs_i_addr
    core.br ^each.head1
}

func @toolbox_pick(%xs: buffer<i64> {ownership = "owned", ppy.kind = "list"}, %i: i64) -> i64 attrs {effects = ["may_raise", "read_memory"], ppy.abi = "ppy", ppy.qualname = "toolbox.pick", ppy.releases_gil = true, ppy.symbol = "ppy_toolbox_pick"} loc("examples/42_toolbox/toolbox.ppy":11:0) {
^entry:
    %i_addr = core.alloca : ptr<i64, stack> loc("examples/42_toolbox/toolbox.ppy":11:0)
    core.store %i, %i_addr
    %0 = core.load %i_addr : i64 loc("examples/42_toolbox/toolbox.ppy":12:4)
    %i_entry = core.load %i_addr : i64
    %1 = core.const 0 : i64
    %2 = core.buffer_len %xs : index
    %3 = core.cast %2 : i64
    %4 = core.cmp.ge %0, %1 : bool
    %5 = core.cmp.lt %0, %3 : bool
    %6 = core.and %4, %5 : bool
    core.guard %6 {kind = "bounds", message = "index out of range"}
    %7 = core.buffer_load %xs, %0 : i64
    %8 = core.const 2 : i64
    %9 = core.mul %7, %8 {overflow = "python"} : i64
    core.ret %9
}

func @toolbox_unused(%n: i64) -> i64 attrs {effects = [], ppy.abi = "ppy", ppy.qualname = "toolbox.unused", ppy.releases_gil = true, ppy.symbol = "ppy_toolbox_unused"} loc("examples/42_toolbox/toolbox.ppy":15:0) {
^entry:
    %n_addr = core.alloca : ptr<i64, stack> loc("examples/42_toolbox/toolbox.ppy":15:0)
    core.store %n, %n_addr
    %0 = core.load %n_addr : i64 loc("examples/42_toolbox/toolbox.ppy":16:4)
    %n_entry = core.load %n_addr : i64
    %1 = core.const 7 : i64
    %2 = core.mul %0, %1 {overflow = "python"} : i64
    core.ret %2
}

ppy emit c toolbox.ppy

79 lines
/* toolbox: generated by ppy, C11 */
#include <math.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>

static inline int ppy_ovf_add_i64(int64_t a, int64_t b, int64_t *out) {
#if defined(__GNUC__) || defined(__clang__)
    return __builtin_add_overflow(a, b, out);
#else
    if ((b > 0 && a > INT64_MAX - b) || (b < 0 && a < INT64_MIN - b)) {
        return 1;
    }
    *out = a + b;
    return 0;
#endif
}

static inline int ppy_ovf_sub_i64(int64_t a, int64_t b, int64_t *out) {
#if defined(__GNUC__) || defined(__clang__)
    return __builtin_sub_overflow(a, b, out);
#else
    if ((b < 0 && a > INT64_MAX + b) || (b > 0 && a < INT64_MIN + b)) {
        return 1;
    }
    *out = a - b;
    return 0;
#endif
}

static inline int ppy_ovf_mul_i64(int64_t a, int64_t b, int64_t *out) {
#if defined(__GNUC__) || defined(__clang__)
    return __builtin_mul_overflow(a, b, out);
#else
    if (a > 0) {
        if (b > 0) { if (a > INT64_MAX / b) return 1; }
        else if (b < INT64_MIN / a) return 1;
    } else if (b > 0) {
        if (a < INT64_MIN / b) return 1;
    } else if (a != 0 && b < INT64_MAX / a) return 1;
    *out = a * b;
    return 0;
#endif
}

int32_t ppy_toolbox_compute(int64_t *xs, int64_t xs_len, int64_t *out);
int32_t ppy_toolbox_pick(int64_t *xs, int64_t xs_len, int64_t i, int64_t *out);
int32_t ppy_toolbox_unused(int64_t n, int64_t *out);

int32_t ppy_toolbox_compute(int64_t *xs, int64_t xs_len, int64_t *out) {
    int64_t total = 0;
    int64_t xs_i = 0;
    while (xs_i < xs_len) {
        int64_t x = xs[xs_i];
        if ((x % 4 + 4) % 4 == 0) {
            if (ppy_ovf_add_i64(total, x, &total)) return 1; /* arith.ok */
        } else {
            if (ppy_ovf_sub_i64(total, 1, &total)) return 1; /* arith.ok */
        }
        xs_i = (int64_t)((uint64_t)xs_i + 1u);
    }
    *out = total;
    return 0;
}

int32_t ppy_toolbox_pick(int64_t *xs, int64_t xs_len, int64_t i, int64_t *out) {
    if (i < 0 || i >= xs_len) return 1; /* bounds.ok */
    int64_t t1;
    if (ppy_ovf_mul_i64(xs[i], 2, &t1)) return 1; /* arith.ok */
    *out = t1;
    return 0;
}

int32_t ppy_toolbox_unused(int64_t n, int64_t *out) {
    int64_t t1;
    if (ppy_ovf_mul_i64(n, 7, &t1)) return 1; /* arith.ok */
    *out = t1;
    return 0;
}

ppy build toolbox.ppy --report-opt

optimization report: PPy (O2, ir road)
module toolbox
  toolbox.compute: native, bound to Python
  toolbox.pick: native, bound to Python
  toolbox.unused: native, native callers only
  toolbox.main: Python -- has effects that must run on CPython: IO
  block merged: 1
  dead code removed: 2
  note: 2
    - @toolbox_compute: core.cmp: folded 4 and 0
    - @toolbox_compute: core.guard: condition always holds
    - simplify-cfg: ^each.latch3 merged into ^endif7
    - dce: unused core.const removed
    - dce: unused core.const removed

ppy build --pgo toolbox.ppyprof toolbox.ppy --report-opt

22 lines
optimization report: PPy (O2, ir road)
profile: toolbox.ppyprof (1 run, hot from 2 calls)
  toolbox.compute: hot, 50 calls; arguments 0: list[400] x50
  toolbox.pick: cold, 0 calls
  toolbox.unused: cold, 0 calls
module toolbox
  toolbox.compute: native, bound to Python
  toolbox.pick: native, bound to Python
  toolbox.unused: native, native callers only
  toolbox.main: Python -- has effects that must run on CPython: IO
  block merged: 1
  dead code removed: 2
  note: 2
  profile applied: 3
    - @toolbox_compute: core.cmp: folded 4 and 0
    - @toolbox_compute: core.guard: condition always holds
    - profile: `toolbox.compute` is hot (50 call(s)); 2 branch(es) weighted, 1 loop(s) with trip counts
    - profile: `toolbox.pick` is cold (0 call(s)); 0 branch(es) weighted, 0 loop(s) with trip counts
    - profile: `toolbox.unused` is cold (0 call(s)); 0 branch(es) weighted, 0 loop(s) with trip counts
    - simplify-cfg: ^each.latch3 merged into ^endif7
    - dce: unused core.const removed
    - dce: unused core.const removed

The IR at each stage

ppy inspect toolbox.ppy --stage analysis   # what the checker knows of each function
ppy inspect toolbox.ppy --stage ir         # the frontend's module, before any pass
ppy inspect toolbox.ppy --stage optimized  # what the backend receives
ppy emit ir toolbox.ppy                    # the same module as .ppyir text
ppy emit c toolbox.ppy                     # one C11 translation unit; `cpp` for C++17
ppy emit llvm-ir toolbox.ppy               # what LLVM is handed
  • analysis says of each function whether it is native and whether it gets a Python boundary. unused is native but "native callers only", because a two-instruction body is not worth a boundary crossing.
  • emit c writes a translation unit that compiles alone with any C11 compiler. It answers what the LLVM road answers, guards and fallbacks included.

The optimization report

ppy build toolbox.ppy --report-opt
ppy build toolbox.ppy --report-opt-json report.json

The report first says whether each function is native, and why. Then it lists every remark the passes left, by stable category (block merged, dead code removed, function inlined, bounds guard removed), so a tool can count them across versions.

Sanitizers

ppy run --sanitize bounds,overflow toolbox.ppy

A sanitizer instruments the IR with checks the program did not ask for: every buffer index, every wrapping or proven integer operation. A check that fails is not a fallback. The function returns a sanitizer status, and the boundary raises SanitizerFailure naming the kind and the function.

Profile-guided optimization

ppy run --profile toolbox.ppy              # runs, then writes toolbox.ppyprof
ppy build --pgo toolbox.ppyprof toolbox.ppy --report-opt
ppy run --pgo toolbox.ppyprof toolbox.ppy

The profiling run counts every block and the taken edge of every branch. It also records what each native function was called with (list[400], fifty times).

The guided build annotates what still matches:

  • compute is hot.
  • unused is cold and never inlined.
  • The branch on x % 4 carries the weights the run measured, and LLVM receives them as !prof metadata.

The report lists the profile first. A function edited since the profile was recorded is named (W2009) and built as without one. A profile changes what is fast, never what is computed.

Read on: CLI ยท The IR

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

42_toolbox/toolbox.ppy

def compute(xs: list[int]) -> int:
    total = 0
    for x in xs:
        if x % 4 == 0:
            total = total + x
        else:
            total = total - 1
    return total


def pick(xs: list[int], i: int) -> int:
    return xs[i] * 2


def unused(n: int) -> int:
    return n * 7


def main() -> None:
    xs: list[int] = []
    for i in range(400):
        xs.append(i)
    acc = 0
    for _ in range(50):
        acc = acc + compute(xs)
    print(acc, pick(xs, 399))


main()

Source: examples/42_toolbox.