Repository navigation
perf(reader): read tiles in one pass without per-feature allocations - #61
Open
AnthonyKrupko wants to merge 6 commits into
Open
AnthonyKrupko wants to merge 6 commits into
AnthonyKrupko wants to merge 6 commits into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
fast-mvt encode/decode — CPU instruction countsfast-mvt's own encode/decode instruction counts, measured by gungraun under Valgrind/Callgrind — one-shot and deterministic, so these are exact (the Results |
This was referenced Nov 7, 2026
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>
This was referenced Nov 7, 2026
Open
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::newdecoded every feature into a generated view with its owntagsandgeometryvectors: 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 packedtagsandgeometrystay borrowed from the tile bytes; they are checked during the scan and decoded as they are read.Upstream's gungraun
decode fastdrops from 1,649,554,480 to 1,370,266,000 instructions (−16.9%). Over all 207 real-world fixtures (385k features):MvtReaderRef::newto_tilenewonly(Callgrind instructions, x86_64 Linux. Criterion on the same machine: 82.3 ms → 36.1 ms.)
Memory
A
MvtReaderRefnow 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 au32in 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. Callingtags()orgeometry_commands()adds 1.76 bytes per tile byte, once per tile.Reader
src/reader/wire.rsreads 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.MvtValueRef.to_tile,to_featureandproperties_vecsize their vectors up front;MvtPropertyIteris now anExactSizeIterator.tags()andgeometry_commands()still return slices. The first call decodes the packed fields of the whole tile into one vector, which is what everynew()used to do;properties()andgeometry()never need it.generate_views(false)), as nothing uses them.Behaviour changes
Results and errors are otherwise unchanged. At the limits:
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.)MessageTooLarge.Testing
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 tojust test-fuzz.to_protoon a large tile.Docs