Skip to content

Device support for TA::Tensor via TA::UMTensor - #554

Merged
evaleev merged 46 commits into
masterfrom
ajay/feature/umtensor
Nov 12, 2026
Merged

evaleev merged 46 commits into
masterfrom
ajay/feature/umtensor

Conversation

@ajay-mk

@ajay-mk ajay-mk commented May 22, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds device (CUDA/HIP) support to TA::Tensor through TA::UMTensor<T>, a TA::Tensor backed by Umpire-managed unified memory (Tensor<T, device_um_allocator<T>>).

Following the review discussion, the device code now lives inside TA::Tensor itself instead of a separate set of UMTensor overloads: each tile op checks whether its operands are UM tensors of a BLAS element type (float, double and their complex types) and, if so, runs a device kernel, otherwise the existing host code. Nothing changes for host tiles.

Design

Each TA::Tensor member that has a device kernel wraps the device branch in TA_ON_DEVICE(types...) (device/dispatch.h). The macro expands to an if chain whose last else is the block written after it:

flowchart TD
    A["tile op, e.g. add_to"] --> B{"device_executable_v of the operand types<br/>(if constexpr)"}
    B -- "false: other tile types, CPU-only builds<br/>(discarded at compile time)" --> H["existing host code"]
    B -- true --> C{"execution_space() == Device<br/>(runtime)"}
    C -- "false: inside ExecutionSpaceScope(Host)" --> S["device::synchronize():<br/>wait for kernels this task already queued"] --> H
    C -- true --> D["device kernel<br/>(blaspp, thrust, librett)"] --> R["return result"]
Loading

For example, Tensor::add_to (tensor/tensor.h), after its existing early exits for empty tiles:

TA_ON_DEVICE(Tensor, Right) {
  TA_ASSERT(detail::is_range_set_congruent(*this, right) &&
            nbatch() == right.nbatch());
  device::axpy(total_size(), numeric_type(1), right.data(), data(),
               range_);
  return *this;
}
return inplace_binary(right, [](value_type& MADNESS_RESTRICT l,
                                const value_t<Right> r) { l += r; });
  • Dispatch. The device branches are discarded at compile time for every other tile type and in builds without device support, so TA::Tensor needs no #ifdefs. Which tile types qualify is decided by the allocator's traits: allocator_memory_space_v<A> (platform.h) gives the memory space an allocator type allocates in (Device_UM for device_um_allocator), and device_executable_v requires a device-visible space, a BLAS element type, and the same type for every operand. is_device_tile<UMTensor<T>> is defined next to it, so every translation unit that uses TA::Tensor sees it.

  • Kernels. gemm, copy, scal, axpy, mult, mult_to, permute, dot and inner_product are declared in device/dispatch.h and compiled once in device/tensor.cpp (blaspp, thrust, librett), so user translation units do not include device headers.

  • Members with device branches. gemm, scaling, negation, add/subt (with factor and permutation), Hadamard product, permute, clone (also used by shift), the in-place forms, dot, inner_product and squared_norm. Ops with no device kernel (e.g. conj) synchronize the task's device work, then run the host code.

  • ExecutionSpaceScope (platform.h) switches UMTensor ops to the host code for its lifetime, for debugging and as a reference. The setting is process-wide and read when each task runs; open scopes from the main thread only, after TA::initialize (the constructor throws otherwise). Under a Host scope, UnaryEvalImpl/BinaryEvalImpl skip the device task for these tiles. A task already running when a scope opens may have queued kernels before the switch; the Host branch of TA_ON_DEVICE waits for them before the host code reads the data.

    using Array = TA::DistArray<TA::UMTensor<double>>;
    Array c, c_host;
    c("i,j") = a("i,k") * b("k,j");  // tile ops run on the GPU
    {
      TA::ExecutionSpaceScope host(TA::ExecutionSpace::Host);
      c_host("i,j") = a("i,k") * b("k,j");  // same UMTensor arrays, host code
      world.gop.fence();  // finish the work before the scope ends
    }
  • Expression engine. is_device_tile<UMTensor<T>> routes tile ops through madness::add_device_task, as for the existing BTAS device tile.

  • Helpers (device/tensor.h): tile and array to_device/to_host prefetch, ta_tensor_to_um_tensor/um_tensor_to_ta_tensor (keeping every batch; a no-op when both tile types are the same, as in code generic over the tile type), and the tile and array conversions. Archiving needs no specialization: Tensor::serialize of a UMTensor waits for the task's device work and prefetches the data to the host before storing it, in every translation unit.

