L1缓存未命中的代价是什么?

76

编辑:供参考(如果有人偶然发现了这个问题),Igor Ostrovsky写了一篇关于缓存未命中的绝妙文章。它讨论了几个不同的问题并展示了例子数据。结束编辑

我进行了一些测试<long story goes here>,想知道性能差异是否由内存缓存未命中导致。以下代码演示了问题,并将其简化为关键的时间部分。以下代码包含几个循环,以随机顺序和按地址升序访问内存。

我在XP机器上运行了它(使用VS2005编译:cl /O2),还在Linux盒子上运行了它(gcc –Os)。两个产生了类似的时间。这些时间以毫秒为单位。我相信所有循环都在运行而且没有被优化掉(否则它会“瞬间”运行)。

*** 测试 20000 个节点
总共排序时间:888.822899
总共随机时间:2155.846268

这些数字有意义吗?差异主要是由 L1 缓存未命中引起的,还是其他原因也在发生?有 20,000^2 的内存访问,如果每一个都是缓存未命中,那么大约是每个未命中需要 3.2 纳秒。我测试的 XP(P4)机器速度为 3.2GHz,并且我认为(但不确定)它具有 32KB 的 L1 缓存和 512KB 的 L2 缓存。对于 20,000 个条目(80KB),我认为没有显著数量的 L2 缓存未命中。因此这将是 (3.2*10^9 次/秒) * 3.2*10^-9 秒/未命中) = 10.1 次/未命中。这对我来说似乎很高。也许不是这样,或者也许我的数学很差。我尝试使用 VTune 测量缓存未命中,但我得到了一个蓝屏。现在我无法连接到许可证服务器(grrrr)。

typedef struct stItem
{
   long     lData;
   //char     acPad[20];
} LIST_NODE;



#if defined( WIN32 )
void StartTimer( LONGLONG *pt1 )
{
   QueryPerformanceCounter( (LARGE_INTEGER*)pt1 );
}

void StopTimer( LONGLONG t1, double *pdMS )
{
   LONGLONG t2, llFreq;

   QueryPerformanceCounter( (LARGE_INTEGER*)&t2 );
   QueryPerformanceFrequency( (LARGE_INTEGER*)&llFreq );
   *pdMS = ((double)( t2 - t1 ) / (double)llFreq) * 1000.0;
}
#else
// doesn't need 64-bit integer in this case
void StartTimer( LONGLONG *pt1 )
{
   // Just use clock(), this test doesn't need higher resolution
   *pt1 = clock();
}

void StopTimer( LONGLONG t1, double *pdMS )
{
   LONGLONG t2 = clock();
   *pdMS = (double)( t2 - t1 ) / ( CLOCKS_PER_SEC / 1000 );
}
#endif



long longrand()
{
   #if defined( WIN32 )
   // Stupid cheesy way to make sure it is not just a 16-bit rand value
   return ( rand() << 16 ) | rand();
   #else
   return rand();
   #endif
}

// get random value in the given range
int randint( int m, int n )
{
   int ret = longrand() % ( n - m + 1 );
   return ret + m;
}

// I think I got this out of Programming Pearls (Bentley).
void ShuffleArray
(
   long *plShuffle,  // (O) return array of "randomly" ordered integers
   long lNumItems    // (I) length of array
)
{
   long i;
   long j;
   long t;

   for ( i = 0; i < lNumItems; i++ )
      plShuffle[i] = i;

   for ( i = 0; i < lNumItems; i++ )
      {
      j = randint( i, lNumItems - 1 );

      t = plShuffle[i];
      plShuffle[i] = plShuffle[j];
      plShuffle[j] = t;
      }
}



int main( int argc, char* argv[] )
{
   long          *plDataValues;
   LIST_NODE     *pstNodes;
   long          lNumItems = 20000;
   long          i, j;
   LONGLONG      t1;  // for timing
   double dms;

   if ( argc > 1 && atoi(argv[1]) > 0 )
      lNumItems = atoi( argv[1] );

   printf( "\n\n*** Testing %u nodes\n", lNumItems );

   srand( (unsigned int)time( 0 ));

   // allocate the nodes as one single chunk of memory
   pstNodes = (LIST_NODE*)malloc( lNumItems * sizeof( LIST_NODE ));
   assert( pstNodes != NULL );

   // Create an array that gives the access order for the nodes
   plDataValues = (long*)malloc( lNumItems * sizeof( long ));
   assert( plDataValues != NULL );

   // Access the data in order
   for ( i = 0; i < lNumItems; i++ )
      plDataValues[i] = i;

   StartTimer( &t1 );

   // Loop through and access the memory a bunch of times
   for ( j = 0; j < lNumItems; j++ )
      {
      for ( i = 0; i < lNumItems; i++ )
         {
         pstNodes[plDataValues[i]].lData = i * j;
         }
      }

   StopTimer( t1, &dms );
   printf( "Total Ordered Time: %f\n", dms );

   // now access the array positions in a "random" order
   ShuffleArray( plDataValues, lNumItems );

   StartTimer( &t1 );

   for ( j = 0; j < lNumItems; j++ )
      {
      for ( i = 0; i < lNumItems; i++ )
         {
         pstNodes[plDataValues[i]].lData = i * j;
         }
      }

   StopTimer( t1, &dms );
   printf( "Total Random Time: %f\n", dms );

}

14
他的问题是:“这些数字有意义吗?” - Tim Sylvester
对不起 - 我在太多的文本中埋藏了问题。但是,问题是这些数字是否合理。L1缓存未命中需要10个周期左右,这个数据正确吗? - Mark Wilkins
2
你应该阅读 Ulrich Drepper 的《每个程序员都应该了解的内存知识》(http://people.redhat.com/drepper/cpumemory.pdf),它深入探讨了内存访问的时间、访问模式和缓存交互。 - caf
3
这个问题中提到的Igor Ostrovsky链接很好。加一分只是为了引导我去看那个链接。 - user334911
8个回答

74
这里尝试用烤巧克力曲奇的比喻来解释缓存未命中的相对成本问题…… 您的手就是寄存器。 把巧克力豆放入面团中只需要1秒钟。 厨房台面就是L1缓存,比寄存器慢12倍。 走到柜台,拿起核桃袋,把一些核桃倒进手中需要12 x 1 = 12秒钟。 冰箱是L2缓存,比L1慢4倍。 走到冰箱,打开它,把昨晚的剩菜挪开,取出一个鸡蛋盒,打开盒子,把3个鸡蛋放在柜台上,然后把盒子放回冰箱,需要4 x 12 = 48秒钟。 碗柜是L3缓存,比L2慢3倍。 走3步到碗柜,弯下身子,打开门,在里面翻找烘焙用品罐,从柜子里取出来,打开瓶子,找到发酵粉,把它放在柜台上并清扫你洒在地板上的东西,需要3 x 48 = 2分24秒钟。 那主内存呢?它就是比L3慢5倍的小卖部。 找到你的钱包,穿上鞋子和外套,跑下街买一瓶牛奶,然后跑回家,脱下鞋子和外套,回到厨房需要5 x 2:24 = 12分钟。
请注意,所有这些访问都是恒定的复杂度——O(1)——但它们之间的差异可能对性能产生巨大的影响。仅为大O复杂度进行优化就像决定是一次添加1颗巧克力豆还是10颗巧克力豆到面糊中,但忘记在杂货清单上写它们一样。 寓言故事的道德: 组织内存访问,使CPU尽可能少地去买菜。从CPU Cache Flushing Fallacy博客文章中取得了数据,指出对于一种特定的2012年Intel处理器,以下内容是正确的:
  • 寄存器访问 = 每个周期 4 条指令
  • L1延迟 = 3个周期 (12 x 寄存器)
  • L2延迟 = 12个周期 (4 x L1, 48 x 寄存器)
  • L3延迟 = 38个周期 (3 x L2, 12 x L1, 144 x 寄存器)
  • DRAM延迟 = 65纳秒 = 在3 GHz CPU上195个周期 (5 x L3, 15 x L2, 60 x L1, 720 x 寄存器)
“处理器缓存效应图库”也是一个不错的阅读材料。

嗯嗯,饼干 ...


1
在O(1)中的那个1总是令人头疼。很好的答案,应该被采纳! - g24l
4
好的回答!此外,这可以扩展到共享相同橱柜(L3缓存)的多个小厨房(核心);如果一个厨师去商店买更多的面粉,其他所有人都可以从那里拿取。 - rosuav
9
我也想补充一点:在虚拟内存的情况下,访问被换出的页面(即需要从磁盘读取数据的页面)就像发现商店没有那种肉桂粉了,他们需要从中国订购新的批次,需要6周的运输时间。 - Smeeheey

27
尽管我不能回答这个问题是否有意义(我不太熟悉缓存延迟,但就记录而言,大约10个周期的L1缓存未命中是合理的),但我可以向您推荐Cachegrind作为一种工具,帮助您实际查看两个测试之间的缓存性能差异。
Cachegrind是一个Valgrind工具(支持始终可爱的memcheck),用于分析缓存和分支的击中/未击中情况。它将让您了解程序中实际获得的缓存命中/未命中次数。

1
非常好。感谢您指向它。我知道Valgrind,但以前没有使用过它(我的大部分开发都在Win32上)。我现在刚在Linux上运行了它,并报告了测试的“随机”部分有41%的缺失率。而测试的“按顺序”部分则几乎没有缺失率。两个部分都没有任何值得一提的L2缺失率。 - Mark Wilkins

18

一个L1缓存未命中需要3.2纳秒时间是完全可能的。为了作比较,在某个现代多核PowerPC CPU上,一个L1未命中约需要40个时钟周期 - 对于一些核心而言,这可能会稍微长一些,取决于它们离L2缓存的距离(是真的)。一个L2未命中至少需要600个时钟周期。

在性能方面,缓存就是一切;如今CPU速度已经远超内存,因此你实际上正在优化内存总线而不是核心。


6

嗯,看起来主要是L1缓存未命中。

一个L1缓存未命中需要10个时钟周期似乎是比较合理的,可能稍微低了一点。

从RAM读取数据需要大约100或甚至1000个时钟周期(现在太累了,无法准确计算)所以这仍然比那个快很多。


5
“有点偏低” - 如果每次取数据都没有命中缓存,那么拥有80K的数据和32K的L1缓存,您可能会感到失望,所以对我来说略低一些是有道理的。 - Steve Jessop
好的一点是,由于顺序已经被随机化,这意味着大约有50/50的缓存未命中和命中。当然,如果能想出一个读取模式,使得每次访问都未命中,那就太好了。 - Goz
我同意 - 很好的观点。如果缓存大小为32K,并且大部分用于容纳数组,那么可能会有40%的引用是命中的。因此,60%的未命中率将使成本上升到每次未命中约17个周期(再次假设我的计算是正确的)。 - Mark Wilkins
http://www.sandpile.org/impl/p4.htm 表明,从 90 到 65 纳米 P4 的 L2 缓存读取的延迟在 18 到 20 个周期之间。所以马克上面的快速计算看起来非常准确 :) - Goz
实际上,假设每次缺失需要18个周期,并将其插入计算中,得出的值约为56.3%的L1缓存缺失率;而假设每次缺失需要20个周期,则得出的值为50.6%的L1缓存缺失率。 - Goz

4
如果您计划使用cachegrind,请注意它只是一个缓存命中/未命中的模拟器。它并不总是准确的。例如:如果您在循环中访问某个内存位置,比如0x1234,循环1000次,即使您有像这样的代码:clflush 0x1234,在第一次访问时cachegrind始终只显示一个缓存未命中。但在x86架构上,这会导致1000个缓存未命中。

请解释一下为什么在x86上需要1000个缓存未命中。 - Anis LOUNIS aka AnixPasBesoin
如果这是真的,那么Cachegrind是否可以在其缓存模拟中添加对clflush指令的支持呢? - Edmund

2

0

没有经过更多的测试很难确定什么,但就我的经验而言,这种差异规模肯定可以归因于CPU L1和/或L2高速缓存,尤其是在随机访问的情况下。您可能可以通过确保每次访问至少与上次访问有一定的最小距离来使情况更加糟糕。


-3

最简单的事情是对目标CPU进行缩放照片并测量核心和一级缓存之间的物理距离。将该距离乘以电子在铜中每秒行进的距离。然后计算在相同时间内可以有多少个时钟周期。这就是你在L1缓存未命中上浪费的最小CPU周期数。

你也可以通过同样的方式计算从RAM提取数据的最小成本,以CPU周期浪费的数量为单位。你可能会感到惊讶。

请注意,你在此处看到的绝对与缓存未命中有关(无论是L1还是L1和L2),因为通常情况下,缓存会在您访问需要较少到RAM的缓存行上的任何内容时提取同一缓存行上的数据。

然而,你可能也看到的是RAM(尽管称为随机访问内存)仍然更喜欢线性内存访问。


6
“电子的速度与电流/电压的速度无关。电子移动非常缓慢。” - Skizz
是的,这更多与电容和振铃需要多长时间才能稳定下来有关。 - Crashworks
@Skizz,你能给我展示一下如何将这些单位转换为秒,这样我就可以把它们融入答案中吗? - Jasper Bekkers
3
你至少应该包括铜中电磁波的传输速度,据我所知大约为0.6倍光速(足够接近此目的)。 - MSalters
1
如果缓存访问是无时钟异步电路,那么这就有意义了。现实处理器是流水线处理的,变化仅发生在时钟边沿,而加载/存储流水线具有可确定操作的流水线寄存器。物理距离只与设计硅片的工程师相关。延迟的原因之一是物理距离,但你不能从芯片照片中确定延迟。 - doug65536

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接