Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions benchmarks/buffa/benches/reflect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,24 @@
//! `decode/view` (`decode_view`, zero-copy — strings/bytes borrow from the
//! input, so this is the floor, below even generated decode).
//! 2. **Encode** — `encode/generated` vs. `encode/reflect`.
//! 3. **Bridge round-trip** — `t.reflect()`: one full encode + decode + boxed
//! `DynamicMessage`, the cost of the codegen-emitted `Reflectable` impl.
//! 3. **Owned-message reflection** — `bridge_round_trip`
//! (`DynamicMessage::from_message`: encode + decode + box, the bridge cost)
//! vs. `owned_vtable_read_all` (borrow the owned message via `ReflectMessage`
//! and scan it — no round-trip). This is the win from vtable mode making
//! `Reflectable::reflect()` borrow `self`.
//! 4. **From wire bytes to reflective field reads** — the interceptor /
//! field-mask workload, in `read_one` and `read_all` variants across three
//! handle strategies: `vtable_*` (`decode_view` + borrow as
//! `&dyn ReflectMessage`), `bridge_*` (`T::decode` then `.reflect()`), and
//! `dynamic_*` (`DynamicMessage::decode`). Vtable reflection is dominated by
//! the cheap zero-copy `decode_view`, so it lands well below both the bridge
//! round-trip and pure `DynamicMessage` reflection.
//! `&dyn ReflectMessage`), `bridge_*` (`T::decode` then
//! `DynamicMessage::from_message`), and `dynamic_*` (`DynamicMessage::decode`).
//! Vtable reflection is dominated by the cheap zero-copy `decode_view`, so it
//! lands well below both the bridge round-trip and pure `DynamicMessage`
//! reflection.

use std::sync::Arc;

use buffa::{Message, MessageView};
use buffa_descriptor::reflect::{DynamicMessage, ReflectMessage, Reflectable};
use buffa_descriptor::reflect::{DynamicMessage, ReflectMessage};
use buffa_descriptor::{DescriptorPool, MessageIndex};
use criterion::{criterion_group, criterion_main, Criterion, Throughput};

