TraceBuffer V2 设计文档

概述

本文档涵盖了 TraceBufferV2 的设计,这是 ProtoVM 之际对核心 trace buffer 代码的 2025 年重写。

TraceBuffer 是 tracing service 用于在内存中保存 traced 数据的非共享用户空间 buffer,直到它被读回或写入文件。对于 trace config 的每个 buffers 部分,都有一个 TraceBuffer 实例

基本操作原理

NOTE: 本部分假设你熟悉 Buffers and dataflow 中介绍的核心概念。

TraceBuffer 是一个_加强版 ring buffer_。不幸的是,由于协议的复杂性(请参阅[挑战](#key-challenges)部分),在读回和删除方面,它与普通的面向 byte 的 FIFO ring buffer 相去甚远。

在深入研究其复杂性之前,让我们探索其关键操作。

从逻辑上讲,TraceBuffer 处理重叠的数据流,称为 TraceWriter Sequences ,或简称为 Sequences

从 TraceBuffer 的角度来看,唯一可见的抽象是 Producer (由 uint16_t ProducerID 标识)和 TraceWriter(由 uint16_t WriterID 标识,在 producer 的范围内)。32 位元组 {ProducerId, WriterID} 构成了 TraceBuffer 的唯一 Sequence ID。 TraceBuffer 中的一切都由该 key 标识。

基本操作:

读回提供以下保证:

读回发生在以下情况:

代码层面有四个主要入口点:

Writer 端(Producer 端):

Reader 端:

关键挑战

RING_BUFFER vs DISCARD

TraceBuffer 可以在两种模式下运行。

RING_BUFFER

这是大多数 traces 使用的模式。它也是最复杂的模式。除非另有说明,本文档重点介绍 RING_BUFFER 模式的操作。 此模式可用于纯 ring buffer tracing,也可以与 write_into_file 结合使用以进行 long traces 流式传输到磁盘,在这种情况下,ring buffer 主要用于解耦 SMB 和 I/O 活动(并处理 fragment 重新组装)。

DISCARD

此模式用于用户关心 trace 最左侧部分的一次性 traces。这在概念上更简单:一旦到达 buffer 的末尾,TraceBuffer 就停止接受数据。

与 V1 实现相比,行为有轻微变化。V1 尝试对 DISCARD 过于(聪明),允许在写入和读取游标从未交叉的情况下继续向 buffer 写入数据(即,只要 reader 跟上)。 事实证明,这是无用且令人困惑的:将 DISCARDwrite_into_file 结合使用会导致 DISCARD 的行为几乎像 RING_BUFFER 的场景。但是,如果 reader 跟不上(例如,由于缺乏 CPU 带宽),TraceBuffer 将停止接受数据(永远)。 我们后来意识到这是一个令人困惑的功能(一个突然停止的 ring buffer),并在尝试组合两者时添加了警告。

V2 不会尝试对读回过于聪明,一旦到达 buffer 的末尾就简单地停止,无论它是否已被读取。

碎片化

Packet fragmentation 是 TraceBuffer 大部分设计复杂性的原因。

简单的 Fragmentation 示例: Chunk A (ChunkID=100) Chunk B (ChunkID=101) Chunk C (ChunkID=102) ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │[Packet1: Complete] │ │[Packet2: Begin] │ │[Packet2: Continue] │ │[Packet2: Begin] │ │ flags: kContOnNext │ │ flags: kContFromPrev│ │ flags: kContOnNext │ └─────────────────────┘ │[Packet2: End] │ └─────────────────────┘ │[Packet3: Complete] │ └─────────────────────┘ Fragmentation 链:Packet2 = [Begin] → [Continue] → [End]

关键的 Fragmentation 挑战

乱序提交

乱序提交很少见但经常存在。它们是由于 Perfetto 早期引入的称为 SMB Scraping 的功能而发生的。

SMB scraping 发生在 TracingServiceImpl 在 Flush 时强制读取 SMB 中的 chunks 时,即使它们未被标记为已完成,也会将它们写入 trace buffer。

这是必要的,用于处理像 TrackEvent 这样的 data sources,这些 sources 可以在没有 TaskRunner 的任意线程上使用,在那里不可能在 trace 协议 Flush 请求时发出 PostTask(FlushTask)。

挑战在于,TracingServiceImpl 在 scraping 时,按线性顺序扫描 SMB 并按找到的方式提交 chunks。但该线性顺序转换为"chunk 分配顺序",这是不可预测的,有效地导致 chunks 以随机顺序提交。

实际上,这些实例相对较少,因为它们发生在:

重要说明:TraceBuffer 假设所有乱序提交都是批量原子性提交的。OOO 的唯一已知用例是 SMB scraping,它在单个 TaskRunner 任务中一次性提交所有 scraped chunks。

因此,我们假设以下情况不可能发生:

TraceBufferV2 的逻辑将通过在按 ChunkID 排序 chunks 后识别的任何 ChunkID 间隙视为数据丢失。

数据丢失的跟踪

有几种路径可能导致数据丢失,TraceBuffer 必须跟踪和报告所有这些路径。调试数据丢失是一项非常常见的活动。TraceBuffer 发出任何数据丢失情况的信号非常重要。

有几种不同类型和原因的数据丢失:

补丁

当 packet 跨越多个 fragments 时,几乎总是涉及 patching,这是由于 protobuf 编码的性质。问题如下:

从协议的角度来看,只有 chunk 的最后一个 fragment 可以被 patched:

关于"needs patching"的信息保存在 SMB 的 Chunk flags (kChunkNeedsPatching) 中。

kChunkNeedsPatching 状态由 CommitData IPC 清除,它包含 patches offset 和 payload,以及 bool has_more_patches flag。当为 false 时,它会导致 kChunkNeedsPatching 状态被清除。

从 TraceBuffer 的角度来看,patching 有以下影响:

重新提交

Recommit 意味着再次提交 buffer 中已存在的具有相同 ChunkID 的 chunk。 Recommit 的唯一合法情况是 SMB scraping 后跟实际提交。我们不期望也不支持 Producers 尝试重新提交相同的 chunk N 次,因为这不可避免地会导致未定义的行为(如果 tracing service 已经将 packets 写入文件怎么办?)。

这是 recommit 可能合法发生的情况:

NOTE: kChunkNeedsPatching 和 kIncomplete 是两个不同且正交的 chunk 状态。kIncomplete 与 fragments 无关,纯粹是关于 SMB scraping(以及我们必须保守并忽略最后一个 fragment 的事实)。

影响:

不完整 chunks 的主要复杂性在于我们无法预先知道它们的 payload 大小。因此,我们必须保守地复制并保留 buffer 中的整个 chunk 大小。

Buffer 克隆

Buffer 克隆通过 CloneReadOnly() 方法发生。顾名思义,它创建一个新的 TraceBuffer 实例,其中包含相同的内容,但只能读入。这是为了支持 CLONE_SNAPSHOT triggers。

架构上,buffer 克隆并不特别复杂,至少在当前的 design 中是这样。主要的设计影响是:

ProtoVM

ProtoVM 是 TracingService 即将推出的功能。它是一种非图灵完备的 VM 语言,用于描述 proto 合并操作,以便在我们覆盖 trace buffer 中的数据时跟踪任意 proto 编码数据结构的状态。ProtoVM 是触发 TraceBuffer V2 重新设计的原因。

不深入其细节,ProtoVM 的主要要求是:在覆盖 trace buffer 中的 chunks 时,我们必须将这些即将被删除的 chunks 中的有效 packets 传递给 ProtoVM。我们必须按顺序这样做,因此复制我们在进行读回时使用的相同逻辑。

关于 ProtoVM 的内部文档:

覆盖

由于上述 ProtoVM 的原因,在 V2 设计中,处理 ring buffer 覆盖 (DeleteNextChunksFor()) 的逻辑几乎相同 - 并且与读回逻辑共享大部分代码。

我说几乎是因为有一个微妙的区别:删除时,停滞(由于待处理的 patches 或不完整)NOT 是一个选项。旧的 chunks 必须消失,为新的 chunks 腾出空间,无论如何。

因此,覆盖相当于无停滞的强制删除读回。

核心设计

涉及两个主要数据结构:

TBChunk

是存储在 trace buffer 内存中的结构,作为从 SMB chunk 调用 CopyChunkUntrusted 的结果。

TBChunk 与 SMB chunk 非常相似,但有以下注意事项:

简而言之,TBChunk 是:

SequenceState

它维护 {ProducerID, WriterID} 序列的状态。

它的重要特性是按 ChunkID 顺序维护 TBChunk(s) 的有序列表(逻辑上) "list" 实际上是一个 offsets 的 CircularQueue,具有 O(1) push_back()pop_front() 操作。

SequenceState 的生命周期有一个微妙的权衡:

TraceBufferV2 使用惰性清理方法平衡这一点:它允许最近删除的 SequenceStates 保持活动状态,最多 kKeepLastEmptySeq = 1024。请参阅 DeleteStaleEmptySequences()

FragIterator

一个简单的类,用于对 chunk 中的 fragments 进行 tokenize 并允许仅向前迭代。

它处理不受信任的数据,检测格式错误/越界场景。

它不会改变 buffer 的状态。

ChunkSeqIterator

一个简单的实用程序类,用于迭代给定 SequenceState 的 TBChunk 有序列表。它只是遵循 SequenceState.chunks 队列并检测间隙。

ChunkSeqReader

封装了大部分读回复杂性。它按序列顺序读取和消耗 chunks,如下所示:

Buffer 顺序 vs Sequence 顺序

Chunks 可以通过两种不同的方式访问:

  1. Buffer order:按照它们在 buffer 中写入的顺序。 在下面的示例中:A1、B1、B3、A2、B2

  2. Sequence order:按照它们在 SequenceState 的列表中出现的顺序。

写入 chunks

当通过 CopyChunkUntrusted() 写入 chunks 时,使用你期望的 ring-buffer 的通常 bump-pointer 模式在 buffer 的 PagedMemory 中分配新的 TBChunk。Chunks 是可变大小的,并以 32 位对齐方式连续存储。

chunk 的 offset 也追加到 SequenceState.chunks 列表中。

在第一次环绕之后,写入 chunk 涉及删除一个或多个现有的 chunks。删除操作 RemoveNextChunksFor() 与读回一样复杂,因为它会重建被删除的 packets,以将它们传递给 ProtoVM。

因此,写入本身是简单的,但现有 chunks 的删除(覆盖)是大部分复杂性所在。这在下一节中描述。

DeleteNextChunksFor() 流程

flowchart TD A[DeleteNextChunksFor
bytes_to_clear] --> B[Initialize: off = wr_
clear_end = wr_ + bytes_to_clear] B --> C{off < clear_end?} C -->|No| M[Create padding chunks
for partial deletions] C -->|Yes| D{off >= used_size_?} D -->|Yes| N[Break - nothing to delete
in unused space] D -->|No| E[chunk = GetTBChunkAt off] E --> F{chunk.is_padding?} F -->|Yes| G[Update padding stats
off += chunk.outer_size] F -->|No| H[Create ChunkSeqReader
in kEraseMode] H --> I[ReadNextPacketInSeqOrder loop] I --> J{Packet found?} J -->|Yes| K[Pass packet to ProtoVM
has_cleared_fragments = true] J -->|No| L{has_cleared_fragments?} K --> I L -->|Yes| O[Mark sequence data_loss = true] L -->|No| P[No data loss] O --> Q[Update overwrite stats
off += chunk.outer_size] P --> Q Q --> R{More chunks in range?} R -->|Yes| C R -->|No| M G --> C M --> S[Scan remaining range for padding] S --> T{Partial chunk at end?} T -->|Yes| U[Create new padding chunk
for remaining space] T -->|No| V[End] U --> V N --> V style A fill:#e1f5fe style K fill:#fff3e0 style O fill:#ffcdd2 style V fill:#c8e6c9

与 ReadNextTracePacket 的关键区别

读回 packets

读回 (ReadNextTracePacket()) 是 TraceBuffer 大部分复杂性所在的地方,因为它需要从 fragments 重新组装 packets,处理间隙/数据丢失,并处理来自不同序列的 chunks 的交错和乱序。

flowchart TD A[ReadNextTracePacket Start] --> B{chunk_seq_reader_ exists?} B -->|No| C[Get chunk at rd_] C --> D{Is chunk padding?} D -->|Yes| E[rd_ += chunk.outer_size
Check wrap around] D -->|No| F[Create ChunkSeqReader
for this chunk] B -->|Yes| G[ReadNextPacketInSeqOrder] F --> G G --> H{Packet found?} H -->|Yes| I[Set sequence properties
Set data_loss flag
Return packet] H -->|No| J[Get end chunk from reader
rd_ = end_offset + size] J --> K{rd_ == wr_ OR
wrapped to wr_?} K -->|Yes| L[Return false - no more data] K -->|No| M[Reset chunk_seq_reader_
Handle wrap around] E --> N{rd_ wrapped around?} N -->|Yes| O[rd_ = 0] N -->|No| P[Continue with new rd_] O --> K P --> K M --> B style A fill:#e1f5fe style I fill:#c8e6c9 style L fill:#ffcdd2

ChunkSeqReader 内部流程

这就是 ReadNextTracePacket() 的工作原理:

flowchart TD A[ReadNextPacketInSeqOrder] --> B{skip_in_generation?} B -->|Yes| C[Return false - stalled] B -->|No| D[NextFragmentInChunk] D --> E{Fragment found?} E -->|Yes| F{Fragment type?} F -->|kFragWholePacket| G[ConsumeFragment
Return packet] F -->|kFragBegin| H[ReassembleFragmentedPacket] F -->|kFragEnd/Continue| I[Data loss - unexpected
ConsumeFragment
Continue loop] E -->|No| J{Chunk corrupted?} J -->|Yes| K[Mark data_loss = true] J -->|No| L{Chunk incomplete?} L -->|Yes| M[Set skip_in_generation
Return false] L -->|No| N[EraseCurrentChunk] K --> N N --> O{Reached end chunk?} O -->|Yes| P[Return false] O -->|No| Q[NextChunkInSequence] Q --> R{Next chunk exists?} R -->|No| P R -->|Yes| S[iter_ = next_chunk
Create new FragIterator] S --> D H --> T{Reassembly result?} T -->|Success| U[Return reassembled packet] T -->|NotEnoughData| V[Set skip_in_generation
Return false] T -->|DataLoss| W[Mark data_loss = true
Continue loop] style A fill:#e1f5fe style G fill:#c8e6c9 style U fill:#c8e6c9 style C fill:#ffcdd2 style P fill:#ffcdd2 style V fill:#fff3e0

处理乱序 chunks

但事情更复杂。让我们首先只考虑乱序。参考上面的图,假设 write cursor 在 offset=48,就在 B3 之前。

如果我们简单地按 buffer 顺序进行,我们将打破 FIFO-ness,因为我们将首先发出 B3 中包含的 packets,然后是 A2(这很好),最后是 B2(这有问题)。

唯一保留序列内 FIFO-ness 的有效线性化是 [A2,B2,B3]、[B2,B3,A2] 或 [B2,A2,B3]。

为了处理这个问题,我们在读回代码中引入了两层 walk:

在代码中,外层 walk 由 TraceBufferV2::ReadNextTracePacket() 实现,而内层 walk 由 class ChunkSeqReader::ReadNextPacket() 实现。

基准测试

Apple Macbook (M4)

BM_TraceBuffer_WR_SingleWriter<TraceBufferV1> bytes_per_second=9.77742G/s BM_TraceBuffer_WR_SingleWriter<TraceBufferV2> bytes_per_second=12.6395G/s BM_TraceBuffer_WR_MultipleWriters<TraceBufferV1> bytes_per_second=8.65385G/s BM_TraceBuffer_WR_MultipleWriters<TraceBufferV2> bytes_per_second=11.7582G/s BM_TraceBuffer_RD_MixedPackets<TraceBufferV1> bytes_per_second=4.27694G/s BM_TraceBuffer_RD_MixedPackets<TraceBufferV2> bytes_per_second=4.35475G/s

Google Pixel 7

BM_TraceBuffer_WR_SingleWriter<TraceBufferV1> bytes_per_second=4.4379G/s BM_TraceBuffer_WR_SingleWriter<TraceBufferV2> bytes_per_second=3.7931G/s BM_TraceBuffer_WR_MultipleWriters<TraceBufferV1> bytes_per_second=3.19148G/s BM_TraceBuffer_WR_MultipleWriters<TraceBufferV2> bytes_per_second=3.47354G/s BM_TraceBuffer_RD_MixedPackets<TraceBufferV1> bytes_per_second=1.26698G/s BM_TraceBuffer_RD_MixedPackets<TraceBufferV2> bytes_per_second=1.35394G/s ``