提问人:stefanobaghino 提问时间:11/3/2023 最后编辑:stefanobaghino 更新时间:11/10/2023 访问量:119
微基准测试 C 代码和缓存效果
Microbenchmarking C code and cache effects
问:
我在 macOS Sonoma 上使用 M1 Pro,编译时使用 15 () 而没有任何(显式)优化。(编辑:使用 -O3,我可以观察到各个版本之间没有有意义的差异。不过,与同一版本的先前运行相比,最后一个版本似乎略快。clang
clang-1500.0.40.1
我写了一个小函数来洗牌数组,我想测试它的性能。为此,我编写了以下函数:
void test_performance() {
// initialize only once to measure performance
int ns[] = {1, 2, 3, 4, 5};
int ops = 1E6;
int start = clock();
for (int i = 0; i < ops; i++) {
shuffle(ns, 5);
}
int end = clock();
double s_total = ((double) (end - start)) / CLOCKS_PER_SEC;
double ns_op = s_total / ops * 1E9;
printf("test_performance: %fns/op (%fs total) \n", ns_op, s_total);
}
在我的机器上,这会产生 ~30ns/op。
然后我意识到循环操作包含在测量中。虽然它可能几乎可以忽略不计,但我本可以消除这个小噪音,所以我将其更改为在循环中调用,并将实际花费的时间添加到累加器中,从而产生以下代码。clock()
shuffle
void test_performance() {
// initialize only once to measure performance
int ns[] = {1, 2, 3, 4, 5};
int ops = 1E6;
int start, end, ticks = 0;
for (int i = 0; i < ops; i++) {
start = clock();
shuffle(ns, 5);
end = clock();
ticks += (end - start);
}
double s_total = ((double) ticks) / CLOCKS_PER_SEC;
double ns_op = s_total / ops * 1E9;
printf("test_performance: %fns/op (%fs total) \n", ns_op, s_total);
}
程序总体运行速度明显变慢,这并不奇怪,因为现在我有几百万个调用。然而,我确实发现令人惊讶的是,最后报告的每个操作的时间增加了一个数量级,约为 ~330ns/op。clock()
第一个问题:是什么导致了这种巨大的差异?
我最好的猜测是,他与现代硬件、缓存以及可能涉及系统调用的事实有关,在一种情况下始终在 CPU 缓存中,在另一种情况下不断被驱逐的代码,但我不太确定是否是这样,或者我是否缺少更明显的东西。clock()
shuffle
第二个问题:如果是这样的话,在使用 C 进行微基准测试并评估缓存的影响时,有哪些好的做法?我主要感兴趣的是制作相对健壮的基准测试,而不需要专门的工具,但我也很高兴听到 C 社区使用的微基准测试工具。
编辑
有趣的是,我尝试使用 ,这也允许明确要求单调增加的时间,最终得到以下内容:clock_gettime
void test_performance() {
struct timespec start_time, end_time;
// initialize only once to measure performance
int ns[] = {1, 2, 3, 4, 5};
int ops = 1E6;
double s_total = 0;
for (int i = 0; i < ops; i++) {
if (clock_gettime(CLOCK_MONOTONIC, &start_time) != 0) {
perror("clock_gettime");
return;
}
shuffle(ns, 5);
if (clock_gettime(CLOCK_MONOTONIC, &end_time) != 0) {
perror("clock_gettime");
return;
}
s_total += (end_time.tv_sec - start_time.tv_sec) + (end_time.tv_nsec - start_time.tv_nsec) / 1.0e9;
}
double ns_op = s_total / ops * 1E9;
printf("test_performance: %fns/op (%fs total) \n", ns_op, s_total);
}
有趣的是,这段代码报告的时间约为 ~25ns。当我尝试将时间测量值放在整个循环中而不是测量单个呼叫时,报告的时间又回到了 ~30ns,就像我使用 .clock()
我很想知道为什么似乎不会引起与 相同的惩罚。clock_gettime
clock()
答:
第一个问题:是什么导致了这种巨大的差异?
很难说,因为在禁用优化的情况下进行基准测试几乎没有意义,因此您必须查看生成的机器代码才能更好地理解。
执行系统调用并在每次迭代中断循环两次意味着执行大量您并不真正想要计时的无用指令,这很容易破坏处理器管道,从而大大降低性能。编译器可能能够也可能无法以优化的方式对指令进行排序以避免这种情况。在你的情况下,它可能没有(这是有道理的,因为你告诉它你不关心优化)。
它还可能导致操作系统进行更多的重新调度,因为系统调用进入/退出通常是常见的调度程序入口点,可以将任务设置为其他人运行,而无需发生内核抢占(例如,在没有系统调用的情况下执行紧密的用户空间循环)。
我很想知道为什么似乎不会引起与 相同的惩罚。
clock_gettime
clock()
请参阅此处的答案:macOS 上的 gettimeofday() 是否使用系统调用?
clock_gettime(CLOCK_MONOTONIC)
可能只是一个包装器,它通过用户空间采用快速路径,并且不需要上下文切换(大多数时候)。这是针对简单且常用的系统调用的常见优化。gettimeofday
我不太了解 macOS 如何实现它的“commpage”,用于在没有上下文切换的情况下执行一些常见的系统调用,但我想它与 Linux 实现“vDSO”的方式非常相似。
评论
gettimeofday
clock_gettime
gettimeofday()
-> OR syscall。clock_gettime(CLOCK_MONOTONIC)
_mach_boottime_usec()
__commpage_gettimeofday()
clock_gettime
struct timespec
clock_gettime
gettimeofday
syscall
clock_gettime
clock_gettime
可能性:时钟测量截断和/或溢出。int
int start = clock();
如果宽度大于 ,则可能会截断测量值。由于第 2 个代码比第 1 个代码进行更多的测量,因此一对读数(一个被截断,另一个不截断)的几率增加了在进行差值时出现计算错误的可能性。clock_t
int
此外,差值和总和可能会溢出。使用宽类型。
用:
long long ticks = 0;
...
clock_t start = clock();
...
clock_t end = clock();
ticks += (long long) end - start;
...
评论
shuffle
rdtsc
lfence; rdtsc
rdtscp
rdtsc