Статьи

Inter Thread Latency

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

В последнее время меня много спрашивали о том, насколько быстрее Disruptorбыло бы, если бы C ++ использовался вместо Java. Наверняка C ++ обеспечит больший контроль за выравниванием памяти и потенциальным доступом к базовым инструкциям ЦП, таким как барьеры памяти и инструкции блокировки. В этой статье я буду непосредственно сравнивать C ++ и Java, чтобы измерить стоимость сигнализации об изменении между потоками

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

Для Java мы будем использовать изменчивые счетчики, которые JVM будет любезно вставлять инструкцию блокировки для обновления, что дает нам эффективный барьер памяти.

public final class InterThreadLatency
    implements Runnable
{
    public static final long ITERATIONS = 500L * 1000L * 1000L;

    public static volatile long s1;
    public static volatile long s2;

    public static void main(final String[] args)
    {
        Thread t = new Thread(new InterThreadLatency());
        t.setDaemon(true);
        t.start();

        long start = System.nanoTime();

        long value = s1;
        while (s1 < ITERATIONS)
        {
            while (s2 != value)
            {
                // busy spin
            }
            value = ++s1;
        }

        long duration = System.nanoTime() - start;

        System.out.println("duration = " + duration);
        System.out.println("ns per op = " + duration / (ITERATIONS * 2));
        System.out.println("op/sec = " + 
            (ITERATIONS * 2L * 1000L * 1000L * 1000L) / duration);
        System.out.println("s1 = " + s1 + ", s2 = " + s2);
    }

    public void run()
    {
        long value = s2;
        while (true)
        {
            while (value == s1)
            {
                // busy spin
            }
            value = ++s2;
        }
    }
}

Для C ++ мы будем использовать GNU Atomic Builtins, которые дают нам вставку инструкций блокировки, аналогичную той, которую использует JVM.

#include <time.h>
#include <pthread.h>
#include <stdio.h>

typedef unsigned long long uint64;
const uint64 ITERATIONS = 500LL * 1000LL * 1000LL;

volatile uint64 s1 = 0;
volatile uint64 s2 = 0;

void* run(void*)
{
    register uint64 value = s2;
    while (true)
    {
        while (value == s1)
        {
            // busy spin
        }
        value = __sync_add_and_fetch(&s2, 1);
    }
}

int main (int argc, char *argv[])
{
    pthread_t threads[1];
    pthread_create(&threads[0], NULL, run, NULL);

    timespec ts_start;
    timespec ts_finish;
    clock_gettime(CLOCK_MONOTONIC, &ts_start);

    register uint64 value = s1;
    while (s1 < ITERATIONS)
    {
        while (s2 != value)
        {
            // busy spin
        }
        value = __sync_add_and_fetch(&s1, 1);
    }

    clock_gettime(CLOCK_MONOTONIC, &ts_finish);

    uint64 start = (ts_start.tv_sec * 1000000000LL) + ts_start.tv_nsec;
    uint64 finish = (ts_finish.tv_sec * 1000000000LL) + ts_finish.tv_nsec;
    uint64 duration = finish - start;

    printf("duration = %lld\n", duration);
    printf("ns per op = %lld\n", (duration / (ITERATIONS * 2)));
    printf("op/sec = %lld\n", 
        ((ITERATIONS * 2L * 1000L * 1000L * 1000L) / duration));
    printf("s1 = %lld, s2 = %lld\n", s1, s2);

    return 0;
}

Полученные результаты

$ taskset -c 2,4 /opt/jdk1.7.0/bin/java InterThreadLatency
duration = 50790271150
ns per op = 50
op/sec = 19,688,810
s1 = 500000000, s2 = 500000000

$ g++ -O3 -lpthread -lrt -o itl itl.cpp
$ taskset -c 2,4 ./itl
duration = 45087955393
ns per op = 45
op/sec = 22,178,872
s1 = 500000000, s2 = 500000000

Версия C ++ немного быстрее на моем ноутбуке Intel Sandybridge. Так что это говорит нам? Хорошо, что задержка между двумя ядрами на машине с частотой 2,2 ГГц составляет ~ 45 нс, и что вы можете обмениваться 22 миллионами сообщений в секунду последовательным способом. На процессоре Intel это, в основном, стоимость инструкции блокировки, обеспечивающей полный порядок и принуждающей к истощению буфер хранения и буферы объединения записи с последующим трафиком когерентности кэша между ядрами. Обратите внимание, что каждое ядро ​​имеет порт 96 ГБ / с на шину кольцевого кэша L3, но 22 м * 64 байта — только 1,4 ГБ / с. Это потому, что мы измерили задержку, а не пропускную способность. Мы могли бы легко разместить несколько хороших жирных сообщений между этими барьерами памяти как часть обмена, если данные были записаны до того, как была выполнена инструкция блокировки.

Так что же все это значит для Disruptor? По сути, задержка Disruptor настолько низка, насколько мы можем получить от Java. Можно было бы получить увеличение задержки примерно на 10%, перейдя на C ++. Я ожидаю аналогичного улучшения пропускной способности для C ++. Главной победой в C ++ будет контроль, а следовательно, и предсказуемость, которая приходит с ним, если используется правильно JVM дает нам хорошие функции безопасности, такие как сборка мусора в сложных приложениях, но мы платим за это немного с помощью дополнительных инструкций, которые он вставляет, что можно увидеть, если вы получите Hotspot для вывода инструкций ассемблера, которые он генерирует.

Как Disruptor достигает более 25 м сообщений в секунду, как я слышал, вы говорите ??? Ну, это одна из аккуратных частей его дизайна. В « Waitfor » семантика наDependencyBarrier обеспечивает очень эффективную форму пакетирования, которая позволяет BatchEventProcessor обрабатывать серию событий, которые произошли с момента последней регистрации в RingBuffer , и все это без создания барьера памяти. Для реальных приложений этот эффект пакетирования действительно важен. Это только делает результаты более случайными в микро-бенчмарке, где мало что сделано, кроме обработки сообщения.

Вывод

Таким образом, при последовательной обработке событий измерения говорят нам, что текущее поколение процессоров может выполнять от 20 до 30 миллионов обменов в секунду с задержкой менее 50 нс. Конструкция Disruptor позволяет нам получать большую пропускную способность без явного пакетирования на стороне издателя. Кроме того, Disruptor имеет явный пакетный API на стороне издателя, который может выдавать более 100 миллионов сообщений в секунду. 

От http://mechanical-sympathy.blogspot.com/2011/08/inter-thread-latency.html