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

Рассмотрим многоядерный процессор ARM. Один поток изменяет блок машинного кода, который, возможно, одновременно выполняется другим потоком. Модифицирующий поток выполняет следующие виды изменений:

  1. Отметьте блок машинного кода для пропуска: он помещает инструкцию перехода как первую инструкцию блока кода, так что тот, кто ее выполняет, должен пропустить остальные инструкции, перепрыгивая через весь блок кода.
  2. Отметьте блок машинного кода для выполнения: он записывает остальные инструкции, начиная со второй, затем атомарно заменяет первую инструкцию (переход) на предполагаемую первую инструкцию блока кода.

Что касается потока писателя кода, я понимаю, что достаточно сделать окончательную запись с std::memory_order_release в C ++ 11.

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


person Serge Rogatch    schedule 02.09.2016    source источник
comment
почему вы самомодифицируете код? или изменение кода вашего партнера? почему здесь не используется ни одно из обычных решений блокировки, которые вы бы использовали для любого другого общего ресурса?   -  person old_timer    schedule 02.09.2016
comment
@dwelch, это связано с инструментами компилятора: какой-то горячий инструментальный механизм. Должна быть возможность включать и выключать инструментарий во время выполнения, чтобы инструментарий не замедлял выполнение кода, когда он выключен.   -  person Serge Rogatch    schedule 02.09.2016
comment
это просто общий ресурс, некий общий баран, используемый двумя потоками, применяются обычные методы. здесь ничего особенного.   -  person old_timer    schedule 02.09.2016
comment
вы, очевидно, не можете войти в этот блок и изменить работающий код во время его работы, у вас недостаточно информации о выполняющейся логике, чтобы иметь возможность делать это чистым образом, поэтому вы должны рассматривать его как общий ресурс и только касаться это когда другой ресурс не использует его.   -  person old_timer    schedule 02.09.2016
comment
вам придется очистить i-cache; стандартного способа сделать это не существует, но это зависит от используемой операционной системы. Например. под linux / gcc вы можете вызвать __builtin___clear_cache.   -  person ensc    schedule 02.09.2016
comment
@dwelch, как я понимаю, особенность в том, что в памяти находится код, а не данные.   -  person Serge Rogatch    schedule 02.09.2016
comment
Справочное руководство по архитектуре ARM имеет ряд соответствующих разделов. См. A3.5.4 Параллельное изменение и выполнение инструкций, возможно B2.2.9 Упорядочивание операций обслуживания кэша и предсказателя ветвления   -  person EOF    schedule 02.09.2016
comment
@ensc: даже если потребительские потоки делают это перед входом в каждый блок, недостаточно, чтобы сделать это безопасным.   -  person Peter Cordes    schedule 03.09.2016


Ответы (1)


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

Первая инструкция без перехода все еще может быть кэширована, чтобы другой поток мог войти в блок. Когда выполнение достигает 2-й строки i-cache блока, возможно, эта строка перезагружается и видит частично измененное состояние.

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

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

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

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


.

Я процитирую еще один слайд (о виртуализации ARM) для этого краткого обзора, но я бы рекомендовал читать слайды ELC2016, а не слайды виртуализации.

Программное обеспечение должно знать о кешах в нескольких случаях: загрузка / генерация исполняемого кода.

  • Требуется очистка D-кеша до точки объединения + аннулирование I-кеша
  • Возможно из пользовательского пространства на ARMv8
  • Требуется системный вызов на ARMv7

D-кеш может быть признан недействительным с обратной записью или без нее (поэтому убедитесь, что вы очищаете / сбрасываете, а не отбрасываете!). Вы можете и должны запускать это по виртуальному адресу (вместо того, чтобы очищать весь кеш сразу, и определенно не используйте для этого сброс с помощью set / way).

Если вы не очистили свой D-кеш перед аннулированием I-кеша, выборка кода могла бы производить выборку непосредственно из основной памяти в некогерентный I-кеш после отсутствия в L2. (Без выделения устаревшей строки в каких-либо унифицированных кешах, что может предотвратить MESI, поскольку L1D имеет строку в Модифицированном состоянии). В любом случае очистка L1D до PoU является архитектурно необходимой и в любом случае происходит в потоке записи, не критичной для производительности, поэтому, вероятно, лучше просто сделать это, а не пытаться обосновать, безопасно ли это. для конкретной микроархитектуры ARM. См. Комментарии к усилиям @ Notlikethat, чтобы прояснить мое замешательство по этому поводу.

