Какова стоимость промаха кэша L1?

редактировать: для справочных целей (если кто-нибудь наткнется на этот вопрос) Игорь Островский написал Великий пост о кэш-промахов. Он обсуждает несколько различных вопросов и показывает номера примеров. End Edit

Я сделал некоторые испытания <long story goes here> и мне интересно, если разница в производительности из-за памяти кэша. Следующий код демонстрирует проблему и сводит ее к критической части синхронизации. Следующий код имеет пару циклов, которые посещают память в случайном порядке, а затем в порядке возрастания адреса.

Я запустил его на машине XP (скомпилированной с VS2005: cl /O2) и на коробке Linux (GCC –Os). Оба произвели похожие времена. Это время в миллисекундах. Я считаю, что все циклы запущены и не оптимизированы (в противном случае он будет работать "мгновенно").

*** Testing 20000 nodes
Total Ordered Time: 888.822899
Total Random Time: 2155.846268

имеют ли эти цифры смысл? Разница в первую очередь из-за Л1 кэша или что-то еще тоже? Есть 20,000^2 доступа к памяти, и если каждый из них был пропуском кэша, то есть около 3,2 наносекунды за промах. Машина XP (P4), на которой я тестировал, - 3.2 GHz, и я подозреваю (но не знаю), имеет кэш 32KB L1 и 512KB L2. С 20 000 записей (80KB), я предполагаю, что нет значительного количества пропусков L2. Так это будет (3.2*10^9 cycles/second) * 3.2*10^-9 seconds/miss) = 10.1 cycles/miss. Мне это кажется высоким. Может, и нет, а может, у меня плохая математика. Я попытался измерить промахи кэша с помощью VTune, но получил BSOD. И теперь я не могу этого понять. подключение к серверу лицензий (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( "nn*** Testing %u nodesn", 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: %fn", 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: %fn", dms );

}

8 ответов


хотя я не могу ответить на вопрос, Имеют ли цифры смысл (я не очень хорошо разбираюсь в задержках кэша, но для записи ~10 циклов L1 кэш пропускает звуки о праве), я могу предложить вам модулем cachegrind как инструмент, который поможет вам увидеть различия в производительности кэша между вашими 2 тестами.

Cachegrind-это инструмент Valgrind (фреймворк, который питает всегда прекрасный memcheck), который профилирует кэш и хиты/промахи ветвей. Это даст вам идея о том, сколько кэш-хитов/промахов вы на самом деле получаете в своей программе.


вот попытка дать представление об относительной стоимости пропусков кэша по аналогии с выпечкой печенья с шоколадной крошкой ...

ваши руки ваши регистры. вам требуется 1 секунда, чтобы бросить шоколадные чипсы в тесто.

кухонный стол-это ваш кэш L1, в двенадцать раз медленнее, чем регистры. Он принимает 12 x 1 = 12 секунды для того чтобы шагнуть к счетчику, выбирает вверх мешок грецких орехов, и опорожняет некоторое в ваше рука.

холодильник-это ваш кэш L2, в четыре раза медленнее, чем L1. требуется 4 x 12 = 48 секунд, чтобы дойти до холодильника, открыть его, переместить остатки прошлой ночи с дороги, вынуть коробку яиц, открыть коробку, положить 3 яйца на стол и положить коробку обратно в холодильник.

шкаф-это ваш кэш L3, в три раза медленнее, чем L2. требуется 3 x 48 = 2 минуты и 24 секунды, чтобы сделать три шага к шкафу, наклониться, открыть дверь, пошарить вокруг, чтобы найти банку для выпечки, извлечь его из шкафа, открыть его, копать, чтобы найти порошок для выпечки, положить его на прилавок и подмести беспорядок, который вы пролили на пол.

а основная память? Это угловой магазин, в 5 раз медленнее, чем L3. требуется 5 x 2: 24 = 12 минут, чтобы найти свой кошелек, надеть обувь и куртку, мчаться по улице, захватить литр молока, мчаться домой, снять обувь и куртку и вернуться к кухня.

отметим, что все эти обращения являются постоянной сложностью -- O (1) -- но различия между ними могут иметь огромный влияние на производительность. Оптимизация исключительно для сложности big-O похожа на решение о том, добавлять ли шоколадные чипсы в тесто 1 за раз или 10 за раз, но забывая поместить их в список покупок.

