Skip to content

Zero-initialize non-independent parameter adjoints and introduce clad::zero_like - #1932

Open
fogsong233 wants to merge 6 commits into
vgvassilev:masterfrom
fogsong233:fix-non-independent-adjoint-zero-init
Open

Zero-initialize non-independent parameter adjoints and introduce clad::zero_like#1932
fogsong233 wants to merge 6 commits into
vgvassilev:masterfrom
fogsong233:fix-non-independent-adjoint-zero-init

Conversation

@fogsong233

@fogsong233 fogsong233 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

While investigating the following case:

double replace_and_square(std::vector<double> values, double x) {
  values[0] = x;
  return values[0] * values[0];
}

auto gradient = clad::gradient(replace_and_square, "x");

I found that even though values is not selected as an independent parameter, Clad still needs an internal _d_values to propagate the derivative through the assignment.

For values = {10.0} and x = 3.0, the expected derivative is 6.0. However, _d_values was initialized by copying the primal value and was never cleared, so its initial value leaked into the reverse pass and produced 16.0.

This means that every internal adjoint created by Clad, including adjoints for non-independent parameters, must represent a valid zero tangent before the reverse pass starts.

While fixing this, I found that the current API does not have enough expressive power to construct such an adjoint correctly for every type.

For a record parameter of type T, the existing logic is roughly:

// T has direct pointer or reference fields:
T _d_param = T{};

// Otherwise, if T is copyable:
T _d_param(param);
clad::zero_init(_d_param);

Neither option is generally correct.

For example, libc++ implements std::vector using direct pointer fields. The field-based check therefore selects:

std::vector<double> _d_values{};

but this loses the vector’s runtime structure: _d_values is empty while values may contain several elements.

At the same time, simply ignoring pointer fields and always copying is also unsafe. A user-defined type may contain an internal pointer referring to its own storage, and its copy constructor may leave the copied pointer referring to the primal object.

In other words, field layout alone cannot tell us whether copying a type creates a valid, independently owned adjoint.

clad::zero_init cannot fully solve this either. Its current purpose is to reset an already existing adjoint while preserving its structure. It is also used for arrays, loop resets, and user-provided specializations. Changing it into an adjoint-construction operation would change its existing semantics and break backward compatibility.

This PR therefore introduces a separate customization point:

auto adjoint = clad::zero_like(primal);

The intended contract of zero_like is:

  • create a new adjoint with independent storage;
  • preserve runtime structure relevant to differentiation, such as size, shape, allocator, device, and internal invariants;
  • initialize all differentiable values to zero;
  • never modify or alias mutable storage from the primal.

Types can provide their own overload:

namespace clad {

MyType zero_like(const MyType& value) {
  // Construct an independently owned zero adjoint.
}

} // namespace clad

For std::vector, the implementation copies the vector to obtain independent storage and preserve its size, then applies zero_init to its elements:

template <class T, class Allocator>
std::vector<T, Allocator>
zero_like(const std::vector<T, Allocator>& value) {
  std::vector<T, Allocator> result(value);
  zero_init(result);
  return result;
}

A Tensor backend can similarly implement this by delegating to an operation such as at::zeros_like.

When Clad creates an internal adjoint from an existing primal value, it now tries a viable clad::zero_like overload first. If no overload is available, it preserves the previous copy/value-initialization logic as a backward-compatible fallback.

This also removes the need for a std::vector-specific exception in ReverseModeVisitor: knowledge about how to construct a vector adjoint now lives in STLBuiltins.h, where it belongs.

Finally, this API provides an important building block for improving clad::gradient. Currently, users must allocate zero adjoints themselves and pass _d_xxx pointers to execute. With zero_like, Clad can eventually construct those terminal adjoints from the primal arguments and return them directly:

auto gradients = gradient.execute(args...);

This PR does not change that interface yet, but it provides the missing construction operation needed to implement it correctly for vectors, tensors, and user-defined structured types.

