perf: allow cross-crate inlining of BitBuffer accessors - #9285
Conversation
`vortex-buffer`'s `Buffer<T>` and `BufferMut<T>` are generic, so their MIR
is exported and downstream crates can inline them without LTO. `BitBuffer`,
`BitBufferMut`, `BitBufferView` and `BitBufferMutView` are concrete types,
so any method without `#[inline]` is only a symbol to a downstream crate.
The thin ones -- constructors, iterator factories, and the wrappers that do
nothing but forward a slice, an offset and a length -- become a real call
per invocation, and the caller loses the offset and length constants it
needs to fold the surrounding code.
This marks those wrappers `#[inline]`. It leaves alone anything with a body
worth outlining, including `append_buffer`, whose bitvec fallback path is
too large to justify inlining for the 1.6% it measured.
Measured with callgrind against a probe crate that calls `vortex-buffer`
across a crate boundary, at the `profile.bench` settings
(`codegen-units = 16`, no LTO), as marginal instructions per iteration:
BitBuffer::slice 8972 -> 6415 (-28.5%)
BitBufferView::slice 3017 -> 2186 (-27.5%)
At `codegen-units = 1`, which removes codegen-unit partitioning as a
variable, `BitBuffer::set_indices` goes 77702 -> 69503 (-10.6%); at
`codegen-units = 16` it already lands on the faster value about half the
time depending on how the partition falls, so the change makes a win that
was previously luck-dependent reliable instead.
`slice_vortex_buffer` measures 1.904us -> 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.
Signed-off-by: "Connor Tsui" <connor@spiraldb.com>
Merging this PR will regress 2 benchmarks
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Simulation | take[small_m/shuffled/primitive/nonnull/chunks=16384/indices=16] |
1.2 ms | 1.3 ms | -10.5% |
| ❌ | Simulation | chunked_into_canonical[(1000, 50, 8, 64)] |
17.8 ms | 19.8 ms | -10.08% |
| ⚡ | Simulation | slice_vortex_buffer |
14.4 µs | 10.9 µs | +32.96% |
| ⚡ | Simulation | decompress[u32, (10000, 256)] |
100.4 µs | 82.9 µs | +21.08% |
| ⚡ | Simulation | set_indices_vortex_buffer[128] |
2.2 µs | 1.9 µs | +18.28% |
| ⚡ | Simulation | bitwise_not_vortex_buffer[1024] |
7.4 µs | 6.3 µs | +16.34% |
| ⚡ | Simulation | bitwise_not_vortex_buffer[2048] |
7.8 µs | 6.8 µs | +15.32% |
| ⚡ | Simulation | bitwise_not_vortex_buffer[128] |
9.1 µs | 8.1 µs | +11.92% |
| ⚡ | Simulation | iter_vortex_buffer[128] |
2 µs | 1.8 µs | +11.46% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing ct/inline-buffers (bc1c689) with develop (66c447e)
Footnotes
-
51 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
|
im pretty sure that the regressions are the typical ones that are noisy |
Related: #9259
Rationale for this change
Buffer<T>andBufferMut<T>are generic, so their MIR travels in the rlib and a downstream crate inlines them without LTO.BitBuffer,BitBufferMut,BitBufferViewandBitBufferMutVieware 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, andprofile.benchsetslto = false, which is thin-local LTO rather than no LTO. Both already inline small functions across a codegen unit boundary withinvortex-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, includingappend_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-bufferacross a crate boundary, built at theprofile.benchsettings:BitBuffer::sliceBitBufferView::sliceslice_vortex_buffermeasures 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.Measurement method, and the parts of the LTO win this does not reach
The probe is a separate crate that calls
vortex-bufferover a real crate boundary, so the cross-crate path is the one under test. Each kernel runs at two iteration counts undervalgrind --tool=callgrindand the totals are differenced, which cancels process startup, CPU feature warmup and setup allocations. This matches what CodSpeed's Simulation mode reports.BitBuffer::set_indicesgoes 77702 to 69503 atcodegen-units = 1. Atcodegen-units = 16the 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_slicesis 1.68x, and it is arrow'sBitSliceIterator. Ten#[inline]attributes on theBitSliceIteratorandUnalignedBitChunkchain recover 13729 to 10026 with no LTO. That belongs upstream in arrow-rs.from_iterandbitand_ownedare 1.9x. Nightly-Zcross-crate-inline-threshold=alwaysdoes 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 thatBitBuffer::from_itercosts 5.19 instructions per bit whileBitBufferMut::from(&[bool])does the same job at 0.19 through the word-packing kernels inpack.rs. Follow-up.value_vortex_bufferandvalue_arrow_bufferboth reported +56.8% on Build benchmarks with whole-program optimization #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.