RFC-0014: Tracing Protocol Redesign: The Big Picture #4508
Replies: 3 comments
|
📝 RFC Document Updated View changes: Commit History |
0 replies
|
📝 RFC Document Updated View changes: Commit History |
0 replies
|
📝 RFC Document Updated View changes: Commit History |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
📄 RFC Doc: 0014-tracing-protocol-redesign.md
Tracing Protocol Redesign: The Big Picture
Authors: @primiano
Status: Draft
PR: N/A
I am far from having a full complete design. But it's time to drop thoughts onto
a document to have a skeleton with the big picture. We'll fan out dedicated RFCs
from here once things crystallise.
Status update and open TODOs
Last Update: 2026-06-09.
A number of things here require re-thinking and update of this RFC:
RFC 28 I realized that QoS needs to be
a key part of the new protocol.
about all the possible edge cases, as we are introducing a new concept that
spands across the lifetime of one session.
in the chunk. Today the buffer ID is known by traced in two ways:
longer holds in the new protocol as we are getting rid of IPCs to make
commits and replacing them with a futex.
as an abstraction since the day they have been introduced.
The main issue here is the drama of "what if we want > 256 bytes?"
marker.
Problems
Over the years we have accumulated a number of issues with the current tracing
protocol, which are not just code bugs, but inherent deficiecies of the overall
tracing architecture.
The whole team believes these limitations have now become a bottleneck to the
scaling of Perfetto and need addressing.
The internal document go/tracing-sdk-2025
has a good overview of the problems. The summary of that document (available
to external on request) is:
Lack of QoS: the SMB is a single resource per-process but a process
can contribute have multiple sessions and buffers.
SMB exhaustion in presence of many idle-ish threads: Some core android
processes can have several hundreds of threads. In the current model once a
thread traces even just once, it holds onto a 4KB chunk forever.
Interning is very unreliable: it's a whack-a-mole game of trying
to guess how frequently we should invalidate incremental state.
Lack of auditing for some data drop patterns: if the SMB is full data can
be dropped if the SMB is exhausted and the thread doesn't manage to trace at all
later on.
High level proposal
protocol must still be supported (probably forever, or for 5y+ at least).
and destroyed. This allows having N ring buffers in the SMB, one per target
buffer (and one or more dedicated to track event).
buffer type for track event, making traced aware of the category system.
hot-path even faster (and far closer to a zero-copy memcpy), and we do
interning (in a reliable batched way) in a different thread (in the long run,
directly in traced).
compression using LZ4/ZSTD within traced. This makes the protos easier to
write and reason about, and removes all the hashmaps and complexity we have
today for interning. It also makes interning across threads possible, which
further benefits the trace size reductions.
only once (rather than today's "once per tracing session"), and then "tee-ed"
internally by traced to the various tracing sessions and buffers that want them.
which uses push/pop markes for begin/end of message (reusing proto groups).
This will only affect internally the new SMB. The final trace file
will still be a valid canonically-encoded binary proto as it is today.
either: (1) only used to handshake a SMB, and everything happens on the SMB.
(2) (At some point in the future, let's not get into this here) used to
support small-binary-size TrackEvent-nano, where apps can just write track
events on a socket and call it a day for low-bandwith cases.
SMB segments
High level sketch, a more detailed design should follow in a dedicated RFC.
Core ideas.
Move to producer-allocated SMB
The SMB should always be client-allocated rather than server-allocated.
Server allocation was a mistake, because makes some use cases impossible
(e.g., (1) startup tracing; (2) using the SMB to hold the booleans for track
event categories).
Technically today we support producer-provided SMB, but de-facto Chromium is the
only one using that feature.
In the new protocol the SMB should always be producer-allocated.
SMB: Large virtual address reservation
We should think about the new SMB as a large-ish (say 128MB) virtual address
reservation. This is going to be realistically the upper bound on how much
memory we are ever willing to use for tracing for a given process. It does NOT
mean that we will use 128MB though. All platforms we support realistically use
overcommit, so we only pay for memory we fault in.
The idea is to use this virtual address reservation to carve out segments
(range of congtiguous pages) and madvise(WONTNEED) when they are not necessary
anymore. Think of some sort of page allocator for tracing purposes.
Overall creating/removing a segment is a slowpath operation (along the lines
of our IPC today). Segments will be created/destroyed when starting/stopping a
tracing session.
SMB Segments
I envision the following type of segments
Directory segment
The root segment. Every SMB MUST have one directory segment. This segment is an
index of all the other segments in the SMB. Essentially an array of tuples.
NOTE: this means that we will support at most 4096/6 = 680 segments
(e.g. 680 ring buffers). This should be plenty. If we ever need more, we can
chain them by having a descriptor that points to another directory segment.
TrackEvent categories segment
This segment will hold all the booleans for track event categories. The client
initializes them as categories are registered. The SDK will use this segment for
the category booleans. This allows traced to directly manipulate the
enabled/disabled categories without doing any IPC.
This segment should contain:
are toggled. This can be useful for the client to do quick checks of the form
"did any of the categories changed? do I need to rescan? (E.g. if a category
has an onchange listener attached).
booleans start from the top of the segment, and the strings from the bottom
and they meet in the middle. If we run out of space, we can always allocate
a new segment for more storage.
hash. Then the only thing that needs the strings is traced and the UI, but
they can figure it out from the trace config or DS descriptor. This requires
more thinking. For now let's say we have the full strings.
{TracePacket, TrackEvent} ring buffer segment
Those are the main buffers for tracing data. In a way they are conceptually
similar to today's main SMB, but with some key differences.
I am putting them in the section because the ring buffer mechanics should be
identical. What will change is the payload of each event, which will be
described separetely below.
Note that we are still talking about the (relatively tiny) ring buffers in the
shmem. NOT about the traced's buffers.
How many shared ring buffers?
For DataSources (anything other than TrackEvent)
In order to solve the QoS problem we should have one ring buffer per traced
buffer (or at very least one RB per session, TBD).
One RB per traced-buffer (per session) gives the most predictable behaviour, as
each buffer section defined by the user becomes de factor a QoS domain.
My only concern is that one RB per buffer can end causing too much memory
overhead in each app using tracing if we have many overlapped sessions.
(But OTOH, I suspect that most apps will only care about and use track event
so it's fine realistically)
For TrackEvent
My intial theory is that we need only ONE RB total (per producer) for track
event, as then we can teach traced to demultiplex and route events into the
various sessions that requested that particular category. That would reduce
by far the overhead requires in each app to trace track events.
However, as track-event becomes popular, that would reintroduce the same QoS
problems that today's one RB has. Imagine Winscope data sources or some other
heavy data source like chrome's memory infra gets implemented via Track Event,
and they start doing multi-MB dumps... they will screw up the rest of tracing.
My theory is that what we need for track event is a category-based partitioning
rather than a session-based partitioning:
rather than having one RB per session, we need to map some TE categories to
dedicated ring buffers, after we discover they do heavy things.
The syntax and semantic of the TraceConfig to configure this partitioning should
be addressed in a dedicated RFC and is left as an exercise to the reader.
Encoding changes
We should use a different encoding (which is not strictly protobuf-compliant)
in the SMB to avoid patching and the redundant varint encodings. The idea is
to revive protobuf's SGROUP / EGROUP, which are a deprecated
feature of protobuf.
to mark begin and end of submessages, rather than using "length-delmited + len".
Note that this will have no visible effect to the outside: traced will still
store protobuf-compliant messages in its ring buffer and will still emit traces
in the canonical encoding. SGROUP/EGROUP will exists only in at the trace
protocol ABI in the SMB.
This allows to get rid of patching altogether, which in turn gets rid of a whole
lot of complexity in GetNewChunk().
It's unclear to me whether we will need to create a variant of
protozero::Messagewhich uses groups. I feel we don't need all that layeringand we can flatten everything into the new TraceWriterV2.
protozero is nice as it is, because was designed both for encoding and decoding
to deal with proto-compliant messages, sometimes on the heap (TraceProcessor
makes abundant use of it).
We don't have the same use-cases here, as we are dealing strictly only with the
SMB ABI. We should just push down all those
BeginNestedMessage(),AppendVarInt()etc methods directly into TraceWriterV2.New Ring buffer design
Like before, this ring buffer is NOT the main tracing ring buffer. it only
serves to decouple scheduling latencies between traced and the client, and to
avoid a context-switch-per-send (which would happen if we were to use purely
socket like logd does).
Each ring buffer has a "control header" (for stats and futex/synchronization)
and then a series of chunks of the same size.
The ring buffer is organized in chunks of 256 bytes. We still want to retain
TLS/cache-affinity but reduce the chances of fragmentation under heavy thread
traffic.
Like in the current design, a chunk is still owned by a TraceWriter (read a
thread x a data source).
Unlike the past, a chunk is NOT forever. The chunk needs to be marked as
ACQUIRED_FOR_WRITING before writing each packet/event and released after
each write.
Acquiring/releasing are a matter of toggling a bit in the header with an
atomic compare-and-swap on the chunk header.
The ACQUIRED_FOR_WRITING state is very short lived: it starts when the
producer starts writing a packet and ends when the producer has emitted that.
There is a tiny chance that the producer can be descheduled while in the
middle of it and hence leaving the chunk acquired for long. We deem this
chance extremely rare. We still deal with it, but assume only a handful of
threads within a process can end up in this state at any given time.
There is no writer-to-writer overwriting in this ring buffer. Only the one
reader (traced) can free up chunks. Writers write chunks if there is any
space, if not they just record a data loss (more details below). No more locks
involved or IPCs. No more patch relocating.
Unlike the past, acquiring a new chunk is a very fast operation: simply bump
the atomic write pointer and either get a free chunk, or realize that the SMB
is full and declare a data loss.
Like before, in case of ring-buffer full, we can either discard immediately
after having recorded the loss, or stall. Stall will NOT require IPC anymore
and will instead involve a futex on the control page.
There is no interning whatsoever at this level. Producers just write the full
strings here. Interning happens in a later stage (compaction and bundling)
Open questions:
cases (e.g. traced_perf, traced_probes) we know we'll have only one writer.
However 256 bytes keeps the header of each chunk more compact as we can use
1 byte per each "size" field. So this will require some benchmarking, as it
might not be worth it (especially considering that now GetNewChunk() will be
much faster).
chunks, the current writer has used more than x% of them (consecutively or
not) they are eligible for bigger chunks. But again, not sure it's worth it.
Memory layout
where
N = (segment_size - sizeof(RBHeader)) / 256Ring buffer header
A 16 bytes header with ring-buffer-wide properties
next chunk. Only written by writer threads.
decision that chunks are always 256 bytes, they can be the chunk index
(i.e.
byte_offset = sizeof(RB header) + N * 256)If we are okay limiting a ring buffer to 16MB, we can use 16-bit offsets. This
can be nice as it allows compare-and-exchange operations on both offsets at
once if we ever need it (I am not sure we do right now).
writer fails to acquire a chunk because the SMB is full.
TBD this should be clared periodically (or snapshotted periodically) by the
reader on each read pass, otherwise once we have a data loss we can't tell
if we have more or not.
operations on the RB. This is to make it so that:
rather than polling as we do today.
opposite direction, when the writer sees that the buffer is becoming full
and wants to signal traced to do a read pass. We cannot use a futex there
because there is no easy way for traced to monitor a futex as part of its
poll() loop. Either that or we keep using our IPC channel as an eventfd.
TBD.
Chunk header
After the RB header, follow an array of 256B chunks. Each chunk is preceded by
an inline chunk header, and then the payload.
The chunk header is 32 bit, and it's important it stays such: this allows to
manipulate it atomically using compare-and-exchange operations on both 32-bit
and 64-bit archs (if we ever need more we could move the writer id on its own
32-bit word, but that would make the chunk header 2x bigger, so let's try to
avoid).
Each chunk header contains:
only after a new message (or fragment, when end of buffer is approached) has
been written.
acquired_for_writing: the writer writing a message. this bit is set beforethe writer writes a message, and cleared (atomically with the payload size
increase) when the message has been written.
continues_on_next_chunk(fragmentation): if this bit is set the last messagein the chunk is partial and continues on the next chunk.
continues_from_prev_chunk(fragmentation): if this bit is set the firstmessage in the chunk is partial and continues from the previous chunk. In
this case the first bytes of the chunk payload contain a varint with the
size of the fragment. This is to allow resynchronizing and skipping the
fragment if there was a data loss and the previous fragments were lost.
data_loss: this bit is set if the writer tried to write a long fragmentedmessage (or just decided to start a new chunk because there was not enough
space left in the current chunk to start a new one) but failed to acquire
the next chunk to continue. Note that it isn't always possible to write this
bit. The current chunk could have been reclaimed by the reader, and the
writer could fail to acquire a new chunk. In this case the writer we rely on
the global data losses counter in the ring-buffer header. This exist to give
a more accurate indication of where the data loss occurred.
needs_rewrite: this bit is set by the reader if it managed to read thechunk while the writer was still on it. More details in the section
Sequence of operations below.
Note that the chunk header no longer contains any chunk id, unlike the current
SMB design. This is because in the new design chunks are always written in order
and scraping is factored in the design, so out-of-order commits are no longer
possible by design.
TODO: i need to be convinced that when a chunk gets stuck because a thread
gets descheduled while the chunk is acquired, that doens't break fifo-ness.
Sequence of operations: writer
If the writer had a chunk (easy case):
If nobody else touched the chunk the WriterID will still match, and so will
the size and flags. In the same cmpxgh operation it will also set the
acquired_for_writing bit. The TraceWriterV2 needs to keep the chunk header
cached locally to do this cmpxcgh.
updates the header with a new cmpxcgh clearing the
acquired_for_writingbitand bumping the
payload_size.the
continues_on_next_chunkbit and moves on with a new chunk (below).If the writer didn't have a chunk or the chunk has been reclaimed by the reader.
It needs to acquire a new chunk, as follows:
be free (unless the buffer is full).
header. If the cmpxcgh succeeds, increment atomically write_off.
TODO: think about when do we wrap to 0. that's the hard part in ring buffers.
which meanwhile must have changed.
thread got descheduled in the middle of a write. In that case we should
skip that, and try to acquire the chunk after. if we suceed increment by N
times the write_off. A stuck chunk will be eventually invalidated.
After writing a chunk, if the rd - wr (the number of free chunks left) is less
than a critical threshold (say 50%) the writer needs to wake up the reader
(via IPC or an eventfd, or a futext, but that requires extra threads).
Sequence of operations: reader
The reader activates either when:
and the central trace buffers up to date. This is important to prevent that
some proces that traces only a little has old data that gets never commited
until the end in the SMB, causing reordering issues in trace processor.
When it activates it iterates throughout all the chunks starting from rd_off:
It does an acquire-load on the chunk header
If the chunk is free (its header is 0) skips it (it shouldn't happen though).
If the chunk header is idle (
acquired_for_writing==0):(more below)
as free. The cmpxcgh succeeds if the writer didn't write any more data or
fails, if the writer manages to write more data before the reader arrives.
message(s) and try again (or hit the case below).
If the chunk is
acquired_for_writing:already being fully written.
needs_rewritebit with a cmpxcgh, keeping the chunkstill with
acquired_for_writingset.acquired_for_writingbit, their cmpxcghwill fail. It will notice the
needs_rewritebit at that point. That willsignal that it needs to re-transact on the last message so that:
the responsibility of clearing the
acquired_for_writingwithout bumpingthe
payload_sizewould be the value of
payload_sizein the header.Overall there are 4 possible cases of overlapping between reader and writer
on the same chunk:
and no writer further changed the chunk. The reader can tell this because
acquired_for_writingwas 0 when it started, old_hdr is still current whenit does the final cmpxcgh. In this case we simply cmpxcgh(old_hdr, 0x000000)
meaning that the chunk is now free.
while the reader was reading. This means that while we were reading the chunk
transitioned RD_EXCL -> RD+WR -> RD_EXCL (
acquired_for_writing: 0 -> 1 -> 0).The reader doesn't see the state transition, but observes that the size has
changed when the cmpxcgh(old_hdr, 0x000000) fails.
In this case the reader should simply do another round and read new data and
repeat this whole logic.
time the reader gets to the epilogue. The reader should do another pass.
writer (it would stall traced), and cannot interrupt it. So we will allow the
writer to finish in its due time, but we will give it homework to do. When it
finishes, it will have to memcpy the last message to the start of a new
chunk.
Data losses auditing
Data losses are always recorded by increasing the RB header's
data_lossescounter.
Furthermore if we happen to still have the previous chunk available when a data
loss happens, the writer will further set the
data_lossbit in the chunkheader to signal precisely where the loss occurred.
Note for future: we could even say that in the latter case we could invalidate
the last message of the last chunk and record how many bytes the writer lost.
But that requires further complexity which I'm not sure it's warranted. We
could create a special TracePacket that contains only a
data_loss_bytesfieldwhich would be forged in replacement of the last message of the chunk. Let's
skip this for now and keep it as idle thought.
Chunk payload
This differs for the cases of a "TracePacket ring buffer segment" and a
"TrackEvent ring buffer segment".
In both cases
Data should be encoded in almost protobuf, that is, using SGROUP/EGROUP
rather than nested messaged to represent submessages. Strings and byte fields
are still represented using length-prefix. Those are not a problem as the writer
knows upfron how big a stirng is before even writing it. The only problem that
cause all the patching drama has always been submessages.
Unlike the current design, I don't think there is any need to prefix each
fragment with its size varint. By using groups we can tell where a message
finishes.
The only case when we need a varint with the size is in presence of
fragmentation, if the chunk header has
continues_from_prev_chunk==1. In thatcase we need to know the size if there has been a data loss and the previous
chunk is gone, to be able to parse the rest.
In order to maximize buffer usage, we can say that the SGROUP for each trace
packet is implicit. Each message starts directly with the TracePacket payload
and contains an EGROUP when the trace packet ends. The next byte will be the
first byte of the next TracePacket. We can tell when we reach the end of the
chunk by using the ChunkHeader.payload_size.
In the TrackEvent case
TODO: I need to think more about this. I'm thinking the payload of each event
to be like this:
I am also thinking of a serialization which is a hybrid where:
schema of these fields in the ABI.
SGROUP/EGROUP)
Goodbye interning, say hello bundling with transparent compression
In the new design the reader (traced) sees a bunch of small 256B chunks, each
containing packets or fragments.
In the current design, those chunks are directly copied into the trace buffer
as-is.
My proposal is that in the new design traced has an intermediate bundling
phase. Rather than copying chunks as they are, chunks are passed into a
compressor (realistically LZ4 or ZSTD) and accumulated until:
The tradeoff is:
strings over a larger window show up only once.
data gets lost in chunks.
These bundles of packets are written as a whole in the TraceBufferV2, similarly
to today's notion of
compressed_packets(I would introduce a different fieldthough as the semantic is slighly different, this is more
transparently_compressed_packets).The main downside is that when we finalize the trace, we have to uncompress
them and run the privacy filter. But that's okay, I would decompress them
anyways to then allow the final trace to be compressed by play services before
uploading (or before passing to it), as that would achieve even better
compression (because of whole file compression vs chunk-based compression).
Essentially the chunk-based compression I'm proposing is only to maximize the
in-memory ring buffer usage, without having to ask people to intern manually
thing. The new ethos would be "just dump strings, they will be compressed by
traced before hitting the ring buffer".
My theory is that will outperform our hand rolled interning today, and remove
a whole bunch of complexity from perfetto.
Also the other downside is that for protovm we'll have to uncompress chunks
before passing it to protovm. But realistically we can have a
bool nocompresssetting for a buffer and use that whenever we use protovm.
Open question: how do we deal with incremental state used for track types? That
is not just interning. I'm talking about this:
Incremental plan
We need to break this into incremental milestones. My proposal is to
buffers, and skip all the segments and the ABI. For now we can have this as
a private implementation detail of the SDK, which then writes TracePackets
using the old SDK. This will allow us to validate the compression
effectiveness, however we cannot use it in the field as-is, at least not
until we introduced some code in traced that does the "if compressed,
decompress before running the privacy filter"
needed.
different track event categories onto different buffers.
different encoding for the TrackEvent ring buffer is to make it more JNI
friendly, but the JNI side needs to evolve in parallel, or the two will never
fit together.
bridging if we want to support talking to an older version of traced.
could be much smaller. If we can ditch the IPC socket (can we? maybe yes if
we care only about track event) the SDK can be made MUCH smaller.
Open questions
VMs: we need to support VMs and this shmem-based protocol goes in the opposite
direction of vsocks, as it goes further beyond the IPC. We need to figure out
what will be the story for commit-over-IPC to support remote machines and VMs.
shmem across vms is incredibly hard, especially with pKVM as it needs to be
negotiated by the bootloader. I have not wrapped my head around this (maybe
VMs will keep using the V1 protocol, and we can make sure they can cohexist
and nest nicely together. So traced_relay does the compaction and passes
bundles of TracePackets to the hypervisor via vsock)
Where do we draw the line between track event and data source? It feels more
and more "if you take track event, and add arbitrary protos, it can subsume
the data source layer". The line for sure is "if you need custom config (other
than the category) that is NOT track event. TrackEvent by design must be
idempotent (at the category level), as it's biggest value is in the tee-ing
and mux/demux.
💬 Discussion Guidelines:
All reactions