【问题标题】:Why is iterating over std::ranges::views::join so slow为什么遍历 std::ranges::views::join 这么慢
【发布时间】:2022-12-15 15:11:27
【问题描述】:

这是这个SO Answer 的后续。给定一个平面输入范围和三个 size_t 维度,代码创建一个嵌套的 random_access_range of random_access_ranges of random_access_ranges,建模三维数组。

Quickbench

使用嵌套 for 循环和索引遍历“多维”范围内的元素比直接遍历输入范围的元素要慢一些(慢 4 倍)。我想一些性能下降是可以预料的,但 4 倍会有点痛。

更糟糕的是,递归views::join将多维范围返回到平坦范围并在该平坦范围内迭代速度慢了 20 倍。阅读了this Github issue 中的 cmets 后,可以预期 views::join 将附带一些额外的开销,但 20 倍似乎有点多。

如何解释views::join 的巨大开销?我使用它是错误的还是我的基准有问题?有没有什么可以加快代码速度,或者范围对于这种应用程序来说只是一个糟糕的选择?

执行

代码可以在上面的 Quickbench 链接下找到,为了完整起见,我将在此处添加它:

#include <vector>
#include <ranges>
#include <cassert>
#include <iostream>

template <size_t dim>
struct Slice {
    // default constructor leaves start at zero and end at dim. Correspondes to the whole dimension
    constexpr Slice() = default;

    // Create a slice with a single index
    constexpr Slice(size_t i) : begin(i), end(i+1) {
        assert( (0 <= i) && (i < dim));
    }

    // Create a slice with a start and an end index
    constexpr Slice(size_t s, size_t e) : begin(s), end(e+1) {
        assert( (0 <= s) && (s <= e) && (e < dim) );
    } 

    size_t begin {0};
    size_t end {dim};
};

// An adaptor object to interpret a flat range as a multidimensional array
template <size_t dim, size_t... dims>
struct MD {
    constexpr static auto dimensions = std::make_tuple(dim, dims...);
    consteval static size_t size(){
        if constexpr (sizeof...(dims) > 0) {
            return dim*(dims * ...);
        }
        else {
            return dim;
        }
    }

    // returns a multidimensional range over the elements in the flat array
    template <typename Rng>
    constexpr static auto slice(
        Rng&& range, 
        Slice<dim> const& slice, 
        Slice<dims> const&... slices
    )
    {        
        return slice_impl(range, 0, slice, slices...);
    }

    template <typename Rng>
    constexpr static auto slice_impl(
        Rng&& range, 
        size_t flat_index,  
        Slice<dim> const& slice, 
        Slice<dims> const&... slices
    )
    {
        if constexpr (std::ranges::sized_range<Rng>) { assert(std::size(range) >= size());  }
        static_assert(sizeof...(slices) == sizeof...(dims), "wrong number of slice arguments.");

        if constexpr (sizeof...(slices) == 0) 
        {
            // end recursion at inner most range
            return range | std::views::drop(flat_index*dim + slice.begin) | std::views::take(slice.end - slice.begin);
        }
        else 
        {
            // for every index to be kept in this dimension, recurse to the next dimension and increment the flat_index
            return std::views::iota(slice.begin, slice.end) | std::views::transform(
                [&range, flat_index, slices...](size_t i){
                    return MD<dims...>::slice_impl(range, flat_index*dim + i, slices...);
                }
            );
        }
    }

    // convenience overload for the full view
    template <typename Rng>
    constexpr static auto slice(Rng&& range){
        return slice(range, Slice<dim>{}, Slice<dims>{}...);
    }


};

// recursively join a range of ranges
// https://stackoverflow.com/questions/63249315/use-of-auto-before-deduction-of-auto-with-recursive-concept-based-fun
template <typename Rng>
auto flat(Rng&& rng) {
    using namespace std::ranges;

    auto joined = rng | views::join;    
    if constexpr (range<range_value_t<decltype(joined)>>) {
        return flat(joined);
    } else {
        return joined;
    }
}

测试用例

迭代 10x10x10 数组中的两个 6x6x6 切片,并将一个切片的元素添加到另一个切片。

// define the dimensions of a 3d-array
constexpr size_t nx{10}, ny{10}, nz{10};

// define the contents of two nx x ny x nz arrays in and out
std::vector<double> Vout(nx*ny*nz, 0.);
std::vector<double> Vin(nx*ny*nz, 1.23);

// define some slice indices for each dimension
size_t lx{0}, ly{0}, lz{0};
size_t hx{5}, hy{5}, hz{5};

auto r_in = MD<nx,ny,nz>::slice(Vin, {lx, hx}, {ly, hy}, {lz, hz});
auto r_out = MD<nx,ny,nz>::slice(Vout, {lx, hx}, {ly, hy}, {lz, hz});

传统的 for 循环

for (int k=lz; k<hz; ++k)
    for (int j=ly; j<hy; ++j)
        for (int i=lx; i<hx; ++i)
            Vout[i+nx*(j+ny*k)] += Vin[i+nx*(j+ny*k)];

MDRanges 设置

该基准测试仅测试创建两个 MD&lt;2,3,2&gt; 对象的时间和平坦范围,而不对其进行迭代。

遍历展平/连接的范围

// C++23: for (auto [o, i] : std::views::zip(flat(r_out), flat(r_in))) { o = i; }

auto r_in_flat = flat(r_in);
auto r_out_flat = flat(r_out);

auto o = r_out_flat.begin();
auto i = r_in_flat.begin();
for(; o != r_out_flat.end(); i++, o++){
    *o += *i;
}

使用范围的嵌套循环

for (size_t x = 0; x <= hx-lx; ++x)
    for (size_t y = 0; y <= hy-ly; ++y)
        for (size_t z = 0; z <= hz-lz; ++z)
            r_out[x][y][z] += r_in[x][y][z];

编辑 1:

我发现了基准测试的一个问题:传统循环遗漏了一些值,因为我在本应使用 &lt;= 的 for 循环条件下使用了 &lt;

for (int k=lz; k<=hz; ++k)
  for (int j=ly; j<=hy; ++j)
      for (int i=lx; i<=hx; ++i)
          Vout[i+nx*(j+ny*k)] += Vin[i+nx*(j+ny*k)];

这样一来,区别就不那么明显了:使用范围的嵌套循环比传统循环慢 2 倍,而连接范围的循环慢 12 倍。尽管如此,我还是希望能得到更轻的处罚。

Quickbench

编辑 2:

受@Newbies 评论的启发,我使用 1x1xN 数组运行了一些基准测试。有趣的是,第一次快速检查显示了非常糟糕的结果,其中非连接范围实现比本机嵌套循环慢 450 倍:https://quick-bench.com/q/-ZHPSTtvF4EZVg3JmuqMec4TYyU

因此,我使用 1xN 数组运行了一些测试,以对我在实现中使用的范围模式进行基准测试:

掉落:在最右边的维度中,我只是std::views::drop前几个元素和std::views::take我正在寻找的元素数量。此范围的形式为 take(drop(input_range))。这个take_drop 模式工作得很好,迭代它基本上和迭代输入范围一样快。

iota_transform:在除最右边的所有其他维度中,我 std::views::transform std::views::iota 的元素对于每个索引到通过递归从右邻维度获得的范围。因此,对于倒数第二个维度,我们创建了一系列 transform(iota, LAMBDA(take(drop(input_range)))) 形式的范围。基准测试表明这会导致计算时间加倍(大概是因为缺乏矢量化?).

加入:没有多少“模式”,但我包括了一个迭代join(transform(iota, LAMBDA(take(drop(input_range)))))的基准。性能再次下降 5.5 倍。

Quickbench

所以也许iota_transform模式是反模式?使用 std::views::iota 基于索引列表构建一系列范围对我来说似乎是规范的,尽管开发人员可能没有考虑范围作为 std::views::transform 的输出。我想要迭代的实际范围在传递给转换的 lambda 表达式中,所以这可能是编译器优化的硬障碍?

但即便如此,它仍然没有回答为什么 std::views::join 应该慢得多的问题。我不明白为什么它需要 5.5 倍的计算时间。

【问题讨论】:

  • 第一个问题:您是在对调试版本还是优化版本(例如-O3)进行基准测试?
  • O3,查看 Quickbench 链接
  • 您可以在此处包含一些代码作为上下文吗?
  • 你看过asm吗?唯一有意义的汇编是TraditionalForLoopMDRanges_setup 的时间较短,但它什么也没做,MDRanges_loop_over_joined 是一团糟,几乎所有时间都花在了不是实际计算的奇怪事情上,MDRanges_nested_loop 没有矢量化并且有一堆循环中的废话,但总的火车残骸少了一点。
  • 我假设 foor 循环得到矢量化和 SIMD 优化更多,其中范围不太可预测并且可能有一些分支来处理连接逻辑。此外,范围是结构,因此还会进行一些分配。我会逐渐测试更大的长方体和 1x1xN 的长方体,以验证范围是否存在内在问题,或者当与值的数量相比有很多长方体时是否存在问题。对于简单的 6^3 示例,您有 43 个范围。

标签: c++ c++20 benchmarking std-ranges


【解决方案1】:

我检查了几种在容器上迭代的方法,它等同于另一种方法! ref 但您也可以使用旧样式进行性能检查(v.data() ,转到页面数据的末尾)。

#include <ranges>
#include <vector>
#include <iostream>
#include <type_traits>

static std::vector<int> v(1e5,99999);

static void overRange(benchmark::State& state) { 
  for (auto _ : state) {
    for (auto i : v) benchmark::DoNotOptimize(i);
  }
}
BENCHMARK(overRange);

static void overForEach(benchmark::State& state) {  
  for (auto _ : state) {
    for_each(v.begin(),v.end(),[](auto i){benchmark::DoNotOptimize(i);});
  }
}
BENCHMARK(overForEach);

static void overOldStyle(benchmark::State& state) {  
  for (auto _ : state) {
    for(std::size_t i =0;i < v.size();++i)
      benchmark::DoNotOptimize(v[i]);
  }
}
BENCHMARK(overOldStyle);

static void overView(benchmark::State& state) {
  for (auto _ : state) {
    for(int n : std::views::all(v)) {
      benchmark::DoNotOptimize(n);
    }
  }
}
BENCHMARK(overView);


【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2020-11-15
    • 2021-07-05
    • 1970-01-01
    • 2014-11-23
    • 1970-01-01
    • 2014-10-26
    • 2022-11-24
    • 2021-05-11
    相关资源
    最近更新 更多