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:
- the compiler is clang
- the alternatives have user-provided copy/move members
- the variant has at least two distinct alternative types
-O2or-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 (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?