Задержка C ++ увеличивается при ослаблении порядка памяти - PullRequest
4 голосов
/ 07 мая 2020

Я использую Windows 7 64-разрядную версию VS2013 (сборка x64 Release), экспериментируя с порядком памяти. Я хочу предоставить общий доступ к контейнеру с помощью максимально быстрой синхронизации. Я выбрал atomi c с функцией сравнения и замены.

Моя программа порождает два потока. Писатель подталкивает к вектору, и считыватель это обнаруживает. 340–380 циклов на операцию.

Чтобы попытаться улучшить производительность, я заставил магазины использовать memory_order_release, а нагрузки - memory_order_acquire.

Однако задержка увеличилась примерно до 1940 циклов на операцию.

Я что-то не понял? Полный код ниже.

По умолчанию memory_order_seq_cst:

#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<bool> _lock{ false };
std::vector<uint64_t> _vec;
std::atomic<uint64_t> _total{ 0 };
std::atomic<uint64_t> _counter{ 0 };
static const uint64_t LIMIT = 1000000;

void writer()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val))
        {
            _vec.push_back(__rdtsc());
            _lock = false;
        }
    }
}

void reader()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val))
        {
            if (_vec.empty() == false)
            {
                const uint64_t latency = __rdtsc() - _vec[0];
                _total += (latency);
                ++_counter;
                _vec.clear();
            }

            _lock = false;
        }
    }
}

int main()
{
    std::thread t1(writer);
    std::thread t2(reader);

    t2.detach();
    t1.join();

    std::cout << _total / _counter << " cycles per op" << std::endl;
}

Использование memory_order_acquire и memory_order_release:

void writer()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val, std::memory_order_acquire))
        {
            _vec.push_back(__rdtsc());
            _lock.store(false, std::memory_order_release);
        }
    }
}

void reader()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val, std::memory_order_acquire))
        {
            if (_vec.empty() == false)
            {
                const uint64_t latency = __rdtsc() - _vec[0];
                _total += (latency);
                ++_counter;
                _vec.clear();
            }

            _lock.store(false, std::memory_order_release);
        }
    }
}

1 Ответ

4 голосов
/ 07 мая 2020

У вас нет никакой защиты от потока, который снова берет блокировку сразу после ее освобождения, только чтобы найти _vec.empty() было не ложным, или сохранить другое значение TS C, перезаписав его этого никогда не видел читатель. Я подозреваю, что ваше изменение позволяет читателю тратить больше времени на блокировку писателя (и наоборот), что приводит к снижению реальной пропускной способности.

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

Повторное получение блокировки потоком если другой поток не получает очередь, это всегда бесполезно и тратит впустую работу, в отличие от многих реальных случаев, когда для заполнения или опустошения очереди требуется больше повторений. Это плохой алгоритм производителя-потребителя (слишком маленькая очередь (размер 1) и / или считыватель отбрасывает все элементы вектора после чтения vec[0]) и наихудшая из возможных схем блокировки для него.


_lock.store(false, seq_cst); компилируется в xchg вместо обычного mov хранилища. Он должен ждать, пока буфер хранилища истощится, и он просто медленный 1 (например, на Skylake, микрокодируется как 8 мопов, пропускная способность составляет один за 23 цикла для многих повторяющихся последовательных операций, в случай отсутствия конкуренции, когда в кеше L1d уже горячо. Вы ничего не указали о том, какое оборудование у вас есть).

_lock.store(false, std::memory_order_release); просто компилируется в обычное хранилище mov без дополнительных инструкций барьера . Таким образом, перезагрузка _counter может происходить параллельно с ним (хотя предсказание ветвления + спекулятивное выполнение не представляет проблемы). И что еще более важно, следующая попытка CAS взять блокировку может быть предпринята раньше.

Существует аппаратный арбитраж для доступа к строке кэша, когда несколько ядер работают с ней, по-видимому, с некоторой эвристикой справедливости, но я не Не знаю, известны ли подробности.

Сноска 1: xchg не так медленен, как mov + mfence на некоторых последних процессорах, особенно на процессорах, производных от Skylake. Это лучший способ реализовать чистое хранилище seq_cst на x86. Но он медленнее, чем обычный mov.


Вы можете полностью решить эту проблему, установив чередующийся писатель / считыватель с силой блокировки

Writer ждет false, а затем сохраняет true, когда это сделано. Читатель поступает наоборот. Таким образом, писатель никогда не сможет повторно войти в критическую секцию, если другой поток не успел по очереди. (Когда вы «ждете значения», делайте это только для чтения с загрузкой, а не с CAS. CAS на x86 требует исключительного владения строкой кэша, предотвращая чтение других потоков. Имея только один считыватель и один модуль записи, вы не нужны никакие atomi c RMW, чтобы это работало.)

Если у вас было несколько читателей и несколько писателей, у вас могла бы быть переменная syn c с 4 состояниями, где писатель пытается ее CAS от 0 до 1, после чего сохраняет 2. Читатели пытаются использовать CAS от 2 до 3, а затем сохраняют 0.

Случай SPS C (один производитель и один потребитель) прост:

enum lockstates { LK_WRITER=0, LK_READER=1, LK_EXIT=2 };
std::atomic<lockstates> shared_lock;
uint64_t shared_queue;  // single entry

uint64_t global_total{ 0 }, global_counter{ 0 };
static const uint64_t LIMIT = 1000000;

void writer()
{
    while(1) {
        enum lockstates lk;
        while ((lk = shared_lock.load(std::memory_order_acquire)) != LK_WRITER) {
                if (lk == LK_EXIT) 
                        return;
                else
                        SPIN;     // _mm_pause() or empty
        }

        //_vec.push_back(__rdtsc());
        shared_queue = __rdtsc();
        shared_lock.store(LK_READER, ORDER);   // seq_cst or release
    }
}

void reader()
{
    uint64_t total=0, counter=0;
    while(1) {
        enum lockstates lk;
        while ((lk = shared_lock.load(std::memory_order_acquire)) != LK_READER) {
                SPIN;       // _mm_pause() or empty
        }

        const uint64_t latency = __rdtsc() - shared_queue;  // _vec[0];
        //_vec.clear();
        total += latency;
        ++counter;
        if (counter < LIMIT) {
                shared_lock.store(LK_WRITER, ORDER);
        }else{
                break;  // must avoid storing a LK_WRITER right before LK_EXIT, otherwise writer races and can overwrite with LK_READER
        }
    }
    global_total = total;
    global_counter = counter;
    shared_lock.store(LK_EXIT, ORDER);
}

Полная версия на Godbolt . На моем рабочем столе Skylake i7-6700k (2-ядерный турбо = 4200 МГц, TS C = 4008 МГц), скомпилированный с помощью clang ++ 9.0.1 -O3. Как и ожидалось, данные довольно зашумлены; Я сделал несколько запусков и вручную выбрал нижнюю и верхнюю точки, игнорируя некоторые реальные выбросы, которые, вероятно, были вызваны эффектами разогрева.

На отдельных физических ядрах:

  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_release: от ~ 180 до ~ 210 циклов / операцию, в основном ноль machine_clears.memory_ordering (например, 19 всего за 1000000 операций, благодаря pause в циклическом ожидании l oop.)
  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_seq_cst: от ~ 195 до ~ 215 эталонных циклов / операцию, тот же прибор, близкий к нулю, очищается.
  • -DSPIN='' -DORDER=std::memory_order_release: от ~ 195 до ~ 225 эталонных циклов / операцию, от 9 до 10 Мвыб / сек c машина очищает без pause.
  • -DSPIN='' -DORDER=std::memory_order_seq_cst: более изменчивый и медленный, от ~ 250 до ~ 315 циклов / операцию, от 8 до 10 Мбит / с c машина очищается без pause

Эти тайминги примерно в 3 раза быстрее, чем ваш seq_cst "быстрый" оригинал в моей системе . Использование std::vector<> вместо скаляра может составлять ~ 4 цикла этого; Думаю, при замене был небольшой эффект. Хотя, может, просто случайный шум. 200 / 4,008 ГГц - это примерно 50 нс межъядерная задержка, что звучит примерно правильно для четырехъядерного «клиентского» чипа.

