为什么第二次迭代大量字节的速度要慢得多?以及如何解决它

Why the second time iterating on large number of bytes is significantly slower? And how to fix it?

本文关键字:何解决 解决 迭代 第二次 字节 速度 为什么      更新时间:2023-10-16

此代码:

#include <memory>
#include <time.h>
#include <chrono>
#include <thread>
#include <stdio.h>
#include <stdlib.h>
void Test( ) {
#define current_milliseconds std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::system_clock::now( ).time_since_epoch( ) ).count( )
    int *c = ( int* )malloc( 1024 * 1024 * 1024 );
    int result = 0;
    auto millis = -current_milliseconds;
    //clock_t timer = -clock( );
    for ( int i = 0 ; i < 1024 * 1024 * 256 /* 1024 / 4 */; ++i )
        result += c[ i ];
    millis += current_milliseconds;
    printf( "Took: %ldms (JUST PASSING BY: %d)n", millis, result );
    free( c );
#undef current_milliseconds
}
int main( ) {
    std::this_thread::sleep_for( std::chrono::milliseconds( 1 ) );
    Test( );
    std::this_thread::sleep_for( std::chrono::milliseconds( 1 ) );
    Test( );
    return -1;
}

我运行了 7 个测试并给出了最后 6 个输出:

Took: 502ms (JUST PASSING BY: 0)
Took: 607ms (JUST PASSING BY: 0)
Took: 480ms (JUST PASSING BY: 0)
Took: 588ms (JUST PASSING BY: 0)
Took: 492ms (JUST PASSING BY: 0)
Took: 562ms (JUST PASSING BY: 0)
Took: 506ms (JUST PASSING BY: 0)
Took: 558ms (JUST PASSING BY: 0)
Took: 470ms (JUST PASSING BY: 0)
Took: 555ms (JUST PASSING BY: 0)
Took: 510ms (JUST PASSING BY: 0)
Took: 562ms (JUST PASSING BY: 0)

如果您的输出不同,请尝试再次运行可执行文件(硬盘缓存未命中)或尝试扩大迭代次数和分配的字节数(有感觉)。

请注意,计时器的代码范围只在循环中,而不是在分配上;然后又来了一个问题:为什么第二次迭代更慢?有没有办法解决它?

附加信息:

  1. 该PC具有奔腾2.8GHZ @ 2核(Intel E6300)处理器,4GB RAM(执行测试前有2.2GB可用RAM)和企业级Intel SSD。
  2. 似乎在执行测试时,它写了几个 100MB 的。当它有足够的可用 RAM 时,为什么会这样做?(我解除了 1GB,然后又分配了 1GB,它不应该传递预交换文件)

我的机器上的输出:

拍摄时间:371毫秒(只是路过:0)
获取:318毫秒(刚刚经过:0)

对于我希望大多数程序员在尝试您的程序时看到的内容来说,这更典型一些。 您可以进行一个小的更改以获得截然不同的结果:

int *c = (int*)malloc(1024 * 1024 * 1024);
memset(c, 0, 1024 * 1024 * 1024);          // <== added
// etc..

在我的机器上生产:

占用:104毫秒(只是路过:0)
占用:102毫秒(只是路过:0)

胖 x3 加速,只需初始化内存内容。 希望它在您的机器上重现,它应该。 你需要得出的第一个结论是,你一直在对与代码成本完全不同的东西进行基准测试。 现代机器上非常典型的基准危险。

这是按需分页虚拟内存操作系统的行为。 比如Windows,Linux或OSX。 您的 malloc() 调用实际上从未分配任何内存,它只是保留了地址空间。 只是处理器的数字,每 4096 字节的内存有一个。 直到以后实际解决内存时,您才支付使用内存的费用。 当需求分页功能发挥作用时。

这发生在您的result += c[ i ];声明中。 此时,踏板必须与金属相接,操作系统被迫实际使内存可用。 每 4096 字节,程序就会生成一个页面错误。 操作系统单步执行并将 4096 字节的 RAM 映射到虚拟内存页。 您的程序生成 1GB/4096 = 262,144 个页面错误。 您可以得出结论,您的操作系统大约需要 400ms/262144 ~= 1.5 微秒来处理页面错误。 我的速度大约是它的两倍。

请注意 memset() 调用如何隐藏该成本,它在开始计时代码执行之前生成了所有这些页面错误。 从而真正衡量代码的成本,并避免不可避免的初始化开销。

首次运行需要多长时间将取决于操作系统使 RAM 可用的速度。 实际测量值可能因一次尝试而异,这取决于有多少其他进程映射了 RAM 页。 如果操作系统需要先查找空间并取消映射页面,将其内容保留在分页文件中,则可能需要相当长的时间。

