Skip to content

Phaser 3.0.0: make messages much cheaper to build, publish and read - #14

Merged
dallison merged 85 commits into
mainfrom
perf/small-message-speed
Oct 23, 2026
Merged

dallison merged 85 commits into
mainfrom
perf/small-message-speed

Conversation

@dallison

@dallison dallison commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

Building, publishing and reading phaser messages is now close to ROS for small and nested messages, and much faster for large ones. Zero-copy publish of the two nested messages measured, one of strings and one of nested fixed-size messages, costs 0.64 to 0.80 times ROS (2.1.1: 6 to 28 times), and with one subscriber reading the message 0.43 to 0.63 times. For the same messages, the ROS-wire path costs 0.97 to 1.30 times ROS to publish and 0.88 to 1.36 times to parse (2.1.1: 6 to 79 times).

This is a major version, 3.0.0: payload buffers have a new magic, so 2.x and 3.x cannot read each other's buffers. The branch also merges the fixed-buffer safety work of 2.1.3 (#13).

Breaking changes

  • Wire format. A payload buffer now starts with a zero byte, then 'P' 'H' and a flags byte (4b03d44), so InferMessageWireFormat reads only the first byte, and kAmbiguous and the protobuf structure scanner are removed. Repeated messages (cf28eed) and, in the ROS frontend, nested messages (4243b06) are also laid out differently.
  • phaser::PayloadBuffer. Phaser has its own copy of PayloadBuffer instead of toolbelt::PayloadBuffer (2e758c3). Code that names the toolbelt type for a phaser buffer must change.
  • Handle lifetimes. Element and nested-message handles of a dynamic message borrow the message, and are valid only while it exists (232ffa4). RuntimeHandle is a MessageRuntime* instead of a std::shared_ptr (bcd2555).
  • Full fixed buffers. A write that does not fit in a caller-provided buffer no longer throws std::bad_alloc (e7e15f7). It does nothing and marks the buffer: later allocations fail too, serializing fails, and a receiver treats the buffer as empty. AllocationFailed() stays true until Clear() of the main message resets the buffer (94f8b93). Code that caught std::bad_alloc must check AllocationFailed() or the returned status instead.
  • Field serializers. A field's Serialize takes a ProtoMessageWriter instead of a ProtoBuffer, and reaches a nested message through its PhaserSerialize(). A hand-written message used in a field needs a PhaserSerialize() too (e89eb50).
  • active_message. This is now a phaser::ActiveMessage instead of a std::any (fb484ba). It is used like a std::any and converts to std::any&, but initializing a std::any from it stores the wrapper.

Changes

Allocator.

  • PayloadBuffer is forked into phaser/runtime, with segregated free lists for small blocks and inline allocate and free paths.
  • Writes to a buffer the process owns skip offset validation, and so do string reads from it (4f3a59e).
  • A new buffer is copied from an image its type builds once, instead of being built through the allocator (f3e94ca).
  • A new string is allocated and copied inline, without a call (e6df05e). Only strings of up to 32 bytes are copied inline, so that setting one saves no registers, and longer ones go to NewLongString() (cec656b).
  • When a new string fits without moving the buffer, SetString stores it without saving registers or locating the source again. Replacing a string is a separate function (7168b43).
  • Allocating from the first free block no longer checks the block against the end of the buffer, which the slow allocator never did (ad01840).
  • Clearing a string of a mutable message frees it without checking its offsets, as setting one already did (67e8613).

Layout.

  • Repeated messages are stored inline, in chunks that never move.
  • Nested messages in the ROS frontend are stored inside their parent.
  • A generated message counts its header once in its size.
  • Each generated field's location is part of its type, so fields hold no state.

Handles and runtime.

  • Handles borrow the runtime rather than sharing ownership of it, and record whether their message is mutable.
  • A nested handle is made the first time it is used.
  • The runtime is 16 bytes, and only dynamic messages own one.
  • CreateMutable builds a fixed message's runtime in its handle instead of copying one in, which stalled on the stores that had just built it (7bee35b).
  • A new message installs the metadata of every type it can reach in one block, and a type's metadata is looked for first where it was last found.
  • A readonly message checks its metadata once.
  • Size() and Finalize() skip nested messages whose handles hold nothing new to write (d345b4a).
  • A runtime control block's bounds are checked with one compare instead of two (db04382).

ROS wire.

  • ROS serialization and parsing write vectors and nested messages straight to and from the payload, and parsing writes a root message's payload directly (cfe7fad).
  • Nested messages are serialized to ROS through an output position held in registers, instead of a ROSBuffer in memory that every byte written could alias, and short strings are copied inline (07b66b9). A populated google.protobuf.Any in an element of a repeated message now fails with a generic Unimplemented error rather than one that names the Any.
  • Parsing reads each string straight into the buffer (e6df05e).
  • Runs of ROS scalars are read after one bounds check, and are stored whole on little-endian hosts.
  • Empty ROS sequences skip vector setup.
  • A pooled message is cleared by resetting its buffer.
  • Syncing an unchanged ROS time or duration field is only a test, and writing a changed one is a separate function (132a7ac).

