Оптимизация кольцевого буфера для повышения пропускной способности

В этой статье мы рассмотрим классический конкурентный кольцевой буфер и обсудим, как его можно оптимизировать для повышения производительности. Я покажу вам, как существенно улучшить этот показатель от 5,5 миллионов элементов в секунду до 112 миллионов элементов в секунду — и эти показатели выше, чем в реализациях Boost и Folly. Если вам требуется готовая реализация со всеми этими оптимизациями, посмотрите мою библиотеку SPSCQueue.h.

Кольцевой буфер также называется очередью «один производитель — один потребитель» (SPSC). В ней не бывает ожидания (и, соответственно, не бывает блокировок), это конкурентный примитив. Такая структура данных находит множество вариантов применения, и здесь я рассмотрю передачу сетевых пакетов между сетевым контроллером и драйверами операционной системы. Основная задача, решаемая при этом — выполнение событий ввода/вывода в относительно новом асинхронном API io_uring.

❯ Классический кольцевой буфер

Сначала давайте реализуем простой кольцевой буфер. В C++ его можно определить примерно так:

struct ringbuffer {
  std::vector<int> data_;
  alignas(64) std::atomic<size_t> readIdx_{0};
  alignas(64) std::atomic<size_t> writeIdx_{0};

  ringbuffer(size_t capacity) : data_(capacity, 0) {}
}

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

Также важно отметить, что индексы чтения (readIdx_) и записи (writeIdx_) выравниваются по размеру кэш-линии (alignas(64)). Это делается для уменьшения трафика, связанного с обеспечением когерентности кэша. На AMD64 / x86_64 и ARM размер кэш-линии составляет 64 байта, на других ЦП вам придётся самостоятельно подбирать подходящее выравнивание. Пользуйтесь для этого std::hardware_destructive_interference_size, если есть такая возможность. Может быть интересно подобрать такой вариант выравнивания, который удовлетворял бы сразу нескольким длинам кэш-линий — это пригодится на случай предвыборки смежных линий.

Ситуация сильно напоминает определение собственного кольцевого буфера io_uring в ядре Linux:

struct io_uring {
  u32 head ____cacheline_aligned_in_smp;
  u32 tail ____cacheline_aligned_in_smp;
};

boost::lockfree::spsc также определяет кольцевой буфер примерно таким же образом.

Теперь можно реализовать операцию проталкивания в стек (или записи):

bool push(int val) {
  auto const writeIdx = writeIdx_.load(std::memory_order_relaxed);
  auto nextWriteIdx = writeIdx + 1;
  if (nextWriteIdx == data_.size()) {
    nextWriteIdx = 0;
  }
  if (nextWriteIdx == readIdx_.load(std::memory_order_acquire)) {
    return false;
  }
  data_[writeIdx] = val;
  writeIdx_.store(nextWriteIdx, std::memory_order_release);
  return true;
}

Обратите внимание: один элемент остался неиспользуемым, тем самым мы отмечаем, что очередь полна. Когда writeIdx_ на один элемент отстаёт от readIdx_, очередь точно полна.

Далее реализуем операцию выталкивания из стека (чтения):

bool pop(int &val) {
  auto const readIdx = readIdx_.load(std::memory_order_relaxed);
  if (readIdx == writeIdx_.load(std::memory_order_acquire)) {
    return false;
  }
  val = data_[readIdx];
  auto nextReadIdx = readIdx + 1;
  if (nextReadIdx == data_.size()) {
    nextReadIdx = 0;
  }
  readIdx_.store(nextReadIdx, std::memory_order_release);
  return true;
}

Опять же, отметим: readIdx_ == writeIdx_ означает, что очередь пуста.

Я специально подобрал для элемента очень небольшой размер (sizeof(int) == 4), поскольку при работе с более крупными элементами память будет тратиться в основном на их копирование, а мы не стремимся здесь измерить производительность memcpy(). Разумеется, это также означает, что, если вам приходится оперировать сравнительно крупными элементами, то вы особенно не выиграете от оптимизации кольцевого буфера.

