Pull to refresh

Экспериментальное определение характеристик кэш-памяти

Reading time 8 min
Views 15K
За счет чего же мы наблюдаем постоянный рост производительности однопоточных программ? В данный момент мы находимся на той ступени развития микропроцессорных технологий, когда прирост скорости работы однопоточных приложений зависит только от памяти. Количество ядер растет, но частота зафиксировалась в пределах 4 ГГц и не дает прироста производительности.

Скорость и частота работы памяти — это то основное за счет чего мы получаем «свой кусок бесплатного торта» (ссылка). Именно поэтому важно использовать память, настолько эффективно, насколько мы можем это делать, а тем более такую быструю как кэш. Для оптимизации программы под конкретный компьютер, полезно знать характеристики кэш-памяти процессора: количество уровней, размер, длину строки. Особенно это важно в высокопроизводительном коде — ядра систем, математические библиотеки.

Как же определить характеристики кэша автоматический? (естественно cpuinfo распарсить не считается, хотя-бы потому-что в конечном итоге мы бы хотели получить алгоритм, который можно без труда реализовать в других ОС. Удобно, не правда ли? ) Именно этим мы сейчас и займемся.

Немного теории


В данный момент существуют и широко используются три разновидности кэш-памяти: кэш с прямым отображением, ассоциативный кэш и множественно-ассоциативный кэш.

Кэш с прямым отображением (direct mapping cache)
— данная строка ОЗУ может быть отображена в единственную строку кэша, но в каждую строку кэша может быть отображено много возможных строк ОЗУ.



Ассоциативный кэш (fully associative cache)
— любая строка ОЗУ может быть отображена в любую строку кэша.



Множественно-ассоциативный кэш
— кэш-память делится на несколько «банков», каждый из которых функционирует как кэш с прямым отображением, таким образом строка ОЗУ может быть отображена не в единственную возможную запись кэша (как было бы в случае прямого отображения), а в один из нескольких банков; выбор банка осуществляется на основе LRU или иного механизма для каждой размещаемой в кэше строки.



LRU — вытеснение самой «долго не использованной» строки, кэш памяти.

Идея


Чтобы определить количество уровней кэша нужно рассмотреть порядок обращений к памяти, на котором будет четко виден переход. Разные уровни кэша отличаются прежде всего скоростью отклика памяти. В случае «кэш-промаха» для кэша L1 будет произведен поиск данных в следующих уровнях памяти, при этом если размер данных больше L1 и меньше L2 — то скоростью отклика памяти будет скорость отклика L2. Предыдущее утверждение так же верно в общем случаи.

Ясно что нужно подобрать тест на котором, мы будем четко видеть кэш промахи и протестировать его на различных размерах данных.

Зная логику множественно-ассоциативных кэшей, работающих по алгоритму LRU не трудно придумать алгоритм на котором кэш «валится», ничего хитрого — проход по строке. Критерием эффективности будем считать время одного обращения к памяти. Естественно нужно последовательно обращаться ко всем элементам строки, повторяя многократно для усреднения результата. К примеру возможны случаи, когда строка умещается в кэше но для первого прохода мы грузим строку из оперативной памяти и потому получаем совсем неадекватное время.

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

Ассоциативный кэш

Как только размер данных превышает размер кэш-памяти,
полностью ассоциативный кэш «промахивается» при каждом обращении к памяти.



Прямой кэш

Рассмотрим разные размеры строк. — показывает максимальное количество промахов, которое потратит процессор для доступа к элементам массива при следующем проходе по строке.









Как видно время доступа к памяти возрастает не резко, а по мере увеличения объема данных. Как только размер данных превысит размер кэша, то промахи будут при каждом обращении к памяти.

Потому у ассоциативного кэша ступенька будет вертикальной, а у прямого — плавно возрастать вплоть до двойного размера кэша. Множественно ассоциативный кэш будет средним случаем, «бугорком», хотя бы потому, что время-доступа не может быть лучше прямого.

Если-же говорить о памяти — то самая быстрая это кэш, следом идет оперативная, самая медленная это swap, про него мы в дальнейшем говорить не будем. В свою очередь у разных уровней кэша (как правило сегодня процессоры имеют 2-3 уровня кэша) разная скорость отклика памяти: чем больше уровень, тем меньше скорость отклика. И поэтому, если строка помещается в первый уровень кэша, (который кстати полностью ассоциативный) время отклика будет меньше, чем у строки значительно превышающей размеры кэша первого уровня. По-этому на графике времени отклика памяти от размеров строки будет несколько плато — плато* отклика памяти, и плато вызванные различными уровнями кэша.

*Плато функции — { i:x, f(xi) — f(xi+1) < eps: eps → 0 }

Приступим к реализации


Для реализации будем использовать Си (ANSI C99).

Быстро написан код, обычный проход по строкам разной длины, меньше 10мб, выполняющийся многократно. (Будем приводить небольшие куски программы, несущие смысловую нагрузку).

	
for (i = 0; i < 16; i++) {
    for (j = 0; j < L_STR; j++) A[j]++;
}   


