Могут ли операции atomi c с указателем не-atomi c <> быть безопаснее и быстрее, чем atomi c <>? - PullRequest
0 голосов
/ 20 апреля 2020

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

Считыватели супер, супер, супер чувствительны ко времени. Я слышал, что atomic<char**> или какова бы ни была скорость перехода в основную память, чего я хочу избежать.

В современных (скажем, 2012 и более поздних) серверах и высокопроизводительных настольных ПК Intel может 8- выровненный по байту обычный указатель гарантированно не порвется, если читать и писать нормально? Мой тест проходит час без слез.

В противном случае, будет ли лучше (или хуже), если я сделаю запись атомарно, а чтение нормально? Например, путем объединения двух?

Обратите внимание, что есть другие вопросы о смешивании операций atomi c и non-atomi c, в которых не указываются процессоры, и обсуждение переходит в языковой адвокатуры. Этот вопрос не о spe c, а о том, что именно произойдет, в том числе о том, знаем ли мы, что произойдет, если spe c не определено.

1 Ответ

2 голосов
/ 22 апреля 2020

x86 никогда не разорвет ассемблерную загрузку или сохранит к выровненному значению ширины указателя. Эта часть этого вопроса, а также ваш другой вопрос ( C ++ 11 на современном Intel: я сумасшедший или не-атоми c выровнены 64-битная загрузка / сохранение на самом деле atomi c? ) оба являются дубликатами Почему целочисленное присваивание для естественно выровненной переменной atomi c на x86?

Это часть того, почему atomic<T> так дешево для компиляторы для реализации, и почему нет недостатка в их использовании.

Единственная реальная стоимость чтения atomic<T> на x86 - это то, что он не может оптимизироваться в регистр при множественном чтении одного и того же вар. Но вам все равно нужно, чтобы ваша программа работала (то есть чтобы потоки замечали обновления указателя). В не-x86 только mo_relaxed такой же дешевый, как обычная загрузка asm, но сильная память x86 модель делает даже seq_cst дешевыми.

Если вы используете указатель несколько раз в одной функции, выполните T* local_copy = global_ptr;, чтобы компилятор мог хранить local_copy в регистре. Думайте об этом как о загрузке из памяти в частный регистр, потому что именно так он будет компилироваться. Операции над объектами atomi c не оптимизируются, поэтому, если вы хотите перечитать глобальный указатель один раз за l oop, напишите свой источник таким образом. Или однажды за пределами l oop: напишите свой исходный код таким образом и позвольте компилятору управлять локальной переменной.


Очевидно, вы продолжаете пытаться избегать atomic<T*>, потому что у вас огромное неправильное представление о производительности std::atomic::load() операции с чистой нагрузкой. std::atomic::store() несколько медленнее, если вы не используете memory_order выпуска или ослабленного, но для x86 std :: atomi c не требует дополнительных затрат для загрузки seq_cst.

Нет никакого преимущества в производительности, чтобы избежать atomic<T*> здесь. Он будет делать именно то, что вам нужно, безопасно и переносимо, и с высокой производительностью для вашего случая использования в основном для чтения. Каждое ядро, читающее его, может получить доступ к копии в своем личном L1d-кэше Запись делает недействительными все копии строки, поэтому автор имеет исключительное право собственности (MESI), но при следующем чтении с каждого ядра будет получена общая копия, которая снова может оставаться горячей в своих частных кешах.

(Это один Преимущества связного кэша: читателям не нужно постоянно проверять какую-то одну общую копию. Авторы должны быть уверены, что нигде нет устаревших копий, прежде чем они смогут писать. Все это делается аппаратными средствами, а не инструкциями программного обеспечения. Все ISA, в которых мы запускаем несколько потоков C ++, имеют согласованную с кэшем разделяемую память, поэтому volatile работает для прокрутки вашей собственной атомики (, но не делает этого ), как это обычно делали люди делать до C ++ 11. Или, как вы пытаетесь сделать без , даже используя volatile, который работает только в отладочных сборках. Определенно не делайте , что !)

Atomi c загружает компиляцию по тем же инструкциям, которые компиляторы используют для всего остального, например, mov. На уровне ассемблера каждая выровненная загрузка и хранилище - это операция атома c (для мощности от 2 размеров до 8 байтов). atomic<T> только должен помешать компилятору предположить, что никакие другие потоки не записывают объект между доступами.

