perf(memtrack): reduce serialization bottleneck#436
Conversation
Merging this PR will improve performance by 15.77%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | write_events[1000000] |
2.1 s | 1.8 s | +16.72% |
| ⚡ | Simulation | write_events[100000] |
207.3 ms | 179 ms | +15.78% |
| ⚡ | Simulation | write_events[10000] |
20 ms | 17.3 ms | +15.33% |
| ⚡ | Simulation | write_events[500000] |
1,036.6 ms | 899.5 ms | +15.25% |
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 (1b48fb0) with main (7ce2c98)
381bac8 to
9be9b22
Compare
Greptile SummaryThis PR targets the memtrack serialization bottleneck with three coordinated changes: a manual
Confidence Score: 4/5Safe to merge for the production binary; the integration test helper will silently collect zero events for any test process that generates fewer than 64 K allocation events. The core serialization and parallel pipeline changes are correct and well-tested (byte-identity test, concatenated-frame decode test). The test helper in tests/shared.rs has a structural mismatch with the new batching contract: it never calls stop_polling() before the 10-second timeout loop, so the final partial batch is flushed after the receiver has been dropped and is permanently lost. The eBPF integration tests were not run as part of this PR's verification, so the regression is undetected. crates/memtrack/tests/shared.rs — the track_command helper needs stop_polling() called (and the child waited on) before draining the batch channel. Important Files Changed
|
| let total = dispatcher_thread | ||
| .join() | ||
| .map_err(|_| anyhow::anyhow!("Failed to join writer thread"))??; | ||
| .map_err(|_| anyhow::anyhow!("Failed to join memtrack dispatcher thread"))??; | ||
| for worker_thread in worker_threads { | ||
| worker_thread | ||
| .join() | ||
| .map_err(|_| anyhow::anyhow!("Failed to join memtrack worker thread"))??; | ||
| } | ||
| ordered_writer_thread | ||
| .join() | ||
| .map_err(|_| anyhow::anyhow!("Failed to join ordered memtrack writer thread"))??; |
There was a problem hiding this comment.
IO errors from the writer thread are masked by cascaded channel errors
When ordered_writer_thread fails (e.g. a disk write error inside writer.write_all), it exits, dropping frame_rx. Workers then get SendError on frame_tx.send, exit with that error, and their work_rx clones are dropped. The dispatcher then gets SendError on work_tx.send and also exits. Because we join the dispatcher before the ordered writer thread, the error surfaced to the caller is the dispatcher's misleading "sending on a closed channel", not the actual IO failure. The ordered_writer_thread is never joined in the error path and its Err is silently dropped.
Moving the ordered_writer_thread.join() before the dispatcher join (or at minimum before early returns) would surface the real root-cause error.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/memtrack/src/main.rs
Line: 258-268
Comment:
**IO errors from the writer thread are masked by cascaded channel errors**
When `ordered_writer_thread` fails (e.g. a disk write error inside `writer.write_all`), it exits, dropping `frame_rx`. Workers then get `SendError` on `frame_tx.send`, exit with that error, and their `work_rx` clones are dropped. The dispatcher then gets `SendError` on `work_tx.send` and also exits. Because we join the dispatcher *before* the ordered writer thread, the error surfaced to the caller is the dispatcher's misleading "sending on a closed channel", not the actual IO failure. The `ordered_writer_thread` is never joined in the error path and its `Err` is silently dropped.
Moving the `ordered_writer_thread.join()` before the dispatcher join (or at minimum before early returns) would surface the real root-cause error.
How can I resolve this? If you propose a fix, please make it concise.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.
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.
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.