Duplicated code-gen on clang for non-defaulted destructor in templated class with shared_ptr
16:57 05 Aug 2026

My friend Claude and I found a codegen difference in clang we don't understand, in a performance-sensitive codebase I'm working on. Adding an explicitly-defaulted destructor roughly halves the code clang emits for a std::variant's destructor.

Four things have to be true for it to happen:

  1. the compiler is clang
  2. the alternatives have user-provided copy/move members
  3. the variant has at least two distinct alternative types
  4. -O2 or -O3

This is min.cpp:

#include 
#include 
#include 

template 
struct A {                       // owns a vector + a shared_ptr
    std::vector v;
    std::shared_ptr p;
    A() = default;
    A(const A& o) : v(o.v), p(o.p) {}
    A(A&& o) noexcept : v(std::move(o.v)), p(std::move(o.p)) {}
    A& operator=(const A& o) { A c(o); swap(c); return *this; }
    A& operator=(A&& o) noexcept { A m(std::move(o)); swap(m); return *this; }
#ifdef WITH_DTOR
    ~A() = default;
#endif
    void swap(A& o) noexcept { v.swap(o.v); p.swap(o.p); }
};

struct B {                       // a DIFFERENT shape: two shared_ptrs
    std::vector v;
    std::shared_ptr p1;
    std::shared_ptr p2;
    B() = default;
    B(const B& o) : v(o.v), p1(o.p1), p2(o.p2) {}
    B(B&& o) noexcept : v(std::move(o.v)), p1(std::move(o.p1)), p2(std::move(o.p2)) {}
    B& operator=(const B& o) { B c(o); swap(c)
    B& operator=(B&& o) noexcept { B m(std::move(o)); swap(m); return *this; }
#ifdef WITH_DTOR
    ~B() = default;
#endif
    void swap(B& o) noexcept { v.swap(o.v); p1.swap(o.p1); p2.swap(o.p2); }
};

using V = std::variant, A<1>, B>;
void reset(V& v) { v = V{}; }

I run:

~/repro$ clang++ --version | head -n1
Ubuntu clang version 23.0.0 (++20260707084633+ec9e62cb609a-1~exp1~20260707084806.846)
~/repro$ clang++ -O3 -std=c++20 -c min.cpp -DWITH_DTOR
~/repro$ size min.o
   text    data     bss     dec     hex filename
   1342       8       0    1350     546 min.o
~/repro$ clang++ -O3 -std=c++20 -c min.cpp
~/repro$ size min.o
   text    data     bss     dec     hex filename
   2509       8       0    2517     9d5 min.o

.text goes from 2509 to 1342 bytes. The main difference is three functions that are emitted in the first build and disappear entirely in the second: B::operator=(B&&) (345 bytes), the _Move_assign_base, A<1>, B>::operator= visitor (349), and its __do_visit instantiation (233). With the destructor declared, clang inlines that whole move-assignment path away; without it, the functions survive as out-of-line copies.

Removing any one of the four conditions makes the difference vanish.

The question

Since ~A() = default; on first declaration leaves the class trivially destructible exactly when it already was, and these classes are never trivially destructible, the declaration should be semantically inert. Why does its presence change what clang inlines?

c++ code-generation clang++ std-variant