Skip to content

fix(linalg): replace lower-enum-index dtype selection with Type.type_promote#984

Merged
pcchen merged 4 commits into
masterfrom
claude/amazing-lederberg-7cf653
Jul 8, 2026
Merged

fix(linalg): replace lower-enum-index dtype selection with Type.type_promote#984
pcchen merged 4 commits into
masterfrom
claude/amazing-lederberg-7cf653

Conversation

@yingjerkao

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #982: that PR fixed Type_class::type_promote (mixed complex/real pairs now promote by precision, e.g. ComplexFloat x Double -> ComplexDouble) and converted Vectordot. This PR converts the remaining nine linalg entry points that still hand-rolled "pick the lower enum index" dtype selection and therefore diverged from Add/Sub/Mul/Div and Vectordot:

Dot (Mat-Vec path), Matmul, Matmul_dg, Tensordot (cuTensor path), Axpy/Axpy_, Gemm/Gemm_, Gemm_Batch, Ger, and Mod.

All sites now follow the Vectordot reference pattern: promote via Type.type_promote, astype both operands (the promoted dtype can differ from both inputs; astype is a no-op when the dtype already matches), allocate the output at the promoted dtype, and dispatch the kernel by it.

Latent bugs fixed along the way

  • Axpy/Ger floored the dtype with an inverted comparison (fin_dtype < Type.Float instead of >): complex inputs were clamped onto the Double kernel (all-ComplexDouble Axpy threw "Cannot cast complex128 to real"), and integer inputs indexed past the size-5 kernel tables — a reproducible segfault. Integer/bool now floors to Double after promotion.
  • Gemm (out-of-place) was missing the integer→Double floor that Gemm_ has; integer inputs segfaulted the same way.
  • Ger built its implicit alpha = 1 scalar before the floor could change fin_dtype; it is now created at the final dtype.
  • Mod's buffer and kernel disagreed: ModInternalImpl already computes in Type_class::type_promote_t<TL,TR>, but the output buffer was allocated with the old min-index rule, so e.g. Uint64 % Int32 wrote Int64 data into a Uint64-labeled buffer. All buffer-dtype sites (Tensor-Tensor, Scalar, and the typed-scalar specializations) now use Type.type_promote.

Tests

  • New tests/linalg_test/DtypePromotion_test.cpp (20 tests) covering the discriminating pairs (ComplexFloat x Double -> ComplexDouble, Uint64 x Int32 -> Int64) and the integer floors. All were observed failing before the fix: 16 assertion failures plus 3 segfaults. The Gemm_Batch case is #ifdef UNI_MKL-guarded like the existing suite; without MKL it still pins the promotion via the pre-dispatch cast of c plus the expected throw.
  • Full suite on macOS/OpenBLAS: 1087 passed / 11 skipped / 4 failed — identical to baseline (the 4 are the known pre-existing BkUt_*Svd_truncate_return_err* failures addressed by fix(linalg): report only actually-dropped values in block SVD return_err #977). No existing test asserted the old lower-index dtype, so none needed updating.

Dependencies

Stacked on #982 (fix/type-promotion-cross-kind) — only the last commit (efa641fb) is new here. Merge #982 first; this PR retargets to master automatically when that branch is deleted on merge.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request refactors several linear algebra operations (including Axpy, Dot, Gemm, Ger, Matmul, Mod, and Tensordot) to use proper dtype promotion via Type.type_promote instead of hand-rolled selection logic. It also ensures integer inputs to BLAS-only operations floor to Double and adds a comprehensive test suite for mixed-dtype promotion. The review feedback suggests optimizing Dot, Matmul_dg, and Mod by passing false to out.Init to skip redundant zero-initialization, since the output tensors are fully overwritten by their respective kernels.

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.

Comment thread src/linalg/Dot.cpp Outdated
} else {
out.Init(newshape, Tr.dtype(), Tr.device());
}
out.Init(newshape, out_dtype, Tl.device());

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.

medium

