Source
scripts/bench/integral_threads.purr
1
# Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).2
# Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.3
# integral_threads.purr — the trapezoid integral of integral.purr, parallelized with the4
# `thread` module: N workers each integrate a contiguous chunk into a LOCAL sum, then make ONE5
# lease write into a shared memory.Owner<float> — coordination at the edges, never in the hot6
# loop. Prints, for 1/2/4/8 workers: the integral (must match the single-thread value) and the7
# wall-clock seconds. The single-file baseline lives in integral.py / integral.purr.8
#9
# STATISTICS AND STRIATION. This used to run each worker count exactly once, in the order10
# 1, 2, 4, 8 — and docs/performance.md then described the resulting table as the "median of11
# repeated runs", which it could not have been. Two things are wrong with one shot per12
# configuration. There is no dispersion, so a single descheduled run is indistinguishable13
# from a real effect; and running the four configurations as consecutive blocks means the14
# 8-worker row is measured last, on the hottest silicon, which biases exactly the number the15
# table is about.16
#17
# So: ROUNDS passes, and each pass runs all four configurations back to back. Any thermal18
# drift across the session now moves all four rows together instead of tilting the scaling19
# curve, and each row reports a median with its spread rather than one sample.20
import io21
import math22
import memory23
import statistics24
import stamp25
import sys26
import thread27
import time29
# One pass runs all four configurations; ROUNDS passes are taken. 7 is the smallest count30
# that gives a median a real middle and still leaves a visible [min, max] range.31
fn rounds() { return 7 }33
struct Sample {34
integral: float35
secs: float36
}38
fn points() { return 20000000 }39
fn width() { return 50.0 / points() }41
# Integrate f(x) = sin(x)·e^(−x/100) over grid points [lo, hi) into a local sum; one lease42
# write at the end (an Owner lease in a 20M-iteration loop would time the lock, not the math).43
fn chunk(o : memory.Owner<float>, lo : int, hi : int) {44
let h = width()45
let s = 0.046
for i in range(lo, hi) {47
let x = h * i48
s = s + math.sin(x) * math.exp(-0.01 * x)49
}50
with o.rwrite().acquire() as w {51
w.write(w.read() + s)52
}53
}55
# The trapezoid endpoint correction (the half-weight first/last samples of integral.purr).56
fn edges() -> float {57
let b = 50.058
return 0.5 * (math.sin(0.0) * math.exp(0.0) + math.sin(b) * math.exp(-0.01 * b))59
}61
fn integral_of(total : float) -> float { return (total + edges()) * width() }63
# median, spread and range for one configuration's samples. `statistics` has no quantiles(),64
# and there is no sort builtin to compute an IQR by hand, so the spread reported is the65
# standard deviation plus the observed [min, max] — enough for a reader to see whether a66
# gap between two rows is larger than the noise that produced it.67
fn report(tag : str, integral : float, samples : list<float>) {68
let lo = samples[0]69
let hi = samples[0]70
for v in samples {71
if v < lo { lo = v }72
if v > hi { hi = v }73
}74
io.print(tag, integral, statistics.median(samples), statistics.stdev(samples), lo, hi)75
}77
fn ms(v : float) -> str { return io.fixed(v * 1000.0, 0) + " ms" }79
# One row of the published table. `speedup` is against the single-worker median; the spread is80
# the standard deviation, because `statistics` has no quantiles() and there is no sort builtin81
# to compute an IQR by hand — reported as `σ` so the reader knows which it is.82
fn row(workers : str, samples : list<float>, base : float, integral : float) -> str {83
let med = statistics.median(samples)84
let sp = "—"85
if base > 0.0 and workers != "1" { sp = "**" + io.fixed(base / med, 2) + "×**" }86
let out = "| " + workers + " | " + ms(med) + " | ±" + ms(statistics.stdev(samples))87
out = out + " | " + sp + " | " + io.str(integral) + " |\n"88
return out89
}91
fn run_1() {92
let o = memory.own(0.0)93
let t0 = time.monotonic()94
chunk(o, 1, points())95
let t1 = time.monotonic()96
return Sample(integral_of(o.rread().acquire().read()), t1 - t0)97
}99
fn run_2() {100
let o = memory.own(0.0)101
let n = points()102
let t0 = time.monotonic()103
with thread.spawn(chunk, o, 1, n / 2) {104
with thread.spawn(chunk, o, n / 2, n) {105
}106
}107
let t1 = time.monotonic()108
return Sample(integral_of(o.rread().acquire().read()), t1 - t0)109
}111
fn run_4() {112
let o = memory.own(0.0)113
let n = points()114
let q = n / 4115
let t0 = time.monotonic()116
with thread.spawn(chunk, o, 1, q) {117
with thread.spawn(chunk, o, q, 2 * q) {118
with thread.spawn(chunk, o, 2 * q, 3 * q) {119
with thread.spawn(chunk, o, 3 * q, n) {120
}121
}122
}123
}124
let t1 = time.monotonic()125
return Sample(integral_of(o.rread().acquire().read()), t1 - t0)126
}128
fn run_8() {129
let o = memory.own(0.0)130
let n = points()131
let e = n / 8132
let t0 = time.monotonic()133
with thread.spawn(chunk, o, 1, e) {134
with thread.spawn(chunk, o, e, 2 * e) {135
with thread.spawn(chunk, o, 2 * e, 3 * e) {136
with thread.spawn(chunk, o, 3 * e, 4 * e) {137
with thread.spawn(chunk, o, 4 * e, 5 * e) {138
with thread.spawn(chunk, o, 5 * e, 6 * e) {139
with thread.spawn(chunk, o, 6 * e, 7 * e) {140
with thread.spawn(chunk, o, 7 * e, n) {141
}142
}143
}144
}145
}146
}147
}148
}149
let t1 = time.monotonic()150
return Sample(integral_of(o.rread().acquire().read()), t1 - t0)151
}153
fn main() {154
let s1: list<float> = []155
let s2: list<float> = []156
let s4: list<float> = []157
let s8: list<float> = []158
let i1 = 0.0159
let i2 = 0.0160
let i4 = 0.0161
let i8 = 0.0162
# Striated: one round touches every configuration before any configuration is repeated.163
for r in range(0, rounds()) {164
let a = run_1()165
let b = run_2()166
let c = run_4()167
let d = run_8()168
s1.append(a.secs)169
s2.append(b.secs)170
s4.append(c.secs)171
s8.append(d.secs)172
i1 = a.integral173
i2 = b.integral174
i4 = c.integral175
i8 = d.integral176
}177
io.print("workers integral median_s stdev_s min_s max_s")178
report("1:", i1, s1)179
report("2:", i2, s2)180
report("4:", i4, s4)181
report("8:", i8, s8)183
# A published table is GENERATED or it is not published. sys.argv[1] is where184
# scripts/bench_table.purr expects the artifact: docs/bench/thread-scaling.md.185
if len(sys.argv) > 1 {186
let base = statistics.median(s1)187
let t = "| workers | wall time (median) | spread | speedup | integral |\n"188
t = t + "|--------:|-------------------:|-------:|--------:|----------|\n"189
t = t + row("1", s1, base, i1)190
t = t + row("2", s2, base, i2)191
t = t + row("4", s4, base, i4)192
t = t + row("8", s8, base, i8)193
let st = stamp.build("thread-scaling",194
"stdlib/thread/, stdlib/memory/, scripts/bench/integral_threads.purr",195
"none — cheatah against itself at 1/2/4/8 workers",196
rounds(),197
"median wall clock; ± is the sample standard deviation",198
"purrc scripts/bench/integral_threads.purr -o /tmp/it.so --import-root scripts && cheatah /tmp/it.so docs/bench/thread-scaling.md")199
stamp.write_region(sys.argv[1], st, t)200
}201
}203
main()