fix(linalg): make GPU scalar in-place ops correct on CUDA (#988)#992
Conversation
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
There was a problem hiding this comment.
Reviewed the diff and built the branch + ran it on a CUDA device (Tesla V100, CUDA 12.9) to check the GPU-only paths — note CI has no CUDA runner, so none of this code (incl. the 7 new GPU tests) is exercised by CI; correctness rests on local hardware runs.
Overall: solid, well-motivated fixes. Two issues below, both reproduced on the V100.
What's good
cudaSetDevice(Rt.device()) → Lt.device())is required now that the scalar stays host-resident (Rt.device()==cpu==-1would be an invalid device). ✅- Dropping the per-call H2D scalar copy (and the latent device-pointer-deref UB it removes). ✅
- Device-independent
real op= complexguard — throws early on both paths instead of corrupting the real buffer. ✅ - The non-contiguous scalar regression fix (route size-1 RHS through the
rconstkernel, throw loudly on genuine non-contiguoustensor⊗tensor) is the right call, and the comments are genuinely excellent.
⚠️ 1. Narrow-LHS scalar path silently breaks storage sharing on GPU (CPU/GPU divergence)
The narrow-LHS guard does Lt = promoted.astype(Lt.dtype()), which rebinds Lt to fresh storage, so an aliased handle stops tracking the update. The CPU path applies lhs[idx] op= rhs[idx] straight into the existing buffer and preserves sharing. Measured:
CPU shared_after=1 b[0]=2 # aliased handle sees a *= 2.0
GPU shared_after=0 b[0]=1 # aliased handle does NOT
(a = ones({4}, Float, dev); b = from_storage(a.storage()); a *= 2.0;)
This diverges from the "preserve storage sharing" invariant of the parent chain (#906/#980). The current tests miss it: GpuScalarInplaceKeepsStorageSharing uses a Double LHS (non-narrow) and GpuScalarInplacePreserveDtype uses a Float LHS but never checks aliasing. Suggest copying the truncated result back into Lt's existing storage in place rather than rebinding, plus a narrow+aliased test.
⚠️ 2. The narrow-LHS memory-corruption fix only covers host scalars, not a GPU-resident RHS
The guard is gated on rhs_is_host_scalar, but the real danger condition is Lt.dtype() > Rt.dtype() on GPU. The shared contiguous branch still does tmpo = Lt.clone() (narrow) while the kernel writes promoted width, so a same-shape mixed-dtype op corrupts — float_gpu *= double_gpu (RHS all 3.0):
no throw; a = 0 2.125 0 2.125 # expected 3 3 3 3 → out-of-bounds garbage
This is pre-existing (the clone() predates this PR — not a regression) and technically outside #988's scalar scope, but it's the exact corruption class the PR's point 4 describes, just via a non-host RHS. Fixing at the source — tmpo = Lt.astype(type_promote(Lt, Rt)) in the shared branch — would close all paths at once and let the special-case guard be dropped. At minimum worth tracking, since a *= b with mixed dtype on GPU silently corrupts.
Minor
iAdd(gpu_tensor, cpu_shape{1}_tensor)now silently succeeds (treated as a broadcast scalar) instead of erroring on device mismatch — intended for the wrapper, but a slight public-API broadening worth a doc note.- Heavy verbatim duplication of the ~14-line guard block across all four files (and the non-contig scalar kernel block between iMul/iDiv); a shared helper would reduce divergence risk.
- Base is the unmerged
fix/scalar-inplace-ops-preserve-storage— needs retargeting tomasterbefore merge (already noted in the description).
🤖 Reviewed with Claude Code; GPU findings reproduced on a Tesla V100 / CUDA 12.9 build of this branch.
Posted by Claude Code on behalf of @pcchen
8fdf81c to
dfe688b
Compare
Code ReviewLGTM on substance — and this PR materially matters for #980: it fixes a real UB that #980 introduces on CUDA. #980's Verified in the diff (all four i-ops)
Notes (non-blocking)
Posted by Claude Code on behalf of @pcchen |
Follow-up to #906/#980. Fixes the two GPU gaps that could not be addressed on a CUDA-less machine, plus two related memory-safety bugs found while validating on a CUDA device. - Regression: iMul/iDiv threw for a non-contiguous GPU LHS. Route a broadcast scalar through the kernel (the rconst path scales every physical element, so layout mappers are irrelevant). cuMul/cuDiv -- unlike cuAdd/cuSub -- ignore the mappers, so a genuine non-contiguous tensor*=tensor / tensor/=tensor now throws loudly instead of silently pairing mismatched elements. - Efficiency: the shape-{1} scalar wrapper now stays CPU-resident. For a length-1 RHS the GPU kernels read the scalar with a host-side dereference and pass it by value, so there is no per-call H2D copy (and the previous GPU-resident scalar made that host dereference UB). cudaSetDevice now targets Lt.device(), and the device check permits a host-resident length-1 scalar against a GPU LHS. - Safety: real op= complex on GPU reinterpreted the real output buffer as complex (out-of-bounds write). Guarded device-independently so it throws like the CPU path. - Safety: a LHS narrower than the promoted dtype (e.g. float_gpu *= 2.0) had the promoted-width result written into the narrower buffer (OOB; float += 1.0 yielded 0). Now computed in the promoted dtype and truncated back, matching CPU element-wise semantics. Adds tests/gpu Tensor.Gpu* covering the non-contiguous *=//= regression, host-scalar values, storage sharing, dtype preservation, real op= complex throwing, and the non-contiguous tensor-tensor guard (no underscores per #857). Verified on RTX 4070 Ti (CUDA 13): 7/7 new GPU tests, 8/8 CPU scalar tests, and the existing GPU iSub/iMul/iDiv all-types suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6f68993 to
4ac2837
Compare
Empty commit: the pull_request workflows last ran against the pre-merge #980 base branch (since deleted); this fires a fresh full run (BuildAndTest, DownstreamFindPackage, wheels) with base=master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #992 +/- ##
==========================================
+ Coverage 31.84% 32.95% +1.10%
==========================================
Files 230 230
Lines 33117 33109 -8
Branches 13846 13813 -33
==========================================
+ Hits 10547 10910 +363
+ Misses 15549 14832 -717
- Partials 7021 7367 +346
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 38 files with indirect coverage changes Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
pcchen
left a comment
There was a problem hiding this comment.
Approving. Verified across all four i-ops: the host-resident {1} wrapper removes the CUDA host-deref UB that #980 alone would have shipped (plus the per-call H2D copy), the rhs_is_host_scalar device-check exemption and cudaSetDevice(Lt.device()) fix are correct, the device-independent real ⊕= complex guard closes a GPU memory-corruption path, the narrow-LHS promote-compute-truncate-back detour eliminates the OOB write while matching CPU value semantics, and the non-contiguous scalar regression is fixed via the mapper-irrelevant rconst path (with genuine non-contiguous tensor⊗tensor mul/div now failing loudly instead of silently mispairing — honest follow-up noted for real kernels). Hardware-validated on RTX 4070 Ti (224 GPU tests); CPU-side CI fully green on master base including BuildAndTest on all platforms — the only red is advisory codecov/patch, structural for GPU-only lines with no GPU CI (#538/#583). The residual narrow-LHS rebind (views detach in that GPU case) is the pre-known #906-class limitation.
Posted by Claude Code on behalf of @pcchen
Fixes #988. Follow-up to #906 / #980 (based on
fix/scalar-inplace-ops-preserve-storageuntil it merges, then this should retargetmaster).Validated on a CUDA device (RTX 4070 Ti, CUDA 13) which the #980 review lacked.
What #988 asked for
*=//=(scalar), whileiAdd/iSubhandled it.{1}scalar wrapper did a per-call H2D copy.Changes (
src/linalg/i{Add,Sub,Mul,Div}.cpp,src/Tensor.cpp)Non-contiguous scalar regression.
iMul/iDivnow route a broadcast scalar on a non-contiguous GPU LHS through the kernel — therconstpath scales every physical element in place, so the layout mappers are irrelevant and the result is correct. BecausecuMul/cuDiv(unlikecuAdd/cuSub) do not consume the layout mappers, a genuine non-contiguoustensor*=tensor/tensor/=tensornow throws loudly instead of silently pairing mismatched elements.No per-call H2D copy.
_scalar_as_rank1_tensorkeeps the wrapper CPU-resident. For a length-1 RHS the GPU kernels read the scalar with a host-side dereference and pass it in by value, so no copy is needed; the device check permits a host-resident length-1 scalar against a GPU LHS, andcudaSetDevicetargetsLt.device(). (This also removes a latent UB: fix(Tensor): scalar in-place arithmetic mutates storage in place #980 moved the scalar to the GPU, making that_Rin[0]host dereference read a device pointer.)Safety —
real op= complexcorrupted memory. The GPU path reinterpreted the real output buffer as complex (out-of-bounds write). Added a device-independent guard so it throws like the CPU path.Safety — narrow LHS corrupted memory. A LHS narrower than the promoted dtype (e.g.
float_gpu *= 2.0) had the promoted-width result written into the narrower buffer (OOB;float += 1.0produced0). Now computed in the promoted dtype and truncated back, matching the CPU element-wise semantics.Tests (
tests/gpu/Tensor_test.cpp,Gpu…— no underscores per #857)Non-contiguous
*=//=regression, host-scalar contiguous values, storage sharing, dtype preservation,real op= complexthrows, and the non-contiguous tensor⊗tensor guard.Verification (RTX 4070 Ti, CUDA 13)
iSub/iMul/iDivall-types in-place case.Known limitation (follow-up)
General non-contiguous
tensor⊗tensormul/div on GPU still throws; the real fix is adding non-contiguous kernels tocuMul/cuDivlikecuAdd/cuSubhave. Left for a separate PR.🤖 Generated with Claude Code