Каковы затраты времени ожидания и пропускной способности совместного использования производителем и потребителем места в памяти между гипер-братьями и сестрами и не-гипер-братьями?

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

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

Здесь меня интересуют издержки производства-потребителя, где единый поток P выполняет запись в область памяти, а другой поток `C читает из области памяти, используя обычные операции чтения и записи.

Какова задержка и пропускная способность такой операции, когда она выполняется на отдельных ядрах в одном сокете, и в сравнении, когда она выполняется на одноядерных гиперпотоках на одном физическом ядре, на последних ядрах x86.

В названии я использую термин "гипер-братья и сестры" для обозначения двух потоков, работающих на двух логических потоках одного и того же ядра, и межъядерные братья и сестры для обозначения более обычного случая двух потоков, работающих на разных физических ядрах.,

2 ответа

Ладно, я не смог найти никакого авторитетного источника, поэтому решил, что сам попробую.

#include <pthread.h>
#include <sched.h>
#include <atomic>
#include <cstdint>
#include <iostream>


alignas(128) static uint64_t data[SIZE];
alignas(128) static std::atomic<unsigned> shared;
#ifdef EMPTY_PRODUCER
alignas(128) std::atomic<unsigned> unshared;
#endif
alignas(128) static std::atomic<bool> stop_producer;
alignas(128) static std::atomic<uint64_t> elapsed;

static inline uint64_t rdtsc()
{
    unsigned int l, h;
    __asm__ __volatile__ (
        "rdtsc"
        : "=a" (l), "=d" (h)
    );
    return ((uint64_t)h << 32) | l;
}

static void * consume(void *)
{
    uint64_t    value = 0;
    uint64_t    start = rdtsc();

    for (unsigned n = 0; n < LOOPS; ++n) {
        for (unsigned idx = 0; idx < SIZE; ++idx) {
            value += data[idx] + shared.load(std::memory_order_relaxed);
        }
    }

    elapsed = rdtsc() - start;
    return reinterpret_cast<void*>(value);
}

static void * produce(void *)
{
    do {
#ifdef EMPTY_PRODUCER
        unshared.store(0, std::memory_order_relaxed);
#else
        shared.store(0, std::memory_order_relaxed);
#enfid
    } while (!stop_producer);
    return nullptr;
}



int main()
{
    pthread_t consumerId, producerId;
    pthread_attr_t consumerAttrs, producerAttrs;
    cpu_set_t cpuset;

    for (unsigned idx = 0; idx < SIZE; ++idx) { data[idx] = 1; }
    shared = 0;
    stop_producer = false;

    pthread_attr_init(&consumerAttrs);
    CPU_ZERO(&cpuset);
    CPU_SET(CONSUMER_CPU, &cpuset);
    pthread_attr_setaffinity_np(&consumerAttrs, sizeof(cpuset), &cpuset);

    pthread_attr_init(&producerAttrs);
    CPU_ZERO(&cpuset);
    CPU_SET(PRODUCER_CPU, &cpuset);
    pthread_attr_setaffinity_np(&producerAttrs, sizeof(cpuset), &cpuset);

    pthread_create(&consumerId, &consumerAttrs, consume, NULL);
    pthread_create(&producerId, &producerAttrs, produce, NULL);

    pthread_attr_destroy(&consumerAttrs);
    pthread_attr_destroy(&producerAttrs);

    pthread_join(consumerId, NULL);
    stop_producer = true;
    pthread_join(producerId, NULL);

    std::cout <<"Elapsed cycles: " <<elapsed <<std::endl;
    return 0;
}

Скомпилируйте следующую команду, заменив определяет:

gcc -std=c++11 -DCONSUMER_CPU=3 -DPRODUCER_CPU=0 -DSIZE=131072 -DLOOPS=8000 timing.cxx -lstdc++ -lpthread -O2 -o timing

Куда:

  • CONSUMER_CPU - это номер процессора, на котором запускается потребительский поток.
  • PRODUCER_CPU - это номер процессора, на котором запускается поток производителя.
  • SIZE - размер внутреннего цикла (имеет значение для кеша)
  • Петли есть, хорошо...

Вот сгенерированные циклы:

Потребительская нить

  400cc8:       ba 80 24 60 00          mov    $0x602480,%edx
  400ccd:       0f 1f 00                nopl   (%rax)
  400cd0:       8b 05 2a 17 20 00       mov    0x20172a(%rip),%eax        # 602400 <shared>
  400cd6:       48 83 c2 08             add    $0x8,%rdx
  400cda:       48 03 42 f8             add    -0x8(%rdx),%rax
  400cde:       48 01 c1                add    %rax,%rcx
  400ce1:       48 81 fa 80 24 70 00    cmp    $0x702480,%rdx
  400ce8:       75 e6                   jne    400cd0 <_ZL7consumePv+0x20>
  400cea:       83 ee 01                sub    $0x1,%esi
  400ced:       75 d9                   jne    400cc8 <_ZL7consumePv+0x18>

Поток производителя, с пустым циклом (без записи в shared ):

  400c90:       c7 05 e6 16 20 00 00    movl   $0x0,0x2016e6(%rip)        # 602380 <unshared>
  400c97:       00 00 00 
  400c9a:       0f b6 05 5f 16 20 00    movzbl 0x20165f(%rip),%eax        # 602300 <stop_producer>
  400ca1:       84 c0                   test   %al,%al
  400ca3:       74 eb                   je     400c90 <_ZL7producePv>

Автор темы, пишу shared :

  400c90:       c7 05 66 17 20 00 00    movl   $0x0,0x201766(%rip)        # 602400 <shared>
  400c97:       00 00 00 
  400c9a:       0f b6 05 5f 16 20 00    movzbl 0x20165f(%rip),%eax        # 602300 <stop_producer>
  400ca1:       84 c0                   test   %al,%al
  400ca3:       74 eb                   je     400c90 <_ZL7producePv>

Программа подсчитывает количество циклов ЦП, потребляемых ядром потребителя для завершения всего цикла. Мы сравниваем первого производителя, который ничего не делает, кроме записи циклов ЦП, со вторым производителем, который нарушает работу потребителя путем повторной записи в shared,

Моя система имеет i5-4210U. То есть 2 ядра, 2 потока на ядро. Они выставлены ядром как Core#1 → cpu0, cpu2Core#2 → cpu1, cpu3,

Результат без запуска производителя вообще:

CONSUMER    PRODUCER     cycles for 1M      cycles for 128k
    3          n/a           2.11G              1.80G

Результаты с пустым производителем. Для операций 1G (1000*1M или 8000*128k).

CONSUMER    PRODUCER     cycles for 1M      cycles for 128k
    3           3            3.20G              3.26G       # mono
    3           2            2.10G              1.80G       # other core
    3           1            4.18G              3.24G       # same core, HT

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

При использовании производителя на процессоре №2, поскольку взаимодействие отсутствует, потребитель работает без влияния производителя, работающего на другом процессоре.

С производителем на процессоре #1 мы видим гиперпоточность на работе.

Результаты с подрывным производителем:

CONSUMER    PRODUCER     cycles for 1M      cycles for 128k
    3           3            4.26G              3.24G       # mono
    3           2           22.1 G             19.2 G       # other core
    3           1           36.9 G             37.1 G       # same core, HT
  • Когда мы планируем оба потока в одном потоке одного и того же ядра, это не влияет. Опять же, как ожидается, записи производителя остаются локальными, без затрат на синхронизацию.

  • Я не могу действительно объяснить, почему у меня гораздо хуже производительность для гиперпоточности, чем для двух ядер. Совет приветствуется.

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

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

Другой основной пример

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

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

  1. Теперь производитель хочет выполнить запись в совместно используемые данные и отправляет запрос об исключительном владении строкой кэша.
  2. Потребитель получает свою строку кэша, все еще находящуюся в общем состоянии, и с радостью считывает значение.
  3. Потребитель продолжает считывать общее значение до тех пор, пока не поступит эксклюзивный запрос.
  4. В этот момент потребитель отправляет общий запрос для строки кэша.
  5. На этом этапе потребитель очищает свои инструкции от первой невыполненной инструкции по загрузке общего значения.
  6. Пока Потребитель ждет данных, он спекулятивно опережает их.

Таким образом, Потребитель может продвинуться в период между тем, как он получит свою линию общего кэша, пока он снова не будет аннулирован. Неясно, сколько операций чтения может быть выполнено одновременно, скорее всего, 2, поскольку ЦП имеет 2 порта чтения. И, к счастью, нет необходимости перезапускать их, как только внутреннее состояние процессора будет удовлетворено, и они не смогут выйти из строя между ними.

То же ядро ​​HT

Здесь два HT разделяют ядро ​​и должны делиться своими ресурсами.

Строка кэша должна оставаться в исключительном состоянии все время, поскольку они совместно используют кэш и, следовательно, не нуждаются в протоколе кэша.

Теперь, почему на ядре HT так много циклов? Начнем с того, что Потребитель просто прочитал общее значение.

  1. Следующий цикл записи из Produces происходит.
  2. Поток потребителя обнаруживает запись и отменяет все свои инструкции с первого невыполненного чтения.
  3. Потребитель повторно выдает свои инструкции, выполняя ~5-14 циклов для повторного запуска.
  4. Наконец, первая инструкция, которая представляет собой чтение, выдается и выполняется, так как она не считала спекулятивное значение, а является правильной, поскольку она находится перед очередью.

Таким образом, для каждого чтения общего значения Потребитель сбрасывается.

Заключение

Разное ядро, по-видимому, каждый раз продвигается вперед между каждым пинг-понгом кеша, что работает лучше, чем HT.

Что бы произошло, если бы процессор ожидал, изменилось ли значение на самом деле?

Для тестового кода HT-версия работала бы намного быстрее, возможно, даже так же быстро, как и версия для приватной записи. Разное ядро ​​не работало бы быстрее, поскольку пропуск кеша покрывал задержку переиздания.

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

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

Узнайте больше здесь

Другие вопросы по тегам