fix(Tensor): scalar in-place arithmetic mutates storage in place#980
Conversation
There was a problem hiding this comment.
Code Review
This pull request optimizes scalar in-place arithmetic operations on Tensors by wrapping scalars into rank-1 Tensors to reuse existing in-place kernels, resolving storage sharing issues. It also corrects operator names in mismatch error messages and adds comprehensive unit tests. The review feedback highlights a potential performance bottleneck in the helper function _scalar_as_rank1_tensor, where creating a temporary CPU tensor and transferring it to a GPU device for every scalar operation can introduce significant overhead.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| template <class T> | ||
| Tensor _scalar_as_rank1_tensor(const T &rc, const int device) { | ||
| Tensor s({1}, Type.cy_typeid(rc), Device.cpu); | ||
| s.storage().at<T>(0) = rc; | ||
| if (device != Device.cpu) s = s.to(device); | ||
| return s; | ||
| } |
There was a problem hiding this comment.
Creating a temporary Tensor on CPU and then calling .to(device) on it introduces a significant performance bottleneck, especially when device is a GPU. This involves allocating GPU memory (cudaMalloc) and performing a host-to-device copy for every single in-place scalar operation (e.g., a += 1.0;), which is extremely slow and defeats the purpose of in-place operations.
Consider optimizing this by checking if device is GPU and reusing a pre-allocated or cached scalar tensor, or by bypassing the tensor wrapper entirely for CPU/GPU scalar operations where possible.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #980 +/- ##
==========================================
- Coverage 31.85% 31.84% -0.01%
==========================================
Files 230 230
Lines 33107 33117 +10
Branches 13840 13846 +6
==========================================
Hits 10547 10547
- Misses 15532 15549 +17
+ Partials 7028 7021 -7
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 7 files with indirect coverage changes Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
| // arithmetic reuses the iAdd/iSub/iMul/iDiv kernels (which broadcast a | ||
| // length-1 RHS and mutate LHS storage in place). See #906. | ||
| template <class T> | ||
| Tensor _scalar_as_rank1_tensor(const T &rc, const int device) { |
There was a problem hiding this comment.
This can be moved to linalg to benefit the linalg functions. If the naming style in the current file is messy, the name of the new function should follow the coding style and avoid prefixing with _.
There was a problem hiding this comment.
Perhaps a good opportunity make scalar as rank 0 tensors? It would make operations such as trace much more consistent.
There was a problem hiding this comment.
That will make Scalar heavier than a simple std::variant and we may have to triage scalar in all operations consuming tensors
There was a problem hiding this comment.
No, I meant rank 0 tensor should exist and be "scalar + shared storage + device"; see comment #1016 (comment)
- Scalar is similar to to Python builtin number
- Rank-0 tensor is similar to numpy rank 0 ndarray
There was a problem hiding this comment.
Proposal: defer this to the outcome of #1016 rather than changing it here. Where the scalar-wrapper helper should live (and what it's named) is entangled with the "rank-0 tensor vs. shape-{1} convention" design question now tracked there — #1016's option B explicitly includes exactly the relocation you suggested (a shared linalg-level scalar_tensor() helper as the single implementation point, no _ prefix), while option A would make this helper obsolete altogether. Two stacked PRs also touch this same helper (#992 makes it CPU-resident to fix a GPU UB; #1015's typed-storage direction may reshape it again), so moving it now would just multiply churn.
If that's acceptable, could you re-review/dismiss the requested-changes gate so #980 + #992 (the CUDA use-after-free / UB fix stacked on this) can land? The rename+relocate then happens once, in its final form, per the #1016 decision.
Posted by Claude Code on behalf of @pcchen
There was a problem hiding this comment.
Good concrete argument — captured in #1016 (the design-question issue for rank-0 vs. the shape-{1} convention), where it weighs for option B/C (keep Scalar a lightweight variant, as #1011 builds) against option A (true rank-0 tensors). Let's keep the design debate there so it accumulates in one place; no change on this PR.
Posted by Claude Code on behalf of @pcchen
Code ReviewFixes the real #906 bug: Correctness — verified
Findings
On the three inline comments
Posted by Claude Code on behalf of @pcchen |
|
Warning Gemini encountered an error creating the issue-comment-reply. You can try again by commenting |
t += 1.0 previously replaced t's Storage with a freshly computed one,
silently detaching every view sharing that storage, while t += tensor
mutated in place. Scalar RHS now routes through the same
iAdd/iSub/iMul/iDiv kernels via a shape-{1} tensor.
Behavior changes: dtype is preserved (Float += 1.0 stays Float, as the
tensor-RHS path already behaved), integer LHS truncates fractional
scalars instead of promoting, and real ⊙= complex now throws instead
of silently promoting the tensor.
GPU note: the iAdd GPU path still rebinds on promotion; that remains
tracked under #906.
Co-Authored-By: Claude <noreply@anthropic.com>
…rors ApplyInplaceArithmeticOp reported 'Cannot perform real+=complex' for all four ops; *= , -= and /= now name themselves. These messages became user-visible for scalar RHS via the #906 fix (real tensor op= complex scalar now throws instead of silently promoting). Known GPU limitation (from the same review, needs a CUDA machine, not fixed here): iMul/iDiv GPU paths throw for non-contiguous LHS while iAdd/iSub handle mappers, so gpu_tensor.permute(...) *= scalar throws; and the shape-{1} scalar wrapper does a per-call H2D copy. Tracked as a follow-up under #906. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8fdf81c to
dfe688b
Compare
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>
pcchen
left a comment
There was a problem hiding this comment.
Approving. The fix is verified: all 48 scalar specializations (+4 Scalar overloads) now route through the in-place iAdd/iSub/iMul/iDiv kernels via a shape-{1} wrapper — the kernels explicitly special-case a length-1 RHS and mutate LHS storage in place, so storage-sharing views stay attached (the #906 bug), and the old full-N-element allocation per scalar op is gone. Dtype preservation, integer truncation, and the real ⊙= complex rejection are all pinned by red-state-verified tests, including through a genuine non-contiguous permute() view. CI is functionally green on the rebased head (only advisory codecov red).
Two coordination notes, both agreed in the threads:
- Land #992 immediately after this — on CUDA, this PR's
.to(device)wrapper is read host-side by the GPU kernels (UB); #992 (stacked on this branch, hardware-validated) fixes that plus the non-contiguous GPU scalar regression. The pair should merge back-to-back. - The helper relocation/rename requested in review is deferred to #1016 (the rank-0-vs-
{1}-convention design issue) rather than churned here — it's exactly option B's "single implementation point" helper there, and #992/#1015 touch the same code. The rename+move happens once, in its final form, per that decision.
Behavior changes (dtype no longer promotes; real ⊙= complex throws) are deliberate, numpy-consistent, and need a changelog entry alongside the other in-place-semantics rulings.
Posted by Claude Code on behalf of @pcchen
Dismissing per the deferral proposed in the review thread: the helper relocation/rename is folded into the #1016 design decision (it is option B's single-implementation-point helper; #992 and #1015 touch the same code), so it will be done once, in its final form, per that outcome rather than churned here. Unblocks the #980→#992 pair (#992 fixes the CUDA host-deref UB stacked on this branch). — dismissed by Claude Code on behalf of @pcchen
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>
…ue-division (#941) Reimplements the deferred half of #1015 on current master, which had since refactored the same six arithmetic files for rank-zero support (#1026), zero-extent (#1043), and type_promote (#984). - Convert CPU out-of-place AND in-place Add/Sub/Mul/Div/Cpr/Mod to typed std::visit over as_storage_variant() with storage_cast<TO> and typed *InternalImpl<TO,TL,TR> kernels; the dead legacy dispatch tables are removed. - True division (#941): `/` and `/=` yield a floating result (int/int -> Double) via make_floating_point_dtype; the rank-zero / broadcast output still routes through #1026's init_broadcast_binary_output (seeded with the floating dtype). - Mixed-dtype in-place TENSOR ops promote the LHS via storage_as_type_or_replace (no more truncation into the narrower LHS storage -- #941's core bug). - Weak-scalar (#980, per #1015's binding ruling): `tensor op= python-scalar` (a rank-0 RHS via scalar_as_rank0_tensor) PRESERVES the LHS dtype for Add/Sub/Mul and follows true-division for Div (Int64 /= 2.0 -> Double, Float /= 3.0 -> Float), keyed on Rt.rank()==0 in DispatchInplaceArithmeticCPU; complex-into-real still throws. - floordiv conforms to #1049 (scalar `ut // 2.0` kept, `ut1 // ut2` raises); #1015's original unbind-all is dropped. - normalize_() now promotes an integer block to Double under true division; its stale "stays Int32" comment is updated. Mod keeps an eager is_complex guard so a zero-extent complex Mod still throws (ZeroExtentLinalgTest). CPU only; GPU stays on the legacy dtype tables behind the unchanged public API (#1013 tracks the GPU conversion). Testing: full openblas-cpu (non-ASan) test_main 1784 passed / 11 skipped / 0 failed -- MixedDtypeArithmetic regressions, weak-scalar Tensor.ScalarInplace*, ZeroExtentLinalgTest, and DenseUniTensorTest true-division/normalize all green; clang-format v14 clean. Reimplements ff30d00, deferred from #1015 (#941). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ue-division (#941) Reimplements the deferred half of #1015 on current master, which had since refactored the same six arithmetic files for rank-zero support (#1026), zero-extent (#1043), and type_promote (#984). - Convert CPU out-of-place AND in-place Add/Sub/Mul/Div/Cpr/Mod to typed std::visit over as_storage_variant() with storage_cast<TO> and typed *InternalImpl<TO,TL,TR> kernels; the dead legacy dispatch tables are removed. - True division (#941): `/` and `/=` yield a floating result (int/int -> Double) via make_floating_point_dtype; the rank-zero / broadcast output still routes through #1026's init_broadcast_binary_output (seeded with the floating dtype). - Mixed-dtype in-place TENSOR ops promote the LHS via storage_as_type_or_replace (no more truncation into the narrower LHS storage -- #941's core bug). - Weak-scalar (#980, per #1015's binding ruling): `tensor op= python-scalar` (a rank-0 RHS via scalar_as_rank0_tensor) PRESERVES the LHS dtype for Add/Sub/Mul and follows true-division for Div (Int64 /= 2.0 -> Double, Float /= 3.0 -> Float), keyed on Rt.rank()==0 in DispatchInplaceArithmeticCPU; complex-into-real still throws. - floordiv conforms to #1049 (scalar `ut // 2.0` kept, `ut1 // ut2` raises); #1015's original unbind-all is dropped. - normalize_() now promotes an integer block to Double under true division; its stale "stays Int32" comment is updated. Mod keeps an eager is_complex guard so a zero-extent complex Mod still throws (ZeroExtentLinalgTest). CPU only; GPU stays on the legacy dtype tables behind the unchanged public API (#1013 tracks the GPU conversion). Testing: full openblas-cpu (non-ASan) test_main 1784 passed / 11 skipped / 0 failed -- MixedDtypeArithmetic regressions, weak-scalar Tensor.ScalarInplace*, ZeroExtentLinalgTest, and DenseUniTensorTest true-division/normalize all green; clang-format v14 clean. Reimplements ff30d00, deferred from #1015 (#941). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic (#941) Address PR #1056 review (Codex P2): DispatchInplaceArithmeticCPU inferred numpy weak-scalar semantics from `Rt.rank() == 0`, but a user-provided rank-0 tensor (e.g. cytnx.zeros([])) is also rank 0. That made `int_tensor += rank0_double_tensor` truncate into the narrower lhs, while the out-of-place op and a shape-[1] tensor RHS promote -- an inconsistency, since rank-0 tensors are first-class tensor values in Cytnx. Thread an explicit `rhs_is_weak_scalar` flag from the scalar in-place operators (which wrap the scalar via scalar_as_rank0_tensor) through iAdd/iSub/iMul/iDiv into the dispatcher, instead of guessing from rank. A python-scalar RHS preserves the lhs dtype (numpy weak scalar, #980); any genuine tensor RHS -- rank-0 included -- promotes (#941). The public iAdd/iSub/iMul/iDiv gain a defaulted `rhs_is_weak_scalar = false` param, so existing callers are unaffected. Adds a pytest pinning both the rank-0-tensor promotion (verified to fail on the pre-fix rank-based detection) and the contrasting python-scalar preservation. Also folds in the PR #1056 pass-by-value style fixes in this file (ApplyInplaceArithmeticOp element params, kernel len/rhs_is_scalar, uint64_t len local). NOTE (numerical behavior): a new observable mixed-dtype rule -- an in-place op against a rank-0 tensor operand now promotes on CPU. Flagging per the type-promotion guardrail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1013) Problem: the iAdd/iSub/iMul/iDiv in-place complex guard rejected EVERY real-LHS / complex-RHS combination. That wrongly blocked a genuine complex *tensor* RHS from promoting the real LHS to complex — behavior the out-of-place operator and the #1013 typed dispatch already support. Fix: narrow the guard to fire only for a complex python *weak scalar* (numpy weak-scalar semantics, #980/#1015 preserve the LHS dtype, so a complex scalar genuinely cannot be stored in a real tensor). A genuine complex tensor RHS now promotes Lt's storage to complex via the existing type_promote_t / storage_as_type_or_replace path. The guard stays device-independent: the GPU kernel's complex-into-real branch silently returns zero instead of throwing, so it relies on this host-side rejection. Testing: new CPU tests (InplaceRealOpComplexTensorPromotes, InplaceRealOpComplexWeakScalarRejected) with independent hand-computed complex expected values, incl. a rank-0 genuine-tensor RHS; new GPU test (gpu_inplace_real_op_complex_weak_scalar_rejected) plus real<-complex promotion coverage in the InplacePromote sweep and literal cases. Both new CPU tests confirmed to fail on the pre-fix guard. clang-format v14 clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
Part of #906.
t += 1.0(and-=,*=,/=with any scalar orcytnx::Scalar) replaced the tensor's Storage with a freshly computed one:Any view sharing that storage (from
permute(), contiguousreshape(),from_storage()) silently detached, whilet += tensormutated in place — the two paths disagreed on aliasing semantics.Fix
Scalar RHS now routes through the same
iAdd/iSub/iMul/iDivkernels via a shape-{1}wrapper tensor (the kernels already broadcast a length-1 RHS and mutate LHS storage in place). All 48 scalar specializations rewritten; theTensor/Tproxy/Sproxyspecializations are untouched.Deliberate behavior changes
Float tensor += 1.0staysFloat(previously promoted toDouble); integer LHS truncates fractional scalars — both matching the existing tensor-RHS in-place behavior and numpy's scalar rules.real_tensor ⊙= complex_scalarnow throws instead of silently promoting the tensor to complex.The GPU
iAddpath can still rebind on promotion; that remains tracked under #906.Testing
Seven new gtests: storage-sharing preserved for all four ops (including through a real non-contiguous
permute()view), dtype preservation, integer truncation pinned,real ⊙= complexthrows for all four ops, andcytnx::ScalarRHS. Red-state verified against the old code (a shared view read0after+=— update lost). Full suite passes at baseline.🤖 Generated with Claude Code