Trusting the serialized size (e89eb50).

  • A new build setting, //phaser/runtime:trust_serialized_size (CMake: PHASER_TRUST_SERIALIZED_SIZE), is for programs that size their output with SerializedSize() or ROSSerializedSize() and then serialize into an array of that size. It is off by default, and without it the generated code is the same as before.
  • With it, SerializeToArray(), SerializeToROSArray() and their string forms write into the array without checking for room. The array must be at least the serialized size, which debug builds assert. Serialize() and SerializeToROS() into a buffer make room for the whole message first: a fixed buffer that is too small still fails, and an owned one grows once.
  • Converting between wire formats checks as before, as do the checks of string and sequence lengths.
  • AnyMessage finds its value's size before writing anything, so an Any whose value's type is unknown cannot write past the room made for it.

New API.

  • Vectors of scalar-only messages can be filled in bulk through a writer, Add(n, fn).
  • insert, emplace and erase work in place on every vector kind, and two vectors in the same buffer swap their headers in O(1).

Fixed-buffer safety, from 2.1.3 (9c841b9).

  • A full fixed buffer records the failure in a flag bit of its magic. Setters keep their previous value, repeated fields keep their elements, and mutable_ and add_ accessors return a handle that writes to per-thread scratch storage (e7e15f7).
  • TryCreateMutable returns an absl::StatusOr, and CreateMutable aborts when the buffer is too small for the message.
  • Phaser and its generated code build with -fno-exceptions -fno-rtti. Misuse that would throw, such as at() out of range, aborts instead.
  • The generator emits messages in dependency order and avoids presence accessor name collisions, and there is a CMake build (3d5eca6).
  • CreateMutable builds its message in place again, instead of moving it out of the StatusOr of TryCreateMutable. Add() on a repeated message is inlined again, and a failed Add() makes its handle the way a successful one does, so neither stays in memory (c8c3b91).
  • Marking a failure also empties the free lists, so later allocations fail without each testing the flag, and Free() drops the blocks of a failed buffer. This also stops a string or vector growing in place after a failure when the small block allocator is off. CreateMutable no longer checks the memory before NewFixedBuffer, or the buffer after it (94f8b93).
  • Clear() of a main message undoes a failure, so a message reused after running out of space can be filled again. A message that owns one block keeps clearing field by field, and only after a failure calls the shared Message::PhaserClearAfterFailure(), which resets the buffer if it may. Clearing a nested message keeps the failure (94f8b93, 3c86b20).

Fixes.

  • Realloc lost 8 bytes of a block when it grew it into the free block below (16d6362).
  • Copying a string from the buffer it was being copied into read freed memory, and assigning a string a part of itself could lose its end (d15cf42).
  • Assigning a protobuf-frontend message as a whole now works (1d54a54).
  • SetString wrote through a null pointer when a string did not fit. It now fails, leaving the old string, and rejects lengths that do not fit in 32 bits (e6df05e, e7e15f7).
  • An owned ProtoBuffer doubled once when a write did not fit, so a write of more than the doubled size overflowed the heap. It now grows until the write fits (785c228).

Performance

These numbers come from a phaser-vs-ROS benchmark outside this repo, as medians of 5 interleaved rounds of an optimized build on one pinned core, with functions aligned to 64 bytes (--per_file_copt=...@-falign-functions=64). Without the alignment, moving an unchanged receive loop by 16 bytes changed its time by 11%. Ratios are phaser time divided by ROS time, so below 1 means phaser is faster. The three columns are the three publish paths, and both receive figures are against ROS deserialization.

Bool and Header are the std_msgs types. The other messages have these shapes:

  • Strings message: a Header and N statuses, each with 3 strings and 4 key/value pairs of strings.
  • Nested message array: a Header and N elements, each with a nested message, 32 small fixed-size messages and 4 uint64s.
  • Byte array: a Header and one uint8[] payload.
  • Image: a Header, 9 timestamps, dimensions, an encoding string, metadata and pixel data.
Message Zero-copy publish Buffer-copy publish ROS-wire publish Read in place ROS-wire parse
Bool 1.00x 0.86x 0.45x 0.96x 0.61x
Header 1.30x 1.31x 0.91x 0.95x 1.13x
Byte array, 64 B to 1 KB 1.05–1.17x 1.25–1.27x 1.29–1.30x 0.62–0.81x 1.11–1.26x
Strings message, 1 to 32 statuses 0.72–0.80x 0.75–0.87x 1.19–1.30x 0.15–0.25x 0.88–1.00x
Nested message array, 1 to 16 elements 0.64–0.80x 0.73–0.89x 0.97–1.28x 0.28–0.37x 1.31–1.36x
Byte arrays and images, 16 KB to 4 MB 0.36–0.55x 0.81–0.99x 0.82–1.01x 52–128 ns at any size 0.86–1.01x

Images and the 16 MB byte array had 3 rounds. From 12 MB up, ROS pays page faults on a freshly allocated message for every publish, so phaser publishes in 0.16 to 0.33 times the ROS time.

The string-copy fix (d15cf42) cost 6% to 9% on zero-copy and buffer-copy publish of the strings message. The later commits more than make up for it: at 4f3a59e those two paths were 28% to 34% faster than at 74920c8, and 7bee35b and 7168b43 make them 9% to 14% faster again. Over the same two commits, Header zero-copy publish is 13% faster and Bool 10%. 07b66b9 makes ROS-wire publish of the strings message 5% to 14% faster and of the nested message array 8% to 15%, for 7 KB (0.2%) more code in the generated messages. The nested message array is filled with the bulk writer for its fixed-size messages.

The table was measured at 07b66b9, before the fixed-buffer safety merge. That merge made zero-copy publish of a Bool or a Header 51% slower, and filling the strings message and the nested message array 6% to 20% slower. Most of that came from CreateMutable moving its message out of a StatusOr and from Add() no longer being inlined, which c8c3b91 undid. Against 07b66b9, 67e8613 measures as follows, as the fastest of 7 interleaved rounds:

  • Zero-copy publish of a Bool or a byte array is 9% faster, and of a Header 2% faster. The strings message is 4% faster with 8 or 32 statuses and 1% slower with 1, and the nested message array is within 3%.
  • Buffer-copy publish of a Header is 10% faster, and of the strings message 3.5% to 6% faster with 8 or 32 statuses. The others are within 4%.
  • ROS-wire publish of the strings message is 2% to 6% faster, and of a Header 5% slower. The others are within 3%.
  • Reading in place is within 3%.
  • ROS-wire parsing is within 3%, except for the strings message, which is 5.5% to 7% slower.

In the same runs, ROS's own publish and parse, whose code is the same in both builds, moved by -5% to +5%. Header ROS-wire publish and strings message parsing both take about 4% fewer instructions than at 07b66b9. Across the last five commits, none of which adds work to it, parsing the strings message took anywhere from 2% less to 7% more time than before them. So the two slowdowns look like code placement.

Most of the gain in building messages comes from the last five commits. Short strings are copied without a call (cec656b), a free block check is gone (ad01840), the control block takes one compare (db04382), and an unchanged time field syncs with a test (132a7ac). Against e89eb50, building a strings message of 8 statuses in a new buffer takes 9.4% fewer instructions, a Header 6% fewer and a nested message array of 16 elements 3% fewer, and buffer-copy publish of a Header takes 12% fewer.

With trust_serialized_size, the protobuf serialization of a large test message takes 45% fewer instructions, and that of a small one 47% fewer. The ROS serialization of the large message takes 15% fewer. ROS-wire publish of a Bool is 10% faster, of a Header 6%, of a small strings message 4.5% and of a nested message array 3%. Larger messages are within the noise.

A library of generated messages is 1.8 KB smaller than at 9c841b9. It is 5 KB smaller again with trust_serialized_size.

Testing

  • bazelisk test -c opt //phaser/... and bazelisk test --config=asan //phaser/...: all 26 tests pass.
  • New tests:
    • in-place insert, emplace, erase and swap on every vector kind, compared against std::vector;
    • strings and messages copied from the same buffer, which fail under asan without the fix;
    • Realloc into the free block below;
    • reading a finalized buffer with a newer schema;
    • readonly vectors throwing before anything changes;
    • ROS serialization into fixed buffers too small for the message, every 7 bytes from empty, which must fail with ResourceExhausted without writing past them under asan;
    • from 2.1.3, every kind of write into a full fixed buffer, which must stay inside it, and generator edge cases;
    • nothing stored after a failure even when space is freed, Clear() after a failure letting a message be filled again, for a message that owns one block too, and clearing a nested message keeping the failure;
    • an owned ProtoBuffer growing to fit a write larger than twice its size, which fails under asan without the fix;
    • an Any whose value's type is unknown failing to serialize.
  • The tests also pass with --//phaser/runtime:trust_serialized_size, under asan too. Two tests of arrays that are too small skip that part with it.
  • The benchmark and its messages build against this branch.

Notes

Messages in caller-provided buffers now stay inside the buffer when it runs
out of space:

- TryCreateMutable returns InvalidArgument for a buffer smaller than its
  header and ResourceExhausted when the message does not fit.  CreateMutable
  aborts in those cases.
- Setters and repeated fields keep their previous contents.  mutable_, add_
  and array element accessors return a detached handle whose writes go to
  per-thread scratch storage.
- AllocationFailed() reports the failure.  Serialize then fails, Deserialize
  returns ResourceExhausted, and CreateReadonly treats the buffer as empty.
- Placeholder string handles ignore assignment instead of dereferencing null.

The runtime and generated code build with -fno-exceptions -fno-rtti, where
misuse that would throw aborts instead.  fixed_buffer_test builds and parses
messages in every buffer size up to 4 KB under those flags and checks that
nothing is written past the end of the buffer.

Requires cpp_toolbelt 2.1.6, pinned with a git_override until it is in the
Bazel Central Registry.
…ions, add a CMake build

