Repository navigation
Phaser 3.0.0: make messages much cheaper to build, publish and read - #14
Merged
Merged
Conversation
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
force-pushed
the
perf/small-message-speed
branch
from
October 22, 2026 16:57
e4afcf1 to
07b66b9
Compare
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
marked this pull request as ready for review
October 23, 2026 21:21
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.
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
'P' 'H'and a flags byte (4b03d44), soInferMessageWireFormatreads only the first byte, andkAmbiguousand 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 ofPayloadBufferinstead oftoolbelt::PayloadBuffer(2e758c3). Code that names the toolbelt type for a phaser buffer must change.RuntimeHandleis aMessageRuntime*instead of astd::shared_ptr(bcd2555).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 untilClear()of the main message resets the buffer (94f8b93). Code that caughtstd::bad_allocmust checkAllocationFailed()or the returned status instead.Serializetakes aProtoMessageWriterinstead of aProtoBuffer, and reaches a nested message through itsPhaserSerialize(). A hand-written message used in a field needs aPhaserSerialize()too (e89eb50).active_message. This is now aphaser::ActiveMessageinstead of astd::any(fb484ba). It is used like astd::anyand converts tostd::any&, but initializing astd::anyfrom it stores the wrapper.Changes
Allocator.
PayloadBufferis forked intophaser/runtime, with segregated free lists for small blocks and inline allocate and free paths.NewLongString()(cec656b).SetStringstores it without saving registers or locating the source again. Replacing a string is a separate function (7168b43).Layout.
Handles and runtime.
CreateMutablebuilds a fixed message's runtime in its handle instead of copying one in, which stalled on the stores that had just built it (7bee35b).Size()andFinalize()skip nested messages whose handles hold nothing new to write (d345b4a).ROS wire.
ROSBufferin memory that every byte written could alias, and short strings are copied inline (07b66b9). A populatedgoogle.protobuf.Anyin an element of a repeated message now fails with a genericUnimplementederror rather than one that names the Any.Trusting the serialized size (e89eb50).
//phaser/runtime:trust_serialized_size(CMake:PHASER_TRUST_SERIALIZED_SIZE), is for programs that size their output withSerializedSize()orROSSerializedSize()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.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()andSerializeToROS()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.AnyMessagefinds 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.
Add(n, fn).insert,emplaceanderasework 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).
mutable_andadd_accessors return a handle that writes to per-thread scratch storage (e7e15f7).TryCreateMutablereturns anabsl::StatusOr, andCreateMutableaborts when the buffer is too small for the message.-fno-exceptions -fno-rtti. Misuse that would throw, such asat()out of range, aborts instead.CreateMutablebuilds its message in place again, instead of moving it out of theStatusOrofTryCreateMutable.Add()on a repeated message is inlined again, and a failedAdd()makes its handle the way a successful one does, so neither stays in memory (c8c3b91).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.CreateMutableno longer checks the memory beforeNewFixedBuffer, 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 sharedMessage::PhaserClearAfterFailure(), which resets the buffer if it may. Clearing a nested message keeps the failure (94f8b93, 3c86b20).Fixes.
Realloclost 8 bytes of a block when it grew it into the free block below (16d6362).SetStringwrote 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).ProtoBufferdoubled 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_msgstypes. The other messages have these shapes: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
CreateMutablemoving its message out of aStatusOrand fromAdd()no longer being inlined, which c8c3b91 undid. Against 07b66b9, 67e8613 measures as follows, as the fastest of 7 interleaved rounds: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/...andbazelisk test --config=asan //phaser/...: all 26 tests pass.insert,emplace,eraseandswapon every vector kind, compared againststd::vector;Reallocinto the free block below;ResourceExhaustedwithout writing past them under asan;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;ProtoBuffergrowing to fit a write larger than twice its size, which fails under asan without the fix;--//phaser/runtime:trust_serialized_size, under asan too. Two tests of arrays that are too small skip that part with it.Notes
git_overrideuntil 2.1.6 is in the Bazel Central Registry.ReallocandSetStringfixes are also needed in cpp_toolbelt, whosePayloadBufferhas the same bugs. They are in Fix data loss when Realloc moves a block into the free block below cpp_toolbelt#30.