Каковы внутренние характеристики процессора при столкновении CAS?

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

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

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

Release 7 Lock-Free Freelist Benchmark #1

   M
   N
   S
  L3U
L2U L2U
L1D L1D
L1I L1I
 P   P
L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 0 1 310134488,31013449,0,1.00
0 1 0 1 136313300,6815665,38365,0.22

0 1 0 1 136401284,6820064,50706,0.22
1 1 1 1 111134328,2778358,23851,0.09

0 0 1 1 334747444,16737372,2421,0.54
1 1 1 1 111105898,2777647,40399,0.09

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

Мой оригинал - и я верю, что теперь ошибся - думал, что CAS вмешивается в CAS. Под этим я подразумеваю, что сама инструкция CAS разрушительно столкнется с другим CAS, если они будут происходить одновременно. Оба потерпят неудачу. (Prolly, потому что я был в глубине души, думая о ethernet).

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

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

Разрушительная помеха IS возникает, но она возникает на более высоком уровне, в самом алгоритме структуры данных. Когда мы нажимаем или поп из/в freelist, мы на самом деле пытаемся поменяться местами. Нам нужно, чтобы место назначения было стабильным достаточно долго, чтобы мы могли его прочитать, выполнить любую работу, которую нам нужно сделать, а затем найти ее без изменений, чтобы мы могли завершить наш push/pop.

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

Но теперь я в замешательстве.

Мы видим, что один поток выполняет около 30 миллионов операций push/pop. Назначение должно быть стабильным на протяжении одной из этих операций, чтобы операция преуспела, поэтому мы видим, что есть 30 миллионов слотов. Если у нас есть два потока, то максимальная теоретическая производительность, которую мы можем иметь, составляет 15 миллионов операций на поток; каждый поток использует половину слотов.

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

Но теперь представьте, что у нас есть много потоков. Первый поток, начинающийся с CAS, будет успешным (если предположить, что каждый CAS принимает ровно одно и то же время - не верно, но это предположение не меняет ничего фундаментального, так что хорошо рассуждать). Все остальные потерпят неудачу.

Но как только первый поток будет завершен, следующий поток, который прочитает новое значение назначения, получит успешное выполнение CAS (и все остальные потоки, все еще выполняющие свои CAS или начинающие новые CAS, будут терпеть неудачу).

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

Я думаю, поэтому я не понимаю CAS должным образом.

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

Drepper в своем белом документе описывает LL/SC и как он работает с использованием MESI.

Мне кажется разумным, чтобы CAS работал аналогичным образом.

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

Вторая нить начинается с CAS. Первое ядро ​​отправляет свою линию кэша во второе ядро, и оба ядра имеют выделенную строку кэша.

Первый поток завершает CAS и записывает в строку кэша (запись всегда происходит на x86/x64, даже если сравнение было ложным, оно просто записывает исходное значение).

Акт записи маркирует строку кэша как измененную; возникает RFO, в результате чего второе ядро ​​помечает свою строку кэша как недопустимую.

Второй поток приходит для завершения CAS и замечает, что его строка кеша недействительна... и затем, что? Мне трудно поверить, что инструкция находится в ЦП, внутренне зацикленной до тех пор, пока это не удастся - хотя мне интересно, потому что LL/SC на ARM требует, чтобы вы в своей сборке выполняли этот цикл. Но инструкция CAS знает, что значение адресата изменилось, поэтому результаты его сравнения недействительны. Но с CAS нет ошибки; он всегда возвращает true или false для сравнения. Но даже если инструкции выполняются до завершения, я все равно ожидаю отличного масштабирования. Каждый слот должен использоваться.

Так что же происходит? что происходит с CAS?

Что я вижу, так это то, что по мере увеличения количества потоков все меньше и меньше делается - все доступные слоты, конечно, не используются. Что-то вызывает это. Является ли это разрушительным вмешательством между инструкциями CAS? или это большое количество RFO, поддерживающих CPU- > северную шину?

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

Ответ 1

Что вы видите здесь, это стоимость перемещения данных между кэшами L1 двух физических ядер. Когда используется только одно ядро, данные находятся в этом кэше L1, и каждый CAS работает на полной скорости с данными в кеше. С другой стороны, когда активны два ядра, с другой стороны, каждый раз, когда ядро ​​удается записать данные, это приведет к недействительности другого кеша, что приведет к копированию данных, которые необходимо скопировать между кэшами, прежде чем другое ядро ​​сможет что-либо сделать (как правило, он блокирует ожидание нагрузки до завершения CAS). Это намного дороже, чем фактический CAS (ему нужно как минимум переместить данные до L3 cahce, а затем вернуться к другому кэшу L1) и приводит к замедлению, которое вы видите, поскольку данные заканчиваются пинг-понгами назад и вперед между двумя кэшами L1

Ответ 2

