Skip to content

View decode ~10% slower in 0.8.1: generated merge_view_field lacks #[inline] #298

Description

@nyurik

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions