Skip to content

perf(reader): read tiles in one pass without per-feature allocations - #61

Open
AnthonyKrupko wants to merge 6 commits into
nyurik:mainfrom
AnthonyKrupko:perf/reader
Open

AnthonyKrupko wants to merge 6 commits into
nyurik:mainfrom
AnthonyKrupko:perf/reader

Conversation

@AnthonyKrupko

Copy link
Copy Markdown
Contributor

Hey! These are some experimental changes carried over from my performance experiments on 0.7, and I'd like to hear your thoughts on them. I split them into two PRs, one for the reader (this one) and one for the writer, plus a follow-up for the writer that builds on it. Please let me know if they make sense.

MvtReaderRef::new decoded every feature into a generated view with its own tags and geometry vectors: about two allocations per feature, even to read only the layer names. The reader now scans the tile once into a few vectors shared by the whole tile. Strings and the packed tags and geometry stay borrowed from the tile bytes; they are checked during the scan and decoded as they are read.

Upstream's gungraun decode fast drops from 1,649,554,480 to 1,370,266,000 instructions (−16.9%). Over all 207 real-world fixtures (385k features):

v0.7.1 this PR
MvtReaderRef::new 1,238.6 M 540.2 M −56.4%
full traversal (geometry + properties) 1,910.9 M 1,367.0 M −28.5%
geometry only 1,663.3 M 1,029.7 M −38.1%
properties only 1,486.2 M 879.1 M −40.9%
to_tile 5,640.4 M 4,427.9 M −21.5%
allocations, full traversal 1,476,435 328,481 −78%
allocations, new only 775,015 828

(Callgrind instructions, x86_64 Linux. Criterion on the same machine: 82.3 ms → 36.1 ms.)

Memory

A MvtReaderRef now keeps 71% less heap: 1.82 bytes per tile byte instead of 6.26, or 154 bytes per feature instead of 529 (heap bytes requested, over all 207 fixtures). On the largest fixture (7 MB, 81k features) that is 13.6 MB instead of 44.5 MB. It also scales differently: v0.7.1's grows with the tags and geometry, every varint widened to a u32 in a vector of its own, while this reader's grows only with the number of features, keys and values. The extra peak while reading every geometry and property is unchanged. Calling tags() or geometry_commands() adds 1.76 bytes per tile byte, once per tile.

Reader

  • src/reader/wire.rs reads the tile with buffa's own wire primitives (Tag::decode, borrow_str, skip_field_depth, …), so a tile is accepted or rejected exactly as before, with the same errors.
  • Layer values are resolved once into MvtValueRef.
  • Geometry is parsed straight from the packed varints. A single point, line or polygon no longer goes through a temporary vector, and rings are sized once, including the closing coordinate.
  • to_tile, to_feature and properties_vec size their vectors up front; MvtPropertyIter is now an ExactSizeIterator.
  • tags() and geometry_commands() still return slices. The first call decodes the packed fields of the whole tile into one vector, which is what every new() used to do; properties() and geometry() never need it.
  • buffa no longer generates the view types (generate_views(false)), as nothing uses them.

Behaviour changes