Since the Matvec_ii and cuMatvec_ii kernels fully overwrite the output tensor, we can skip the redundant zero-initialization by passing false as the fourth argument to out.Init. This matches the optimization pattern used in Matmul and Tensordot.

Suggested change
out.Init(newshape, out_dtype, Tl.device());
out.Init(newshape, out_dtype, Tl.device(), false);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied in d711fbf. Verified the Matvec path fully overwrites out before adopting it: the integer kernel self-zeros each element (out[n] = 0; out[n] += ...) and the BLAS/cuBLAS gemv variants use beta = 0, so out is written without being read. DtypePromotion.Dot_matvec + the Dot suites pass under ASAN (which fills fresh allocations with 0xbe, so this isn't relying on coincidentally-zero memory).

Comment thread src/linalg/Matmul_dg.cpp
} else {
out.Init({Tl.shape()[0], Tr.shape().back()}, Tr.dtype(), Tr.device());
}
out.Init({Tl.shape()[0], Tr.shape().back()}, out_dtype, Tl.device());

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.

medium

Since the Matmul_dg_ii and cuMatmul_dg_ii kernels fully overwrite the output tensor, we can skip the redundant zero-initialization by passing false as the fourth argument to out.Init. This matches the optimization pattern used in Matmul and Tensordot.

Suggested change
out.Init({Tl.shape()[0], Tr.shape().back()}, out_dtype, Tl.device());
out.Init({Tl.shape()[0], Tr.shape().back()}, out_dtype, Tl.device(), false);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not applying this one — it would corrupt results. Unlike Matmul (gemm, beta=0) and Tensordot, the CPU Matmul_dg diag_L path accumulates into out via axpy (_out[i*Nr] += _inl[i]*_inr[i*Nr], in Matmul_dg_internal.cpp), so it reads the existing out values and therefore requires the zero-initialization. (Only the diag_R driver and the GPU kernel overwrite directly.) Since one out.Init feeds both paths, dropping the zero-fill here would leave the diag_L BLAS result adding onto uninitialized memory. Leaving init_zero=true; confirmed DtypePromotion.Matmul_dg_complexfloat_double still passes.

Comment thread src/linalg/Mod.cpp
Comment on lines 18 to 28
if (Lt.shape().size() == 1 && Lt.shape()[0] == 1) {
out.Init(Rt.shape(), Lt.dtype() < Rt.dtype() ? Lt.dtype() : Rt.dtype(), Lt.device());
out.Init(Rt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device());
icnst = true;
} else if (Rt.shape().size() == 1 && Rt.shape()[0] == 1) {
out.Init(Lt.shape(), Lt.dtype() < Rt.dtype() ? Lt.dtype() : Rt.dtype(), Lt.device());
out.Init(Lt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device());
icnst = true;
} else {
cytnx_error_msg(Lt.shape() != Rt.shape(),
"[Mod] The two tensors do not have the same shape.%s", "\n");
out.Init(Lt.shape(), Lt.dtype() < Rt.dtype() ? Lt.dtype() : Rt.dtype(), Lt.device());
out.Init(Lt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device());
}

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.

medium

Since the element-wise modulo kernels fully overwrite the output tensor, we can skip the redundant zero-initialization by passing false as the fourth argument to out.Init. This matches the optimization pattern used in Matmul and Tensordot.

