Подтвердить что ты не робот

Как я могу определить, является ли медленность в моей программе проблемой кэша процессора (в Linux)?

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

Моя программа выглядит примерно так:

int large_buffer[10000];

void compute(FILE * input) {
    for(int i=0; i<100; i++) {
        do_lots_of_stuff();
        printf(".");
        fflush(stdout);
    }
}

int main() {
    FILE *input = fopen("input.txt", "r");
    compute(input);
    fclose(input); // <--- everything gets 2x slower if I comment this out (!)
    return 0;
}

Теоретически, что строка fclose(input) в конце основной функции не должна иметь значения, так как OS должна автоматически закрывать файл в конце программы в любом случае. Однако я заметил, что моя программа заняла 2,5 секунды для запуска, когда я включил инструкцию fclose и 5 секунд, когда я ее прокомментировал. Фактор 2 разницы! И это произошло не из-за задержки в начале или в конце программы: скорость, с которой распечатывается ., заметно отличается от версии с помощью оператора fclose.

Я подозреваю, что это может быть связано с некоторым выравниванием памяти или проблемой промаха в кеше. Если я заменил fclose на другую функцию, такую ​​как ftell, она также запустит 5 секунд, и если я уменьшу размер элементов large_buffer до <= 8000, то он всегда будет выполняться в течение 2,5 секунд, независимо от того, какой оператор fclose существует или нет.

Но я действительно хотел бы быть в состоянии на 100% убедиться в том, что является виновником этого странного поведения. Можно ли запустить мою программу под каким-то профилировщиком или другим инструментом, который даст мне эту информацию? До сих пор я пытался использовать обе версии под valgrind --tool=cachegrind, но сообщал о том же количестве промахов в кеше (0%) для обеих версий моей программы.


edit 1: После запуска обеих версий моей программы в perf stat -d -d -d я получил следующие результаты:

 Performance counter stats for './no-fclose examples/bench.o':

       5625.535086      task-clock (msec)         #    1.000 CPUs utilized          
                38      context-switches          #    0.007 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
                54      page-faults               #    0.010 K/sec                  
    17,851,853,580      cycles                    #    3.173 GHz                      (53.23%)
     6,421,955,412      stalled-cycles-frontend   #   35.97% frontend cycles idle     (53.23%)
     4,919,383,925      stalled-cycles-backend    #   27.56% backend cycles idle      (53.23%)
    13,294,878,129      instructions              #    0.74  insn per cycle         
                                                  #    0.48  stalled cycles per insn  (59.91%)
     3,178,485,061      branches                  #  565.010 M/sec                    (59.91%)
       440,171,927      branch-misses             #   13.85% of all branches          (59.92%)
     4,778,577,556      L1-dcache-loads           #  849.444 M/sec                    (60.19%)
           125,313      L1-dcache-load-misses     #    0.00% of all L1-dcache hits    (60.22%)
            12,110      LLC-loads                 #    0.002 M/sec                    (60.25%)
   <not supported>      LLC-load-misses                                             
   <not supported>      L1-icache-loads                                             
        20,196,491      L1-icache-load-misses                                         (60.22%)
     4,793,012,927      dTLB-loads                #  852.010 M/sec                    (60.18%)
               683      dTLB-load-misses          #    0.00% of all dTLB cache hits   (60.13%)
             3,443      iTLB-loads                #    0.612 K/sec                    (53.38%)
                90      iTLB-load-misses          #    2.61% of all iTLB cache hits   (53.31%)
   <not supported>      L1-dcache-prefetches                                        
            51,382      L1-dcache-prefetch-misses #    0.009 M/sec                    (53.24%)

       5.627225926 seconds time elapsed
 Performance counter stats for './yes-fclose examples/bench.o':

       2652.609254      task-clock (msec)         #    1.000 CPUs utilized          
                15      context-switches          #    0.006 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
                57      page-faults               #    0.021 K/sec                  
     8,277,447,108      cycles                    #    3.120 GHz                      (53.39%)
     2,453,171,903      stalled-cycles-frontend   #   29.64% frontend cycles idle     (53.46%)
     1,235,728,409      stalled-cycles-backend    #   14.93% backend cycles idle      (53.53%)
    13,296,127,857      instructions              #    1.61  insn per cycle         
                                                  #    0.18  stalled cycles per insn  (60.20%)
     3,177,698,785      branches                  # 1197.952 M/sec                    (60.20%)
        71,034,122      branch-misses             #    2.24% of all branches          (60.20%)
     4,790,733,157      L1-dcache-loads           # 1806.046 M/sec                    (60.20%)
            74,908      L1-dcache-load-misses     #    0.00% of all L1-dcache hits    (60.20%)
            15,289      LLC-loads                 #    0.006 M/sec                    (60.19%)
   <not supported>      LLC-load-misses                                             
   <not supported>      L1-icache-loads                                             
           140,750      L1-icache-load-misses                                         (60.08%)
     4,792,716,217      dTLB-loads                # 1806.793 M/sec                    (59.93%)
             1,010      dTLB-load-misses          #    0.00% of all dTLB cache hits   (59.78%)
               113      iTLB-loads                #    0.043 K/sec                    (53.12%)
               167      iTLB-load-misses          #  147.79% of all iTLB cache hits   (53.44%)
   <not supported>      L1-dcache-prefetches                                        
            29,744      L1-dcache-prefetch-misses #    0.011 M/sec                    (53.36%)

       2.653584624 seconds time elapsed

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


