perf(memtrack): reduce serialization bottleneck#436
Conversation
Merging this PR will improve performance by 51.79%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | write_events[1000000] |
2.1 s | 1.4 s | +56.12% |
| ⚡ | Simulation | write_events[500000] |
1,036.6 ms | 679.9 ms | +52.47% |
| ⚡ | Simulation | write_events[10000] |
20 ms | 13.4 ms | +49.62% |
| ⚡ | Simulation | write_events[100000] |
207.3 ms | 139.1 ms | +49.05% |
| 🆕 | Simulation | encode_pipeline_zstd_fast[100000] |
N/A | 103 ms | N/A |
| 🆕 | Simulation | encode_pipeline[100000] |
N/A | 142 ms | N/A |
| 🆕 | Simulation | encode_pipeline[1000000] |
N/A | 1.4 s | N/A |
| 🆕 | Simulation | encode_pipeline_zstd_fast[1000000] |
N/A | 1 s | N/A |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing cod-3071-fix-serialization-bottleneck (346a162) with main (3278ec8)
381bac8 to
9be9b22
Compare
Greptile SummaryThis PR replaces the single-threaded, streaming
Confidence Score: 5/5Safe to merge; the new parallel encode pipeline is well-structured, error propagation from the writer thread is correctly ordered before any fallback sentinel, and the byte-identical serializer is test-verified. The three core invariants of this change — correct frame ordering, error surfacing, and wire-format compatibility — are each covered by tests and the code logic is sound. The only known issue (partial tail-batch loss in integration test helpers) was flagged in a prior review pass and is unchanged by this PR. crates/memtrack/tests/shared.rs — the integration test helper still lacks the stop_polling() call needed to flush the final partial batch before recv_timeout exits. Important Files Changed
|
The custom Serialize impl emits ~13 tiny writes per event (map header + per-field key/value). With the BufWriter on the compressed output side, each of those hit zstd's streaming compressor directly, whose per-call overhead dominated: ~2.4x slower than the derived flatten path, which coalesced each event into one write. Move the BufWriter to the encoder's input side so the tiny writes are batched before reaching zstd. Restores baseline throughput.
92a19a6 to
d48193f
Compare
The drain path fed events through three per-event std::mpsc hops (poll -> keepalive -> drain -> dispatcher) before batching, each send allocating a node and locking. Batch events in the poll callback instead: the single poll thread coalesces into fixed-size batches and hands off one Vec at a time. The poller now lives in the Tracker (removing the keepalive thread) and track() returns batches; main's drain thread is gone and the dispatcher just tags ordered batches with a sequence number. Per-event work drops from three mpsc sends to one Vec push.
a225638 to
9561cdd
Compare
The drain->encode->write pipeline lived inline in main.rs, so it was reachable only through the eBPF path and had no test or benchmark coverage. Move it into runner_shared::artifacts::memtrack::encode_batches, which takes any ordered batch source (the live tracker channel or synthetic data) and an output writer. Split the memtrack artifact module into mod/writer/pipeline. main.rs now spawns one thread running encode_batches. Adds unit tests for ordering across parallel workers and the empty-run frame, plus an encode_pipeline divan benchmark writing to a sink.
The raw (uncompressed) sink existed only to measure the serialization floor in benches. With it gone the Compression enum collapses to a single Zstd variant, so pass the zstd level as a plain i32: encode_batches_with_config becomes encode_batches_with_level and encode_frame loses its match.
Compression is ~2/3 of the encode cost. Level -5 (fast strategy) raises pipeline throughput ~1.3x (11M -> 14.5M ev/s at 4 workers) so encoding stays ahead of the allocation rate and nothing backs up when eBPF finishes. On real traces the repeated map keys compress away regardless of level, so the size cost is small.
encode_frame built a fresh MemtrackWriter (new zstd Encoder = new CCtx) and output Vec for every batch. Give each worker one zstd::bulk::Compressor and one msgpack scratch buffer, reused across frames: serialize the batch into the scratch, then compress it in one shot. Amortizes the CCtx allocation (RawVecInner::with_capacity_in was ~5.6% self) and drops the per-frame streaming BufWriter. ~9% faster pipeline throughput (14.3M -> 15.7M ev/s, 1M events, level -5, 4 workers); output frames unchanged, decoder untouched.
Drop the min(8) cap on encode workers. The pipeline scales near-linearly with core count and memtrack does not measure wall-time, so using every core to keep encoding ahead of the allocation rate is worth the contention with the tracked process.
9561cdd to
9292b3f
Compare
…per-event lock The ring-buffer callback locked an Arc<Mutex<BatchAccumulator>> on every event, even though libbpf invokes it synchronously on the poll thread and nothing else touches the accumulator. Stage events in a thread-local Vec instead: the callback does a plain push (no atomic) and the poll loop, on the same thread, drains and batches them. Removes the lock/unlock atomics from the hottest serial stage, which gates ring-buffer overflow at high allocation rates.
The shutdown path did a fixed 50ms sleep between two consume() calls, paying the worst case even when the ring buffer drained immediately. Consume in a loop instead, sleeping only between empty rounds and stopping after a few consecutive idle rounds. Drains as fast as stragglers arrive (typically a few ms) while still tolerating late events, cutting the fixed tail wait after the tracked process exits.
Workers allocated a fresh output Vec per frame via Compressor::compress. Add a pool channel: the writer clears each written frame and returns it to the workers, which compress into a recycled buffer with compress_to_buffer. Amortizes the per-frame output allocation. The effect is negligible for low frame counts but adds up under the many-small-frames streaming that per-poll-cycle draining produces on high-rate runs. Best-effort: an empty pool just falls back to a fresh Vec.
Summary
MemtrackEventserialization with a byte-identical manual serializerMemtrackWriter::finishreturn encoded frame bytes for batchingVerification
cargo test -p runner-shared artifacts::memtrackcargo test -p runner-sharedcargo check -p runner-sharedcargo test --manifest-path crates/memtrack/Cargo.tomlvia Nix shell with libclang/build deps; eBPF integration cases compiled but were ignored becauseGITHUB_ACTIONSwas unsetcargo bench -p runner-shared --bench memtrack_writerNotes
memtrack_writerbench measures the Phase A single-writer encoder path only, not the full parallel memtrack pipeline.