Теперь давайте проанализируем производительность этой очереди. Я написал простой бенчмарк ringbuffer.cpp, проталкивающий 100M элементов между 2 потоками через кольцевой буфер размером 100k. Мы измеряем, сколько времени уйдёт у читающего потока на обработку всех элементов. Скомпилируем ringbuffer.cpp следующим образом:

 g++ -Wall -O3 -march=native -std=c++20 ringbuffer.cpp

Я выполнил эту операцию на 12-ядерном процессоре AMD Ryzen 9 3900X, разместив два потока на разных чиплетах/ядерных комплексах (CCX):

$ perf stat -e cache-misses,cache-references,l2_request_g1.change_to_x ./a.out 1 11
5513850 ops/s

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

       349,857,603      cache-misses:u            #   91.228 % of all cache refs    
       383,497,078      cache-references:u                                          
         6,673,242      l2_request_g1.change_to_x:u                                   

      18.137421039 seconds time elapsed

      36.140630000 seconds user
       0.002986000 seconds sys

Как видите, здесь достигнута пропускная способность 5.5M элементов в секунду. Вполне согласуется с данными о производительности таких библиотек как boost::lockfree::spsc и folly::ProducerConsumerQueue. Количество промахов кэша (~300M) кажется пропорциональным (трёхкратным) количеству обработанных нами элементов (100M).

❯ Оптимизированный кольцевой буфер

Почему у нас получается по 3 кэш-промаха на каждую пару операций чтения и записи? Рассмотрим операцию чтения: требуется обновить индекс чтения и, следовательно, кэш-линия загружается в кэш L1 в исключительном состоянии (см. протокол MESI). Индекс записи нужно читать по порядку, чтобы убедиться, что очередь не пуста. Поэтому данный индекс загружается в кэш L1 в разделяемом состоянии. Поскольку при операции записи в очередь требуется читать индекс операций чтения, линия этого индекса должна быть вытеснена из кэша или перейти в разделяемое состояние. Теперь при операции чтения нам потребуется потратить часть трафика на обеспечение когерентности кэша, чтобы кэш-линия индекса чтения вернулась в исключительное состояние. В свою очередь, придётся дополнительно обеспечивать когерентность кэша и для операции записи, чтобы кэш-линия индекса записи вернулась в исключительное состояние. В худшем случае получится по одному переходу на новую кэш-линию при изменении состояния с исключительного на разделяемое и обратно. Именно такие смены состояния и учитываются как промахи кэша. Точные детали реализации протокола когерентности кэша неизвестны, но в целом мы работаем примерно по протоколу MESI.

Чтобы сократить этот трафик, затрачиваемый на обеспечение когерентности, читающий и записывающий потоки могут хранить по одной кэшированной копии индекса записи и индекса чтения соответственно. В таком случае, как только читающий заметит, что доступно N элементов для чтения, он кэширует эту информацию, и поэтому при N-1 последующих операциях чтения не требуется обращаться к индексу записи. Аналогично, как только записывающий заметит, что доступно N элементов для записи, он кэширует эту информацию, и поэтому при N-1 последующих операциях записи не требуется обращаться к индексу чтения.

Новый кольцевой буфер будет определяться так:

struct ringbuffer2 {
  std::vector<int> data_{};
  alignas(64) std::atomic<size_t> readIdx_{0};
  alignas(64) size_t writeIdxCached_{0};
  alignas(64) std::atomic<size_t> writeIdx_{0};
  alignas(64) size_t readIdxCached_{0};

  ringbuffer2(size_t capacity) : data_(capacity, 0) {}
}

Операция проталкивания обновляется так, чтобы код сначала сверился с кэшированным индексом чтения (readIdxCached_) и, если она не удастся, попытался повторить её после того, как обновит кэш:

