view: opt-in ViewEncode for serializing from borrowed fields#55
view: opt-in ViewEncode for serializing from borrowed fields#55
Conversation
Adds ViewEncode<'a>: MessageView<'a> with compute_size / write_to / cached_size, mirroring Message's two-pass model. Provided encode/encode_to_vec/encode_to_bytes. Field bytes are written by borrow — no String/Vec<u8> allocations. MessageFieldView gains compute_size/write_to/cached_size forwarding when V: ViewEncode. MapView gains new()/From<Vec>/FromIterator so callers can build a view map from borrowed (&str, &str) pairs (duplicate keys are encoded as-is). UnknownFieldsView gains write_to (concatenate raw spans). MessageView itself is unchanged. View remains the zero-copy read path by default; encode is opt-in via codegen. Targets 0.4.0: enabling view_encode on buffa-types adds a __buffa_cached_size field to WKT view structs (see CHANGELOG).
CodeGenConfig::view_encode (default false). When set, codegen emits
__buffa_cached_size on each view struct and a separate
impl ViewEncode<'a> for FooView<'a> block.
The per-field encode-stmt builders (scalar_/repeated_/map_/oneof_*_stmt)
emit &self.field-relative code that already takes &str/&[u8], so they
apply to view field types unchanged. Oneof: the view-side enum
(mod::FooOneofView) has the same variant names as owned with borrowed
payload types; oneof_{size,write}_arm dispatch by name and call
duck-typed primitives (string_encoded_len(x), x.compute_size()), so they
work once pointed at the view enum path.
buffa_build::Config::view_encode(bool) setter; protoc-gen-buffa
view_encode=true option.
WKTs are commonly nested inside application messages (Timestamp, Any, Struct/Value); a consumer that opts into view_encode needs the WKT view types to implement ViewEncode for nested-message dispatch to compile. The extra __buffa_cached_size field is 4 bytes per WKT view. `task gen-wkt-types`
|
All contributors have signed the CLA ✍️ ✅ |
Enable view_encode(true) for basic.proto. Add Address home_address = 15 to Person's contact oneof to cover ViewEncode dispatch through Box<View<'a>>. New tests: round-trip (decode_view → encode_to_vec byte-equal), construct-from-borrows for scalars/repeated/map/oneof, compute_size matches encoded len.
encode_view: serialize a pre-decoded view (parity with owned encode; wire-compat asserted by decode-and-compare). build_encode vs build_encode_view: construct a string+map LogRecord from borrowed source data and encode. Includes the per-field String allocs that the view path avoids — 1.35 µs → 227 ns (5.96×) on a 15-label fixture.
fec0c02 to
ea68c0d
Compare
|
I have read the CLA Document and I hereby sign the CLA |
binary-encode.svg gains a "buffa (view)" series (encode_view, all 4 messages). New build-encode.svg shows build_encode vs build_encode_view for LogRecord — the alloc-elimination case (522 -> 3011 MiB/s, 5.77x). generate.py: messages_with_data() so per-chart message lists auto-shrink to those with data (build-encode is LogRecord-only); skip all-None series. Regenerated from native buffa/prost cargo bench + Docker google/Go.
…d owned test, doc notes - impl_message.rs: extract classify_fields() shared between generate_message_impl and build_view_encode_methods so a new field category only needs adding once. Add doc-notes on MessageSet (verbatim spans need no Item-group rewrap) and MapView/HashMap match-ergonomics. - benches/protobuf.rs: bench_build_encode! macro + per-type fixtures for all 4 message types (was log_record only). Spectrum: GoogleMessage1 1.34x to LogRecord 5.80x; view never slower. - buffa-test/src/tests/view.rs: restore owned test_compute_size_matches_encode_len alongside the new _view variant. - charts/: regenerate (build-encode.svg now shows all 4).
ViewEncode is now generated whenever views are generated (generate_views, on by default). The separate view_encode opt-in flag is removed: the uncalled .text cost measured ~12 B/message in release without LTO (linker dedups identical cached_size bodies; write_to is generic so monomorphizes only at call sites), and the WKT struct-shape break landed in 0.4.0 regardless. generate_views(false) remains the escape hatch for targets that want neither. Removes Config::view_encode field/builder/plugin-param and the 9+1 build.rs callsites. WKT regen byte-identical.
Why ViewEncode is always-on (no
|
| description | downside | |
|---|---|---|
| A | keep view_encode flag, flip default to true |
one more config knob (#9); the WKT struct-shape break lands in 0.4.0 regardless of the flag, so it doesn't actually protect against the break |
| B | drop the flag; tie ViewEncode to generate_views |
the (narrow) audience that wants zero-copy decode but never encode-from-borrows pays for unused codegen |
| C | keep flag, default false (original draft) |
common case requires config; WKT break still lands; generate_text analogy is weak (that flag exists because it gates a runtime feature dep — ViewEncode has none) |
Why B: the cost B imposes turns out to be negligible. Measured on buffa-test (82 messages, release build, no LTO, ViewEncode generated but never called):
.text |
|
|---|---|
view_encode off |
2,724,340 B |
view_encode on, uncalled |
2,725,288 B |
| Δ | +948 B (~12 B/message) |
write_to(&mut impl BufMut) is generic — zero .text unless monomorphized at a call site. cached_size impls are 4 bytes each and the linker deduplicates identical bodies (nm shows e.g. Int32ValueView::cached_size and FloatValueView::cached_size at the same address). compute_size is the only per-message unique code.
For a hypothetical 1000-message embedded schema without LTO that's ~12 KB; with LTO (standard for size-constrained builds) it's zero. The escape hatch for anyone who genuinely can't afford it is the existing generate_views(false).
So: one fewer config knob, no meaningful cost, and the __buffa_cached_size struct-shape break is already in 0.4.0 via WKTs anyway.
|
Built a standalone echo bench to test whether view-decode pays off without view-encode: 906-byte string-heavy request (4 strings, 8 tags, 10-entry label map), measuring the full bytes-in → decode → build response → encode → bytes-out pipeline. Baseline uses move semantics (most charitable to owned).
View-decode alone: +17%. View-encode alone: +3%. Both: +119% — far more than the sum, because only view→view eliminates the string-alloc pass entirely (the |
view:
ViewEncode— serialize from borrowed fieldslog_recordbuild+encode from&strsource: 1.35 µs → 227 ns (5.96×). Serialize-only at parity with ownedMessage::encode(±5%).Why
Encoding currently requires building an owned message — cloning every string and map entry just to pass
&selftoMessage::encode(), then dropping the struct. When the source data is already in-memory&strs (RPC handlers serializing from app state), that's ~19 allocs/message for a typical string-heavy payload. Views already have&'a strfields andpubconstructors; this adds the encode side.Change
ViewEncode<'a>: MessageView<'a>sub-trait with the same two-passcompute_size/write_tomodel asMessage. Generated*View<'a>types implement it whenever views are generated (generate_views(true), the default).The codegen reuses the existing per-field encode-stmt builders unchanged — they already emit
&self.field-relative code that takes&str/&[u8], so they apply to view fields as-is. Oneof same: arm builders are duck-typed; pointing them at the view-side enum is the only delta.MessageViewitself is unchanged.Compatibility — 0.4.0
The trait surface is additive, but every generated view struct gains a
__buffa_cached_sizefield — public-shape change for code that constructs views without..Default::default(). Folded into 0.4.0 alongside the existingAny.value: Bytesbreak in Unreleased. Uncalled.textcost is negligible (~12 B/message in release, no LTO; the linker dedups identicalcached_sizebodies andwrite_tois generic so it monomorphizes only at call sites).Testing
Every view-generating
buffa-testproto exercises the encode path (proto3, proto2 groups/closed-enums, nested oneofs, WKT,utf8_validation=NONE,use_bytes_type,preserve_unknown_fields=false, edge_cases) — compile-time coverage for the duck-type-reuse claim across syntaxes. 9 view-encode round-trip / construct-from-borrows tests. Conformance 6× PASSED, 0 unexpected.Companion
For connectrpc handlers, connect-rust needs a way to return pre-encoded bytes (
PreEncodednewtype). Companion PR linked once open. This change is independently useful for any non-connectrpc encode path.Follow-ups (not here)
MessageFieldViewboxes the inner view (recursive types) — one alloc per nested-message field; non-boxed variant for non-recursive fields would close the gap for tree-shaped messages.via-view-encodeconformance mode (decode_view → encode_view).