По CAS, я предполагаю, что вы говорите о LOCK CMPXCHG

Вторая нить начинается с CAS. Первый core отправляет свою строку кэша на второе ядро ​​и оба ядра имеют кэш-строка, помеченная совместно.

Кажется, вы думаете, что операция начинается, прерывается, продолжается. CAS является атомарным относительно подсистемы памяти. Таким образом, он считывает значение, сравнивает и записывает за один раз. Там нет временного интервала, где он потеряет кешью к другому ядру, как только он его приобретет. Как это работает? Он вызывает сигнал блокировки процессора во время выполнения команды, так что другие инструкции останавливаются в подсистеме памяти до тех пор, пока кэш-линия не будет доступна снова. Вот почему есть префикс LOCK в инструкции CMPXCHG. Вы можете прочитать описание LOCK для более подробной информации.

Таким образом, большинство утверждений о том, что происходит на L1, пытается получить эксклюзивное право владения келинией, в то время как этот сигнал в основном поднимается все время. Если у L1 уже есть кешлайн (например, в случае двух потоков на одном ядре), единственное утверждение относится к длительности самого CAS, не включая передачу памяти кешлин по ядрам (поскольку она уже существует). И это намного быстрее.

Ответ 3

Итак, я все это думал.

В настоящее время у меня есть два отдельных предложения о том, как обрабатывается CAS - "блокировка кеша" и MESI.

Это сообщение относится исключительно к блокировке кеша.

Блокировка кэша устанавливает, что ядро ​​блокирует данную строку кэша и другие ядра, пытающиеся выполнить CAS в этом тайм-ауте кэша, все еще освобожден кеш.

Кроме того, я считаю, что CAS всегда записывает свои результаты обратно в память до завершения.

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

Release 7 Lock-Free Freelist Benchmark #1

   M
   N
   S
  L3U
L2U L2U
L1D L1D
L1I L1I
 P   P
L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 0 1 310134488,31013449,0,1.00
0 1 0 1 136313300,6815665,38365,0.22

0 1 0 1 136401284,6820064,50706,0.22
1 1 1 1 111134328,2778358,23851,0.09

0 0 1 1 334747444,16737372,2421,0.54
1 1 1 1 111105898,2777647,40399,0.09

Итак, сначала однопоточный случай;

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 0 1 310134488,31013449,0,1.00

Здесь мы имеем максимальную производительность. Каждый "слот" используется одним потоком.

Теперь мы приходим к двум потокам на одном ядре;

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 1 1 334747444,16737372,2421,0.54

Здесь у нас, конечно же, одинаковое количество "слотов" - CAS занимает столько времени, сколько потребуется, но мы видим, что они распределены равномерно между логическими процессорами. Это имеет смысл; одно ядро ​​блокирует линию кэша, другие киоски, первое завершает, второе получает блокировку... они чередуются. Адресат остается в кеше L1 с линией кэша, находящейся в измененном состоянии; нам никогда не нужно перечитывать адресат из памяти, поэтому в этом смысле мы похожи на случай с одним потоком.

Теперь мы приходим к двум потокам на разных ядрах;

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 1 0 1 136401284,6820064,50706,0.22

Здесь мы видим, что наши первые большие замедления. Наше максимальное теоретическое масштабирование составляет 0,5, но мы находимся на уровне 0,22. Как так? ну, каждый поток пытается заблокировать одну и ту же линию кэша (в своем собственном кеше, конечно), что хорошо - но проблема в том, что ядро ​​получает блокировку, ему нужно будет перечитать адресат из памяти, потому что его кеш строка будет отмечена недействительной другим ядром, изменив его копию данных. Поэтому мы помещаем медленное чтение в память, которую мы должны делать.

Теперь мы приходим к четырем потокам, по два на ядро.

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
1 1 1 1 111105898,2777647,40399,0.09

Здесь мы видим, что общее количество ops на самом деле немного меньше, чем один поток на ядро, хотя, конечно, масштабирование намного хуже, так как теперь у нас есть четыре потока, а не два.

В одном потоке для основного сценария каждый CAS начинается с чтения памяти, поскольку другое ядро ​​лишило строку кэша ядра CASing.

В этом случае, когда ядро ​​заканчивает CAS и освобождает блокировку кеша, три потока конкурируют за блокировку, два на другом ядре, один на одном ядре. Таким образом, две трети времени нам нужно перечитать память в начале CAS; третья часть времени у нас нет.

Итак, мы должны быть БЫСТРО. Но мы на самом деле SLOWER.

0% memory re-reading gives 33,474,744.4 total ops per second (two threads, same core)
66% memory re-reading, gives 11,110,589.8 total ops per second (four threads, two per core)
100% memory re-reading, gives 13,640,128.4 total ops per second (two threads, one per core)

И это меня озадачивает. Наблюдаемые факты не соответствуют теории.