第二次运行需要多长时间将取决于操作系统回收RAM页面的速度,如果没有足够的RAM页面可用来映射另一个千兆字节。 我的问题不大,我有 8 GB 的 RAM,现在只使用 5.6 个。 它们需要零初始化,这是典型操作系统上的低优先级职责。

所以,这里的基本结论:

  • 您正在测量初始化成本,它完全不能代表程序在继续使用内存时将如何受到影响。 您的基准测试按原样不会告诉您任何事情。
  • 您观察到的区别是操作系统实现细节,它与您的程序无关。

您是在第一圈第一次访问页面,而且比第二圈更快,因此您的操作系统在新分配的千兆字节上执行的任何内存管理第一次都更便宜。

如果您的操作系统将新分配的

存储映射到充满零的页面,并在访问而不是写入时将新引用的内存映射到自己的页面,并且它没有将千兆字节的分配映射到大的 2M 页面,那么第二圈通过您的千兆字节时,您将几乎每 4K 丢失一次 TLB。 那可以得到...不便宜。

所以我认为无论您的操作系统为映射新引用的页面所做的任何事情都可能比完整的 TLB 未命中更快。 例如,如果 PTE 是可缓存的,并且每次操作系统分配新的底层表时,它都会清除它,从而允许您的 TLB 未命中从最近在片上初始化的预启动缓存行重新加载,到第二圈时,您的串行未命中早已刷新条目,CPU 必须在这么长时间内获取它们, 长总线(在此关注级别上,最好将其视为网络连接)。

我不知道 x86 性能计数器或工具来检查它们,但如果您可以使用体面的工具,那么通过查看正确的计数器增量来检查理论应该很容易。

您的时间测量值看起来不一致,因为时间差异太大。看起来 CPU 可能在您运行基准测试时切换频率。

尝试在运行基准测试时禁用 CPU 频率缩放。在Windows中,您可以通过在控制面板的电源管理中设置性能配置文件来执行此操作,该配置文件将CPU频率锁定在其最高水平。


让我们来检验一下这个假设。

以下是我在 Linux 上使用默认powersave调控器的基准测试结果,它们在高时序方差方面

与您的相似:
[max@localhost:~/src/test] $ cpupower frequency-info
analyzing CPU 0:
  driver: intel_pstate
  CPUs which run at the same hardware frequency: 0
  CPUs which need to have their frequency coordinated by software: 0
  maximum transition latency: 0.97 ms.
  hardware limits: 1.20 GHz - 5.70 GHz
  available cpufreq governors: performance, powersave
  current policy: frequency should be within 1.20 GHz and 5.70 GHz.
                  The governor "powersave" may decide which speed to use
                  within this range.
  current CPU frequency is 1.26 GHz.
  boost state support:
    Supported: yes
    Active: yes
    25500 MHz max turbo 4 active cores
    25500 MHz max turbo 3 active cores
    25500 MHz max turbo 2 active cores
    25500 MHz max turbo 1 active cores
[max@localhost:~/src/test] $ for ((i=0; i<10; ++i)); do ./test; echo; done
Took: 698ms (JUST PASSING BY: 0)
Took: 598ms (JUST PASSING BY: 0)
Took: 541ms (JUST PASSING BY: 0)
Took: 570ms (JUST PASSING BY: 0)
Took: 660ms (JUST PASSING BY: 0)
Took: 656ms (JUST PASSING BY: 0)
Took: 673ms (JUST PASSING BY: 0)
Took: 616ms (JUST PASSING BY: 0)
Took: 637ms (JUST PASSING BY: 0)
Took: 650ms (JUST PASSING BY: 0)
Took: 690ms (JUST PASSING BY: 0)
Took: 667ms (JUST PASSING BY: 0)
Took: 671ms (JUST PASSING BY: 0)
Took: 603ms (JUST PASSING BY: 0)
Took: 537ms (JUST PASSING BY: 0)
Took: 544ms (JUST PASSING BY: 0)
Took: 535ms (JUST PASSING BY: 0)
Took: 629ms (JUST PASSING BY: 0)
Took: 660ms (JUST PASSING BY: 0)
Took: 656ms (JUST PASSING BY: 0)

以下是performance州长的结果,请注意这次时间的差异有多小:

[max@localhost:~/src/test] $ sudo cpupower frequency-set --related --governor performance
Setting cpu: 0
Setting cpu: 1
Setting cpu: 2
Setting cpu: 3
Setting cpu: 4
Setting cpu: 5
Setting cpu: 6
Setting cpu: 7
[max@localhost:~/src/test] $ cpupower frequency-info
analyzing CPU 0:
  driver: intel_pstate
  CPUs which run at the same hardware frequency: 0
  CPUs which need to have their frequency coordinated by software: 0
  maximum transition latency: 0.97 ms.
  hardware limits: 1.20 GHz - 5.70 GHz
  available cpufreq governors: performance, powersave
  current policy: frequency should be within 1.20 GHz and 5.70 GHz.
                  The governor "performance" may decide which speed to use
                  within this range.
  current CPU frequency is 4.34 GHz.
  boost state support:
    Supported: yes
    Active: yes
    25500 MHz max turbo 4 active cores
    25500 MHz max turbo 3 active cores
    25500 MHz max turbo 2 active cores
    25500 MHz max turbo 1 active cores
