了解 std::transform 以及如何打败它
Understanding std::transform and how to beat it
我正在尝试理解针对一个简单、具体问题的面向数据的设计。提前向面向数据的设计人员道歉,如果我做了一些非常愚蠢的事情,但我很难理解我的推理失败的原因和位置。
假设我有一个简单的操作,即,float_t result = int_t(lhs) / int_t(rhs)
。如果我将所有变量保存在它们相应的容器中, 例如 、std::vector<float_t>
和 std::vector<int_t>
,并且我使用 std::transform
,我会得到正确的结果.然后,对于 using float_t = float
和 using int_t = int16_t
的具体示例,我假设在 64 位架构上将这些变量打包到 struct
中,并将它们收集到容器中 应该 产生更好的性能。
我认为 struct
构成了一个 64 位对象,并且对 struct
的单个内存访问将为我提供我需要的所有变量。另一方面,当所有这些变量都收集在不同的容器中时,我将需要三种不同的内存访问来获取所需的信息。以下是我如何设置环境:
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <vector>
using namespace std::chrono;
template <class float_t, class int_t> struct Packed {
float_t sinvl;
int_t s, l;
Packed() = default;
Packed(float_t sinvl, int_t s, int_t l) : sinvl{sinvl}, s{s}, l{l} {}
void comp() { sinvl = float_t(l) / s; }
};
using my_float = float;
using my_int = int16_t;
int main(int argc, char *argv[]) {
constexpr uint32_t M{100};
for (auto N : {1000, 10000, 100000}) {
double t1{0}, t2{0};
for (uint32_t m = 0; m < M; m++) {
std::vector<my_float> sinvl(N, 0.0);
std::vector<my_int> s(N, 3), l(N, 2);
std::vector<Packed<my_float, my_int>> p1(
N, Packed<my_float, my_int>(0.0, 3, 2));
// benchmark unpacked
auto tstart = high_resolution_clock::now();
std::transform(l.cbegin(), l.cend(), s.cbegin(), sinvl.begin(),
std::divides<my_float>{}); // 3 different memory accesses
auto tend = high_resolution_clock::now();
t1 += duration_cast<microseconds>(tend - tstart).count();
if (m == M - 1)
std::cout << "sinvl[0]: " << sinvl[0] << '\n';
// benchmark packed
tstart = high_resolution_clock::now();
for (auto &elem : p1) // 1 memory access
elem.comp();
tend = high_resolution_clock::now();
t2 += duration_cast<microseconds>(tend - tstart).count();
if (m == M - 1)
std::cout << "p1[0].sinvl: " << p1[0].sinvl << '\n';
}
std::cout << "N = " << N << ", unpacked: " << (t1 / M) << " us.\n";
std::cout << "N = " << N << ", packed: " << (t2 / M) << " us.\n";
}
return 0;
}
g++ -O3
的编译代码在我的机器上产生,
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 1000, unpacked: 0 us.
N = 1000, packed: 1 us.
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 10000, unpacked: 5.06 us.
N = 10000, packed: 12.97 us.
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 100000, unpacked: 52.31 us.
N = 100000, packed: 124.49 us.
基本上,std::transform
比 2.5x
压缩访问要快。如果您帮助我理解这种行为,我将不胜感激。结果是由于
- 我没有正确把握面向数据的设计原则,或者,
- 这个非常简单的示例的一些人工制品,例如内存位置彼此分配得非常接近,并且在某种程度上被编译器非常有效地优化?
最后,在这个例子中有没有办法击败 std::transform
,或者它是否足够好成为首选解决方案?我不是编译器优化方面的专家,也不是面向数据设计方面的专家,因此,我自己无法回答这个问题。
谢谢!
编辑。 我已经根据@bolov 在评论中的建议更改了测试这两种方法的方式。
现在代码如下所示:
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <vector>
using namespace std::chrono;
template <class float_t, class int_t> struct Packed {
float_t sinvl;
int_t s, l;
Packed() = default;
Packed(float_t sinvl, int_t s, int_t l) : sinvl{sinvl}, s{s}, l{l} {}
void comp() { sinvl = float_t(l) / s; }
};
using my_float = float;
using my_int = int16_t;
int main(int argc, char *argv[]) {
uint32_t N{1000};
double t{0};
if (argc == 2)
N = std::stoul(argv[1]);
#ifndef _M_PACKED
std::vector<my_float> sinvl(N, 0.0);
std::vector<my_int> s(N, 3), l(N, 2);
// benchmark unpacked
auto tstart = high_resolution_clock::now();
std::transform(l.cbegin(), l.cend(), s.cbegin(), sinvl.begin(),
std::divides<my_float>{}); // 3 different memory accesses
auto tend = high_resolution_clock::now();
t += duration_cast<microseconds>(tend - tstart).count();
std::cout << "sinvl[0]: " << sinvl[0] << '\n';
std::cout << "N = " << N << ", unpacked: " << t << " us.\n";
#else
std::vector<Packed<my_float, my_int>> p1(N,
Packed<my_float, my_int>(0.0, 3, 2));
// benchmark packed
auto tstart = high_resolution_clock::now();
for (auto &elem : p1) // 1 memory access
elem.comp();
auto tend = high_resolution_clock::now();
t += duration_cast<microseconds>(tend - tstart).count();
std::cout << "p1[0].sinvl: " << p1[0].sinvl << '\n';
std::cout << "N = " << N << ", packed: " << t << " us.\n";
#endif
return 0;
}
用相应的shell(鱼)脚本
g++ -Wall -std=c++11 -O3 transform.cpp -o transform_unpacked.out
g++ -Wall -std=c++11 -O3 transform.cpp -o transform_packed.out -D_M_PACKED
for N in 1000 10000 100000
echo "Testing unpacked for N = $N"
./transform_unpacked.out $N
./transform_unpacked.out $N
./transform_unpacked.out $N
echo "Testing packed for N = $N"
./transform_packed.out $N
./transform_packed.out $N
./transform_packed.out $N
end
给出以下内容:
Testing unpacked for N = 1000
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
Testing packed for N = 1000
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
Testing unpacked for N = 10000
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
Testing packed for N = 10000
p1[0].sinvl: 0.666667
N = 10000, packed: 17 us.
p1[0].sinvl: 0.666667
N = 10000, packed: 13 us.
p1[0].sinvl: 0.666667
N = 10000, packed: 13 us.
Testing unpacked for N = 100000
sinvl[0]: 0.666667
N = 100000, unpacked: 64 us.
sinvl[0]: 0.666667
N = 100000, unpacked: 66 us.
sinvl[0]: 0.666667
N = 100000, unpacked: 66 us.
Testing packed for N = 100000
p1[0].sinvl: 0.666667
N = 100000, packed: 180 us.
p1[0].sinvl: 0.666667
N = 100000, packed: 198 us.
p1[0].sinvl: 0.666667
N = 100000, packed: 177 us.
希望我已经正确理解了正确的测试方法。尽管如此,还是相差2-3倍。
这是 std::transform
案例的编译循环:
400fd0: f3 41 0f 7e 04 47 movq xmm0,QWORD PTR [r15+rax*2]
400fd6: 66 0f 61 c0 punpcklwd xmm0,xmm0
400fda: 66 0f 72 e0 10 psrad xmm0,0x10
400fdf: 0f 5b c0 cvtdq2ps xmm0,xmm0
400fe2: f3 0f 7e 0c 43 movq xmm1,QWORD PTR [rbx+rax*2]
400fe7: 66 0f 61 c9 punpcklwd xmm1,xmm1
400feb: 66 0f 72 e1 10 psrad xmm1,0x10
400ff0: 0f 5b c9 cvtdq2ps xmm1,xmm1
400ff3: 0f 5e c1 divps xmm0,xmm1
400ff6: 41 0f 11 04 80 movups XMMWORD PTR [r8+rax*4],xmm0
400ffb: f3 41 0f 7e 44 47 08 movq xmm0,QWORD PTR [r15+rax*2+0x8]
401002: 66 0f 61 c0 punpcklwd xmm0,xmm0
401006: 66 0f 72 e0 10 psrad xmm0,0x10
40100b: 0f 5b c0 cvtdq2ps xmm0,xmm0
40100e: f3 0f 7e 4c 43 08 movq xmm1,QWORD PTR [rbx+rax*2+0x8]
401014: 66 0f 61 c9 punpcklwd xmm1,xmm1
401018: 66 0f 72 e1 10 psrad xmm1,0x10
40101d: 0f 5b c9 cvtdq2ps xmm1,xmm1
401020: 0f 5e c1 divps xmm0,xmm1
401023: 41 0f 11 44 80 10 movups XMMWORD PTR [r8+rax*4+0x10],xmm0
401029: 48 83 c0 08 add rax,0x8
40102d: 48 83 c1 02 add rcx,0x2
401031: 75 9d jne 400fd0 <main+0x570>
在每个循环周期中,它处理8个元素(有两条divps
指令,每条指令做4次除法)。
这是另一种情况:
401190: f3 0f 6f 42 04 movdqu xmm0,XMMWORD PTR [rdx+0x4]
401195: f3 0f 6f 4a 14 movdqu xmm1,XMMWORD PTR [rdx+0x14]
40119a: 66 0f 70 c9 e8 pshufd xmm1,xmm1,0xe8
40119f: 66 0f 70 c0 e8 pshufd xmm0,xmm0,0xe8
4011a4: f2 0f 70 d0 e8 pshuflw xmm2,xmm0,0xe8
4011a9: 66 0f 6c c1 punpcklqdq xmm0,xmm1
4011ad: 66 0f 72 e0 10 psrad xmm0,0x10
4011b2: 0f 5b c0 cvtdq2ps xmm0,xmm0
4011b5: f2 0f 70 c9 e8 pshuflw xmm1,xmm1,0xe8
4011ba: 66 0f 62 d1 punpckldq xmm2,xmm1
4011be: 66 0f 61 ca punpcklwd xmm1,xmm2
4011c2: 66 0f 72 e1 10 psrad xmm1,0x10
4011c7: 0f 5b c9 cvtdq2ps xmm1,xmm1
4011ca: 0f 5e c1 divps xmm0,xmm1
4011cd: f3 0f 11 02 movss DWORD PTR [rdx],xmm0
4011d1: 0f 28 c8 movaps xmm1,xmm0
4011d4: 0f c6 c9 e5 shufps xmm1,xmm1,0xe5
4011d8: f3 0f 11 4a 08 movss DWORD PTR [rdx+0x8],xmm1
4011dd: 0f 28 c8 movaps xmm1,xmm0
4011e0: 0f 12 c9 movhlps xmm1,xmm1
4011e3: f3 0f 11 4a 10 movss DWORD PTR [rdx+0x10],xmm1
4011e8: 0f c6 c0 e7 shufps xmm0,xmm0,0xe7
4011ec: f3 0f 11 42 18 movss DWORD PTR [rdx+0x18],xmm0
4011f1: 48 83 c2 20 add rdx,0x20
4011f5: 48 83 c1 fc add rcx,0xfffffffffffffffc
4011f9: 75 95 jne 401190 <main+0x730>
在每个循环周期中,它处理4个元素(有一个divps
指令)。
在第一种情况下,数据格式良好,SIMD 指令可以(几乎)在不移动任何数据的情况下对其进行操作,并且可以轻松写入结果(一条指令写入 4 个结果)。
然而,在第二种情况下,情况并非如此。编译器必须发出大量数据移动(混洗)操作,并且每个结果都用单独的指令编写。所以 input/output 不是 SIMD 友好格式。
我没有时间进一步分析这个问题,但如果你只是认为这两个片段具有相似的大小、相似的指令,但第一个处理的元素数量是第二个的两倍,你可以知道为什么第二个更慢。抱歉草率的解释。
[...] and collecting them inside a container should yield better
performance.
我认为对于顺序访问情况,您的直觉有点倒退。仅考虑对非平凡大小的输入的顺序循环,对于 顺序访问。
,SoA 几乎总是与 AoS 一样快或更快。
在许多情况下,SoA 实际上会比 AoS 净减少缓存未命中总数,因为它避免了结构填充(不面临交错非同类字段的对齐要求)并且您可以避免加载冷字段一个给定的循环(例如:物理模拟器可能能够避免加载粒子的 color
场,在应用物理时仅用于渲染)。您的案例不会从这些中受益,但请记住这一点。
AoS 倾向于 excel 的地方是 随机访问 。在那种情况下,如果您加载第 4096 个元素,您可能只需要一个带有 AoS 的缓存行来获取所有三个字段。如果您使用 SoA,则需要 3,并且它可能会为相邻元素加载大量数据(元素 4097、4098、4099 等的数据),其中 none 由于随机性而在驱逐之前被使用- 内存访问模式的访问性质。但是对于顺序访问,所有这些相邻数据通常会在驱逐之前与 SoA 代表一起使用。
因此,就缓存未命中率而言,如果您按顺序循环非平凡的输入大小,SoA 通常会平局或获胜。
但此外,在这种顺序访问模式中,所有相邻数据都将在逐出之前使用,当您考虑数据从缓存加载到 SIMD 寄存器时的动态时,SoA 提供同类数据。它可以以 float, float, float, float, ...
和 int16, int16, int16, int16, ...
的形式加载内存,而不是 float, int16, int16, float, int16, int16 ...
并且跨 SIMD 寄存器执行 vertical/symmetrical 操作。这种情况往往会为人类和编译器提供更多的优化机会,正如 Geza 指出的那样,这可能是您的特定情况受益最多的地方。
Finally, is there a way to beat std::transform in this example, or, is
it simply good enough to be a go-to solution?
我认为在满足相同要求的同时试图击败 std::transform
是一种错误的看待方式,但您可以通过稍微改变它们来获得一些性能提升。例如,代替 std::divides
,您可以提前准备数据以将其转换为乘法。在你的情况下,我最想寻找的是看看代码是否可以针对更快的 SoA ("unpacked") 代表并行执行。您可能能够在每个单独的线程中处理每个 SoA 容器的给定索引范围,仍然在每个线程中使用 std::transform
。
我正在尝试理解针对一个简单、具体问题的面向数据的设计。提前向面向数据的设计人员道歉,如果我做了一些非常愚蠢的事情,但我很难理解我的推理失败的原因和位置。
假设我有一个简单的操作,即,float_t result = int_t(lhs) / int_t(rhs)
。如果我将所有变量保存在它们相应的容器中, 例如 、std::vector<float_t>
和 std::vector<int_t>
,并且我使用 std::transform
,我会得到正确的结果.然后,对于 using float_t = float
和 using int_t = int16_t
的具体示例,我假设在 64 位架构上将这些变量打包到 struct
中,并将它们收集到容器中 应该 产生更好的性能。
我认为 struct
构成了一个 64 位对象,并且对 struct
的单个内存访问将为我提供我需要的所有变量。另一方面,当所有这些变量都收集在不同的容器中时,我将需要三种不同的内存访问来获取所需的信息。以下是我如何设置环境:
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <vector>
using namespace std::chrono;
template <class float_t, class int_t> struct Packed {
float_t sinvl;
int_t s, l;
Packed() = default;
Packed(float_t sinvl, int_t s, int_t l) : sinvl{sinvl}, s{s}, l{l} {}
void comp() { sinvl = float_t(l) / s; }
};
using my_float = float;
using my_int = int16_t;
int main(int argc, char *argv[]) {
constexpr uint32_t M{100};
for (auto N : {1000, 10000, 100000}) {
double t1{0}, t2{0};
for (uint32_t m = 0; m < M; m++) {
std::vector<my_float> sinvl(N, 0.0);
std::vector<my_int> s(N, 3), l(N, 2);
std::vector<Packed<my_float, my_int>> p1(
N, Packed<my_float, my_int>(0.0, 3, 2));
// benchmark unpacked
auto tstart = high_resolution_clock::now();
std::transform(l.cbegin(), l.cend(), s.cbegin(), sinvl.begin(),
std::divides<my_float>{}); // 3 different memory accesses
auto tend = high_resolution_clock::now();
t1 += duration_cast<microseconds>(tend - tstart).count();
if (m == M - 1)
std::cout << "sinvl[0]: " << sinvl[0] << '\n';
// benchmark packed
tstart = high_resolution_clock::now();
for (auto &elem : p1) // 1 memory access
elem.comp();
tend = high_resolution_clock::now();
t2 += duration_cast<microseconds>(tend - tstart).count();
if (m == M - 1)
std::cout << "p1[0].sinvl: " << p1[0].sinvl << '\n';
}
std::cout << "N = " << N << ", unpacked: " << (t1 / M) << " us.\n";
std::cout << "N = " << N << ", packed: " << (t2 / M) << " us.\n";
}
return 0;
}
g++ -O3
的编译代码在我的机器上产生,
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 1000, unpacked: 0 us.
N = 1000, packed: 1 us.
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 10000, unpacked: 5.06 us.
N = 10000, packed: 12.97 us.
sinvl[0]: 0.666667
p1[0].sinvl: 0.666667
N = 100000, unpacked: 52.31 us.
N = 100000, packed: 124.49 us.
基本上,std::transform
比 2.5x
压缩访问要快。如果您帮助我理解这种行为,我将不胜感激。结果是由于
- 我没有正确把握面向数据的设计原则,或者,
- 这个非常简单的示例的一些人工制品,例如内存位置彼此分配得非常接近,并且在某种程度上被编译器非常有效地优化?
最后,在这个例子中有没有办法击败 std::transform
,或者它是否足够好成为首选解决方案?我不是编译器优化方面的专家,也不是面向数据设计方面的专家,因此,我自己无法回答这个问题。
谢谢!
编辑。 我已经根据@bolov 在评论中的建议更改了测试这两种方法的方式。
现在代码如下所示:
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <vector>
using namespace std::chrono;
template <class float_t, class int_t> struct Packed {
float_t sinvl;
int_t s, l;
Packed() = default;
Packed(float_t sinvl, int_t s, int_t l) : sinvl{sinvl}, s{s}, l{l} {}
void comp() { sinvl = float_t(l) / s; }
};
using my_float = float;
using my_int = int16_t;
int main(int argc, char *argv[]) {
uint32_t N{1000};
double t{0};
if (argc == 2)
N = std::stoul(argv[1]);
#ifndef _M_PACKED
std::vector<my_float> sinvl(N, 0.0);
std::vector<my_int> s(N, 3), l(N, 2);
// benchmark unpacked
auto tstart = high_resolution_clock::now();
std::transform(l.cbegin(), l.cend(), s.cbegin(), sinvl.begin(),
std::divides<my_float>{}); // 3 different memory accesses
auto tend = high_resolution_clock::now();
t += duration_cast<microseconds>(tend - tstart).count();
std::cout << "sinvl[0]: " << sinvl[0] << '\n';
std::cout << "N = " << N << ", unpacked: " << t << " us.\n";
#else
std::vector<Packed<my_float, my_int>> p1(N,
Packed<my_float, my_int>(0.0, 3, 2));
// benchmark packed
auto tstart = high_resolution_clock::now();
for (auto &elem : p1) // 1 memory access
elem.comp();
auto tend = high_resolution_clock::now();
t += duration_cast<microseconds>(tend - tstart).count();
std::cout << "p1[0].sinvl: " << p1[0].sinvl << '\n';
std::cout << "N = " << N << ", packed: " << t << " us.\n";
#endif
return 0;
}
用相应的shell(鱼)脚本
g++ -Wall -std=c++11 -O3 transform.cpp -o transform_unpacked.out
g++ -Wall -std=c++11 -O3 transform.cpp -o transform_packed.out -D_M_PACKED
for N in 1000 10000 100000
echo "Testing unpacked for N = $N"
./transform_unpacked.out $N
./transform_unpacked.out $N
./transform_unpacked.out $N
echo "Testing packed for N = $N"
./transform_packed.out $N
./transform_packed.out $N
./transform_packed.out $N
end
给出以下内容:
Testing unpacked for N = 1000
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
sinvl[0]: 0.666667
N = 1000, unpacked: 0 us.
Testing packed for N = 1000
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
p1[0].sinvl: 0.666667
N = 1000, packed: 1 us.
Testing unpacked for N = 10000
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
sinvl[0]: 0.666667
N = 10000, unpacked: 5 us.
Testing packed for N = 10000
p1[0].sinvl: 0.666667
N = 10000, packed: 17 us.
p1[0].sinvl: 0.666667
N = 10000, packed: 13 us.
p1[0].sinvl: 0.666667
N = 10000, packed: 13 us.
Testing unpacked for N = 100000
sinvl[0]: 0.666667
N = 100000, unpacked: 64 us.
sinvl[0]: 0.666667
N = 100000, unpacked: 66 us.
sinvl[0]: 0.666667
N = 100000, unpacked: 66 us.
Testing packed for N = 100000
p1[0].sinvl: 0.666667
N = 100000, packed: 180 us.
p1[0].sinvl: 0.666667
N = 100000, packed: 198 us.
p1[0].sinvl: 0.666667
N = 100000, packed: 177 us.
希望我已经正确理解了正确的测试方法。尽管如此,还是相差2-3倍。
这是 std::transform
案例的编译循环:
400fd0: f3 41 0f 7e 04 47 movq xmm0,QWORD PTR [r15+rax*2]
400fd6: 66 0f 61 c0 punpcklwd xmm0,xmm0
400fda: 66 0f 72 e0 10 psrad xmm0,0x10
400fdf: 0f 5b c0 cvtdq2ps xmm0,xmm0
400fe2: f3 0f 7e 0c 43 movq xmm1,QWORD PTR [rbx+rax*2]
400fe7: 66 0f 61 c9 punpcklwd xmm1,xmm1
400feb: 66 0f 72 e1 10 psrad xmm1,0x10
400ff0: 0f 5b c9 cvtdq2ps xmm1,xmm1
400ff3: 0f 5e c1 divps xmm0,xmm1
400ff6: 41 0f 11 04 80 movups XMMWORD PTR [r8+rax*4],xmm0
400ffb: f3 41 0f 7e 44 47 08 movq xmm0,QWORD PTR [r15+rax*2+0x8]
401002: 66 0f 61 c0 punpcklwd xmm0,xmm0
401006: 66 0f 72 e0 10 psrad xmm0,0x10
40100b: 0f 5b c0 cvtdq2ps xmm0,xmm0
40100e: f3 0f 7e 4c 43 08 movq xmm1,QWORD PTR [rbx+rax*2+0x8]
401014: 66 0f 61 c9 punpcklwd xmm1,xmm1
401018: 66 0f 72 e1 10 psrad xmm1,0x10
40101d: 0f 5b c9 cvtdq2ps xmm1,xmm1
401020: 0f 5e c1 divps xmm0,xmm1
401023: 41 0f 11 44 80 10 movups XMMWORD PTR [r8+rax*4+0x10],xmm0
401029: 48 83 c0 08 add rax,0x8
40102d: 48 83 c1 02 add rcx,0x2
401031: 75 9d jne 400fd0 <main+0x570>
在每个循环周期中,它处理8个元素(有两条divps
指令,每条指令做4次除法)。
这是另一种情况:
401190: f3 0f 6f 42 04 movdqu xmm0,XMMWORD PTR [rdx+0x4]
401195: f3 0f 6f 4a 14 movdqu xmm1,XMMWORD PTR [rdx+0x14]
40119a: 66 0f 70 c9 e8 pshufd xmm1,xmm1,0xe8
40119f: 66 0f 70 c0 e8 pshufd xmm0,xmm0,0xe8
4011a4: f2 0f 70 d0 e8 pshuflw xmm2,xmm0,0xe8
4011a9: 66 0f 6c c1 punpcklqdq xmm0,xmm1
4011ad: 66 0f 72 e0 10 psrad xmm0,0x10
4011b2: 0f 5b c0 cvtdq2ps xmm0,xmm0
4011b5: f2 0f 70 c9 e8 pshuflw xmm1,xmm1,0xe8
4011ba: 66 0f 62 d1 punpckldq xmm2,xmm1
4011be: 66 0f 61 ca punpcklwd xmm1,xmm2
4011c2: 66 0f 72 e1 10 psrad xmm1,0x10
4011c7: 0f 5b c9 cvtdq2ps xmm1,xmm1
4011ca: 0f 5e c1 divps xmm0,xmm1
4011cd: f3 0f 11 02 movss DWORD PTR [rdx],xmm0
4011d1: 0f 28 c8 movaps xmm1,xmm0
4011d4: 0f c6 c9 e5 shufps xmm1,xmm1,0xe5
4011d8: f3 0f 11 4a 08 movss DWORD PTR [rdx+0x8],xmm1
4011dd: 0f 28 c8 movaps xmm1,xmm0
4011e0: 0f 12 c9 movhlps xmm1,xmm1
4011e3: f3 0f 11 4a 10 movss DWORD PTR [rdx+0x10],xmm1
4011e8: 0f c6 c0 e7 shufps xmm0,xmm0,0xe7
4011ec: f3 0f 11 42 18 movss DWORD PTR [rdx+0x18],xmm0
4011f1: 48 83 c2 20 add rdx,0x20
4011f5: 48 83 c1 fc add rcx,0xfffffffffffffffc
4011f9: 75 95 jne 401190 <main+0x730>
在每个循环周期中,它处理4个元素(有一个divps
指令)。
在第一种情况下,数据格式良好,SIMD 指令可以(几乎)在不移动任何数据的情况下对其进行操作,并且可以轻松写入结果(一条指令写入 4 个结果)。
然而,在第二种情况下,情况并非如此。编译器必须发出大量数据移动(混洗)操作,并且每个结果都用单独的指令编写。所以 input/output 不是 SIMD 友好格式。
我没有时间进一步分析这个问题,但如果你只是认为这两个片段具有相似的大小、相似的指令,但第一个处理的元素数量是第二个的两倍,你可以知道为什么第二个更慢。抱歉草率的解释。
[...] and collecting them inside a container should yield better performance.
我认为对于顺序访问情况,您的直觉有点倒退。仅考虑对非平凡大小的输入的顺序循环,对于 顺序访问。
,SoA 几乎总是与 AoS 一样快或更快。在许多情况下,SoA 实际上会比 AoS 净减少缓存未命中总数,因为它避免了结构填充(不面临交错非同类字段的对齐要求)并且您可以避免加载冷字段一个给定的循环(例如:物理模拟器可能能够避免加载粒子的 color
场,在应用物理时仅用于渲染)。您的案例不会从这些中受益,但请记住这一点。
AoS 倾向于 excel 的地方是 随机访问 。在那种情况下,如果您加载第 4096 个元素,您可能只需要一个带有 AoS 的缓存行来获取所有三个字段。如果您使用 SoA,则需要 3,并且它可能会为相邻元素加载大量数据(元素 4097、4098、4099 等的数据),其中 none 由于随机性而在驱逐之前被使用- 内存访问模式的访问性质。但是对于顺序访问,所有这些相邻数据通常会在驱逐之前与 SoA 代表一起使用。
因此,就缓存未命中率而言,如果您按顺序循环非平凡的输入大小,SoA 通常会平局或获胜。
但此外,在这种顺序访问模式中,所有相邻数据都将在逐出之前使用,当您考虑数据从缓存加载到 SIMD 寄存器时的动态时,SoA 提供同类数据。它可以以 float, float, float, float, ...
和 int16, int16, int16, int16, ...
的形式加载内存,而不是 float, int16, int16, float, int16, int16 ...
并且跨 SIMD 寄存器执行 vertical/symmetrical 操作。这种情况往往会为人类和编译器提供更多的优化机会,正如 Geza 指出的那样,这可能是您的特定情况受益最多的地方。
Finally, is there a way to beat std::transform in this example, or, is it simply good enough to be a go-to solution?
我认为在满足相同要求的同时试图击败 std::transform
是一种错误的看待方式,但您可以通过稍微改变它们来获得一些性能提升。例如,代替 std::divides
,您可以提前准备数据以将其转换为乘法。在你的情况下,我最想寻找的是看看代码是否可以针对更快的 SoA ("unpacked") 代表并行执行。您可能能够在每个单独的线程中处理每个 SoA 容器的给定索引范围,仍然在每个线程中使用 std::transform
。