Из лучшей версии (mo_release, вращение на pause во избежание очистки компьютера):

$ clang++ -Wall -g -DSPIN='_mm_pause()' -DORDER=std::memory_order_release -O3 inter-thread.cpp -pthread && 
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r4 ./a.out
195 ref cycles per op. total ticks: 195973463 / 1000000 ops
189 ref cycles per op. total ticks: 189439761 / 1000000 ops
193 ref cycles per op. total ticks: 193271479 / 1000000 ops
198 ref cycles per op. total ticks: 198413469 / 1000000 ops

 Performance counter stats for './a.out' (4 runs):

            199.83 msec task-clock:u              #    1.985 CPUs utilized            ( +-  1.23% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               128      page-faults               #    0.643 K/sec                    ( +-  0.39% )
       825,876,682      cycles:u                  #    4.133 GHz                      ( +-  1.26% )
        10,680,088      branches:u                #   53.445 M/sec                    ( +-  0.66% )
        44,754,875      instructions:u            #    0.05  insn per cycle           ( +-  0.54% )
       106,208,704      uops_issued.any:u         #  531.491 M/sec                    ( +-  1.07% )
        78,593,440      uops_executed.thread:u    #  393.298 M/sec                    ( +-  0.60% )
                19      machine_clears.memory_ordering #    0.094 K/sec                    ( +-  3.36% )

           0.10067 +- 0.00123 seconds time elapsed  ( +-  1.22% )

И из худшей версии (mo_seq_cst, no pause): spin-wait l oop вращается быстрее, поэтому количество выданных / выполненных ветвей и мопов намного выше, но фактическая полезная пропускная способность несколько ниже хуже.

$ clang++ -Wall -g -DSPIN='' -DORDER=std::memory_order_seq_cst -O3 inter-thread.cpp -pthread && 
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r4 ./a.out
280 ref cycles per op. total ticks: 280529403 / 1000000 ops
215 ref cycles per op. total ticks: 215763699 / 1000000 ops
282 ref cycles per op. total ticks: 282170615 / 1000000 ops
174 ref cycles per op. total ticks: 174261685 / 1000000 ops

 Performance counter stats for './a.out' (4 runs):

            207.82 msec task-clock:u              #    1.985 CPUs utilized            ( +-  4.42% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               130      page-faults               #    0.623 K/sec                    ( +-  0.67% )
       857,989,286      cycles:u                  #    4.129 GHz                      ( +-  4.57% )
       236,364,970      branches:u                # 1137.362 M/sec                    ( +-  2.50% )
       630,960,629      instructions:u            #    0.74  insn per cycle           ( +-  2.75% )
       812,986,840      uops_issued.any:u         # 3912.003 M/sec                    ( +-  5.98% )
       637,070,771      uops_executed.thread:u    # 3065.514 M/sec                    ( +-  4.51% )
         1,565,106      machine_clears.memory_ordering #    7.531 M/sec                    ( +- 20.07% )

           0.10468 +- 0.00459 seconds time elapsed  ( +-  4.38% )

Прикрепление читателя и записывающего устройства к логическим ядрам одного физического ядра ускоряет его лот : в моей системе, ядра 3 и 7 являются братьями и сестрами, поэтому Linux taskset -c 3,7 ./a.out не позволяет ядру планировать их где-либо еще: от 33 до 39 циклов ссылок на операцию или от 80 до 82 без pause.

( Что будет использоваться для обмен данными между потоками выполняется на одном ядре с HT? ,)

$ clang++ -Wall -g -DSPIN='_mm_pause()' -DORDER=std::memory_order_release -O3 inter-thread.cpp -pthread && 
 taskset -c 3,7 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r4 ./a.out