Results and errors are otherwise unchanged. At the limits:

  • The 32 MiB element-memory budget is charged with the reader's smaller records, so a tile may hold more features before it is rejected (599k empty features instead of 466k).
  • to_proto() decodes the original bytes without a memory budget of its own, as converting the view did. (Decoding with buffa's default budget would reject tiles with over ~330k values.)
  • Tiles of 4 GiB or more are rejected with MessageTooLarge.

Testing

  • New fuzz target mvt_reader: an arbitrary tile, encoded, then edited byte by byte. Whatever the reader accepts must read field for field as the generated decoder reads it, and whatever that decoder rejects the reader must reject. Added to just test-fuzz.
  • Unit tests for packed-varint validation (every run length and offset, against buffa's decoder), packed fields split over several records, unknown fields, wire-type mismatches, the memory-budget boundary, and to_proto on a large tile.
  • Outside this PR, a differential test against v0.7.1 over all fixtures, 300k mutated tiles and 300k random geometry streams found no difference in any accessor, Debug output or error message.

Docs

  • README decoding table refreshed (all rows from one run, x86_64 Linux).

MvtReaderRef::new decoded every feature into a generated view with its own
tags and geometry vectors, about two allocations per feature even to read
only the layer names. The reader now scans the tile once into a few
vectors shared by the whole tile, with buffa's wire primitives, so tiles
are accepted or rejected as before, with the same errors. Strings and the
packed tags and geometry stay borrowed from the tile bytes: they are
checked during the scan and decoded as they are read.

- Layer values are resolved once into MvtValueRef.
- Geometry is parsed straight from the packed varints; a single point,
  line or polygon no longer goes through a temporary vector, and rings are
  sized once, including the closing coordinate.
- to_tile, to_feature and properties_vec size their vectors up front, and
  MvtPropertyIter is an ExactSizeIterator.
- tags() and geometry_commands() still return slices: the first call
  decodes the packed fields of the whole tile, as every new() used to.
- to_proto() decodes the original bytes without a memory budget of its
  own, as converting the view did.

The 32 MiB decode budget is charged with the smaller records, so a tile
may hold more features before it is rejected, and tiles of 4 GiB or more
are rejected with MessageTooLarge.
The reader no longer decodes through buffa's views, so they were dead code.
The new mvt_reader target encodes an arbitrary tile and edits its bytes, so that most inputs are almost valid. What the reader accepts must read field for field as the generated decoder reads it, and what that decoder rejects the reader must reject too. just test-fuzz runs both targets.
@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.39%. Comparing base (8ee0411) to head (c64fdfb).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #61      +/-   ##
==========================================
+ Coverage   99.20%   99.39%   +0.18%     
==========================================
  Files          11       12       +1     
  Lines        2018     2483     +465     
==========================================
+ Hits         2002     2468     +466     
+ Misses         16       15       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

fast-mvt encode/decode — CPU instruction counts

fast-mvt's own encode/decode instruction counts, measured by gungraun under Valgrind/Callgrind — one-shot and deterministic, so these are exact (the mvt/mvt-reader/tinymvt comparison cases run only locally via just bench-cpu, not in CI). A diff vs. the PR base appears once the base branch also carries the gungraun bench. Informational only; this never fails CI.

Results
decoder_cpu::decoder_cpu::decode fast:(setup(Dec :: Fast))
  Baselines:                                              |base
  Instructions:                                 1325374237|1583913792           (-16.3228%) [-1.19507x]
  L1 Hits:                                      1883562446|2284056290           (-17.5343%) [-1.21263x]
  LL Hits:                                          839113|1161100              (-27.7312%) [-1.38372x]
  RAM Hits:                                        5083396|8286330              (-38.6532%) [-1.63008x]
  Total read+write:                             1889484955|2293503720           (-17.6158%) [-1.21382x]
  Estimated Cycles:                             2065676871|2579883340           (-19.9314%) [-1.24893x]

Gungraun result: Ok. 1 without regressions; 0 regressed; 2 filtered; 1 benchmarks finished in 38.3917s

encoder_cpu::encoder_cpu::encode fast:(setup(Enc :: Fast))
  Baselines:                                              |base
  Instructions:                                   90870238|91046239             (-0.19331%) [-1.00194x]
  L1 Hits:                                       127659503|127609285            (+0.03935%) [+1.00039x]
  LL Hits:                                          162785|173521               (-6.18715%) [-1.06595x]
  RAM Hits:                                         304536|315124               (-3.35995%) [-1.03477x]
  Total read+write:                              128126824|128097930            (+0.02256%) [+1.00023x]
  Estimated Cycles:                              139132188|139506230            (-0.26812%) [-1.00269x]
encoder_cpu::encoder_cpu::encode fast_owned:(setup(Enc :: FastOwned))
  Baselines:                                              |base
  Instructions:                                  156326255|156907067            (-0.37016%) [-1.00372x]
  L1 Hits:                                       218627597|219854551            (-0.55808%) [-1.00561x]
  LL Hits:                                          636785|664263               (-4.13661%) [-1.04315x]
  RAM Hits:                                         319689|328314               (-2.62706%) [-1.02698x]
  Total read+write:                              219584071|220847128            (-0.57191%) [-1.00575x]
  Estimated Cycles:                              233000637|234666856            (-0.71004%) [-1.00715x]

Gungraun result: Ok. 2 without regressions; 0 regressed; 4 filtered; 2 benchmarks finished in 6.11720s

nyurik added a commit to nyurik/buffa that referenced this pull request Nov 7, 2026
A packed field is a run of varints in one length-delimited record:

```proto
repeated uint32 geometry = 4 [packed = true];
// on the wire: 22 03 09 02 04 (tag, length 3, values 9 2 4)
```

buffa can only decode that payload into a `Vec`: `extend_packed_uint32` for owned messages, `RepeatedView` in views. So every packed field of every message costs an allocation, even when it is never read. `PackedView` borrows the payload instead. It checks the payload and counts its elements once, then decodes the elements as they are iterated.

```rust
use buffa::packed_view::{PackedView, Uint32};

let payload = [0x09, 0x02, 0x04]; // the bytes after the tag and length
let geometry = PackedView::<Uint32>::from_packed(&payload)?; // checked, no allocation
assert_eq!(geometry.len(), 3);
assert_eq!(geometry.iter().collect::<Vec<_>>(), [9, 2, 4]);
```

- **Errors:** `from_packed` fails exactly where decoding each element would, with the same `DecodeError`. Iteration never fails.
- **Several records:** `extend_packed` and `push` accept a field split over several records or sent unpacked. The field then holds decoded elements.
- **Codec markers:** the marker (`Int32`, `Int64`, `Uint32`, `Uint64`, `Sint32`, `Sint64`, `Bool`) names the encoding, which the Rust type alone doesn't: `i32` is `int32` or `sint32`.

This is the runtime half of anthropics#814; a follow-up lets generated views use it. Until then it serves hand-written decoders, such as the one nyurik/fast-mvt#61 wrote to avoid these allocations.

Refs anthropics#814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nyurik added a commit to nyurik/buffa that referenced this pull request Nov 7, 2026
…dView

Closes anthropics#814. Builds on the `PackedView` PR; only this commit is new.

Generated views decode every packed varint field into a `Vec` (`RepeatedView`), even when the caller never reads it. That's one heap allocation per field per message. For a Mapbox Vector Tile, that's two per feature:

```proto
message Feature {
  repeated uint32 tags = 2 [packed = true];
  repeated uint32 geometry = 4 [packed = true];
}
```

The new opt-in option makes views hold these fields as a borrowed `PackedView`, so decoding a view makes no allocation for them:

```rust
buffa_build::Config::new().packed_views(true)   // plugin: packed_views=true

// generated view field (was RepeatedView<'a, u32>)
pub geometry: ::buffa::PackedView<'a, ::buffa::packed_view::Uint32>,

let view = FeatureView::decode_view(&bytes)?;   // geometry: checked and borrowed
for v in &view.geometry { /* u32 */ }           // decoded as iterated
```

On the 207 real-world tiles of mapbox/mvt-fixtures, a full read goes from 781,975 allocations per pass to 11,846. It's 21% fewer instructions and 12% faster on x86 (c8a.large), and 24% fewer instructions and 22% faster on ARM (c8g.large). Plain buffa views then beat the 745-line hand-written scanner of nyurik/fast-mvt#61. Measurements are in anthropics#814.

**What changes**
- **One predicate, `packed_view_codec`,** picks the fields: the option is on, the field is repeated and packed, its type is a varint scalar (`int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64`, `bool`), and the view doesn't implement reflection (`generate_reflection_vtable`). It's used for:
  - the view field type;
  - the decode arm (`extend_packed` for a packed record, `push` for an unpacked element);
  - `ViewEncode`;
  - JSON (via `to_vec()`);
  - `to_owned_message` with a custom `repeated_type`.

  Eager and lazy views both get it.
- **`ViewEncode` re-encodes the elements canonically**, so its output stays identical to the owned message's, as the `ViewEncode` docs promise. For this, `PackedView` gains `packed_len` and `write_packed` (hidden, for generated code) and `FromIterator`, so a view can be built from application data.
- **Config:** `CodeGenConfig::packed_views`, `Config::packed_views` and the plugin option `packed_views=true`. There's a row in the guide's options table and a changelog fragment.
- **Off by default:** generated code is unchanged without it.

**Not included:** fixed-width fields, enums (because of closed-enum unknown-value routing) and views that implement reflection keep `RepeatedView`; each can follow.

**Source impact when the option is turned on:** a `PackedView` has no slice to deref to, so code that indexes such a field uses `iter()` or `to_vec()` instead.

**Tests**
- **Codegen:** packed varint fields become `PackedView`; fixed-width fields, `[packed = false]` fields, and views with vtable reflection keep `RepeatedView`; output is unchanged without the option.
- **Plugin option parsing.**
- **`PackedView` encoding:** canonical re-encoding of borrowed and decoded fields, and `FromIterator`.
- **End to end** (`buffa-test`, `packed_views.proto`):
  - every varint type, in eager and lazy views;
  - the round trip against the owned message, including `ViewEncode` bytes;
  - split and unpacked records;
  - malformed payloads failing exactly as owned decoding does;
  - JSON against the owned message.

fast-mvt built on this branch passes all 336 of its tests, including 212 fixture snapshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nyurik added a commit to nyurik/buffa that referenced this pull request Nov 8, 2026
…PackedView

Closes anthropics#814. Builds on the `PackedView` PR; only this commit is new.

Generated views decode every packed varint field into a `Vec` (`RepeatedView`), even when the caller never reads it. That's one heap allocation per field per message. For a Mapbox Vector Tile, that's two per feature:

```proto
message Feature {
  repeated uint32 tags = 2 [packed = true];
  repeated uint32 geometry = 4 [packed = true];
}
```

The new opt-in option makes views hold the fields it selects as borrowed `PackedView`s, so decoding a view makes no allocation for them:

```rust
buffa_build::Config::new().packed_views_in(&[".vector_tile.Tile.Feature"])
// or every field: .packed_views(); plugin: packed_views_in=<path>, packed_views=true

// generated view field (was RepeatedView<'a, u32>)
pub geometry: ::buffa::PackedView<'a, ::buffa::packed_view::Uint32>,

let view = FeatureView::decode_view(&bytes)?;   // geometry: checked and borrowed
for v in &view.geometry { /* u32 */ }           // decoded as iterated
```

On the 207 real-world tiles of mapbox/mvt-fixtures, a full read goes from 781,975 allocations per pass to 11,846. It's 21% fewer instructions and 12% faster on x86 (c8a.large), and 24% fewer instructions and 22% faster on ARM (c8g.large). Plain buffa views then beat the 745-line hand-written scanner of nyurik/fast-mvt#61. Measurements are in anthropics#814.

**Why the option is per field**
A `PackedView` has no slice to index or deref to, and decodes its elements again on every iteration. That suits a field read once, in order, such as tile geometry, but not one read by index or more than once. So rules select fields by proto path, as the other per-field options do.

**What changes**
- **Config**, modeled on `unbox_oneof` / `unbox_oneof_in`:
  - `CodeGenConfig::packed_view_fields: Vec<(String, bool)>`: proto path prefixes (a package, a message with its nested messages, or a field; `"."` is everything), the last matching rule winning, as for `preserve_unknown_fields_in`;
  - buffa-build: `packed_views_in(paths)`, and `packed_views()` for `"."`;
  - plugin: repeatable `packed_views_in=<path>`, and `packed_views=true`;
  - a row for each plugin option in the guide's options table, and a changelog fragment.
- **One predicate, `packed_view_codec`,** picks the fields: a rule selects the field, the field is repeated and packed, its type is a varint scalar (`int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64`, `bool`), and the view doesn't implement reflection (`generate_reflection_vtable`). The field path is built next to `field_repeated_repr`'s. The predicate is used for:
  - the view field type;
  - the decode arm (`extend_packed` for a packed record, `push` for an unpacked element);
  - `ViewEncode`;
  - JSON (via `to_vec()`);
  - `to_owned_message` with a custom `repeated_type`.

  Eager and lazy views both get it, so a message can mix `PackedView` and `RepeatedView` fields.
- **`ViewEncode` re-encodes the elements canonically**, so its output stays identical to the owned message's, as the `ViewEncode` docs promise. For this, `PackedView` gains `packed_len` and `write_packed` (hidden, for generated code) and `FromIterator`, so a view can be built from application data.
- **Warnings:**
  - a rule that matches no generated field (`FieldTypeRuleMatchedNothing`, as for `repeated_type_in`);
  - rules that cannot apply because views are off or implement reflection (`PackedViewsIgnored`).
- **Off by default:** generated code is unchanged without it.

**Not included:** fixed-width fields, enums (because of closed-enum unknown-value routing) and views that implement reflection keep `RepeatedView`; each can follow. buffa-build and the plugin only add rules that select fields; excluding a field from a broader rule needs a `false` rule in `CodeGenConfig`, and a builder for it can follow.

**Source impact for selected fields:** code that indexes such a field uses `iter()` or `to_vec()` instead.

**Tests**
- **Codegen:**
  - packed varint fields become `PackedView`; fixed-width fields, `[packed = false]` fields, and views with vtable reflection keep `RepeatedView`;
  - field rules, message rules and a later `false` rule; whole-segment matching (`.Msg.id` doesn't select `.Msg.ids`); a mixed message's two decode arms;
  - output is unchanged when no rule matches;
  - the three warnings.
- **buffa-build and plugin option parsing:** paths normalized, repeated, and rejected when empty.
- **`PackedView` encoding:** canonical re-encoding of borrowed and decoded fields, and `FromIterator`.
- **End to end** (`buffa-test`):
  - `packed_views.proto` with every field selected:
    - every varint type, in eager and lazy views;
    - the round trip against the owned message, including `ViewEncode` bytes;
    - split and unpacked records;
    - malformed payloads failing exactly as owned decoding does;
    - JSON against the owned message.
  - `packed_views_scoped.proto` with field and message rules: a `PackedView` field, a `RepeatedView` varint field and a nested message's field in one message, round-tripped through eager and lazy views, `ViewEncode` bytes, `to_owned_message` and JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant