【发布时间】:2022-12-15 15:11:27
【问题描述】:
这是这个SO Answer 的后续。给定一个平面输入范围和三个 size_t 维度,代码创建一个嵌套的 random_access_range of random_access_ranges of random_access_ranges,建模三维数组。
使用嵌套 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<2,3,2> 对象的时间和平坦范围,而不对其进行迭代。
遍历展平/连接的范围
// 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:
我发现了基准测试的一个问题:传统循环遗漏了一些值,因为我在本应使用 <= 的 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)];
这样一来,区别就不那么明显了:使用范围的嵌套循环比传统循环慢 2 倍,而连接范围的循环慢 12 倍。尽管如此,我还是希望能得到更轻的处罚。
编辑 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 倍。
所以也许iota_transform模式是反模式?使用 std::views::iota 基于索引列表构建一系列范围对我来说似乎是规范的,尽管开发人员可能没有考虑范围作为 std::views::transform 的输出。我想要迭代的实际范围在传递给转换的 lambda 表达式中,所以这可能是编译器优化的硬障碍?
但即便如此,它仍然没有回答为什么 std::views::join 应该慢得多的问题。我不明白为什么它需要 5.5 倍的计算时间。
【问题讨论】:
-
第一个问题:您是在对调试版本还是优化版本(例如
-O3)进行基准测试? -
O3,查看 Quickbench 链接
-
您可以在此处包含一些代码作为上下文吗?
-
你看过asm吗?唯一有意义的汇编是
TraditionalForLoop。MDRanges_setup的时间较短,但它什么也没做,MDRanges_loop_over_joined是一团糟,几乎所有时间都花在了不是实际计算的奇怪事情上,MDRanges_nested_loop没有矢量化并且有一堆循环中的废话,但总的火车残骸少了一点。 -
我假设 foor 循环得到矢量化和 SIMD 优化更多,其中范围不太可预测并且可能有一些分支来处理连接逻辑。此外,范围是结构,因此还会进行一些分配。我会逐渐测试更大的长方体和 1x1xN 的长方体,以验证范围是否存在内在问题,或者当与值的数量相比有很多长方体时是否存在问题。对于简单的 6^3 示例,您有 43 个范围。
标签: c++ c++20 benchmarking std-ranges