- Emit top-level messages after the same-file messages their fields (and
  their nested messages' fields) use, so a message can use one defined later
  in the .proto file.
- When a message has fields named both <name> and has_<name>, name <name>'s
  presence accessor _phaser_has_<name>() so it doesn't collide with the
  has_<name> field's getter.
- Add CMakeLists.txt: the phaser_runtime library, the protoc-gen-phaser
  plugin (host builds only; cross-compiles take PHASER_PLUGIN_EXECUTABLE), and
  phaser_add_library() to generate and build a library like phaser_library.
- Add generator_edge_cases_test covering both generator fixes.
Phaser now uses its own copy of PayloadBuffer (phaser::PayloadBuffer) so its
allocator can be tuned for message construction without affecting other
cpp_toolbelt users. The in-buffer layout is unchanged, so buffers remain
readable through toolbelt::PayloadBuffer. toolbelt is still used for Hexdump
and Now.
Replace the bitmap-run small block allocator with per-size-class LIFO free
lists (like malloc fastbins) whose heads live in the existing header slots.
An empty class carves a single block from the main free list, so a fresh
buffer is bump allocated and no runs are preallocated. This shrinks small
messages (Header 1048 -> 384 bytes) and makes allocate/free O(1).

Also fix call sites that passed a stale alignment argument as 'clear'
(strings were always zeroed and never used small blocks).
Finalize() walks every message field and vector element calling
SyncToPayload, constructing a handle per element, even though only ROS
time/duration/header fields cache anything outside the payload. The generator
now skips fields whose message type cannot transitively contain one.
Reusing a free small block, carving from the start of the first free block,
and freeing a small block are now inline in the header; other cases go to
AllocateSlow/FreeSlow.
The writer-side metadata registry stored a copy of each type's full name in
the buffer and found entries with string compares. Entries now hold a 64-bit
FNV-1a hash of the name, computed once per type, which removes an allocation
and the name bytes per type and makes lookup an integer scan. The control
block version is bumped to 2.
A mutable message always carries its own type's metadata, and writes already
use each field's compiled offset and id. Fields of a mutable message now cache
that location at construction instead of resolving it through the in-buffer
metadata on first read. Read-only messages still resolve through metadata.
- Generated constructors move the runtime handle into Message instead of
  copying it, saving a refcount round trip per handle for owned runtimes.
- MessageVectorField::Add/Mutable give a new element the first element's
  metadata offset rather than looking it up in the metadata registry.
- Generated messages report kPhaserClearReleasesNothing when every field is
  an inline scalar; vector and indirect message Clear() skip clearing such
  elements before freeing them.
Generated messages emit kPhaserHasSyncToPayloadWork; message vector and
indirect message fields skip the per-element virtual SyncToPayload call
when the element type has nothing to sync.
Avoids materializing and moving a shared_ptr through each constructor
layer, which also removes a store-forwarding stall on the 16-byte reload.
The handle members start empty in a constructor, so build them in the
member initializers instead of default-constructing and reassigning.
Generated messages expose a constexpr PhaserROSFixedSize(), so sizing a
fixed-size message or a vector of them needs no per-element work.
Repeated field loops evaluate the element count once, and ROSBuffer
writes take an inline fast path with stores that no longer alias size_.
Appended message elements are freshly allocated, so deserialize them
without clearing first.  ROSReader::Read keeps its error formatting out
of line and reads through a local pointer.
ROS sizing and writing read cached ROS values through the field
accessors, so syncing them into the payload first only allocated
sub-messages that the next Clear() freed again.
A message that reaches no other message types installs its metadata
once, so the control block had nothing to cache.
When a received message's metadata is identical to the compiled table,
its fields are at their compiled locations, so a readonly handle can
skip the per-field metadata lookups just like a mutable one.
VectorPush now appends in place whenever the vector's block has room,
using the full size class of a small block rather than only its requested
size, and only falls back to the reallocating path when the block is full.
MessageVectorField::Add() resolves the buffer once and copies the first
element's metadata word directly instead of going through validated
lookups for every element.
Clearing a message vector used to build a handle for every element and
call its Clear(), which resets each field and frees through validated
lookups, only to free the element afterwards.  Generated messages now
have a static PhaserReleaseAt() that frees what an element owns directly
at its compiled field offsets.  Each supported field type declares how
its payload memory is released through internal::FieldRelease; a message
with any other field (a oneof, Any, string array) still clears through a
handle.

ClearString is now inline, and PayloadBuffer::FreeAt frees a block by
offset.
A time or duration set through a temporary handle, such as a repeated
element, was cached in the handle and lost when it was destroyed.  Set()
now writes to the payload immediately, and a value changed through the
MutableRos() reference is written when the handle is destroyed.

Since every repeated element handle is a temporary, SyncToPayload no
longer walks repeated fields.
A mutable runtime's buffer was built here, so its address conversions,
field stores and presence bits no longer check the magic and bounds on
every access.  Received readonly buffers are still fully validated.
Enum and oneof setters now reject a readonly message like the other
setters, since they rely on that.
A repeated message field used to hold an array of offsets, with each
element in its own allocation.  Its data is now a MessageVectorBlock
(stride, first chunk capacity, element metadata, chunk directory)
followed by the first chunk of elements.  Further chunks each double the
capacity and never move, so element handles stay valid as the vector
grows.

Add() into the first chunk no longer calls the allocator, each element
saves its size word and offset slot, the metadata is stored once per
vector, and Clear() frees a few chunks instead of every element.
Received blocks are validated once per access and elements outside the
first chunk are bounds checked.

ROS deserialization and CloneFrom reserve the element count up front;
the ROS count is limited by the bytes left in the input.
The constructor that binds a generated message to a runtime and offset is
now defined inline in the header, so element and field handles are built
in place instead of through a call into the generated library.  Field and
MessageRuntime no longer have virtual destructors: fields are only
destroyed as members, and runtimes are owned by shared_ptrs made for their
own types.  This removes a vtable store per field of every handle.
Each field carried a lazily filled cache of its offset and id, which every
handle had to initialize and every copy-assignment had to reset.  A
message already knows whether its fields are at their compiled locations,
so a field now keeps only its compiled offset and looks itself up in the
message's metadata when they are not (a readonly message written with
another schema version).

Primitive and enum setters read the runtime, the message start and the
field id before writing the payload, since the compiler must assume a
payload store can change the handle.  The readonly-message throw is moved
out of line.
A handle for an element or a nested message of a dynamic message shared
ownership of its runtime, so making one cost an atomic increment and
decrement whenever the process had a second thread.  Such handles now
borrow the runtime: they use the message's dynamic runtime in place, so
they still follow the payload buffer when it moves, but they are valid
only while the message exists.  Handles for fixed and readonly buffers
copy their runtime as before.  Copying a ROS-frontend handle into a
dynamic message still deep-copies it.

Nested and oneof message handles are rebound with BindBorrowed, which also
records whether their fields are at their compiled locations; after the
per-field cache was removed they looked up every field.
A new buffer used to get its root's field metadata copied in by a lookup
that missed, and each other message type got its own allocation and table
entry the first time a nested or element message of that type was made.
The generator now passes the root and every type it can reach to
InitializeMetadata, which copies their tables in as one block and fills
the control block's entries directly.  The block is built at compile time,
so this is one allocation and one copy.  When the tables are larger than
kMaxEagerMetadataSize only the root's is installed and the rest are still
added on first use, so a message that can reach many types it rarely uses
does not grow.

Default-constructed dynamic messages are bound with BindNewMutable, which
records that their fields are at their compiled locations; since the
per-field cache was removed they had been looking up every field.  The
dynamic creators make the owning runtime before installing metadata so
the buffer is freed if that throws, and a failed allocation of the main
message or control block now throws std::bad_alloc instead of writing
through a null pointer.
A singular message field was always a 4-byte offset to a message in its
own allocation, so filling a Header cost an allocation and a metadata
install for its stamp, and an element of a repeated message paid that for
every nested message it held.  ROS messages have no presence for nested
messages, so the ROS frontend now stores each singular message that is
not in a oneof inside the message holding it, unless its type can reach
the holding type through singular fields.  An inline message is present
when its metadata word is set; Mutable() sets it and Clear() releases the
message's contents and zeroes it.

Inline messages follow the other fields.  Their offsets are C++
constants built from the nested types' BinarySize(), so the layout
always agrees with the nested types as compiled.  Their field metadata
entries use the otherwise unused id kInlineMessageFieldId, so a reader
whose layout differs, such as the protobuf frontend's, finds the message
either way.  IndirectMessageField takes the storage as a template
parameter, with InlineMessageField as the inline form, and the ROS
intrinsic fields pass it through.
The generator's binary size already starts with the metadata offset and
the presence mask, but BinarySize() added HeaderSize() to it again, so
every generated message and every element of a repeated message carried
4 to 12 unused bytes.  A Timestamp took 28 bytes rather than 20 and a
message of two uint64 fields took a 32-byte stride rather than 24.
ProtobufToROS wrote, and ROSToProtobuf read, every scalar at its protobuf
width, so a field declared as a narrower ROS type (uint8, int16, ...) did
not match SerializeToROS.  Packed fixed-width fields are now copied raw only
when the ROS and protobuf widths agree.
SerializeToROS and ROSSerializedSize built a handle for every element of a
repeated message field.  In a mutable buffer every message has its compiled
layout, so a generated message whose fields are all plain (no oneof, Any or
fixed array) now has static PhaserSerializeToROSAt and
PhaserROSSerializedSizeAt functions that read it at an offset, and a
repeated message field of a mutable message walks the vector's chunks and
calls them, falling back to a handle for other element types.  An absent
nested message is written from the cached ROS bytes of a default one, which
are built once from a message of its own.

ROS-wire publish of a nested message array of 16 elements goes from 8.5x
ROS to 4.9x, and of a strings message of 32 statuses from 4.2x to 2.4x.
RuntimeHandle is now MessageRuntime* instead of a shared_ptr.  Only the
runtime of a dynamic message is owned: DynamicMutableMessageRuntime
counts its owning handles, a copy of an owning handle shares it, a move
passes it on and the last owner deletes it.  BindNewMutable() takes the
new runtime and OwnsRuntime() says whether a handle is an owner.  Every
other handle borrows its runtime or holds a copy, as before.  This
takes the Message base from 56 to 48 bytes.

IndirectMessageField keeps its nested handle in a small slot instead of
a std::optional, so making the handle has no destroy path and stays
inline.

Reading received messages without copying is 6-24% faster; publishing
is unchanged.  25.6KB less text in a library of generated ROS messages.
The defaulted copy assignment rebound the handle to the other message
and then assigned each field, which copies the field's value into the
message the handle now refers to.  For a message with a oneof holding a
message, that wrote through the oneof's unbound nested handle and threw
"cannot mutate a readonly Phaser message", even for two default
messages.  Assignment now copies the handle as the copy constructor
does, by building the copy and moving it into place, like move
assignment.
A readonly runtime's buffer size and a mutable one's reset mark now
share a 32-bit word, which takes MessageRuntime from 24 to 16 bytes and
the Message base from 48 to 40.  BufferSize() and ResetMark() read it
for their kind of runtime only.

