anthropics / anthropics/buffa

buffa in production: full prost->buffa migration (+21.65%) — memory & binary-size tradeoffs worth investigating

Aperta
#270 1 commento 1 reazione 0 assegnatari Vedi su GitHub
Lingua principale
Rust
Stelle
883
Fork
88
Merge medio
3g 19h
PR unite (30g)
42

Descrizione

Hey — the full prost 0.13 -> buffa 0.8.1 migration I've been chipping away at is now landing in production (a large WhatsApp protocol implementation), so I wanted to share the end-to-end results as a real-world "buffa in production" data point, and flag two tradeoffs that look like they might be addressable in buffa itself rather than at the app level.

Overall it's a clear win — the full CodSpeed suite came out **+21.65% net** across 179 benchmarks (**17 improvements, 6 regressions, 156 unchanged**), on top of the struct-size and decode-speed wins from the earlier issues. Sharing the regressions too so the tradeoff is on the table and this isn't one-sided.

PR (real production migration, reproducible CodSpeed run attached to it): oxidezap/whatsapp-rust#557

## The wins (so it's not one-sided)

A sample of the big movers vs prost on the same suite:

| benchmark | metric | prost | buffa | Δ |
|---|---|---:|---:|---|
| `decode_plaintext[text_reply]` | memory | 3.4 KB | 1.3 KB | ×2.6 |
| `decode_plaintext[group_skdm_text]` | time | 8.4 µs | 6.0 µs | +40% |
| `history_sync_stream_drain` | memory | 431 KB | 243 KB | +77% |
| `process_patch_50_validated` | memory | 171 KB | 48 KB | ×3.6 |
| `dm_session_establishment` | memory | 3.7 KB | 2.4 KB | +55% |
| `full_dm_conversation` | memory | 9.8 KB | 7.8 KB | +27% |
| `decode_record[contact]` | time | 44.6 µs | 35.2 µs | +27% |

Decode across the board is both faster and lighter, and the deep-tree history-sync/appstate paths improved the most — exactly what the boxed-field + tighter-codegen work was aiming at.

## Tradeoff 1: a subset of decode/encode shapes regressed on memory

A handful of benchmarks allocate *more* under buffa than prost. Two shapes stand out:

| benchmark | metric | prost | buffa | Δ |
|---|---|---:|---:|---|
| `decode_record[contact]` | memory | 385 B | 1,113 B | −65% |
| `decode_record[star]` | memory | 484 B | 1,085 B | −55% |
| `group_send_skdm_256` | memory | 415.5 KB | 461.8 KB | −10% |
| `group_send_skdm_50` | memory | 73.4 KB | 82.5 KB | −11% |

The `decode_record` case is the interesting one: memory ~3x but time *−25%* (it's in the wins table above), so it's a clean time/memory trade rather than a straight loss — buffa is doing fewer passes but allocating more per decode. The group-send fanout cases allocate ~10% more per encoded recipient.

I haven't pinned the exact source, but the candidates all live in buffa: eager materialization of small nested/repeated fields during decode, per-field `MessageField` box allocations, or `SizeCache` spill growth. Worth a look at whether the extra allocation on these small-message decode paths is inherent to buffa's representation or is reclaimable — the *time* is already better, so closing the memory gap would make these strict wins.

## Tradeoff 2: final binary grew ~+3.85% despite codegen shrinking 23%

This is the one I'd most like a second opinion on, because the two halves point in opposite directions:

```
Metric prost buffa Δ
bin size (stripped) 10.20 MiB 10.59 MiB +401.91 KiB (+3.85%)
bin .text 8.24 MiB 8.63 MiB +397.62 KiB (+4.71%)
llvm-lines wacore (proto crate) 649,184 497,172 -152,012 (-23.42%)
llvm-lines wacore copies 17,998 17,033 -965 (-5.36%)
llvm-lines whatsapp-rust lib 678,181 710,609 +32,428 (+4.78%)
llvm-lines whatsapp-rust lib copies 21,000 23,096 +2,096 (+9.98%)
```

The **generated proto crate got 23% leaner** in llvm-lines (buffa's codegen is tighter than prost's) — but the **final binary still grew ~400 KiB**, and the growth tracks the *consuming* crate: `whatsapp-rust` lib llvm-lines +4.78% and, tellingly, monomorphization **copies +9.98%**. So the extra `.text` isn't in the generated code — it's in more monomorphized instances downstream.

My working hypothesis is that buffa's encode/decode entry points are generic over the buffer (`impl Buf` / `impl BufMut`) and the message/field wrappers are generic (`MessageField`, `EnumValue`, the view types), so each concrete message type instantiates those generics in the consuming crate — whereas prost's hot paths are more concrete (`bytes::Bytes` in fixed positions). With this repo's ~hundreds of message types, that multiplies. But that's a guess — the `copies +9.98%` number is the concrete signal and I'd rather hear from someone who knows the codegen where those instances are coming from.

Open questions / directions (leaving the fix open):
- Could the per-message `encode`/`decode` public entry points take a concrete buffer type (or `&mut dyn Buf`) at the boundary and only go generic internally, so N message types don't each monomorphize the buffer generic?
- Is the +4.78% consumer-side growth mostly the `MessageField`/`EnumValue`/view generic instantiations, or the per-message `compute_size`/`write_to`/`merge_field` bodies?
- Is any of this a codegen `#[inline]` policy that could be relaxed to dedup across message types?

Repro is the PR itself (the Binary-Size and CodSpeed jobs run on every push, numbers above are from that CI); happy to pull any specific `cargo llvm-lines` / `cargo bloat` breakdown from the tree if it helps localize where the copies land.

Guida per i contributori

Apri la guida per i contributori

Valutazione

Questa issue non è ancora stata valutata.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.