refactor(trace): compute the CPU Tensor trace in the container layer#862
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the linalg::Trace implementation to perform direct, stride-aware diagonal sums instead of falling back to expensive tensor contractions. It introduces a Tensor::strides() method to retrieve the storage strides of a tensor, adds comprehensive unit tests for both strides and trace operations, and includes benchmarks to evaluate performance. A review comment suggests optimizing the multi-index and base offset calculation in the CPU trace hot loop by replacing division and modulo operations with an odometer-like incremental index update.
IvanaGyro
left a comment
There was a problem hiding this comment.
(Review written by Claude on behalf of @IvanaGyro.)
Nice rewrite — moving the trace into a single stride-aware TraceImpl<T> over the container layer removes the UniTensor::eye + Contract round-trip and the old 2D path's contiguity assumption (rawdata[i*Ldim + i]). I worked through the offset math and it is sound: for any output multi-index, base + (Ndiag-1)*diag_stride lands on a valid element, so the std::span(data+base, extent) end pointer is at most one-past storage — no OOB. Tensor::strides() is the right primitive and Tensor_strides_test validates it against at() across permutations/dtypes. CPU coverage (permuted ranks 2–5, Double/ComplexDouble/Int32, 2D path, output-rank, swapped axes) is solid and all of it passed on both openblas-cuda and mkl-cuda. Two things inline: one stacked-build concern and one test gap. Also a minor note: the Reshape_2D benchmark mutates the shared storage of a const input via resize() and relies on shrink-not-reallocating to restore it — it's verified once against Trace, but it's a fragile trick to leave in a committed benchmark; a comment-backed assertion that capacity was preserved would harden it.
dd601ed to
92ae99d
Compare
621a730 to
915105b
Compare
92ae99d to
f894e02
Compare
cce87ec to
954e366
Compare
|
Pushed the rebased branch (
Tests on the rebased tip: Open inline:
Generated by Claude Code |
1e86067 to
539c057
Compare
954e366 to
6b5a73a
Compare
|
Pushed
No behavior change. Generated by Claude Code |
539c057 to
6777742
Compare
f19ceb6 to
b7b48b2
Compare
6777742 to
9c8e4c0
Compare
b7b48b2 to
9fa5b19
Compare
|
Rebased onto the new mdspan-based #860 (no longer has Diff at the top commit:
Generated by Claude Code |
9c8e4c0 to
87ee88a
Compare
9fa5b19 to
2184f11
Compare
87ee88a to
ece64c0
Compare
dfa0fe9 to
07ee406
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #862 +/- ##
==========================================
+ Coverage 30.15% 30.87% +0.71%
==========================================
Files 238 229 -9
Lines 35429 34757 -672
Branches 14745 14409 -336
==========================================
+ Hits 10685 10730 +45
+ Misses 17450 16720 -730
- Partials 7294 7307 +13
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 14 files with indirect coverage changes Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
3265c7b to
00d0a8a
Compare
00d0a8a to
ac4f5e5
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac4f5e5d7e
ℹ️ 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".
The CPU Tensor trace built an identity UniTensor and called Contract, so the
container-level linalg_internal layer depended on the higher-level UniTensor
and Contract APIs -- an inverted layering. Compute the trace directly over
storage instead, and tighten the internal Trace API at the same time.
* Simplify the dispatch API: every Trace_internal_* / cuTrace_internal_*
dispatcher (and the Tracefunc_oii typedef) now takes only (Tn, ax1, ax2) and
returns the result Tensor. A small TraceParams helper
(backend/linalg_internal_cpu/trace_dispatch.hpp) derives Ndiag, Nelem, accu,
remain_rank_id, the reduced shape, and the is_2d flag once per call from
shape and axes, so the dispatchers and the _trace_2d / _trace_nd kernels no
longer plumb those through individually. Trace.cpp loses its derivation
block and is now just validation + a dtype-keyed dispatch call.
* Add a public Tensor::strides() getter (declared in include/Tensor.hpp,
defined in src/Tensor.cpp): for each logical axis, the storage offset
increment per +1 step, derived from the shape and inverse mapper -- the same
layout Tensor::at already indexes through. This is what the dispatchers use
to sum the diagonal in place.
* Replace the body of _trace_2d / _trace_nd with a stride-aware reduction
via PairwiseSum over a new range adaptor stride_view (snake_case, with a
`r | stride(k)` pipe form; iterator stores the underlying base iterator so it
stays valid independent of the view's lifetime). The diagonal stride is
strides[ax1] + strides[ax2], so the previous contiguous() copy is dropped,
and floating-point traces get the same O(log N * eps) error bound as
linalg::Sum. Guard Ndiag == 0 / Nelem == 0 (unsigned (Ndiag - 1) would
otherwise underflow into a huge span extent). Drop the UniTensor and
Generator includes from the CPU translation unit.
* On the GPU side, the cuTrace_internal_* dispatchers in cuTrace_internal.cu
are rewritten as new-signature trampolines that derive is_2d (rank == 2) and
Ndiag (shape[ax1]) from the input and forward to the existing Contract-based
_trace_2d_gpu / _trace_nd_gpu helpers. The helpers themselves keep their
bodies and only drop the unused Nelem / accu / remain_rank_id / shape
parameters from _trace_nd_gpu's signature. This keeps the GPU TU
link-consistent with the new cuTrace_internal.hpp declarations on this
commit alone; the native CUDA-kernel rewrite of the GPU helpers lands as a
separate commit in the stacked GPU branch.
* tests/linalg_test/stride_view_test.cpp exercises the iterator concept model,
the pipe form, the iterator-outlives-view case, the non-dividing
size-vs-stride branch, the zero-stride throw, and complex strided summation.
Tests are reached through src/ added to the test binary's include path, so
internal headers can be unit-tested directly.
* tests/Tensor_strides_test.cpp checks strides() against Tensor::at across
ranks 2-5 permutations and across dtypes.
* tests/linalg_test/Trace_test.cpp covers the strided in-place path against
the contiguous-clone reference across ranks 2-5, complex and integer dtypes,
the rank-2 / rank-N output shape invariants, and swapped axis order.
* benchmarks/linalg/Trace_bm.cpp pits the in-place strided Trace against the
"collect contiguously and reduce" alternatives Ivana asked for: the 3D
contiguous baseline is a BLAS matrix-vector multiplication
(tr(A[d,m,d]) over d as {middle, n*n} @ {n*n, 1} against vec(I_n)), and the
2D contiguous baseline is the reshape trick (save A[n-1, n-1], view the rest
as {n-1, n+1}, permute+contiguous, Sum the resulting first row, add the
saved entry). On openblas-cpu debug, the strided path beats both baselines
by orders of magnitude at the tested shapes.
* The discovery timeout for gtest_discover_tests is bumped to 120 s so the
test binary's ASAN+MKL listing fits on slower runners.
Co-authored-by: Claude <noreply@anthropic.com>
The ND CPU trace decoded each flat output index into an input base offset by dividing and taking the modulo against precomputed row-major accumulators on every element. Division and modulo dominate that hot loop, and the accumulator vector only existed to support them. Carry the base offset on an odometer instead: precompute the input stride for each surviving axis once, then for each output element bump the last axis index (carrying into earlier axes on wrap) and adjust the base offset by the affected axes' strides. Advancing is amortized O(1) per element with no division, modulo, or per-element flat-index decode, and the row-major accumulator vector is gone. Also drop the now-unused backend/lapack_wrapper.hpp and utils/utils.hpp includes, left over from the previous Contract-based implementation. Co-authored-by: Claude <noreply@anthropic.com>
TraceImpl<T> allocated the result as a Tensor and then reached through out.storage() to fill it, mixing the container and storage layers. Fill a flat result Storage directly and compose the Tensor with Tensor::from_storage once the diagonal sums are written, so the storage is populated at the storage layer and only promoted to a Tensor (and reshaped to the reduced rank) at the end. Behavior is unchanged: the 2D trace yields a single-element result, the ND trace one element per remaining-rank multi-index, and the Ndiag == 0 / Nelem == 0 guard still returns a zeroed result of the reduced shape. Co-authored-by: Claude <noreply@anthropic.com>
…eImpl Two simplifications to the CPU TraceImpl<T>, neither changing behavior: * Remove the std::min/std::max on the trace axes. linalg::Trace validates upstream that ax1 != ax2 and shape[ax1] == shape[ax2], and both quantities TraceImpl derives from the axes -- the diagonal stride strides[ax1] + strides[ax2] and the set of surviving axes (every axis that is neither ax1 nor ax2) -- are symmetric in the two axes, so their order never mattered. * Build the reduced output shape and the per-output-axis input strides in a single pass over the surviving axes, instead of first recording a remain_rank_id list and then indexing strides[remain_rank_id[x]] in a second loop. The odometer hot loop reads out_strides[x] directly. Rename the locals to snake_case (n_diag, n_elem, out_rank) per the Google C++ style guide. Co-authored-by: Claude <noreply@anthropic.com>
The previous identifiers (n_diag, n_elem, out_rank, out_shape, out_strides, data, out_data, base, index, extent, is_2d) were terse abbreviations that described how each variable was used in the loop rather than what it represents in the trace algorithm. Rename them after the concept each one carries: n_diag -> diagonal_length diag_stride -> diagonal_stride extent -> diagonal_span (storage span first..last + 1) n_elem -> output_size out_rank -> surviving_rank (rank of the non-traced axes) out_shape -> output_shape (target shape for reshape_) out_strides -> surviving_input_stride (input stride per surviving axis) is_2d -> output_is_scalar (rank-2 input -> scalar output) shape_in -> input_shape strides -> input_strides data -> input_data out_data -> output_data base -> diagonal_start_offset (start of this output's diagonal) index -> surviving_index (per-surviving-axis odometer) out_storage -> output_storage i / x -> output_index / axis (loop counters by role) No behavior change. Co-authored-by: Claude <noreply@anthropic.com>
…e=8192} shapes The existing 3D Trace shapes all keep middle small (≤ 64) so the diagonal length n dominates the work; the matvec baseline never has enough small-n GEMM rows to amortize its O(n²·middle) traffic against trace's O(n·middle). Add two shapes with the opposite balance — small n and large middle, where BLAS could plausibly close the gap by chaining many short dot-products through tuned kernels — so the comparison is recorded across both regimes rather than only the strided-favourable one. The pair fits in memory at the existing benchmark scale (vec(I_n) and packed each ≤ 8M elements, ~64 MB for double). Co-Authored-By: Claude <noreply@anthropic.com>
The odometer's wrap step subtracts (output_shape[axis] - 1) * stride from diagonal_start_offset to roll the index back to zero. output_shape was already cytnx_int64 (Tensor::reshape_ takes signed extents), so the old code pulled the operands through an explicit static_cast<cytnx_uint64> to do the subtraction in unsigned space and feed an unsigned offset. Make the surviving-axis strides, the surviving-axis index vector, the diagonal start offset, the output linear index, and the surviving rank all cytnx_int64 instead. The wrap subtraction is now a same-type signed expression with no casts, the inner axis loop steps -1..0 with a natural 'axis >= 0' termination rather than the 'axis-- > 0' unsigned-wrap trick, and the comparison ++index[axis] < output_shape[axis] no longer mixes signednesses. Co-Authored-By: Claude <noreply@anthropic.com>
…dex types Tensor::strides() returned std::vector<cytnx_uint64>, but TraceImpl's odometer walk needs signed offsets (it subtracts during wrap-around). This forced a static_cast at every call site and made it easy to miss one of the index types in the function, since some stayed unsigned and others were upgraded to cytnx_int64 ad hoc. Change Tensor::strides() to return std::vector<cytnx_int64> directly, update TraceImpl to use the signed strides without re-casting, and make diagonal_length, diagonal_stride, and diagonal_span consistently cytnx_int64 instead of mixing in cytnx_uint64. The std::vector<cytnx_int64> surviving_index constructor takes its count by value, so the existing static_cast<std::size_t>(surviving_rank) was redundant once surviving_rank is already a signed type that converts implicitly. Add cytnx::internal::CheckedCastToInt64 in include/utils/checked_cast.hpp for the narrowing conversions that remain: input_shape (and storage sizes in general) are still cytnx_uint64, so converting a shape entry to the signed index type used by TraceImpl can in principle overflow. The helper raises a cytnx_error_msg instead of silently wrapping, and is used both in Tensor::strides() and in TraceImpl's shape-to-index conversions. Tensor_strides_test.cpp's ExpectStridesMatchAt helper is updated to accumulate the offset in cytnx_int64 to match the new return type. Co-Authored-By: Claude <noreply@anthropic.com>
ac4f5e5 to
4fe03fc
Compare
|
@pcchen May you review this PR again? Or are there someone else can review this PR? |
|
@IvanaGyro yes. I will review again. |
Review-thread status check — are all raised issues fixed?Verified each of the 9 review threads against the actual code on Genuinely fixed in code (7)
Resolved by declining the change (1) — judgment call, not a code change
Marked resolved but NOT actually fixed (1)
|
|
@IvanaGyro can you add test for the "Zero-extent path"? |
Tensor's own constructor and Tensor::get() (slicing) both reject a 0
in any shape dimension, so the existing test suite never exercised
TraceImpl's `diagonal_length == 0 || output_size == 0` early-out
branch. Add a ZeroExtentTensor helper that builds a genuinely
zero-extent Tensor by composing a zero-element Storage (whose Init
does not reject zero length) via Tensor::from_storage and reshape_,
then cover the three zero-extent shapes relevant to Trace:
- {0,0} traced over (0,1): scalar 0 (empty diagonal sums to 0).
- {3,0,0} traced over (1,2): shape {3}, all zero (empty diagonal per
surviving index).
- {3,0,3} traced over (0,2): shape {0} (zero-length surviving axis
produces an empty result).
These match NumPy's np.trace convention for the equivalent
zero-extent inputs.
Co-Authored-By: Claude <noreply@anthropic.com>
@pcchen Yes, added. And here is the decision about tracing zero-dim tensors: #862 (comment) |
|
The failling check will be fixed in #960. I will approve and merge this. |
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>
Part of #834.
Problem
The low-level
Tensortrace built an identityUniTensorand calledContract, so the container-levellinalg_internallayer depended on the higher-levelUniTensor/ContractAPIs — an inverted layering. Compute the trace directly over storage instead, and tighten the internal Trace API at the same time.Change
Trace_internal_*(CPU) andcuTrace_internal_*(GPU) now takes only(Tn, ax1, ax2)and returns the resultTensor. The per-dtype dispatchers each call a single templatedTraceImplin an unnamed namespace that derivesdiagonal_length/output_size/ per-surviving-axis layout / theoutput_is_scalarflag inline fromTn.shape()andTn.strides().Trace.cppis just validation + a dtype-keyed dispatch call.Tensor::strides()getter (snake_case, mirrorsshape()/ NumPy's.strides): for each logical axis, the storage stride derived from_shapeand_invmapper. The CPU trace calls it; refactor(trace): implement the GPU Tensor trace as CUDA kernels #850 reuses it for the GPU.PairwiseSumover thestride_viewadaptor (from feat(stride_view): add a strided random-access range adaptor #860) with diagonal stride =strides[ax1] + strides[ax2]. The previouscontiguous()copy is dropped; floating-point traces get the sameO(log N · ε)error bound aslinalg::Sum.diagonal_length == 0/output_size == 0guarded with an early zeroed return.Tensor::from_storageis used to compose the output: fill a flat resultStoragedirectly, then promote it to aTensoronce the diagonal sums are written, so the storage layer stays at the storage layer until the final return.cuTrace_internal.hppare kept consistent with the CPU side (the actual GPU implementation lands in refactor(trace): implement the GPU Tensor trace as CUDA kernels #850).Tests
tests/Tensor_strides_test.cpp— walks every multi-index across rank-2..5 permutations and dtypes, assertingflatten(idx · strides()) == offset at()actually reads (so ifstrides()ever drifts from the layoutat()indexes through, every case fails).tests/linalg_test/Trace_test.cpp— strided in-place Trace vslinalg::Trace(t.contiguous(), ...)reference across ranks 2–5, complex + integer dtypes, the rank-2 path, output rank invariants (rank-N → rank-(N-2)), and swapped axis order.Benchmark
benchmarks/linalg/Trace_bm.cpppits the in-place strided Trace against the "collect contiguously and reduce" baselines:tr(A)[m] = ⟨vec(I_n), vec(A[:, m, :])⟩as a{middle, n·n}@{n·n, 1}BLAS GEMM againstvec(I_n).tr(A) = ⟨vec(I_n), vec(A)⟩as a BLAS dot product on then·n-element flattenings.A[n-1, n-1], view the rest as{n-1, n+1}, permute + contiguous so the first column becomes a contiguous row, reduce it, add the saved corner. The{n-1, n+1}view reuses the source storage in place (Storage::resizeis a no-realloc metadata shrink), so thepermute → contiguousgather is the only copy; the size is restored (and the dropped corner written back) afterwards.Every variant runs once before timing and is checked against
linalg::Trace; a wrong baselinestd::aborts instead of producing fast-but-meaningless numbers.Numbers below are from
openblas-cpu(RelWithDebInfo) at the current branch tip (a9d4866),--benchmark_min_time=0.3s, CPU times in µs (smaller is better). Strided wins at every tested size in every regime — trace touchesO(n·middle)diagonal elements while the GEMM baseline touches allO(n²·middle), so BLAS throughput never closes the gap, even in the small-n / large-middle regime where many short dots chain through tuned kernels:3D —
{n, middle, n}traced over (0, 2):The last two rows are the BLAS-favorable shapes (small
n, largemiddle) added at review request: strided still wins by 34–74×.2D —
{n, n}traced over (0, 1):Test plan
openblas-cpu: full suite 1072/1072 passed.linalg::Tracebefore timing (the benchmarkabort()s if any baseline disagrees) across all sizes above.pre-commit(clang-format-14 + EOF + whitespace) clean.Draft — opened for review.