Invoke the existing zero_init customization point after copying the primal structure for an internally-created adjoint. Add an std::vector regression where the copied primal value otherwise contaminates the selected parameter's gradient.
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/Differentiator/VisitorBase.cpp 90.47% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@fogsong233 fogsong233 changed the title Zero-initialize copied adjoints for non-independent parameters Zero-initialize non-independent parameter adjoints and introduce clad::zero_like Jul 29, 2026

@github-actions github-actions 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.

clang-tidy made some suggestions

/// size, allocator, or device information. The reverse-mode visitor prefers
/// this customization point when it must create an internal adjoint from an
/// existing primal value.
template <class T, typename std::enable_if<std::is_arithmetic<T>::value ||

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.

warning: use c++14 style type templates [modernize-type-traits]

Suggested change
template <class T, typename std::enable_if<std::is_arithmetic<T>::value ||
template <class T, std::enable_if_t<std::is_arithmetic<T>::value ||

include/clad/Differentiator/Differentiator.h:218:

-                                              int>::type = 0>
+                                              int> = 0>

/// size, allocator, or device information. The reverse-mode visitor prefers
/// this customization point when it must create an internal adjoint from an
/// existing primal value.
template <class T, typename std::enable_if<std::is_arithmetic<T>::value ||

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.

warning: use c++17 style variable templates [modernize-type-traits]

Suggested change
template <class T, typename std::enable_if<std::is_arithmetic<T>::value ||
template <class T, typename std::enable_if<std::is_arithmetic_v<T> ||

/// this customization point when it must create an internal adjoint from an
/// existing primal value.
template <class T, typename std::enable_if<std::is_arithmetic<T>::value ||
std::is_enum<T>::value,

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.

warning: use c++17 style variable templates [modernize-type-traits]

Suggested change
std::is_enum<T>::value,
std::is_enum_v<T>,

