Skip to content

Commit 95d7a2a

Browse files
committed
Tensor: an empty-operand fast path must own its copy, not alias the source
`detail::clone_or_cast<Tensor>(right)` deep-copies only when `Right` *is* `Tensor`, where it calls `clone()`. For any other nested-tensor `Right` -- most importantly the `TensorInterface<..., BlockRange>` that `Tensor::block()` yields -- it falls into a generic branch that `std::copy`s the *cells*, and a cell is a shallow handle: an `ArenaTensor` is a non-owning view, and `TA::Tensor` is itself reference-counted. The copy therefore shared storage with `right`, and the empty-`this` fast paths mutate it immediately afterwards: t.subt_to(p.block(...)); // neg_to() -> p's cells negated t.add_to(p.block(...), 3.0); // scale_to() -> p's cells scaled Both corrupt a const source operand. The value-returning `add`/`subt` empty-left exits have the same defect one step removed: they hand the caller a result aliasing `right`, so the caller's first mutation writes through. Route all eight of these through a new `owning_copy_of`, which for a nested tensor builds the copy with `arena_trivial_unary` -- a fresh slab, elements copied, no shared cells -- and keeps `clone_or_cast` for the flat and same-type cases where it is already a deep copy. Being range-addressed, that kernel also handles a strided source correctly. This is reachable only because the null-cell guard earlier in this branch made `add_to`/`subt_to` compile for a non-contiguous `Right` at all; previously they failed to instantiate. The unscaled `add_to`'s aliasing predates that and is fixed here too. Also in this commit: - `arena_trivial_scaled` joins its unary and binary siblings in addressing operands through `arena_cell_offset` and taking counts from the `total_size` CPO. It still used `nbatch()` and linear `data()[ord]`, so the mixed `Tensor<ArenaTensor>` +/-/* `Tensor<scalar>` overloads failed to compile against a `block()` operand under TA_ASSERT_THROW and silently read the wrong scalars under TA_ASSERT_IGNORE -- a TA_ASSERT_POLICY-dependent difference. - einsum's view-inner ToT x T diagnostic now fires only once a source inner cell is actually populated. It threw on entry to the loop, which rejected the fully screened case that used to work: `axpy_to` early-returns on a null source, so an all-null operand never needed the destination to have storage and correctly produced a null result tile. - A test pins intersection sparsity for the plain owning ToT. `Tensor::mult`'s intersection rule applies in the `is_ta_tensor_v` branch as well, so it is a behavior change for `TA::Tensor<TA::Tensor<T>>`, not only for arena tiles; the pre-existing mult_mismatched_null_inners is written permissively and passes under either rule. Two of the six new corruption regressions were confirmed failing before the fix (`-1 != 1` and `3 != 1`); the rest exercise the same path.
1 parent fc6c692 commit 95d7a2a

4 files changed

Lines changed: 162 additions & 25 deletions

File tree

‎src/TiledArray/einsum/tiledarray.h‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -948,17 +948,35 @@ auto einsum(expressions::TsrExpr<ArrayA_> A, expressions::TsrExpr<ArrayB_> B,
948948
if constexpr (inner_is_view) {
949949
// `tile` is built with default-constructed (null) inner cells
950950
// and a view cell cannot allocate, so it has no storage to
951-
// accept the first contribution: the free `axpy_to` refuses a
952-
// null destination rather than silently drop the source. Only
951+
// accept a contribution: the free `axpy_to` refuses a null
952+
// destination rather than silently drop the source. Only
953953
// run_regime_a_arena grows result cells, so reaching this
954954
// legacy path means the arena plan was inactive (e.g. under
955-
// detail::arena_disabled()). Report that here rather than let
956-
// the per-cell kernel raise a lower-level diagnostic --
957-
// mirrors the ToT x ToT branch above.
958-
TA_EXCEPTION(
959-
"TA::einsum: ToT x T product with view inner cells (e.g. "
960-
"ArenaTensor) is supported only via the regime-A arena "
961-
"fast path, which was inactive for this expression");
955+
// detail::arena_disabled()).
956+
//
957+
// Report that here rather than let the per-cell kernel raise a
958+
// lower-level diagnostic -- but only once a source cell is
959+
// actually populated. A fully screened operand contributes
960+
// nothing, leaves the null result tile correct, and must keep
961+
// working: `axpy_to` early-returns on a null source, so that
962+
// case never needed the destination to have storage.
963+
auto aik = ai.batch(k);
964+
auto bik = bi.batch(k);
965+
const auto vol = aik.total_size();
966+
TA_ASSERT(vol == bik.total_size());
967+
for (auto i = 0; i < vol; ++i) {
968+
bool src_populated;
969+
if constexpr (IsArrayToT<ArrayA>)
970+
src_populated = !aik.data()[i].empty();
971+
else
972+
src_populated = !bik.data()[i].empty();
973+
if (src_populated)
974+
TA_EXCEPTION(
975+
"TA::einsum: ToT x T product with view inner cells "
976+
"(e.g. ArenaTensor) is supported only via the regime-A "
977+
"arena fast path, which was inactive for this "
978+
"expression");
979+
}
962980
} else {
963981
auto aik = ai.batch(k);
964982
auto bik = bi.batch(k);

‎src/TiledArray/tensor/arena_kernels.h‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -489,21 +489,26 @@ OuterTensor arena_trivial_scaled(const ToTSide& tot_outer,
489489
FillOp&& fill_op) {
490490
using elem_t = typename OuterTensor::value_type::value_type;
491491
using inner_range_t = typename OuterTensor::value_type::range_type;
492+
// Either side may be a strided view; address both through `arena_cell_offset`
493+
// and take the cell/batch counts from the `total_size` CPO, as the unary and
494+
// binary kernels do. `nbatch()` is a TA::Tensor member a view does not have.
492495
TA_ASSERT(tot_outer.range().volume() == scalar_outer.range().volume());
493-
TA_ASSERT(tot_outer.nbatch() == scalar_outer.nbatch());
496+
TA_ASSERT(TiledArray::total_size(tot_outer) ==
497+
TiledArray::total_size(scalar_outer));
494498
auto range_fn = [&tot_outer](std::size_t ord) -> inner_range_t {
495-
const auto& t = tot_outer.data()[ord];
499+
const auto& t = tot_outer.data()[arena_cell_offset(tot_outer, ord)];
496500
return t.empty() ? inner_range_t{} : t.range();
497501
};
498-
OuterTensor result = arena_outer_init<OuterTensor>(
499-
tot_outer.range(), tot_outer.nbatch(), range_fn, alignof(elem_t),
500-
/*zero_init=*/false);
501-
const std::size_t N_cells = tot_outer.range().volume() * tot_outer.nbatch();
502+
const auto [N_cells, tot_nbatch] = arena_cells_and_batches(tot_outer);
503+
OuterTensor result =
504+
arena_outer_init<OuterTensor>(tot_outer.range(), tot_nbatch, range_fn,
505+
alignof(elem_t), /*zero_init=*/false);
502506
for (std::size_t ord = 0; ord < N_cells; ++ord) {
503507
auto& dst = result.data()[ord];
504508
if (dst.empty()) continue;
505-
fill_op(dst.data(), tot_outer.data()[ord].data(), scalar_outer.data()[ord],
506-
dst.size());
509+
fill_op(
510+
dst.data(), tot_outer.data()[arena_cell_offset(tot_outer, ord)].data(),
511+
scalar_outer.data()[arena_cell_offset(scalar_outer, ord)], dst.size());
507512
}
508513
return result;
509514
}

‎src/TiledArray/tensor/tensor.h‎

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,6 +2167,39 @@ class Tensor {
21672167
}
21682168
}
21692169

2170+
/// An owning deep copy of \p right, safe to mutate without touching it.
2171+
2172+
/// `detail::clone_or_cast` deep-copies only when `Right` *is* `Tensor`, in
2173+
/// which case it calls `clone()`. For any other nested-tensor `Right` it
2174+
/// falls into a generic branch that `std::copy`s the *cells*, and a cell is
2175+
/// a shallow handle -- an `ArenaTensor` is a non-owning view, and
2176+
/// `TA::Tensor` is itself reference-counted. The copy would then share
2177+
/// storage with \p right, so negating or scaling it afterwards writes
2178+
/// through to the source, corrupting a const operand.
2179+
///
2180+
/// Route nested tensors through the arena unary kernel instead: it allocates
2181+
/// a fresh slab and copies *elements*, and being range-addressed it handles
2182+
/// a strided (block-view) source correctly.
2183+
/// \tparam Right The source tensor type
2184+
/// \param right The tensor to copy
2185+
/// \return An owning `Tensor` sharing no storage with \p right
2186+
template <typename Right>
2187+
static Tensor owning_copy_of(const Right& right) {
2188+
using RightT = std::remove_cv_t<std::remove_reference_t<Right>>;
2189+
if constexpr (detail::is_tensor_of_tensor_v<Tensor> &&
2190+
!std::is_same_v<RightT, Tensor> &&
2191+
std::is_same_v<value_type, typename RightT::value_type>) {
2192+
auto fill = [](typename value_type::value_type* dst,
2193+
const typename value_type::value_type* src,
2194+
std::size_t n) {
2195+
for (std::size_t i = 0; i < n; ++i) dst[i] = src[i];
2196+
};
2197+
return detail::arena_trivial_unary<Tensor>(right, fill);
2198+
} else {
2199+
return detail::clone_or_cast<Tensor>(right);
2200+
}
2201+
}
2202+
21702203
// Addition operations
21712204

21722205
/// Element-wise add for `Tensor<ArenaTensor>` ToT operands. Routes through
@@ -2175,7 +2208,7 @@ class Tensor {
21752208
requires(is_arena_tensor_v<value_type> &&
21762209
is_arena_tensor_v<typename Right::value_type>)
21772210
Tensor add(const Right& right) const {
2178-
if (empty()) return detail::clone_or_cast<Tensor>(right);
2211+
if (empty()) return owning_copy_of(right);
21792212
if (right.empty()) return this->clone();
21802213
auto fill = [](typename value_type::value_type* dst,
21812214
const typename value_type::value_type* l,
@@ -2290,7 +2323,7 @@ class Tensor {
22902323
if (right.empty()) return this->clone();
22912324

22922325
// early exit for empty this
2293-
if (empty()) return detail::clone_or_cast<Tensor>(right);
2326+
if (empty()) return owning_copy_of(right);
22942327

22952328
if constexpr (detail::is_tensor_of_tensor_v<Tensor> &&
22962329
detail::is_ta_tensor_v<value_type> &&
@@ -2461,7 +2494,7 @@ class Tensor {
24612494

24622495
// early exit for empty this
24632496
if (empty()) {
2464-
*this = detail::clone_or_cast<Tensor>(right);
2497+
*this = owning_copy_of(right);
24652498
return *this;
24662499
}
24672500

@@ -2497,7 +2530,7 @@ class Tensor {
24972530

24982531
// early exit for empty this: (0 + right) * factor
24992532
if (empty()) {
2500-
*this = detail::clone_or_cast<Tensor>(right);
2533+
*this = owning_copy_of(right);
25012534
this->scale_to(factor);
25022535
return *this;
25032536
}
@@ -2529,7 +2562,7 @@ class Tensor {
25292562
Tensor& axpy_to(const Right& right, const Scalar factor) {
25302563
if (right.empty()) return *this;
25312564
if (empty()) {
2532-
*this = detail::clone_or_cast<Tensor>(right);
2565+
*this = owning_copy_of(right);
25332566
this->scale_to(factor);
25342567
return *this;
25352568
}
@@ -2585,7 +2618,7 @@ class Tensor {
25852618
// result inner cell): initialize to factor * (perm ^ arg) rather
25862619
// than asserting non-empty in inplace_binary -- mirrors the
25872620
// non-permuting axpy_to overload above.
2588-
*this = detail::clone_or_cast<Tensor>(permuted);
2621+
*this = owning_copy_of(permuted);
25892622
this->scale_to(factor);
25902623
return *this;
25912624
}
@@ -2837,7 +2870,7 @@ class Tensor {
28372870

28382871
// early exit for empty this: 0 - right == -right
28392872
if (empty()) {
2840-
*this = detail::clone_or_cast<Tensor>(right);
2873+
*this = owning_copy_of(right);
28412874
this->neg_to();
28422875
return *this;
28432876
}
@@ -2874,7 +2907,7 @@ class Tensor {
28742907

28752908
// early exit for empty this: (0 - right) * factor
28762909
if (empty()) {
2877-
*this = detail::clone_or_cast<Tensor>(right);
2910+
*this = owning_copy_of(right);
28782911
this->neg_to();
28792912
this->scale_to(factor);
28802913
return *this;

‎tests/arena_tot_trivial.cpp‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,4 +694,85 @@ BOOST_AUTO_TEST_CASE(arena_mult_to_does_not_densify_null_cells) {
694694
}
695695
}
696696

697+
// --- an empty-`this` fast path must not alias, let alone mutate, the source
698+
// `detail::clone_or_cast` deep-copies only when `Right` *is* `Tensor`; for any
699+
// other nested-tensor `Right` (e.g. the `TensorInterface<..., BlockRange>` that
700+
// `block()` yields) it copies the cell *handles*, which for a view cell aliases
701+
// the source's storage. Scaling or negating the copy then writes through to a
702+
// const operand.
703+
704+
BOOST_AUTO_TEST_CASE(subt_to_empty_left_must_not_mutate_source) {
705+
arena_outer_t P =
706+
make_arena_tot_2d(2, 3, 4, 1.0, {true, true, true, true, true, true});
707+
const double before = P.data()[0].data()[0];
708+
arena_outer_t t;
709+
t.subt_to(P.block({0l, 0l}, {2l, 2l}));
710+
BOOST_CHECK_EQUAL(P.data()[0].data()[0], before);
711+
BOOST_CHECK_EQUAL(t.data()[0].data()[0], -before);
712+
}
713+
714+
BOOST_AUTO_TEST_CASE(subt_to_scaled_empty_left_must_not_mutate_source) {
715+
arena_outer_t P =
716+
make_arena_tot_2d(2, 3, 4, 1.0, {true, true, true, true, true, true});
717+
const double before = P.data()[0].data()[0];
718+
arena_outer_t t;
719+
t.subt_to(P.block({0l, 0l}, {2l, 2l}), 2.0);
720+
BOOST_CHECK_EQUAL(P.data()[0].data()[0], before);
721+
BOOST_CHECK_EQUAL(t.data()[0].data()[0], -before * 2.0);
722+
}
723+
724+
BOOST_AUTO_TEST_CASE(add_to_scaled_empty_left_must_not_mutate_source) {
725+
arena_outer_t P =
726+
make_arena_tot_2d(2, 3, 4, 1.0, {true, true, true, true, true, true});
727+
const double before = P.data()[0].data()[0];
728+
arena_outer_t t;
729+
t.add_to(P.block({0l, 0l}, {2l, 2l}), 3.0);
730+
BOOST_CHECK_EQUAL(P.data()[0].data()[0], before);
731+
BOOST_CHECK_EQUAL(t.data()[0].data()[0], before * 3.0);
732+
}
733+
734+
BOOST_AUTO_TEST_CASE(axpy_to_empty_left_must_not_mutate_source) {
735+
arena_outer_t P =
736+
make_arena_tot_2d(2, 3, 4, 1.0, {true, true, true, true, true, true});
737+
const double before = P.data()[0].data()[0];
738+
arena_outer_t t;
739+
t.axpy_to(P.block({0l, 0l}, {2l, 2l}), 2.0);
740+
BOOST_CHECK_EQUAL(P.data()[0].data()[0], before);
741+
}
742+
743+
// the value-returning empty-left exits have the same hazard: the result must
744+
// own its cells, or the caller's later mutation corrupts the source
745+
BOOST_AUTO_TEST_CASE(add_empty_left_result_must_not_alias_source) {
746+
arena_outer_t P =
747+
make_arena_tot_2d(2, 3, 4, 1.0, {true, true, true, true, true, true});
748+
const double before = P.data()[0].data()[0];
749+
const arena_outer_t empty_left;
750+
arena_outer_t r = empty_left.add(P.block({0l, 0l}, {2l, 2l}));
751+
r.scale_to(-1.0);
752+
BOOST_CHECK_EQUAL(P.data()[0].data()[0], before);
753+
}
754+
755+
// --- intersection sparsity also governs the plain owning ToT --------------
756+
// `Tensor::mult`'s intersection rule is applied in the `is_ta_tensor_v` branch
757+
// too, so it is a behavior change for `TA::Tensor<TA::Tensor<T>>`, not only for
758+
// arena tiles. Pin it: the pre-existing mult_mismatched_null_inners is written
759+
// permissively and passes under either rule.
760+
BOOST_AUTO_TEST_CASE(plain_tot_mult_uses_intersection_sparsity) {
761+
outer_t L = make_tot_sparse(5, 4, 2.0, nz_L);
762+
outer_t R = make_tot_sparse(5, 4, 0.5, nz_R);
763+
outer_t prod = L.mult(R);
764+
for (std::size_t ord = 0; ord < 5; ++ord) {
765+
const inner_t& l = *(L.data() + ord);
766+
const inner_t& r = *(R.data() + ord);
767+
const inner_t& d = *(prod.data() + ord);
768+
if (!l.empty() && !r.empty()) {
769+
BOOST_REQUIRE(!d.empty());
770+
for (std::size_t i = 0; i < d.range().volume(); ++i)
771+
BOOST_CHECK_EQUAL(d.at_ordinal(i), l.at_ordinal(i) * r.at_ordinal(i));
772+
} else {
773+
BOOST_CHECK(d.empty());
774+
}
775+
}
776+
}
777+
697778
BOOST_AUTO_TEST_SUITE_END()

0 commit comments

Comments
 (0)