edit 2: Интересный факт, по-видимому, я все еще могу сохранить странное поведение, если я заменил "fclose" вызов одной инструкцией NOP.


edit 3: Мой процессор - Intel i5-2310 (Sandy Bridge)


Редактировать 4: Оказывается, если я изменяю размер массивов, редактируя файл сборки, он не ускоряется. В Apparely причина, по которой это ускорилось, когда я изменил их размеры в коде C, состоял в том, что gcc решил упорядочить порядок вещей в двоичном формате.


Изменить 5: Еще одно доказательство того, что важны точные адреса инструкций JMP: если я добавлю один NOP (вместо printf) в начале моего кода, он будет быстрее. Аналогично, если я удалю бесполезную инструкцию с начала моего кода, она также ускорится. И когда я скомпилировал свой код на другой версии gcc, он также ускорился, несмотря на то, что сгенерированный ассемблерный код остался прежним. Единственным отличием была отладочная информация в начале и что разделы двоичного файла были в другом порядке.

4b9b3361

Ответ 1

Метка ключа

Ваш виновник - промахи в ветке:

440,171,927      branch-misses             #   13.85% of all branches

против.

71,034,122      branch-misses             #    2.24% of all branches

Я не уверен, какой процессор вы используете, но если вы гипотетически используете 2,5-гигагерцовый процессор на Haswell, вы увидите, что каждое отклонение от прогноза ветвления стоит около 15 циклов (обычно это немного больше, потому что другие вещи киосков тоже), и каждый цикл равен 0,4 нс. Таким образом, 0,4 нс/цикл * 15 циклов/пропущенная ветвь * (440,171,927 - 71,034,122) пропусков ветки = 2,2 секунды. Это будет зависеть от вашего точного процессора и машинного кода, но это объясняет основную часть разницы.

Причина

Алгоритмы предсказания ветвлений разных чипов являются собственностью, но если вы исследуете здесь (http://www.agner.org/optimize/microarchitecture.pdf), вы можете узнать больше о разных процессорах и там ограничения. По сути, вы обнаружите, что некоторые процессоры лучше справляются с тем, чтобы избежать столкновений в таблицах предсказания ветвлений, которые они хранят, чтобы делать прогнозы относительно принятых ветвей или нет.

Итак, почему это важно? Ну, что произошло (вероятность 99%) заключается в том, что, слегка изменив свою программу, вы меняете, где именно разные ветки находятся в памяти. Слишком много этой карты в тот же самый ведро в таблицах прогнозирования ветвлений для процессора. Изменяя исполняемый файл немного, эта проблема уходит. Вы должны иметь очень конкретное расстояние между двумя точками ветвления, чтобы вызвать эту проблему. Вам это не повезло.

Простое решение

Как вы нашли, многие изменения на самом деле заставят программу не ударить эту ухудшенную производительность. По существу, все, что изменит расстояние между двумя критическими точками ветвления, устранит проблему. Вы можете сделать это, буквально вставив 16 байт (или достаточно, чтобы переместить точки ветвления в разные ковши) машинного кода где-то между двумя местами. Вы также можете сделать то же, что и вы, и изменить то, что нарушит это расстояние до непатологического случая.

Копаем глубже

Если вы хотите по-настоящему понять, что вызывает его в этой ситуации, вам нужно будет замарать руки. Весело! Вам нужно будет выбрать один из многих инструментов, чтобы найти конкретную ветвь, которая неверно предсказана. Вот один из способов: Как измерить неверные предсказания для отдельной ветки в Linux?

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

Более широкие уроки

Программисты недооценивают стоимость ветвления (когда компилятор не может удалить ветки во время компиляции). Как вы обнаружили, каждая ветвь ставит больше нагрузки на буферы прогнозирования ветвлений процессора и увеличивает вероятность ошибочных прогнозов. Таким образом, даже ветки, которые на 100% предсказуемы для процессора, могут снизить производительность за счет сокращения ресурсов, доступных для прогнозирования других веток. Кроме того, когда ветвь неверно предсказана, она стоит минимум 12-15 циклов и может быть намного больше, если необходимые инструкции не находятся в кэше L1, кеш L2, кеш-память L3 или небеса помогают вам, основная память. Кроме того, компилятор не может выполнять все оптимизации по ветвям.

Ответ 2

Сначала вы хотите проверить здравомыслие, разобрав все функции и убедившись, что единственная разница в основном. Вы можете разобрать с objdump -d и взломать, чтобы сравнить с diff.

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

Таким образом, подозрение в том, что чрезмерное кэширование кэша отсутствует в предыдущей версии программы.

В вашем вопросе вы указываете, что cachegrind был выполнен, но он сообщил 0%. Это не складывается. Даже если вышеупомянутое подозрение неверно, вы все равно получите несколько промахов. Вставьте оба результата.

Канонический инструмент для работы с linux - perf (https://perf.wiki.kernel.org/index.php/Tutorial). Не забудьте запустить его несколько раз, так как для таких коротких периодов времени вы получите большую дисперсию.

Вы можете играть с явным выравниванием обеих переменных и функций, аннотируя их с помощью

  __attribute__((aligned(64)))

Играйте с номером.

Ответ 3

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

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

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

Как только вы знаете, где время идет, вы должны задать более точные вопросы и, возможно, сможете исключить некоторые факторы. В этот момент вы можете выйти из perf-tools.