Статьи

Дальнейшие приключения с инструкциями CAS и микро-бенчмаркинг

 В предыдущей статье я сообщал о проблемах с производительностью инструкций 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, при сопоставимых тактовых частотах.

Заключение

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

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