# The budgets that `test_speed.py` checks, for rustymath's Python API. Each section starts with an item type in brackets. Each line under it holds a Python expression and its two native budgets, separated by tabs: first for macOS on Apple silicon, where matrix products and decompositions run on Accelerate, then for Linux on CI's x86 runner, where they run on rustymath's own kernels. `rm.timing().column` picks the budget a machine checks. The expression names the arrays that `test_speed.py` makes. A budget counts copies of 65,536 items of the section's type, the number of items in each case's main input.
# A budget is the case's expected cost times the multiplier that `rustymath_timing::budget` holds, as basedpl's DEV.md describes, and at least 0.5. A line such as `# held linux 14.2: REASON` above a case's line holds that column at the budget for the cost it gives, until the gap it names is fixed, and `# busy` does the same for CI's busy hosts, as `rustymath_timing::hold` reads them. The expected cost starts from a first-principles analysis of what the case should cost. A later expected cost comes from a fuller understanding of the case than that analysis gives, such as a platform difference or a cost the analysis missed. A comment above each case, or above its group, gives the expected cost and its reason, as in "1 copy: one read and one write per item", and records each later change with its reason.
# One expected cost serves both platforms at first. A platform's budget differs only where a specific difference on that platform is found, such as Accelerate's matrix products on macOS.
# A case that costs more than its budget needs study, which finds either a speed bug to fix or something the analysis missed. Avoid raising a budget to match a run without understanding why the case costs what it does.
# After its budgets, a case's line can record the instructions that it retires for each item in one more tab-separated field, as `rustymath_timing::count` reads it, such as `instructions linux 41.5`. `test_speed.py` fails a count more than `rustymath_timing::COUNT_MARGIN` times its record. `basedpl.bpltests.set_counts` writes the counts from a calibration run's log.
[float64]
# 1.6 copies, for two reads and one write per item, plus a tenth of a copy for the Python call.
x+y	3.2	4.7	instructions linux 1.226
# 1.1 copies, for one read and one write, plus a tenth of a copy for the Python call.
x*2	2.2	2.5	instructions linux 1.019
# 1.1 copies, for one read and one write, plus a tenth of a copy for the Python call.
-x	2.2	2	instructions linux 0.941
# 7.1 copies, for Exp's 7 copies, plus a tenth of a copy for the Python call.
rm.exp(x)	15	9.7	instructions linux 10.44
# One real input and one complex output per item; bulk cost is uncalibrated.
rm.cis(x)	-	-
# Error-function slice forms at f64; bulk costs are uncalibrated.
rm.erfc(x)	-	-
rm.erfinv(x)	-	-
# Sorted inputs exercise whole-region Erfc blocks at both widths; costs are uncalibrated.
rm.erfc(xs)	-	-
# 1.9 copies, for reading the Boolean mask, x and y, a select, one write, plus a tenth of a copy for the Python call.
rm.where(mask, x, y)	3.8	5.6	instructions linux 2.173
# 1.6 copies, for one read, two comparisons, one write, plus a tenth of a copy for the Python call.
rm.clip(x, -0.5, 0.5)	3.2	2	instructions linux 1.304
# 1.9 copies, for reading m and a, a select and one write, with a 256-item mask that broadcasts over m's rows and stays in cache, plus a tenth of a copy for the Python call. The broadcast mask takes the general ternary route through `read_runs`, which reads each 256-item repeat of the mask as one item.
rm.where(row > 0, m, a)	3.8	4.3	instructions linux 2.728
# 1.6 copies, for one read of m, two comparisons and one write, with bounds from a 256-item row that broadcasts over m's rows and stays in cache, plus a tenth of a copy for the Python call. The broadcast bounds take the general ternary route through `read_runs`, which reads each 256-item repeat of a bound as one item.
rm.clip(m, -row, row)	3.2	3.8	instructions linux 2.147
# 0.6 copies, for one read per item, plus a tenth of a copy for the Python call.
rm.sum(x)	1.2	0.67	instructions linux 0.542
# 1.1 copies, for one read and one write, plus a tenth of a copy for the Python call.
rm.cumsum(x)	2.2	3	instructions linux 4.136
# 1.1 copies, for reading m and writing the result; the row stays in cache, plus a tenth of a copy for the Python call.
m+row	2.2	2.2	instructions linux 1.373
# 2.6 copies, for a blocked transposed read (about 1), a read and a write, plus a tenth of a copy for the Python call.
m.T+m	5.2	5.8	instructions linux 4.539
# 0.6 copies, for one read per item, plus a tenth of a copy for the Python call.
rm.sum(m.T, axis=0)	1.2	1.3	instructions linux 1.256
# 0.6 copies, for one read per item, plus a tenth of a copy for the Python call.
rm.max(m.T, axis=1)	1.2	1.1	instructions linux 1.399
# 0.6 copies, for one reversed read per item, plus a tenth of a copy for the Python call.
rm.sum(x[::-1])	1.2	0.93	instructions linux 0.817
# 0.8 copies, for one read and two ops per item, plus a tenth of a copy for the Python call.
rm.minmax(m.T)	1.6	1.4	instructions linux 1.754
# 1.6 copies, for the kernel's read and write (1), a gather of each 4,096-item chunk of the reversed view into a cached buffer (0.4), and a tenth of a copy for each of two Python calls, one of which makes the view. Raised on 21 October 2026 from 1.1 copies, which counted the kernel reading through the view in one pass. The route gathers first because kernels read only forward contiguous slices. At this size, a pass over a cached buffer costs about as much as any other pass. A kernel run over the block in storage order, with the result taking the view's mapping, would skip the gather at about 1.15 copies. Nobody has built it.
x[::-1]+1	3.2	3.9	instructions linux 2.729
# 2.4 copies, for two strided gathers into cached buffers, each reading every cache line of its argument and writing half its items (0.75 each), the kernel's two reads and one write on half the items (0.7), and a tenth of a copy for each of three Python calls. Raised on 21 October 2026 from 1.4 copies, which counted the kernel reading through the views in one pass. The route gathers first because kernels read only forward contiguous slices. At this size, a pass over a cached buffer costs about as much as any other pass. A storage-order kernel would still need these gathers, because half of each view's storage holds items outside it.
x[::2]+y[::2]	4.8	6.5	instructions linux 5.242
# 64 copies on Linux, for 256 FMAs per result item at about 4 FMAs in a copy's time on rustymath's own kernels. On macOS, 8.8 copies, at Accelerate's rate for large square products in the header of `tests/speed.txt`, as `linalg::PlusTimes` expects there, and 9.8 for `a[::-1]@b`, which copies its reversed rows first. On macOS they measured 10 to 13 copies on 23 October 2026.
a@b	18	80	instructions linux 147.313
a.T@b	18	83	instructions linux 149.282
a[::-1]@b	20	82	instructions linux 148.853
# 3 copies, for a random gather from L2, with about 16 loads in flight (2), and reading the 8-byte positions (0.5), plus a tenth of a copy for the Python call. The analysis misses the pass that checks the positions before the gather, about 0.5 copies. It measured 5.3 to 5.9 copies on macOS on 23 October 2026, since Python reads index arrays in place as basedpl's `⌷` does, down from 6.1 to 6.3 when it copied them into a list of positions first. On the same 65,536 positions, basedpl's `[p]⌷v` measured 4.7 copies under dynamic kinds and 6.3 under static kinds. Nobody has found the cause of that difference.
x[idx]	6	8.9	instructions linux 1.668
# 4 copies, for reading both position arrays (1), forming each offset, a random gather from L2 (2), a write, plus a tenth of a copy for the Python call. It measured about 16 copies on macOS and 14.2 on CI on 23 October 2026.
# held macos 16: the cause isn't known yet
m[r, c]	8	29	instructions linux 37.683
# 4 copies, for reading idx and y (1), a random read and write of x in L2 (3), plus a tenth of a copy for the Python call. It measured 8.5 to 9.7 copies on macOS on 23 October 2026, down from about 10.6 before writes stopped checking each position that selection had already checked, and 7.86 on 30 October 2026. CI measured 7.55 on 23 October 2026.
# held macos 9.7: the cause isn't known yet
rm.at(rm.add, x, idx, y)	8	18	instructions linux 17.284
# 4 copies, as `rm.at(rm.add, x, idx, y)`, with a maximum's step in place of a sum's: a place written more than once folds its values through the maximum's reduction (`Combine`). It measured 7.89 copies on macOS on 30 October 2026, and CI measured 7.47 on 23 October 2026.
# held macos 7.89: as for `rm.at(rm.add, x, idx, y)`, the cause isn't known yet
rm.at(rm.maximum, x, idx, y)	8	18	instructions linux 17.265
# 56 copies, for an unstable comparison sort of 65,536 float keys, computed once: about 16 levels per item at about 3.5 copies a level, the standard library sort's cost on random keys, which falls with lane length from about 5 at 256 items, plus a pass that computes the keys, one that writes the floats back, and a tenth of a copy for the Python call. A radix sort of the keys costs more below 2^17 items. A sorting network would gain here only as the base case of a long sort, as "A vectorized sort kernel" in basedpl's `meta/rustymath.md` records. It costs about 54 copies on macOS, down from about 87 when each comparison computed two keys.
rm.sort(x)	120	91	instructions linux 149.869
# 3.3 copies, for 9 FMAs per output item (2.25), one read and one write, plus a tenth of a copy for the Python call. On macOS, 5.6 copies, because NEON's two lanes of f64 take twice the FMAs' time (4.5). It measured 6.0 to 6.2 copies there on 23 October 2026, and 6.6 to 7.6 after rustymath's `COLUMN_STRIP` widened `by_columns`' strips to 32 results on 27 October 2026, which left 30 results in each 254-wide output row to one-at-a-time code. Strips of 8 for those results, with each result written rather than added to the zeroed output, brought it to 5.1 to 5.8 on 28 October 2026. CI measured 5.31 on 23 October 2026, and its Linux budget follows its expected cost.
rm.correlate(img, k)	12	7.8	instructions linux 11.475
# 5 copies, for 16 FMAs per output item (4), one read and one write, plus a tenth of a copy for the Python call. It measured 9.2 to 9.3 copies on macOS on 23 October 2026, and 7.02 on 30 October 2026. CI measured 8.24 on 23 October 2026.
rm.convolve(x, kern)	10	9	instructions linux 11.874
# 1.1 copies, for reading each frame item once and adding it into the output, plus a tenth of a copy for the Python call.
rm.overlap_add(frames, 32)	2.2	1.7	instructions linux 2.354
# 1.1 copies, for one copy of the NumPy array's items, plus a tenth of a copy for the Python call.
rm.array(xn)	2.2	1.8	instructions linux 0.143
# 0.1 copies, for the call alone, since the buffer protocol lends the items without copying.
np.asarray(x)	0.5	0.5	instructions linux 0.29
[float32]
# 1.7 copies, for two reads and one write per item, plus the call, which costs twice as much against a 32-bit copy.
xf+yf	3.4	3.2	instructions linux 0.735
# 5.7 copies, for Exp's 5.5 copies at f32, plus the call.
rm.exp(xf)	12	6.5	instructions linux 4.193
# Real-input cis with complex output at f32; bulk cost is uncalibrated.
rm.cis(xf)	-	-
# Error-function slice forms at f32; bulk costs are uncalibrated.
rm.erfc(xf)	-	-
rm.erfinv(xf)	-	-
rm.erfc(xsf)	-	-
# 2.1 copies, for reading the mask, xf and yf, a select, one write,, plus the call.
rm.where(mask, xf, yf)	4.2	5.5	instructions linux 1.116
# 0.7 copies, for one read per item, plus the call.
rm.sum(xf)	1.4	1.2	instructions linux 0.389
# 1 copy, for one read per item, its conversion to f64 and a fused multiply-add into an f64 running sum, in lanes that hold half as many items as f32 lanes, plus the call. The squares are f64 so that the square of `1e20` doesn't overflow. Before 22 October 2026, the squares were f32 and went through a buffer, at about 1.2 copies.
rm.norm(xf)	2	1.6	instructions linux 0.736
