Skip to content

perf(accumulate): one-pass PR curve kernel - #12

Merged
derekallman merged 1 commit into
derekallman:mainfrom
Borda:perf/A2
Sep 25, 2026
Merged

derekallman merged 1 commit into
derekallman:mainfrom
Borda:perf/A2

Conversation

@Borda

@Borda Borda commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

accumulate() computes each precision-recall curve in one pass over the ranked detections instead of four array round trips. Every precision, recall, and scores value is bit-identical to before.

Branch perf/A2 is cut from main (1bfe6ad), independent of perf/A1. The numbers below are this change alone against main.

Motivation

Profiling hotcoco inside RF-DETR's validation loop (OnePassCocoMeanAveragePrecision(backend="hotcoco"), 5,000 images × 300 detections = 1.5M detections, maxDets=[1, 10, 300]) put accumulate() at 40–58% of CPU time, and inside it the precision-recall kernel plus the cumulative TP/FP pass at roughly half of that. The kernel was a direct port of pycocotools' accumulate: for each of the 10 IoU thresholds in each (category, area range, maxDets) cell, write a cumulative TP array and a cumulative FP array over all ranked detections, then write a recall array and a precision array from them, then walk the precision array right to left for the VOC envelope, then walk recall and precision again to sample the 101 recall thresholds. Four nd-long arrays written and read back, and a division per detection for recall and another for precision, where nd is around 19,000 per cell on this workload.

Almost all of that work is inert. Only a true positive moves recall, so a recall threshold is first met either at rank 0 or at a true-positive rank. Only a true positive raises precision: at a false-positive rank precision is tp / (tp + fp) with the same tp and a larger fp, so it is below the precision at the true positive before it, and at an ignored rank it is unchanged. The right-to-left envelope over every rank is therefore equal to the envelope over the true-positive ranks alone. So one forward pass with integer counters, a precision division only at true-positive ranks (36K ground truths bound the count, against 1.5M detections), and the threshold scan turned inside out to run over ranks produce the same curve with no intermediate arrays.

Change

  • crates/hotcoco/src/metrics/counts.rs: new precision_recall_curve_of_order_into(order, matched, ignored, num_gt, rec_thrs, scratch, out), the fused kernel. TP/FP classification moves into one private tally() that both cumulative_tp_fp and the new kernel call, so the crate still has exactly one owner of that decision. The threshold predicate is spelled rc.partial_cmp(&thr) != Some(Less) — the negation of the old rc < thr, not rc >= thr — so unsorted, duplicated, and NaN recall thresholds land where the old scan put them. Recall stays a fresh tp / num_gt division at each change, never a reciprocal multiply. PrCurveScratch gains a per-point index buffer.
  • crates/hotcoco/src/detection/accumulate.rs: the per-threshold loop calls the fused kernel for every mode except Open Images, which keeps cumulative_tp_fp + precision_recall_curve_into because average_precision_all_points needs the cumulative arrays.
  • crates/hotcoco/src/metrics/counts.rs (tests): fused_curve_matches_cumulative_then_interpolate_bit_for_bit runs the old two-function path and the new kernel on the same input and asserts to_bits() equality of final_recall and every (rec_thr_idx, precision, rank) tuple. Nine hand-built cases (empty order, num_gt = 0, single TP / FP / ignored, 19 of 20 against 0.95 and against 0.9500000000000001, ignored at both ends, all ignored, reversed order) × six threshold grids (the default 101-point grid, empty, [0.95, 0.9500000000000001, 0.95], unsorted with duplicates, one NaN, [1.5]), plus seeded random nd ∈ {0, 1, 2, 17, 1000} × num_gt ∈ {0, 1, 7, 20, 1000} × 8 trials at ignore rates 0 / 0.1 / 0.6 with shuffled orders. The test was checked to fail on four injected violations: rc >= thr (caught by the NaN grid), a reciprocal multiply for recall, .min in the envelope, and skipping ignored ranks.
  • CHANGELOG.md: entries under Unreleased → Added and Changed.

Measured impact

RF-DETR-shaped synthetic workload: 5,000 images, 80 categories, 300 detections per image (1.5M total), iouThrs 0.50:0.95, 101 recall thresholds, maxDets=[1, 10, 300]. Apple M4 Max, release build, baseline rebuilt from main in the same session, 3 repeats (last two shown). total = evaluate() + accumulate() + summarize().

RAYON_NUM_THREADS accumulate() before after Δ total before after Δ
1 1.484 / 1.495 s 0.914 / 0.953 s −37% 2.544 / 2.572 s 1.894 / 1.919 s −25%
2 0.808 / 0.791 s 0.524 / 0.531 s −34% 1.591 / 1.533 s 1.281 / 1.278 s −18%
16 0.208 / 0.283 s 0.148 / 0.148 s −29% (vs the 0.208 rep) 0.746 / 0.849 s 0.656 / 0.644 s −12%

compare(n_bootstrap=20) on a 100K-detection workload at 2 threads — one accumulation per resample, 42 in total — goes from 1.452 / 1.442 s to 0.917 / 0.916 s (−37%). Peak RSS is unchanged (3,065 → 3,092 MB in the harness column).