мораль истории: организовать доступ к памяти, так что процессор должен идти за продуктами, как как можно реже.

номера были взяты из ошибка промывки кэша процессора сообщение в блоге, которое указывает, что для конкретного процессора Intel 2012-era верно следующее:

  • Регистрация доступа = 4 Инструкции за цикл
  • задержка L1 = 3 цикла (12 x регистр)
  • задержка L2 = 12 циклов (4 x L1, 48 x регистр)
  • задержка L3 = 38 циклов (3 x L2, 12 x L1, 144 x регистр)
  • драма задержка = 65 НС = 195 циклов на 3 ГГц CPU (5 x L3, 15 x L2, 60 x L1, 720 x регистр)

на Галерея эффектов кэша процессора также делает хорошее чтение по этой теме.

Mmmm, cookies ...


3.2 ns для промаха кэша L1 вполне правдоподобно. Для сравнения, на одном конкретном современном многоядерном процессоре PowerPC L1 miss составляет около 40 циклы -- немного дольше для некоторых ядер, чем другие, в зависимости от того, насколько они далеки от кэша L2 (да, действительно). В Л2 скучаю по крайней мере 600 циклы.

кэш-это все в производительности; процессоры настолько быстрее, чем память, что вы действительно почти оптимизируете для шины памяти вместо ядра.


Ну да, похоже, что в основном это будут промахи кэша L1.

10 циклов для промаха кэша L1 звучит разумно, вероятно, немного на низкой стороне.

чтение из ОЗУ будет принимать порядка 100s или может быть даже 1000s (я слишком устал, чтобы попытаться сделать математику прямо сейчас ;)) циклов, поэтому его все еще огромная победа над этим.


Если вы планируете использовать cachegrind, обратите внимание, что это только симулятор попадания/промаха кэша. Это не всегда будет точно. Например: если вы получаете доступ к некоторому местоположению памяти, скажем, 0x1234 в цикле 1000 раз, cachegrind всегда покажет вам, что был только один промах кэша (первый доступ), даже если у вас есть что-то вроде:

clflush 0x1234 в цикле.

на x86, это вызовет все 1000 кэш-промахов.


некоторые номера для 3.4 GHz P4 от запуска Эвереста Lavalys:

  • dcache L1 - 8K (cacheline 64 байта)
  • L2 составляет 512 КБ
  • Л1 принести задержка 2 цикла
  • задержка выборки L2 примерно вдвое больше, чем вы видите: 20 циклов

подробнее здесь: http://www.freeweb.hu/instlatx64/GenuineIntel0000F25_P4_Gallatin_MemLatX86.txt

(для задержек смотрите в нижней части страницы)


трудно сказать что-нибудь наверняка без гораздо большего тестирования, но по моему опыту, что масштаб разницы определенно можно отнести к кешу CPU L1 и/или L2, особенно в сценарии с рандомизированным доступом. Вероятно, вы могли бы сделать это еще хуже, гарантируя, что каждый доступ по крайней мере на некотором минимальном расстоянии от последнего.


проще всего сделать масштабированную фотографию целевого процессора и физически измерить расстояние между ядром и кэшем уровня 1. Умножьте это расстояние на расстояние, на которое электроны могут перемещаться в секунду в меди. Затем выясните, сколько тактов вы можете иметь за одно и то же время. Это минимальное количество циклов процессора, которое вы потратите на пропуск кэша L1.

вы также можете разработать минимальную стоимость извлечения данных из ОЗУ с точки зрения количества ЦП циклы растрачиваются таким же образом. Вы можете быть удивлены.

обратите внимание, что то, что вы видите здесь, определенно имеет какое-то отношение к Cache-misses (будь то L1 или оба L1 и L2), потому что обычно кэш будет извлекать данные в той же строке кэша, как только вы получите доступ к чему-либо в этой строке кэша, требующей меньше поездок в ОЗУ.

однако то, что вы, вероятно, также видите, - это тот факт, что RAM (даже если он вызывает оперативную память) по-прежнему предпочитает линейный доступ к памяти.