perf(accumulate): parallel cell grouping - #13
Merged
Merged
Conversation
- `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>
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()sorts each (category, area range) work item once instead of once permaxDetsentry. Everyprecision,recall, andscoresvalue 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 thataccumulate()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 permaxDetsentry — on what is essentially the same sequence.pycocotools does the same three sorts (
np.argsort(-dtScores, kind='mergesort')permaxDet), 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 eachmaxDetsslot is a stable filter of that order.Change
crates/hotcoco/src/detection/accumulate.rs: gather once atParams::max_det(), record each detection's rank inside its cell, sort once, derive eachmaxDetsslot byrank_in_cell < max_det. Cells are truncated to the current cap, so loweringmaxDetsbetweenevaluate()andaccumulate()(pycocotools'accumulate(p)idiom) keeps working. The metric kernel inmetrics::countsis 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, assertsto_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 afterevaluate(), andslice_byhalves 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),
iouThrs0.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_THREADSaccumulate()beforeAgainst 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, andstats(asu64views) plus a SHA-256 digest ofevalImgsagainst the pre-change build, 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)output is byte-identical;slice_byis exercised by the new test.cargo test -p hotcoco(151 integration tests),cargo clippy --all-targets -D warnings, andpytest scripts/test_parity.py crates/hotcoco-pyo3/tests(107 passed) are green.Not run:
just parityon 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
metrics::counts), the next largest item inaccumulate().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.EvalGrouping::build(one sort per evaluator across bootstrap resamples) was prototyped and measured at ≈0% oncompare(n_bootstrap=20); reverted.