Подробнее об очистке I-кеша из пользовательского пространства см. Как очистить и сделать недействительным кеш процессора ARM v7 из пользовательского режима в Linux 2.6.35. Функция GCC __clear_cache() и Linux sys_cacheflush работают только с областями памяти, которые mmap были заполнены PROT_EXEC.


Не изменять на месте: использовать новое местоположение

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

Поскольку вы обновляете указатель атомарно, потребительские потоки либо переходят к старому, либо к новому блоку кода.

Теперь ваша проблема заключается в том, чтобы убедиться, что ни одно ядро ​​не имеет устаревшей копии нового местоположения в его i-cache. Учитывая возможности переключения контекста, которое включает текущее ядро, если переключение контекста не полностью очистить i-cache.

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

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


Принуждение других потоков к очистке кеша:

В Linux 4.3 и более поздних версиях есть membarrier() системный вызов, который будет выполняться барьер памяти на всех других ядрах системы (обычно с межпроцессорным прерыванием) перед возвратом (таким образом блокируя все потоки всех процессов). См. Также это сообщение в блоге, описывающее некоторые варианты использования (например, RCU в пользовательском пространстве) и mprotect() в качестве альтернативы.

Однако, похоже, он не поддерживает кеши инструкций по очистке. Если вы создаете собственное ядро, вы можете рассмотреть возможность добавления поддержки нового значения cmd или flag, которое означает очистку кешей инструкций вместо (или также) запуска барьера памяти. Возможно, значение flag могло быть виртуальным адресом? Это будет работать только на архитектурах, где адрес соответствует int, если вы не настроите API системного вызова, чтобы смотреть на полную ширину регистра flag для вашего нового cmd, но только на значение int для существующего MEMBARRIER_CMD_SHARED.


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

IDK, если munmap()ing, он будет работать, но, вероятно, дороже, чем необходимо (потому что он должен изменить таблицы страниц и сделать недействительными соответствующие записи TLB).


Другие стратегии

Вы могли бы что-то сделать, опубликовав монотонно увеличивающийся порядковый номер в общей переменной (с семантикой выпуска, чтобы он упорядочен относительно записи инструкций). Затем потребительские потоки проверяют порядковый номер на соответствие локальному потоку, наиболее часто встречающемуся, и аннулируют i-cache, если есть новые данные. Это может быть поблочное или глобальное.

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


Другой возможный метод: если существует только один потребительский поток, производитель устанавливает целевой указатель перехода так, чтобы он указывал на блок, который не изменяется (поэтому нет необходимости очищать i-cache). Этот блок (который выполняется в потоке-потребителе) выполняет очистку кеша для соответствующей строки i-cache, а затем снова изменяет указатель цели перехода, на этот раз так, чтобы он указывал на блок, который должен запускаться каждый раз.

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

