Summary
View decoding regressed ~10% between buffa 0.7.1 and 0.8.1. In an MVT decode workload, MvtReaderRef::new(..) + traversal (pure zero-copy view decode, no to_owned) went from 4.06s → 4.47s for the same input.
Root cause: the 0.8 decode-loop refactor split the per-field match out of the monomorphized loop into a generated merge_view_field method that is emitted without #[inline], so it stays out-of-line and every field pays a call + the pass-by-value slice ABI.
This is a follow-up to the same MVT view-decode hot path as #171 (packed-field capacity reservation), which is now shipped in 0.8.
What changed
0.7.1 — each generated view had one monomorphized function containing both the tag loop and the field match; it inlined into a single tight function:
pub fn _merge_into_view(&mut self, buf: &'a [u8], depth: u32) -> Result<(), DecodeError> {
let mut cur = buf;
while !cur.is_empty() {
let tag = Tag::decode(&mut cur)?;
match tag.field_number() {
1u32 => { /* ... */ }
// ...
}
}
Ok(())
}
0.8.1 — the tag loop moved into the generic provided trait method MessageView::merge_into_view, which calls the generated per-message merge_view_field once per field:
// buffa view.rs (provided)
fn merge_into_view(&mut self, buf: &'a [u8], ctx: DecodeContext<'_>) -> Result<(), DecodeError> {
let mut cur = buf;
while !cur.is_empty() {
let before_tag = cur;
let tag = Tag::decode(&mut cur)?;
cur = self.merge_view_field(tag, cur, before_tag, ctx)?; // <- generated, NOT #[inline]
}
Ok(())
}
The generated merge_view_field has no #[inline] attribute, so LLVM keeps it out-of-line across the crate boundary (buffa's generic loop ↔ the downstream crate's generated impl). An MVT tile decodes millions of fields (per feature: id/tags/type/geometry; every Value; every layer), so the per-field call + &[u8]-in/&[u8]-out ABI dominates.
Repro / measurements
Decoding a 6.99 MB real-world MVT tile, 60 iterations, min of interleaved runs (release):
| build |
time |
vs 0.7.1 |
| buffa 0.7.1 |
4.06 s |
— |
| buffa 0.8.1 |
4.47 s |
+10% |
0.8.1 + #[inline] on generated merge_view_field |
4.17 s |
+2.6% |
Adding #[inline] to the four generated merge_view_field methods (nothing else changed) recovers ~75% of the regression. #[inline(always)] gives the same result.
Ruled out along the way:
count_varints reservation (0.8 replaced reserve(payload.len()) with reserve(count_varints(payload))): patching it back to payload.len() made no measurable difference.
- The residual ~2–3% is the new
DecodeContext threading (Cell unknown-field allowance, before_tag capture, descend() per submessage) — inherent to the 0.8 design.
Suggested fix
Have buffa-codegen emit #[inline] on the generated merge_view_field method. It's a small, hot dispatch function whose whole purpose is to be folded into the provided merge_into_view loop, so inlining across the crate boundary is exactly what's wanted.
Environment
- buffa / buffa-build / buffa-codegen 0.8.1 (regressed) vs 0.7.1 (baseline)
- rustc release build,
edition = "2024"
- Workload: fast-mvt decoder benchmark
Summary
View decoding regressed ~10% between buffa 0.7.1 and 0.8.1. In an MVT decode workload,
MvtReaderRef::new(..)+ traversal (pure zero-copy view decode, noto_owned) went from 4.06s → 4.47s for the same input.Root cause: the 0.8 decode-loop refactor split the per-field
matchout of the monomorphized loop into a generatedmerge_view_fieldmethod that is emitted without#[inline], so it stays out-of-line and every field pays a call + the pass-by-value slice ABI.This is a follow-up to the same MVT view-decode hot path as #171 (packed-field capacity reservation), which is now shipped in 0.8.
What changed
0.7.1 — each generated view had one monomorphized function containing both the tag loop and the field
match; it inlined into a single tight function:0.8.1 — the tag loop moved into the generic provided trait method
MessageView::merge_into_view, which calls the generated per-messagemerge_view_fieldonce per field:The generated
merge_view_fieldhas no#[inline]attribute, so LLVM keeps it out-of-line across the crate boundary (buffa's generic loop ↔ the downstream crate's generated impl). An MVT tile decodes millions of fields (per feature: id/tags/type/geometry; everyValue; every layer), so the per-field call +&[u8]-in/&[u8]-out ABI dominates.Repro / measurements
Decoding a 6.99 MB real-world MVT tile, 60 iterations, min of interleaved runs (release):
#[inline]on generatedmerge_view_fieldAdding
#[inline]to the four generatedmerge_view_fieldmethods (nothing else changed) recovers ~75% of the regression.#[inline(always)]gives the same result.Ruled out along the way:
count_varintsreservation (0.8 replacedreserve(payload.len())withreserve(count_varints(payload))): patching it back topayload.len()made no measurable difference.DecodeContextthreading (Cellunknown-field allowance,before_tagcapture,descend()per submessage) — inherent to the 0.8 design.Suggested fix
Have
buffa-codegenemit#[inline]on the generatedmerge_view_fieldmethod. It's a small, hot dispatch function whose whole purpose is to be folded into the providedmerge_into_viewloop, so inlining across the crate boundary is exactly what's wanted.Environment
edition = "2024"