(В отличие от чистой загрузки / чистого хранилища, атомарность всего RMW не бывает бесплатно ; ptr_to_int++ будет компилироваться в lock add qword [ptr], 4. Но в неконтролируемом случае это все же значительно быстрее, чем потеря кеша вплоть до DRAM, просто требуется «блокировка кеша» внутри ядра, которая имеет исключительное владение линией. Например, 20 циклов на операцию, если вы ничего не делаете, кроме этого в Хасвелле (https://agner.org/optimize/), но только один атом c RMW в середине другого кода может хорошо перекрываться с окружающими операциями ALU.)

Чистый доступ только для чтения - это то место, где код без блокировки, использующий атомарность, действительно сияет по сравнению с чем-либо, что требует считывателей RWlock - atomic<> не конкурируют друг с другом, поэтому сторона чтения отлично масштабируется для такого варианта использования, как этот ( или RCU или SeqLock ).

На x86 загрузка seq_cst (порядок по умолчанию) не требует каких-либо барьерных инструкций, благодаря модели аппаратного упорядочения памяти x86 (программа загружает / сохраняет порядок, плюс буфер хранилища с пересылкой в ​​хранилище). Это означает, что вы получаете полную производительность на стороне чтения, которая использует указатель без необходимости ослабления до acquire или consume порядка памяти.

Если бы производительность магазина была фактором, вы могли бы использовать std::memory_order_release, поэтому магазины также может быть просто mov, без необходимости опустошать буфер хранилища с помощью mfence или xchg.


Я слышу, что atomic<char**> или как там скорость в основную память

Что бы вы ни читали, это вводило вас в заблуждение.

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

Сразу после того, как ядро ​​запишет строку кэша, оно все еще будет находиться в своем частном кэше L1d в состоянии MESI Modified (и недействительно в каждом другой кеш, именно так MESI поддерживает когерентность кеша = нигде нет устаревших копий строк). Поэтому нагрузка на другое ядро ​​из этой строки кэша будет отсутствовать в частных кэшах L1d и L2, но теги L3 сообщат аппаратному обеспечению, у какого ядра есть копия строки. Сообщение передается по кольцевой шине к этому ядру, заставляя его выполнить обратную запись линии в L3. Оттуда это может быть передано ядру, все еще ожидающему данные загрузки. Это в значительной степени то, что измеряется межъядерная задержка - время между хранилищем на одном ядре и получением значения на другом ядре.

Время, которое это занимает (межъядерная задержка): примерно как загрузка, которая отсутствует в кеше L3 и должна ждать DRAM, например, 40 нс против 70 нс в зависимости от процессора. Возможно, это то, что вы читаете. (У многоядерных Xeon больше переходов по кольцевой шине и больше задержка между ядрами и от ядер к DRAM.)

Но это только для первой загрузки после записи. Данные кэшируется кэшами L2 и L1d на ядре, которое его загрузило, и в состоянии общего доступа в L3. После этого любой поток, который часто читает указатель, будет стремиться к тому, чтобы строка оставалась горячей в быстром приватном кеше L2 или даже L1d в ядре, на котором запущен этот поток. Кэш-память L1d имеет задержку в 4-5 циклов и может обрабатывать 2 нагрузки за такт.

И линия будет находиться в состоянии общего доступа в L3, где может ударить любое другое ядро, поэтому только первое ядро ​​оплачивает полную интер штраф за задержку.

(До Skylake-AVX512 чипы Intel использовали включающий кэш L3, поэтому теги L3 могут работать как фильтр sn oop для согласованности кэша на основе каталогов между ядрами. Если строка в состоянии Shared в каком-то частном кеше, он также действителен в состоянии Shared в L3. Даже в SKX, где кэш L3 не поддерживает свойство inclusive, данные будут в L3 некоторое время после совместного использования их между ядрами.)

В отладочных сборках каждая переменная сохраняется / перезагружается в память между операторами C ++. Тот факт, что это (как правило) не в 400 раз медленнее, чем обычные оптимизированные сборки, показывает, что доступ к памяти не слишком медленный в неконтролируемом случае, когда он попадает в кэш. (Хранение данных в регистрах быстрее, чем в памяти, поэтому отладочные сборки в целом довольно плохие. Если бы вы сделали каждую переменную atomic<T> с memory_order_relaxed, это было бы несколько похоже на компиляцию без оптимизации, за исключением вещей как ++). Просто чтобы быть ясным, я не говорю, что atomic<T> заставляет ваш код работать на скорости режима отладки. Совместно используемую переменную, которая могла бы изменяться асинхронно, необходимо перезагружать из памяти (через кеш) каждый раз, когда об этом упоминает источник, и atomic<T> делает это.


Как я уже сказал, читая atomic<char**> ptr будет компилироваться только для mov загрузки на x86, без лишних заборов, точно так же, как и для чтения неатомного c объекта.

За исключением того, что он блокирует некоторое переупорядочение во время компиляции, например volatile не позволяет компилятору предполагать, что значение никогда не меняется, и выводить нагрузки из циклов. Это также мешает компилятору изобретать дополнительные операции чтения. См. https://lwn.net/Articles/793253/


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

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

Другие ответы на ваш C ++ 11/14/17: блокировка чтения / записи ... без блокировки для читатели? предложили вещи, включающие несколько RWlocks, чтобы убедиться, что читатель всегда может их взять. Это все еще включает атоми c RMW в некоторой строке общего кэша, которую все читатели пытаются изменить. Если у вас есть считыватели, которые используют RWlock, они, вероятно, будут останавливаться для задержки между ядрами, поскольку они переводят строку кэша, содержащую блокировку, в состояние MESI Modified.

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

...