A first version that still computed precision at every rank, with one nd-long buffer, measured only −4 to −7%; the gain comes from confining the divisions and the buffer to true-positive ranks.

Correctness evidence

Bit-for-bit comparison of precision, recall, scores, and stats (as u64 views) plus a SHA-256 digest of evalImgs against the main build, identical on all 10 configurations:

  • 1.5M-detection workload, maxDets=[1, 10, 300]
  • 100K-detection workload: [1, 10, 300], [100, 1, 10] (unsorted), [7], evaluate([1, 10, 100]) → accumulate([10]), → accumulate([50, 5])
  • 100K-detection workload with scores rounded to two decimals (99.7% of detections share a score with a detection in another image): [1, 10, 300], [100, 1, 10], evaluate([1, 10, 100]) → accumulate([10]), → accumulate([50, 5])

compare(n_bootstrap=20) reports the same AP delta to the last digit. cargo test -p hotcoco (176 unit + 150 integration tests), cargo clippy --all-targets -D warnings, cargo doc, pytest scripts/test_parity.py crates/hotcoco-pyo3/tests (107 passed), and just fuzz (scripts/fuzz_parity.py against pycocotools, 3 passed in 7 minutes) are green.

Not run: just parity on COCO val2017 (data/ absent on this machine). The change alters only how the curve is produced from the same ranked flags, not which flags or which order it reads, so parity is expected to hold as before; please run it before release.

Not in this PR

  • The per-cell sort-once change on perf/A1 (accumulate() −22–24% on the same workload). The two branches edit the same threshold loop in accumulate_impl and the same [Unreleased] section of the CHANGELOG, so whichever merges second needs a conflict resolution there; their combined effect has not been measured.
  • average_precision_of_order_into (the TIDE and diagnostics AP path) still uses the two-function kernel. It takes the same inputs the fused kernel does and is a follow-up candidate.
  • Aligning the per-cell None gate with pycocotools' len(gt)==0 and len(dt)==0 — affects only eval["scores"] at recall threshold 0 for non-"all" area ranges, unrelated to this change.

- `metrics::counts::precision_recall_curve_of_order_into` computes the interpolated precision-recall curve from `order`/`matched`/`ignored` with integer counters: precision only at true-positive ranks, thresholds recorded at rank 0 and true-positive ranks with `partial_cmp(..) != Some(Less)`, then one right-to-left envelope over the true-positive precisions; `PrCurveScratch` gains `env_idx` for the per-point lookup
- `accumulate_impl` calls it for every mode except Open Images, which keeps `cumulative_tp_fp` + `precision_recall_curve_into` for `average_precision_all_points`
- TP/FP classification moves into one private `tally()` returning `Tally::{TruePositive, FalsePositive, Ignored}`, used by both `cumulative_tp_fp` and the new kernel; docs on `cumulative_tp_fp`, `precision_recall_curve_into`, and `PrCurveScratch` follow
- `fused_curve_matches_cumulative_then_interpolate_bit_for_bit` asserts `to_bits()` equality of `final_recall` and every `(r_idx, precision, rank)` against the two-function path: hand cases (19-of-20 vs `0.95`/`0.9500000000000001`, ignored at both ends, all ignored, reversed order, `num_gt = 0`, empty) times six grids incl. unsorted, duplicated, `NaN`, empty, and seeded random `nd`/`num_gt`/ignore rates
- CHANGELOG: 34–37% faster `accumulate()`, 37% faster `compare()`, 18–25% end-to-end on a 1.5M-detection workload, arrays bit-identical
---
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Borda

Borda commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

@derekallman ^^ if you could have look 🦝

@derekallman
derekallman merged commit 878728a into derekallman:main Sep 25, 2026
6 checks passed
derekallman added a commit that referenced this pull request Sep 25, 2026
… perf PRs

Post-merge cleanup of #11, #12, #13, and #15. No behavior change: every
Rust suite, the fast pytest suite, and real-data parity on val2017 pass
bit-identically before and after.

- Extract one `tie_heavy_datasets()` builder and one `Lcg` into the
  integration-test helpers; both accumulate tests call it instead of
  carrying their own copy, and it now matches the seed and cell size of
  `_tie_heavy_dataset` in scripts/test_parity.py, with cross-references
  in both directions.
- Borrow the shared score order for the unfiltered `maxDets` slot instead
  of copying it once per work item.
- Drop the dead `nd` counter and early return from
  `precision_recall_curve_of_order_into`; the fall-through already returns
  the same value. Remove unused derives on `Tally`.
- Replace comments that narrated the pre-merge code or a rejected
  experiment with the constraint they were standing in for, and fix a
  comment that wrongly claimed `rand` is not a dependency.
- Test nits: compare slice metrics straight from the `BTreeMap`, turn a
  five-parameter closure into a nested fn, hoist a loop-invariant assert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uhTHqxEPRhN3cEZoSaaU
@Borda
Borda deleted the perf/A2 branch September 25, 2026 07:41
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