39 ref cycles per op. total ticks: 39085983 / 1000000 ops
37 ref cycles per op. total ticks: 37279590 / 1000000 ops
36 ref cycles per op. total ticks: 36663809 / 1000000 ops
33 ref cycles per op. total ticks: 33546524 / 1000000 ops

 Performance counter stats for './a.out' (4 runs):

             89.10 msec task-clock:u              #    1.942 CPUs utilized            ( +-  1.77% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               128      page-faults               #    0.001 M/sec                    ( +-  0.45% )
       365,711,339      cycles:u                  #    4.104 GHz                      ( +-  1.66% )
         7,658,957      branches:u                #   85.958 M/sec                    ( +-  0.67% )
        34,693,352      instructions:u            #    0.09  insn per cycle           ( +-  0.53% )
        84,261,390      uops_issued.any:u         #  945.680 M/sec                    ( +-  0.45% )
        71,114,444      uops_executed.thread:u    #  798.130 M/sec                    ( +-  0.16% )
                16      machine_clears.memory_ordering #    0.182 K/sec                    ( +-  1.54% )

           0.04589 +- 0.00138 seconds time elapsed  ( +-  3.01% )

На логических ядрах, совместно использующих одно физическое ядро. В лучшем случае задержка примерно в 5 раз меньше, чем между ядрами, опять же для pause + mo_release. Но фактический эталонный тест завершается только в 40% случаев, а не в 20%.

  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_release: ~ от 33 до ~ 39 эталонных циклов / операцию , почти- ноль machine_clears.memory_ordering
  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_seq_cst: от ~ 111 до ~ 113 опорных циклов / операцию, всего 19 сбросов машины. На удивление худшее!
  • -DSPIN='' -DORDER=std::memory_order_release: от ~ 81 до ~ 84 эталонных циклов / операцию, ~ 12,5 млн очищений машины / сек c.
  • -DSPIN='' -DORDER=std::memory_order_seq_cst: от ~ 94 до ~ 96 c / op, 5 M / se c машина очищает без pause.

Все эти тесты выполняются с clang++, который использует xchg для хранения seq_cst. g++ использует mov + mfence, что медленнее в случаях pause, быстрее без pause и с меньшим количеством машинных сбросов. (Для случая гиперпоточности.) Обычно очень похоже на случай отдельных ядер с pause, но быстрее в случае отдельных ядер seq_cst без случая pause. (Опять же, специально на Skylake, для этого одного теста.)


Дополнительные исследования исходной версии:

Также стоит проверить счетчики производительности для machine_clears.memory_ordering ( Почему грипп sh конвейер для нарушения порядка памяти, вызванного другими логическими процессорами? ).

Я проверил свой Skylake i7-6700k, и существенной разницы не было со скоростью machine_clears.memory_ordering в секунду (около 5M / se c как для быстрого seq_cst, так и для медленного выпуска) при 4,2 ГГц. Результат «циклов на операцию» на удивление согласован для версии seq_cst (от 400 до 422). Опорная частота моего процессора TS C составляет 4008 МГц, фактическая частота ядра 4200 МГц при максимальном турбо. Я предполагаю, что максимальная турбо-частота вашего процессора выше по сравнению с его эталонной частотой, чем у меня, если у вас есть цикл 340-380. И / или другая микроархитектура.

Но я обнаружил дико разные результаты для версии mo_release: с GCC9.3.0 -O3 на Arch GNU / Linux: 5790 для одного пробег, 2269 для другого. С clang9.0.1 -O3 73346 и 7333 для двух прогонов, да действительно с коэффициентом 10). Это сюрприз. Ни одна из версий не выполняет системные вызовы для освобождения / выделения памяти при опустошении / выталкивании вектора, и я не вижу, чтобы машина упорядочивала много памяти из версии clang. С вашим исходным LIMIT два прогона с clang показали 1394 и 22101 цикл на операцию.

С clang ++ даже времена seq_cst меняются несколько больше, чем с G CC, и выше, например, от 630 до 700. (g ++ использует mov + mfence для чистых хранилищ seq_cst, clang ++ использует xchg, как MSV C).

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

Два запуска perf, первый - mo_release, второй - mo_seq_cst.

$ clang++ -DORDER=std::memory_order_release -O3 inter-thread.cpp -pthread &&
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r1 ./a.out
27989 cycles per op

 Performance counter stats for './a.out':

         16,350.66 msec task-clock:u              #    2.000 CPUs utilized          
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               231      page-faults               #    0.014 K/sec                  
    67,412,606,699      cycles:u                  #    4.123 GHz                    
       697,024,141      branches:u                #   42.630 M/sec                  
     3,090,238,185      instructions:u            #    0.05  insn per cycle         
    35,317,247,745      uops_issued.any:u         # 2159.989 M/sec                  
    17,580,390,316      uops_executed.thread:u    # 1075.210 M/sec                  
       125,365,500      machine_clears.memory_ordering #    7.667 M/sec                  

       8.176141807 seconds time elapsed

      16.342571000 seconds user
       0.000000000 seconds sys