A readonly runtime's bound is now never 0.  One made without a size
takes the buffer's size when it is made rather than at every access; a
size of 0 trusts nothing.  Its bounds checks therefore never fall back
to the size inside the payload buffer, so that branch is gone from
every inlined check.

Reading received messages without copying is 4-12% faster, and
publishing is 2-5% faster for a strings message and within 3% for a
nested message array.  94KB less text in a library of generated ROS
messages.
When Realloc grows a block into the free block below it, the block
moves down and what is left of the two follows it.  The header of that
free block was written before the move, and starts inside the block's
old place when the new size is at least that of the free block below,
so it overwrote 8 bytes of the block before they were copied.  When
nothing was left, the header was written over the size of the next
block, and a free block of length 0 was put on the free list.  The
header is now written after the move, and only when there is room for
one; otherwise the free block is taken off the list.

Growing a vector could lose two 4-byte elements this way.
insert() and emplace() copied every value of the vector out and
assigned them back.  They now add the value at the end and move the
values from the position up by one, so an insertion copies only the
values that move.  The elements of a message vector move within and
between its chunks, and emplace() with no arguments leaves the new
element empty rather than copying an empty message into it.

A value in the buffer being added to, a string or a message, is copied
out first, since the buffer can move while it grows.  A message value
of that buffer stores its cached ROS values first, so that they stay
with its element as it moves.

Building a vector by inserting in the middle is 9-270x faster for
primitive and enum vectors, 15-48000x for strings and 137-5000x for
messages, from 16 to 1024 elements.
erase() copied every value of the vector out and assigned back the
ones left.  It now releases what the values erased hold and moves the
values after them down, so an erasure copies only the values that move.
The elements of a message vector move within and between its chunks.

swap() of two vectors in the same buffer exchanges their VectorHeaders,
as the data of a vector is reached only through buffer offsets, so
nothing is copied or allocated.  Vectors in different buffers still
exchange copies.  As with insert(), a readonly message throws before
anything changes.

Erasing from the middle of a vector until it is empty is 10-230x faster
for primitive and enum vectors, 44-120000x for strings and 170-10000x
for messages, from 16 to 1024 elements.  A swap in one buffer takes
7-17ns, against 0.6us to 2ms before.
Setting a string from a view into the same message read freed memory
when making room for it moved the buffer: push_back(msg.names[0]),
names.Set(i, other), a singular field assigned from a repeated one,
and cloning a message within its own buffer all did.  Assigning a
string a part of itself could also lose its end, as shrinking the old
string in place writes a free block header over it.

SetString() now finds a source in the buffer again by its offset after
allocating, and copies out a source inside the string it replaces.
String vector push_back() and Set() and string array Set() hold the
source the same way across the allocations they make first.

Assigning with = to a string element that does not exist yet still
cannot be fixed this way, as the element is made after the view is
taken; the user guide says to use Set() there.
Payload buffers are now told apart by their first byte (64829aa), so
buffers made by 2.x and 3.x cannot be read by each other, and
InferMessageWireFormat no longer returns kAmbiguous.
CreateMutable built every new buffer from scratch: the header, the main
message, the runtime control block and the metadata tables, each through the
allocator.  Only the buffer's size and the length of its free block depend
on the size of the buffer, so each type now builds a new buffer once, keeps
its bytes, and copies them into each later buffer it fits in, patching those
two words.  Tuning::kSize, buffers smaller than the image and images over
64KB still build in place.

The image is never destroyed, so static destructors can still make messages.
A buffer from NewFixedBuffer is known to be new, so it is marked without
checking its layout, and Finalize syncs and sizes the message once.
Most strings a message is given are new and short, so their cost was in
calls: SetString checked the header, called Allocate, which is in another
library, and called memcpy.  A string whose header holds none now goes to
NewString, which allocates and copies short strings inline, reading the
header without checking it as the other mutable-buffer stores do.

Parsing ROS reads each string straight into the buffer with ReadNewString,
which is one call, instead of returning it through a StatusOr first.  The
generated code is smaller for it.

SetString now throws std::bad_alloc, leaving any old string, when a string
does not fit, where it wrote through a null pointer, and it rejects lengths
that do not fit in 32 bits.
ParseFromROS on a mutable message whose fields can all be parsed directly
now clears it and writes the payload as its nested messages already were,
instead of through each field's handle.  A nested message of a fixed ROS
size is read inline, without a call.

Parsing a Header is about 6% faster, a Bool 20%, a byte array message 10%
and a nested message array of one element 11%.
SyncToPayload() of a message field rebound the field's handle and called
the message's SyncToPayload() whenever the handle had been made, as Size()
and Finalize() both do.  It now first asks the handle, which answers from
its fields' handles alone, whether any holds a value not yet written, and
binds it more cheaply in a mutable message.

Copying a message with a 64-byte byte array into a slot is about 27%
faster, building it in place 13%, and a strings message or nested
message array of one element 7-12%.
A mutable buffer was built by this process, so, as with its other offsets,
a string in it is read inline without being bounded by the buffer's size.

