Skip to content

perf(postprocess): pick selected rows without materialising repeated index tensors - #1268

Merged
Borda merged 3 commits into
roboflow:developfrom
JESUSROYETH:perf/postprocess-no-repeat-index
Aug 3, 2026
Merged

Borda merged 3 commits into
roboflow:developfrom
JESUSROYETH:perf/postprocess-no-repeat-index

Conversation

@JESUSROYETH

Copy link
Copy Markdown
Contributor

What does this PR do?

The three query selections in PostProcess build their gather index with repeat(), so the index tensor gets materialised at the full size of the data it selects. For masks this is an int64 tensor of shape [K, Hm, Wm] — at num_select=300 that is 21 MiB per image for rfdetr-seg-small (96×96 mask head) and 84 MiB for rfdetr-seg-2xlarge (192×192), allocated and filled on every image just to pick 300 rows.

index_select picks the same whole rows without building any index tensor, and expand on the box path replaces the materialised [B, K, 4] index with a stride-0 view. The output does not change — bit for bit (see below).

The ONNX benchmark keeps its own copy of the box selection (post_process in src/rfdetr/export/benchmark.py) with the same repeat(), so it gets the same one-line change.

Measured on CPU (torch 2.13, single thread, pinned to one core, median of interleaved A/B runs of the full PostProcess.forward):

path before after change
masks at head resolution, 96², K=300 8.79 ms 3.40 ms 2.6×
masks at head resolution, 192², K=300 79.99 ms 27.09 ms 3.0×
keypoints, 17 kpts, K=300 1.07 ms 0.95 ms 1.12×
boxes only, K=300 0.44 ms 0.41 ms 1.07×
masks upsampled to 1080p, K=300 3192.9 ms 3172.0 ms ~neutral

A few notes on where this matters:

  • The big win is the mask path when masks stay at head resolution (upsample_masks_to_image_size=False, the eval_masks_head_resolution cost lever), where the gather is most of the postprocess. That path runs with K=300 on every validation image.
  • On the normal predict path the upsample to image size dominates, so this is time-neutral there — what it removes is the 21–84 MiB index allocation per image before the resize.
  • With a score_threshold (perf(inference): skip upsampling masks the caller's threshold discards #1265) K is small and everything is already cheap, nothing changes there.
  • The two-stage query selection in src/rfdetr/models/transformer.py (L381, L389) builds its gather indices with the same repeat() pattern. I left it alone on purpose: it sits in the model forward under autograd, and its indices top out at [B, K, d_model] — a fraction of the mask case. Happy to cover it here or in a follow-up if you want it.

Related Issue(s): none, follow-up of #1265 on the same function.

Type of Change

  • Performance improvement (no behaviour change)

Testing

  • Bit-identical outputs: old vs new forward() compared over the three heads (masks / keypoints / boxes) on CPU and CUDA — 198 output tensors compared byte for byte, including duplicated top-k query indices, Q < num_select, a threshold that keeps zero rows, and both upsample_masks_to_image_size branches. All equal.
  • Three new tests pin the contract the change relies on: top-k can select the same query under two classes, and each occurrence must reproduce the source row verbatim (test_duplicate_query_selection_repeats_the_same_mask_rows, test_gather_keypoints_for_queries_repeats_duplicated_indices, test_post_process_repeats_boxes_for_duplicated_topk_queries for the benchmark copy). All pass before and after the change — they test the equivalence, not the implementation.
  • The benchmark copy was verified the same way: old vs new post_process bit-compared (int32 view) over 50 randomised shapes, including k clamped below and above Q*C. All equal.
  • tests/models plus tests/inference/test_trt_inference.py pass with the patch (643 passed, 5 skipped) and ruff check/ruff format are clean on the touched files .. let me know if you want numbers for any other case.

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84%. Comparing base (f50258b) to head (66f005d).
⚠️ Report is 1 commits behind head on develop.

❌ Your project check has failed because the head coverage (84%) is below the target coverage (95%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@           Coverage Diff            @@
##           develop   #1268    +/-   ##
========================================
+ Coverage       82%     84%    +1%     
========================================
  Files          108     108            
  Lines        13500   13500            
========================================
+ Hits         11121   11314   +193     
+ Misses        2379    2186   -193     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Ready to approve

The changes are localized, preserve semantics, and are backed by targeted tests covering the key equivalence contract (including duplicated query selections).

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR optimizes PostProcess query selection by avoiding materializing large repeated index tensors during gathers, reducing per-image allocations and improving CPU postprocessing latency (especially for native-resolution mask outputs).

Changes:

  • Replace repeat()-materialized gather indices with expand() (stride-0 view) for box selection.
  • Replace torch.gather(...repeat(...)) with index_select for per-query row selection in masks and keypoints.
  • Add regression tests ensuring duplicated/out-of-order query indices reproduce source rows verbatim across masks, keypoints, and the ONNX benchmark post_process copy.
File summaries
File Description
src/rfdetr/models/postprocess.py Eliminates large repeated index tensors in box/mask/keypoint selection (expand + index_select).
src/rfdetr/export/benchmark.py Mirrors the box selection optimization in the ONNX benchmark postprocess.
tests/models/test_postprocess.py Adds coverage for duplicated top-k query indices on the mask native-resolution path.
tests/models/test_postprocess_keypoints.py Adds coverage for duplicated/out-of-order query indices in keypoint gathering.
tests/inference/test_trt_inference.py Adds coverage that the benchmark post_process repeats boxes correctly for duplicated top-k queries.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Close test-coverage findings from the PR 1268 review. Top-k over flattened [Q, C] scores can pick the same query under two classes, so the box/mask/keypoint selection must copy the source row verbatim for every duplicated or out-of-order index; the shipped paths lacked that pin (only the TRT-benchmark box twin had one).

* test(postprocess): dupe/out-of-order box pin on the production _gather_and_scale_boxes path (finding: shipped box path untested, only the benchmark.py twin was)
* test(postprocess): mask dupe through the upsample=True branch, straddling _MASK_CHUNK (finding: mask dupe test covered only upsample=False)
* test(postprocess): keypoint dupe/out-of-order through the class-filtering decode path (finding: keypoint dupe coverage stopped at the raw gather helper)
* test(postprocess): CUDA-parity gpu-marked box equivalence (finding: CUDA parity never directly executed)

---------
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@Borda
Borda merged commit 4a81045 into roboflow:develop Aug 3, 2026
40 checks passed
Borda added a commit that referenced this pull request Aug 4, 2026
…index tensors (#1268)

* perf: pick selected rows without materialising repeated index tensors
* test: pin dupe-index selection paths
* test: dupe/out-of-order box pin on the production _gather_and_scale_boxes path (finding: shipped box path untested, only the benchmark.py twin was)
* test: mask dupe through the upsample=True branch, straddling _MASK_CHUNK (finding: mask dupe test covered only upsample=False)
* test: keypoint dupe/out-of-order through the class-filtering decode path (finding: keypoint dupe coverage stopped at the raw gather helper)
* test: CUDA-parity gpu-marked box equivalence (finding: CUDA parity never directly executed)

---------

Co-authored-by: Jesús Royeth <JESUSROYETH@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@Borda Borda added the enhancement New feature or request label Aug 13, 2026
@Borda Borda mentioned this pull request Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants