Summary
etl::visit (using version 20.49.0) dispatches through a table of function pointers, with one separate function for each alternative of the variant. The compiler cannot inline a call through a pointer, so every visit call site adds N out-of-line functions, where N is the number of alternatives.
libstdc++ dispatches small variants with a switch, so its cases are inlined into one function. As a result, etl::visit produces 58% more code than std::visit at -Os, and 149% more at -O2. The reproducer below shows this.
We found this while testing whether etl::variant could save flash in place of std::variant on a Cortex-M7 application. The switch made the image 34.7 KB larger.
Related work
This looks like the remaining part of #1390 ("etl::variant uses too much flash size"), after #1441.
Environment
| Item |
Value |
| ETL |
20.49.0 |
| Compiler |
arm-zephyr-eabi-g++ 12.2.0 (Zephyr SDK 0.16.0) |
| Target |
Cortex-M7, Thumb |
| Standard |
C++20 |
| Defines |
ETL_NO_STL, ETL_FORCE_STD_INITIALIZER_LIST |
| Other flags |
-fno-exceptions -fno-rtti -ffunction-sections -fdata-sections |
Reproducer
The program visits a variant with 7 alternatives from 30 different lambda types. A real application does the same when a class template that visits a variant has many instantiations.
// repro.cpp
// Build once with -DUSE_ETL and once without, then compare the .text size.
#include <cstdint>
#include <type_traits>
#include <utility>
#if defined(USE_ETL)
#include <etl/variant.h>
namespace lib = etl;
#else
#include <variant>
namespace lib = std;
#endif
struct Timestamp {
int64_t seconds;
};
// Seven alternatives, like a typical "signal value" type.
using Value = lib::variant<bool, int32_t, uint32_t, double, float, int64_t, Timestamp>;
// Each instantiation visits the variant with its own lambda type.
template <typename T, int Tag>
struct Input {
T stored{};
__attribute__((noinline)) bool update(const Value& value) {
bool updated = false;
lib::visit(
[this, &updated](const auto& x) {
using S = std::decay_t<decltype(x)>;
if constexpr (std::is_arithmetic_v<S>) {
stored = static_cast<T>(x);
updated = true;
}
},
value);
return updated;
}
};
template <typename T, int Tag>
Input<T, Tag> gInput;
template <typename T, int... Tags>
bool updateAll(const Value& value, std::integer_sequence<int, Tags...>) {
return (gInput<T, Tags>.update(value) | ...);
}
// 30 visitor instantiations in total.
extern "C" bool run(const Value* value) {
return updateAll<double>(*value, std::make_integer_sequence<int, 10>{}) |
updateAll<int32_t>(*value, std::make_integer_sequence<int, 10>{}) |
updateAll<uint32_t>(*value, std::make_integer_sequence<int, 10>{});
}
Build commands:
FLAGS="-mcpu=cortex-m7 -mthumb -std=c++20 -fno-exceptions -fno-rtti \
-ffunction-sections -fdata-sections \
-DETL_NO_STL -DETL_FORCE_STD_INITIALIZER_LIST -Ietl/include"
arm-zephyr-eabi-g++ $FLAGS -Os -c repro.cpp -o std.o
arm-zephyr-eabi-g++ $FLAGS -Os -DUSE_ETL -c repro.cpp -o etl.o
size -A std.o etl.o # add up the .text* sections
nm -S -C etl.o # list the generated functions
Results
| Optimisation |
Library |
.text |
Functions |
-Os |
std::visit |
3,888 B |
61 |
-Os |
etl::visit |
6,128 B (+58%) |
241 |
-O2 |
std::visit |
2,768 B |
31 |
-O2 |
etl::visit |
6,888 B (+149%) |
241 |
At -Os, the ETL build contains 210 functions named etl::private_variant::do_visit_single<…, I>. That is 30 visitors × 7 alternatives. The std build contains 30 std::__do_visit functions instead, one for each visitor.
Real application
In a Cortex-M7 firmware image (-Os, about 1 MB of flash), we switched two
variant types from std::variant to etl::variant:
- a value type with 7 alternatives, visited by about 137 class template instantiations
- an endpoint type with 2 alternatives, compared with
operator==
|
std::variant |
etl::variant |
| Visit functions |
142 |
968 |
| Size of visit functions |
22.2 KB |
45.6 KB |
| Total flash |
1,005,012 B |
1,039,716 B (+34,704 B) |
Other variant operations cost about the same in both libraries. For example, get_if, holds_alternative and construction changed by −148 B in total across 463 functions.
Cause
In include/etl/private/variant_variadic.h (around line 2227), do_visit builds a jump table and calls through it:
constexpr func_ptr jmp_table[]{helper_t::template fptr<tIndices>()...};
return jmp_table[v.index()](static_cast<TCallable&&>(f), static_cast<TVariant&&>(v), static_cast<TVarRest&&>(variants)...);
Each fptr<I>() is a separate function. The call goes through a pointer, so the compiler cannot inline it or merge the cases.
operator== for etl::variant is also built on etl::visit (equality_visitor), so every comparison pays the same cost.
Suggested change
For variants with a small number of alternatives, dispatch with a switch on index() so the compiler can inline each case. Keep the jump table for large variants.
libstdc++ does this in <variant>. It uses a switch for up to 11 alternatives, through the _GLIBCXX_VISIT_CASE macro:
constexpr size_t __max = 11; // "These go to eleven."
...
switch (__v0.index())
{
_GLIBCXX_VISIT_CASE(0)
_GLIBCXX_VISIT_CASE(1)
...
}
A similar change in etl::visit for the single-variant case, the most common one, should bring its size close to std::visit.
Notes
- The difference grows with the number of alternatives and the number of distinct visitor types.
- Our results are from GCC 12.2 on ARM. We have not measured other compilers.
Summary
etl::visit(using version 20.49.0) dispatches through a table of function pointers, with one separate function for each alternative of the variant. The compiler cannot inline a call through a pointer, so every visit call site adds N out-of-line functions, where N is the number of alternatives.libstdc++ dispatches small variants with a
switch, so its cases are inlined into one function. As a result,etl::visitproduces 58% more code thanstd::visitat-Os, and 149% more at-O2. The reproducer below shows this.We found this while testing whether
etl::variantcould save flash in place ofstd::varianton a Cortex-M7 application. The switch made the image 34.7 KB larger.Related work
This looks like the remaining part of #1390 ("etl::variant uses too much flash size"), after #1441.
visit. The jump table indo_visitand thedo_visit_singlefunctions are the same in 20.47.1, 20.48.0 and 20.49.0.visit, not from copy, move or destroy.Environment
arm-zephyr-eabi-g++12.2.0 (Zephyr SDK 0.16.0)ETL_NO_STL,ETL_FORCE_STD_INITIALIZER_LIST-fno-exceptions -fno-rtti -ffunction-sections -fdata-sectionsReproducer
The program visits a variant with 7 alternatives from 30 different lambda types. A real application does the same when a class template that visits a variant has many instantiations.
Build commands:
Results
.text-Osstd::visit-Osetl::visit-O2std::visit-O2etl::visitAt
-Os, the ETL build contains 210 functions namedetl::private_variant::do_visit_single<…, I>. That is 30 visitors × 7 alternatives. The std build contains 30std::__do_visitfunctions instead, one for each visitor.Real application
In a Cortex-M7 firmware image (
-Os, about 1 MB of flash), we switched twovariant types from
std::varianttoetl::variant:operator==std::variantetl::variantOther variant operations cost about the same in both libraries. For example,
get_if,holds_alternativeand construction changed by −148 B in total across 463 functions.Cause
In
include/etl/private/variant_variadic.h(around line 2227),do_visitbuilds a jump table and calls through it:Each
fptr<I>()is a separate function. The call goes through a pointer, so the compiler cannot inline it or merge the cases.operator==foretl::variantis also built onetl::visit(equality_visitor), so every comparison pays the same cost.Suggested change
For variants with a small number of alternatives, dispatch with a
switchonindex()so the compiler can inline each case. Keep the jump table for large variants.libstdc++ does this in
<variant>. It uses aswitchfor up to 11 alternatives, through the_GLIBCXX_VISIT_CASEmacro:A similar change in
etl::visitfor the single-variant case, the most common one, should bring its size close tostd::visit.Notes