Serializing a Header to ROS is about 17% faster, a 64-byte byte array
message 21% and a nested message array of one element 9%.
Parsing and reading a Header is 13% faster.
CreateMutable built a runtime on the stack and the handle copied it at
once.  The copy's wide load of the narrower stores that had just built it
could not be forwarded from them and waited.  The handle now builds its
runtime in place.  Bool zero copy publish is 13% faster and the messages
library 2.6 KB smaller.
A new string that fits without moving the buffer needs neither the
source's offset in the buffer nor any call.  NewString() now tries that
first and leaves everything else to NewStringSlow(), and SetString() hands
a replaced string to a function of its own.  SetString() for a new string
saves 3 registers rather than 6 and reaches its slow paths by tail calls.

Allocate() is no longer inlined: with one slow call it had become small
enough to inline into every caller, adding 50 KB to a messages library.

Publishing a strings message is 7% to 12% faster in each mode, parsing
it 5% to 7%, and publishing a Header 4% to 5%.
ROS serialization of a message in a mutable buffer wrote each nested message
through a ROSBuffer in memory.  Every string was a call to memcpy, after
which the buffer's size was reloaded and stored, and every nested message
returned an absl::Status.  The generated PhaserWriteROSAt() replaces
PhaserSerializeToROSAt(): it takes the output position and the end of the
space by value and returns the new position, or null if the message does not
fit, and copies short strings inline.  The root message hands each vector of
messages the free space of its ROSBuffer; one that does not fit has room
made for it and is written again.

Serializing a strings message to ROS is 5-14% faster and a nested
message array 8-15%.  A library of generated ROS messages is 7 KB (0.2%)
larger.
@dallison
dallison force-pushed the perf/small-message-speed branch from e4afcf1 to 07b66b9 Compare October 22, 2026 16:57
A full fixed buffer records allocation failure and leaves earlier contents intact, so the 3.0 payload format keeps the safety checks from 2.1.3.
The fixed-buffer safety work slowed two paths that every fill takes.

CreateMutable() called TryCreateMutable() and moved the message out of the
StatusOr.  The move copied the handle's runtime with one wide load just
after it was stored with narrow ones, a store-forwarding stall that cost
12 ns for a Bool made in place and 25 ns for a Header.  Both now call
NewMutableFixedBuffer(), which returns the buffer or null, and build the
message where it is returned.  TryCreateMutable() takes its status from
NewMutableFixedBufferError(), so the error codes are unchanged.

MessageVectorField::Add() built a whole detached message when the buffer
was full.  That made Add() too big to inline for most types, and where it
was inlined, the detached message was made through the handle's address,
so the handle stayed in memory and every field store reloaded it.
AllocationFailureSinkFor() now returns the sink's runtime and message, and
Add() makes the failure handle from them as it makes the element's.  Add()
is always inlined again.

Zero-copy publish of a Bool or a Header is within 4% of its speed before
the safety checks, against 51% slower after them.  Zero-copy and
buffer-copy publish of a strings message with 8 or 32 statuses are within
2.5%, against 6% to 11%.  A nested message array is still 2% to 7% slower,
mostly from the allocation-failed check in each allocation.  A library of
generated messages is 7 KB (0.2%) larger.
Once a fixed buffer has failed an allocation, later allocations must fail
too, so that the message holds only what was written before the failure.
That was a test of the failure flag at the start of every in-place
allocation, which cost 1-4% on messages that allocate many small blocks.

Marking the failure now also empties the free lists.  The in-place path
then fails by itself, and the slow path already tests the flag.  Free()
drops the blocks of a failed buffer, so freeing cannot refill the lists.
This also closes a gap: with the small block allocator off, a string or
vector could grow in place into the free block after it, after the
failure.

Clear() of a main message now undoes a failure.  The reset that remakes
the buffer's free memory also clears the flag, so a message that is reused
after running out of space can be filled again.  It used to stay failed for
good.  A message that owns one block keeps clearing field by field, and
resets only after a failure, in a function of its own so that its Clear()
needs no stack frame.  A message that owns no blocks cannot fail.

CreateMutable no longer checks the memory before NewFixedBuffer, which
already returns null for memory that cannot hold the message, and now for a
null address too.  It no longer checks the new buffer after it either: a
buffer NewFixedBuffer returns has its message and no failure.

Against the previous commit, zero-copy publish of a Bool is 13% faster, of
a byte array 3% faster, and of a strings message or a nested message array
0% to 2.5% faster.  Parsing ROS wire is 4% faster to 0.5% slower.  Publish
of a Header is 2% to 3% slower in every mode, including zero-copy, where
its only change is the shorter creation that made a Bool faster, so that is
likely code placement.  A library of generated messages is 18 KB (0.2%)
larger.
Each message owning one block had its own PhaserClearAfterFailure(),
repeating its fields' clearing.  In a large library of generated ROS
messages, those functions came to 18 KB, 0.5% of its text (the previous
commit put it at 0.2%).

One Message::PhaserClearAfterFailure() now does the work for all of them.
It unmarks the failure so that the virtual Clear() takes its usual path,
marks it again to drop the blocks that Clear() freed, and then resets the
buffer if it may.  Such a message has no field that allocates when
cleared.  Clear() keeps its one test of the flag, with a tail call when
it is set.