if (nextWriteIdx == readIdxCached_) {
  readIdxCached_ = readIdx_.load(std::memory_order_acquire);
  if (nextWriteIdx == readIdxCached_) {
    return false;
  }
}

Операция выталкивания обновляется схожим образом: сначала код сверяется с кэшированным индексом записи (writeIdxCached_):

if (readIdx == writeIdxCached_) {
  writeIdxCached_ = writeIdx_.load(std::memory_order_acquire);
  if (readIdx == writeIdxCached_) {
    return false;
  }
}

Вот что получится, если повторно прогнать тот же самый бенчмарк с новой реализацией кольцевого буфера:

$ perf stat -e cache-misses,cache-references,l2_request_g1.change_to_x ./a.out 1 11
112287037 ops/s

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

        15,010,392      cache-misses:u            #   32.553 % of all cache refs    
        46,110,663      cache-references:u                                          
         6,781,273      l2_request_g1.change_to_x:u                                   

       0.892256380 seconds time elapsed

       1.775185000 seconds user
       0.000996000 seconds sys

Просто супер! Теперь пропускная способность составляет 112M элементов в секунду, а количество промахов кэша существенно сократилось. Если хотите убедиться в этом сами — посмотрите файл ringbuffer.cpp.

❯ Дальнейшие оптимизации

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

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


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud  в нашем Telegram-канале 

Перейти ↩
Перейти

📚 Читайте также:

@Albert_Wesker
28.12.2024 15:10 UTC
Первоисточник

Комментарии

@Biga
28.12.2024 14:09 UTC
0

А если использовать readIdx_.load(std::memory_order_relaxed) вместо readIdxCached_ , это будет не то же самое? В чём разница?

@nin-jin
28.12.2024 22:14 UTC
0

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

29.12.2024 10:43 UTC
0

На x86 да, а вот, например, на arm упс получится

29.12.2024 14:37 UTC
0

Почему это?

29.12.2024 20:49 UTC
0

Небольшая выдержка про порядок Release-Acquire.

Если atomic store в потоке A помечен как memory_order_release, atomic load в потоке B из той же переменной помечен как memory_order_acquire, а load в потоке B считывает значение, записанное store в потоке A, то store в потоке A synchronizes-with (синхронизируется с) load в ​​потоке B.

Все записи в память (включая неатомарные и атомарные с memory_order_relaxed), которые happened‑before (произошли до) atomic store с точки зрения потока A, становятся видимыми побочными эффектами в потоке B. То есть, как только atomic load завершен, поток B гарантированно увидит все, что поток A записал в память. Это обещание выполняется только в том случае, если B фактически получает значение, которое A сохранил (или значение из более поздней последовательности записи с memory_order_release).

Таким образом не достаточно установить только memory_order_release при записи в атомик, нужно еще и прочитать из него с memory_order_acquire.

29.12.2024 22:33 UTC
+4

Искусство писать простые вещи сложным языком... Написали бы просто: чтения после acquire гарантированно увидят все записи до release, без барьеров на каждом из потоков порядок видимости случаен. Подробную доку нашёл тут: https://lwn.net/Articles/576489/

@kyberduk
29.12.2024 17:31 UTC
+1

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

При чтении из readIdx_ придется вытеснить в память значение из кеш линии ядра, в котором последний раз проводилась модификация, попутно сбросив состояние кеш линии в shared.

А при записи в readIdx_ придется инвалидировать все кеш линиию в ядре, которое последним читало из этой памяти.

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

С протоколом MESI можно поиграться здесь https://www.scss.tcd.ie/Jeremy.Jones/vivio/caches/MESIHelp.htm

@nv13
28.12.2024 21:46 UTC
0

"Разумеется, это также означает, что, если вам приходится оперировать сравнительно крупными элементами, то вы особенно не выиграете от оптимизации кольцевого буфера"

  • на этом можно было бы и закончить с оптимизацией, имхо.

