微基准测试 C 代码和缓存效果

Microbenchmarking C code and cache effects

提问人:stefanobaghino 提问时间:11/3/2023 最后编辑:stefanobaghino 更新时间:11/10/2023 访问量:119

问:

我在 macOS Sonoma 上使用 M1 Pro,编译时使用 15 () 而没有任何(显式)优化。(编辑:使用 -O3,我可以观察到各个版本之间没有有意义的差异。不过,与同一版本的先前运行相比,最后一个版本似乎略快。clangclang-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_gettimeclock()

C 性能测试 微基准测试

评论

0赞 Simon Goater 11/3/2023
我经常使用热回路进行预热,然后计算 rdtsc 周期,因为这些周期的成本非常低。
1赞 Lundin 11/3/2023
“使用 -O3,我无法观察到各个版本之间有意义的差异。”通过走在汽车后面并手动推动它来测量您的汽车的速度是没有帮助的,这是肯定的。始终在启用优化的情况下进行基准测试。
0赞 Lundin 11/3/2023
无论如何,我认为这在很大程度上取决于如何实现,以及当编译器首先可能内联它,然后从那里进一步优化时会发生什么。没有其他副作用的普通循环使编译器可以更自由地执行各种技巧。shuffle
0赞 Lundin 11/3/2023
最后,纳秒级精度在某些 PC 上并不存在。如果你这么想,你可能是在自欺欺人。如果某个上下文切换决定在基准测试期间闲逛,那又如何呢?所有的时机都被抛出窗外。
0赞 Peter Cordes 11/3/2023
您肯定希望在定时区域有一个重复循环,以分摊定时开销。如果您认为重复循环展开可能并非微不足道,那么某些循环展开可以摊销重复循环成本,但是在每次调用受测代码之间花费时间戳是灾难性的。( 在现代 x86 上只需要大约 20 个时钟周期,但要耗尽 ROB(重新排序缓冲区),防止无序执行需要更多时间。将结果缩放到纳秒级时间戳也是如此。参见 绩效评估的惯用方式?rdtsclfence; rdtscrdtscprdtsc

答:

2赞 Marco Bonelli 11/3/2023 #1

第一个问题:是什么导致了这种巨大的差异?

很难说,因为在禁用优化的情况下进行基准测试几乎没有意义,因此您必须查看生成的机器代码才能更好地理解。

执行系统调用并在每次迭代中断循环两次意味着执行大量您并不真正想要计时的无用指令,这很容易破坏处理器管道,从而大大降低性能。编译器可能能够也可能无法以优化的方式对指令进行排序以避免这种情况。在你的情况下,它可能没有(这是有道理的,因为你告诉它你不关心优化)。

它还可能导致操作系统进行更多的重新调度,因为系统调用进入/退出通常是常见的调度程序入口点,可以将任务设置为其他人运行,而无需发生内核抢占(例如,在没有系统调用的情况下执行紧密的用户空间循环)。

我很想知道为什么似乎不会引起与 相同的惩罚。clock_gettimeclock()

请参阅此处的答案:macOS 上的 gettimeofday() 是否使用系统调用?

clock_gettime(CLOCK_MONOTONIC)可能只是一个包装器,它通过用户空间采用快速路径,并且不需要上下文切换(大多数时候)。这是针对简单且常用的系统调用的常见优化。gettimeofday

我不太了解 macOS 如何实现它的“commpage”,用于在没有上下文切换的情况下执行一些常见的系统调用,但我想它与 Linux 实现“vDSO”的方式非常相似。

评论

1赞 Peter Cordes 11/3/2023
我认为你把它倒过来了:已经过时了(根据 POSIX),通常只是一个将纳秒除以 1000 得到微秒的包装器。gettimeofdayclock_gettime
0赞 Marco Bonelli 11/3/2023
@PeterCordes我不确定,这里的代码似乎暗示了相反的情况:-> -> gettimeofday() -> OR syscall。clock_gettime(CLOCK_MONOTONIC)_mach_boottime_usec()__commpage_gettimeofday()
0赞 Peter Cordes 11/3/2023
哦,奇怪。Linux 则以另一种方式执行此操作,因为它是更通用的功能,并且使用 Linux 内核内部使用的纳秒精度。你的回答在某种程度上暗示了 Linux 也是“倒退”的,我很确定事实并非如此。clock_gettimestruct timespec
0赞 Marco Bonelli 11/3/2023
@PeterCordes是的,这很奇怪。我也很确定 Linux 会做相反的事情,在这里(在 Linux 上也是一个实际的系统调用)。将更新答案以使其更清晰。clock_gettime
1赞 Marco Bonelli 11/4/2023
@PeterCordes哦找到了它,这里是 vDSO 中执行 for 的后备,以防它未能通过快速路径。所以你是对的,即使在内核代码中也有回退到 .gettimeofdaysyscallclock_gettimeclock_gettime
1赞 chux - Reinstate Monica 11/4/2023 #2

可能性:时钟测量截断和/或溢出。int

int start = clock();如果宽度大于 ,则可能会截断测量值。由于第 2 个代码比第 1 个代码进行更多的测量,因此一对读数(一个被截断,另一个不截断)的几率增加了在进行差值时出现计算错误的可能性。clock_tint

此外,差值和总和可能会溢出。使用宽类型。

用:

long long ticks = 0;
...
  clock_t start = clock();
  ...
  clock_t end = clock();
  ticks += (long long) end - start;
...

评论

1赞 stefanobaghino 11/4/2023
事实证明事实并非如此,但我还是学到了一些新东西,谢谢!