That library is now 1.7 KB smaller than before the failure path was
added.

A new test clears a nested message in a failed buffer, whose reset is
refused, and checks that the failure stays and that the freed space is
not used again.
A ProtoBuffer that owns its memory doubled once when a write did not fit,
whether or not that made room for it.  A write of more than the doubled
size then ran past the end: a 100-byte string written to a new 16-byte
buffer overflowed the heap.  It now doubles until the write fits.

A new test writes such a string, and fails under AddressSanitizer without
the fix.
A program that sizes its output with SerializedSize() or
ROSSerializedSize() and then calls SerializeToArray() or
SerializeToROSArray() pays for a check on every write that cannot fail.
The new build setting //phaser/runtime:trust_serialized_size (CMake:
PHASER_TRUST_SERIALIZED_SIZE) defines PHASER_TRUST_SERIALIZED_SIZE for the
runtime and everything built against it, and sets kTrustSerializedSize.

With it, serializing a message writes without checking for room:

- The generated PhaserSerialize() and PhaserSerializeToROS() write through
  a ProtoMessageWriter or ROSMessageWriter.  These forward to the buffer's
  writes, which now take a template argument that turns the check off.
  The direct ROS writers skip their checks of the end too.
- SerializeToArray(), SerializeToROSArray() and the string forms write
  straight into the array, which must be at least the serialized size.
  Debug builds assert this.
- Serialize() and SerializeToROS() into a buffer make room for the whole
  message first.  A fixed buffer that is too small still fails, and an
  owned one grows once.
- Converting between wire formats checks as before, as do the checks of
  string and sequence lengths.

Without the setting, a large library of generated ROS messages builds to
the same code as before.

Field serializers take a ProtoMessageWriter rather than a ProtoBuffer, and
reach a nested message through its PhaserSerialize().  A hand-written
message used in a field needs one too, as the test messages now have.
AnyMessage finds its value's size before writing anything.
SerializedSize() is 0 when the value's type is unknown, so writing the
type URL first would run past the room made for it.

With the setting, the protobuf serialization of a large test message takes
45% fewer instructions, and that of a small one 47% fewer.  The ROS
serialization of the large message takes 15% fewer.  In a publish
benchmark that fills a message and serializes it with SerializeToROSArray(),
a Bool is 10% faster, a Header 6%, a small strings message 4.5% and a
nested message array 3%.  Larger messages are within the noise, with 3.8%
fewer instructions.  A large library of generated ROS messages is 5 KB
smaller: its ROS serializers shrink, and its protobuf ones grow as their
writes are inlined.

The tests pass with the setting, under AddressSanitizer too.  Two tests of
arrays that are too small skip that part with it.  New tests serialize an
Any whose value's type is unknown, which overflows with the setting
without the reordering, and grow an owned ProtoBuffer with EnsureSpace().
NewString() copied a string of any length in line, calling memcpy() for
one of more than 32 bytes.  Keeping the buffer, the block and the header
offset across that call made every string save and restore registers.  A
string of more than 32 bytes now goes to NewLongString(), which is not
inlined, so setting a shorter one makes no call.

CopyString() copied a string of 17 to 32 bytes through two 16-byte
arrays, one of which clang kept on the stack.  It now copies four 8-byte
words.

Building a strings message of eight entries in a new buffer takes 3.3%
fewer instructions: about 3 fewer for each of its 89 strings, and 5 more
for each of the 24 that are 17 to 32 bytes long.
Carving an allocation from the first free block checked that the block's
length did not reach past the end of the buffer.  The free list is this
process's own, and AllocateSlow(), which carves from any free block, never
checked it.  Without it the allocation saves its compares and a register.

Building a strings message of eight entries in a new buffer takes 5.6%
fewer instructions, and a Header 1.4% fewer.
GetRuntimeControl() of a mutable message checked that the control block
fit in the buffer with two compares, the first so that the second could
not wrap.  A sum in 64 bits cannot wrap, so one compare does.

Finding the metadata of a nested message and Finalize() both pay for the
check.  Building a Header in a new buffer takes 10 fewer instructions (2%),
and a nested message array of 16 entries 1.4% fewer.  A large library of
generated ROS messages is 8 KB smaller.
A ROS time or duration field keeps its value apart from the payload and
writes it there when synced.  Syncing an unchanged field called the whole
sync, which was not inlined.  The write is now StoreCache(), which is not
inlined, and the test for whether there is anything to write is.

Publishing a Header by copying its finished buffer syncs it more than once,
and takes 34 fewer instructions (about 7%).  Building one in place takes 11
fewer (2%).
Clearing a string field checked that its header, and the string it points
to, lay inside the buffer, as reading a received buffer must.  Only a
mutable message can be cleared, and this process built its buffer, so
ClearBuiltString() frees the string without checking, as setting one
already does.  It is not inlined: inlined, it made the Clear() and
CloneFrom() of messages with many strings 15 KB bigger in all.

Reusing a Header, clearing it and filling it again, takes 17 fewer
instructions, 4.4% of filling it and serializing it to ROS.
@dallison
dallison marked this pull request as ready for review October 23, 2026 21:21
@dallison
dallison merged commit 8a4b37e into main Oct 23, 2026
5 checks passed
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