perf(inference): widen uint8 predict() inputs on the accelerator - #1415
Open
JESUSROYETH wants to merge 1 commit into
Open
perf(inference): widen uint8 predict() inputs on the accelerator#1415JESUSROYETH wants to merge 1 commit into
JESUSROYETH wants to merge 1 commit into
Conversation
JESUSROYETH
requested review from
Borda,
SkalskiP,
isaacrob and
probicheaux
as code owners
August 31, 2026 17:59
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1415 +/- ##
=======================================
Coverage 86% 86%
=======================================
Files 114 114
Lines 14880 14890 +10
=======================================
+ Hits 12835 12845 +10
Misses 2045 2045 🚀 New features to boost your workflow:
|
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.
predict()converts a PIL or uint8 NumPy image to floating point on the host, and only after that it pins it and copies it to the model's device. The conversion is elementwise and shape-preserving, so it can run after the copy just as well. Doing it before means the bus carries four times the bytes it needs (one float32 per channel instead of one byte), and the host also pays for a full-image dtype conversion and a full-image in-place divide — work a GPU does for free. It also pins a staging buffer four times bigger than necessary. The cost scales with the input image, not with the model resolution, so a 4K frame pays sixteen times what a 540p one does, and the model still only ever sees 384x384.This is the host-to-device counterpart of #1388, which made the CUDA source-image path transfer bytes instead of floats in the device-to-host direction, six lines further down in the same loop.
Changes
_uint8_image_to_tensorinto its layout half (_uint8_image_to_chw_view, a zero-copyuint8CHW view) and its dtype half (_uint8_chw_to_float).predict()now takes the view, pins and transfers that, and widens on the far side.(C, H, W), soinvalid_shape, the deferredpending_checksordering, theh, w = img_tensor.shape[1:]unpack and the publicValueErrormessages (which printtuple(img.shape)) are all byte-for-byte the same as before.int255. This one matters: CUDA evaluatesTensor.div_(255)as a multiplication by the scalar's reciprocal, which rounds 1 ULP away from the host's division for just under half of all byte values (126 of 256) — 96,524 of 196,608 elements on an L4, 113,418 of 230,400 on an RTX 4060. With a tensor divisor both devices do IEEE-754 correctly-rounded division, so the pixels stay byte-for-byte identical totorchvision.transforms.functional.to_tensor's on every device this was verified on (CPU and CUDA, the latter on both an L4 and an RTX 4060 Laptop GPU). On CPU the two forms already agree — checked exhaustively over all 256 byte values againstfloat16/bfloat16/float32/float64— so a CPU model behaves exactly as it did. MPS and XLA were not exercised.F.to_tensorand their existing transfer.What actually moves
Exact per-call accounting for one
RFDETRNano.predict(), recorded by instrumentingTensor.to/Tensor.pin_memory. These are byte counts, not timings, so at the defaultfloat32they come out the same on either machine I ran this on:Performance
One real COCO image resized to each input size, 60 timed
predict()calls per run after 12 warm-up calls, three alternating baseline/candidate processes per configuration, on an L4 (g2-standard-8, torch 2.9.1+cu129). Medians of the three per-process medians, with the full spread of those medians in parentheses.The 640x640 and 2160x3840 Nano rows work as the control here: the model does identical work in both, and the gain goes from −6.5% to −46.4% only because the input pixel count changes. So the gain is coming from the transfer and the host conversion, not from anything downstream.
Percentages depend on the machine they were measured on, so here is a second one too — an RTX 4060 Laptop GPU on torch 2.13.0+cu130, same harness, Nano, PIL input, medians of the three per-process medians as above: −18.37% at 720x1280 (10.823 → 8.834 ms), −32.30% at 1080x1920 (15.085 → 10.212 ms), −61.19% at 2160x3840 (50.504 → 19.603 ms). The magnitudes are different but the direction is the same, and the byte counts above are identical on both machines.
Validation
predict(threshold=0.0),xyxy/confidence/class_idcompared withnp.array_equal, 300 detections per pair. 14 baseline/candidate pairs: the eight L4 configurations in the table, three earlier L4 runs on a synthetic image, and three on the RTX 4060. 0 mismatches.TestPredictUint8Conversion, red 4/4 on a pristine checkout of the same base commit: three withAttributeErrorbecause the helpers do not exist yet, andtest_uint8_images_cross_the_device_boundary_unwidenedwithassert [torch.float32] == [torch.uint8], which is exactly the property being fixed. All four pass on this branch.test_deferred_widening_is_bit_exact_on_real_cudais@pytest.mark.gpuand carries the weight, because the CPU exactness case cannot: on the host the scalar and tensor divisors already agree bit-for-bit, so that test passes either way. I ran the GPU one on an RTX 4060 Laptop GPU — it passes on this branch, and under a revert of the divisor back to the Python scalar it is the only one of the class's 23 tests that fails.test_one_divisor_serves_every_image_of_a_multi_image_callis the mirror of that: under a mutation that keeps the arithmetic exact but corrupts the shared divisor after use, it is the only one that fails.tests/export/test_onnx_notes.py::_export_tiny_model, a doctest that fails identically on an unmodified checkout of the same base commit, so it is not from this change.torch.cuda.max_memory_allocatedover the 60 timed calls) is +0.0005 MB on the candidate in all eight measured configurations, deterministic across all three repetitions: it's one 512-byte allocator block for the 0-dim divisor. That number is the overall session peak, and in every measured configuration it was dominated by the model's own resident memory (160–245 MB), not by preprocessing. Widening on the accelerator does briefly hold the transferreduint8tensor and its freshly allocated float version side by side before theuint8tensor's reference gets dropped, adding up to one input byte per channel/pixel to the momentary preprocessing peak. That extra is bounded by the input image's own size, it was not the larger term in any of the eight configurations here, and it was not exercised near an out-of-memory boundary. Host memory goes the other direction: the pinned staging buffer shrinks 4x, per the table above.mypy --strict,ruff formatanddocformatter._uint8_image_to_tensornow spy on_uint8_image_to_chw_view. Their assertions are unchanged: the fused path is still used for uint8, the PIL source array is still the array that gets converted, and a read-only NumPy input still keeps the caller's storage and itsnot writablewarning.Limits, to be straight about them
I did not re-run COCO mAP. The preprocessed batch that reaches the model is bit-identical and the public detections match exactly across all 14 compared pairs, but that is byte parity, not a separately measured mAP number. I'm happy to run it if you'd rather see it before merging .. I also did not test on MPS or XLA; the widening there follows the same path as CPU, where the scalar and tensor divisors already agree, but I have not run it on that hardware.