Skip to content

etl::visit creates one function per alternative, so it produces more code than std::visit #1603

Description

@tolgayilmaz86

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions