• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# ProtoZero design document
2
3ProtoZero is a zero-copy zero-alloc zero-syscall protobuf serialization libary
4purposefully built for Perfetto's tracing use cases.
5
6## Motivations
7
8ProtoZero has been designed and optimized for proto serialization, which is used
9by all Perfetto tracing paths.
10Deserialization was introduced only at a later stage of the project and is
11mainly used by offline tools
12(e.g., [TraceProcessor](/docs/analysis/trace-processor.md).
13The _zero-copy zero-alloc zero-syscall_ statement applies only to the
14serialization code.
15
16Perfetto makes extensive use of protobuf in tracing fast-paths. Every trace
17event in Perfetto is a proto
18(see [TracePacket](/docs/reference/trace-packet-proto.autogen) reference). This
19allows events to be strongly typed and makes it easier for the team to maintain
20backwards compatibility using a language that is understood across the board.
21
22Tracing fast-paths need to have very little overhead, because instrumentation
23points are sprinkled all over the codebase of projects like Android
24and Chrome and are performance-critical.
25
26Overhead here is not just defined as CPU time (or instructions retired) it
27takes to execute the instrumentation point. A big source of overhead in a
28tracing system is represented by the working set of the instrumentation points,
29specifically extra I-cache and D-cache misses which would slow down the
30non-tracing code _after_ the tracing instrumentation point.
31
32The major design departures of ProtoZero from canonical C++ protobuf libraries
33like [libprotobuf](https://github.com/google/protobuf) are:
34
35* Treating serialization and deserialization as different use-cases served by
36  different code.
37
38* Optimizing for binary size and working-set-size on the serialization paths.
39
40* Ignoring most of the error checking and long-tail features of protobuf
41  (repeated vs optional, full type checks).
42
43* ProtoZero is not designed as general-purpose protobuf de/serialization and is
44  heavily customized to maintain the tracing writing code minimal and allow the
45  compiler to see through the architectural layers.
46
47* Code generated by ProtoZero needs to be hermetic. When building the
48  amalgamated [Tracing SDK](/docs/instrumentation/tracing-sdk.md), the all
49  perfetto tracing sources need to not have any dependency on any other
50  libraries other than the C++ standard library and C library.
51
52## Usage
53
54At the build-system level, ProtoZero is extremely similar to the conventional
55libprotobuf library.
56The ProtoZero `.proto -> .pbzero.{cc,h}` compiler is based on top of the
57libprotobuf parser and compiler infrastructure. ProtoZero is as a `protoc`
58compiler plugin.
59
60ProtoZero has a build-time-only dependency on libprotobuf (the plugin depends
61on libprotobuf's parser and compiler). The `.pbzero.{cc,h}` code generated by
62it, however, has no runtime dependency (not even header-only dependencies) on
63libprotobuf.
64
65In order to generate ProtoZero stubs from proto you need to:
66
671. Build the ProtoZero compiler plugin, which lives in
68   [src/protozero/protoc_plugin/](/src/protozero/protoc_plugin/).
69   ```bash
70   tools/ninja -C out/default protozero_plugin protoc
71   ```
72
732. Invoke the libprotobuf `protoc` compiler passing the `protozero_plugin`:
74   ```bash
75  out/default/protoc \
76      --plugin=protoc-gen-plugin=out/default/protozero_plugin \
77      --plugin_out=wrapper_namespace=pbzero:/tmp/  \
78      test_msg.proto
79   ```
80   This generates `/tmp/test_msg.pbzero.{cc,h}`.
81
82   NOTE: The .cc file is always empty. ProtoZero-generated code is header only.
83   The .cc file is emitted only because some build systems' rules assume that
84   protobuf codegens generate both a .cc and a .h file.
85
86## Proto serialization
87
88The quickest way to undestand ProtoZero design principles is to start from a
89small example and compare the generated code between libprotobuf and ProtoZero.
90
91```protobuf
92syntax = "proto2";
93
94message TestMsg {
95  optional string str_val = 1;
96  optional int32 int_val = 2;
97  repeated TestMsg nested = 3;
98}
99```
100
101#### libprotobuf approach
102
103The libprotobuf approach is to generate a C++ class that has one member for each
104proto field, with dedicated serialization and de-serialization methods.
105
106```bash
107out/default/protoc  --cpp_out=. test_msg.proto
108```
109
110generates test_msg.pb.{cc,h}. With many degrees of simplification, it looks
111as follows:
112
113```c++
114// This class is generated by the standard protoc compiler in the .pb.h source.
115class TestMsg : public protobuf::MessageLite {
116  private:
117   int32 int_val_;
118   ArenaStringPtr str_val_;
119   RepeatedPtrField<TestMsg> nested_;  // Effectively a vector<TestMsg>
120
121 public:
122  const std::string& str_val() const;
123  void set_str_val(const std::string& value);
124
125  bool has_int_val() const;
126  int32_t int_val() const;
127  void set_int_val(int32_t value);
128
129  ::TestMsg* add_nested();
130  ::TestMsg* mutable_nested(int index);
131  const TestMsg& nested(int index);
132
133  std::string SerializeAsString();
134  bool ParseFromString(const std::string&);
135}
136```
137
138The main characteristic of these stubs are:
139
140* Code generated from .proto messages can be used in the codebase as general
141  purpose objects, without ever using the `SerializeAs*()` or `ParseFrom*()`
142  methods (although anecdotal evidence suggests that most project use these
143  proto-generated classes only at the de/serialization endpoints).
144
145* The end-to-end journey of serializing a proto involves two steps:
146  1. Setting the individual int / string / vector fields of the generated class.
147  2. Doing a serialization pass over these fields.
148
149  In turn this has side-effects on the code generated. STL copy/assignment
150  operators for strings and vectors are non-trivial because, for instance, they
151  need to deal with dynamic memory resizing.
152
153#### ProtoZero approach
154
155```c++
156// This class is generated by the ProtoZero plugin in the .pbzero.h source.
157class TestMsg : public protozero::Message {
158 public:
159  void set_str_val(const std::string& value) {
160    AppendBytes(/*field_id=*/1, value.data(), value.size());
161  }
162  void set_str_val(const char* data, size_t size) {
163    AppendBytes(/*field_id=*/1, data, size);
164  }
165  void set_int_val(int32_t value) {
166    AppendVarInt(/*field_id=*/2, value);
167  }
168  TestMsg* add_nested() {
169    return BeginNestedMessage<TestMsg>(/*field_id=*/3);
170  }
171}
172```
173
174The ProtoZero-generated stubs are append-only. As the `set_*`, `add_*` methods
175are invoked, the passed arguments are directly serialized into the target
176buffer. This introduces some limitations:
177
178* Readback is not possible: these classes cannot be used as C++ struct
179  replacements.
180
181* No error-checking is performed: nothing prevents a non-repeated field to be
182  emitted twice in the serialized proto if the caller accidentally calls a
183  `set_*()` method twice. Basic type checks are still performed at compile-time
184  though.
185
186* Nested fields must be filled in a stack fashion and cannot be written
187  interleaved. Once a nested message is started, its fields must be set before
188  going back setting the fields of the parent message. This turns out to not be
189  a problem for most tracing use-cases.
190
191This has a number of advantages:
192
193* The classes generated by ProtoZero don't add any extra state on top of the
194  base class they derive (`protozero::Message`). They define only inline
195  setter methods that call base-class serialization methods. Compilers can
196  see through all the inline expansions of these methods.
197
198* As a consequence of that, the binary cost of ProtoZero is independent of the
199  number of protobuf messages defined and their fields, and depends only on the
200  number of `set_*`/`add_*` calls. This (i.e. binary cost of non-used proto
201  messages and fields) anecdotally has been a big issue with libprotobuf.
202
203* The serialization methods don't involve any copy or dynamic allocation. The
204  inline expansion calls directly into the corresponding `AppendVarInt()` /
205  `AppendString()` methods of `protozero::Message`.
206
207* This allows to directly serialize trace events into the
208  [tracing shared memory buffers](/docs/concepts/buffers.md), even if they are
209  not contiguous.
210
211### Scattered buffer writing
212
213A key part of the ProtoZero design is supporting direct serialization on
214non-globally-contiguous sequences of contiguous memory regions.
215
216This happens by decoupling `protozero::Message`, the base class for all the
217generated classes, from the `protozero::ScatteredStreamWriter`.
218The problem it solves is the following: ProtoZero is based on direct
219serialization into shared memory buffers chunks. These chunks are 4KB - 32KB in
220most cases. At the same time, there is no limit in how much data the caller will
221try to write into an individual message, a trace event can be up to 256 MiB big.
222
223![ProtoZero scattered buffers diagram](/docs/images/protozero-ssw.png)
224
225#### Fast-path
226
227At all times the underlying `ScatteredStreamWriter` knows what are the bounds
228of the current buffer. All write operations are bound checked and hit a
229slow-path when crossing the buffer boundary.
230
231Most write operations can be completed within the current buffer boundaries.
232In that case, the cost of a `set_*` operation is in essence a `memcpy()` with
233the extra overhead of var-int encoding for protobuf preambles and
234length-delimited fields.
235
236#### Slow-path
237
238When crossing the boundary, the slow-path asks the
239`ScatteredStreamWriter::Delegate` for a new buffer. The implementation of
240`GetNewBuffer()` is up to the client. In tracing use-cases, that call will
241acquire a new thread-local chunk from the tracing shared memory buffer.
242
243Other heap-based implementations are possible. For instance, the ProtoZero
244sources provide a helper class `HeapBuffered<TestMsg>`, mainly used in tests (see
245[scattered_heap_buffer.h](/include/perfetto/protozero/scattered_heap_buffer.h)),
246which allocates a new heap buffer when crossing the boundaries of the current
247one.
248
249Consider the following example:
250
251```c++
252TestMsg outer_msg;
253for (int i = 0; i < 1000; i++) {
254  TestMsg* nested = outer_msg.add_nested();
255  nested->set_int_val(42);
256}
257```
258
259At some point one of the `set_int_val()` calls will hit the slow-path and
260acquire a new buffer. The overall idea is having a serialization mechanism
261that is extremely lightweight most of the times and that requires some extra
262function calls when buffer boundary, so that their cost gets amortized across
263all trace events.
264
265In the context of the overall Perfetto tracing use case, the slow-path involves
266grabbing a process-local mutex and finding the next free chunk in the shared
267memory buffer. Hence writes are lock-free as long as they happen within the
268thread-local chunk and require a critical section to acquire a new chunk once
269every 4KB-32KB (depending on the trace configuration).
270
271The assumption is that the likeliness that two threads will cross the chunk
272boundary and call `GetNewBuffer()` at the same time is extremely slow and hence
273the critical section is un-contended most of the times.
274
275```mermaid
276sequenceDiagram
277  participant C as Call site
278  participant M as Message
279  participant SSR as ScatteredStreamWriter
280  participant DEL as Buffer Delegate
281  C->>M: set_int_val(...)
282  activate C
283  M->>SSR: AppendVarInt(...)
284  deactivate C
285  Note over C,SSR: A typical write on the fast-path
286
287  C->>M: set_str_val(...)
288  activate C
289  M->>SSR: AppendString(...)
290  SSR->>DEL: GetNewBuffer(...)
291  deactivate C
292  Note over C,DEL: A write on the slow-path when crossing 4KB - 32KB chunks.
293```
294
295### Deferred patching
296
297Nested messages in the protobuf binary encoding are prefixed with their
298varint-encoded size.
299
300Consider the following:
301
302```c++
303TestMsg* nested = outer_msg.add_nested();
304nested->set_int_val(42);
305nested->set_str_val("foo");
306```
307
308The canonical encoding of this protobuf message, using libprotobuf, would be:
309
310```bash
3111a 07 0a 03 66 6f 6f 10 2a
312^-+-^ ^-----+------^ ^-+-^
313  |         |          |
314  |         |          +--> Field ID: 2 [int_val], value = 42.
315  |         |
316  |         +------> Field ID: 1 [str_val], len = 3, value = "foo" (66 6f 6f).
317  |
318  +------> Field ID: 3 [nested], length: 7  # !!!
319```
320
321The second byte in this sequence (07) is problematic for direct encoding. At the
322point where `outer_msg.add_nested()` is called, we can't possibly know upfront
323what the overall size of the nested message will be (in this case, 5 + 2 = 7).
324
325The way we get around this in ProtoZero is by reserving four bytes for the
326_size_ of each nested message and back-filling them once the message is
327finalized (or when we try to set a field in one of the parent messages).
328We do this by encoding the size of the message using redundant varint encoding,
329in this case: `87 80 80 00` instead of `07`.
330
331At the C++ level, the `protozero::Message` class holds a pointer to its `size`
332field, which typically points to the beginning of the message, where the four
333bytes are reserved, and back-fills it in the `Message::Finalize()` pass.
334
335This works fine for cases where the entire message lies in one contiguous buffer
336but opens a further challenge: a message can be several MBs big. Looking at this
337from the overall tracing perspective, the shared memory buffer chunk that holds
338the beginning of a message can be long gone (i.e. committed in the central
339service buffer) by the time we get to the end.
340
341In order to support this use case, at the tracing code level (outside of
342ProtoZero), when a message crosses the buffer boundary, its `size` field gets
343redirected to a temporary patch buffer
344(see [patch_list.h](/src/tracing/core/patch_list.h)). This patch buffer is then
345sent out-of-band, piggybacking over the next commit IPC (see
346[Tracing Protocol ABI](/docs/design-docs/api-and-abi.md#tracing-protocol-abi))
347
348### Performance characteristics
349
350NOTE: For the full code of the benchmark see
351      `/src/protozero/test/protozero_benchmark.cc`
352
353We consider two scenarios: writing a simple event and a nested event
354
355#### Simple event
356
357Consists of filling a flat proto message with of 4 integers (2 x 32-bit,
3582 x 64-bit) and a 32 bytes string, as follows:
359
360```c++
361void FillMessage_Simple(T* msg) {
362  msg->set_field_int32(...);
363  msg->set_field_uint32(...);
364  msg->set_field_int64(...);
365  msg->set_field_uint64(...);
366  msg->set_field_string(...);
367}
368```
369
370#### Nested event
371
372Consists of filling a similar message which is recursively nested 3 levels deep:
373
374```c++
375void FillMessage_Nested(T* msg, int depth = 0) {
376  FillMessage_Simple(msg);
377  if (depth < 3) {
378    auto* child = msg->add_field_nested();
379    FillMessage_Nested(child, depth + 1);
380  }
381}
382```
383
384#### Comparison terms
385
386We compare, for the same message type, the performance of ProtoZero,
387libprotobuf and a speed-of-light serializer.
388
389The speed-of-light serializer is a very simple C++ class that just appends
390data into a linear buffer making all sorts of favourable assumptions. It does
391not use any binary-stable encoding, it does not perform bound checking,
392all writes are 64-bit aligned, it doesn't deal with any thread-safety.
393
394```c++
395struct SOLMsg {
396  template <typename T>
397  void Append(T x) {
398    // The memcpy will be elided by the compiler, which will emit just a
399    // 64-bit aligned mov instruction.
400    memcpy(reinterpret_cast<T*>(ptr_), &x, sizeof(x));
401    ptr_ += sizeof(x);
402  }
403
404  void set_field_int32(int32_t x) { Append(x); }
405  void set_field_uint32(uint32_t x) { Append(x); }
406  void set_field_int64(int64_t x) { Append(x); }
407  void set_field_uint64(uint64_t x) { Append(x); }
408  void set_field_string(const char* str) { ptr_ = strcpy(ptr_, str); }
409
410  char storage_[sizeof(g_fake_input_simple)];
411  char* ptr_ = &storage_[0];
412};
413```
414
415The speed-of-light serializer serves as a reference for _how fast a serializer
416could be if argument marshalling and bound checking were zero cost._
417
418#### Benchmark results
419
420##### Google Pixel 3 - aarch64
421
422```bash
423$ cat out/droid_arm64/args.gn
424target_os = "android"
425is_clang = true
426is_debug = false
427target_cpu = "arm64"
428
429$ ninja -C out/droid_arm64/ perfetto_benchmarks && \
430  adb push --sync out/droid_arm64/perfetto_benchmarks /data/local/tmp/perfetto_benchmarks && \
431  adb shell '/data/local/tmp/perfetto_benchmarks --benchmark_filter=BM_Proto*'
432
433------------------------------------------------------------------------
434Benchmark                                 Time           CPU Iterations
435------------------------------------------------------------------------
436BM_Protozero_Simple_Libprotobuf         402 ns        398 ns    1732807
437BM_Protozero_Simple_Protozero           242 ns        239 ns    2929528
438BM_Protozero_Simple_SpeedOfLight        118 ns        117 ns    6101381
439BM_Protozero_Nested_Libprotobuf        1810 ns       1800 ns     390468
440BM_Protozero_Nested_Protozero           780 ns        773 ns     901369
441BM_Protozero_Nested_SpeedOfLight        138 ns        136 ns    5147958
442```
443
444##### HP Z920 workstation (Intel Xeon E5-2690 v4) running Linux
445
446```bash
447
448$ cat out/linux_clang_release/args.gn
449is_clang = true
450is_debug = false
451
452$ ninja -C out/linux_clang_release/ perfetto_benchmarks && \
453  out/linux_clang_release/perfetto_benchmarks --benchmark_filter=BM_Proto*
454
455------------------------------------------------------------------------
456Benchmark                                 Time           CPU Iterations
457------------------------------------------------------------------------
458BM_Protozero_Simple_Libprotobuf         428 ns        428 ns    1624801
459BM_Protozero_Simple_Protozero           261 ns        261 ns    2715544
460BM_Protozero_Simple_SpeedOfLight        111 ns        111 ns    6297387
461BM_Protozero_Nested_Libprotobuf        1625 ns       1625 ns     436411
462BM_Protozero_Nested_Protozero           843 ns        843 ns     849302
463BM_Protozero_Nested_SpeedOfLight        140 ns        140 ns    5012910
464```
465