В предыдущей статье я сообщал о проблемах с производительностью инструкций CAS / LOCK для микроархитектуры Sandy Bridge по сравнению с предыдущей микроархитектурой Nehalem . С тех пор я работал с хорошими людьми из Intel, чтобы понять, что происходит, и теперь я рад возможности пролить свет на предыдущие результаты.
Я наблюдал небольшое снижение пропускной способности в случае непредусмотренного однопотокового режима и на порядок уменьшения пропускной способности, когда несколько потоков конкурируют при выполнении обновлений. Это тестирование порождено наблюдениями, тестирующими реализации Java Queue и Disruptor для случая с несколькими производителями. Первоначально я был озадачен этими результатами, потому что почти все другие тесты производительности, которые я применял к Sandy Bridge, указывали на важный шаг вперед для этой микроархитектуры.
После более глубокого изучения этой проблемы выяснилось, что мои тесты снова упали на трудности, связанные с микропроцессорным тестированием. Мой тест не является хорошим средством проверки пропускной способности, и на самом деле он проверяет честность окольным путем. Давайте вернемся к коду и поработаем над тем, что происходит.
Тестовый код
#include <time.h> #include <pthread.h> #include <stdlib.h> #include <iostream> typedef unsigned long long uint64; const uint64 COUNT = 500 * 1000 * 1000; volatile uint64 counter = 0; void* run_add(void* numThreads) { register uint64 value = (COUNT / *((int*)numThreads)) + 1; while (--value != 0) { __sync_add_and_fetch(&counter, 1); } } void* run_xadd(void*) { register uint64 value = counter; while (value < COUNT) { value = __sync_add_and_fetch(&counter, 1); } } void* run_cas(void*) { register uint64 value = 0; while (value < COUNT) { do { value = counter; } while (!__sync_bool_compare_and_swap(&counter, value, value + 1)); } } void* run_cas2(void*) { register uint64 value = 0; register uint64 next = 0; while (value < COUNT) { value = counter; do { next = value + 1; value = __sync_val_compare_and_swap(&counter, value, next); } while (value != next); } } int main (int argc, char *argv[]) { const int NUM_THREADS = atoi(argv[1]); const int TESTCASE = atoi(argv[2]); pthread_t threads[NUM_THREADS]; void* status; timespec ts_start; timespec ts_finish; clock_gettime(CLOCK_MONOTONIC, &ts_start); for (int i = 0; i < NUM_THREADS; i++) { switch (TESTCASE) { case 1: std::cout << "LOCK ADD" << std::endl; pthread_create(&threads[i], NULL, run_add, (void*)&NUM_THREADS); break; case 2: std::cout << "LOCK XADD" << std::endl; pthread_create(&threads[i], NULL, run_xadd, (void*)&NUM_THREADS); break; case 3: std::cout << "LOCK CMPXCHG BOOL" << std::endl; pthread_create(&threads[i], NULL, run_cas, (void*)&NUM_THREADS); break; case 4: std::cout << "LOCK CMPXCHG VAL" << std::endl; pthread_create(&threads[i], NULL, run_cas2, (void*)&NUM_THREADS); break; default: exit(1); } } for (int i = 0; i < NUM_THREADS; i++) { pthread_join(threads[i], &status); } clock_gettime(CLOCK_MONOTONIC, &ts_finish); uint64 start = (ts_start.tv_sec * 1000000000) + ts_start.tv_nsec; uint64 finish = (ts_finish.tv_sec * 1000000000) + ts_finish.tv_nsec; uint64 duration = finish - start; std::cout << "threads = " << NUM_THREADS << std::endl; std::cout << "duration = " << duration << std::endl; std::cout << "ns per op = " << (duration / (COUNT * 2)) << std::endl; std::cout << "op/sec = " << ((COUNT * 2 * 1000 * 1000 * 1000) / duration) << std::endl; std::cout << "counter = " << counter << std::endl; return 0; }
Приведенный выше код позволяет протестировать основные методы, основанные на CAS, на x86. Для полной ясности
objdump -d двоичного файла показывает сгенерированные компилятором инструкции по сборке для вышеуказанных методов. Инструкция «
lock » в каждом разделе — это то место, где происходит атомарное обновление.
0000000000400dc0 <_z8run_cas2pv>: 400dc0: 48 8b 05 d9 07 20 00 mov 0x2007d9(%rip),%rax 400dc7: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1) 400dce: 00 00 400dd0: 48 8d 50 01 lea 0x1(%rax),%rdx 400dd4: f0 48 0f b1 15 c3 07 lock cmpxchg %rdx,0x2007c3(%rip) 400ddb: 20 00 400ddd: 48 39 c2 cmp %rax,%rdx 400de0: 75 ee jne 400dd0 <_z8run_cas2pv> 400de2: 48 3d ff 64 cd 1d cmp $0x1dcd64ff,%rax 400de8: 76 d6 jbe 400dc0 <_z8run_cas2pv> 400dea: f3 c3 repz retq 400dec: 0f 1f 40 00 nopl 0x0(%rax) 0000000000400df0 <_z7run_caspv>: 400df0: 48 8b 15 a9 07 20 00 mov 0x2007a9(%rip),%rdx 400df7: 48 8d 4a 01 lea 0x1(%rdx),%rcx 400dfb: 48 89 d0 mov %rdx,%rax 400dfe: f0 48 0f b1 0d 99 07 lock cmpxchg %rcx,0x200799(%rip) 400e05: 20 00 400e07: 75 e7 jne 400df0 <_z7run_caspv> 400e09: 48 81 fa ff 64 cd 1d cmp $0x1dcd64ff,%rdx 400e10: 76 de jbe 400df0 <_z7run_caspv> 400e12: f3 c3 repz retq 400e14: 66 66 66 2e 0f 1f 84 data32 data32 nopw %cs:0x0(%rax,%rax,1) 400e1b: 00 00 00 00 00 0000000000400e20 <_z8run_xaddpv>: 400e20: 48 8b 05 79 07 20 00 mov 0x200779(%rip),%rax 400e27: 48 3d ff 64 cd 1d cmp $0x1dcd64ff,%rax 400e2d: 77 1b ja 400e4a <_z8run_xaddpv> 400e2f: 90 nop 400e30: b8 01 00 00 00 mov $0x1,%eax 400e35: f0 48 0f c1 05 62 07 lock xadd %rax,0x200762(%rip) 400e3c: 20 00 400e3e: 48 83 c0 01 add $0x1,%rax 400e42: 48 3d ff 64 cd 1d cmp $0x1dcd64ff,%rax 400e48: 76 e6 jbe 400e30 <_z8run_xaddp> 400e4a: f3 c3 repz retq 400e4c: 0f 1f 40 00 nopl 0x0(%rax) 0000000000400e50 <_z7run_addpv>: 400e50: 48 63 0f movslq (%rdi),%rcx 400e53: 31 d2 xor %edx,%edx 400e55: b8 00 65 cd 1d mov $0x1dcd6500,%eax 400e5a: 48 f7 f1 div %rcx 400e5d: 48 85 c0 test %rax,%rax 400e60: 74 15 je 400e77 <_z7run_addpv> 400e62: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1) 400e68: f0 48 83 05 2f 07 20 lock addq $0x1,0x20072f(%rip) 400e6f: 00 01 400e71: 48 83 e8 01 sub $0x1,%rax 400e75: 75 f1 jne 400e68 <_z7run_addpv> 400e77: f3 c3 repz retq 400e79: 90 nop 400e7a: 90 nop 400e7b: 90 nop 400e7c: 90 nop 400e7d: 90 nop 400e7e: 90 nop 400e7f: 90 nop
Чтобы чисто изолировать производительность операции CAS, тест должен быть запущен с использованием
опции lock xadd для атомарного приращения в оборудовании. Эта инструкция позволяет нам избежать цикла повторных попыток чисто программного CAS, который может испортить эксперимент.
Я повторил эксперимент из предыдущей статьи и получил очень похожие результаты. Ранее я думал, что наблюдал падение пропускной способности даже в неконтролируемом однопоточном случае. Поэтому я сосредоточился на этом, чтобы подтвердить. Чтобы сделать это, мне нужно было найти два процессора, которые
после включения Turbo Boost будут сравнимы по тактовой частоте. Я нашел это, используя Nehalem 2,8 ГГц и Sandy Bridge 2,4 ГГц. Для однопоточного корпуса они оба работают на частоте ~ 3,4 ГГц.
Nehalem 2.8GHz ============== $ perf stat ./atomic_inc 1 2 LOCK XADD threads = 1 duration = 3090445546 ns per op = 3 op/sec = 323577938 Performance counter stats for './atomic_inc 1 2': 3085.466216 task-clock # 0.997 CPUs utilized 331 context-switches # 0.107 K/sec 4 CPU-migrations # 0.001 K/sec 360 page-faults # 0.117 K/sec 10,527,264,923 cycles # 3.412 GHz 9,394,575,677 stalled-cycles-frontend # 89.24% frontend cycles idle 7,423,070,202 stalled-cycles-backend # 70.51% backend cycles idle 2,517,668,293 instructions # 0.24 insns per cycle # 3.73 stalled cycles per insn 503,526,119 branches # 163.193 M/sec 110,695 branch-misses # 0.02% of all branches 3.093402966 seconds time elapsed Sandy Bridge 2.4GHz =================== $ perf stat ./atomic_inc 1 2 LOCK XADD threads = 1 duration = 3394221940 ns per op = 3 op/sec = 294618330 Performance counter stats for './atomic_inc 1 2': 3390.404400 task-clock # 0.998 CPUs utilized 357 context-switches # 0.105 K/sec 1 CPU-migrations # 0.000 K/sec 358 page-faults # 0.106 K/sec 11,522,932,068 cycles # 3.399 GHz 9,542,667,311 stalled-cycles-frontend # 82.81% frontend cycles idle 6,721,330,874 stalled-cycles-backend # 58.33% backend cycles idle 2,518,638,461 instructions # 0.22 insns per cycle # 3.79 stalled cycles per insn 502,490,710 branches # 148.210 M/sec 36,955 branch-misses # 0.01% of all branches 3.398206155 seconds time elapsed
Анализ
Итак, повторение испытаний с сопоставимыми тактовыми частотами подтвердило предыдущие результаты. В однопоточном корпусе пропускная способность составляет ~ 10%, а в многопоточном конкурирующем корпусе — разность порядка пропускной способности.
Теперь большой вопрос: что происходит и почему пропускная способность упала? Хорошо, однопоточный случай предполагает, что ничего не произошло с числом циклов, необходимых для выполнения инструкции, когда она не выполняется. Небольшие различия могут быть связаны с системным шумом или изменениями внешнего интерфейса ЦП для Sandy Bridge с введением дополнительного модуля генерации адреса нагрузки.
Для многопоточного корпуса мы обнаружили интересный сюрприз, когда Intel проверила, что делают инструкции. Мы обнаружили, что каждый поток в Nehalem мог выполнять больше обновлений в пакете, прежде чем потерял исключительное состояние в строке кэширования, содержащей счетчик. Это связано с тем, что задержка между ядрами улучшилась с помощью Sandy Bridge, поэтому другие потоки могут быстрее запрашивать строку кэша, содержащую счетчик, для выполнения своих собственных обновлений. Что мы на самом деле измеряем с помощью этого микро-теста, так это то, как долго ядро может удерживать кеш-линию, прежде чем оно будет передано другому ядру. Sandy Bridge демонстрирует большую справедливость, чего вы и хотели бы видеть в реальных приложениях.
Этот микро-тест очень нереалистичен для реального применения. Обычно между выполнением обновлений счетчика ядро выполняет много другой работы. В тот момент, когда счетчик должен быть обновлен, межядерный процессор с уменьшенной задержкой будет тогда преимуществом.
Во всех моих тестах приложений для макросов Sandy Bridge показал лучшую производительность, чем Nehalem, при сопоставимых тактовых частотах.
Заключение
Что я узнал из этого? Хорошо еще раз, что написание микро-тестов, как известно, сложно. Очень трудно понять, что вы измеряете и какие эффекты могут вступить в игру. Чтобы проиллюстрировать, как трудно распознать такой недостаток, для всех тех, кто читал этот блог, никто не выявил проблему и не передал ее мне.
Это также показывает, что то, что с первого взгляда можно считать ошибкой производительности, на самом деле противоположно. Это показывает, как можно получить эффект второго порядка, когда улучшение производительности может замедлить выполнение конкретного рабочего задания.