Expand Down Expand Up @@ -101,7 +105,7 @@ fn bench_message<M>(
vt_read_one: impl Fn(&[u8]),
vt_read_all: impl Fn(&[u8]),
) where
M: Message + Default + Reflectable,
M: Message + Default + ReflectMessage,
{
let dataset = load_dataset(dataset_bytes);
let bytes = total_payload_bytes(&dataset);
Expand Down Expand Up @@ -179,7 +183,18 @@ fn bench_message<M>(
group.bench_function("reflect/bridge_round_trip", |b| {
b.iter(|| {
for m in &typed {
criterion::black_box(m.reflect());
criterion::black_box(DynamicMessage::from_message(m, Arc::clone(p), idx));
}
});
});

// Owned-message reflection (a server reflecting its in-memory response):
// vtable mode borrows the owned message directly via `ReflectMessage`, so
// there is no round-trip — contrast with `bridge_round_trip` above.
group.bench_function("reflect/owned_vtable_read_all", |b| {
b.iter(|| {
for m in &typed {
read_all(m);
}
});
});
Expand All @@ -191,7 +206,7 @@ fn bench_message<M>(
// "read one field" and "read all set fields" variant:
//
// vtable — decode_view(bytes), borrow as &dyn ReflectMessage
// bridge — M::decode(bytes) then .reflect() (encode + decode + Box)
// bridge — M::decode(bytes) then DynamicMessage::from_message (round-trip)
// dynamic — DynamicMessage::decode(bytes) (pure reflection, no typed step)

group.bench_function("reflect/vtable_read_one", |b| {
Expand All @@ -213,15 +228,17 @@ fn bench_message<M>(
b.iter(|| {
for payload in &dataset.payload {
let m = M::decode_from_slice(payload).expect("decode");
read_one(&*m.reflect());
let dm = DynamicMessage::from_message(&m, Arc::clone(p), idx);
read_one(&dm);
}
});
});
group.bench_function("reflect/bridge_read_all", |b| {
b.iter(|| {
for payload in &dataset.payload {
let m = M::decode_from_slice(payload).expect("decode");
read_all(&*m.reflect());
let dm = DynamicMessage::from_message(&m, Arc::clone(p), idx);
read_all(&dm);
}
});
});
Expand Down
3 changes: 1 addition & 2 deletions benchmarks/buffa/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ fn main() {
])
.includes(&["../proto/"])
.generate_json(true)
.generate_reflection(true)
.generate_reflection_vtable(true)
.reflect_mode(buffa_build::ReflectMode::VTable)
.compile()
.expect("failed to compile benchmark protos");
}
67 changes: 41 additions & 26 deletions buffa-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ use buffa_codegen::generated::descriptor::FileDescriptorSet;
#[doc(inline)]
pub use buffa_codegen::CodeGenConfig;
#[doc(inline)]
pub use buffa_codegen::ReflectMode;
#[doc(inline)]
pub use buffa_codegen::StringRepr;

/// How to produce a `FileDescriptorSet` from `.proto` files.
Expand Down Expand Up @@ -271,13 +273,18 @@ impl Config {
self
}

/// Generate `impl Reflectable` for owned message types (default: false).
/// Enable reflection on generated types (default: off).
///
/// `generate_reflection(true)` selects [`ReflectMode::VTable`] — the fast
/// path: `foo.reflect()` borrows `foo` directly (no encode/decode
/// round-trip), and owned and view types implement `ReflectMessage`. For
/// the smaller bridge implementation (`reflect()` round-trips through a
/// [`DynamicMessage`]), use [`reflect_mode(ReflectMode::Bridge)`](Self::reflect_mode)
/// instead. `generate_reflection(false)` is [`ReflectMode::Off`].
///
/// When enabled, each generated message gets a bridge-mode reflection
/// impl: `foo.reflect()` returns a [`ReflectCow`] wrapping a
/// [`DynamicMessage`] decoded from `foo`'s wire encoding, against a
/// lazily-built [`DescriptorPool`] embedded as `FileDescriptorSet`
/// bytes. The pool is reachable as `your_crate::your_pkg::descriptor_pool()`.
/// Either mode embeds a lazily-built [`DescriptorPool`] (as
/// `FileDescriptorSet` bytes) reachable as
/// `your_crate::your_pkg::descriptor_pool()`.
///
/// # Cargo.toml setup
///
Expand Down Expand Up @@ -308,10 +315,10 @@ impl Config {
///
/// # Performance
///
/// `reflect()` is one full encode/decode round-trip plus a heap
/// allocation. For repeated reflective access, hold onto the returned
/// handle rather than calling `reflect()` per field. The first call
/// also pays a one-time pool build cost.
/// In the default vtable mode, `reflect()` borrows `self` — no round-trip,
/// no allocation; reflective accessors read fields in place. (Bridge mode
/// instead pays one encode/decode round-trip plus a heap allocation per
/// call.) Either way the first call pays a one-time pool build cost.
///
/// # Build time and binary size
///
Expand All @@ -320,34 +327,42 @@ impl Config {
/// crate this is one copy. For a multi-package codegen run the bytes
/// duplicate per package — measurable for large proto trees. The
/// serialization happens once per `compile()` call (not per package),
/// so build-time CPU does not scale with package count.
/// so build-time CPU does not scale with package count. Vtable mode also
/// emits an `impl ReflectMessage` per type, so it produces more code than
/// bridge mode.
///
/// [`ReflectCow`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/reflect/enum.ReflectCow.html
/// [`DynamicMessage`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/reflect/struct.DynamicMessage.html
/// [`DescriptorPool`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/struct.DescriptorPool.html
#[must_use]
pub fn generate_reflection(mut self, enabled: bool) -> Self {
self.codegen_config.generate_reflection = enabled;
// The simple on/off knob selects the fast vtable path; Bridge is opt-in
// via `reflect_mode`.
let mode = if enabled {
ReflectMode::VTable
} else {
ReflectMode::Off
};
mode.apply(&mut self.codegen_config);
self
}

/// Additionally emit vtable-mode reflection (`impl ReflectMessage` on view
/// types) on top of the bridge-mode `Reflectable` impl.
/// Select the reflection mode (the fuller form of
/// [`generate_reflection`](Self::generate_reflection)).
///
/// Requires [`generate_reflection`](Self::generate_reflection) and view
/// generation (both must be enabled — [`compile`](Self::compile) errors
/// otherwise). Vtable mode reads view struct fields directly through
/// `ReflectMessage`, with no encode/decode round-trip and no per-field
/// allocation for fields that are not read.
/// - [`ReflectMode::Off`] — no reflection (the default).
/// - [`ReflectMode::Bridge`] — `reflect()` round-trips through
/// `DynamicMessage`; equivalent to `generate_reflection(true)`.
/// - [`ReflectMode::VTable`] — `impl ReflectMessage` on owned and view
/// types, and `reflect()` borrows `self` with no round-trip. Requires
/// view generation (on by default).
///
/// **Experimental and `#[doc(hidden)]`.** This is a stopgap until the
/// public `ReflectMode` selector lands; the name and shape may change. It
/// is hidden from the rendered docs to avoid advertising an API that will
/// be superseded — internal builds use it directly.
#[doc(hidden)]
/// All non-`Off` modes require the consuming crate to depend on
/// `buffa-descriptor` with its `reflect` feature and on `std`. The call
/// site (`foo.reflect().get(fd)`) is identical across modes.
#[must_use]
pub fn generate_reflection_vtable(mut self, enabled: bool) -> Self {
self.codegen_config.generate_reflection_vtable = enabled;
pub fn reflect_mode(mut self, mode: ReflectMode) -> Self {
mode.apply(&mut self.codegen_config);
self
}

Expand Down
53 changes: 39 additions & 14 deletions buffa-codegen/src/impl_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,20 +598,45 @@ pub fn generate_message_impl(
&quote! { #name_ident },
);

// Bridge-mode reflection: `impl Reflectable` resolving against the
// package's embedded descriptor pool. Skipped for map entry synthetic
// messages — they're not registered in the pool by name and consumers
// never reflect over them directly.
let reflectable_impl = if ctx.config.generate_reflection
&& !msg
.options
.as_option()
.is_some_and(|o| o.map_entry.unwrap_or(false))
{
crate::feature_gates::cfg_block(
crate::reflect::reflectable_impl(&quote! { #name_ident }, &quote! { __buffa }),
ctx.config.feature_gates().reflect,
)
// Reflection: `impl Reflectable` resolving against the package's embedded
// descriptor pool. Skipped for map entry synthetic messages — they're not
// registered in the pool by name and consumers never reflect over them.
//
// In vtable mode this also emits `impl ReflectMessage` / `impl
// ReflectElement` on the owned struct and makes `reflect()` borrow `self`
// directly (no encode/decode round-trip). In bridge mode `reflect()` boxes
// a `DynamicMessage` from a round-trip.
let is_map_entry = msg
.options
.as_option()
.is_some_and(|o| o.map_entry.unwrap_or(false));
let reflectable_impl = if ctx.config.generate_reflection && !is_map_entry {
let gate = ctx.config.feature_gates().reflect;
if ctx.config.generate_reflection_vtable {
let buffa_path = quote! { __buffa };
let owned = crate::reflect_owned::reflect_owned_impls(
&crate::reflect_owned::OwnedReflectScope {
ctx,
msg,
name_ident: &name_ident,
buffa_path: &buffa_path,
current_package,
features,
oneof_idents,
oneof_prefix,
nesting,
},
)?;
let reflect_body = crate::reflect::reflectable_impl_vtable(&quote! { #name_ident });
// Multiple sibling impls (ReflectMessage, ReflectElement, the memo
// inherent impl, Reflectable) — gate them together with one cfg.
crate::feature_gates::cfg_const_block(quote! { #owned #reflect_body }, gate)
} else {
crate::feature_gates::cfg_block(
crate::reflect::reflectable_impl(&quote! { #name_ident }, &quote! { __buffa }),
gate,
)
}
} else {
quote! {}
};
Expand Down
82 changes: 62 additions & 20 deletions buffa-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub(crate) mod imports;
pub(crate) mod message;
pub(crate) mod oneof;
pub(crate) mod reflect;
pub(crate) mod reflect_owned;
pub(crate) mod reflect_view;
pub(crate) mod view;

Expand Down Expand Up @@ -229,6 +230,43 @@ impl StringRepr {
}
}

/// How much reflection support generated types get.
///
/// Selected through `buffa_build`'s `reflect_mode` builder method (or the
/// `protoc-gen-buffa` `reflect_mode=` option). All modes need the consuming
/// crate to depend on `buffa-descriptor` with its `reflect` feature and on
/// `std`; the call site is `foo.reflect().get(fd)` regardless of mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ReflectMode {
/// No reflection impls.
#[default]
Off,
/// `Reflectable::reflect()` round-trips the message through a
/// `DynamicMessage` (encode → decode → boxed handle). Smaller generated
/// code; pays an allocation and a re-encode per `reflect()` call.
Bridge,
/// `impl ReflectMessage` directly on the owned and view types, and
/// `Reflectable::reflect()` borrows `self` with no round-trip. Larger
/// generated code; near-free reflective access. Requires view generation.
VTable,
}

impl ReflectMode {
/// Apply this mode to a [`CodeGenConfig`] (sets `generate_reflection` /
/// `generate_reflection_vtable`). Used by the `buffa-build` and
/// `protoc-gen-buffa` front-ends.
pub fn apply(self, config: &mut CodeGenConfig) {
let (reflection, vtable) = match self {
ReflectMode::Off => (false, false),
ReflectMode::Bridge => (true, false),
ReflectMode::VTable => (true, true),
};
config.generate_reflection = reflection;
config.generate_reflection_vtable = vtable;
}
}

/// Configuration for code generation.
#[derive(Debug, Clone)]
#[non_exhaustive]
Expand Down Expand Up @@ -478,15 +516,20 @@ pub struct CodeGenConfig {
///
/// Defaults to `false`.
pub generate_reflection: bool,
/// Additionally emit `impl ReflectMessage` / `impl ReflectElement` on view
/// types (vtable mode), on top of the bridge-mode `Reflectable` impl.
/// Emit vtable-mode reflection: `impl ReflectMessage` / `impl
/// ReflectElement` on **both** the view types and the owned message
/// structs, and switch the owned `Reflectable::reflect()` body to borrow
/// `self` (`ReflectCow::Borrowed(self)`) instead of the bridge round-trip.
///
/// Reflective access then reads struct fields in place — no encode/decode
/// round-trip and no per-field allocation — for both a decoded view and an
/// in-memory owned message.
///
/// Requires [`generate_reflection`](Self::generate_reflection) (the vtable
/// impls resolve against the same embedded `DescriptorPool`) and
/// [`generate_views`](Self::generate_views). Internal flag, not yet exposed
/// through `buffa-build`; the public `ReflectMode` surface is wired
/// separately. Vtable mode reads view struct fields directly — no
/// encode/decode round-trip and no per-field allocation.
/// Requires [`generate_reflection`](Self::generate_reflection) (the impls
/// resolve against the same embedded `DescriptorPool`) and
/// [`generate_views`](Self::generate_views). Set via [`ReflectMode::VTable`]
/// — front-ends expose it as `buffa_build::Config::reflect_mode` /
/// `protoc-gen-buffa`'s `reflect_mode=vtable`.
///
/// Defaults to `false`.
pub generate_reflection_vtable: bool,
Expand Down Expand Up @@ -820,24 +863,23 @@ pub fn generate(
///
/// Returns [`CodeGenError::FileNotFound`] if a name in `files_to_generate` has
/// no matching descriptor, [`CodeGenError::Other`] if `generate_reflection_vtable`
/// is set without `generate_reflection` and `generate_views`, and other
/// [`CodeGenError`] variants for malformed descriptors (e.g. a missing required
/// field) encountered while generating.
/// is set without `generate_reflection`, and other [`CodeGenError`] variants for
/// malformed descriptors (e.g. a missing required field) encountered while
/// generating.
pub fn generate_with_diagnostics(
file_descriptors: &[FileDescriptorProto],
files_to_generate: &[String],
config: &CodeGenConfig,
) -> Result<(Vec<GeneratedFile>, Vec<CodeGenWarning>), CodeGenError> {
// Vtable reflection emits `impl ReflectMessage` on view types and resolves
// against the per-package descriptor pool, so it needs both view generation
// and bridge-mode reflection (which emits that pool). Without this check the
// flag would silently emit nothing and the consumer would hit an opaque
// "FooView: ReflectMessage is not satisfied" error far from the cause.
if config.generate_reflection_vtable && (!config.generate_reflection || !config.generate_views)
{
// Vtable reflection resolves against the per-package descriptor pool, which
// is emitted by bridge-mode reflection — so it requires `generate_reflection`.
// It does NOT require views: the owned `impl ReflectMessage` is self-contained,
// so with views off, vtable mode still emits owned-message reflection (the
// view impls are simply skipped along with the views).
if config.generate_reflection_vtable && !config.generate_reflection {
return Err(CodeGenError::Other(
"generate_reflection_vtable requires both generate_reflection and \
generate_views to be enabled"
"generate_reflection_vtable requires generate_reflection to be enabled \
(it provides the descriptor pool the reflect impls resolve against)"
.into(),
));
}
Expand Down
Loading
Loading