У меня есть 2 столбца целых чисел с разделителями табуляции, первый из которых является случайным целым числом, второй - целым числом, идентифицирующим группу, которая может быть сгенерирована этой программой. ( generate_groups.cc
)
#include <cstdlib>
#include <iostream>
#include <ctime>
int main(int argc, char* argv[]) {
int num_values = atoi(argv[1]);
int num_groups = atoi(argv[2]);
int group_size = num_values / num_groups;
int group = -1;
std::srand(42);
for (int i = 0; i < num_values; ++i) {
if (i % group_size == 0) {
++group;
}
std::cout << std::rand() << '\t' << group << '\n';
}
return 0;
}
Затем я использую вторую программу ( sum_groups.cc
) для расчета сумм по группе.
#include <iostream>
#include <chrono>
#include <vector>
// This is the function whose performance I am interested in
void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
for (size_t i = 0; i < n; ++i) {
p_out[p_g[i]] += p_x[i];
}
}
int main() {
std::vector<int> values;
std::vector<int> groups;
std::vector<int> sums;
int n_groups = 0;
// Read in the values and calculate the max number of groups
while(std::cin) {
int value, group;
std::cin >> value >> group;
values.push_back(value);
groups.push_back(group);
if (group > n_groups) {
n_groups = group;
}
}
sums.resize(n_groups);
// Time grouped sums
std::chrono::system_clock::time_point start = std::chrono::system_clock::now();
for (int i = 0; i < 10; ++i) {
grouped_sum(values.data(), groups.data(), values.size(), sums.data());
}
std::chrono::system_clock::time_point end = std::chrono::system_clock::now();
std::cout << (end - start).count() << std::endl;
return 0;
}
Если затем я запускаю эти программы для набора данных заданного размера, а затем перетасовываю порядок строк в одном и том же наборе данных, то перемешанные данные вычисляют суммы в ~ 2 раза или более быстрее, чем упорядоченные данные.
g++ -O3 generate_groups.cc -o generate_groups
g++ -O3 sum_groups.cc -o sum_groups
generate_groups 1000000 100 > groups
shuf groups > groups2
sum_groups < groups
sum_groups < groups2
sum_groups < groups2
sum_groups < groups
20784
8854
8220
21006
Я ожидал, что исходные данные, отсортированные по группам, будут иметь лучшую локальность данных и будут быстрее, но я наблюдаю противоположное поведение. Мне было интересно, если кто-нибудь может предположить причину?
источник
.at()
или режим отладки,operator[]
который делает границы проверяешь увидишь.sum
. Вместо тогоsums.reserve(n_groups);
, чтобы звонитьsums.resize(n_groups);
- это то, на что намекал @Shawn.p_out[p_g[i]] += p_x[i];
. Может быть, в первоначальном зашифрованном порядке группы фактически демонстрируют хорошую кластеризацию в отношении доступа кp_out
массиву. Сортировка значений может привести к неправильной схеме доступа с индексированием по группамp_out
.Ответы:
Настройка / сделать это медленно
Прежде всего, программа работает примерно в одно и то же время независимо от:
Большую часть времени проводят в цикле ввода. Но так как мы заинтересованы в этом
grouped_sum()
, давайте проигнорируем это.Изменение цикла тестирования с 10 до 1000 итераций
grouped_sum()
начинает доминировать во время выполнения:перф
Теперь мы можем использовать
perf
самые горячие точки в нашей программе.И разница между ними:
Больше времени в
main()
, что, вероятно,grouped_sum()
указал. Отлично, большое спасибо, перф.перф аннотировать
Есть ли разница в том, где время проводится внутри
main()
?перетасовал:
Сортировка:
Нет, доминируют одни и те же две инструкции. Таким образом, они занимают много времени в обоих случаях, но еще хуже, когда данные сортируются.
перф стат
Ладно. Но мы должны запускать их одинаковое количество раз, поэтому каждая инструкция по какой-то причине должна работать медленнее. Посмотрим что
perf stat
говорит.Выделяется только одно: цикл-фронт-интерфейс .
Хорошо, конвейер инструкций останавливается. На фронтенде. То, что это означает, вероятно, зависит от микроархитектуры.
У меня есть предположение, хотя. Если вы щедры, вы можете даже назвать это гипотезой.
гипотеза
Сортируя входные данные, вы увеличиваете локальность записей. На самом деле, они будут очень локальными; почти все добавленные вами записи будут записаны в ту же папку, что и предыдущая.
Это здорово для кеша, но не отлично для конвейера. Вы вводите зависимости данных, предотвращая выполнение следующей инструкции добавления до тех пор, пока предыдущее добавление не будет завершено (или иным образом не сделает результат доступным для последующих инструкций ).
Это твоя проблема.
Я думаю.
Исправляя это
Векторы с несколькими суммами
На самом деле, давайте попробуем что-нибудь. Что если мы использовали несколько векторов суммы, переключаясь между ними для каждого сложения, а затем суммировали их в конце? Это стоит нам немного локальности, но должно удалить зависимости данных.
(код не симпатичный; не суди меня, интернет !!)
(о, и я также исправил расчет n_groups; он был выключен одним.)
Результаты
После настройки моего make-файла для предоставления
-DNSUMS=...
аргумента компилятору я мог сделать это:Оптимальное количество векторов суммы, вероятно, будет зависеть от глубины конвейера вашего процессора. Мой 7-летний процессор для ультрабуков, вероятно, может максимально использовать конвейер с меньшим количеством векторов, чем потребуется новому модному настольному процессору.
Очевидно, что больше не обязательно лучше; когда я сошел с ума из-за 128 векторов сумм, мы стали больше страдать из-за промахов в кеше - о чем свидетельствует перемешанный ввод, становящийся медленнее, чем отсортированный, как вы изначально ожидали. Мы прошли полный круг! :)
Сумма по группам в реестре
(это было добавлено в редактировании)
Ах, ботаник ! Если вы знаете, что ваши входные данные будут отсортированы и вам нужна еще более высокая производительность, следующая перезапись функции (без дополнительных массивов сумм) будет еще быстрее, по крайней мере, на моем компьютере.
Хитрость в этом заключается в том, что он позволяет компилятору хранить
gsum
переменную, сумму группы, в регистре. Я предполагаю (но, возможно, очень ошибочно), что это быстрее, потому что цикл обратной связи в конвейере может быть короче и / или меньше обращений к памяти. Хороший предсказатель ветвления сделает дополнительную проверку на равенство групп дешевой.Результаты
Это ужасно для случайного ввода ...
... но примерно на 40% быстрее, чем мое решение "много сумм" для сортированного ввода.
Много небольших групп будет происходить медленнее , чем несколько больших, так ли это или нет , тем быстрее реализация будет действительно зависеть от ваших данных здесь. И, как всегда, на вашей модели процессора.
Векторы с несколькими суммами, со смещением вместо битовой маскировки
Сопель предложил четыре развернутых дополнения в качестве альтернативы моему подходу маскировки битов. Я реализовал обобщенную версию их предложения, которая может обрабатывать разные
NSUMS
. Я рассчитываю на то, что компилятор развернет для нас внутренний цикл (что он сделал, по крайней мере, дляNSUMS=4
).Результаты
Время измерять. Обратите внимание, что, поскольку я вчера работал в / tmp, у меня нет точно таких же входных данных. Следовательно, эти результаты не сопоставимы напрямую с предыдущими (но, вероятно, достаточно близки).
Да, внутренний цикл с
NSUMS=8
является самым быстрым на моем компьютере. По сравнению с моим подходом "local gsum", он также имеет дополнительное преимущество, заключающееся в том, что он не становится ужасным для перемешанного ввода.Интересно отметить:
NSUMS=16
хуже становитсяNSUMS=8
. Это может быть потому, что мы начинаем видеть больше пропусков кэша, или потому что у нас недостаточно регистров для правильной развертки внутреннего цикла.источник
perf
.Вот почему сортированные группы медленнее, чем несортированные группы;
Сначала вот код сборки для цикла суммирования:
Давайте посмотрим на инструкцию добавления, которая является основной причиной этой проблемы;
Когда процессор сначала выполняет эту инструкцию, он отправляет запрос чтения (загрузки) из памяти на адрес в edx, затем добавляет значение ecx, а затем выдает запрос записи (сохранения) для того же адреса.
есть функция переупорядочения памяти вызывающего процессора
и есть правило
Поэтому, если следующая итерация достигнет инструкции добавления до того, как запрос на запись завершится, она не будет ждать, если адрес edx отличается от предыдущего значения, и выдаст запрос на чтение, и он переупорядочится по старому запросу на запись, и инструкция добавления продолжится. но если адрес один и тот же, инструкция добавления будет ждать до завершения старой записи.
Обратите внимание, что цикл короткий, и процессор может выполнить его быстрее, чем контроллер памяти завершает запрос на запись в память.
таким образом, для отсортированных групп вы будете читать и писать с одного и того же адреса много раз подряд, так что это приведет к потере повышения производительности при переупорядочении памяти; Между тем, если используются случайные группы, то каждая итерация, вероятно, будет иметь другой адрес, поэтому чтение не будет ожидать более старой записи и переупорядочено перед ней; инструкция add не будет ждать предыдущую.
источник