@nin-jin
28.12.2024 22:07 UTC
+2

Передаём через буфер не сам объект, а исключительную ссылку на него, и всё.

29.12.2024 05:05 UTC
0

И зачем нужно, чтобы ссылки кешировались? Это будет влиять на производительность? Просто я видел использование кольцевых буферов в драйверах Ethernet и использовал из в драйверах АЦП, везде стат аллокация памяти и DMA. С boost не работал. Поэтому и не очень понимаю о чём речь, хотя идея и понятна

29.12.2024 07:29 UTC
+1

Тут я уже не понял вашего вопроса.

29.12.2024 08:49 UTC
+1

Я не очень понял реальные юзкейсы, где может потребоваться использование циклических буферов такого типа. Буфер под ссылки с кешированием - тоже не очень буфер, имхо. Если есть короткая ссылка куда нибудь буду рад познакомиться

29.12.2024 09:10 UTC
+3

Эти кейсы из библиотек биржевого трейдига - цены гонять в busy loop с минимальной задержкой. Тут выигрыш может быть даже от того, что кэш-линия последовательно пишется.
А для выравнивая лучше брать двойной размер (2x64=128) префетчер все равно две за раз прочтет при запросе (текущую, и следующую за ней), и соседнюю - лучше не "марать".

29.12.2024 09:57 UTC
0

Понял, спасибо, плюсы не ставятся)

@
29.12.2024 11:58 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
@nin-jin
29.12.2024 15:04 UTC
0

Что такое распределённый кольцевой буфер?

29.12.2024 15:28 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
29.12.2024 15:51 UTC
+1

Это противоречит смыслу такого буфера, как места, где читатель и писатель не мешают друг другу. Но можно из нескольких таких буферов сделать коммуникацию многие ко многим. Например: https://github.com/nin-jin/go.d

29.12.2024 23:26 UTC
0

Потестил, кстати, на i5-8300H CPU @ 2.30GHz, переливание и сложение 160 миллионов интов занимает как раз секунду.

Код
import std.datetime.stopwatch;
import std.range;
import std.stdio;
import std.algorithm;

import jin.go;

enum int iterations = 160_000_000;

static auto produce()
{
	return iterations.iota;
}

void main()
{
	auto timer = StopWatch(AutoStart.yes);

	int sum = go!produce.fold!q{a+b};

	timer.stop();

	writeln("Result\t\tTime");
	writeln(sum, "\t", timer.peek.total!"msecs", " ms");

}

@aptahar
29.12.2024 17:31 UTC
0

Кольцевой буфер также называется очередью «один производитель — один потребитель» (SPSC). В ней не бывает ожидания (и, соответственно, не бывает блокировок)

Разве переполнение буфера не вызовет ожидание на запись и соответсвующую блокировку?

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

@nin-jin
29.12.2024 19:40 UTC
0

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

@LeonidPr
29.12.2024 19:41 UTC
0

У меня давний вопрос по поводу кольцевого буфера.

Я правильно понимаю, что записывать может один поток, а читать другой поток, при этом они могут выполняться на разных ядрах и это не создаст гонки (при условии атомарности операций с указателями чтения и записи)?

И еще, не будет ли гонки, когда мы опустошим буфер или наоборот заполним (т.е. когда указатель записи упрется в указатель чтения и наоборот)?

@kyberduk
29.12.2024 20:12 UTC
0

Да, это исключительно для ситуации с одним потоком-читателем и одним потоком-писателем. Когда поток упирается в предел, происходит возврат из функции с флагом false, так что гонке тут взяться неоткуда.

29.12.2024 20:19 UTC
0

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

Я больше с baremetal имею дело. И мне интересно, выходит, я могу условно в обработчике прерывания писать в кольцевой буфер, а в основном цикле читать из него и критические секции тут не нужны получается?

29.12.2024 21:06 UTC
0

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