Suggested change
if (Lt.shape().size() == 1 && Lt.shape()[0] == 1) {
out.Init(Rt.shape(), Lt.dtype() < Rt.dtype() ? Lt.dtype() : Rt.dtype(), Lt.device());
out.Init(Rt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device());
icnst = true;
} else if (Rt.shape().size() == 1 && Rt.shape()[0] == 1) {
out.Init(Lt.shape(), Lt.dtype() < Rt.dtype() ? Lt.dtype() : Rt.dtype(), Lt.device());
out.Init(Lt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device());
icnst = true;
} else {
cytnx_error_msg(Lt.shape() != Rt.shape(),
"[Mod] The two tensors do not have the same shape.%s", "\n");
out.Init(Lt.shape(), Lt.dtype() < Rt.dtype() ? Lt.dtype() : Rt.dtype(), Lt.device());
out.Init(Lt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device());
}
if (Lt.shape().size() == 1 && Lt.shape()[0] == 1) {
out.Init(Rt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device(), false);
icnst = true;
} else if (Rt.shape().size() == 1 && Rt.shape()[0] == 1) {
out.Init(Lt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device(), false);
icnst = true;
} else {
cytnx_error_msg(Lt.shape() != Rt.shape(),
"[Mod] The two tensors do not have the same shape.%s", "\n");
out.Init(Lt.shape(), Type.type_promote(Lt.dtype(), Rt.dtype()), Lt.device(), false);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied in d711fbf (all three Init calls). ModInternalImpl (contiguous and non-contiguous) and cuMod assign every output element (_out[i] = ...), so out is fully overwritten. All DtypePromotion Mod cases pass under ASAN.

@ianmccul

ianmccul commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Ok, this is the follow-up that makes the type_promote change much safer for the old dtype-indexed linalg backends.

The key pattern here looks right to me: compute the promoted dtype, cast both operands to that dtype, allocate output at that dtype, then dispatch the old single-dtype kernel table. That avoids the dangerous case where only the output dtype changes while the backend still reinterprets input/output buffers according to the old enum-order rule.

I would still be careful about the claimed scope. This appears to cover the listed Tensor linalg entry points, but not all arithmetic paths. In particular, general Tensor in-place arithmetic (iAdd/iSub/iMul/iDiv) and UniTensor elementwise arithmetic still look like separate paths with old “choose a buffer, then mutate it” semantics. For example, linalg::Add(UniTensor, UniTensor) still appears to choose which operand to clone by enum ordering and then calls Add_ in-place.

I think the only safe way to do this change is to implement #941, followed by making dtype an opaque type (detect and fix every instance where dtype is computed by hand), and only then it might be safe to change type_promote.

Python code tends to do arithmetc on dtype as well, so the corresponding change to make dtype opaque in python (or switch to the numpy dtypes) should probably be done at the same time; see ianmccul/Cytnx-fixes#3

Base automatically changed from fix/type-promotion-cross-kind to master July 6, 2026 01:34
@ianmccul

ianmccul commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

There are still float/complex promotion bugs at the tip of PR #984, even if we ignore integer arithmetic completely.

The clearest one is Outer.

Tensor out(new_shape, Type.type_promote(Tl.dtype(), Tr.dtype()), Tl.device());
lii.Outer_ii[Tl.dtype()][Tr.dtype()](out.storage, Tl.storage, Tr.storage, ...);

For ComplexFloat x Double, the front-end now allocates a ComplexDouble output, but the generated CPU kernel still dispatches to:

Outer_general<cytnx_complex64, cytnx_complex64, cytnx_double>(out, Lin, Rin, j1, j2);

So the output storage has ComplexDouble layout, but the kernel writes ComplexFloat values into it. That is a direct buffer-layout mismatch, not just a precision issue. The same dispatch pattern exists on GPU.

There are several other non-integer promotion paths still using ordinal dtype comparison rather than Type.type_promote:

  • Lstsq.cpp still chooses A.dtype() < b.dtype() ? A.dtype() : b.dtype(), so ComplexFloat mixed with Double is cast to ComplexFloat, not ComplexDouble.
  • Concatenate.cpp, Hstack.cpp, Vstack.cpp, and Directsum.cpp still use lower-enum dtype selection. Again, ComplexFloat + Double becomes ComplexFloat.
  • Scalar binary arithmetic still uses enum order, for example this->dtype() < rhs.dtype(), so Scalar(ComplexFloat) + Scalar(Double) keeps/promotes to ComplexFloat.
  • The UniTensor out-of-place arithmetic wrappers in Add.cpp, Sub.cpp, Mul.cpp, and Div.cpp still choose the output dtype by enum order before calling the in-place implementation.
  • The existing Exp / Expf issue is still present: the output dtype is changed, then the kernel dispatch is selected from the output dtype while the input storage still has the original dtype. For example, Exp(Float) reads a float* buffer as double*, and Exp(ComplexFloat) reads complex<float>* as complex<double>*.

they were found with an AI search, there are probably others.

@yingjerkao yingjerkao closed this Jul 6, 2026
@yingjerkao yingjerkao reopened this Jul 6, 2026
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.33333% with 112 lines in your changes missing coverage. Please review.
✅ Project coverage is 32.63%. Comparing base (d6dcd16) to head (d711fbf).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/linalg/Mod.cpp 25.53% 2 Missing and 33 partials ⚠️
src/linalg/Sub.cpp 10.00% 3 Missing and 6 partials ⚠️
src/linalg/Div.cpp 20.00% 0 Missing and 8 partials ⚠️
src/linalg/Gemm.cpp 58.82% 0 Missing and 7 partials ⚠️
src/linalg/Add.cpp 37.50% 0 Missing and 5 partials ⚠️
src/linalg/Axpy.cpp 58.33% 0 Missing and 5 partials ⚠️
src/linalg/Mul.cpp 37.50% 0 Missing and 5 partials ⚠️
src/linalg/Outer.cpp 16.66% 0 Missing and 5 partials ⚠️
src/linalg/Directsum.cpp 0.00% 0 Missing and 4 partials ⚠️
src/linalg/Dot.cpp 20.00% 0 Missing and 4 partials ⚠️
... and 9 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #984      +/-   ##
==========================================
+ Coverage   31.82%   32.63%   +0.80%     
==========================================
  Files         230      230              
  Lines       33124    33021     -103     
  Branches    13852    13758      -94     
==========================================
+ Hits        10543    10775     +232     
+ Misses      15539    14972     -567     
- Partials     7042     7274     +232     
Flag Coverage Δ
cpp 32.27% <33.33%> (+0.81%) ⬆️
python 61.84% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
C++ backend 33.48% <33.33%> (+0.93%) ⬆️
Python bindings 24.02% <ø> (ø)
Python package 61.84% <ø> (ø)
Files with missing lines Coverage Δ
include/backend/Scalar.hpp 22.05% <100.00%> (-0.18%) ⬇️
src/linalg/Gemm_Batch.cpp 49.46% <100.00%> (+4.30%) ⬆️
src/linalg/Tensordot.cpp 29.09% <ø> (ø)
src/algo/Hstack.cpp 50.00% <33.33%> (+1.21%) ⬆️
src/algo/Vstack.cpp 43.58% <33.33%> (+1.08%) ⬆️
src/linalg/Exp.cpp 17.64% <0.00%> (-5.89%) ⬇️
src/linalg/Expf.cpp 0.00% <0.00%> (ø)
src/linalg/Lstsq.cpp 15.55% <0.00%> (+15.55%) ⬆️
src/algo/Concatenate.cpp 14.28% <0.00%> (-5.72%) ⬇️
src/linalg/Directsum.cpp 48.38% <0.00%> (+2.93%) ⬆️
... and 12 more

... and 6 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update d6dcd16...d711fbf. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@yingjerkao
yingjerkao force-pushed the claude/amazing-lederberg-7cf653 branch from efa641f to f56ecea Compare July 6, 2026 12:15
yingjerkao added a commit that referenced this pull request Jul 6, 2026
…pe sites (#984 review)

Follow-up to the review of #984: #982 changed Type.type_promote to promote
across the real/complex boundary, but several entry points still hand-rolled
the "pick the lower enum index" rule and so disagree with the new promotion
(and, for Outer/Exp, write the narrower type into the wider output buffer).

Buffer-layout / reinterpret fixes:
- Outer (CPU + GPU): allocated the promoted output but dispatched by the raw
  input dtypes, so ComplexFloat x Double wrote complex64 into a complex128
  buffer. Now casts both operands and dispatches by the promoted dtype (only
  the same-type diagonal kernels, avoiding the mixed-cuComplex path).
- Exp/Expf: dispatched by out.dtype() while feeding Tin's original storage,
  reinterpreting e.g. a float buffer as double*. Now feeds the already-cast
  `out` storage as the input (kernels support in == out).

Lower-enum -> Type.type_promote:
- Lstsq, Concatenate, Hstack, Vstack, Directsum.
- Scalar binary arithmetic (radd/rmul/rsub/rdiv, both the Scalar and typed
  overloads).
- The UniTensor out-of-place Add/Sub/Mul/Div wrappers (and their scalar
  variants), which chose the output dtype / cloned operand by enum order.

Tests:
- New DtypePromotion cases for every fixed entry point (ComplexFloat x Double
  -> ComplexDouble; Exp(Float) reads the correct buffer width).
- Updated the Hstack/Vstack/Directsum test helpers (CPU and GPU copies), which
  encoded the old min-enum-index contract, to expect Type.type_promote.
- Full CPU suite: 1101 passed / 11 skipped / 0 failed.

Note: the GPU library does not currently compile on this branch (pre-existing,
independent of this change) because #982's promotion change broke the cuKron
device kernel's output type; the GPU edits here mirror the CPU fixes verified
above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yingjerkao

yingjerkao commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @ianmccul — pushed 731294f addressing the concrete promotion bugs in the second comment.

Buffer-layout / reinterpret fixes

  • Outer (CPU + GPU): now casts both operands to Type.type_promote(...) and dispatches by the promoted dtype, so it uses only the same-type diagonal kernels — no more writing complex64 into a complex128 allocation. (This also keeps Outer off the mixed-cuComplex kernel path.)
  • Exp/Expf: the out-of-place versions fed Tin's original storage to the out.dtype()-keyed kernel (reading float* as double*). They now feed the already-cast out storage as the input; the kernels support in == out (as Exp_.cpp already relies on).

Lower-enum-index → Type.type_promote

  • Lstsq, Concatenate, Hstack, Vstack, Directsum.
  • Scalar binary arithmetic (radd/rmul/rsub/rdiv, both the Scalar and typed-scalar overloads).
  • The UniTensor out-of-place Add/Sub/Mul/Div wrappers and their scalar variants (they chose the cloned operand / output dtype by enum order).

Tests: added DtypePromotion cases for every fixed entry point (the ComplexFloat × Double → ComplexDouble discriminator, plus Exp(Float) reading the correct buffer width), and updated the Hstack/Vstack/Directsum test helpers (CPU and GPU copies) that had hard-coded the old min-enum-index contract. Full CPU suite: 1101 passed / 11 skipped / 0 failed.

Tridiag also uses the min-index idiom but rejects complex inputs, so mintype_promote there — left as-is.

On the broader point (first comment): you're right that this is whack-a-mole, and I hit concrete evidence for it — the GPU library doesn't currently compile on this branch: #982's type_promote change broke the cuKron device kernel (cuKron_internal.cuh:49, out[x] = Lin[x] * Rin[y] no longer type-checks because the promoted TO differs from the operands). I confirmed it reproduces on the branch tip with my changes stashed, so it's pre-existing and independent of this PR — but it means the "only the output dtype changed, the kernel still reinterprets by the old rule" hazard is already live on GPU, exactly as you described. The in-place iAdd/iSub/iMul/iDiv and UniTensor in-place paths you mention are also still separate. I don't think this PR should try to be the fix for all of that; the opaque-dtype / #941 direction is the real remedy.

I've filed the cuKron breakage as a separate must-fix: #999 (with root cause — Kron.cpp allocates the output via runtime Type.type_promote but derives the kernel TO from the compile-time type_promote_from_gpu_pointer_t trait, and #982 left the two inconsistent). It's also concrete evidence for doing #941 before further type_promote-dependent work.

yingjerkao added a commit that referenced this pull request Jul 6, 2026
…pe sites (#984 review)

Follow-up to the review of #984: #982 changed Type.type_promote to promote
across the real/complex boundary, but several entry points still hand-rolled
the "pick the lower enum index" rule and so disagree with the new promotion
(and, for Outer/Exp, write the narrower type into the wider output buffer).

Buffer-layout / reinterpret fixes:
- Outer (CPU + GPU): allocated the promoted output but dispatched by the raw
  input dtypes, so ComplexFloat x Double wrote complex64 into a complex128
  buffer. Now casts both operands and dispatches by the promoted dtype (only
  the same-type diagonal kernels, avoiding the mixed-cuComplex path).
- Exp/Expf: dispatched by out.dtype() while feeding Tin's original storage,
  reinterpreting e.g. a float buffer as double*. Now feeds the already-cast
  `out` storage as the input (kernels support in == out).

Lower-enum -> Type.type_promote:
- Lstsq, Concatenate, Hstack, Vstack, Directsum.
- Scalar binary arithmetic (radd/rmul/rsub/rdiv, both the Scalar and typed
  overloads).
- The UniTensor out-of-place Add/Sub/Mul/Div wrappers (and their scalar
  variants), which chose the output dtype / cloned operand by enum order.

Tests:
- New DtypePromotion cases for every fixed entry point (ComplexFloat x Double
  -> ComplexDouble; Exp(Float) reads the correct buffer width).
- Updated the Hstack/Vstack/Directsum test helpers (CPU and GPU copies), which
  encoded the old min-enum-index contract, to expect Type.type_promote.
- Full CPU suite: 1101 passed / 11 skipped / 0 failed.

Note: the GPU library does not currently compile on this branch (pre-existing,
independent of this change) because #982's promotion change broke the cuKron
device kernel's output type; the GPU edits here mirror the CPU fixes verified
above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yingjerkao
yingjerkao force-pushed the claude/amazing-lederberg-7cf653 branch from 822f55d to 2fd3149 Compare July 6, 2026 13:12
@ianmccul

ianmccul commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

I've filed the cuKron breakage as a separate must-fix: #999 (with root cause — Kron.cpp allocates the output via runtime Type.type_promote but derives the kernel TO from the compile-time type_promote_from_gpu_pointer_t trait, and #982 left the two inconsistent).

That cannot be the case, for sure type_promote_from_gpu_pointer_t uses the same type_promote mechanism, that was the whole point of the design: to localize the type promotion choice in one place. @pcchen already identified the true cause and a fix in #995 , the cuXXX have their own weird type promotion rules, cuFloatComplex * double does not promote properly, and there is no assignment operator for cuDoubleComplex = cuFloatComplex. GPU Kron itself is actually a good type-safe design, it is only the weird cuda types that caused it to break.

yingjerkao and others added 4 commits July 7, 2026 16:57
…promote

Dot, Matmul, Matmul_dg, Tensordot, Axpy, Gemm, Gemm_Batch, Ger, and Mod
still hand-rolled mixed-dtype promotion by picking the lower enum index,
so ComplexFloat x Double produced ComplexFloat and silently discarded
double precision - diverging from Add/Sub/Mul/Div and Vectordot after
the type_promote fix. All sites now follow the Vectordot pattern:
promote via Type.type_promote, astype BOTH operands (the promoted dtype
can differ from both inputs), allocate the output at the promoted dtype,
and dispatch the kernel by it.

Latent bugs fixed along the way:

* Axpy/Ger floored the dtype with an inverted comparison
  (fin_dtype < Type.Float), which clamped complex inputs onto the
  Double kernel (all-ComplexDouble Axpy threw "Cannot cast complex128
  to real") and let integer inputs index past the size-5 kernel tables
  (segfault). Integer/bool now floors to Double after promotion.
* Gemm lacked the integer->Double floor that Gemm_ has; integer inputs
  segfaulted the same way.
* Ger built its implicit alpha=1 scalar before the floor could change
  fin_dtype; it is now created at the final dtype.
* Mod's kernel (ModInternalImpl) computes in
  Type_class::type_promote_t<TL,TR>, but the output buffer used the old
  min-index rule, so e.g. Uint64 % Int32 wrote Int64 data into a
  Uint64-labeled buffer. All buffer-dtype sites, including the
  typed-scalar specializations, now use Type.type_promote.

Adds tests/linalg_test/DtypePromotion_test.cpp (20 tests) covering the
discriminating pairs (ComplexFloat x Double -> ComplexDouble,
Uint64 x Int32 -> Int64) and the integer floors; all were observed
failing (16 assertions, 3 segfaults) before the fix. No existing test
asserted the old lower-index dtype: the full suite is unchanged vs
baseline (1087 passed / 11 skipped / 4 known pre-existing
BkUt_*Svd_truncate_return_err failures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pe sites (#984 review)

Follow-up to the review of #984: #982 changed Type.type_promote to promote
across the real/complex boundary, but several entry points still hand-rolled
the "pick the lower enum index" rule and so disagree with the new promotion
(and, for Outer/Exp, write the narrower type into the wider output buffer).

Buffer-layout / reinterpret fixes:
- Outer (CPU + GPU): allocated the promoted output but dispatched by the raw
  input dtypes, so ComplexFloat x Double wrote complex64 into a complex128
  buffer. Now casts both operands and dispatches by the promoted dtype (only
  the same-type diagonal kernels, avoiding the mixed-cuComplex path).
- Exp/Expf: dispatched by out.dtype() while feeding Tin's original storage,
  reinterpreting e.g. a float buffer as double*. Now feeds the already-cast
  `out` storage as the input (kernels support in == out).

Lower-enum -> Type.type_promote:
- Lstsq, Concatenate, Hstack, Vstack, Directsum.
- Scalar binary arithmetic (radd/rmul/rsub/rdiv, both the Scalar and typed
  overloads).
- The UniTensor out-of-place Add/Sub/Mul/Div wrappers (and their scalar
  variants), which chose the output dtype / cloned operand by enum order.

Tests:
- New DtypePromotion cases for every fixed entry point (ComplexFloat x Double
  -> ComplexDouble; Exp(Float) reads the correct buffer width).
- Updated the Hstack/Vstack/Directsum test helpers (CPU and GPU copies), which
  encoded the old min-enum-index contract, to expect Type.type_promote.
- Full CPU suite: 1101 passed / 11 skipped / 0 failed.

Note: the GPU library does not currently compile on this branch (pre-existing,
independent of this change) because #982's promotion change broke the cuKron
device kernel's output type; the GPU edits here mirror the CPU fixes verified
above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards the remaining CPU-reachable lines of the type_promote conversion:
every typed-scalar Mod specialization (both scalar-left and scalar-right)
must allocate its output buffer at Type.type_promote of its operands to
agree with ModInternalImpl's type_promote_t, the ComplexFloat-scalar
variants must promote before the kernel rejects complex Mod, and Axpy
without y must allocate its zeros() output at the promoted dtype.

Also raises codecov/patch above the auto target: the previous round
measured 27.98% because the bulk-edited Mod specializations and GPU-only
branches were unexercised; the former are now covered (the latter cannot
run on CI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address the @gemini-code-assist review on #984. Pass init_zero=false to
out.Init in the Dot mat-vec path and all three Mod paths: those kernels fully
overwrite the output, so the zero-fill is dead work.

Verified each kernel overwrites before adopting the suggestion:
- Dot -> Matvec_ii/cuMatvec_ii: the integer kernel self-zeros each element
  (out[n] = 0; out[n] += ...) and the BLAS/cuBLAS gemv variants use beta=0, so
  out is written without being read.
- Mod -> ModInternalImpl (contiguous and non-contiguous) and cuMod: each output
  element is assigned (_out[i] = ...).

NOT applied to Gemini's third suggestion (Matmul_dg): the CPU diag_L path uses
axpy (out += inl[i]*inr), which accumulates into out and therefore *requires*
the zero-initialization. Skipping it there would corrupt results, so that
out.Init is left with the default init_zero=true.

Tested: DtypePromotion (incl. Dot_matvec and every Mod case), Vectordot, and the
broader Dot/Mod/Matmul/Gemm/Ger/Tensordot/Axpy suites all pass under ASAN (which
fills fresh allocations with 0xbe, so the passing value checks confirm the
kernels overwrite rather than relying on coincidentally-zero memory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yingjerkao
yingjerkao force-pushed the claude/amazing-lederberg-7cf653 branch from 2fd3149 to d711fbf Compare July 7, 2026 09:03
@pcchen

pcchen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Replaces hand-rolled "pick the lower enum index" dtype selection with Type.type_promote across ~15 linalg/algo entry points (follow-up to the merged #982), fixing several real latent bugs along the way. Correct and well-tested.

Verified correct

I traced the headline fixes directly, and a full-breadth pass corroborated every other converted site.

  • type_promote semantics — genuinely promotes across the real/complex boundary by precision (is_complex(L) != is_complex(R)to_complex(promote(to_real, to_real)), e.g. ComplexFloat × Double → ComplexDouble), plus correct signed/unsigned handling. ✅
  • Latent bugs fixed:
    • Axpy/Ger inverted floor — the old fin_dtype < Type.Float clamped complex inputs onto the Double kernel (the "Cannot cast complex128 to real" throw) and left integers to index past the size-5 kernel table (segfault). Now > Type.Float floors integers/bool — correct given the enum order (Float=4, ints ≥ 5). ✅
    • Gemm (out-of-place) — the previously-missing integer→Double floor is added, matching Gemm_. ✅
    • Ger alpha — now built after the floor, at the final dtype. ✅
    • Mod buffer/kernel disagreement — every buffer-dtype site (Tensor-Tensor, generic Scalar, all 10 typed-scalar specializations, and the reverse forms) now uses type_promote, matching what ModInternalImpl<TL,TR> computes (e.g. Uint64 % Int32 → Int64 buffer). ✅
  • Every converted site casts both operands to the promoted dtype (no one-sided casts), allocates the output at it, and dispatches by it. Size-5 kernel tables (axpy/ger/Gemm) are only reached with floored/float dtypes; full tables (Matmul/Matvec/Outer/Mod) need no floor. Outer additionally fixes a 2-D-table-indexed-by-raw-dtype bug; Exp/Expf fix a separate reinterpret bug. ✅
  • Base fix(Type): promote mixed complex/real dtypes by precision; add to_real/to_complex #982 is merged; CI is green on all platforms (BuildAndTest ×3 + DownstreamFindPackage), plus the 20 new DtypePromotion_test.cpp tests (all observed red pre-fix, incl. 3 segfaults).

Minor note (not a bug)

Gemm_Batch relies on per-operand dtype() > 4 error guards to reject integer inputs (they run before the dispatch, so the size-5 cuGemm_Batch_ii[promoted_dtype] is only reached with dtype ≤ 4 — no past-the-end access), rather than the floor-to-Double the other size-5 kernels use. That's a deliberate, safe asymmetry (batched GEMM doesn't support integer at all); a one-line comment noting it would help, but no change needed.

No findings requiring changes — a clean, high-value correctness fix that unifies dtype selection and squashes real segfaults/throws. LGTM.

Posted by Claude Code on behalf of @pcchen

@pcchen pcchen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. Verified across all ~15 converted sites: promotion correctly routed through Type.type_promote (real/complex by precision + signed/unsigned handling), both operands cast, outputs allocated at the promoted dtype, and the size-5 kernel tables only reached with floored/float dtypes. The four latent bugs are genuinely fixed — the Axpy/Ger inverted floor (complex mis-clamp + integer table-overrun segfault), the missing Gemm integer floor, Ger's alpha built after the floor, and Mod's buffer/kernel dtype disagreement. CI green on all platforms; 20 new promotion tests observed red pre-fix (incl. 3 segfaults).

Merge-order note: the Scalar.hpp hunks here are subsumed by #1011's variant rewrite — landing this first means #1011's rebase resolves that file trivially by taking its own version.

Posted by Claude Code on behalf of @pcchen

@pcchen
pcchen merged commit 3c11796 into master Jul 8, 2026
19 checks passed
@pcchen
pcchen deleted the claude/amazing-lederberg-7cf653 branch July 8, 2026 02:35
yingjerkao added a commit that referenced this pull request Jul 15, 2026
…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>
yingjerkao added a commit that referenced this pull request Jul 15, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants