Build benchmarks with whole-program optimization - #9259
Conversation
`profile.bench` used `codegen-units = 16` with no LTO. Under those settings, adding unrelated code to a crate reshuffles which functions share a codegen unit, and cross-unit inlining changes for functions whose source did not change. A benchmark then moves without its code moving, which is indistinguishable from a real regression. The effect is not small. Adding a scalar function framework to `vortex-array` on a separate branch moved several `take_filter_list` and `list_sum` benchmarks by 14-16%, none of which had a source change, while every `take_filter_primitive` benchmark held. `codegen-units = 1` with fat LTO removes the variable. Benchmark builds get slower in exchange. Signed-off-by: Connor Tsui <connor@spiraldb.com> Co-authored-by: Claude <noreply@anthropic.com>
Merging this PR will improve performance by 29.51%
Performance Changes
Tip Curious why this is faster? Comment Comparing Footnotes
|
|
Closing. The profile change does not isolate what it was meant to isolate. Fat LTO over one codegen unit made a wide set of benchmarks faster, including many with no relationship to the change under test, so it substitutes one confound for a larger one. A CodSpeed run against this base still cannot attribute a move to the diff. The benchmarks that got faster under LTO are worth a separate look. They point at cross-crate inlining that the default build leaves on the table, which is an optimization opportunity in shipped code rather than a benchmarking setting. That work is not tracked yet. #9255 is back on Generated by Claude Code |
Related: #9259 ## Rationale for this change `Buffer<T>` and `BufferMut<T>` are generic, so their MIR travels in the rlib and a downstream crate inlines them without LTO. `BitBuffer`, `BitBufferMut`, `BitBufferView` and `BitBufferMutView` are concrete, so a method without `#[inline]` reaches a downstream crate as a declaration only. The thin ones cost a real call per invocation, and the caller loses the offset and length constants it needs to fold the surrounding code. Note that `#[inline]` is not what enables inlining across codegen units inside a crate. MIR inlining runs before partitioning, and `profile.bench` sets `lto = false`, which is thin-local LTO rather than no LTO. Both already inline small functions across a codegen unit boundary within `vortex-buffer`. The attribute only matters across the crate boundary, which is where every measurement below was taken. ## What changes are included in this PR? `#[inline]` on the thin wrappers of those four types: constructors, iterator factories, and the methods that forward a slice, an offset and a length. Methods with a body worth outlining keep their current behavior, including `append_buffer`, whose bitvec fallback path is too large to justify inlining for the 1.6% it measured. Marginal instructions per iteration, measured with callgrind against a probe crate that calls `vortex-buffer` across a crate boundary, built at the `profile.bench` settings: | kernel | before | after | fat LTO | | --- | --- | --- | --- | | `BitBuffer::slice` | 8972 | 6415 | 5390 | | `BitBufferView::slice` | 3017 | 2186 | 1994 | `slice_vortex_buffer` measures 1.904us to 1.829us of wall time, and stops intermittently landing on a slower 2.12us mode. The wall-clock gain is much smaller than the instruction-count gain because these paths are bound by refcount atomics rather than by instruction issue. CodSpeed measures instruction counts, so expect its numbers to sit closer to the table than to the wall time. <details> <summary>Measurement method, and the parts of the LTO win this does not reach</summary> The probe is a separate crate that calls `vortex-buffer` over a real crate boundary, so the cross-crate path is the one under test. Each kernel runs at two iteration counts under `valgrind --tool=callgrind` and the totals are differenced, which cancels process startup, CPU feature warmup and setup allocations. This matches what CodSpeed's Simulation mode reports. `BitBuffer::set_indices` goes 77702 to 69503 at `codegen-units = 1`. At `codegen-units = 16` the baseline lands on the faster value about half the time depending on how thin-local LTO's import decisions fall, so the change makes a previously partition-dependent win reliable rather than producing a new one. The remaining fat LTO gap on these benchmarks is not cross-crate inlining, and `#[inline]` cannot reach it: - `set_slices` is 1.68x, and it is arrow's `BitSliceIterator`. Ten `#[inline]` attributes on the `BitSliceIterator` and `UnalignedBitChunk` chain recover 13729 to 10026 with no LTO. That belongs upstream in arrow-rs. - `from_iter` and `bitand_owned` are 1.9x. Nightly `-Zcross-crate-inline-threshold=always` does not move either one, so no amount of MIR availability explains them. Fat LTO is partially rescuing a per-bit read-modify-write loop by unrolling it. The real fix is that `BitBuffer::from_iter` costs 5.19 instructions per bit while `BitBufferMut::from(&[bool])` does the same job at 0.19 through the word-packing kernels in `pack.rs`. Follow-up. - `value_vortex_buffer` and `value_arrow_buffer` both reported +56.8% on #9259. The probe measures both at exactly 147467 instructions in every profile. That row is divan overhead. Thin LTO was measured as an alternative and rejected: 0 to 2.5% across these kernels for 2.5x the bench build time. </details> Signed-off-by: "Connor Tsui" <connor@spiraldb.com> Co-authored-by: Claude <noreply@anthropic.com>
Rationale for this change
profile.benchbuilds withcodegen-units = 16and no LTO. Under those settings, adding unrelated code to a crate reshuffles which functions share a codegen unit, and cross-unit inlining changes for functions whose source did not change. A benchmark moves without its code moving, which is indistinguishable from a real regression.The effect is large enough to mislead a review. On #9255, which adds a row scalar function framework to
vortex-array, CodSpeed reported 14-16% regressions acrosstake_filter_list_*andlist_sum_small. None of those have a source change on that branch, and everytake_filter_primitive_*benchmark held.Cargo.lockis identical and nothing was added to the session registry, so codegen-unit partitioning is what is left.Benchmarks exist to attribute a change in measurement to a change in code.
codegen-units = 1with fat LTO removes the variable.What changes are included in this PR?
One profile, two settings:
codegen-units = 16to1, andlto = falsetotrue.What APIs are changed? Are there any user-facing changes?
None.
profile.benchaffectscargo benchandcargo codspeed build --profile benchonly.Notes
profile.releasedeliberately keeps LTO off, with a comment recording why: Vortex's performance must not depend on a downstream crate enabling it. That argument is about shipped code. It does not extend to a profile whose only job is measuring a diff, and the two profiles already disagree oncodegen-units.The trade-off is benchmark build time. Fat LTO over one codegen unit is materially slower than 16 parallel units, and every CodSpeed shard pays it.
Open question: is the noise this removes worth the CI time it costs, or is the better fix to keep the current profile and treat sub-20% moves in untouched code as unattributable?
Merging this invalidates comparison against any baseline built with the old settings. The first CodSpeed run on this PR compares a fat-LTO head against a
codegen-units = 16base, so its numbers describe the profile change rather than any code change. Runs after it lands are the meaningful ones.Generated by Claude Code