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

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

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

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

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

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

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

4b9b3361

Ответ 1

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

#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;
}

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

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 - размер внутреннего цикла (вопросы для кеша)
  • LOOPS, ну...

Здесь создаются петли:

Потребительский поток

  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, cpu2 Core#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

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

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

С производителем на CPU # 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
  • Когда мы планируем оба потока в одном потоке одного и того же ядра, нет никакого эффекта. Ожидаемый снова, поскольку производитель пишет, остается локальным, не требуя затрат на синхронизацию.

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

Ответ 2

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

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

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

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

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

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

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

Тот же сердечник HT

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

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

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

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

Таким образом, для каждого чтения общего значения потребитель reset.

Заключение

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

Что произошло бы, если бы ЦП ожидал увидеть, действительно ли значение изменилось?

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

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

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

Подробнее здесь