Changes that affect existing code

  • sync_madness_task_with outside a device task now blocks until the stream finishes (external/device.h). Before, the thread-local stream was preset, so a call from a plain task (e.g. foreach, truncate) recorded nothing and returned while the kernel was still running. This applies to every device tile, including btasUMTensor. Outside device tasks, stream_for/blasqueue_for now pick the stream by tile offset instead of always using stream 0.
  • PaRSEC's own GPU support is off by default (cmake/modules/FindOrFetchMADWorld.cmake). With it on, each PaRSEC context reserves 95% of each visible GPU's free memory at startup, and TA does not run PaRSEC device tasks. These are cache defaults, so a parent project that sets PARSEC_GPU_WITH_CUDA (or _HIP, _LEVEL_ZERO) first keeps its value.
  • Storage freed inside a device task is released after the task's device work completes (external/device.h, device/device_task_fn.h). A UMTensor temporary (e.g. a lazy block argument materialized by BinaryWrapper, or the unpermuted sum in add(right, perm)) can drop its last reference while kernels reading it are still queued; returning it to Umpire's pool then would let another thread reuse the block under those kernels. Tensor's deleter now hands the release to the enclosing device task (device::defer_until_task_completes), which runs it when the task is destroyed, after its stream callback. Outside device tasks every kernel has already waited for its stream, so storage is released at once. This replaces the blocking device::synchronize() calls that the permuted add/subt/mult and axpy_to(perm) used to make before freeing their temporaries.
  • Host reductions on UM tiles synchronize first (Tensor::reduce): a reduction task converts lazy tiles inside a device task, so a block's shift could queue a device copy that the host reduction read before it finished.
  • MADNESS pin bumped to 3ff5ac06d (the merge of world: let several foreign threads schedule on the PaRSEC backend m-a-d-n-e-s-s/madness#849; the range from the previous pin also brings in unrelated worlddc/cloud archive-chunking and mra commits, none of which TA uses): with the PaRSEC backend, several threads that PaRSEC did not create, such as the thread that runs CUDA stream callbacks, can now submit tasks. Before, the second such thread aborted at 2 or more ranks.
  • detail::gemm computes ldc once, used by the host, device and logging paths.
  • TA_ON_DEVICE ends in its own else, so following it with another else is a compile error rather than silently dead host code.

Limits

  • Non-ToT tiles only.
  • Only device_um_allocator maps to a device memory space today; a TA::Tensor with another allocator runs the host code. A future device-only allocator would also need its host branch disabled, since the host code reads the same memory.
  • Nested (tensor-of-tensor) and arena tiles run on the host; device support for them is follow-up work.
  • BLAS-1 kernels run in chunks of at most INT_MAX elements (blaspp's length type is int); device permutes above INT_MAX elements throw TiledArray::Exception (librett uses 32-bit indices).
  • Under a Host scope only the elementwise evaluators skip the device task; contraction, reduction, assignment-time conversion and clone tasks are still device tasks whose tile ops run the host code (documented on ExecutionSpaceScope).
  • Batched tiles (nbatch() > 1): every device branch covers all batches. The host code does not, which predates this PR: the value-returning ops and clone (built on Tensor(other, op) and friends) return one batch, scale_to/neg_to touch only the first, and permute asserts nbatch() == 1; only the binary in-place ops cover every batch.
  • HIP builds have not been tested.

Follow-up

Once this PR is merged, a follow-up PR will remove the BTAS-based device tile (btasUMTensor), which UMTensor replaces.

Tests and examples

  • tests/expressions_device_tensor.cpp: 79 cases comparing UMTensor expressions with the same expressions on host TA::Tensor arrays, and, for complex tiles and batched products, with the same UMTensor arrays under a Host scope. Also covers conversions, archives, the stream choice outside device tasks, the reduction race, a Host scope opened while device tasks run, tile ops on batched (nbatch() > 1) tiles, SparsePolicy arrays (shape estimation, contraction, norm, truncate), and release of storage freed inside a device task only after its stream drains.
  • device_tensor_large_suite (disabled by default, about 18 GB per case): the INT_MAX chunking and the permute exception.
  • Examples: examples/device/ta_dense_um_tensor.cpp, ta_vector_um_tensor.cpp.

New public API

TA::UMTensor<T> (fwd.h); ExecutionSpace, execution_space(), ExecutionSpaceScope and allocator_memory_space_v (platform.h); TA_ON_DEVICE and device_executable_v (device/dispatch.h). UMTensor is now an is_device_tile.

Testing

Check Result
Full ta_test, CUDA (A30), 1 rank 2305/2309 passed, 4 skipped
Full ta_test, CUDA (A30), 2 ranks all passed except an intermittent tolerance miss in the existing um_expressions_suite/dot (BTAS tiles, unchanged here)
Device suite, 1 rank, 1 and 3 streams; 2 ranks 76/76
After the review fixes (deferred release, batched tiles, serialize), GTX 1080, CUDA 12.9, Debug: device suite, 1 rank, 1 and 3 streams 79/79
After the review fixes: ta_test built from the device, expressions, sparse expressions and range units, 1 rank / 2 ranks all passed
Large-tile suite passes
CPU-only build, single-rank suite 2132/2134 passed, 2 skipped
MPQC device validation (CCSD(T), CCk GS/LR/EOM, spin-orbital variants), Pthreads backend np1: 7/7; np2: 6/6
MPQC device validation, PaRSEC backend, MADNESS 3ff5ac06d np1: 7/7; np2: 6/7 (the new h2o-ccsd-631g-pvdz-gpu test crashes at exit in MPQC's teardown, after the result)
Dense GEMM throughput, one A100, FP64 19.25 TFLOPS (98.7% of peak, 99.2% of cuBLAS)
CCSD PPL term, one A100, FP64 17.57 TFLOPS (90% of peak); same as the BTAS UM tile

@ajay-mk ajay-mk mentioned this pull request May 22, 2026
5 of 10 tasks
@evaleev

evaleev commented May 22, 2026

Copy link
Copy Markdown
Member

@ajay-mk if we can ever hope to be able to use this for CSV-CC we should probably avoid duplicating TA::Tensor tile ops (gemm, etc.); these are now pretty complex due to the need to manage arena memory. TA::Tensor is already allocator aware, so we can compose UM-based variant. However, we need to be able to control dispatch to host/device execution spaces based on the allocator's traits. I suggest we control this dynamically (i.e. not "if Allocator allocates in a device-visible memory we launch device kernels", but using a flag; this will also be useful for testing).

To minimize the amount of boilerplate in the tile ops it should be possible to use macros.

@ajay-mk

ajay-mk commented May 22, 2026

Copy link
Copy Markdown
Member Author

I like the idea. I think the runtime flag can also give us control the execution space of specific operations, so that we can push only certain terms/operations into device. I will look into this.
I did test the current version with CCk, it runs, and results match the host side results for GS, LR and EOM.

Adds the entry point for native TA::Tensor support on devices, paralleling
the existing btas_um_tensor path. UMTensor<T> is a TA::Tensor instantiated
on device_um_allocator<T>, which already maps to MemorySpace::Device_UM
via platform.h:90.

* fwd.h: UMTensor<T> typedef under TILEDARRAY_HAS_DEVICE.
* device/tensor.h: is_device_tile<UMTensor<T>> partial specialization so
  the existing pass-through specs for Tile<T> and LazyArrayTile<T, Op>
  (tensor/type_traits.h) classify UMTensor-backed tiles as device tiles
  -- this is the gate that routes the expression engine through
  madness::add_device_task at binary_eval/unary_eval/contraction_eval and
  the 6 sites in expressions/expr.h. Also adds detail::to_device /
  detail::to_host prefetch helpers that go directly through
  device::memPrefetchAsync (TA::Tensor stores data via shared_ptr<T[]>
  rather than a varray, so we cannot route through to_execution_space).
* device/tensor.cpp: static_asserts pinning down that the trait fires
  for UMTensor<{double,float,complex<double>}>, propagates through
  Tile<>, and does not misclassify plain Tensor<double>. Placeholder
  for the explicit instantiations that will land in Phase 4.
* src/CMakeLists.txt: hook the two files into the TILEDARRAY_HAS_HIP OR
  TILEDARRAY_HAS_CUDA source list, alongside btas_um_tensor.{h,cpp}.

No tile-op overloads yet -- Phase 1 is compile-only.
Adds the smallest tile-op set that the expression engine needs in order
to evaluate a `C("ij") = a*A("ik")*B("kj")`-style expression plus a norm
on UMTensor: clone, scale/scale_to, neg/neg_to, add/add_to (+ scaled
forms), subt/subt_to (+ scaled forms), dot, squared_norm, norm, and gemm
(returning + accumulating). Element-wise mult, permute, shift, and
batched paths are deliberately not included yet -- they need librett /
custom kernels and a clear nbatch story, and dragging them in here
would obscure whether the dispatch surface alone is correct.

Each overload is a concrete-type free function in `namespace TiledArray`
so ADL prefers it over the generic templated forwarders in
`tile_op/tile_interface.h`. No constraint relaxation, no member-function
revert -- the dispatch falls out of overload partial ordering.

The kernel pattern mirrors `device/btas.h`: resolve a BLAS++ queue from
`blasqueue_for(range)`, prefetch every input + result to the device,
call into BLAS++, then `sync_madness_task_with(stream)` so the wrapping
MADNESS device task waits for the queue to drain. We do NOT thread an
explicit `blas::Queue&` through composite ops -- `stream_for(range)` in
`external/device.h` already returns the current task's stream when
invoked inside a device task, which is what we want.

For now batched tiles (`nbatch_ > 1`) are asserted away; the expression
engine doesn't currently route batched UMTensor through these ops and
silently miscomputing would be worse than a clear assert.

`device/tensor.cpp` grows a tiny instantiation probe that exercises the
full Tier-1 surface for `double` and `float`, so anything that doesn't
type-check breaks the build immediately rather than waiting on Phase 5
tests. Real explicit instantiations land in Phase 4.
Rounds out Tier 1 with the ops that require librett or the device
element-wise kernel:

* permute(arg, Permutation)         -- librett_permute on the tile data
* permute(arg, BipartitePermutation)-- forwards to outer(perm); required
                                       to win ADL against the generic
                                       member-delegating overload (the
                                       analogous comment lives in
                                       device/btas_um_tensor.h:193)
* shift(arg, bound_shift)           -- copy + Range::inplace_shift
* shift_to(arg, bound_shift)        -- in-place range shift, no copy
* mult / mult_to (+ scaled + permuted variants) via device::mult_kernel
  and device::mult_to_kernel
* scale(a, f, perm), neg(a, perm), add(..., perm), subt(..., perm), and
  their scaled-with-perm forms -- thin compositions over the
  non-permuted core and the new permute

All sit in `namespace TiledArray`; ADL wins over the
`tile_op/tile_interface.h` defaults the same way it does for the
non-permuted core. The instantiation probe in device/tensor.cpp now
exercises the full Tier-1 surface (Phase 2a + 2b).
…ssion tests

Three bug categories surfaced once expressions actually ran end-to-end:

1. **Scale-factor semantics on `*_to(arg, factor)` were inverted.**
   `TA::Tensor::add_to(r, factor)` is `(this += r) *= factor`, and
   `TA::Tensor::subt_to(r, factor)` is `(this -= r) *= factor` -- the
   scaling applies to the result of the in-place op, not to `arg` alone.
   My initial implementations computed `result += factor * arg` and
   `result -= factor * arg`, which gives wrong values whenever the
   engine calls `subt_to(std::move(second), first, -1)` from
   `tile_op/subt.h:117` (the "right is consumable" branch -- expected
   to compute `(second - first) * -1 = first - second`). The new
   implementations chain `add_to/subt_to(no factor)` + `scale_to`,
   matching TA::Tensor's `(l += r) *= factor` pattern.

2. **Engine passes consumable operands as rvalues.** Subt::eval and
   ScalSubt::eval at `tile_op/subt.h:117,302` pass the result tile via
   `std::move(...)`. A plain `UMTensor<T>&` overload is not a viable
   candidate for an rvalue argument, so overload resolution falls
   through to the generic forwarder in `tile_op/tile_interface.h`
   (and `tile_interface/scale.h`) that delegates to TA::Tensor's CPU
   member function. The CPU member reads UM memory while the prior
   device kernel is still in flight on the queue -- silently
   miscomputing. Every in-place op (scale_to, neg_to, add_to[x2],
   subt_to[x2], mult_to[x2], shift_to) now has two concrete-type
   overloads: `UMTensor<T>&` and `UMTensor<T>&&`. Concrete types beat
   the templated forwarding reference in partial ordering regardless
   of constraint shape -- a constrained forwarding-ref version would
   in principle also win (constrained > unconstrained per [temp.constr.
   order]) but g++ does not consistently treat tile_interface's
   `enable_if`-only forwarders as unconstrained for this purpose; the
   two-concrete-overload form is robust.

3. **No correctness coverage existed for the dispatch path.** The
   previous `tensor_device.cpp` exercised tile-level ops directly from
   the main thread, which is the wrong contract: tile ops cooperate
   with the enclosing `madness::add_device_task` for stream sync and
   never run that way in production. Replaced with
   `tests/expressions_device_tensor.cpp`, mirroring the structure of
   `expressions_device_um.cpp` (the existing btas device test). 18
   cases including: trait classification, direct assign, permute,
   scale, neg, add/subt (+ with-permute / -to / -with-factor variants),
   scaled-subt isolations on left/right, mixed linear combination
   (catches bug #1), Hadamard, contraction, norm2 / dot reductions,
   and a `reuse_stress` case repeating `dot(a, a)` 8x (catches the
   LazyArrayTile conversion race in MPQC's pattern -- expected to be
   a master-branch baseline failure not introduced here). All 18 pass.

Also documents the `UMTensorArg` concept inline as the marker for
"this is a UMTensor (any cv/ref qualifier)" -- kept around as
documentation of intent even where the dispatch tiebreak forced us
to use concrete-type overloads instead.
…m, contraction variants)

Goes from 18 to 59 test cases, covering the patterns the expression
engine uses in production but the original cut didn't exercise. New
coverage:

Elementary ops + permutations:
* In-place expression operators (+=, -=, *=, += with permute).
* Negation in compounds: -(2*(a+b)), -a("c,b,a").
* All scale/permute combinations for add/subt/mult: scale_add,
  scale_add_permute, subt_permute, scale_subt, scale_subt_permute,
  mult_permute, scale_mult, scale_mult_permute.

Dataflow + reduction:
* Multi-step dataflow chain (t = a + b; c = 2*t - a) -- engine wires
  t's Future<Tile> into the next dist-eval without an intervening
  fence (per CLAUDE.md's synchronization hierarchy).
* Contraction-plus-reduce (norm2 of a contraction result).
* no_alias() + reduce -- exercises the LHS-doesn't-alias-RHS
  optimization through to a reduction.

Block expressions (PR 531 trouble area):
* Basic block assign / scaled-sum / accumulate.
* const_block: block from a const reference.
* scal_block: 2 * a.block(...).
* permute_block: a("c,b,a").block(...).
* assign_sub_block: write into a tile sub-block of an existing array.
* block_contract, block_permute_contract: blocks fed into GEMM.

Contraction variants:
* Outer product (rank-changing GEMM, no shared contraction index).
* Permuted result (c("k,i") = a("i,j") * b("j,k")).
* Transpose-on-right input (c("i,k") = a("i,j") * b("k,j")).
* CC-style rank-4: r("a,c") = t("a,b,k,l") * v("c,b,k,l").
* scale_cont, scale_cont_permute, scale_cont_with_input_transpose
  -- scale-fuse-into-GEMM paths.
* cont_non_uniform_split_inner / _two_inner -- catches GEMM kernels
  that silently assume uniform tile shapes.

TA::einsum entry point:
* Matmul, Hadamard, two-index contraction. Documents the one pattern
  not covered (`einsum("ij,jk->ijk")` with an index in both inputs
  and output) -- it segfaults inside einsum's internals on master
  regardless of allocator, out of scope for this branch.

Dot variants:
* dot_permute: dot of permuted arrays.
* dot_contr: dot of two contraction expressions (one tier deeper than
  btas-device's NO_THROW-only version; we validate the scalar value).

Tolerances are 5e-14 for non-GEMM ops, 1e-10 to 1e-9 for GEMM-bearing
paths to absorb summation-order differences between BLAS++ on the
device and the Eigen-based CPU reference.

Validation:
* All 59 device-tensor cases pass.
* Full np=1 ta_test suite (1880 cases, 12.56M assertions) still
  green -- no regressions from this branch.
Phase 3 surface: serialization plus the bulk DistArray-level helpers
(prefetch and host<->device conversion) needed by downstream code that
wants to round-trip UMTensor data through MADNESS archives or shuttle
DistArrays between memory spaces.

src/TiledArray/device/tensor.h:

* `to_host(DistArray<UMTensor<T>, P>&)` and
  `to_device(DistArray<UMTensor<T>, P>&)`: bulk prefetch every local
  tile, fence the world, deviceSynchronize on exit. Mirrors the btas
  helpers in btas_um_tensor.h:567-617 but takes the bare UMTensor
  (no `TA::Tile<>` wrapper, per CLAUDE.md).
* `um_tensor_to_ta_tensor(DistArray<UMTensor<T>, P>)` and
  `ta_tensor_to_um_tensor(DistArray<TA::Tensor<T>, P>)`: tile-by-tile
  conversion via `to_new_tile_type`. The per-tile lambda allocates a
  result of the target tile type, prefetches the source as needed,
  and memcpys -- since both sides are TA::Tensor and only the
  allocator differs, no per-element conversion is required.
* `madness::archive::ArchiveStoreImpl<Archive, UMTensor<T>>`:
  prefetches the tile to host before serializing, then writes the
  same fields TA::Tensor::serialize would
  (empty/range/nbatch/wrap(data)). The default member serialize is
  not safe to use as-is because UM data may be stale on the host
  while a device kernel is in flight.
* `madness::archive::ArchiveLoadImpl<Archive, UMTensor<T>>`:
  reconstructs the tensor in UM (writes go through host pages of UM
  -- if downstream code wants the data on the device, it should call
  `to_device` explicitly).

tests/expressions_device_tensor.cpp:

Five new cases (now 64 total):
* serialize_um_tensor: single-tile round-trip through BufferOutput/
  Input archives.
* serialize_um_tensor_empty: empty branch in Store/Load.
* um_to_ta_round_trip: device array -> host array -> device array,
  values preserved across both legs.
* um_to_ta_then_expression: a host expression on a converted-from-
  device array matches the same expression on the host mirror.
* bulk_prefetch_round_trip: to_host/to_device on a DistArray are
  no-ops for correctness (they only adjust page residency hints).

Validation:
* All 64 device-tensor cases pass.
* Full np=1 ta_test suite: 1885 cases, 12.64M assertions -- still
  green; no regressions from this branch.
Phase 4: instantiate `Tensor<T, device_um_allocator<T>>` once in
device/tensor.cpp and `extern template`-declare them in
device/tensor.h, so each TU including device/tensor.h does not
re-instantiate the full ~3000-line Tensor class body.

Mirrors the pattern at the bottom of src/TiledArray/tensor/tensor.h
+ src/TiledArray/tensor/tensor.cpp for the host-side instantiations.
The instantiated set is double, float, complex<{double,float}>, int,
long -- a superset of the host set (which omits int/long) for parity
with btas_um_tensor.cpp.

BLAS-bearing free functions (gemm, scale, axpy-driven add/subt, ...)
are left as header-defined templates; explicitly instantiating them
would pull the full BLAS++/librett surface into device/tensor.cpp,
and the build-time saving from extern-templating them does not
justify it. They get instantiated lazily in whichever TU actually
calls them (typically the test or example TU).

Validation:
* All 64 device-tensor cases still pass.
* Full np=1 ta_test suite: 1885 cases, 12.63M assertions -- still
  green; no regressions from this change.
Phase 6: two example programs that exercise the UMTensor surface
end-to-end through real timing loops, mirroring the existing
btas-based ta_dense_device.cpp and ta_vector_device.cpp but using
the bare UMTensor tile type (no `TA::Tile<>` wrapper).

* examples/device/ta_dense_um_tensor.cpp:
    c(Nm,Nn) = a(Nm,Nk) * b(Nk,Nn) on UMTensor<double> tiles, blocked
    by user-supplied Bm/Bn/Bk. Reports per-iteration wall time and
    GFLOPS, then verifies every result element equals the analytic
    Nk * val_a * val_b. Honors `cudaProfilerStart/Stop` when built
    with CUDA so the timed loop is profilable separately from setup.

* examples/device/ta_vector_um_tensor.cpp:
    Element-wise op benchmark -- add, subt, scale, Hadamard, permute,
    in-place axpy -- on Nm x Nn UMTensor matrices. Reports per-op
    average wall time and effective bandwidth (counting one read +
    one write per element for unary ops, two reads + one write for
    binary ops).

Both examples follow the convention in CLAUDE.md / the rest of TA's
device examples: TA_SCOPED_INITIALIZE for runtime setup, world fence
before reading device-side results, exception-catching `main` wrapper.
Hooked into examples/device/CMakeLists.txt's `foreach(_exec ...)`
list so they build alongside the existing examples whenever
TILEDARRAY_HAS_CUDA OR TILEDARRAY_HAS_HIP.

Smoke-validated locally:
  ta_dense_um_tensor 256 64 256 64 256 64 3
    -> Verification PASSED
  ta_vector_um_tensor 512 128 512 128 3
    -> all six ops reported timings without error
The runtime instantiation probe (`compile_test_tier1<T>` plus the two
`instantiate_tier1_*` function pointers) was added in the Phase 2 commits
to force template type-checking of the device tile-op overloads before
any test exercised them. It is no longer load-bearing:

* tests/expressions_device_tensor.cpp (64 cases) calls every tier-1
  overload through the expression engine for `double`, so any
  instantiation breakage shows up at test-build time.
* device/tensor.cpp's explicit `template class Tensor<T,
  device_um_allocator<T>>` instantiations cover the class members
  authoritatively.

Removing the probe trims ~75 lines of dead code from the .cpp. The
`is_device_tile_v` static_asserts are kept -- they are zero-cost and
guarantee trait correctness even when BUILD_TESTING=OFF.
* Remove the `UMTensorArg` concept and the `detail::is_um_tensor` trait
  it was built on. Both were introduced during the dispatch debugging
  to try a constrained forwarding-reference approach (per the comment
  block at the top of the section); that approach was abandoned in
  favor of two concrete-type overloads (`UMTensor<T>&` and
  `UMTensor<T>&&`) per in-place op, which beat the templated
  forwarding-ref candidate in partial ordering. The concept and trait
  are dead declarations; the explanatory comment is kept and trimmed.

* Rewording: two inline comments referencing "Phase 2" and "Phase 3"
  development sequencing -- meaningless to a future reader -- are
  reworded to describe the rule without the historical label.

Verified: 64-case device_tensor_expressions_suite still passes; no
behavior change.
Widen um_tensor_to_ta_tensor / ta_tensor_to_um_tensor from <T, Policy> to
<UMTile, HostTile, Policy>, mirroring the signature in
device/btas_um_tensor.h:619+. Identity overloads cover the case where source
and destination tiles coincide.
The tile-op overloads for UMTensor<T> dispatch BLAS-on-pointer kernels
(scal, copy, gemm, dot, etc.). When CCk is instantiated for the device
tile, it composes nested tiles of the form UMTensor<UMTensor<double>>
to represent its outer/inner structure; those nested tiles must route
through TA::Tensor member ops, not the device kernels.

Add `requires TiledArray::detail::is_numeric_v<T>` to each device tile-op
overload, and gate `is_device_tile<UMTensor<T>>` and the archive store
helper on the same predicate so non-numeric element types fall through
to the host tile path.
The returning overload was missing left_right_congruent checks, and the
in-place overload was missing the full left_result/right_result/left_right
congruence set. Silent geometry mismatches would have produced wrong
results instead of asserting. Mirrors the asserts in tensor/kernels.h's
host-side detail::gemm worker.
detail::to_host wraps memPrefetchAsync, so the immediate host reads in
um_tensor_to_ta_tensor's convert_tile lambda and in ArchiveStoreImpl::store
were racing the prefetch on Pascal+ devices with concurrent_managed_access.
Drain the stream via sync_madness_task_with before the host walks the tile.
- shift_to: call Tensor::shift_to instead of const_cast'ing the range.
  TA::Tensor exposes a public shift_to member (unlike btas::Tensor),
  so the const_cast inherited from btas_um_tensor.h is unnecessary here.
- apply_scale_factor: flatten 3-level nested if constexpr into one
  else-if-constexpr cascade.
sync_madness_task_with() records a stream so the enclosing MADNESS device
task can defer completion until it drains; it never blocks itself. Outside
a device task there is nothing to defer, and the thread-local it consults
is pre-initialized non-empty, so such calls synchronized nothing.

Device ops do reach that path: foreach.h, dist_array.h, array_impl.h and
dist_eval/array_eval.h submit with plain taskq.add and carry no
is_device_tile branch, and contraction_eval.h guards 1 of 18 submissions.
An op reached that way was fire-and-forget -- the caller read a tile whose
kernel was still writing it, silently and nondeterministically. Found via a
downstream iterative solver whose residual stalled instead of converging.
detail::gemm calls a device gemm when every operand is a UMTensor of a
BLAS type and execution_space() is Device; the UMTensor gemm overloads
are removed, so Tensor::gemm's per-batch loop now covers nbatch > 1.
execution_space() defaults to Device, and ExecutionSpaceScope switches a
region to the host code.
clone, permute, scale, neg, add, subt, mult and their in-place, scaled
and permuted forms branch to device kernels through TA_ON_DEVICE, and
the matching UMTensor overloads in device/tensor.h are removed. The
device declarations are visible in every build, so Tensor needs no
preprocessor guards.
dot maps to the unconjugated device dot and inner_product to the
conjugated one; the remaining UMTensor reduction overloads are removed.
@ajay-mk
ajay-mk force-pushed the ajay/feature/umtensor branch from 54b09f9 to fe37cbf Compare November 10, 2026 21:39
@ajay-mk
ajay-mk marked this pull request as ready for review November 11, 2026 18:14
ajay-mk and others added 4 commits November 11, 2026 22:33
…pletion

- Device branches of add_to/subt_to/axpy_to/scale_to/mult/mult_to use
  total_size() and check nbatch() congruence, so batched tiles are
  processed in full (previously only batch 0 in Release builds);
  device permute loops over batches.
- UMTensor storage freed inside a device task is returned to the Umpire
  pool only after the task's stream work completes (DeferredReleases held
  by deviceTaskFn). Replaces permute_temporary and the blocking
  synchronize in axpy_to(right, factor, perm), and also covers lazily
  evaluated temporaries (e.g. BinaryWrapper).
- Tensor::serialize prefetches UM storage to host before writing; drop the
  separate ArchiveStoreImpl<UMTensor> specialization. Move UMTensor extern
  template declarations into tensor/tensor.h so every TU sees them.
- Document ExecutionSpaceScope limits and that TA_ON_DEVICE is usable in
  templates only; rename ponytail: tags to TODO:.
@evaleev
evaleev merged commit 610a602 into master Nov 12, 2026
9 checks passed
@evaleev
evaleev deleted the ajay/feature/umtensor branch November 12, 2026 18:57
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.

2 participants