Смотрим на график — и видим одну большую ступеньку. Но ведь в теории получается все, просто замечательно. Стало быть нужно понять: из за чего это происходит? И как это исправить?



Очевидно что это может происходить по двум причинам: либо в процессоре нет кэш памяти, либо процессор так хорошо угадывает обращения к памяти. Поскольку первый вариант ближе к фантастике причина всему хорошее предсказание обращений.

Дело в том, что сегодня далеко не топовые процессоры, помимо принципа пространственной локальности, предсказывают также и арифметическую прогрессию в порядке обращения к памяти. Поэтому нужно рандомизовать обращения к памяти.

Длина рандомизованного массива должна быть сопоставимой с длиной основной строки, чтобы избавиться от большой гранулярности обращений, так же длина массива не должна быть степенью двойки, из-за этого происходили «наложения» — следствием чего могут — быть выбросы. Лучше всего задать гранулярность константно, в том числе, если гранулярность простое число, то можно избежать эффектов наложений. А длина рандомиованого массива — функция от длинны строки.
	
	for (i = 0; i < j; i++) {                 
		for (m = 0; m < L; m++) {           
			for (x = 0; x < M; x++){      
				v = A[ random[x] + m ]; 
			}                             
		}                                                                       
	}                                         
	


После чего мы удивили столь долгожданную «картинку», о которой говорили в начале.





Программа разбита на 2 части — тест и обработка данных. Написать скрипт в 3 строки для запуска или 2 раза запустить ручками решайте сами.

Листинг файла size.с Linux


#include	<stdio.h>
#include	<stdlib.h>
#include	<string.h>
#include	<time.h>

#define		T	char
#define		MAX_S	0x1000000
#define		L	101

volatile T A[MAX_S];
int m_rand[0xFFFFFF];

int main (){
	static struct timespec t1, t2;

	memset ((void*)A, 0, sizeof (A));

	srand(time(NULL));

	int v, M;
	register int i, j, k, m, x;

	for (k = 1024; k < MAX_S;) {
		M = k / L;

		printf("%g\t", (k+M*4)/(1024.*1024));

		for (i = 0; i < M; i++) m_rand[i] = L * i;
		for (i = 0; i < M/4; i++)	{
			j = rand() % M;
			x = rand() % M;

			m = m_rand[j];
			m_rand[j] = m_rand[i];
			m_rand[i] = m;
			
		}

		if (k < 100*1024) j = 1024;
		else if (k < 300*1024) j = 128;
		else j = 32;

		clock_gettime (CLOCK_PROCESS_CPUTIME_ID, &t1);
		for (i = 0; i < j; i++) {

			for (m = 0; m < L; m++) {
				for (x = 0; x < M; x++){
					v = A[ m_rand[x] + m ];
				}
			}
			
		}
		clock_gettime (CLOCK_PROCESS_CPUTIME_ID, &t2);

		printf ("%g\n",1000000000. * (((t2.tv_sec + t2.tv_nsec * 1.e-9) - (t1.tv_sec + t1.tv_nsec * 1.e-9)))/(double)(L*M*j));

		if (k > 100*1024)	k += k/16;
		else			k += 4*1024;
	}
return 0;
}



Листинг файла size.с Windows

	

#include	<stdio.h> 
#include	<stdlib.h> 
#include	<string.h> 
#include	<time.h> 
#include	<iostream> 
#include	<Windows.h> 
using namespace std; 

#define		T	char 
#define		MAX_S	0x1000000 
#define		L	101 

volatile T A[MAX_S]; 
int m_rand[0xFFFFFF]; 

int main (){ 
	LARGE_INTEGER freq; LARGE_INTEGER time1; 	LARGE_INTEGER time2; 
   	QueryPerformanceFrequency(&freq); 

	memset ((void*)A, 0, sizeof (A)); 

	srand(time(NULL)); 

	int v, M; 
	register int i, j, k, m, x; 

	for (k = 1024; k < MAX_S;) { 
		M = k / L; 

		printf("%g\t", (k+M*4)/(1024.*1024)); 

		for (i = 0; i < M; i++) m_rand[i] = L * i; 
		for (i = 0; i < M/4; i++)	{ 
			j = rand() % M; 
			x = rand() % M; 

			m = m_rand[j]; 
			m_rand[j] = m_rand[i]; 
			m_rand[i] = m; 
			 
		} 

		if (k < 100*1024) j = 1024; 
		else if (k < 300*1024) j = 128; 
		else j = 32; 

		QueryPerformanceCounter(&time1); 
		for (i = 0; i < j; i++) { 

			for (m = 0; m < L; m++) { 
				for (x = 0; x < M; x++){ 
					v = A[ m_rand[x] + m ]; 
				} 
			} 
			 
		} 
		QueryPerformanceCounter(&time2); 

		time2.QuadPart -= time1.QuadPart; 
		double span = (double) time2.QuadPart / freq.QuadPart; 

		printf ("%g\n",1000000000. * span/(double)(L*M*j)); 

		if (k > 100*1024)	k += k/16; 
		else			k += 4*1024; 
	} 
return 0; 
}



