Skip to content

refactor(trace): implement the GPU Tensor trace as CUDA kernels#850

Merged
IvanaGyro merged 19 commits into
masterfrom
claude/gallant-franklin-4g4rY
Jul 9, 2026
Merged

refactor(trace): implement the GPU Tensor trace as CUDA kernels#850
IvanaGyro merged 19 commits into
masterfrom
claude/gallant-franklin-4g4rY

Conversation

@IvanaGyro

@IvanaGyro IvanaGyro commented May 29, 2026

Copy link
Copy Markdown
Member

Fixes #834.

Stacked on #862 (CPU trace) → #860 (stride_view). This PR's diff is only the GPU CUDA implementation. Each prerequisite PR retargets up the chain as it merges.

Summary

Implements the GPU Tensor trace as native CUDA kernels reading storage directly via Tensor::strides(), replacing the previous Contract-based path that built an identity UniTensor per call.

Change

  • BlockTraceDiagonal<T>: sums one diagonal with the whole block. Each thread strides over the diagonal, each warp reduces its lanes with a __shfl_down_sync tree, the per-warp sums land in shared memory, and the first warp reduces those. A single-warp launch returns directly after the warp shuffle (no shared memory or barrier).
  • TraceKernel<T>: one block per output element. The block decodes its flat index into the surviving-axis multi-index using DecodeDiagonalStartOffset, then reduces the corresponding diagonal via BlockTraceDiagonal. The rank-2 trace is the output_size == 1 / surviving_rank == 0 case — the decode loop is empty, the diagonal starts at offset 0, and a single full block reduces the whole matrix diagonal.
  • Block sized per call from diagonal_length: threads_per_block = min(round_up_to_warp(diagonal_length), 256). A short diagonal launches a small block (no idle warps; many such blocks run concurrently across SMs); a long diagonal gets the full 256-thread block with each thread striding. The rank-2 trace puts every thread of its single block on the one diagonal.
  • WarpShuffleDownAdd wraps __shfl_down_sync, overloaded for cuda::std::complex (shuffled component-wise since __shfl_down_sync only moves scalar lanes). Small integer types promote to int through the shuffle and truncate back, preserving the modular-sum semantics the trace already has.
  • TraceImplGpu<T> mirrors the CPU TraceImpl<T>: derives the reduced output shape and per-surviving-axis input strides in a single pass; fills a device-resident Storage and promotes it via Tensor::from_storage (no host round-trip).
  • diagonal_length == 0 / output_size == 0 guards skip the kernel launch; checkCudaErrors(cudaGetLastError()) after launch surfaces any launch/configuration failure at the trace call rather than the next unrelated CUDA call.
  • Only the two surviving_rank-sized layout arrays (shape + per-axis input stride) are shipped to the device; the kernel decodes each element's offset itself rather than the host materializing an output_size-sized offset table.
  • Type-generic over T via T(0) / +=; complex storage is reinterpret_cast to cuda::std::complex<{float,double}>, which provides device operator+ / zero-construction (CUDA ≥ 12.6).

Tests

tests/gpu/linalg_test/Trace_test.cpp as LinalgGpuTraceTest mirrors the CPU LinalgTraceTest matrix:

  • PermutedRank3MatchesContiguous / PermutedTracesAcrossRanksAndDtypes: permuted (non-contiguous) inputs over non-adjacent axes across ranks 3–5 and Double/ComplexDouble/Int32, compared against the trace of a contiguous CPU clone.
  • Rank2Path: the surviving_rank == 0 single-block path.
  • OutputRankIsInputMinusTwo, SwappedAxisOrderMatches: rank and axis-order invariants.
  • MatchesCpuTraceOnContiguousInput: GPU result equals the CPU pairwise result on identical contiguous data across dtypes.
  • BlockSizeBoundaries: rank-2 traces at diagonal lengths {1, 31, 32, 33, 64, 255, 256, 257, 1024} — straddles every block-sizing boundary (sub-warp, exact-warp, warp+1, the 256 cap and one past it, multi-stride).
  • ShortDiagonalsManyOutputs: {n, middle, n} traced over (0, 2) for (n, middle) = (2, 1000), (32, 500), (33, 100) — many small blocks per launch, exercises block indexing across a large output together with sub-warp / exact-warp / warp+1 block sizes.
  • LargeDiagonalAccuracyBound: upper bounds the diagonal-sum accuracy at diagonal_length = 4096 with a slack proportional to n · ε · |value| — generous enough that the current serial intra-thread accumulation passes, and any future intra-block tree reduction also passes (locks correctness, not the current error regime).

Benchmark

benchmarks/linalg/Trace_bm.cpp, BM_gpu_Trace_* (Google Benchmark, release openblas-cuda build, -DRUN_BENCHMARKS=ON, GTX 1660 SUPER):

Trace pattern shape params Double ComplexDouble
Strided_3D 256/64 72.3us 86.0us
Strided_3D 1024/16 77.4us 86.6us
Strided_3D 2048/16 79.1us 83.7us
Strided_3D 4096/8 83.0us 88.1us
Matvec_3D 256/64 4603us 7179us
Matvec_3D 1024/16 12190us 22461us
Matvec_3D 2048/16 42026us 83319us
Matvec_3D 4096/8 80809us 164402us
Strided_2D 256 16.8us 17.2us
Strided_2D 1024 17.5us 18.0us
Strided_2D 4096 19.3us 20.2us
Strided_2D 8192 21.9us 23.6us
Vecdot_2D 256 289us 293us
Vecdot_2D 1024 321us 360us
Vecdot_2D 4096 882us 1362us
Vecdot_2D 8192 2216us 4040us
Reshape_2D 256 2026us 2239us
Reshape_2D 1024 2926us 3503us
Reshape_2D 4096 13274us 22613us
Reshape_2D 8192 46304us 80679us

