perf(accumulate): one-pass PR curve kernel - #12
Merged
Merged
Conversation
- `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>
Contributor
Author
|
@derekallman ^^ if you could have look 🦝 |
2 tasks
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
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.
Description
accumulate()computes each precision-recall curve in one pass over the ranked detections instead of four array round trips. Everyprecision,recall, andscoresvalue is bit-identical to before.Branch
perf/A2is cut frommain(1bfe6ad), independent ofperf/A1. The numbers below are this change alone againstmain.Motivation
Profiling hotcoco inside RF-DETR's validation loop (
OnePassCocoMeanAveragePrecision(backend="hotcoco"), 5,000 images × 300 detections = 1.5M detections,maxDets=[1, 10, 300]) putaccumulate()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. Fournd-long arrays written and read back, and a division per detection for recall and another for precision, wherendis 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 sametpand a largerfp, 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: newprecision_recall_curve_of_order_into(order, matched, ignored, num_gt, rec_thrs, scratch, out), the fused kernel. TP/FP classification moves into one privatetally()that bothcumulative_tp_fpand the new kernel call, so the crate still has exactly one owner of that decision. The threshold predicate is spelledrc.partial_cmp(&thr) != Some(Less)— the negation of the oldrc < thr, notrc >= thr— so unsorted, duplicated, andNaNrecall thresholds land where the old scan put them. Recall stays a freshtp / num_gtdivision at each change, never a reciprocal multiply.PrCurveScratchgains 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 keepscumulative_tp_fp+precision_recall_curve_intobecauseaverage_precision_all_pointsneeds the cumulative arrays.crates/hotcoco/src/metrics/counts.rs(tests):fused_curve_matches_cumulative_then_interpolate_bit_for_bitruns the old two-function path and the new kernel on the same input and assertsto_bits()equality offinal_recalland every(rec_thr_idx, precision, rank)tuple. Nine hand-built cases (empty order,num_gt = 0, single TP / FP / ignored, 19 of 20 against0.95and against0.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, oneNaN,[1.5]), plus seeded randomnd ∈ {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 theNaNgrid), a reciprocal multiply for recall,.minin 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),
iouThrs0.50:0.95, 101 recall thresholds,maxDets=[1, 10, 300]. Apple M4 Max, release build, baseline rebuilt frommainin the same session, 3 repeats (last two shown).total=evaluate()+accumulate()+summarize().RAYON_NUM_THREADSaccumulate()beforecompare(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, andstats(asu64views) plus a SHA-256 digest ofevalImgsagainst themainbuild, identical on all 10 configurations:maxDets=[1, 10, 300][1, 10, 300],[100, 1, 10](unsorted),[7],evaluate([1, 10, 100])→accumulate([10]), →accumulate([50, 5])[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), andjust fuzz(scripts/fuzz_parity.pyagainst pycocotools, 3 passed in 7 minutes) are green.Not run:
just parityon 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
perf/A1(accumulate()−22–24% on the same workload). The two branches edit the same threshold loop inaccumulate_impland 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.Nonegate with pycocotools'len(gt)==0 and len(dt)==0— affects onlyeval["scores"]at recall threshold 0 for non-"all" area ranges, unrelated to this change.