Skip to content

perf(parser): make the benchmarks runnable and cut 16% of parse instructions - #359

Draft
digizeph wants to merge 15 commits into
mainfrom
perf/parser-throughput
Draft

digizeph wants to merge 15 commits into
mainfrom
perf/parser-throughput

Conversation

@digizeph

@digizeph digizeph commented Nov 4, 2026

Copy link
Copy Markdown
Member

The benchmarks could not run on a clean checkout, so this branch starts with benches/ repaired: the fixture download truncated its own target (it opened the destination for decompression before creating it), the target-directory lookup panicked under CARGO_TARGET_DIR, and the CLI benchmark panicked when the cli feature was off. The suites now parse whole corpora instead of the first 100k records, report records and elems as separate denominators, and keep the real-corpus runs opt-in through environment variables.

On that harness, six measured changes:

  • record headers stay in a stack buffer instead of a Vec plus a Bytes per record
  • AS_PATH decodes from the record body, with segments and ASNs decoded in place
  • ORIGIN, NEXT_HOP, MED and LOCAL_PREF decode from the section slice, one branch each
  • the attribute vector is sized from the section instead of one slot per three bytes
  • an empty attribute section reserves nothing

End to end on the rrc00 bview of 2026-10-14 (420 MB gz, 55.8 M elems, -e counting path, four alternating runs): 463.1 G to 390.6 G instructions (-15.7%) and 54.4 s to 42.2 s wall. Parse-only Criterion medians: RIB record iteration -18.9%, RIB elem iteration -11.4%, updates record iteration -17.2%.

benches/RESULTS.md carries the method, the per-benchmark tables, the comparison against CAIDA BGPStream on the same file (3.4x instructions, 3.4x wall), and the change that was measured and reverted: routing all thirteen attribute parsers through one slice dispatcher cost 7% instead of saving it.

Draft because the structural direction is not here: emitting elems from the record body instead of building the owned graph first is a separate design, and the dense peer lookup is deferred for now.

The benchmark targets could not run on a clean checkout. The test data
download opened the target with get_reader (which decompresses by suffix)
before File::create truncated it, so the copy always ended in
"decompression not finished but EOF reached"; the checksum was also
computed over decompressed bytes. Both targets resolved the target
directory by walking up for a literal target/, which breaks under
CARGO_TARGET_DIR or build.target-dir, and bench_main panicked when the
CLI binary was missing even though cargo bench never builds it
(required-features = ["cli"]).

- Download to a .part file, hash the raw bytes, then rename.
- Resolve the target directory from CARGO_TARGET_TMPDIR, keeping the
  walk-up as a fallback for older toolchains.
- Skip bgpkit-parser in bench_main with a hint instead of panicking.
- Benchmark the whole in-memory corpus instead of the first 100k items,
  which meant different record counts per mode, and report throughput
  (records/s or elems/s) next to the wall time.
- Give the record-to-elem benchmark the peer index table it needs; RIB
  records were silently returning no elems without it.
- Add opt-in real archives via BGPKIT_BENCH_RIB/BGPKIT_BENCH_UPDATES,
  streamed from disk so decompression is included.
- Rewrite benches/README.md around the actual commands and variables.
- parse_as_path keeps segments inline and decodes ASNs straight into the
  segment SmallVec instead of growing a Vec and copying it in.
- parse_attributes sizes its attribute vector from the section length with a
  realistic per-attribute floor instead of one slot per three bytes, which
  over-allocated by an order of magnitude on short records.
- parse_rib_afi_entries reserves one slot per entry instead of two.

Measured on a fixed workload with perf stat, which is stable to 0.01% across
runs on this host: updates elems -2.0%, updates records -2.9%, RIB elems
-1.7%, RIB records -2.0%. Allocations per RIB record 14.0 -> 11.9.
Framing a record allocated a Vec for the captured header bytes and a Bytes for
the header itself, both only needed by the raw-record API and by error
reporting. Read the header into a fixed 16-byte buffer, keep the body Vec, and
build the header Bytes only where the raw API asks for it. Truncated headers
still keep the bytes they did contain, so the existing partial-framing error
tests pass unchanged.

