refactor(linalg)!: GPU elementwise arithmetic typed std::visit dispatch (#1013)#1067
Conversation
UNI_GPU is applied PUBLIC, so any target linking cytnx (gpu_test_main, benchmarks_main, test_doc_cplusplus) inherits it and its public header Type.hpp then includes <cuda/std/complex>. CUDA 12+/13 relocate that header under the toolkit's cccl/ subdir, which is not part of any CUDA:: imported target's INTERFACE and was added only PRIVATE -- so host compilation of those consumers failed with "cuda/std/complex: No such file or directory". Add the detected cccl dir PUBLIC, wrapped in $<BUILD_INTERFACE:> so the absolute build-machine path is not baked into the installed/exported target (keeps find_package(Cytnx) relocatable). Pre-existing on master; surfaced when building the GPU test suite under CUDA 13. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1013) Replace the per-dtype-pair function tables for out-of-place elementwise arithmetic with std::visit over as_storage_variant() + type_promote_t -- the same value types the CPU path dispatches on -- and confine the CUDA-native complex representation to the kernel-launch boundary via a new to_cuda_t trait (cuTypeCvt.hpp). The shared machinery lives in cuArithmeticDispatch.cuh; Sub, Mul, Div and Mod route through cuArithmeticDispatchGPU<op>, and Cpr keeps its Bool-output kernels but drops the type_promote_gpu_t switch dispatch. This removes the parallel type_promote_gpu_t / cy_typeid_gpu_v hierarchy from these paths. BREAKING CHANGE: GPU true division. Div now computes int/int in floating point (e.g. Int64 / Int64 -> Double), matching the CPU path; div_output_dtype in Div.cpp is collapsed to always-floating. Previously GPU Div did integer division, silently diverging from CPU. Testing: gpu_test_main on an RTX 4070 Ti SUPER -- 68 Add tests plus all-dtype -pair sweeps for Sub/Mul/Div/Mod/Cpr and the mixed unsigned/signed promote tests all pass; compute-sanitizer memcheck reports 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…legacy tables (#1013) Convert in-place elementwise arithmetic (iAdd/iSub/iMul/iDiv) to the typed std::visit design (cuiArithmeticDispatch.cuh), mirroring the CPU DispatchInplaceArithmeticCPU: promote Lt's storage to the output dtype via storage_as_type_or_replace, then run the op writing at Lt's physical layout. This removes the per-op tmpo clone dance and the narrow-LHS / zero-extent special-cases in the iXxx.cpp GPU branches. With in-place off the legacy cuAri_ii table, the whole legacy machinery is now dead and removed: the cuAri_ii function-pointer table and the 121 cuArithmetic_internal_* dispatchers (cuArithmetic_internal.{cu,hpp}), the four per-dtype cuSub/cuMul/cuDiv/cuMod_internal.cu, the per-dtype declarations in the cu*_internal.hpp headers, and the obsolete bet.py generator. cuAdd_internal.cu is trimmed to just cuAdd_dispatch, which now routes out-of-place Add through the shared cuArithmeticDispatchGPU<0> (Add was coupled to this file's per-dtype defs, so its out-of-place conversion lands here with the removal). Net: -35k lines. type_promote_gpu_t / cy_typeid_gpu_v / Type_list_gpu are kept -- still used by Tensor.hpp's GPU storage machinery. BREAKING CHANGE: GPU in-place now promotes the LHS storage like the CPU path. e.g. `int16_t += int64_tensor` makes the tensor Int64, and `int64_t /= int64_tensor` makes it Double (true division). Non-contiguous `tensor *= tensor` / `tensor /= tensor` is now supported in place (previously rejected -- the legacy cuMul/cuDiv kernels ignored the layout mappers, #988). Testing: gpu_test_main on an RTX 4070 Ti SUPER -- in-place regression for all four ops plus the new promotion/non-contiguous coverage pass; compute-sanitizer memcheck reports 0 errors. CPU: debug build green, 211 CPU arithmetic/in-place tests pass (CPU behavior unchanged). clang-format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rage The per-op iadd/isub/imul GPU tests only exercise same-dtype / non-promoting in-place and reject non-contiguous tensor*=tensor / tensor/=tensor. Add InplacePromote_test.cpp covering the behavior the typed in-place dispatch newly guarantees (#1013): promoting the LHS storage to match the CPU result dtype (Add/Sub/Mul type_promote, Div true division) across all promoting dtype pairs, and non-contiguous tensor (op)= tensor in place for every op -- both validated against the CPU path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request refactors the GPU elementwise binary arithmetic and comparison dispatch to use std::visit over as_storage_variant(), aligning the GPU implementation with the CPU design and removing legacy per-dtype function tables. This change correctly handles true division on GPU and supports in-place operations on non-contiguous tensors. However, two critical bugs were identified in cuArithmeticDispatch.cuh and cuiArithmeticDispatch.cuh where device-allocated pointers are directly dereferenced and written to from host code during shape accumulator calculations. These values must be computed in host vectors first and then copied to the device using cudaMemcpy to prevent segmentation faults or undefined behavior.
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.
| cytnx_uint64 *m_accu_shape = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuCalloc_gpu(shape.size(), sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_old_accu_shapeL = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuCalloc_gpu(shape.size(), sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_old_accu_shapeR = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuCalloc_gpu(shape.size(), sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_invmapper_L = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuMalloc_gpu(invmapper_L.size() * sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_invmapper_R = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuMalloc_gpu(invmapper_R.size() * sizeof(cytnx_uint64))); | ||
|
|
||
| checkCudaErrors(cudaMemcpy(m_invmapper_L, &invmapper_L[0], | ||
| sizeof(cytnx_uint64) * invmapper_L.size(), | ||
| cudaMemcpyHostToDevice)); | ||
| checkCudaErrors(cudaMemcpy(m_invmapper_R, &invmapper_R[0], | ||
| sizeof(cytnx_uint64) * invmapper_R.size(), | ||
| cudaMemcpyHostToDevice)); | ||
|
|
||
| cytnx_uint64 tmp1 = 1, tmp2 = 1, tmp3 = 1; | ||
| for (cytnx_uint64 i = 0; i < shape.size(); i++) { | ||
| m_accu_shape[shape.size() - 1 - i] = tmp1; | ||
| tmp1 *= shape[shape.size() - 1 - i]; | ||
|
|
||
| m_old_accu_shapeL[shape.size() - 1 - i] = tmp2; | ||
| tmp2 *= shape[invmapper_L[shape.size() - 1 - i]]; | ||
|
|
||
| m_old_accu_shapeR[shape.size() - 1 - i] = tmp3; | ||
| tmp3 *= shape[invmapper_R[shape.size() - 1 - i]]; | ||
| } |
There was a problem hiding this comment.
Directly dereferencing and writing to device pointers (m_accu_shape, m_old_accu_shapeL, m_old_accu_shapeR) from host code is a critical bug. In standard CUDA, device memory allocated via cuCalloc_gpu (which wraps cudaMalloc) is not host-writable and will cause a segmentation fault or undefined behavior.
To fix this, compute the values in host vectors first, and then copy them to the device pointers using cudaMemcpy. Additionally, since the entire buffers are overwritten immediately, you can use cuMalloc_gpu instead of cuCalloc_gpu to avoid redundant zero-initialization.
cytnx_uint64 *m_accu_shape = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(shape.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_old_accu_shapeL = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(shape.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_old_accu_shapeR = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(shape.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_invmapper_L = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(invmapper_L.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_invmapper_R = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(invmapper_R.size() * sizeof(cytnx_uint64)));
std::vector<cytnx_uint64> accu_shape(shape.size());
std::vector<cytnx_uint64> old_accu_shapeL(shape.size());
std::vector<cytnx_uint64> old_accu_shapeR(shape.size());
cytnx_uint64 tmp1 = 1, tmp2 = 1, tmp3 = 1;
for (cytnx_uint64 i = 0; i < shape.size(); i++) {
accu_shape[shape.size() - 1 - i] = tmp1;
tmp1 *= shape[shape.size() - 1 - i];
old_accu_shapeL[shape.size() - 1 - i] = tmp2;
tmp2 *= shape[invmapper_L[shape.size() - 1 - i]];
old_accu_shapeR[shape.size() - 1 - i] = tmp3;
tmp3 *= shape[invmapper_R[shape.size() - 1 - i]];
}
checkCudaErrors(cudaMemcpy(m_accu_shape, accu_shape.data(),
sizeof(cytnx_uint64) * shape.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_old_accu_shapeL, old_accu_shapeL.data(),
sizeof(cytnx_uint64) * shape.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_old_accu_shapeR, old_accu_shapeR.data(),
sizeof(cytnx_uint64) * shape.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_invmapper_L, &invmapper_L[0],
sizeof(cytnx_uint64) * invmapper_L.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_invmapper_R, &invmapper_R[0],
sizeof(cytnx_uint64) * invmapper_R.size(),
cudaMemcpyHostToDevice));There was a problem hiding this comment.
Thanks, but this is a false positive — no host-write-to-device-memory happens here.
cuCalloc_gpu/cuMalloc_gpu allocate unified (managed) memory via cudaMallocManaged, not cudaMalloc — see src/backend/utils_internal_gpu/cuAlloc_gpu.cu:11 (cuCalloc_gpu) and :17 (cuMalloc_gpu). Managed memory is host-accessible, so writing m_accu_shape/m_old_accu_shapeL/m_old_accu_shapeR from host before the kernel launch is valid; the launch orders the host writes ahead of the device reads. (The invmapper buffers use cudaMemcpy only because they originate from a host std::vector, not because managed memory requires it.) This is the existing convention for the non-contiguous kernels — unchanged from the legacy per-dtype code this PR replaces.
Verified on an RTX 4070 Ti SUPER (sm_89): the non-contiguous GPU tests that exercise this exact path pass, and compute-sanitizer --tool memcheck reports 0 errors for both the out-of-place and in-place dispatch. Leaving as-is.
| cytnx_uint64 *m_accu_shape = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuCalloc_gpu(shape.size(), sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_old_accu_shapeL = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuCalloc_gpu(shape.size(), sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_old_accu_shapeR = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuCalloc_gpu(shape.size(), sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_invmapper_L = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuMalloc_gpu(invmapper_L.size() * sizeof(cytnx_uint64))); | ||
| cytnx_uint64 *m_invmapper_R = reinterpret_cast<cytnx_uint64 *>( | ||
| utils_internal::cuMalloc_gpu(invmapper_R.size() * sizeof(cytnx_uint64))); | ||
|
|
||
| checkCudaErrors(cudaMemcpy(m_invmapper_L, &invmapper_L[0], | ||
| sizeof(cytnx_uint64) * invmapper_L.size(), | ||
| cudaMemcpyHostToDevice)); | ||
| checkCudaErrors(cudaMemcpy(m_invmapper_R, &invmapper_R[0], | ||
| sizeof(cytnx_uint64) * invmapper_R.size(), | ||
| cudaMemcpyHostToDevice)); | ||
|
|
||
| cytnx_uint64 tmp1 = 1, tmp2 = 1, tmp3 = 1; | ||
| for (cytnx_uint64 i = 0; i < shape.size(); i++) { | ||
| m_accu_shape[shape.size() - 1 - i] = tmp1; | ||
| tmp1 *= shape[shape.size() - 1 - i]; | ||
|
|
||
| m_old_accu_shapeL[shape.size() - 1 - i] = tmp2; | ||
| tmp2 *= shape[invmapper_L[shape.size() - 1 - i]]; | ||
|
|
||
| m_old_accu_shapeR[shape.size() - 1 - i] = tmp3; | ||
| tmp3 *= shape[invmapper_R[shape.size() - 1 - i]]; | ||
| } |
There was a problem hiding this comment.
Directly dereferencing and writing to device pointers (m_accu_shape, m_old_accu_shapeL, m_old_accu_shapeR) from host code is a critical bug. In standard CUDA, device memory allocated via cuCalloc_gpu (which wraps cudaMalloc) is not host-writable and will cause a segmentation fault or undefined behavior.
To fix this, compute the values in host vectors first, and then copy them to the device pointers using cudaMemcpy. Additionally, since the entire buffers are overwritten immediately, you can use cuMalloc_gpu instead of cuCalloc_gpu to avoid redundant zero-initialization.
cytnx_uint64 *m_accu_shape = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(shape.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_old_accu_shapeL = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(shape.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_old_accu_shapeR = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(shape.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_invmapper_L = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(invmapper_L.size() * sizeof(cytnx_uint64)));
cytnx_uint64 *m_invmapper_R = reinterpret_cast<cytnx_uint64 *>(
utils_internal::cuMalloc_gpu(invmapper_R.size() * sizeof(cytnx_uint64)));
std::vector<cytnx_uint64> accu_shape(shape.size());
std::vector<cytnx_uint64> old_accu_shapeL(shape.size());
std::vector<cytnx_uint64> old_accu_shapeR(shape.size());
cytnx_uint64 tmp1 = 1, tmp2 = 1, tmp3 = 1;
for (cytnx_uint64 i = 0; i < shape.size(); i++) {
accu_shape[shape.size() - 1 - i] = tmp1;
tmp1 *= shape[shape.size() - 1 - i];
old_accu_shapeL[shape.size() - 1 - i] = tmp2;
tmp2 *= shape[invmapper_L[shape.size() - 1 - i]];
old_accu_shapeR[shape.size() - 1 - i] = tmp3;
tmp3 *= shape[invmapper_R[shape.size() - 1 - i]];
}
checkCudaErrors(cudaMemcpy(m_accu_shape, accu_shape.data(),
sizeof(cytnx_uint64) * shape.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_old_accu_shapeL, old_accu_shapeL.data(),
sizeof(cytnx_uint64) * shape.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_old_accu_shapeR, old_accu_shapeR.data(),
sizeof(cytnx_uint64) * shape.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_invmapper_L, &invmapper_L[0],
sizeof(cytnx_uint64) * invmapper_L.size(),
cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy(m_invmapper_R, &invmapper_R[0],
sizeof(cytnx_uint64) * invmapper_R.size(),
cudaMemcpyHostToDevice));There was a problem hiding this comment.
Thanks, but this is a false positive — no host-write-to-device-memory happens here.
cuCalloc_gpu/cuMalloc_gpu allocate unified (managed) memory via cudaMallocManaged, not cudaMalloc — see src/backend/utils_internal_gpu/cuAlloc_gpu.cu:11 (cuCalloc_gpu) and :17 (cuMalloc_gpu). Managed memory is host-accessible, so writing m_accu_shape/m_old_accu_shapeL/m_old_accu_shapeR from host before the kernel launch is valid; the launch orders the host writes ahead of the device reads. (The invmapper buffers use cudaMemcpy only because they originate from a host std::vector, not because managed memory requires it.) This is the existing convention for the non-contiguous kernels — unchanged from the legacy per-dtype code this PR replaces.
Verified on an RTX 4070 Ti SUPER (sm_89): the non-contiguous GPU tests that exercise this exact path pass, and compute-sanitizer --tool memcheck reports 0 errors for both the out-of-place and in-place dispatch. Leaving as-is.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c13ebcbfba
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| EXPECT_EQ(gpu_l.dtype(), expected); | ||
| EXPECT_EQ(gpu_l.dtype(), cpu_l.dtype()); | ||
| EXPECT_TRUE(cytnx::TestTools::AreNearlyEqTensor(gpu_l.to(Device.cpu), cpu_l, 1e-5)); |
There was a problem hiding this comment.
Assert independent expected arithmetic results
CLAUDE.md's test guidance for arithmetic/dtype changes says tests should check independent expected values, but this new coverage uses the CPU in-place path as the oracle (ApplyInplace(op, cpu_l, cpu_r)) and only compares GPU against that result. If the shared typed promotion or non-contiguous indexing logic is wrong in the same way on both CPU and GPU, these tests still pass; please add a few literal expected dtype/value cases such as Int16 += Int64, Int64 /= Int64, and a non-contiguous tensor case.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — addressed in 623c88b. Added InplacePromoteTest.gpu_inplace_literal_expected with hand-computed expected dtype and values, independent of any Cytnx execution path:
Int16 += Int64 -> Int64=[11,22,33]Int64 /= Int64 -> Double=[0.5,1.0,1.5](true division)- contiguous LHS + permuted RHS (Add), and permuted LHS + contiguous RHS (Sub) — so
Lidx != Ridxis actually exercised and a wrong physical zip would fail.
Inputs are built with CPU arange then moved to the GPU, so the test data itself doesn't depend on GPU kernels.
ianmccul
left a comment
There was a problem hiding this comment.
This is a valuable correctness improvement: typed dispatch removes roughly 35,000 lines of generated dtype-pair machinery, fixes GPU true division and in-place promotion, and eliminates several unsafe pointer/dtype combinations. However, the non-contiguous path needs a more careful review than the current tests provide.
The new implementation handles non-contiguous operands by carrying forward the old permutation-decoding algorithm. For every logical output index, the kernel reconstructs a coordinate vector, applies invmapper_L and invmapper_R, and computes physical offsets. The out-of-place kernel writes out[idx]; the in-place kernel writes out[Lidx]. This is plausibly correct for Cytnx current permutation-only view model, but it is not the stride/layout design proposed in #1003.
The Gemini claim that the host writes themselves are invalid is a false positive: cuCalloc_gpu uses cudaMallocManaged, so those writes are legal. But legal managed-memory access does not make this a good implementation.
Findings
-
The new non-contiguous test barely exercises the mapper logic. Both operands use the same permutation:
gpu_l = ...permute({1, 0}); gpu_r = ...permute({1, 0});Consequently
Lidx == Ridx, and even an incorrect physical-buffer zip can pass. The test then compares against the CPU path, which uses essentially the same mapper algorithm, so a shared indexing error can pass on both devices. Please add independently checked cases with literal expected values, including:- contiguous LHS and permuted RHS;
- permuted LHS and contiguous RHS;
- differently permuted operands with the same logical shape;
- promoted in-place output with different LHS/RHS layouts;
- at least subtraction and division, where an operand-order/indexing error is visible.
-
Dynamic shared-memory use imposes an undocumented rank-dependent launch limit. Each block requests
512 * rank * sizeof(uint64_t) = 4096 * rank bytesfor the per-thread coordinate vectors. Rank 13 requests 53,248 bytes, already above the common 48 KiB default dynamic-shared-memory limit. There is no rank check, launch-configuration adjustment, or opt-in shared-memory attribute, so sufficiently high-rank non-contiguous arithmetic can fail at kernel launch. At minimum this needs an explicit supported-rank check or a block size derived from rank; preferably the per-thread coordinate vector should not live in dynamic shared memory at all.
-
The old per-call metadata-allocation model remains. Every non-contiguous operation performs five managed-memory allocations, two copies, a kernel launch, and five immediate frees. This adds substantial launch overhead and prevents clean asynchronous lifetime management. Extents and strides are tiny launch metadata and should be preprocessed into a compact trivially-copyable layout passed as a kernel argument, as described in #1003.
-
Traversal remains duplicated. Nearly identical coordinate-decoding code now exists in
cuArithmeticDispatch.cuh,cuiArithmeticDispatch.cuh, andcuCpr_dispatch.cu, in addition to the analogous CPU traversal introduced by #1056. The operation functor is shared within one GPU arithmetic family, but the actual elementwise traversal is not yet centralized across out-of-place, in-place, comparison, CPU, and GPU paths. -
The generic out-of-place kernel is not uniformly reachable. The front ends for out-of-place GPU
Mul,Div,Mod, andCprstill reject non-contiguous tensor/tensor operands. OnlyAddandSub, plus the newly enabled in-place paths, reach mapper-aware kernels. The PR description should not imply general non-contiguous GPU elementwise support.
I do not think the full #1003 architecture must be implemented in this PR before the typed-dispatch cleanup can land. The deletion of the generated tables and the dtype correctness fixes are independently worthwhile. However, the weak non-contiguous tests and the rank-dependent kernel-launch failure are correctness issues that should be addressed before approval.
After this lands, #1003 should be broadened explicitly to a shared CPU/GPU elementwise framework: common operation functors and dtype policies, common host-side stride/layout preprocessing, and backend-specific CPU/CUDA execution consuming the same compact layout. The mapper kernels in this PR should be treated as transitional code, not the final non-contiguous implementation.
Written by OpenAI Codex and posted at @ianmccul request.
|
One additional point about the in-place non-contiguous kernel: even if the current inverse-mapper representation is retained temporarily, there is no reason to map both operands. For an in-place operation, the LHS is also the output, so its physical storage order should define the traversal: const auto lhs_idx = physical_idx;
const auto rhs_idx = map_lhs_physical_to_rhs_physical(physical_idx);
out[lhs_idx] = op(lhs[lhs_idx], rhs[rhs_idx]);The current kernel instead iterates a logical linear index, decodes logical coordinates, maps them to Iterating LHS physical order guarantees coalesced LHS reads and output writes; only the RHS may require an index transformation. The two permutations can be composed during host-side preprocessing into a direct LHS-physical-to-RHS-physical mapping. If both operands have the same layout, that mapping is identity and the operation reduces to a normal physical-buffer zip. This is independent of the broader stride-layout redesign in #1003. It is a straightforward simplification of the existing mapper approach and is another reason the test where both operands use the same permutation is insufficient: that case should not need mapping at all. Written by OpenAI Codex and posted at @ianmccul request. |
|
A performance concern follows directly from the identical-layout test case. The front end chooses the simple physical-buffer kernel only when both tensors are individually contiguous: if (Lt.is_contiguous() && Rt.is_contiguous()) {
// physical zip
} else {
// full mapper kernel
}Two tensors carrying the same non-contiguous layout therefore take the most expensive mapper path, even though an in-place operation needs no mapping at all. If Lt.shape() == Rt.shape() && Lt.invmapper() == Rt.invmapper()then corresponding logical elements occupy corresponding physical offsets, so the correct implementation is simply: for (std::size_t i = 0; i < Lt.size(); ++i) {
lhs_data[i] = op(lhs_data[i], rhs_data[i]);
}This remains true for non-commutative operations such as subtraction and division; it depends only on identical layouts, not on the operation. The new test constructs both operands with the same Before treating this as an acceptable non-contiguous implementation, I suggest adding benchmarks for at least:
Run these over multiple tensor sizes and ranks. Case 2 should be close to case 1 because the required kernel is identical. With the current branch selection it instead pays the complete mapper overhead. For some sizes, even materializing contiguous copies may be faster than the nominal no-copy path. The same-layout physical-zip check is a small improvement that can be made independently of #1003. The broader benchmark should then guide the stride-layout replacement rather than preserving this mapper path without performance evidence. Written by OpenAI Codex and posted at @ianmccul request. |
|
CI is green ✅ — all checks pass on the current head: Formatting Check, the four BuildWheel jobs (ubuntu-24.04, ubuntu-24.04-arm, macos-14, macos-15-intel — each builds and runs ctest + pytest), Local validation on an RTX 4070 Ti SUPER (CUDA 13, sm_89) is also green: full GPU suite for all six ops (out-of-place + in-place, incl. promotion and non-contiguous coverage), Re: the two "critical" findings — both are false positives, addressed on the inline threads: |
|
Managed memory makes these host writes legal, but it does not make this a good GPU metadata-transfer path. After the CPU writes a There is also a redundant initialization here. cudaMallocManaged(...);
cudaMemset(...);but the CPU immediately overwrites every element of So yes: the Gemini report was wrong to classify the host writes as invalid, and passing tests plus a clean memcheck confirm legality. They do not establish that demand-paging five tiny managed allocations is efficient. For these small, predictable arrays, an explicit transfer would generally be preferable to first-touch migration. However, five separate synchronous
The current non-contiguous path performs five managed allocations, two explicit copies, a kernel launch, and five immediate frees for metadata totaling only: At rank 12 that is 480 bytes. Allocation, synchronization, page-fault, and free overhead will dominate moving the actual metadata. This is independent of whether the managed-memory access is technically valid. Written by OpenAI Codex and posted at @ianmccul request. |
|
The overhead of copying a few hundred bytes - or even a few thousand bytes - of data into the kernel as fixed-size parameters is essentially zero, it is immeasurably small in the kernel startup procedure. A suitable implementation would resemble constexpr int max_rank = 32;
struct LayoutMetadata {
std::array<std::uint64_t, max_rank> accu_shape;
std::array<std::uint64_t, max_rank> lhs_strides;
std::array<std::uint64_t, max_rank> rhs_strides;
std::array<std::uint64_t, max_rank> lhs_mapper;
std::array<std::uint64_t, max_rank> rhs_mapper;
int rank;
};
__global__ void kernel(const __grid_constant__ LayoutMetadata layout, ...);That is about 1.3 KiB for NVIDIA documents a total parameter limit of 32,764 bytes on modern devices; older pre-Volta devices have a 4 KiB limit. CUDA parameter documentation (https://docs.nvidia.com/cuda/cuda-programming-guide/05-appendices/cpp-language-support.html) set |
…review) Per CLAUDE.md's test guidance and the #1067 review (Codex P2 / @ianmccul finding 1): the promotion and non-contiguous coverage compared GPU against the CPU path, so a bug shared by both dispatchers (in the common type_promote_t / true-division rule or the mapper math) could hide, and the non-contiguous case used the same permutation on both operands (Lidx == Ridx), which even a wrong physical zip passes. Add gpu_inplace_literal_expected with hand-computed expected dtype AND values, independent of any Cytnx execution path: - Int16 += Int64 -> Int64 [11,22,33] (integer promotion) - Int64 /= Int64 -> Double [0.5,1.0,1.5] (true division) - contiguous LHS + permuted RHS, Add (Lidx != Ridx) - permuted LHS + contiguous RHS, Sub (operand order + permuted output writes) Inputs are built with CPU arange then moved to the GPU so the test data never depends on GPU kernels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @ianmccul — the two "before approval" items are addressed and the architectural items are filed as follow-up #1069. Finding 1 — weak non-contiguous tests (fixed, 623c88b). The old case used the same permutation on both operands ( Finding 2 (rank-dependent shared-memory launch limit) + the architecture items (per-call metadata model / Separately, while writing the literal tests I hit what looks like a pre-existing, unrelated bug: GPU |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1067 +/- ##
==========================================
+ Coverage 72.11% 72.14% +0.03%
==========================================
Files 225 225
Lines 28207 28183 -24
Branches 71 71
==========================================
- Hits 20341 20333 -8
+ Misses 7845 7829 -16
Partials 21 21
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 1 file with indirect coverage changes Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
|
Thanks, the literal expected-value tests in I still see two consistency/build issues:
Apart from those, I am fine with the typed-dispatch cleanup landing before the single-kernel redesign in #1069. Written by OpenAI Codex and posted at @ianmccul request. |
#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>
Two GPU-only tests in tests/gpu/Tensor_test.cpp asserted the pre-#1013 in-place behavior and had been red for the whole PR (CI has no GPU runner, so it never caught them): - GpuRankZeroTensorRhsInplacePreserveDtype: a rank-0 genuine tensor RHS now *promotes* the LHS dtype (was asserting preserve) -> renamed to GpuRankZeroTensorRhsInplacePromotesDtype and assert Float += Double rank-0 yields Double. - GpuNoncontigTensorTensorMulDivThrows: non-contiguous tensor *= / /= tensor now *works* via the typed dispatch's layout mappers (was asserting throw) -> renamed to GpuNoncontigTensorTensorMulDivWorks with independent hand-computed expected values; inputs built via CPU arange + .to(cuda). Testing: gpu_test_main in-place/arithmetic suites green (21/21). clang-format v14 clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @ianmccul — item 1 is fixed in Item 1 — genuine complex Tensor RHS now promotes a real LHS in-place. The guard in cytnx_error_msg(
rhs_is_weak_scalar && !Type.is_complex(Lt.dtype()) && Type.is_complex(Rt.dtype()),
"[iAdd] Cannot perform real += complex-scalar in-place: weak-scalar "
"semantics preserve the real LHS dtype, so the complex scalar cannot be stored. "
"Use a complex LHS, or a genuine complex tensor RHS to promote.%s", "\n");A genuine complex Tensor RHS (including a rank-0 tensor) now promotes Tests (independent, hand-computed expected values — not compared against a CPU path):
Two stale GPU tests fixed ( Item 2 — CCCL install-interface — not yet addressed. You're right that the fix is only exported through |
…1067) Problem: the CUDA-13 cccl fix (commit b79d8889) is exported only through $<BUILD_INTERFACE:${_cytnx_cccl_dir}>, so an installed GPU Cytnx package strips it. That package still exports UNI_GPU (PUBLIC), and its public Type.hpp includes <cuda/std/complex> — which CUDA 12+/13 place under the toolkit's cccl/ subdir, part of no CUDA:: imported target's INTERFACE. A downstream find_package(Cytnx) GPU consumer therefore fails to compile with "cuda/std/complex: No such file". The DownstreamFindPackage CI job uses the openblas-cpu preset, so it never exercised this. Fix: baking the build-machine cccl path into the install interface would break relocatability, and it points into the build machine's toolkit anyway. Instead re-detect cccl against the *consumer's* CUDAToolkit in CytnxConfig.cmake and append it to Cytnx::cytnx's interface includes at find_package time. The detection is factored into a shared cmake/CytnxDetectCCCL.cmake module so the in-tree build and the installed config use identical logic (single source of truth), and the module is installed next to the config (and copied beside the build-tree export, which export(PACKAGE Cytnx) also serves). Testing: built + installed a CUDA (arch 89, CUDA 13) Cytnx, then a probe find_package(Cytnx) consumer. The exported CytnxTargets.cmake carries no cccl (BUILD_INTERFACE stripped, confirmed), yet after find_package Cytnx::cytnx's INTERFACE_INCLUDE_DIRECTORIES gains /usr/local/cuda/include/cccl. Negative control: stripping the new block from the installed config -> cccl absent (CCCL_MISSING), confirming the fix is load-bearing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
I will merge this and move to #1003 |
Problem
GPU elementwise arithmetic (
Add/Sub/Mul/Div/Mod/Cpr, in-place and out-of-place)still dispatched through per-dtype-pair function tables (
cuAri_ii, the 121cuArithmetic_internal_*dispatchers, and the per-dtypecu*_internal_*kernels)and a parallel
type_promote_gpu_t/cy_typeid_gpu_vpromotion hierarchy — thelegacy pattern #650/#938/#1013 are migrating away from. The GPU path had also
drifted from the CPU semantics established in #941/#1056: integer division stayed
integer, in-place ops kept the LHS dtype instead of promoting, and non-contiguous
tensor *= tensor/tensor /= tensorwas rejected outright (#988).Fix
Convert the GPU arithmetic dispatch to the same
std::visitdesign as the CPUpath (#941):
Sub/Mul/Div/Modroute through a sharedcuArithmeticDispatchGPU<op>(cuArithmeticDispatch.cuh):std::visitoveras_storage_variant()+type_promote_t, with the CUDA-native complexrepresentation confined to the kernel boundary by a new
to_cuda_ttrait(
cuTypeCvt.hpp).Cprkeeps its Bool-output kernels but drops thetype_promote_gpu_tswitch.Addfollows in the removal commit (its dispatchshared a file with the legacy per-dtype defs).
iAdd/iSub/iMul/iDivusecuiArithmeticDispatch.cuh, mirroringthe CPU
DispatchInplaceArithmeticCPU: promoteLt's storage viastorage_as_type_or_replace, then run the op atLt's physical layout. Removesthe
tmpoclone dance and the narrow-LHS / zero-extent special-cases.cuAri_ii,cuArithmetic_internal.{cu,hpp}, the four deadcu{Sub,Mul,Div,Mod}_internal.cu, the per-dtype declarations in thecu*_internal.hpp, and the obsoletebet.pygenerator. Net −35k lines.type_promote_gpu_t/cy_typeid_gpu_v/Type_list_gpuare kept — still usedby
Tensor.hpp's GPU storage variant.cccl/include dir isnow exposed to in-tree GPU consumers (
gpu_test_main/benchmarks), whichotherwise couldn't compile
<cuda/std/complex>at all.These are marked
!on the commits and warrant a numerical-correctness look:int / int → Double(was integer division).int16 += int64→ the tensor becomes Int64;int64 /= int64→ Double.tensor *= tensor/tensor /= tensornow works in place(previously threw — the legacy cuMul/cuDiv kernels ignored the layout mappers,
GPU: scalar in-place ops throw on non-contiguous tensors for *= and /=, and do a per-call H2D copy #988).
Testing
On an RTX 4070 Ti SUPER (CUDA 13):
gpu_test_main— 68 Add tests + all-dtype-pair sweeps for Sub/Mul/Div/Mod/Cpr,in-place regression for all four ops, and the new
InplacePromote_test.cpp(promotion + non-contiguous, validated against CPU) —all pass.
behavior unchanged).
Relates to #1013. Stacked on #1056 (base branch
refactor/cpu-linalg-typed-dispatch).🤖 Generated with Claude Code