Comment thread lib/Differentiator/VisitorBase.cpp Outdated
// Test overload viability before asking Sema to build the call. A failed
// optional customization must quietly fall back to the legacy adjoint
// construction path instead of producing a diagnostic.
if (UnresolvedLookup->hasPlaceholderType(BuiltinType::Overload)) {

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.

warning: no header providing "clang::BuiltinType" is directly included [misc-include-cleaner]

    if (UnresolvedLookup->hasPlaceholderType(BuiltinType::Overload)) {
                                             ^

Comment thread lib/Differentiator/VisitorBase.cpp Outdated
// optional customization must quietly fall back to the legacy adjoint
// construction path instead of producing a diagnostic.
if (UnresolvedLookup->hasPlaceholderType(BuiltinType::Overload)) {
OverloadExpr::FindResult Find = OverloadExpr::find(UnresolvedLookup);

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.

warning: no header providing "clang::OverloadExpr" is directly included [misc-include-cleaner]

      OverloadExpr::FindResult Find = OverloadExpr::find(UnresolvedLookup);
      ^

Comment thread lib/Differentiator/VisitorBase.cpp Outdated
if (UnresolvedLookup->hasPlaceholderType(BuiltinType::Overload)) {
OverloadExpr::FindResult Find = OverloadExpr::find(UnresolvedLookup);
if (!Find.HasFormOfMemberPointer &&
isa<UnresolvedLookupExpr>(Find.Expression)) {

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.

warning: no header providing "clang::UnresolvedLookupExpr" is directly included [misc-include-cleaner]

          isa<UnresolvedLookupExpr>(Find.Expression)) {
              ^

@fogsong233
fogsong233 force-pushed the fix-non-independent-adjoint-zero-init branch from be04c51 to 91b0837 Compare July 29, 2026 12:52

@github-actions github-actions 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.

clang-tidy made some suggestions

/// size, allocator, or device information. The reverse-mode visitor prefers
/// this customization point when it must create an internal adjoint from an
/// existing primal value.
template <class T, typename std::enable_if<std::is_arithmetic<T>::value ||

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.

warning: use c++14 style type templates [modernize-type-traits]

Suggested change
template <class T, typename std::enable_if<std::is_arithmetic<T>::value ||
template <class T, std::enable_if_t<std::is_arithmetic<T>::value ||

include/clad/Differentiator/Differentiator.h:222:

-                                              int>::type = 0>
+                                              int> = 0>


/// Default zero_like implementation for explicitly opted-in owning ranges.
template <class T,
typename std::enable_if<

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.

warning: use c++14 style type templates [modernize-type-traits]

Suggested change
typename std::enable_if<
std::enable_if_t<

include/clad/Differentiator/Differentiator.h:304:

-                 int>::type = 0>
+                 int> = 0>

template <class T,
typename std::enable_if<
is_range<T>::value && is_copy_and_zero_range<T>::value &&
std::is_copy_constructible<T>::value,

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.

warning: use c++17 style variable templates [modernize-type-traits]

Suggested change
std::is_copy_constructible<T>::value,
std::is_copy_constructible_v<T>,

}

template <class T, class Allocator>
struct is_copy_and_zero_range<std::vector<T, Allocator>> : std::true_type {};

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.

warning: explicit specialization of undeclared template struct 'is_copy_and_zero_range' [clang-diagnostic-error]

struct is_copy_and_zero_range<std::vector<T, Allocator>> : std::true_type {};
       ^

@fogsong233
fogsong233 force-pushed the fix-non-independent-adjoint-zero-init branch from 91b0837 to 8affac2 Compare July 29, 2026 13:10

@github-actions github-actions 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.

clang-tidy made some suggestions

There were too many comments to post at once. Showing the first 10 out of 12. Check the log or trigger a new build to see more.

#include <clad/Differentiator/BuiltinDerivatives.h>
#include <clad/Differentiator/FunctionTraits.h>
#include <deque>
#include <forward_list>

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.

warning: included header deque is not used directly [misc-include-cleaner]

Suggested change
#include <forward_list>
#include <forward_list>

#include <clad/Differentiator/FunctionTraits.h>
#include <deque>
#include <forward_list>
#include <functional>

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.

warning: included header forward_list is not used directly [misc-include-cleaner]

Suggested change
#include <functional>
#include <functional>

#include <initializer_list>
#include <iterator>
#include <list>
#include <map>

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.

warning: included header list is not used directly [misc-include-cleaner]

Suggested change
#include <map>
#include <map>

#include <type_traits>
#include <unordered_map>
#include <valarray>
#include <vector>

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.

warning: included header valarray is not used directly [misc-include-cleaner]

Suggested change
#include <vector>
#include <vector>


// Sequence containers whose copy constructors create independent element
// storage can use the default copy-and-zero implementation of zero_like.
template <class T, std::size_t N>

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.

warning: no header providing "std::size_t" is directly included [misc-include-cleaner]

include/clad/Differentiator/STLBuiltins.h:7:

- #include <deque>
+ #include <cstddef>
+ #include <deque>

// Sequence containers whose copy constructors create independent element
// storage can use the default copy-and-zero implementation of zero_like.
template <class T, std::size_t N>
struct is_copy_and_zero_range<std::array<T, N>> : std::true_type {};

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.

warning: explicit specialization of undeclared template struct 'is_copy_and_zero_range' [clang-diagnostic-error]

struct is_copy_and_zero_range<std::array<T, N>> : std::true_type {};
       ^

struct is_copy_and_zero_range<std::array<T, N>> : std::true_type {};

template <class T, class Allocator>
struct is_copy_and_zero_range<std::deque<T, Allocator>> : std::true_type {};

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.

warning: too few template arguments for class template 'is_copy_and_zero_range' [clang-diagnostic-error]

struct is_copy_and_zero_range<std::deque<T, Allocator>> : std::true_type {};
       ^
Additional context

include/clad/Differentiator/STLBuiltins.h:36: template is declared here

struct is_copy_and_zero_range<std::array<T, N>> : std::true_type {};
       ^

struct is_copy_and_zero_range<std::deque<T, Allocator>> : std::true_type {};

template <class T, class Allocator>
struct is_copy_and_zero_range<std::forward_list<T, Allocator>>

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.

warning: too few template arguments for class template 'is_copy_and_zero_range' [clang-diagnostic-error]

struct is_copy_and_zero_range<std::forward_list<T, Allocator>>
       ^
Additional context

include/clad/Differentiator/STLBuiltins.h:36: template is declared here

struct is_copy_and_zero_range<std::array<T, N>> : std::true_type {};
       ^

: std::true_type {};

template <class T, class Allocator>
struct is_copy_and_zero_range<std::list<T, Allocator>> : std::true_type {};

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.

warning: too few template arguments for class template 'is_copy_and_zero_range' [clang-diagnostic-error]

struct is_copy_and_zero_range<std::list<T, Allocator>> : std::true_type {};
       ^
Additional context

include/clad/Differentiator/STLBuiltins.h:36: template is declared here

struct is_copy_and_zero_range<std::array<T, N>> : std::true_type {};
       ^

struct is_copy_and_zero_range<std::list<T, Allocator>> : std::true_type {};

template <class T, class Allocator>
struct is_copy_and_zero_range<std::vector<T, Allocator>> : std::true_type {};

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.

warning: too few template arguments for class template 'is_copy_and_zero_range' [clang-diagnostic-error]

struct is_copy_and_zero_range<std::vector<T, Allocator>> : std::true_type {};
       ^
Additional context

include/clad/Differentiator/STLBuiltins.h:36: template is declared here

struct is_copy_and_zero_range<std::array<T, N>> : std::true_type {};
       ^

@fogsong233
fogsong233 force-pushed the fix-non-independent-adjoint-zero-init branch from 8affac2 to 9b0557b Compare July 29, 2026 13:29

@github-actions github-actions 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.

clang-tidy made some suggestions


template <class T, class It>
std::integral_constant<
bool, !std::is_same<typename std::remove_cv<T>::type,

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.

warning: use c++14 style type templates [modernize-type-traits]

Suggested change
bool, !std::is_same<typename std::remove_cv<T>::type,
bool, !std::is_same<std::remove_cv_t<T>,

template <class T, class It>
std::integral_constant<
bool, !std::is_same<typename std::remove_cv<T>::type,
typename iterator_traits<It>::value_type>::value>

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.

warning: use c++17 style variable templates [modernize-type-traits]

Suggested change
typename iterator_traits<It>::value_type>::value>
bool, !std::is_same_v<typename std::remove_cv<T>::type,
typename iterator_traits<It>::value_type>>

// Fill an array with zeros.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays)
unsigned char tmp[sizeof(T)] = {};
template <class T, typename std::enable_if<!is_range<T>::value, int>::type = 0>

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.

warning: use c++14 style type templates [modernize-type-traits]

Suggested change
template <class T, typename std::enable_if<!is_range<T>::value, int>::type = 0>
template <class T, std::enable_if_t<!is_range<T>::value, int> = 0>

CUDA_HOST_DEVICE void zero_impl(volatile T& t) {
// Bound once so the assertion and the guard below cannot drift apart: a
// type the assertion rejects must not go on to be memcpy'd anyway.
constexpr bool is_zeroable = std::is_trivially_destructible<T>::value;

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.

warning: use c++17 style variable templates [modernize-type-traits]

Suggested change
constexpr bool is_zeroable = std::is_trivially_destructible<T>::value;
constexpr bool is_zeroable = std::is_trivially_destructible_v<T>;

if constexpr (is_zeroable) {
// Fill an array with zeros.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays)
unsigned char tmp[sizeof(T)] = {};

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.

warning: do not declare C-style arrays, use std::array<> instead [modernize-avoid-c-arrays]

    unsigned char tmp[sizeof(T)] = {};
    ^

for (auto& x : t)
zero_init(x);
}
template <class T, typename std::enable_if<is_range<T>::value, int>::type = 0>

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.

warning: use c++14 style type templates [modernize-type-traits]

Suggested change
template <class T, typename std::enable_if<is_range<T>::value, int>::type = 0>
template <class T, std::enable_if_t<is_range<T>::value, int> = 0>

template <class T> struct is_range;
namespace zero_like_detail {
template <class T, class> struct is_mutable_range;
}

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.

warning: namespace 'zero_like_detail' not terminated with a closing comment [llvm-namespace-comment]

Suggested change
}
} // namespace zero_like_detail
Additional context

include/clad/Differentiator/STLBuiltins.h:26: namespace 'zero_like_detail' starts here

namespace zero_like_detail {
          ^

@fogsong233
fogsong233 force-pushed the fix-non-independent-adjoint-zero-init branch from 9b0557b to f475422 Compare July 29, 2026 14:10

@github-actions github-actions 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.

clang-tidy made some suggestions

#include <iterator>
#include <list>
#include <map>
#include <memory>

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.

warning: included header map is not used directly [misc-include-cleaner]

Suggested change
#include <memory>
#include <memory>

#include <tuple>
#include <type_traits>
#include <unordered_map>
#include <valarray>

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.

warning: included header unordered_map is not used directly [misc-include-cleaner]

Suggested change
#include <valarray>
#include <valarray>

@fogsong233
fogsong233 force-pushed the fix-non-independent-adjoint-zero-init branch from f475422 to 5c3b836 Compare July 29, 2026 14:27

@github-actions github-actions 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.

clang-tidy made some suggestions

// NOLINTBEGIN(cppcoreguidelines-avoid-c-arrays)
template <typename T> CUDA_HOST_DEVICE void zero_init(T* x, std::size_t N) {
for (std::size_t i = 0; i < N; ++i)
zero_init(x[i]);

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.

warning: do not use pointer arithmetic [cppcoreguidelines-pro-bounds-pointer-arithmetic]

    zero_init(x[i]);
              ^

@fogsong233
fogsong233 force-pushed the fix-non-independent-adjoint-zero-init branch from 5c3b836 to 7e52112 Compare July 29, 2026 15:05
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@vgvassilev vgvassilev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This actually looks very good!

Comment thread lib/Differentiator/VisitorBase.cpp Outdated
OverloadCandidateSet::iterator Best = nullptr;
OverloadingResult OverloadResult =
CandidateSet.BestViableFunction(m_Sema, noLoc, Best);
if (OverloadResult != 0U)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
if (OverloadResult != 0U)
if (OverloadResult != OR_Success)

Comment thread lib/Differentiator/VisitorBase.cpp Outdated

namespace clad {

template <class T> struct is_range;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Do we really need this forward declaration -- seems fragile.

template <class T, std::enable_if_t<is_range<T>::value &&
std::is_copy_constructible<T>::value,
int> = 0>
T zero_like(const T& value) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This copies the whole primal only to overwrite it with zeros — for an N-element container that's N element copy-ctors + two data passes. For sized, size-constructible ranges you can build T(value.size()) and skip the copy while keeping zero_init for correctness:

From 4949edc8882e8864a0466a53390a65674907a580 Mon Sep 17 00:00:00 2001
From: Vassil Vassilev <v.g.vassilev@gmail.com>
Date: Wed, 29 Jul 2026 16:51:17 +0000
Subject: [PATCH] [Differentiator] Build sized zero_like adjoints without a
 primal copy.

The default zero_like for a range copies the primal and then zeroes the
copy, so building an adjoint for an N-element container touches the data
twice and runs N element copy-constructors whose results are immediately
overwritten.

For a range that exposes size() and a size-constructor -- vector, deque,
list, valarray -- construct N value-initialized elements directly and
zero_init them; this drops the primal copy while zero_init keeps element
types that do not value-initialize to zero correct. Ranges without a
size-constructor (forward_list, std::array, the associative containers)
keep the copy-then-zero fallback.

Follows up on the zero_like customization point from #1932.
---
 include/clad/Differentiator/Differentiator.h | 42 +++++++++++--
 test/Misc/ZeroLikeSized.C                    | 63 ++++++++++++++++++++
 2 files changed, 101 insertions(+), 4 deletions(-)
 create mode 100644 test/Misc/ZeroLikeSized.C

diff --git a/include/clad/Differentiator/Differentiator.h b/include/clad/Differentiator/Differentiator.h
index 80f74b37..78c1584f 100644
--- a/include/clad/Differentiator/Differentiator.h
+++ b/include/clad/Differentiator/Differentiator.h
@@ -295,10 +295,44 @@ CUDA_HOST_DEVICE auto back(TapeType& of) -> decltype(of.back()) {
     return T();
   }
 
-  /// Default zero_like implementation for copyable ranges.
-  template <class T, std::enable_if_t<is_range<T>::value &&
-                                          std::is_copy_constructible<T>::value,
-                                      int> = 0>
+  // Detect a range that has size() and a T(size_type) constructor, so a zero
+  // adjoint can be built from just the size instead of copying the primal.
+  // C++14-safe overload dispatch, mirroring is_range above.
+  namespace zero_like_detail {
+  template <class T>
+  auto has_sized_ctor(int)
+      -> decltype(static_cast<void>(std::declval<const T&>().size()),
+                  static_cast<void>(T(std::declval<typename T::size_type>())),
+                  std::true_type{});
+  template <class T> std::false_type has_sized_ctor(...);
+  } // namespace zero_like_detail
+
+  template <class T>
+  struct has_sized_ctor : decltype(zero_like_detail::has_sized_ctor<T>(0)) {};
+
+  /// Default zero_like for a sized, size-constructible range (vector, deque,
+  /// list, valarray, ...): construct `size` value-initialized elements -- no
+  /// copy of the primal's values -- then zero_init for correctness on element
+  /// types that do not value-initialize to zero.
+  template <class T,
+            std::enable_if_t<is_range<T>::value &&
+                                 std::is_copy_constructible<T>::value &&
+                                 has_sized_ctor<T>::value,
+                             int> = 0>
+  T zero_like(const T& value) {
+    T result(value.size());
+    zero_init(result);
+    return result;
+  }
+
+  /// Fallback zero_like for copyable ranges without a size-constructor
+  /// (forward_list, std::array, associative containers, ...): copy the
+  /// structure, then zero it.
+  template <class T,
+            std::enable_if_t<is_range<T>::value &&
+                                 std::is_copy_constructible<T>::value &&
+                                 !has_sized_ctor<T>::value,
+                             int> = 0>
   T zero_like(const T& value) {
     T result(value);
     zero_init(result);
diff --git a/test/Misc/ZeroLikeSized.C b/test/Misc/ZeroLikeSized.C
new file mode 100644
index 00000000..30f706bc
--- /dev/null
+++ b/test/Misc/ZeroLikeSized.C
@@ -0,0 +1,63 @@
+// RUN: %cladclang %s -I%S/../../include -o %t
+// RUN: %t | %filecheck_exec %s
+// Lock in the C++14 compatibility of the zero_like SFINAE.
+// RUN: %cladclang -std=c++14 %s -I%S/../../include -o %t14
+// RUN: %t14 | %filecheck_exec %s
+
+// A sized, size-constructible range (std::vector) builds its zero adjoint from
+// the size alone -- no copy of the primal's values -- while ranges without a
+// size-constructor (std::forward_list, std::array) fall back to copy-then-zero.
+// All must still yield an all-zero, same-shape result.
+
+#include "clad/Differentiator/Differentiator.h"
+#include "clad/Differentiator/STLBuiltins.h"
+
+#include <array>
+#include <cstdio>
+#include <forward_list>
+#include <vector>
+
+// Counts copies and does NOT value-initialize to zero: proves the sized path
+// avoids the primal copy yet still zeroes via zero_init.
+struct Counted {
+  double v = 7;
+  static long copies;
+  Counted() = default;
+  Counted(const Counted& o) : v(o.v) { ++copies; }
+  Counted& operator=(const Counted&) = default;
+};
+long Counted::copies = 0;
+
+namespace clad {
+void zero_init(Counted& c) { c.v = 0; }
+} // namespace clad
+
+template <class R> bool all_zero(const R& r) {
+  for (const auto& e : r)
+    if (e.v != 0)
+      return false;
+  return true;
+}
+
+int main() {
+  std::vector<Counted> vec(1000);
+  for (auto& c : vec)
+    c.v = 3.14;
+  Counted::copies = 0;
+  auto dvec = clad::zero_like(vec); // sized path: constructs, never copies
+  std::printf("%d %ld\n", (int)(all_zero(dvec) && dvec.size() == vec.size()),
+              Counted::copies);
+  // CHECK-EXEC: 1 0
+
+  std::forward_list<Counted> fl(3); // no size() -> copy fallback
+  for (auto& c : fl)
+    c.v = 2.0;
+  std::printf("%d\n", (int)all_zero(clad::zero_like(fl)));
+  // CHECK-EXEC: 1
+
+  std::array<Counted, 4> arr{}; // no size-ctor -> copy fallback
+  for (auto& c : arr)
+    c.v = 5.0;
+  std::printf("%d\n", (int)all_zero(clad::zero_like(arr)));
+  // CHECK-EXEC: 1
+}
-- 
2.39.5 (Apple Git-154)

@fogsong233 fogsong233 Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I am concerned that this optimization is not generally correct. For example, for an std::vector<std::vector<double>> with an m x n (or ragged) shape, T(value.size()) preserves only the outer size. It constructs m empty inner vectors, and zero_init only zeros existing elements, so it cannot recover the inner sizes.

This particular case could be addressed by recursively applying the same construction to each element. In the longer term, however, that would still not make size-based construction safe as a generic rule. A size is not a complete structural descriptor: stateful allocators or memory resources, comparators, hash functions, key-equality objects, and other runtime metadata may be lost.

For example, although Clad does not appear to support differentiating std::unordered_map today, it is a concrete false positive for has_sized_ctor. It has an unordered_map(size_type bucket_count) constructor, so T(value.size()) constructs an empty container whose bucket count merely happens to equal the primal size. It drops all keys and values and resets the hash, equality, and allocator state.

Therefore, I think recursively reconstructing each element from the corresponding primal value can serve as the default zero_like strategy for sized ranges. This would preserve nested sizes without copying primal values. Types whose relevant structure cannot be fully reconstructed this way, such as those carrying stateful allocators or other runtime metadata, should provide type-specific zero_like overloads or explicitly opt into a more appropriate construction policy.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

That’s a fair point. However, I am really worried about the performance hit we are going to take with this feature. What are our alternative optimization options?

template <class T, std::enable_if_t<std::is_arithmetic<T>::value ||
std::is_enum<T>::value,
int> = 0>
CUDA_HOST_DEVICE T zero_like(const T&) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Two customization points now coexist (zero_init in-place vs zero_like return-new) and the boundary is undocumented. Add to the doc-comment + docs/: "zero_like(x) returns a value structurally like x with zero_init applied; override it when copy-then-zero is wrong — owning storage, refcounts, device memory." Consider documenting zero_like as the adjoint-construction extension point and zero_init as the primitive it builds on.

We can add the missing section on zero_init, too.

@@ -0,0 +1,54 @@
// RUN: %cladclang %s -I%S/../../include -o %t 2>&1 | %filecheck %s

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

zero_like's SFINAE is deliberately C++14-safe (the NOLINT(modernize-type-traits)), but these run one mode. Add -std=c++14 and -std=c++20 RUN lines so a future _v-trait/if constexpr creep can't silently regress C++14 clients.

Same holds for the other test.

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

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