Repository navigation
RFC-0050: TBv3 = TBv2 + a buffer of compressed bundles #7838
Replies: 2 comments
|
📝 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: 0050-tracebuffer-v3.md
TBv3 = TBv2 + a buffer of compressed bundles
Authors: @primiano @rsavitski @sashwinbalaji
Status: Draft
This RFC describes the layout and flow of TraceBuffer V3 (TBv3).
The flow starts just like today, the tracing service copies producer chunks
(SMB v1 and SMB v2) into the trace buffer. But now that buffer is a staging
area. Periodically we read its chunks as complete trace packets, bundle and
compress them into a second buffer, which is a plain ring.
Requirements
should return the same way as it does today.
Overview
--- config: look: handDrawn theme: forest --- flowchart TD V1["SMB v1 commits, scraping, patches"] V2["SMB v2 ring drains"] Staging["staging buffer, TBv2<br>chunks -> complete packets"] Bundle["validate, stamp loss, compress into bundles"] Ring["bundled buffer, byte ring<br>oldest [B1][B2][B3] newest"] Read["ReadNextTracePacket()<br>decode oldest bundle"] Out["trusted fields -> filter -> output compression"] V1 --> Staging V2 --> Staging Staging -- "EndWrite(): threshold reached" --> Bundle Bundle --> Ring Ring --> Read Read --> Out Read -- "ring empty, bundle one more from staging" --> BundleSo TBv3 has two buffers.
exists. Once a packet leaves staging, it is in normal protobuf form.
So for each sequence, every packet in the bundled buffer is older than every
packet of that sequence in staging. And once we make a bundle, it doesn't
change and nobody outside the buffer holds a pointer into it.
So it would look roughly like this.
How chunks reach the staging buffer
No change in this flow. The tracing service calls these functions in write batches of consecutive
calls that copy chunks (and apply patches) in one go.
v2 uses DrainV2RingBuffer() -> ServiceRingBufferDrainer::Drain() ->
CopyChunkV2Untrusted().
v1: CommitData -> CopyProducerPageIntoLogBuffer() ->
CopyChunkUntrusted().
v1: ScrapeSharedMemoryBuffers() -> ForEachScrapableChunk() ->
CopyProducerPageIntoLogBuffer() -> CopyChunkUntrusted().
The scraping case. It walks the SMB page by page, so the chunks of one
writer can come out of ChunkID order.
The service makes these calls through the TraceBuffer interface, so it does
not care whether the buffer is a TBv2 or a TBv3.
When do we bundle packets from staging
A bundling pass reads complete packets from staging, compresses them into one
or more bundles, stores them in the bundled buffer, and compacts staging.
We set a threshold, bundle_target_bytes, for how much new chunk data can
arrive in staging before we run another bundling pass.
Now the question is where exactly we check this threshold and start bundling.
Doing it through a separate periodic PostTask seems like unnecessary
complexity. Doing it only at read time is too late because at that point we
might as well bypass bundling and write to the file. We want bundling to make
room while data is still coming in, so we can write more. So the write path
seems like the best place, and the bundling pass should be much like a
periodic readback.
The obvious place would be inside CopyChunkxxUntrusted. Count the chunk bytes
copied and run a bundling pass once we cross bundle_target_bytes.
But with SMB v1 chunks that won't work for two reasons.
Scraping means chunks can be out of order.
find them. That linear order is the chunk allocation order, which is
unpredictable, so chunks are effectively committed in random order.
any read.
copies of the same scrape, a chunk that comes later in page order but
earlier in ChunkID order arrives after the bundling pass has already read
past its position. TBv2 then discards it as older than the last chunk
read.
Patches are copied after the chunks (minor).
copy the chunks first, then ApplyChunkPatches() applies their patches
through TryPatchChunkContents().
patched.
waiting for a patch that is a few lines further down in the same message.
It leaves it, and everything the writer wrote after it, in staging.
The proposal is to introduce EndWrite().
the service has finished using the read slices. EndWrite() tells it that
a write batch is done.
producer and after draining a v2 ring. TBv1 and TBv2 do nothing in it.
even if nothing was readable, so waiting for a patch does not trigger a
pass on every EndWrite().
We also need to bundle during a read once the bundled buffer is empty but
staging still has readable packets. That ignores the threshold and builds one
bundle at a time. We get to that flow below.
How do we bundle packets from staging
We start by reading staging with the same BeginRead() and
ReadNextTracePacket() as today, then compress the packets we get back.
ReadNextTracePacket() in a loop. We get the same behavior as the TBv2 reader.
Packets from other sequences can still be bundled.
tail waits for the later commit.
previous_packet_on_sequence_dropped reason bits.
previous_packet_dropped already in the packet and append that field.
Keep its identity in TBv3's sequence map and add its ProducerAndWriterID
to this bundle's sequence list the first time it appears. Feed the packet
into zstd, with a small header saying whose packet it is and its size.
sequence list. Make room in the bundled buffer, then copy the whole thing
into it.
staging with TBv2's shift-left compaction. This removes the consumed chunks'
padding and keeps the chunks we could not bundle. Use CompactIfWritable()
so DISCARD can also reuse this space while staging still accepts writes.
To start with, I would use zstd level 3. RFC-0038 measured zstd-3 as roughly
break-even with today's trace size at a fraction of the CPU
(ref: #6397).
When do we finish one bundle
For now, we use bundle_target_bytes for both, incoming chunk bytes to start
bundling and uncompressed trace packet bytes plus their small headers to
finish a bundle. We can split this into two variables later.
bundle_target_bytes.
We only know a packet's size after reading it from staging. Adding it before
checking the total saves us keeping a separate copy for the next bundle.
all its packets before building the next.
A record is often two varints and a small packet, and calling zstd costs more
than copying a few bytes. So gather small pieces in a 4 KiB buffer and pass
them to zstd together.
Add loss to TracePacket before bundling
When a packet leaves staging, add its loss bits to previous_packet_dropped,
combining them with any bits already in the packet. That saves us carrying
the loss separately through the bundle.
Today ReadBuffers() calls PacketStreamValidator::Validate() when returning
packets to the consumer. For TBv3, do that here before adding the loss field,
because adding a field could make a malformed packet pass validation. Drop
invalid packets and count each once in the existing stats.
If a dropped packet carried a loss flag from staging, put the flag back so
the next valid packet of that sequence reports it.
Have Validate() return the packet's own loss bits too. TBv3 returns true
from validates_packets(), so ReadBuffers() skips the second validation.
The service still adds uid, pid, machine ID and trusted sequence ID when it
returns the packet to the consumer.
What a bundle looks like
Each bundle we store in BundledBuffer has three parts.
Header
the read position.
check that zstd produced that many bytes.
bytes start.
Sequence list and identity
We put each sequence in the list once using ProducerAndWriterID.
The uid, pid and machine ID live in TBv3's map, in a ClientIdentity for each
sequence. We get that identity from TBv2's PacketSequenceProperties when
bundling and copy it into the map.
Inside the compressed bytes
Each trace packet has two varints before it.
sequence_indexpoints into the sequence list above.packet_sizetells us where this packet ends and the next record starts.Open question: Todo for later, would grouping the sequence and sizes
before the packet bytes compress better?
Why we keep the header and sequence list uncompressed
We need the header and sequence list when overwriting. We need to know which
sequences lost packets, and we don't want to decode the whole bundle just to
find them. So we keep the header and sequence list outside zstd.
How the bundled buffer stores these bundles
The bundled buffer is one flat piece of memory of size_kb * 1024 bytes.
wr_ and rd_ count bytes in the logical stream, and wr_ - rd_ is
the amount in use. The byte at position p lives at
p % capacity.These rules hold at all times.
rd_ <= wr_andwr_ - rd_ <= capacity.padding between them.
Both counters are uint64_t and count bytes, so they should never wrap in
practice.
Hopefully no tracing session lives that long. So we can treat these counters
as only growing and leave out wrap handling.
Reset both to zero when empty, so frequent drains reuse the first pages.
Appending, including when the ring wraps
Say we have a 2 MiB ring in RING_BUFFER mode, with 1.9 MiB used. We want to
append a 0.2 MiB bundle.
bundle_size, say 0.3 MiB. Repeat if we still need more room.
remaining 0.1 MiB at offset zero.
Letting a bundle wrap avoids padding at the end. The copy takes at most two
pieces. Copy a wrapped frame into contiguous scratch before decoding it.
Overwriting only needs the header and sequence list, unless ProtoVM needs
the packets too. Then we decode them.
How packets go back to the consumer
ReadBuffers() calls BeginRead(), then ReadNextTracePacket() in a loop.
The service can call MaybeCompact() afterwards if compaction is enabled.
Read bundles from oldest to newest, then bundle and read staging. For each
sequence, staging holds the newer packets.
Reading one bundle
uncompressed_size bytes of the decode arena.
by sequence_index. Set read_offset to zero.
the packet's loss bits with any unreported loss for that sequence.
Return slices into the decoded bytes and advance read_offset.
Split large packets into slices to fit the IPC sender's size limit. After
the last packet, advance rd_ by bundle_size and release the bundle's
sequence references. Keep the decoded bytes until the batch is delivered.
Check the decoded size, sequence IDs and record bounds. If these fail, drop
the bundle, increment bundles_corrupted and report DATA_LOSS_CHUNK_CORRUPTED
on its sequences.
Keeping decoded bytes across batches
ReadBuffersIntoConsumer() collects about 32 KiB per task.
ReadBuffersIntoFile() uses batches of about 1 MiB. The last packet can exceed
either size, so these do not cap decode memory.
A batch can stop partway through a bundle or span several bundles. TBv2
resumes from payload_avail. TBv3 keeps the decoded bytes, read_offset and
sequence properties across BeginRead().
I would use two reusable blocks, each holding several decoded bundles.
Allocate each on first use and decode into unused space at its end.
Do not grow, move or overwrite a block while slices point into it. If neither
block has room, use a temporary allocation. Keep compression scratch separate.
The service must finish using the batch's slices before the next read batch,
write or compaction. Put that in the buffer interface.
temporary allocations. Keep the block or allocation holding unread packets.
Open question: how big should decode_block_bytes be? I would start at
2.25 x bundle_target_bytes, enough for about two bundles near the target
size. A large packet or a batch spanning many bundles can still need
temporary space.
When RING_BUFFER overwrites a partly read bundle
Say EndWrite() needs space while the oldest bundle is partly read. In the
example above, packets 41 to 300 are still in the decode arena. We cannot keep
them readable and drop newer bundles to make room. RING_BUFFER should keep
the newest data.
with unread packets. If ProtoVM needs the bundle, give it only those packets.
previous batch has been delivered, so we can reuse the memory.
fits. For a wholly unread bundle, its sequence list is enough to mark loss.
Open question: after making space, should we decode the oldest remaining
bundle into the freed scratch space, or wait for the next read? (maybe not)
Reading what's left in staging
Staging may hold packets below bundle_target_bytes, or packets made readable
by a patch or another fragment.
Once rd_ == wr_, read any pending_bundle in DISCARD first.
ReadNextTracePacket() then uses EndWrite()'s builder even below the target.
Build and read one bundle at a time. Once staging has no more readable
packets, compact it in RING_BUFFER mode.
This keeps readback from overwriting packets it has not returned yet. It
does mean compressing staging packets only to decode them immediately.
Frequent reads also make smaller bundles, which can compress less well.
Data loss
Loss still goes in previous_packet_dropped on the next packet of that
sequence.
A chunk is lost in staging
The bundler combines TBv2's SequenceState::data_loss_reasons with any
previous_packet_dropped bits already in the packet, then appends the combined
value before compression.
A bundle is overwritten
TBv3 keeps unreported_loss_reasons separate from staging loss because the next
packet of that sequence may already be bundled.
On overwrite, walk the bundle's sequence list and OR DATA_LOSS_PRESENT and
DATA_LOSS_OVERWRITE into each sequence's unreported_loss_reasons. For a partly
read bundle, only mark sequences with unread packets.
When the consumer reads the next packet of that sequence, combine these bits
with its existing previous_packet_dropped and clear unreported_loss_reasons.
The decoded bundle caches a pointer to each sequence, so this needs no extra
map lookup.
If the last bundle holding a sequence goes away while loss is still pending,
the next packet of that sequence can only come from staging. Move the bits
into TBv2's data_loss_reasons then, so the bundler puts them on that packet.
Update sequence cleanup to account for both buffers
Today DeleteStaleEmptySequences() treats a sequence as empty when
chunks.empty(). Bundling can empty that list while packets are still in the
bundled buffer, so the check needs to include both buffers.
Keep a bundle_refs count per sequence in TBv3. TBv3 tells TBv2 about it
with two calls, RetainSequence() and ReleaseSequence().
pending_bundle in DISCARD. Call RetainSequence() for the first one.
count reaches zero, erase the sequence from TBv3's map and call
ReleaseSequence().
and it is not retained.
Use this rule to keep both maps in step. TBv2's SequenceState keeps the
consumed ChunkID history needed to detect gaps and any staging loss still
waiting to be reported. TBv3's map keeps the identity needed to return bundled
packets and unreported_loss_reasons.
When a bundle is larger than the whole bundled buffer
A bundle can exceed the whole bundled buffer if a large packet compresses
poorly. By then, ReadNextTracePacket() has already consumed its packets
from staging.
I would drop it in both modes and increment bundles_too_large. Decrement
bundle_refs for each sequence it contains.
We need the next staging read to put the loss on P4. Add a small TBv2
helper, AddSequenceDataLoss(), to OR DATA_LOSS_PRESENT into
data_loss_reasons for each affected sequence. Also OR in any loss bits its
dropped packets already carried, otherwise dropping the bundle would erase
those earlier loss reports too.
unreported_loss_reasons goes on the next packet returned to the consumer.
That could still be P1, which came before the packets we dropped.
Open question: are we okay with dropping a bundle larger than the whole
buffer in both modes?
How the fill policies work with two buffers
RING_BUFFER
In the bundled buffer, overwrite the oldest whole bundles until the new one
fits.
In staging, bundling and compaction normally make room. But staging can
still fill in two ways.
patches or missing fragments can build up and fill staging.
can copy several chunks before EndWrite() runs. We cannot bundle halfway
through a scrape because its chunks can arrive out of order.
In either case, incoming chunks can overwrite packets before we bundle them.
Keep TBv2's DeleteNextChunksFor() and report the loss.
Open question: are we okay with keeping older packets in bundles while
losing newer ones in staging, even when the bundled buffer has free space?
DISCARD
Here we want to keep the early data in both buffers.
While writes are still allowed, keep compacting staging after bundling so we
can reuse that space.
The bundled buffer fills first. When the next bundle fits the whole
buffer but not the remaining space, stop bundling in EndWrite(). Keep
accepting chunks into staging until it fills, then stop taking new chunks.
Open question: TBv2 has already consumed that bundle's packets. Could
we keep it in pending_bundle and read it after the stored bundles, before
staging? I would do that. It needs an extra variable and a branch when
the stored bundles have drained.
Staging fills first. Stop taking new chunks and stop bundling in
EndWrite(), even if the bundled buffer still has room. Keep everything
already in both buffers.
On readback, read the stored bundles first, then any pending bundle and the
packets left in staging.
Bundling in EndWrite() stays stopped after the pending bundle has been read.
A read never compacts a DISCARD staging buffer.
Open question: before either buffer fills, should reads free bundled
space for later writes?
When ProtoVM sees packets
With TBv3, we can overwrite packets in two places, staging and the bundled
buffer. We could call MaybeProcessOverwrittenPacket() from both,
but that can pass packets to ProtoVM out of order.
Say a writer emits P1, then P2 on the same sequence. We have bundled P1,
but P2 is still in staging.
Open question: should we call ProtoVM only when overwriting bundles,
and report staging overwrites as ordinary packet loss? I would do that to
keep each sequence in order.
If the consumer already read part of the bundle, ProtoVM gets only the
unread packets. A bundle that nobody read is decoded only if one of its
producers has a VM.
The VMs belong to TBv3, not to its staging buffer. For that, move the ProtoVM
handling out of TBv2 into a small class that either buffer can own.
What CloneReadOnly needs to copy
wr_, the stats and TBv2's sequences_.
does for its own clone. Copy
[rd_, wr_)in logical order, withtwo memcpys if the ring wrapped. Rebase the clone to
rd_ = 0,wr_ = copied_size.reference counts.
the source is between read tasks, the consumer already has the packets
before that offset.
On its first read, the clone decodes its oldest bundle, looks up the sequence IDs
and starts at the copied read_offset.
Once we read the copied bundles, use the same buffer for the packets left in
staging, one bundle at a time. The clone has the same capacity as its source,
so a bundle that fits there also fits here.
Future optimization: the clone reserves the full size even if it copies
only a few bytes. That costs address space, not memory, since we only pay for
the pages we write to. Later we can reserve just the copied bytes and grow
when a bundle from staging needs more.
Stats
I would add these fields to BufferStats.
bundles_writtenbundle_bytes_uncompressedbundle_bytes_compressedbundles_overwrittenbundles_too_largebundles_corruptedbundling_max_usstaging_chunks_read and
staging_readaheads_*.their uncompressed record sizes.
readaheads_*stay 0, because decoding a bundle does notvisit the original chunks.
A commit after its chunk was bundled
SMB v1 scraping can copy complete chunks before their CommitData arrives.
If EndWrite() bundles them in between, the staging reader consumes and
erases those copies. The later commits then hit the consumed-chunk check
in CopyChunkUntrusted().
P and Q are still in the bundled buffer, but chunks_discarded counts their
late commits as lost data. Without that bundling pass, CopyChunkUntrusted()
finds the existing copies and ignores the identical commits without
changing the counter.
TBv2 can already hit this when a consumer reads between the scrape and
commit. Bundling lets it happen even when the consumer hasn't read. This
path also hits a DCHECK in debug builds, so we need to handle that too.
What TBv3 needs from TBv2
Along with TBv2's normal write and read methods, we need these additions.
buffer while it still accepts writes.
hold its packets.
then dropped.
The TraceBuffer interface gets EndWrite(), validates_packets() and
invalid_packets(). It also gets the SMB v2 calls and the ProtoVM access that
the service reaches today by casting to TraceBufferV2.
💬 Discussion Guidelines:
All reactions