Strided_3D/Strided_2D (this PR's new kernel path, traced over non-adjacent/permuted axes) stay roughly flat regardless of size — dominated by launch overhead rather than diagonal length. Matvec_3D, Vecdot_2D, and Reshape_2D are reference/comparison shapes that do proportionally more work and scale with size as expected. ComplexDouble tracks Double at roughly 1.0–1.5x throughout, consistent with the cuda::std::complex reinterpret-cast path.

Test plan

  • CPU: openblas-cpu full suite 1072/1072 passed (the GPU TU is excluded; no CPU regressions from the GPU patch).
  • nvcc 12.6 compile-check of cuTrace_internal.cu: 0 errors.
  • Adaptive block reduction verified against exact sums for every combination of block sizes {32, 64, 96, 128, 256} × diagonal lengths {1, 5, 31, 32, 33, 64, 100, 255, 256, 257, 4096} via host simulation of the shuffle semantics.
  • GPU CI — the .cu runtime can't be exercised locally; relying on CI to run tests/gpu/.

Draft — opened for review.

@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 introduces benchmarks for linalg::Trace and refactors both the CPU and GPU implementations of Trace_internal to perform in-place diagonal summation using physical strides, eliminating the need to contract with an identity tensor. The review feedback identifies critical bugs where an empty tensor (Ndiag == 0) causes unsigned underflow and subsequent out-of-bounds memory access in the CPU functions. For the GPU implementation, an empty element count (Nelem == 0) can lead to an invalid CUDA configuration error, and the use of redundant cudaDeviceSynchronize() calls blocks host execution and degrades performance.

Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread benchmarks/linalg/Trace_bm.cpp
Comment thread benchmarks/linalg/Trace_bm.cpp Outdated
@IvanaGyro
IvanaGyro force-pushed the claude/835-pairwise-sum branch from e355381 to 4c76053 Compare May 29, 2026 07:53
@IvanaGyro
IvanaGyro force-pushed the claude/gallant-franklin-4g4rY branch from f480e9b to 6a2cce9 Compare May 29, 2026 07:53

@IvanaGyro IvanaGyro left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(Review written by Claude on behalf of @IvanaGyro.)

I verified the central correctness claim: PhysicalStrides reconstructs exactly the row-major-over-invmapper layout that the container layer already uses to address elements (Tensor_impl::get does vec_map(_shape, _invmapper) then row-major over that order), so summing the diagonal in place via strides[ax1] + strides[ax2] is correct, and it's strictly more correct than the old _trace_2d, which assumed contiguous storage (rawdata[i*Ldim + i]) and would have been wrong for a lazily-permuted input. The GPU 2D tree reduction is valid (block dim 512 is a power of two), and the nd offset precomputation mirrors the CPU path. Nice refactor — it removes the inverted layering and the full-tensor contiguous() copy.

A few non-blocking points, left inline:

  1. PhysicalStrides is duplicated byte-for-byte between the CPU and GPU translation units.
  2. extent = (Ndiag - 1) * diag_stride + 1 underflows if Ndiag == 0.
  3. The GPU tree reduction silently assumes _TNinB_TRACE_ is a power of two.

Also a cross-PR note: this PR makes StrideView (from #849) live — the CPU trace now reduces a PairwiseSum over a StrideView temporary. The current call sites are safe because the StrideView temporary outlives the whole PairwiseSum(...) full-expression, but it means the dangling-pointer concern @gemini-code-assist raised on #849's StrideView::Iterator now has a real consumer; worth fixing the iterator (store the base iterator, not a pointer to the view) before more call sites appear in #834 follow-ups.

GPU build + test results (openblas-cuda / mkl-cuda) will follow in a separate comment.

Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
@IvanaGyro

Copy link
Copy Markdown
Member Author

(Comment written by Claude on behalf of @IvanaGyro.)

Test-coverage assessment. The correctness-relevant paths this PR adds aren't all exercised by committed tests:

  1. Non-contiguous / permuted input — the headline behavior change — is untested. The whole point of PhysicalStrides/invmapper is to trace a lazily-permuted tensor in place, where the old _trace_2d assumed contiguous storage (rawdata[i*Ldim + i]). But DenseUniTensorTest.Trace and gpu_Trace both trace a single loaded, contiguous dense4trtensor. Please add a test that permutes a tensor (so is_contiguous() == false) and traces non-adjacent axes, comparing against the contiguous reference — i.e. T.permute(p).Trace(i,j) vs T.permute(p).contiguous().Trace(i,j). Today only the benchmark touches non-adjacent axes.

  2. Commit the brute-force validation. The description says the index math was validated "against a brute-force trace across ranks 2–5 and swapped axis order" — that is the test this PR needs, but only benchmarks/linalg/Trace_bm.cpp was added, no test file. Please commit that brute-force comparison as a gtest, parameterized over rank and axis order, for both CPU and GPU.

  3. 2D path is not covered. The fixtures trace a rank-4 tensor over (0,3) → the _trace_nd / cuTrace_nd_kernel path. Add a rank-2 trace so _trace_2d and the GPU cuTrace_2d_kernel shared-memory reduction are actually exercised.

  4. Dtype coverage on GPU. Add at least one complex-dtype GPU trace (exercises the reinterpret_cast to cuda::std::complex) and an integer dtype, instead of only the loaded fixture's dtype.

  5. Edge case Ndiag == 0 (zero-length traced axis) — ties to the extent underflow inline comment; add a test asserting the intended behavior (throw, or result 0).

Suggestions rather than blockers — except (1) and (2): the permuted-input path is the core change of this PR and currently has no automated coverage.

@IvanaGyro

Copy link
Copy Markdown
Member Author

(Comment written by Claude on behalf of @IvanaGyro.)

GPU build + test results

Built and tested on two GPUs (GTX 1660 SUPER, RTX 2080 Ti), CUDA 12, conda toolchain.

Builds — both succeeded (rc=0):

  • openblas-cuda — library + test_main + gpu_test_main built clean.
  • mkl-cuda — library + test_main + gpu_test_main built clean.

Tests (release, the authoritative result for this PR):

  • openblas-cuda: 100% — 1678/1678 passed, 0 failed.
  • mkl-cuda: 100% — 1678/1678 passed, 0 failed.
  • The GPU trace path this PR rewrites is exercised and green in both: DenseUniTensorTest.gpu_Trace and BlockUniTensorTest.gpu_Trace passed.

Canonical ASan run (ctest --preset cpu-and-cudadebug-openblas-cuda, USE_DEBUG=ON, AddressSanitizer):

  • 99% — 11 failed / 1678. None are related to this change. The failures are all pre-existing under the debug/ASan build, in unrelated areas:
    Rsvd_truncate.*exp_svals*, Gesvd_truncate.*exp_svals*, Svd_truncate.*exp_svals*, Rsvd.*exp_svals*, SearchTreeTest.BasicSearchOrder2, BlockFermionicUniTensorTest.SaveLoad.
    They abort without a gtest [ FAILED ] marker (classic ASan-abort / debug-assert), and gpu_Trace itself passed under ASan too. I did not run the base branch to formally confirm they pre-date this PR, but the change only touches Trace/Sum and no Trace/Sum test fails.

Conclusion: this PR is green on GPU for both BLAS backends; the only failures are unrelated ASan/debug pre-existing issues.


Separately, while running the suite I noticed the GPU tests are dominated by a handful of extremely slow element-wise *AllShapes all_types/mixed_types cases (the worst ~386 s in release). I filed #851 to track speeding them up — not related to this PR.

@IvanaGyro
IvanaGyro force-pushed the claude/835-pairwise-sum branch from 4c76053 to 38fdf4f Compare May 29, 2026 23:12
@IvanaGyro
IvanaGyro force-pushed the claude/gallant-franklin-4g4rY branch from 6a2cce9 to 95cb3b0 Compare May 29, 2026 23:13
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated

@IvanaGyro IvanaGyro left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(Review written by Claude on behalf of @IvanaGyro.)

This round resolves all four of my earlier code comments: PhysicalStrides is hoisted into a public Tensor::strides() (no more CPU/GPU duplication, and the impl matches the invmapper layout Tensor::at uses), the Ndiag == 0 underflow is guarded on both backends, the _TNinB_TRACE_ power-of-two invariant has a static_assert, and the reductions now use the | stride(...) adaptor. Nice. Remaining items are about test coverage — inline.

Comment thread src/Tensor.cpp Outdated
Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
@IvanaGyro

Copy link
Copy Markdown
Member Author

(Comment written by Claude on behalf of @IvanaGyro.)

GPU build + test results (updated commit 95cb3b0)

Two GPUs (GTX 1660 SUPER, RTX 2080 Ti), CUDA 12, conda toolchain. Both presets configured with -DRUN_TESTS=ON and built lib + test_main + gpu_test_main.

Builds: both succeeded (rc=0).

Tests (release, ctest):

  • openblas-cuda: 100% — 1683/1683 passed, 0 failed.
  • mkl-cuda: 100% — 1683/1683 passed, 0 failed.
  • The GPU trace path is green on both: DenseUniTensorTest.gpu_Trace and BlockUniTensorTest.gpu_Trace passed.

Notes:

Conclusion: green on GPU for both BLAS backends.

Comment thread benchmarks/linalg/Trace_bm.cpp Outdated
Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
Comment thread benchmarks/linalg/Trace_bm.cpp Outdated
Comment thread benchmarks/linalg/Trace_bm.cpp Outdated
@IvanaGyro
IvanaGyro force-pushed the claude/835-pairwise-sum branch from 38fdf4f to 3a5a680 Compare May 30, 2026 09:57
@IvanaGyro
IvanaGyro force-pushed the claude/gallant-franklin-4g4rY branch from 95cb3b0 to fd6fd1f Compare May 30, 2026 09:57
Comment thread src/backend/linalg_internal_cpu/Trace_internal.cpp Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread tests/linalg_test/stride_view_test.cpp Outdated
Comment thread tests/linalg_test/Trace_test.cpp
@IvanaGyro
IvanaGyro force-pushed the claude/gallant-franklin-4g4rY branch 3 times, most recently from 47869fa to b295b04 Compare May 31, 2026 01:53
@pcchen pcchen added this to the v1.1.0 milestone May 31, 2026
@IvanaGyro
IvanaGyro force-pushed the claude/gallant-franklin-4g4rY branch from b295b04 to cce6d7d Compare May 31, 2026 03:05
@IvanaGyro IvanaGyro changed the title refactor(trace): compute Tensor trace in the container layer (CPU + GPU) refactor(trace): implement the GPU Tensor trace as CUDA kernels May 31, 2026
@IvanaGyro
IvanaGyro changed the base branch from claude/835-pairwise-sum to claude/trace-cpu May 31, 2026 03:06
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated

@IvanaGyro IvanaGyro left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(Review written by Claude on behalf of @IvanaGyro.)

Replacing the eye+Contract GPU trace with direct kernels is a solid win and mirrors the CPU TraceImpl offset math 1:1, so the two stay easy to keep in sync. The cuda::std::complex mapping for the complex dtypes is the right way to keep the kernels type-generic, and the power-of-two static_assert on the block size correctly guards the tree reduction. I built and ran this on both openblas-cuda and mkl-cuda: DenseUniTensorTest.gpu_Trace and BlockUniTensorTest.gpu_Trace pass on both. Comments inline: an async-launch error-check gap, and a GPU test-coverage gap that I think is the most important follow-up here.

Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
IvanaGyro and others added 2 commits July 1, 2026 18:26
…exits

The zero-extent early return and the normal function exit both ended
with the same three lines: Tensor::from_storage on the filled storage,
a conditional reshape_ to output_shape, and the return. Factoring this
into ComposeTraceOutput means a future change to how the output Tensor
is composed only needs to be made in one place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/backend/StorageImplementation.cpp includes cuFill_gpu.hpp
unconditionally, regardless of whether the build has CUDA enabled --
the same pattern every other utils_internal_gpu header in this
directory (cuAlloc_gpu.hpp, cuMovemem_gpu.hpp, etc.) already follows
by wrapping their CUDA-specific declarations in #ifdef UNI_GPU.

cuFill_gpu.hpp's unconditional `#include "cuda/std/complex"` and the
ToCudaDType specializations built on cuda::std::complex broke every
non-GPU build (no CUDA toolkit installed, so the header doesn't
exist) with "fatal error: cuda/std/complex: No such file or
directory". Wrapping both in #ifdef UNI_GPU, matching the sibling
headers' convention, restores non-GPU builds while leaving GPU builds
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…read

TraceImplGpu previously called cudaFree directly on the two small
device-side layout arrays after the kernel launch. cudaFree is a
synchronizing legacy-API call, so every GPU Trace() stalled the host
thread until the whole device pipeline drained, even though the
buffers themselves are only a handful of uint64s.

Records a CUDA event right after the kernel launch and hands the
wait-then-free off to a detached std::thread, so TraceImplGpu returns
as soon as the kernel is queued instead of blocking on cudaFree.

The wait must run on a detached std::thread rather than std::async:
an unstored std::future returned by std::async blocks in its own
destructor until the task completes, which would reintroduce the same
synchronous stall at the end of the calling expression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@IvanaGyro

IvanaGyro commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

(Comment written by Claude on behalf of @IvanaGyro.)

GPU memory-management microbenchmarks

Standalone microbenchmarks (not part of this PR's diff or test suite) reproducing this PR's device_surviving_shape/device_surviving_input_stride malloc → launch → free pattern, run on GTX 1660 SUPER / RTX 2080 Ti. Questions covered: (1) is the std::thread+cudaEvent deferred free actually faster than a direct cudaFree, (2) how does Storage's per-call array-build cost compare to the raw CUDA approach this PR uses, and (3, added after review) is skipping cudaMalloc entirely and passing the array as a kernel parameter viable.

1. cudaFree strategy: direct vs. thread-deferred vs. cudaFreeAsync

Swept surviving_rank 1–64 and a simulated kernel duration 0–2000us (a clock64() busy-wait standing in for real TraceKernel compute). Ranking was identical at every rank/duration combination tested; surviving_rank = 8 shown:

kernel duration direct cudaFree thread-deferred (this PR) cudaFreeAsync (naive sync) cudaFreeAsync (event-then-free)
0us 14.0us 42.0us 714.2us 13.3us
100us 95.6us 111.7us 794.8us 95.5us
500us 423.1us 492.4us 1125.9us 422.7us
2000us 1650.5us 1710.3us 2350.9us 1662.6us

What each case actually runs:

Direct — what this PR replaced:

cudaMalloc(&d_shape, ...); cudaMalloc(&d_stride, ...);
cudaMemcpy(d_shape, ...); cudaMemcpy(d_stride, ...);
TraceKernel<<<...>>>(...);
cudaDeviceSynchronize();  // explicit here for a clean timing boundary; a bare
                          // cudaFree() already does this internally
cudaFree(d_shape); cudaFree(d_stride);

Thread-deferred — this PR's actual pattern:

TraceKernel<<<...>>>(...);
cudaEvent_t free_ready;
cudaEventCreate(&free_ready);
cudaEventRecord(free_ready, 0);
std::thread([d_shape, d_stride, free_ready]() {
  cudaEventSynchronize(free_ready);
  cudaFree(d_shape); cudaFree(d_stride);
  cudaEventDestroy(free_ready);
}).detach();

cudaFreeAsync, naive (full stream sync):

cudaMallocAsync(&d, ..., 0); cudaMemcpyAsync(d, ..., 0);
TraceKernel<<<...>>>(...);
cudaFreeAsync(d, 0);
cudaStreamSynchronize(0);  // the trap: this also waits for the free, which
                           // exposes the stream as idle and trips the memory
                           // pool's default release-to-OS behavior

cudaFreeAsync, event-then-free (the actual fix):

TraceKernel<<<...>>>(...);
cudaEventRecord(kernel_done, 0);
cudaEventSynchronize(kernel_done);  // waits only for the kernel
cudaFreeAsync(d, 0);                 // enqueued, not waited on

Takeaway: direct cudaFree beats the std::thread+cudaEvent pattern at every rank and kernel duration tested — thread-creation overhead (~25–45us here) outweighs the device-wide-sync stall it's avoiding, since that stall is bounded by the same in-order-stream kernel a direct cudaFree would also wait on. If avoiding the stall matters (e.g. concurrent unrelated streams elsewhere on the device), cudaFreeAsync synchronized via an event recorded before the free call matches direct cudaFree's cost without spawning a thread per call — the naive full-stream-sync version of cudaFreeAsync should be avoided, it's the worst of the four.

2. Memory-block comparison

Same call pattern, comparing six ways to build (or avoid building) the small per-call uint64 array — strategy 1 is this PR's actual approach, strategy 6 was added following this review comment and this one:

Strategy rank 1 rank 8 rank 64
1. std::vectorcudaMalloccudaMemcpy (this PR) 9.0us 8.6us 11.0us
2. thrust::universal_vector, reserved push_back 61.3us 85.9us 346.0us
3. cudaMalloc, per-element cudaMemcpy 9.1us 26.1us 166.1us
4. cudaMallocManaged, per-element host store 143.0us 146.0us 146.9us
5. cytnx::Storage (best of 3 variants below) 137.8us 135.9us 145.4us
6. no cudaMalloc__grid_constant__ kernel param 4.2us 4.3us 5.3us

1. std::vectorcudaMalloccudaMemcpy — this PR's actual approach:

std::vector<uint64_t> host(rank);
for (i) host[i] = ...;
cudaMalloc(&d, sizeof(uint64_t) * rank);
cudaMemcpy(d, host.data(), sizeof(uint64_t) * rank, cudaMemcpyHostToDevice);

2. thrust::universal_vector, push_back:

thrust::universal_vector<uint64_t> v;
v.reserve(rank);
for (i) v.push_back(...);
// kernel reads thrust::raw_pointer_cast(v.data())

3. cudaMalloc, elem-by-elem cudaMemcpy ("one by one"):

cudaMalloc(&d, sizeof(uint64_t) * rank);
for (i) {
  uint64_t val = ...;
  cudaMemcpy(d + i, &val, sizeof(uint64_t), cudaMemcpyHostToDevice);
}

4. cudaMallocManaged, direct host store:

cudaMallocManaged(&d, sizeof(uint64_t) * rank);
for (i) d[i] = ...;   // plain host pointer write, no copy API needed

5. cytnx::Storage — three variants tested (table shows the fastest per rank):

// (a) reserve-then-append (Storage has no dedicated reserve(), but resize()
//     only grows the buffer when newsize > capacity_, so shrinking back to 0
//     leaves the grown capacity_ in place):
Storage s(0, Type.Uint64, Device.cuda);
s.resize(rank); s.resize(0);
for (i) s.append(cytnx_uint64(...));  // now hits append()'s cheap
                                       // size_ != capacity_ branch, no realloc

// (b) sized constructor + at<>() write:
Storage s(rank, Type.Uint64, Device.cuda);  // one allocation, size_ = rank up front
for (i) s.at<cytnx_uint64>(i) = ...;  // valid host write: GPU Storage backs
                                       // onto cudaMallocManaged, not cudaMalloc

// (c) presized + from_vector:
Storage s = Storage::from_vector(host, Device.cuda);

6. no cudaMalloc/cudaMemcpy/cudaFree__grid_constant__ kernel parameter:

constexpr int kMaxTraceRank = 64;

struct TraceLayout {
  uint64_t rank;
  uint64_t data[kMaxTraceRank];
};

__global__ void TraceKernel(const __grid_constant__ TraceLayout layout, uint64_t* sink) {
  uint64_t acc = 0;
  for (uint64_t i = 0; i < layout.rank; ++i) acc += layout.data[i] * (i + 1);
  if (threadIdx.x == 0 && blockIdx.x == 0) *sink = acc;
}

// per call:
TraceLayout layout{};
layout.rank = rank;
for (i) layout.data[i] = ...;
TraceKernel<<<grid, block>>>(layout, sink);
cudaDeviceSynchronize();  // no free to wait for, but the call must still not
                          // return until the kernel is done

Takeaway: skipping cudaMalloc entirely and passing the layout array by value as a __grid_constant__ kernel parameter is confirmed correct and is the fastest option by a wide margin — flat ~4.2–5.3us across ranks 1–64, roughly 2x faster than this PR's current cudaMalloc+cudaMemcpy approach (strategy 1) and ~30x faster than any cytnx::Storage-based alternative. nvcc 12.6 at sm_75 accepts __grid_constant__ without complaint and the values read back correctly in the kernel.

One correctness caveat worth flagging explicitly: this fast path requires a compile-time cap on surviving_rank (kMaxTraceRank above, e.g. 64). cytnx::Tensor::rank() (== shape().size(), a std::vector) is otherwise unbounded — nothing in the codebase enforces a maximum tensor rank. So a production implementation of this optimization would need either a cytnx_error_msg guard for surviving_rank > kMaxTraceRank with a fallback to one of the heap-allocated strategies above, or to just accept that as a hard limit and document it. It shouldn't be adopted as an unconditional drop-in replacement for the current heap-allocated path without that fallback.

Also worth flagging separately: the unreserved Storage::append() pattern (no resize() first) is not in the strategy-5 table above because it scales catastrophically — 147us at rank 1 to 8.3ms at rank 64 — since each STORAGE_DEFT_SZ=2 growth step is a full device realloc+copy+free. Any GPU code path that calls Storage::append() in a loop without reserving capacity first is worth double-checking.


Not part of this PR's diff or CI — standalone benchmarks run locally against this branch, for informing the review discussion above.

IvanaGyro and others added 4 commits July 2, 2026 08:40
…uffers

The prior commit deferred freeing device_surviving_shape and
device_surviving_input_stride to a detached std::thread synchronized
on a CUDA event, to avoid cudaFree's implicit device-wide sync
stalling the host on every Trace() call.

That trade only pays off if the deferred wait is shorter than the
thread it runs on costs to spawn. Here it isn't: TraceKernel is
already launched on the same in-order default stream cudaFree would
wait on, so cudaFree's wait is bounded by the kernel's own
completion -- there's no additional stall to defer. The
std::thread construction and destruction on every call adds fixed
overhead on top of that same wait, at every grid size and kernel
duration.

Reverts to a direct cudaFree and drops the now-unused <thread>
include.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior commit reverted to a direct cudaFree for
device_surviving_shape/device_surviving_input_stride, since with
every op on the default stream, cudaFree's device-wide wait is
already bounded by TraceKernel's own completion -- no additional
stall to avoid.

That equivalence only holds because everything currently runs on the
default stream. Several internal GPU ops already create their own
non-default streams (cuEig_internal.cu, cuQuantumQr_internal.cu,
cuQuantumGeSvd_internal.cu, cutensornet.cu), so a genuinely
multi-stream call pattern is plausible as this backend grows. A
direct cudaFree synchronizes the whole device rather than just the
stream that produced the freed buffers, so once concurrent unrelated
stream work exists, it would stall that work too.

Switches the allocation/copy/free of the two layout buffers to
cudaMallocAsync/cudaMemcpyAsync/cudaFreeAsync on the default stream.
cudaEventRecord+cudaEventSynchronize after the kernel launch still
waits for TraceKernel specifically (not the whole device) before the
buffers are released; cudaFreeAsync then enqueues the release into
the stream-ordered memory pool without the host blocking on the
deallocation itself. cudaFreeAsync requires memory allocated via
cudaMallocAsync/cudaMallocFromPoolAsync, so the allocation and copy
calls switch to their async counterparts too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…u.hpp

ToCudaDType is a generic scalar-to-device-type trait with no
connection to Fill specifically; cuTrace_internal.cu including
cuFill_gpu.hpp only for this trait meant the trace implementation
depended on an unrelated operation's header.

Moves the trait to a new cuTypeTraits_gpu.hpp, leaving cuFill_gpu.hpp
with just the FillGpu declaration it's named for. cuFill_gpu.cu and
cuTrace_internal.cu now include the trait header directly instead of
picking it up as a side effect of including cuFill_gpu.hpp.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TraceImplGpu mixed cytnx_uint64 (diagonal_length, diagonal_stride, the
surviving-axis shape/stride arrays, surviving_rank, output_size) with
the cytnx_int64 Tensor::strides() and output_shape already needed, so
values crossed between the two with a static_cast at nearly every
assignment.

Switches every index/extent in TraceImplGpu, TraceKernel, and
DecodeDiagonalStartOffset to cytnx_int64 throughout, mirroring the CPU
TraceImpl (Trace_internal.cpp, PR #862): CheckedCastToInt64Gpu is now
the only place a uint64 shape value crosses into the signed side, and
std::ssize replaces a manual cast for surviving_rank. output_shape
also now doubles as the surviving-axis extent array uploaded to the
device, since after the cast it holds exactly the same values as the
former host_surviving_shape, removing that separate vector.
blockIdx/gridDim/threadIdx stay CUDA's native unsigned int, cast once
at the point they enter the int64 arithmetic, since they aren't part
of the host-side bookkeeping this mirrors and CUDA's execution model
defines them as unsigned.

Also names the previously-repeated literal `0` stream argument once
as a local `stream` variable, used consistently across the
cudaMallocAsync/cudaMemcpyAsync/cudaEventRecord/cudaFreeAsync calls,
and passes it explicitly to the TraceKernel launch's stream slot
(previously implicit) so every operation in this function is
demonstrably on the same stream rather than relying on the default
stream being the same thing by convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ianmccul

ianmccul commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

I guess it copies similar style from elsewhere in Cytnx, but for small parameters do not use CudaMalloc, but just pass directly into the kernel. CUDA passes __global__ kernel parameters through constant memory, the cost is negligible part of the kernel startup, << 1 μs.

constexpr int kMaxTraceRank = 64;   // a bit extreme, but OK.....

struct TraceLayout {
  int64_t rank;
  int64_t shape[kMaxTraceRank];
  int64_t input_stride[kMaxTraceRank];
};

TraceLayout layout{};
layout.rank = surviving_rank;
for (int i = 0; i < surviving_rank; ++i) {
  layout.shape[i] = output_shape[i];
  layout.input_stride[i] = surviving_input_stride[i];
}

TraceKernel<<<grid, block, 0, stream>>>(out, in, layout, ...);
cudaStreamSynchronize(stream);

The "Thread-deferred" code above is unsafe for the unified memory model. The function must not return until the GPU device has syncronized, otherwise host reads via unified memory will receive corrupted data.

@ianmccul

ianmccul commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

make sure layout is passed as __grid_constant__, otherwise each thread might get its own copy (it might be redundant, but __grid_constant__ guarantees that it stays in the GPU constant memory and not copied to local memory).

template <class T>
__global__ void TraceKernel(
    T* __restrict__ output_data,
    const T* __restrict__ input_data,
    const __grid_constant__ TraceLayout layout,
    cytnx_int64 output_size,
    cytnx_int64 diagonal_length,
    cytnx_int64 diagonal_stride) {
  ...
} 

and if using a __device__ helper function, pass layout to it by const reference.

TraceImplGpu always shipped the surviving-axis shape/stride arrays to
the device via cudaMallocAsync + cudaMemcpyAsync + cudaFreeAsync, even
though those arrays are only a handful of int64s for the vast majority
of tensor-network tensors (MPS/MPO rank 2-4, PEPS rank 4-6, MERA/TTN
rank 3-4). That round trip's allocator and driver-call overhead
dominates the actual reduction for calls this small.

Adds TraceLayout, a fixed-size (kMaxTraceRank = 32) struct holding the
surviving-axis rank/shape/stride, and TraceKernelFast, a kernel that
takes it by value as a __grid_constant__ parameter -- placed in
read-only per-grid constant memory rather than copied into each
thread's local memory -- instead of two device pointers. For
surviving_rank <= kMaxTraceRank (the common case), TraceImplGpu
populates the struct directly in the same pass that builds
output_shape and launches TraceKernelFast: no cudaMalloc, cudaMemcpy,
or cudaFree at all. 32 mirrors NumPy's historical NPY_MAXDIMS -- far
above any realistic tensor-network rank, and free in kernel
parameter/constant-memory budget regardless.

Above that cap, TraceImplGpu falls back to the previous
cudaMallocAsync/cudaMemcpyAsync/TraceKernel/cudaFreeAsync path, since
TraceLayout can't hold more than kMaxTraceRank entries. The fallback
rebuilds the stride list as three contiguous std::back_inserter range
copies around ax1/ax2 rather than a per-element branch, since this
path is only reached when rank is large enough for that branch to
cost something.

Adds SurvivingRankAtFastPathCapMatchesReference and
SurvivingRankAboveFastPathCapUsesFallback to tests/gpu/linalg_test/
Trace_test.cpp, pinning correctness on both sides of the
kMaxTraceRank boundary against the CPU reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ianmccul

ianmccul commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Please don't add a fallback case that uses cudaMalloc. It will never be used in practice, its just adding code for no purpose and a duplicated but more complicated path. The test for that case is very artificial, most of the legs of the tensor have dimension 1. But they can be trivially removed - the surviving legs that get passed into the kernel should all have dimension >= 2.

So that means the surviving_rank corresponds to a tensor of size at least $2^{\text{surviving rank}+2}$ (assuming you also identify the no-operation case where the legs that are traced over have dimension 1).

The number of bits that an x86-64 machine can physically address is 52. So the maximum conceivable size of surviving_rank is 50, even if its a tensor of int8 (which Cytnx would need to add), for a tensor of minimum size $2^{52}$ bytes. At current DDR5 prices that is at least US$50 million dollars, assuming you can find a machine that has 65,000 DIMM slots. That is just for the host memory, the GPU memory is about US$1 billion dollars. So just set

kMaxTraceRank = 50

and remove the fallback path. It is irrelevant for the cost of the kernel launch. You could set it to 128 and it will still fit comfortably in pre-sm_70 devices that have much smaller constant memory than sm_70+.

@IvanaGyro

Copy link
Copy Markdown
Member Author

Please don't add a fallback case that uses cudaMalloc. It will never be used in practice, its just adding code for no purpose and a duplicated but more complicated path. The test for that case is very artificial, most of the legs of the tensor legs have dimension 1. But they can be trivially removed - the surviving legs that get passed into the kernel should all have dimension >= 2.

So that means the surviving_rank corresponds to a tensor of size at least 2 surviving rank + 2 (assuming you also identify the no-operation case where the legs that are traced over have dimension 1).

The number of bits that an x86-64 machine can physically address is 52. So the maximum conceivable size of surviving_rank is 50, even if its a tensor of int8 (which Cytnx would need to add), for a tensor of minimum size 2 52 bytes. At current DDR5 prices that is at least US$50 million dollars, assuming you can find a machine that has 65,000 DIMM slots. That is just for the host memory, the GPU memory is about US$1 billion dollars. So just set

kMaxTraceRank = 50

and remove the fallback path. It is irrelevant for the cost of the kernel launch. You could set it to 128 and it will still fit comfortably in pre-sm_70 devices that have much smaller constant memory than sm_70+.

Surviving ranks may be 1 for 3-rank tensor.

kMaxTraceRank = 50 and no fallback seems reasonable if all ranks have at least 2 dimensions.

@ianmccul

ianmccul commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Surviving ranks may be 1 for 3-rank tensor.

Not sure what you mean here - surviving ranks could be zero, for trace of rank-2 tensor (or higher rank where some of the dimensions are 1). Did you misread my previous comment? I notice that the quote has munged the LaTeX - "at least 2 surviving rank" was part of a Latex expression 2^{surviving rank + 2} as the minimum number of elements.

@IvanaGyro

IvanaGyro commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Surviving ranks may be 1 for 3-rank tensor.

Not sure what you mean here - surviving ranks could be zero, for trace of rank-2 tensor (or righer rank where some of the dimensions are 1). Did you misread my previous comment? I notice that the quite has munged the LaTeX - "at least 2 surviving rank" was part of a Latex expression 2^{surviving rank + 2} as the minimum number of elements.

kernel should all have dimension >= 2.

Sorry, I misread. I read this to kernel should all have rank >= 2. Everything is good now.

@IvanaGyro

Copy link
Copy Markdown
Member Author

(Comment written by Claude on behalf of @IvanaGyro.)

Validating the async-allocator + grid-constant fixes against the actual Trace kernel

Follow-up to the microbenchmarks above, now measuring the real BM_gpu_Trace_Strided_3D_* benchmarks (benchmarks/linalg/Trace_bm.cpp) across the actual commits, isolating the grid-constant optimization's contribution on top of the async-allocator switch. GTX 1660 SUPER, release openblas-cuda build, system idle for all runs.

Benchmark cudaMallocAsync/cudaFreeAsync, no grid-constant (1f745365) __grid_constant__ + async fallback (latest, d5b98c73)
Strided_3D_Double/256/64 27.5us 16.4us
Strided_3D_Double/1024/16 28.9us 18.0us
Strided_3D_Double/2048/16 31.5us 20.4us
Strided_3D_Double/4096/8 33.8us 23.1us
Strided_3D_ComplexDouble/256/64 28.2us 17.4us
Strided_3D_ComplexDouble/1024/16 29.9us 18.9us
Strided_3D_ComplexDouble/2048/16 32.4us 21.6us
Strided_3D_ComplexDouble/4096/8 34.9us 24.1us

Strided_2D (rank-2, surviving_rank == 0) is flat across both commits, as expected — that path never allocated a layout buffer even before either fix, since the decode loop is empty when there are no surviving axes. Matvec_3D/Vecdot_2D/Reshape_2D (unrelated reference paths using other cytnx ops) are also unchanged across both commits, confirming no regression elsewhere.

Takeaway: skipping the allocation entirely via __grid_constant__ is a genuine additional win on top of the async-allocator switch — roughly a further 40% reduction (e.g. 27.5us → 16.4us), not a redundant micro-optimization once cudaMallocAsync/cudaMemcpyAsync/cudaFreeAsync are already in place. Those three calls still carry real per-call driver overhead even without a thread spawn or a full-device sync, and the grid-constant path removes them entirely for the common surviving_rank <= kMaxTraceRank case.


Not part of this PR's diff or CI — standalone benchmark runs against the two commits above, for informing the review discussion.

@ianmccul

ianmccul commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Strided_2D (rank-2, surviving_rank == 0) is flat across both commits, as expected

That is actually a key point. The overhead of passing layout to the CUDA kernel is zero. The kernel launch needs to assemble the parameters, grid size etc and copy it to the GPU anyway, and adding ~500 bytes of data to that has no measurable effect.

…eRank to 50

TraceImplGpu kept a cudaMallocAsync/cudaMemcpyAsync/cudaFreeAsync
fallback for surviving_rank > kMaxTraceRank, guarding a case that
cannot occur on any physically constructible machine: every surviving
axis has extent >= 2 (a size-1 axis contributes nothing to the
diagonal decode and would be squeezed away rather than kept), so a
tensor with surviving_rank axes plus the two traced axes has at least
2^(surviving_rank + 2) elements. x86-64 addresses at most 2^52 bytes,
so even a 1-byte-per-element dtype caps surviving_rank at 50 -- no
dtype/hardware combination can reach 51. Raising kMaxTraceRank from
32 to 50 costs nothing extra against CUDA's per-launch parameter/
constant-memory budget, so there was no reason to keep the fallback
path duplicating the same reduction logic through a second kernel
just to handle an unreachable case.

Removes the fallback branch and the now-dead pointer-parameter
TraceKernel, renaming TraceKernelFast to TraceKernel since it is once
again the only kernel. Replaces the removed fallback with a defensive
cytnx_error_msg guard: if surviving_rank ever did exceed
kMaxTraceRank, TraceLayout's shape/stride arrays beyond that index
were never populated by the build loop, so proceeding would read
uninitialized stack memory on the device -- the guard turns that into
a clear error instead of silent corruption.

tests/gpu/linalg_test/Trace_test.cpp: replaces
SurvivingRankAtFastPathCapMatchesReference (rank 32) and
SurvivingRankAboveFastPathCapUsesFallback (rank 33, the now-removed
path) with a single SurvivingRankAtTraceLayoutCapMatchesReference at
the new rank-50 boundary, since there is no separate fallback path
left to pin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@IvanaGyro
IvanaGyro marked this pull request as ready for review July 9, 2026 02:19
@IvanaGyro
IvanaGyro requested review from pcchen and yingjerkao July 9, 2026 02:20

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d28f0edc6

ℹ️ 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".

Comment thread src/backend/linalg_internal_gpu/cuTrace_internal.cu Outdated
TraceImplGpu's kMaxTraceRank derivation assumed every surviving axis
has extent >= 2, but the code stored every surviving axis in
TraceLayout regardless of extent -- including size-1 axes, which the
public API does not disallow and does not squeeze away. A tensor with
many size-1 surviving axes (e.g. shape {2, 2, 1, 1, ...} with 51+
trailing 1s, traced over the first two axes) has only a handful of
elements, so it satisfies the address-space argument trivially, yet
the code rejected it once surviving_rank exceeded kMaxTraceRank -- a
regression the fallback path this branch removed would not have hit.

A surviving axis of extent 1 contributes nothing to
DecodeDiagonalStartOffset's per-output decode: its odometer step is
always (remaining_flat_index % 1 == 0, remaining_flat_index /= 1
leaves it unchanged, stride * 0 == 0), so omitting it from the decode
arrays changes nothing about the offsets computed for the other axes.
TraceImplGpu now only stores axes with extent > 1 in TraceLayout, and
counts only those toward kMaxTraceRank; output_shape (used for
Tensor::reshape_) still includes every surviving axis regardless of
extent, so a size-1 surviving axis still appears in the output shape
correctly. This is what makes kMaxTraceRank's derivation (every
counted axis has extent >= 2) true of what's actually stored, rather
than merely assumed of the input.

DecodeDiagonalStartOffset's parameters are renamed from
surviving_shape/surviving_input_stride/surviving_rank to
axis_shape/axis_input_stride/axis_count, since the count it receives
(TraceLayout::rank) is no longer the tensor's literal surviving rank
and the old names invited exactly this kind of confusion.

tests/gpu/linalg_test/Trace_test.cpp: replaces the previous cap
boundary test (which, after this fix, no longer exercises the cap at
all, since all its surviving axes are size-1) with
SingletonSurvivingAxesDoNotCountTowardTraceLayoutCap, matching this
regression directly, and MixedSingletonAndNonTrivialSurvivingAxesPreserveShape,
covering singleton and non-trivial surviving axes interleaved in the
same tensor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ab7ee0911

ℹ️ 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".

const cytnx_int64 dim = CheckedCastToInt64Gpu(input_shape[axis], "input_shape[axis]");
output_shape.push_back(dim);
if (dim > 1) {
cytnx_error_msg(non_trivial_rank >= kMaxTraceRank,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid rejecting empty high-rank trace outputs

When any surviving axis has extent 0, output_size becomes 0 and the function should take the zero-output early return without launching TraceKernel, so the fixed-size TraceLayout is not needed. This check runs before output_size is computed, so a cheap zero-extent tensor such as reshape({2, 2, 0, 2, ...}) with more than 50 extent-2 surviving axes now errors on GPU while the CPU path returns an empty tensor with the requested shape.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in bf13a1a. The kMaxTraceRank guard ran inside the axis-building loop, before output_size was known — so it could reject a tensor whose eventual output is empty, exactly as you describe.

Moved the guard to after output_size is computed and after the zero-extent early return, so it only fires when a real kernel launch (and thus TraceLayout) is actually about to happen. The loop no longer errors inline; it just stops writing into the fixed arrays once they're full while still tracking the true non_trivial_rank, so the guard has an accurate count whenever it does run.

Added ManyNonTrivialSurvivingAxesWithZeroExtentReturnsEmptyOutput to tests/gpu/linalg_test/Trace_test.cpp, close to your example: 51 non-trivial (extent-2) surviving axes plus one zero-extent surviving axis, built via the existing ZeroExtentGpuTensor helper so it stays cheap.


Generated by Claude Code

…ank guard

TraceImplGpu's kMaxTraceRank guard ran inside the loop that builds
output_shape and TraceLayout, before output_size was known. A tensor
with more than kMaxTraceRank non-trivial (extent > 1) surviving axes
plus one zero-extent surviving axis has output_size == 0 -- the
zero-extent early return should apply, matching the CPU path's empty
output -- but the guard rejected it first, since it never got to see
that the eventual output was empty.

Moves the kMaxTraceRank check to after output_size is computed and
after the zero-extent early return, so it only fires when a real
kernel launch (and thus TraceLayout) is actually about to happen. The
loop itself no longer errors inline; it just stops writing into
layout.shape/stride once the fixed arrays are full, tracking the true
non_trivial_rank regardless, so the guard has an accurate count to
check once it does run.

Adds ManyNonTrivialSurvivingAxesWithZeroExtentReturnsEmptyOutput to
tests/gpu/linalg_test/Trace_test.cpp: a tensor with 51 non-trivial
surviving axes and one zero-extent surviving axis, built via
ZeroExtentGpuTensor so it stays cheap despite the rank.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@yingjerkao yingjerkao 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.

LGTM

@IvanaGyro
IvanaGyro merged commit bbf8e70 into master Jul 9, 2026
19 checks passed
@IvanaGyro
IvanaGyro deleted the claude/gallant-franklin-4g4rY branch July 9, 2026 05:18
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.

Refactor Tensor trace: low-level backend depends on UniTensor (API encapsulation violation)

4 participants