Measured with perf stat on a fixed workload (instruction counts are stable to
0.01% across runs on this host): updates records -7.1%, updates elems -3.5%,
RIB records -3.2%, RIB elems -2.6% against the previous commit.
AS_PATH is in essentially every announcement, and it was the last attribute that
had to be split out of the record body into its own Bytes before parsing. Parse
it straight from a borrowed slice instead. Other attributes keep the owned path:
routing all thirteen parses through one Option<Result<..>> dispatcher measured
6.8% slower on RIB records, so the win depends on the dispatch staying in the
parse loop.

Measured with perf stat on a fixed workload: RIB elems -2.6%, RIB records -3.1%,
updates elems -2.4%, updates records -3.2% against the previous commit.
Adds benches/RESULTS.md with the before/after instruction counts, allocation
counts, the harness defects that were fixed, the change that regressed and was
reverted, and the reproduction commands. Un-ignores benches/*.md so benchmark
documentation can live next to the benchmarks.
The stack-header rewrite advanced the captured length only after the read
returned successfully, so an I/O error in the middle of the 4-byte microsecond
field dropped the one to three bytes already read from the reported error bytes.
An external review caught it; a clean end of stream was unaffected, which is why
the existing 0/5/17-byte truncation tests stayed green.

Read through a helper that advances the captured length in place, and cover 13,
14 and 15 header bytes for both an I/O error and a clean EOF.
A floor of four left most RIB entries growing their attribute vector: across all
470,642 fixture entries, 71.4% exceeded four slots and paid the realloc and
copy. Six leaves 3.7% short, so eight was chosen; four, six and eight were
measured against the same binary and stayed within 0.5% of each other.

Instruction counts for the committed build against four slots, at a fixed
iteration count: RIB elem iteration -3.2%, RIB record iteration -1.8%, updates
elem iteration -2.9%, updates record iteration -0.5%. Allocations per RIB
record 8.94 -> 7.24, per updates record 6.03 -> 5.55.
An external review of the harness found four ways a reader could be misled:

- into_update_iter reported records/s while yielding update items (about 3.6%
  off on the updates fixture);
- preload truncated at 64 MiB silently, which can cut a record in half and be
  hidden by the skipping iterators;
- the record-to-elem and real-archive benchmarks divided by a fabricated 1 when
  they produced nothing;
- the elementor benchmark still caps at 100k records, which RESULTS.md described
  as parsing the whole fixture.

Fail loudly instead, and commit examples/perf_workload.rs so the fixed-workload
instruction counts in RESULTS.md can actually be reproduced.
Updates RESULTS.md with the figures after the extended-timestamp fix and the
attribute-capacity floor, states what the measurements do not cover, and adds a
same-file comparison against CAIDA BGPStream with the probe and container used
for it.

BGPStream walks a full rrc00 bview with 116 G instructions and 9.1 s where this
parser needs 392 G and 42.6 s; part of that gap is BGPStream handing out elems as
views while BgpElem owns its attributes.
The bgpkit cell had been measured through the default output path (which formats
and writes every element), not the -e counting path the rest of the table uses.
On the counting path the fixture is 6.49 G instructions and 0.85 s, so the gap
there is 1.7x rather than 3.3x; the full-feed rrc00 numbers are unaffected and
re-verified at 392.4 G / 43.6 s.
…ord walk

0.09 s for 150360 records, so every cell in the comparison table now has both
instructions and wall time.
The code and the headline measurements use eight slots, not six; the commit
message and this record said six because an eight-slot experiment was left
applied. All four/six/eight numbers are in the entry, and the end-to-end table
was measured with eight.
Withdrawals and End-of-RIB messages carry no path attributes, yet every one of
them reserved eight Attribute slots (1.5 KiB) before discovering the section was
empty. Reserve nothing in that case.

Measured on the real RIS updates file (150,360 records): elem iteration -0.25%,
record iteration -0.36%. On the RIB fixture it costs 0.07%, where no record has
an empty section.
These four carry a fixed-width value that is read once, so the per-attribute
`Bytes` split only bought refcount traffic. Each now decodes straight from the
attribute section, with its own branch: routing all four through one
`Option<Result<..>>` dispatcher measured slower than the split it removes.

Instruction counts against the previous commit at a fixed iteration count: RIB
record iteration -1.1%, RIB elem iteration -0.9%, updates record iteration
-0.7%, updates elem iteration -0.5%, real RIS updates record iteration -0.2%.
Adds the two kept changes, the Criterion medians against the pre-opt baseline,
the end-to-end re-measurement, and why the dense peer lookup is deferred rather
than dropped.
Copilot AI balanced review requested due to automatic review settings November 4, 2026 23:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The probe treats BGPStream iteration errors as normal exhaustion and exits successfully, allowing failed runs to report partial counts as completed benchmarks and appear faster.

1 open issue
What changed in this PR

This PR repairs the benchmark harness and reduces allocations in MRT framing and BGP attribute parsing, with documented measurements showing roughly 16% fewer end-to-end parse instructions.

Changes:

  • Fix fixture downloads and benchmark setup, report mode-specific throughput, and add opt-in real-archive runs.
  • Keep MRT headers on the stack, decode common attributes from borrowed slices, and reduce vector allocations.
  • Add a fixed-workload example, BGPStream comparison tools, and performance results.
File Description
src/​parser/​utils.rs Enable parsing utilities for borrowed byte slices.
src/​parser/​mrt/​mrt_record.rs Use stack-backed headers and test partial-header error capture.
src/​parser/​mrt/​mrt_header.rs Add fixed-buffer framing with incremental byte capture.
src/​parser/​mrt/​messages/​table_dump_v2/​rib_afi_entries.rs Reduce RIB entry preallocation.
src/​parser/​bgp/​attributes/​mod.rs Use slice-based fast paths and smaller attribute reservations.
src/​parser/​bgp/​attributes/​attr_05_local_pref.rs Decode LOCAL_PREF from a slice.
src/​parser/​bgp/​attributes/​attr_04_med.rs Decode MED from a slice.
src/​parser/​bgp/​attributes/​attr_03_next_hop.rs Decode NEXT_HOP from a slice.
src/​parser/​bgp/​attributes/​attr_02_17_as_path.rs Decode AS paths directly into segment storage.
src/​parser/​bgp/​attributes/​attr_01_origin.rs Decode ORIGIN from a slice.
examples/​perf_workload.rs Add repeatable in-memory parsing workloads.
benches/​RESULTS.md Document measurements, methodology, and limitations.
benches/​README.md Explain benchmark setup and comparisons.
benches/​internals.rs Benchmark whole fixtures with mode-specific throughput.
benches/​data_source.rs Repair downloads and target-directory lookup.
benches/​bgpstream-compare/​Dockerfile Provide the BGPStream comparison environment.
benches/​bgpstream-compare/​bgps_probe.c Add a record and element counting probe.
benches/​bench_main.rs Skip the CLI benchmark when its binary is absent.
.gitignore Allow benchmark Markdown documentation.

Review effort: Balanced

Comment on lines +44 to +52
int rv;
while ((rv = bgpstream_get_next_record(bs, &record)) > 0) {
records++;
if (elems_mode) {
while (bgpstream_record_get_next_elem(record, &elem) > 0) {
elems++;
}
}
}
@codecov

codecov Bot commented Nov 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.16667% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.06%. Comparing base (4019315) to head (8e9f83c).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
src/parser/mrt/mrt_record.rs 98.23% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #359      +/-   ##
==========================================
+ Coverage   92.02%   92.06%   +0.03%     
==========================================
  Files          97       97              
  Lines       24759    24916     +157     
==========================================
+ Hits        22785    22939     +154     
- Misses       1974     1977       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants