我一直在使用https://github.com/google/benchmark和g++ 9.4.0来检查不同场景下的数据访问性能(使用“-O3
“编译),结果令我感到惊讶。
我的基线是访问std::array
中的long(“reduced data”)。我想添加一个额外的字节数据。一次我创建了一个额外的容器(“split data”),一次我在数组中存储了一个struct(“combined data”)。
这是密码:
#include <benchmark/benchmark.h>
#include <array>
#include <random>
constexpr int width = 640;
constexpr int height = 480;
std::array<std::uint64_t, width * height> containerWithReducedData;
std::array<std::uint64_t, width * height> container1WithSplitData;
std::array<std::uint8_t, width * height> container2WithSplitData;
struct CombinedData
{
std::uint64_t first;
std::uint8_t second;
};
std::array<CombinedData, width * height> containerWithCombinedData;
void fillReducedData(const benchmark::State& state)
{
// Variable is intentionally unused
static_cast<void>(state);
// Generate pseudo-random numbers (no seed, therefore always the same numbers)
// NOLINTNEXTLINE
auto engine = std::mt19937{};
auto longsDistribution = std::uniform_int_distribution<std::uint64_t>{};
for (int row = 0; row < height; ++row)
{
for (int column = 0; column < width; ++column)
{
const std::uint64_t number = longsDistribution(engine);
containerWithReducedData.at(static_cast<unsigned int>(row * width + column)) = number;
}
}
}
std::uint64_t accessReducedData()
{
std::uint64_t value = 0;
for (int row = 0; row < height; ++row)
{
for (int column = 0; column < width; ++column)
{
value += containerWithReducedData.at(static_cast<unsigned int>(row * width + column));
}
}
return value;
}
static void BM_AccessReducedData(benchmark::State& state)
{
// Perform setup here
for (auto _ : state)
{
// Variable is intentionally unused
static_cast<void>(_);
// This code gets timed
benchmark::DoNotOptimize(accessReducedData());
}
}
BENCHMARK(BM_AccessReducedData)->Setup(fillReducedData);
void fillSplitData(const benchmark::State& state)
{
// Variable is intentionally unused
static_cast<void>(state);
// Generate pseudo-random numbers (no seed, therefore always the same numbers)
// NOLINTNEXTLINE
auto engine = std::mt19937{};
auto longsDistribution = std::uniform_int_distribution<std::uint64_t>{};
auto bytesDistribution = std::uniform_int_distribution<std::uint8_t>{};
for (int row = 0; row < height; ++row)
{
for (int column = 0; column < width; ++column)
{
const std::uint64_t number = longsDistribution(engine);
container1WithSplitData.at(static_cast<unsigned int>(row * width + column)) = number;
const std::uint8_t additionalNumber = bytesDistribution(engine);
container2WithSplitData.at(static_cast<unsigned int>(row * width + column)) = additionalNumber;
}
}
}
std::uint64_t accessSplitData()
{
std::uint64_t value = 0;
for (int row = 0; row < height; ++row)
{
for (int column = 0; column < width; ++column)
{
value += container1WithSplitData.at(static_cast<unsigned int>(row * width + column));
value += container2WithSplitData.at(static_cast<unsigned int>(row * width + column));
}
}
return value;
}
static void BM_AccessSplitData(benchmark::State& state)
{
// Perform setup here
for (auto _ : state)
{
// Variable is intentionally unused
static_cast<void>(_);
// This code gets timed
benchmark::DoNotOptimize(accessSplitData());
}
}
BENCHMARK(BM_AccessSplitData)->Setup(fillSplitData);
void fillCombinedData(const benchmark::State& state)
{
// Variable is intentionally unused
static_cast<void>(state);
// Generate pseudo-random numbers (no seed, therefore always the same numbers)
// NOLINTNEXTLINE
auto engine = std::mt19937{};
auto longsDistribution = std::uniform_int_distribution<std::uint64_t>{};
auto bytesDistribution = std::uniform_int_distribution<std::uint8_t>{};
for (int row = 0; row < height; ++row)
{
for (int column = 0; column < width; ++column)
{
const std::uint64_t number = longsDistribution(engine);
containerWithCombinedData.at(static_cast<unsigned int>(row * width + column)).first = number;
const std::uint8_t additionalNumber = bytesDistribution(engine);
containerWithCombinedData.at(static_cast<unsigned int>(row * width + column)).second = additionalNumber;
}
}
}
std::uint64_t accessCombinedData()
{
std::uint64_t value = 0;
for (int row = 0; row < height; ++row)
{
for (int column = 0; column < width; ++column)
{
value += containerWithCombinedData.at(static_cast<unsigned int>(row * width + column)).first;
value += containerWithCombinedData.at(static_cast<unsigned int>(row * width + column)).second;
}
}
return value;
}
static void BM_AccessCombinedData(benchmark::State& state)
{
// Perform setup here
for (auto _ : state)
{
// Variable is intentionally unused
static_cast<void>(_);
// This code gets timed
benchmark::DoNotOptimize(accessCombinedData());
}
}
BENCHMARK(BM_AccessCombinedData)->Setup(fillCombinedData);
Live demo
这就是结果
Run on (12 X 4104.01 MHz CPU s)
CPU Caches:
L1 Data 32 KiB (x6)
L1 Instruction 32 KiB (x6)
L2 Unified 256 KiB (x6)
L3 Unified 12288 KiB (x1)
Load Average: 0.33, 1.82, 1.06
----------------------------------------------------------------
Benchmark Time CPU Iterations
----------------------------------------------------------------
BM_AccessReducedData 55133 ns 55133 ns 12309
BM_AccessSplitData 64089 ns 64089 ns 10439
BM_AccessCombinedData 170470 ns 170470 ns 3827
我对BM_AccessCombinedData
的长运行时间并不感到惊讶。添加字节需要额外的工作(与“减少的数据”相比)。我的解释是,添加的字节不再适合该高速缓存行,这使得访问成本高得多。(可能还有其他影响吗?)
但是为什么访问不同的容器(“分割数据”)会这么快呢?数据位于内存中的不同位置,并且交替访问。这不是应该更慢吗?但是它几乎比访问合并数据快三倍!这难道不令人惊讶吗?
1条答案
按热度按时间jqjz2hbq1#
前言:此答案 * 仅 * 针对您在基准测试链接中提供的示例/场景编写:对不同大小的整数的交错集合与非交错集合的求和归约。求和是一种无序操作。你可以访问集合的元素,并以任何顺序将它们添加到累加结果中。无论你是“合并”(通过结构体)还是“拆分”(通过单独的数组),累加的顺序都无关紧要。
注:如果您提供一些关于优化技术的已知信息以及处理器/内存的通常能力,将会有所帮助。您的评论表明您了解缓存,但我不知道您还了解什么,或者您对缓存的确切了解是什么。
术语
“合并”与“拆分”的选择还有其他众所周知的名称:
这个问题符合人们在谈论data-oriented design时所关心的领域。
在本答案的其余部分,我将与您的术语保持一致。
对齐、填充和结构
引用CppReference,
C++ 有以下要求:
每个完整的对象类型都有一个称为对齐要求的属性,它是size_t类型的整数值,表示可以分配此类型对象的连续地址之间的字节数。有效的对齐值是2的非负整数幂。
“每个完整对象”都包含内存中的结构体示例。
为了满足结构的所有成员的对齐要求,可以在其某些成员之后插入填充。
其中一个例子表明:
这就是Peter Cordes在评论中提到的,因为 C++ 的这个要求/属性/特性,所以为您的“组合”集合插入了填充。
我不确定这里的填充是否会对缓存性能造成很大的损害,因为和只访问数组的每个元素一次,在元素经常被访问的场景中,这可能更重要:当与分离表示相比时,组合表示的填充导致该高速缓存的“浪费”字节,并且该浪费更可能对高速缓存性能具有显著影响,但是其重要程度取决于重新访问数据的模式。
单指令多数据集
wikipedia article
SIMD指令是专门的CPU机器指令,用于对内存中的多段数据执行操作,例如对内存中一个接一个排列的一组大小相同的整数求和(这正是您的场景的“拆分”表示版本中可以完成的操作)。
与不使用SIMD的机器码相比,使用SIMD可以提供常数因子的改进(常数因子的值基于SIMD指令)。例如,将8个字节加在一起的SIMD指令应该比做同样事情的循环或做同样事情的展开循环快8倍。
其他关键词:矢量化、并行化代码。
Peter Cordes提到了相关的例子(psadbw,paddq),这里有一个英特尔SSE算术指令列表。
正如Peter所提到的,在“组合”表示中,SIMD的使用程度仍然是可能的,但不如“分割”表示那样多。这取决于目标机器架构的指令集提供了什么。我不认为您的示例的“组合”表示有专用的SIMD指令。
密码
对于“拆分”表示,我将执行如下操作:
这是一种更直接的方式来(向代码的读者和编译器)传递整数集合的无序和。
std::reduce
std::execution::<...>
std::plus
但是不同的立场又如何呢?
在那里,数据位于内存中的不同位置,并且可以交替访问,这不是应该更慢吗?
正如我在上面的代码中所展示的,对于您的特定场景,不需要交替访问,但是如果将特定场景更改为需要交替访问,一般来说,我认为不会对缓存产生太大影响。
如果拆分数组的对应条目Map到相同的缓存集,则可能存在conflict misses问题。我不知道这种情况发生的可能性有多大,或者C++中是否有技术可以防止这种情况。如果有人知道,请编辑此答案。如果缓存具有
N
路集关联性,而对“split”表示数据的访问模式只访问热循环中的N
或更少的数组(即不访问任何其他内存),我相信应该不可能遇到这种情况。其他注解
我建议您保持问题中的基准链接不变,如果您想更新它,请添加一个新链接,以便查看讨论的人能够看到引用的旧版本。
出于好奇,你为什么不使用像gcc 11这样的新版本编译器来进行基准测试呢?
我强烈推荐我展示的
std::reduce
的用法。广泛推荐的做法是使用专用的C标准算法,而不是适合任务的原始循环。请参阅CppCoreGuidlines链接中引用的原因。代码可能很长(从这个意义上说,很难看),但它清楚地传达了在归约运算符(plus)未排序的情况下执行求和的意图。您的问题是关于 * 速度 * 的,但值得注意的是,在C中,在 * 空间 * 成本很重要的地方,选择数组结构还是数组结构可能很重要,这正是因为对齐和填充。
在选择数组的结构体和结构体的数组时,还有更多我没有列出的注意事项:存储器访问模式是性能的主要考虑因素,可读性和简单性也是重要的考虑因素;您可以通过构建良好的抽象来缓解问题,但这仍然是有限制的,而且构建抽象本身的维护、可读性和简单性成本也是有限的。
您可能还会发现this video by Matt Godbolt on "Memory and Caches"非常有用。