[max@localhost:~/src/test] $ for ((i=0; i<10; ++i)); do ./test; echo; done
Took: 539ms (JUST PASSING BY: 0)
Took: 548ms (JUST PASSING BY: 0)
Took: 543ms (JUST PASSING BY: 0)
Took: 547ms (JUST PASSING BY: 0)
Took: 542ms (JUST PASSING BY: 0)
Took: 543ms (JUST PASSING BY: 0)
Took: 548ms (JUST PASSING BY: 0)
Took: 539ms (JUST PASSING BY: 0)
Took: 538ms (JUST PASSING BY: 0)
Took: 536ms (JUST PASSING BY: 0)
Took: 536ms (JUST PASSING BY: 0)
Took: 536ms (JUST PASSING BY: 0)
Took: 546ms (JUST PASSING BY: 0)
Took: 547ms (JUST PASSING BY: 0)
Took: 559ms (JUST PASSING BY: 0)
Took: 534ms (JUST PASSING BY: 0)
Took: 537ms (JUST PASSING BY: 0)
Took: 540ms (JUST PASSING BY: 0)
Took: 538ms (JUST PASSING BY: 0)
Took: 534ms (JUST PASSING BY: 0)

它表明执行两个循环需要相同的时间,两个循环都不是更快的。

(注释后重写)

第二次迭代明显较慢,因为系统上的负载不同。第一次测试在正常情况下运行,第二次运行是在第一次运行刚刚完成时。
当第二个测试开始时,系统上的其他程序在争夺资源后仍在恢复/交换/日志记录。如果要比较两个测试,请在启动第二个测试之前给系统一些时间以恢复正常状态。
一个简单的方法是在第一个 Test() 之后添加几秒钟的睡眠时间。

此解决方案已在评论中讨论过。LyingOnTheSky证实,增加3秒的睡眠可以解开这个谜团:第一次和第二次测试的执行时间完全相同(421ms)。

Took: 682ms (JUST PASSING BY: 0)
Took: 666ms (JUST PASSING BY: 0)
Took: 686ms (JUST PASSING BY: 0)
Took: 680ms (JUST PASSING BY: 0)
Took: 674ms (JUST PASSING BY: 0)
Took: 675ms (JUST PASSING BY: 0)
Took: 694ms (JUST PASSING BY: 0)
Took: 684ms (JUST PASSING BY: 0)

在我的工作站中,第二次测试需要相同的时间甚至更少。如果您知道问题的内存布局,则始终可以使用内部内置来预取内存。

 __builtin_prefetch

https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html

正如其他人已经解释的那样,基本上您正在经历一种偏见(初始化成本 + 操作系统 + 您无法控制的其他硬件相关操作)。

所有结果都取决于工作站(CPU + RAM + OS)。例如,我的结果总是接近第一次和第二次运行(有和没有优化都经过测试,见下文)。

只是好奇。不要只调用 Test() 2 次,而是尝试在代码中调用它 10 或 15。您应该开始看到更接近的结果。

最后一件事,我不知道您是否正在尝试评估代码的性能,查看时间执行。如果是,请考虑使用更好的工具,例如 valgrindgprof .使用 valgrind,您可以使用 callgrind 来估计执行的指令数和硬件性能(可能是模拟缓存缺失分支预测硬件预取器等的传递选项):

 valgrind --tool=callgrind --cache-sim=yes --branch-sim=yes YOUR_PROGRAM

2014年底在Apple MB Pro上执行:

Took: 914ms (JUST PASSING BY: 0)
Took: 911ms (JUST PASSING BY: 0)
Took: 917ms (JUST PASSING BY: 0)
Took: 913ms (JUST PASSING BY: 0)
Took: 910ms (JUST PASSING BY: 0)
Took: 916ms (JUST PASSING BY: 0)
Took: 914ms (JUST PASSING BY: 0)
Took: 907ms (JUST PASSING BY: 0)
Took: 387ms (JUST PASSING BY: 0)
Took: 387ms (JUST PASSING BY: 0)
Took: 380ms (JUST PASSING BY: 0)
Took: 387ms (JUST PASSING BY: 0)
Took: 384ms (JUST PASSING BY: 0)
Took: 382ms (JUST PASSING BY: 0)
Took: 380ms (JUST PASSING BY: 0)
Took: 381ms (JUST PASSING BY: 0)