feat: Benchmarking infrastructure, perf optimisations, and GC/latency investigation - #7
Merged
Merged
Conversation
Adds a bench/ executable comparing generate_qr (heap) vs generate_qr_stack (stack) across three input sizes and all four ECLs.
Registers a landmark per pipeline phase (encode, split_into_blocks, interleave_blocks, place_pattern_modules, place_format_info, place_data, apply_mask_pattern) so the profiler shows a full call-graph breakdown. Wraps call sites inside generate_qr to avoid touching [@zero_alloc] sub-functions.
Adds a non-zero_alloc variant of generate_qr_stack with its own set of landmark instances (stack/* prefix) so both functions expand fully in the call graph without triggering recursive-call warnings from shared landmark IDs.
Adds a `reserved : bytes` field to `Qr.t`, populated once by `mark_reserved` at the end of `place_pattern_modules`. `place_data` and `apply_mask_pattern` now do an O(1) bitmap lookup per cell instead of calling `is_reserved` (which walked alignment coordinate lists) on every cell. place_data: 7.15G → 2.45G cycles (2.9x) apply_mask_pattern: 3.65G → 1.04G cycles (3.5x) Also fixes a latent bug where apply_mask_pattern passed hardcoded version=1 to is_reserved, incorrectly skipping the alignment-pattern exclusion for QR codes version 2+.
Three changes, driven by perf profiling after the reserved-cell bitmap
precomputation commit:
1. Rewrite mark_reserved to stamp known regions directly instead of
calling is_reserved (which called is_in_alignment_pattern) for
every cell. The old path iterated width² cells, each paying an
O(coords²) alignment search. The new path marks three fixed 9×9
corner rectangles, the timing strips, and each alignment 5×5 square
— all O(reserved_cells). is_in_alignment_pattern and is_reserved
become dead code and are removed.
2. place_data: replace bit_pos/8 and bit_pos%8 with running byte-index
and shift-counter refs, removing integer division from the inner
loop.
3. apply_mask_pattern: replace (x+y) % 2 with (x+y) land 1.
Benchmark results (120 000 calls across 3 inputs × 4 ECLs × 10 000 iters):
┌───────────────────────┬───────────────┬───────────────┬───────────────────────┐
│ Phase │ Before │ After │ Speedup │
├───────────────────────┼───────────────┼───────────────┼───────────────────────┤
│ place_pattern_modules │ 6.68G cycles │ 761M cycles │ 8.8× │
├───────────────────────┼───────────────┼───────────────┼───────────────────────┤
│ apply_mask_pattern │ 1.04G cycles │ 881M cycles │ 1.18× │
├───────────────────────┼───────────────┼───────────────┼───────────────────────┤
│ generate_qr total │ 13.80G cycles │ 7.84G cycles │ 1.76× │
└───────────────────────┴───────────────┴───────────────┴───────────────────────┘
The dominant win is mark_reserved: is_in_alignment_pattern was 22% of
total samples and is_reserved a further 12%. Both are now gone.
oxqr.opam is generated by dune from dune-project. landmarks was added to the (depends ...) stanza in dune-project alongside the benchmarking commit (e480e7e) but the regenerated opam file was never staged.
…ner loop Two independent optimisations, each targeting a separate perf hotspot. place_data + apply_mask_pattern → place_data_and_apply_mask Both functions performed the same full-matrix zigzag scan, checking the same reserved bitmap. The new combined function does a single pass, applying mask pattern 0 (XOR with (x+y+1) land 1) at write time. set_module's four bounds checks are also bypassed since the scan guarantees valid coordinates. apply_mask_pattern is retained as a public API but is no longer called by the pipeline. Reed-Solomon: hoist log_coef + precompute log-generator polynomial_mult(generator[j], coef) recomputed log_table[coef] on every iteration of the inner j loop (ec_count+1 times per data byte). Caching it once before the inner loop eliminates that redundancy. Additionally, log_table[generator[j]] is now precomputed into a generator_log_polynomials table at startup, removing one table lookup per inner step and the polynomial_mult function-call overhead entirely. Benchmark results (120 000 calls across 3 inputs × 4 ECLs × 10 000 iters): ┌───────────────────────────────┬───────────────┬───────────────┬───────────┐ │ Phase │ Before │ After │ Speedup │ ├───────────────────────────────┼───────────────┼───────────────┼───────────┤ │ place_data + apply_mask │ 3.40G cycles │ │ │ │ → place_data_and_apply_mask │ │ 1.54G cycles │ 2.2× │ ├───────────────────────────────┼───────────────┼───────────────┼───────────┤ │ split_into_blocks (RS) │ 1.51G cycles │ 716M cycles │ 2.1× │ ├───────────────────────────────┼───────────────┼───────────────┼───────────┤ │ generate_qr total │ 7.84G cycles │ 5.12G cycles │ 1.53× │ └───────────────────────────────┴───────────────┴───────────────┴───────────┘ Cumulative speedup from the original baseline: 15.06G → 5.12G (2.94×).
capacity_table and ec_table were association lists built by prepending, so version 1 sat at position 159 in each. Every get_capacity call scanned all 160 entries; get_ec_info did the same. find_version also allocated a throwaway config record on every iteration just to look up the capacity for that version. Replace both tables with flat int arrays indexed by (version - 1) * 4 + ecl_idx O(1) per lookup. get_ec_info reconstructs the ec_info record from five consecutive ints inside exclave_ (stack-allocated in the caller's frame, zero heap cost). find_version now avoids the throwaway make_local call on every iteration, and get_config computes ecl_idx once before the loop. Benchmark results (120 000 calls across 3 inputs × 4 ECLs × 10 000 iters): ┌──────────────────┬──────────────┬──────────────┬───────────┐ │ Phase │ Before │ After │ Speedup │ ├──────────────────┼──────────────┼──────────────┼───────────┤ │ encode │ 913M cycles │ 223M cycles │ 4.1× │ ├──────────────────┼──────────────┼──────────────┼───────────┤ │ generate_qr │ 5.12G cycles │ 3.74G cycles │ 1.37× │ └──────────────────┴──────────────┴──────────────┴───────────┘ Cumulative speedup from the original baseline: 15.06G → 3.74G (4.03×).
Three additions to the benchmarking suite, each investigating a different aspect of heap vs zero-alloc stack allocation: bench.ml — Landmark overhead isolation + GC mode investigation - Add a third benchmark path, generate_qr_stack (direct), which calls the [@zero_alloc] function directly with one outer Landmark enter/exit per call rather than the six inner pairs in the _bench variant. Measured result: the six inner Landmark calls cost ~0.18G cycles (5%) and ~162 bytes/call; the direct path shows exactly 0 major-heap bytes across 120 000 calls. - Add a GC-stat section that runs 1 000 iterations per version for inputs spanning v1–v21 (ECL L) and prints minor_gc, major_gc, minor_words, promoted_words, and major_direct (direct-to-major allocations that bypass the minor heap). Key finding: Qr.t buf and reserved cross the Max_young_wosize threshold (~256 words / 2 KB) somewhere between v6 (1 681 B per buffer, stays in minor heap) and v10 (3 249 B, goes directly to major heap). At v21, the heap path triggers 5× more major GC cycles than the stack path per 1 000 calls. bench_versions.ml — Mean runtime vs QR version (v1–v40) - Scans increasing alphanumeric string lengths to find the minimum input that forces each version 1–40 under ECL L. - Uses an adaptive iteration count (target 300 ms of wall time, min 300, max 100 000 iters) so small versions get ~30 000–60 000 iterations and large versions get ~600–1 000, giving consistent sub-1% noise across the full range. - Outputs CSV (version, input_chars, heap_ns, stack_ns, heap_iters, stack_iters) to stdout for plotting. - Finding: p50 heap ≈ p50 stack at every version; the mean overhead of Qr.make is only 1–5% and vanishes into noise above v15 as Reed-Solomon computation and matrix operations dominate. bench_dist.ml + time_ns.c — Per-call latency distribution (v1–v40) - time_ns.c wraps clock_gettime(CLOCK_MONOTONIC) as a [@@noalloc] OCaml external returning an unboxed int (nanoseconds). The noalloc attribute prevents GC safe-point insertion around the call, so no collection can slip between the timer read and the function under test. - bench_dist.ml collects 5 000 individual call latencies per path per version, sorts them, and outputs p50/p90/p95/p99/p99.9/max as CSV. - Finding: median latency is equal for both paths. The tail diverges sharply at v8–v25 (the range where Qr.t spills to the major heap): at v10, heap p99.9 = 223 µs vs stack p99.9 = 85 µs (2.6× worse). At v8 the heap max reaches 254 µs vs 95 µs for the stack path. The gap narrows at large versions (v30+) because the ~270–460 µs per-call time makes GC pauses a smaller fraction of total latency, and OS scheduler jitter dominates both paths equally.
bench_versions.ml was deleted in f21244c but its dune stanza was not, breaking the build.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
This branch does two things: it builds a Landmark-based benchmarking framework that makes the heap vs zero-alloc stack allocation paths measurable, and uses that framework to drive a sequence of performance optimisations. A final investigation pass then goes deeper on GC behaviour and latency distribution across all 40 QR versions.
Cumulative speedup from the original baseline: 15.06G → 3.74G cycles (4.03×) across a 120 000-call benchmark (3 inputs × 4 ECLs × 10 000 iterations).
Benchmarking infrastructure
Landmark profiling (
bench/bench.ml)A
bench/executable comparinggenerate_qr(heap path,Qr.makeeach call) againstgenerate_qr_stack(zero-alloc path, pre-allocatedQr.tin anArena). Each pipeline phase is wrapped with its own Landmark node so the profiler emits a full call-graph breakdown acrossencode → split_into_blocks → interleave_blocks → place_pattern_modules → place_format_info → place_data_and_apply_mask.Because
generate_qr_stackis[@zero_alloc], its sub-functions cannot share Landmark IDs with the heap path. Agenerate_qr_stack_benchvariant carries its ownstack/*-prefixed Landmark set for instrumented profiling, leaving the originalgenerate_qr_stackas the clean zero-alloc reference.A third benchmark path calls
generate_qr_stackdirectly with only one outer Landmark enter/exit, isolating Landmark overhead from allocation overhead:generate_qr(heap)generate_qr_stack(landmark-wrapped)generate_qr_stack(direct)The 197 bytes/call on the Landmark-wrapped stack path is entirely the six inner
enter/exitpairs. The direct path is genuinely zero-alloc.Performance optimisations
1. Precompute reserved-cell bitmap
Added a
reserved : bytesfield toQr.t, populated once bymark_reserved.place_dataandapply_mask_patternpreviously calledis_reservedon every cell, which walked alignment coordinate lists. They now do an O(1) bitmap lookup. Also fixes a latent bug whereapply_mask_patternpassed hardcodedversion=1tois_reserved, silently skipping alignment-pattern exclusion for QR codes version 2+.place_data: 7.15G → 2.45G cycles (2.9×)apply_mask_pattern: 3.65G → 1.04G cycles (3.5×)2. Rewrite
mark_reservedto stamp regionsThe old implementation iterated all
width²cells testing each viais_reserved → is_in_alignment_pattern(O(alignment_coords²) per cell). The rewrite stamps known regions directly — three 9×9 corner rectangles, two timing strips, each alignment 5×5 square — all O(reserved_cells).is_in_alignment_patternandis_reservedare removed entirely. Also replacesbit_pos/8andbit_pos%8inplace_datawith running refs (removing integer division from the inner loop) and(x+y) % 2with(x+y) land 1inapply_mask_pattern.place_pattern_modules: 6.68G → 761M cycles (8.8×)3. Combine
place_data+apply_maskinto one pass; optimise Reed-SolomonBoth functions performed an identical full-matrix zigzag scan. The combined
place_data_and_apply_maskdoes a single pass applying mask pattern 0 at write time, and bypasses theset_modulebounds checks since the scan guarantees valid coordinates.Reed-Solomon:
polynomial_mult(generator[j], coef)was recomputinglog_table[coef]on every inner-loop iteration. Caching it once eliminates that redundancy.log_table[generator[j]]is now precomputed into agenerator_log_polynomialstable at startup, removing one lookup per inner step and thepolynomial_multcall overhead entirely.place_data_and_apply_mask: 3.40G → 1.54G cycles (2.2×)split_into_blocks(RS): 1.51G → 716M cycles (2.1×)4. Replace assoc-list lookups with flat array indexing in Config
capacity_tableandec_tablewere association lists built by prepending, so version 1 sat at position 159. Both are now flat int arrays indexed by(version - 1) * 4 + ecl_idx— O(1) per lookup.get_ec_inforeconstructs its record insideexclave_(stack-allocated, zero heap cost).find_versionno longer allocates in its loop.encode: 913M → 223M cycles (4.1×)GC and latency investigation
Major-heap spill threshold (
bench/bench.ml— GC stat section)Runs 1 000 iterations per version for inputs spanning v1–v21, capturing
Gc.statdeltas forminor_gc,major_gc,minor_words,promoted_words, andmajor_direct.OCaml's
Max_young_wosizeis 256 words (2 048 bytes).Qr.tcarries twoBytes.makebuffers ofwidth²bytes:At v21, the heap path triggers 5× more major GC cycles per 1 000 calls than the stack path. The stack path shows constant
minor_gc=6, major_gc=3at every version.Mean runtime vs QR version (
bench/bench_versions.ml)Scans increasing alphanumeric string lengths to find the minimum input forcing each version 1–40 under ECL L. Uses an adaptive iteration count targeting 300 ms of wall time (min 300, max 100 000). Output: CSV with
version, input_chars, heap_ns, stack_ns, heap_iters, stack_iters.Finding: mean latency is essentially identical for both paths at every version. The heap overhead from
Qr.makeis 1–5% at small versions and disappears into noise above v15 as Reed-Solomon and O(width²) matrix operations dominate.Per-call latency distribution (
bench/bench_dist.ml+bench/time_ns.c)time_ns.cwrapsclock_gettime(CLOCK_MONOTONIC)as a[@@noalloc]OCaml external returning an unboxedint(nanoseconds).[@@noalloc]prevents GC safe-point insertion around the call, so no collection can slip between the timer read and the function under test.bench_dist.mlcollects 5 000 individual call latencies per path per version and outputsp50/p90/p95/p99/p99.9/maxas CSV.Tail latency diverges sharply at v8–v25 — the range where
Qr.tspills to the major heap and per-call time is still short enough for GC pauses to dominate the tail:Above v30, per-call time (~270–460 µs) is long enough that OS scheduler jitter dominates both paths equally. Remaining spikes on the stack path at p99.9/max are pure OS preemption — the stack path does zero allocation inside the timed call.
Running the benchmarks
taskset -c Npins the process to a single core to eliminate CPU-migration jitter. For cleaner tail-latency numbers, combine withsudo chrt -f 99or configureisolcpus=N nohz_full=Nin the kernel boot parameters.