Skip to main content

Benchmarking

lplex has a comprehensive benchmark suite covering all performance-critical paths. Benchmarks live alongside the code they measure as *_bench_test.go files.

Running benchmarks

# Run all benchmarks
go test -bench=. -run=^$ ./...

# Run benchmarks in a specific package
go test -bench=. -run=^$ ./pgn/
go test -bench=. -run=^$ ./filter/

# Run a specific benchmark by name
go test -bench=BenchmarkDecode -run=^$ ./pgn/

# Run with memory allocation reporting (already enabled via b.ReportAllocs())
go test -bench=. -benchmem -run=^$ ./...

# Run with longer duration for more stable results
go test -bench=. -benchtime=1s -count=5 -run=^$ ./...

Comparing performance

Use benchstat to compare benchmark results across changes:

# Install benchstat
go install golang.org/x/perf/cmd/benchstat@latest

# Capture baseline
git checkout main
go test -bench=. -count=10 -run=^$ ./... > old.txt

# Capture with changes
git checkout my-branch
go test -bench=. -count=10 -run=^$ ./... > new.txt

# Compare
benchstat old.txt new.txt

CI integration

Pull requests automatically get a benchmark comparison comment posted by CI. The workflow:

  1. Runs all benchmarks on the PR branch (3 iterations, 250ms each)
  2. Checks out the base branch and runs the same benchmarks
  3. Compares results with benchstat
  4. Posts the comparison as a PR comment (updates the same comment on force-push)

Only statistically significant changes (p < 0.05) are flagged. The benchmark job does not block merge — it's informational only.

Benchmark coverage

Root package (broker_bench_test.go)

BenchmarkWhat it measures
BenchmarkFrameJSONSerializationjson.Marshal of a pre-built frameJSON struct
BenchmarkFrameJSONSerializationFullFull serialization path: time format + hex encode + JSON marshal
BenchmarkHexEncodeDatahex.EncodeToString for 8-byte CAN payloads
BenchmarkTimeFormattime.Format(RFC3339Nano) for timestamps
BenchmarkRingBufferWriteWriting a pre-serialized entry to the ring buffer (lock + assign + advance)
BenchmarkEventFilterMatchesEventFilter.matches() with nil, single PGN, multiple PGNs, exclude filters
BenchmarkResolvedFilterMatchesresolvedFilter.matches() with map-based PGN and source lookups
BenchmarkFanOutFan-out to 0, 1, 10, and 100 subscribers (with and without filters)

Root package (fastpacket_bench_test.go)

BenchmarkWhat it measures
BenchmarkFastPacketProcessComplete fast-packet reassembly (single transfer, assembler reuse, concurrent sources)
BenchmarkFragmentFastPacketSplitting payloads into CAN frames (20-byte and 223-byte payloads)
BenchmarkIsFastPacketRegistry lookup for fast-packet flag
BenchmarkPurgeStaleCleanup of timed-out in-progress assemblies

Root package (journal_writer_bench_test.go)

BenchmarkWhat it measures
BenchmarkJournalAppendFrameFull journal write pipeline — frame encoding, block flush, and file I/O (uncompressed and zstd)
BenchmarkJournalFrameEncodingRaw frame encoding into a block buffer (varint + CAN ID + data copy)
BenchmarkBuildCANIDConstructing a 29-bit CAN identifier from a CANHeader

Filter package (filter/filter_bench_test.go)

BenchmarkWhat it measures
BenchmarkCompileLexing + parsing filter expressions (simple to complex)
BenchmarkMatchEvaluating compiled filters against header fields, decoded struct fields (float, string), lookup fields, and nil decoded values

PGN package (pgn/pgn_bench_test.go)

BenchmarkWhat it measures
BenchmarkDecodeDecoding raw bytes for VesselHeading, WindData, Temperature, EngineParametersRapidUpdate, BatteryStatus
BenchmarkEncodeEncoding decoded structs back to raw bytes
BenchmarkDecodeEncodeFull decode + encode round-trip
BenchmarkRegistryLookupMap lookup in pgn.Registry (known PGN, unknown PGN, lookup + decode)

Writing new benchmarks

Follow these conventions when adding benchmarks:

  1. File naming: Use *_bench_test.go to keep benchmarks separate from tests.
  2. Always call b.ReportAllocs() so memory allocations are tracked.
  3. Use b.Loop() (Go 1.24+) for the benchmark loop.
  4. Use b.ResetTimer() after setup code to exclude setup from measurement.
  5. Use subtests (b.Run("name", ...)) to group related benchmarks.
  6. Avoid struct literals for generated types — use Decode* functions to construct test values, since generated field types may change.

Example:

func BenchmarkMyOperation(b *testing.B) {
// Setup (not measured)
data := prepareTestData()

b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
myOperation(data)
}
}

Performance characteristics

The broker hot path is designed for minimal allocation and lock contention:

  • PGN decode: ~1.5–7 ns/op, zero allocations
  • Ring buffer write: ~8 ns/op, zero allocations
  • Filter matching: ~2–6 ns/op for resolved filters, zero allocations
  • JSON pre-serialization: ~200 ns/op (dominated by json.Marshal + time.Format)
  • Fan-out: scales linearly with subscriber count (~6 ns/subscriber)
  • Fast-packet reassembly: ~70 ns/op per complete transfer
  • Journal frame encoding: ~2–3 ns/op (raw encoding), ~26–28 ns/op (amortized with block flush and I/O)

These numbers are from an Apple M5 Pro. Absolute values will differ across hardware, but relative comparisons via benchstat are meaningful on any machine.