$ clang++ -DORDER=std::memory_order_seq_cst -O3 inter-thread.cpp -pthread &&
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r1 ./a.out
779 cycles per op

 Performance counter stats for './a.out':

            875.59 msec task-clock:u              #    1.996 CPUs utilized          
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               137      page-faults               #    0.156 K/sec                  
     3,619,660,607      cycles:u                  #    4.134 GHz                    
        28,100,896      branches:u                #   32.094 M/sec                  
       114,893,965      instructions:u            #    0.03  insn per cycle         
     1,956,774,777      uops_issued.any:u         # 2234.806 M/sec                  
     1,030,510,882      uops_executed.thread:u    # 1176.932 M/sec                  
         8,869,793      machine_clears.memory_ordering #   10.130 M/sec                  

       0.438589812 seconds time elapsed

       0.875432000 seconds user
       0.000000000 seconds sys

Я изменил ваш код с порядком памяти как макрос CPP, поэтому вы можете скомпилировать с помощью -DORDER=std::memory_order_release, чтобы получить медленную версию.
acquire vs. seq_cst здесь не имеет значения; он компилируется в тот же asm на x86 для нагрузок и atomi c RMW. Только чистые хранилища нуждаются в специальном asm для seq_cst.

Также вы исключили stdint.h и intrin.h (MSV C) / x86intrin.h (все остальное). Фиксированная версия - на Godbolt с clang и MSV C. Раньше я увеличил LIMIT в 10 раз, чтобы убедиться, что частота ЦП успевает подняться до максимального значения в турбо-режиме большую часть временного диапазона, но отменил это изменение, поэтому тестирование mo_release заняло бы только секунды, а не минуты.

Установка LIMIT для проверки определенного общего количества циклов TS C может помочь выйти в более согласованное время . Это все еще не учитывает время, когда писатель заблокирован, но в целом должен выполняться, который занимает очень много времени с меньшей вероятностью.


У вас также много очень чрезмерных сложные вещи происходят, если вы просто пытаетесь измерить задержку между потоками.

( Как происходит связь между ЦП? )

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

У вас также есть приращение atomi c RMW _counter в считывателе, даже если эта переменная является частной для читатель. Это может быть обычная глобальная переменная c, которую вы читаете после reader.join(), или, что еще лучше, локальная переменная, которую вы сохраняете только в глобальной после l oop. (Простой не-atomi c global, вероятно, все равно будет сохраняться в памяти на каждой итерации, а не храниться в регистре, из-за хранилищ релизов. И поскольку это крошечная программа, все глобальные переменные, вероятно, находятся рядом с каждым другое, и, вероятно, в той же строке кэша.)

std::vector также не требуется . __rdtsc() не будет равным нулю, если он не оборачивается вокруг 64-битного счетчика 1 , поэтому вы можете просто использовать 0 в качестве контрольного значения в скаляре uint64_t, что означает пустой. Или, если вы исправите блокировку, чтобы считыватель не мог повторно войти в критическую секцию без поворота писателя, вы можете удалить эту проверку.

Сноска 2: Для эталонной частоты TS ~ 4 ГГц C , это 2 ^ 64/10 ^ 9 секунд, что достаточно близко к 2 ^ 32 секундам ~ = 136 годам, чтобы обернуть TS C. Обратите внимание, что эталонная частота TS C равна не текущей тактовой частоте ядра; это фиксированное значение для данного процессора. Обычно близка к номинальной частоте «стикера», а не к максимальному турбо.


Кроме того, имена с начальным _ зарезервированы в глобальной области видимости в ISO C ++. Не используйте их для собственных переменных. (И, как правило, нигде. Вы можете использовать вместо этого завершающий символ подчеркивания, если хотите.)

...