Experimenting with the standard variant

published at 02.03.2023 12:12 by Jens Weller
Save to Instapaper Pocket

I've started sketching out a piece of software that I'm about to write. And part of this is a variant, so I was wondering about variants performance and if the various ways to access its value differ.

And my tool of choice for this was quick bench. I plan to run under gcc 11 and C++20, so that is the combination of flags I chose for the compilation environment. And initial results were interesting:

blog/variantperf.png

When sketching out the code to handle things, I first went with a 2 type variant. I did not expect such a stark difference, but a comment from Mastodon hints at this being a bug in gcc 11, which later got improved. Also this code doesn't really cycle between the two types and it may just show this also.

Adding more types made some changes to the code necessary, the single variant became a vector of variants through which the code cycles. Handling the various types also makes the code longer, and in case of the if/else I was wondering, there must be a better way. And with variants index() member function one can indeed test, if this makes a difference.

Lets look at the code, first the version with std::visit and if constexpr as a type switch:

using myvariant = std::variant<std::string_view,int,float,double,std::string>;
static void StdVisit(benchmark::State& state) {
  std::string str("long,int,views");
  std::vector v;v.push_back(std::string_view(&str[4],3));
  v.push_back({4});v.push_back({1.0f});v.push_back({1.0d});v.push_back(std::string("kljdfalsd"));
  int i = 0;
  size_t x = 0;
  for (auto _ : state) {
    myvariant& lv = v[i % v.size()];
    x += std::visit([](auto&& arg) {
            using T = std::decay_t<decltype(arg)>;
            size_t x =0;
            if constexpr (std::is_same_v<T, std::string_view>)
                x += arg.size();
            else if constexpr (std::is_same_v<T, int>)
                x += arg;
            else if constexpr (std::is_same_v<T, float>)
                x += arg;
            else if constexpr (std::is_same_v<T, double>)
                x += arg;
            else if constexpr (std::is_same_v<T, std::string>)
                x += arg.size();
           benchmark::DoNotOptimize(arg);
           benchmark::DoNotOptimize(x);
           return x;
        }, lv);
    i++;
    benchmark::DoNotOptimize(i);
  }
}

To me the above version is the most "Modern C++" one. if constexpr and other compile time techniques can be used to optimise the code. I even could make the if/else code shorter by expanding the if with a logical or (||) for the other types.

Then comes the code block with the if/else blocks using get_if. Pointers are rare in C++, but in this code the committee chose to give you a pointer or a nullptr, depending if the variant holds this type or not.

static void VariantGetIf(benchmark::State& state) {
  // Code before the loop is not measured
  std::string str("long,int,views");
  std::vector v;v.push_back(std::string_view(&str[4],3));
  v.push_back({4});v.push_back({1.0f});v.push_back({1.0d});v.push_back(std::string("kljdfalsd"));
    size_t x = 0;
    int i = 0;
  for (auto _ : state) {
    myvariant& lv = v[i % v.size()];
    if(const int* pval = std::get_if(&lv))
          x += *pval;
    else if(const std::string_view* pval = std::get_if(&lv))
      x+= pval->size();
    else if(const float* pval = std::get_if(&lv))
      x+= *pval;
    else if(const double* pval = std::get_if(&lv))
      x+= *pval;
    else if(const std::string* pval = std::get_if(&lv))
      x+= pval->size();
    i++;
    benchmark::DoNotOptimize(i);
    benchmark::DoNotOptimize(x);
  }
}

As I already mentioned, the if/else block here makes me a bit uneasy. Its ok, but the code could be shorter and more precise. The last type in the if/else will have to run through all other calls of get_if and their tests. What if there is a better way?

The better way could be a switch, as std::variant has an index method, giving you the zero based index of the type held by the variant. You then can run this over a switch and the overhead of the if/else block is gone:

static void VariantIndex(benchmark::State& state) {
  // Code before the loop is not measured
  std::string str("long,int,views");
  std::vector v;v.push_back(std::string_view(&str[4],3));
  v.push_back({4});v.push_back({1.0f});v.push_back({1.0d});v.push_back(std::string("kljdfalsd"));
    size_t x = 0;
    int i = 0;
  for (auto _ : state) {
    myvariant& lv = v[i % v.size()];
    switch(lv.index())
    {
    case 0: if(const int* pval = std::get_if(&lv))
          x += *pval;
          break;
    case 1: if(const std::string_view* pval = std::get_if(&lv))
      x+= pval->size();
      break;
    case 2: if(const float* pval = std::get_if(&lv))
      x+= *pval;
      break;
    case 3: if(const double* pval = std::get_if(&lv))
      x+= *pval;
      break;
    case 4: if(const std::string* pval = std::get_if(&lv))
      x+= pval->size();
      break;
    }
    i++;
    benchmark::DoNotOptimize(i);
    benchmark::DoNotOptimize(x);
  }
}

When running this on Quick Bench, there is no real difference in the code any more. Variants live at run time, so that not all things can be optimized as with other code may possible. Personally, I prefer the code with std::visit, but was curious if that holds up to the other ways.

Most of this code was written on a Sunday night, so its not the best in all manners. Like the original version with index() had an index bug in the switch. I tried getting std::get to run with it, but it mostly gave me time outs on quick-bench. Thanks to Toby Allsopp's tweet there is a version with std::get and thanks for the hint at the index bug! Playing around with all this allowed me to get a feeling on how to handle the data with variant.

Right now I'm also thinking about std::any, and if storing a single value in this form would be cheaper on the performance. Also, for the moment I think that I'll not have the std::string_view in the variant, but have it as a data member before it. The variant then only represents conversions from the raw data, but the hot code path of dealing with the raw data or seeing it as a string is not dealing with variant.

 

Join the Meeting C++ patreon community!
This and other posts on Meeting C++ are enabled by my supporters on patreon!