В общем- то думаю все понятно, но хотелось бы оговорить несколько моментов.

Массив A объявлен как volatile — эта директива гарантирует нам что к массиву A всегда будут обращения, то-есть их не «вырежут» ни оптимизатор, ни компилятор. Так-же стоит оговорить то что вся вычислительная нагрузка выполняется до замера времени, что позволяет нам, уменьшить фоновое влияние.

Файл переведен в ассемблер на Ubuntu 12.04 и компилятором gcc 4.6 — циклы сохраняются.

Обработка данных


Для обработки данных логично использовать производные. И несмотря на то что с повышением порядка дифференцирования шумы возрастают, будет использована вторая производная и её свойства. Как бы не была зашумлена вторая производная, нас интересует лишь знак второй производной.

Находим все точки, в которых вторая производная больше нуля (с некоторой погрешностью потому-что вторая производная, помимо того что считается численно, — сильно зашумлена ). Задаем функцию зависимости знака второй производной функции от размера кэша. Функция принимает значение 1 в точках, где знак второй производной больше нуля, и ноль, если знак второй производной меньше или равен нулю.

Точки «взлетов» — начало каждой ступеньки. Также перед обработкой данных нужно убрать одиночные выбросы, которые не меняют смысловой нагрузки данных, но создают ощутимый шум.

Листинг файла data_pr.с

#include <stdio.h> 
#include <stdlib.h> 
#include <math.h>

double round (double x)
{
  	int mul = 100;
   if (x > 0)
	return floor(x * mul + .5) / mul;
   else
	return ceil(x * mul - .5) / mul;
}

float size[112], time[112], der_1[112], der_2[112]; 

int main(){ 
	size[0] = 0; time[0] = 0; der_1[0] = 0; der_2[0] = 0; 
	int i, z = 110; 
	for (i = 1; i < 110; i++)	 
		scanf("%g%g", &size[i], &time[i]); 
	 
	for (i = 1; i < z; i++) 
		der_1[i] = (time[i]-time[i-1])/(size[i]-size[i-1]); 
	 
	for (i = 1; i < z; i++) 
		if ((time[i]-time[i-1])/(size[i]-size[i-1]) > 2) 
			der_2[i] = 1; 
		else 
			der_2[i] = 0; 
 
	//comb 
	for (i = 0; i < z; i++) 
		if (der_2[i] == der_2[i+2] && der_2[i+1] != der_2[i]) der_2[i+1] = der_2[i]; 

	for (i = 0; i < z-4; i++) 
		if (der_2[i] == der_2[i+3] && der_2[i] != der_2[i+1] && der_2[i] != der_2[i+2]) { 
			der_2[i+1] = der_2[i]; der_2[i+2] = der_2[i]; 
		} 


	for (i = 0; i < z-4; i++) 
		if (der_2[i] == der_2[i+4] && der_2[i] != der_2[i+1] && der_2[i] != der_2[i+2] && der_2[i] != der_2[i+3]) { 
			der_2[i+1] = der_2[i]; der_2[i+2] = der_2[i]; der_2[i+3] = der_2[i]; 
		} 
	// 

	int k = 1; 

	for (i = 0; i < z-4; i++){ 
		if (der_2[i] == 1) printf("L%d = %g\tMb\n", k++, size[i]); 
		while (der_2[i] == 1) i++;		 
	} 

	return 0; 
}	


Тесты


CPU/ОС/версия ядра/компилятор/ключи компиляции — будут указаны для каждого теста.


  • Intel Pentium CPU P6100 @2.00GHz / Ubuntu 12.04 / 3.2.0-27-generic / gcc -Wall -O3 size.c -lrt

    L1 = 0.05 Mb
    L2 = 0.2 Mb
    L3 = 2.7 Mb
  • Не буду приводить все хорошие тесты, давайте лучше поговорим о «Граблях»


Давайте поговорим о «граблях»


Грабля обнаружилась при обработке данных на серверном процессоре Intel Xeon 2.4/L2 = 512 кб/Windows Server 2008



Проблема заключается в маленьком количестве точек, попадающих на интервал выхода на плато, — соответственно, скачок второй производной незаметен и принимается за шум.

Можно решить эту проблему методом наименьших квадратов, либо прогонять тесты в по ходу определения зон плато.

Хотелось бы услышать ваши предложения, по поводу решения этой проблемы.

Список литературы


  • Макаров А.В. Архитектура ЭВМ и Низкоуровневое программирование.
  • Ulrich Drepper What every programmer should know about memory
Tags:
Hubs:
+22
Comments 17
Comments Comments 17

Articles