Оптимизация работы с памятью
Как известно, внутри процессора имеется специальная быстродействующая область памяти − кэш. В кэше хранятся копии часто используемых данных из оперативной памяти. Если при обращении к ячейке памяти она обнаруживается в кэше, то операция будет выполнена многократно быстрее, чем при использовании более медленной основной памятью.
Важно понимать, что одним из принципов организации кэша является пространственная локальность: если произошло обращение к ячейке оперативной памяти, то с большой вероятностью будет произведено обращение к соседним ячейкам. Реализуется это обычно так. Вся память условно разбита на строки некоторого размера (например, по 64 байта). Когда происходит обращение к какой-то ячейке памяти, в кэш загружается вся строка (причём данная операция реализована аппаратным образом так, чтобы выполняться максимально быстро). Если следующее обращение программы происходит к соседней ячейке памяти, то чаще всего она будет принадлежать этой же строке, и потому с большой вероятностью будет уже присутствовать в кэше.
Из вышенаписанного можно сделать вывод: программа, которая обрабатывает данные в памяти последовательно, работает заметно эффективнее, чем программа, которая "прыгает" по памяти, даже если они выполняют одинаковое число операций. Рассмотрим простой пример. Имеется достаточно большой двухмерный массив размера n x m (например, n=m=1000). Требуется вычислить сумму элементов массива. Который из двух вариантов кода работает лучше?
// Пример 9.2.8 - особенности работы кэша
// сумма элементов матрицы - вариант 1
int sum = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
sum += a[i][j];
// сумма элементов матрицы - вариант 2
int sum = 0;
for (int j = 0; j < m; j++)
for (int i = 0; i < n; i++)
sum += a[i][j];
Первый вариант кода на машине автора работает в 4.5 раза быстрее. Причина в том, что в памяти элементы матрицы хранятся по строкам − a[0][0], в следующей ячейке − a[0][1], и так далее. Первый вариант кода обходит матрицу по строкам, при этом достигается большое число попаданий в кэш. Во второй варианте матрица обходится по столбцам, при этом попаданий в кэш оказывается значительно меньше.
Интересно, что первый вариант кода будет быстрее работать и в случае, когда матрица настолько велика, что не влезает целиком в оперативную память. В этом случае роль кэша будет выполнять оперативная память, а роль медленной памяти − жёсткий диск, на котором находится файл подкачки.
Эффективное использование кэша может являться причиной, почему некоторые алгоритмы работают быстрее других несмотря на то, что они выполняют примерно одинаковое число операций − например, быстрая сортировка Хоара (quicksort) работает заметно быстрее алгоритма сортировки пирамидой (heapsort).
Предсказание переходов
Современный процессор за счёт конвейера инструкций способен выполнять сразу несколько инструкций параллельно, а потом объединять их результаты. Однако, что делать процессору, если встретилась инструкция условного перехода? Ведь пока результат условного перехода неизвестен, непонятно, какую инструкцию нужно выполнять следующей.
Специально для такого случая в процессоре предусмотрен модуль предсказания переходов. Данный модуль позволяет сократить простой конвейера внутри процессора за счёт предварительной загрузки и исполнения инструкций, которые должны выполниться после перехода. Если впоследствии обнаруживается, что предсказание было выполнено неверно, то результаты неверного ветвления отменяются и в конвейер загружается правильное, что приводит к задержке.
Для динамического предсказания ветвлений используется история ветвлений − информация о том, как часто этот переход выполнялся несколько предыдущих раз. Программный код, при выполнении которого предсказатель переходов хорошо угадывает результат перехода, будет выполняться заметно более эффективно, чем код, в котором результат перехода сложно предугадать.
Для примера рассмотрим две реализации сортировки массива методом пузырька (пример взят из статьи https://habrahabr.ru/post/73726).
// Пример 9.2.9 - две сортировки методом пузырька
// Вариант 1
for (int i = 0; i < N; i++)
for (int j = 0; j < N-i-1; j++)
if (a[j] > a[j+1])
std::swap(a[j], a[j+1]);
}
// Вариант 2
void bubble2()
{
for (int i = 0; i < N-1; i++)
for (int j = i; j >= 0; j--)
if (a[j] > a[j+1])
std::swap(a[j], a[j+1]);
}
Второй код почти не отличается от первого, но работает примерно в три раза быстрее. Чтобы разобраться, почему так, посмотрим на условие a[j]>a[j+1]. В первом случае при небольших i внутренний цикл по j пробегает почти до конца массива, при этом условие a[j]>a[j+1] выполняется почти случайно. При увеличении i элементы немного упорядочиваются, но всё равно остаётся большая доля случайности.
Во втором случае внутренний цикл бежит по уже отсортированной части массива, в который вставляется новый элемент a[i+1] (фактически, это сортировка вставками). При этом, пока элемент не встал на свою позицию, условие a[j]>a[j+1] будет оказываться всё время истинным, а затем до конца цикла всё время ложным. После нескольких итераций цикла модуль предсказания переходов начинает правильно угадывать результат ветвления, что и приводит к трёхкратной разнице в скорости.
Ускорение ввода-вывода
Если в программе выполняется ввод/вывод значительных объёмов данных в файлы, то операции ввода/вывода легко могут стать узким местом. В связи с этим необходимо хорошо знать классы и функции для ввода/вывода в стандартной библиотеке C++, в том числе представлять себе их сравнительную эффективность.
Заметим, что время выполнения операций ввода складывается из двух составляющих. Во-первых, это собственно чтение данных из файла в буфер в оперативной памяти. Во-вторых, это преобразование прочитанных данных в значения нужного типа (многие стандартные функции выполняют сразу обе операции). При оптимизации стоит учитывать оба аспекта.
Рассмотрим следующую задачу. Дан текстовый файл input.txt. В первой его строке содержится натуральное число N, за ним идут N неотрицательных целых чисел. Требуется прочитать всё содержимое файла в вектор. Для эксперимента нами был создан сравнительно большой файл размером 64 мегабайта, содержащий 10 миллионов чисел.
Ниже приводится несколько вариантов решения данной задачи. В первом варианте для работы с классом используется стандартный класс std::ifstream из заголовочного файла fstream.
Во втором варианте работа с файлом проводится в стиле языка C − используются функции из заголовочного файла stdio.h. Для чтения очередного числа из файла используется функция fscanf.
В третьем варианте решения используется функция fread, которая позволяет за один раз прочитать всё содержимое файла в память. Затем полученная строка разбивается на лексемы с помощью функции strtok, а каждая лексема переводится в число стандартной функцией atoi.
Четвёртый вариант является модификацией третьего − вместо функция atoi написана собственная функция str_to_int для перевода строки в число.
Поскольку все функции писались лишь для проведения эксперимента, в них отсутствуют проверки существования файла и правильности его содержимого.
// Пример 9.2.10 - разные реализации файлового ввода
// Вариант 1 - используем std::ifstream
std::vector<int> read1()
{
std::ifstream fin("input.txt");
int n; fin >> n;
std::vector<int> a(n);
for (int i = 0; i < n; i++)
fin >> a[i];
return a;
}
// Вариант 2 - используем fscanf
std::vector<int> read2()
{
FILE *f = fopen("input.txt", "r");
int n; fscanf(f, "%d", &n);
std::vector<int> a(n);
for (int i = 0; i < n; i++)
fscanf(f, "%d", &a[i]);
fclose(f);
return a;
}
// Вариант 3 - используем fread + strtok + atoi
std::vector<int> read3()
{
FILE *f = fopen("input.txt", "rb");
// получим размер файла
fseek(f, 0, SEEK_END);
int size = ftell(f);
fseek(f, 0, SEEK_SET);
// прочитаем данные
char *s = new char[size + 1];
fread(s, 1, size, f);
char *p = strtok(s, " \r\n");
int n = atoi(p);
std::vector<int> a(n);
for (int i = 0; i < n; i++)
{
p = strtok(NULL, " \r\n");
a[i] = atoi(p);
}
fclose(f);
return a;
}
// Вариант 4 - своя функция для замены atoi в функции read3
inline int str_to_int(char s[])
{
int res = 0;
for (char *p = s; *p != 0; p++)
res = res * 10 + *p - '0';
return res;
}
В следующей таблице приведено время работы программы.
Способ ввода | Результат, g++ 4.9.2 (mingw) | Результат, Visual C++ 2013 |
std::ifstream | 6.5 сек. | 6.7 сек. |
fscanf | 2.5 сек. | 2.2 сек. |
fread + strtok + atoi | 1.1 сек. | 1.2 сек. |
fread + strtok + своя функция перевода строки в число | 0.08 сек. | 0.08 сек. |
Результаты получились довольно интересными. Во-первых, видно, что объектно-ориентированный ввод в стиле C++ заметно проигрывает по скорости вводу в "старом" стиле языка C. Можно также отметить, что в некоторых компиляторах, как это ни странно, скорость ввода при использовании директивы #include <stdio.h> может оказаться выше, чем при использовании #include <cstdio>.
Во-вторых, заметное ускорение можно получить, считывая файл в память сразу крупными кусками с помощью функции fread (в нашем случае мы читаем вообще весь файл целиком).
И самый интересный результат: оказалось, что очень сильно влияет на производительность способ преобразования прочитанных данных в тип int. Замена библиотечной функции atoi на собственную функцию str_to_int повысила скорость работы более чем на порядок. Вероятно, это связано с тем, что в нашей функции str_to_int не делается никаких проверок − предполагается, что входная строка содержит корректную запись числа.
Заметим, что разница между самым быстрым и самым медленным способом ввода составила более 80 раз!