Skip to content

perf(accumulate): parallel cell grouping - #13

Merged
derekallman merged 2 commits into
derekallman:mainfrom
Borda:perf/A3
Sep 25, 2026
Merged

derekallman merged 2 commits into
derekallman:mainfrom
Borda:perf/A3

Conversation

@Borda

@Borda Borda commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

accumulate() sorts each (category, area range) work item once instead of once per maxDets entry. Every precision, recall, and scores value is bit-identical to before.

Motivation

Profiling hotcoco inside RF-DETR's validation loop (OnePassCocoMeanAveragePrecision(backend="hotcoco"), 5,000 images × 300 detections = 1.5M detections, maxDets=[1, 10, 300]) showed that accumulate() is the single largest phase: 40–58% of CPU time, more than IoU computation and matching combined. Inside it, for every (category, area range) cell the code gathered all detection scores and 20 match/ignore flag rows, sorted them by score, and did that three times — once per maxDets entry — on what is essentially the same sequence.

pycocotools does the same three sorts (np.argsort(-dtScores, kind='mergesort') per maxDet), which is why the port inherited them. They are redundant: a stable sort of the concatenation truncated at the largest cap, filtered to detections whose rank inside their image is below the smaller cap, equals the stable sort of the smaller-cap concatenation. Filtering preserves relative order, and ties break on concatenation position, which the filter also preserves. So one gather and one sort per cell suffice, and each maxDets slot is a stable filter of that order.

Change

  • crates/hotcoco/src/detection/accumulate.rs: gather once at Params::max_det(), record each detection's rank inside its cell, sort once, derive each maxDets slot by rank_in_cell < max_det. Cells are truncated to the current cap, so lowering maxDets between evaluate() and accumulate() (pycocotools' accumulate(p) idiom) keeps working. The metric kernel in metrics::counts is untouched.
  • crates/hotcoco/tests/integration_test.rs: test_accumulate_shared_order_equals_per_cap_runs — 12 images × 3 categories, scores quantized to two decimals plus a hand-built cross-image TP/FP tie at the same score, asserts to_bits() equality of all four arrays for the [1, 10, 100] slices against fresh single-cap runs (which never take the filter branch), the [100, 1, 10] permutation, caps lowered after evaluate(), and slice_by halves against from-scratch subset evaluations. The test was checked to fail on two injected violations (<= in the filter, sort_unstable_by).
  • CHANGELOG.md: entry under Unreleased → 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, 3 repeats (last two shown). total = evaluate() + accumulate() + summarize().

RAYON_NUM_THREADS accumulate() before after Δ total before after Δ
1 1.459 / 1.467 s 1.120 / 1.105 s −24% 2.483 / 2.516 s 2.151 / 2.174 s −14%
2 0.798 / 0.781 s 0.602 / 0.605 s −24% 1.553 / 1.534 s 1.385 / 1.361 s −11%
16 0.209 / 0.223 s 0.168 / 0.163 s −22% 0.743 / 0.786 s 0.686 / 0.688 s −10%

Against RF-DETR's full compute() on the same workload (2.00 s at 2 threads, replayed in the RF-DETR venv): about −0.19 s, −10%. Peak RSS is unchanged; the per-cell rank vector is a transient of about 50 MB at 1.5M detections.

What the change removed per (category, area range): two of three gathers (scores plus 20 flag rows per cell) and two of three sorts. What remains, per profile: the precision/recall kernel (~0.56 s CPU) and cumulative TP/FP (~0.17 s), one gather (~0.15 s), one sort (~0.05 s), grouping (~0.07 s) — the kernel is the next target.

Correctness evidence

Bit-for-bit comparison of precision, recall, scores, and stats (as u64 views) plus a SHA-256 digest of evalImgs against the pre-change 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 so 99.7% of detections share a score with a detection in another image (cross-image ties are the only place a wrong tie-break would show): [1, 10, 300], [100, 1, 10], evaluate([1, 10, 100]) → accumulate([10]), → accumulate([50, 5])

compare(n_bootstrap=20) output is byte-identical; slice_by is exercised by the new test. cargo test -p hotcoco (151 integration tests), cargo clippy --all-targets -D warnings, and pytest scripts/test_parity.py crates/hotcoco-pyo3/tests (107 passed) are green.

Not run: just parity on COCO val2017 (data/ absent on this machine). The change does not alter the numbers the metric kernel receives — only how the sorted order is produced — so parity is expected to hold as before; please run it before release.

Not in this PR

  • Fusing the precision/recall kernel into a single pass (metrics::counts), the next largest item in accumulate().
  • 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.
  • Hoisting the sort into EvalGrouping::build (one sort per evaluator across bootstrap resamples) was prototyped and measured at ≈0% on compare(n_bootstrap=20); reverted.

Borda and others added 2 commits September 16, 2026 11:08
- `EvalGrouping::build` splits into `build` (picks a run length of a few chunks per rayon thread) and `build_chunked`, which walks the cells with `par_chunks`, keeps per-run buckets in cell order, and concatenates the runs bucket by bucket in run order, so every bucket keeps `eval_imgs` order
- category and image lookups are memoized on the previous cell's id (`last_cat`, `last_img`); image slots are assigned run-local and remapped to global slots through `remaps` once the runs are collected
- `area_rng_to_idx` HashMap replaced by `area_keys: Vec<[u64; 2]>` matched with `rposition`, keeping the last-wins bucket for a range listed twice
- `detection::accumulate::tests`: the old three-HashMap sequential walk kept as `reference_grouping`, checked against `build_chunked` at chunk lengths 1, 3, 7, 16, usize::MAX under reconfigured `cat_ids`, reordered and duplicated `area_ranges`, an out-of-scope range, and `use_cats = false`; slot consistency and `image_mask` agreement asserted
- CHANGELOG: entry under Unreleased → Changed with the wl300 numbers (grouping 69 → 32 / 18 / 6 ms, `accumulate()` −34% at 16 threads, bit-identical on ten configurations)

---
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `accumulate_arrays_are_independent_of_thread_count` in `integration_test.rs`: 12 images × 3 categories with two-decimal LCG scores and a hand-built cross-image TP/FP tie at 0.70, `max_dets = [1, 5, 100]`, evaluated and accumulated inside rayon pools of 1, 2, 3, 5, 8, and 16 threads
- asserts `to_bits()` equality of `precision`, `recall`, `scores`, and `ap_all_points`, and of every `slice_by` metric, against the 1-thread run; the existing `evaluation_is_independent_of_thread_count` compares only `stats`
- CHANGELOG: the parallel-grouping entry names the new test

---
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 cabd596 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/A3 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