person Peter Cordes    schedule 03.09.2016
comment
На слайдах не ясно, зачем вам нужно сбрасывать L1D, потому что он должен быть согласован с L2 - тьфу, это не очень хороший пример, так как с точки зрения гипервизора с уровнями кеша, о котором гость не знает, и гость выполняет операции set / way (которые не применимы к ситуации SMP). Очистка D-кеша с помощью VA до PoU (который влияет на столько уровней, сколько необходимо) - это все, о чем вам здесь нужно заботиться. FWIW, в этом году у ELC была более актуальная презентация. - person Notlikethat; 04.09.2016
comment
@Notlik, что спасибо за ссылку. Но я до сих пор не понимаю, почему очистку D-кеша от VA до PoU нужно выполнять вручную / явно. Если вы используете соответствующие барьеры, чтобы убедиться, что хранилища привязаны к L1D (а не только к буферу хранилища) перед очисткой I-кеша, тогда в чем проблема? Унифицированный кеш PoU согласован со всеми другими кэшами данных во всем ЦП, верно? Поэтому, когда L1I пытается прочитать из него, он должен в конечном итоге получить данные, которые в настоящее время находятся в строке Modified в L1D (того или другого ядра). Является ли явная очистка способом преодоления барьера в одном потоке? - person Peter Cordes; 04.09.2016
comment
В этом случае, когда мы записываем данные в одном потоке и только очищаем i-cache + выполняем новый код в другом потоке, я думаю, что мы более безопасны. Если потребительский поток использует загрузку запроса на указатель или счетчик последовательности, то наличие указателя на блок означает, что обновленное содержимое блока также видно ему (при условии, что производитель использовал хранилище релизов в правильном порядке). Значит, выборка кода после недействительности i-cache должна увидеть обновление, как при загрузке данных? Или здесь мои рассуждения ошиблись, и унифицированные кеши могут повторно считываться из памяти, чтобы удовлетворить промах в i-cache? - person Peter Cordes; 04.09.2016
comment
Забыл сказать: единственный случай, когда я понимаю, что нужно очищать D $ до PoU, - это когда нет единого кеша между D $ и памятью. В этом случае I $ будет читать из памяти, что не согласуется с D $. В новом наборе слайдов снова говорится, что требуется предварительное обслуживание D $ для PoU или PoC! перед запуском IC IALLU, снова без объяснения причин. Хм, я вижу для IC IVAU (аннулировать I $ по VA), он не говорит, что вам нужно очистить D $. Я не уверен, что понимаю, как подходят для общего доступа домены, но давайте предположим, что адреса находятся в регионе, для которого установлено значение внешнего общего доступа, если это имеет смысл. - person Peter Cordes; 04.09.2016
comment
Кеши данных согласованы друг с другом только в отношении доступа к данным; промах L1I может искать в объединенном L2, но это не приведет к тому, что L2 поднимется и отследит L1D - одна из основных причин наличия некогерентных I-кэшей (в однопроцессорной ситуации они эволюционировали из) - это простота и экономия энергии за счет отсутствия такого механизма отслеживания. - person Notlikethat; 04.09.2016
comment
@ Notlikethat: ой, чтоб L2 можно было заполнить по памяти. Я предполагаю, что другое ядро, использующее тот же L2, также может получить старую копию в свой L1D, если это не предотвратит загрузка или барьер. Но фактического нарушения согласованности можно было бы предотвратить путем отслеживания, если бы он захотел изменить эту строку на Modified. Хорошо, я думаю, что все это имеет смысл, и это объясняет необходимость очистки до PoU. TYVM для выяснения того, что я упустил момент, когда L2 мог заполнить из памяти, даже когда L1D был грязным. :) - person Peter Cordes; 04.09.2016
comment
Этого не происходит с современными конструкциями Intel, потому что общий L3 является включенным. Это универсальный магазин для согласованности кеша: теги L3 сообщают вам, кэшируется ли строка (и потенциально грязная) где-нибудь на кристалле. Это означает, что большой поток согласованного трафика остается на кристалле, а не в память и обратно. В любом случае, именно из-за этого особого случая конструкции кеша я думал, что L2 заметит L1D, и что после того, как хранилище было привязано к L1D, все остальные ядра видят эти данные. Я по-прежнему думаю, что это верно для Intel HW, но теперь я понимаю, что это не только из-за MESI, а, скорее, из-за того, как он реализован. - person Peter Cordes; 04.09.2016
comment
... или он может вообще не заполняться - например, на Cortex-A7 все линейные выборки размещаются непосредственно в соответствующем L1, в то время как унифицированный L2 выделяет только выселения из L1D. Кеши с ума сошли. - person Notlikethat; 04.09.2016
comment
@ Notlikethat: я думаю, что последний пункт (L1I извлекает непосредственно из памяти, используя L2 только в качестве кэша жертвы) является реальным объяснением. Я только что перепроверил статью MESI, и мои рассуждения в конце концов не были ориентированы на Intel: Если L1D имеет модифицированную строку, всем другим согласованным кэшам (включая L2) не разрешается хранить копию строки. Таким образом, L2 не будет разрешено размещать устаревшую копию из памяти; только некогерентный кеш, такой как L1I, мог это сделать. Но он проверит попадание L2 перед переходом в память, и именно там он сможет подобрать измененную строку. - person Peter Cordes; 05.09.2016
comment
По крайней мере, если предположить, что кэши ARM действительно соответствуют некоторым вариантам правил MESI. - person Peter Cordes; 05.09.2016