Что происходит с ожидаемой семантикой памяти (например, чтение после записи), когда поток запланирован на другом ядре ЦП? - PullRequest
3 голосов
/ 05 февраля 2020

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

Что происходит с такими память гарантирует, что поток будет перенесен на другое ядро ​​процессора? Скажем, поток записывает 10 в ячейку памяти X, а затем переносится на другое ядро. Кэш L1 этого ядра может иметь другое значение для X (из другого потока, который ранее выполнялся на этом ядре), поэтому теперь чтение X не вернет 10, как ожидает поток. Есть ли какая-то синхронизация кэша L1, которая происходит, когда поток запланирован на другом ядре?

Ответы [ 4 ]

3 голосов
/ 05 февраля 2020

Все, что требуется в этом случае, - это то, что записи, выполняемые на первом процессоре, становятся глобально видимыми, прежде чем процесс начнет выполняться на втором процессоре. В архитектуре Intel 64 это достигается путем включения одной или нескольких инструкций с семантикой ограничения памяти в код, который ОС использует для передачи процесса из одного ядра в другое. Пример из ядра Linux:

/*
 * Make previous memory operations globally visible before
 * sending the IPI through x2apic wrmsr. We need a serializing instruction or
 * mfence for this.
 */
static inline void x2apic_wrmsr_fence(void)
{
    asm volatile("mfence" : : : "memory");
}

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

Ссылка: разделы 8.2 и 8.3 тома 3 Руководства разработчика программного обеспечения Intel для архитектуры (документ 325384-071, октябрь 2019 г.).

1 голос
/ 09 февраля 2020

TL; DR: Это зависит от архитектуры и ОС. В x86 этот тип опасности чтения после записи в основном не является проблемой, которую необходимо учитывать на уровне программного обеспечения, за исключением хранилищ слабого порядка W C, которые требуют выполнения ограничения хранилища в программном обеспечении на том же логическое ядро ​​до переноса потока.


Обычно операция переноса потока включает в себя как минимум одно хранилище памяти. Рассмотрим архитектуру со следующим свойством:

  • Модель памяти такова, что хранилища памяти могут не стать глобально наблюдаемыми в программном порядке. В этой статье Википедии есть не точная, но достаточно хорошая таблица, в которой приведены примеры архитектур, обладающих этим свойством (см. Строку «Хранилища могут быть переупорядочены после хранилищ»).

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

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

  • Существует «окно уязвимости» между временем, когда хранилище закрывается, и когда оно становится глобально наблюдаемым. Это может произойти, например, из-за наличия буферов хранилища и / или MSHR. У большинства современных процессоров есть это свойство.

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

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

Обычно поток перепланируется для запуска на другом ядре используя один из следующих двух методов:

  • Произошло аппаратное прерывание, такое как прерывание по таймеру, которое в конечном итоге приводит к перепланированию потока на другом логическом ядре.
  • Поток сам выполняет системный вызов, такой как sched_setaffinity, который в конечном итоге заставляет его работать на другом ядре.

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

На процессорах Intel и AMD x86 существует несколько способов выполнения системных вызовов (т. е. изменение уровня привилегий), включая INT, SYSCALL, SYSENTER и дальний CALL , Ни один из них не гарантирует, что все предыдущие магазины станут глобально наблюдаемыми. Следовательно, ОС должна делать это явно при планировании потока на другом ядре путем выполнения операции ограничения хранилища. Это делается как часть сохранения контекста потока (архитектурные регистры пользовательского режима) в памяти и добавления потока в очередь, связанную с другим ядром. Эти операции включают, по крайней мере, один магазин, на который распространяется гарантия последовательного заказа. Когда планировщик запускается на целевом ядре, он будет видеть полный регистр и архитектурное состояние памяти (в точке последней удаленной инструкции) потока будет доступно на этом ядре.

На x86, если Поток использует хранилища типа W C, которые не гарантируют последовательное упорядочение, ОС в этом случае может не гарантировать, что это сделает эти хранилища глобально наблюдаемыми. В x86 spe c прямо говорится, что для того, чтобы сделать хранилища W C глобально наблюдаемыми, необходимо использовать ограничитель хранилища (либо в потоке на том же ядре, либо, что намного проще, в ОС). ОС обычно должна делать это, как указано в ответе @ JohnDMcCalpin. В противном случае, если ОС не предоставляет гарантию заказа программы программным потокам, программисту в пользовательском режиме может потребоваться принять это во внимание. Один из способов будет следующим:

  1. Сохраните копию текущей маски ЦП и закрепите нить на текущем ядре (или на любом одном ядре).
  2. Выполните слабо упорядоченные store.
  3. Выполнение ограничения хранилища.
  4. Восстановление маски ЦП.

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

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


Миграция потока в Linux Ядро

Фрагмент кода из ответа @ JohnDMcCalpin находится на пути к отправьте межпроцессорное прерывание, которое достигается с помощью инструкции WRMSR в регистр API C. IPI может быть отправлен по многим причинам. Например, чтобы выполнить операцию сброса TLB. В этом случае важно убедиться, что обновленные структуры пейджинга являются глобально наблюдаемыми, прежде чем отключить записи TLB на других ядрах. Вот почему может потребоваться x2apic_wrmsr_fence, который вызывается непосредственно перед отправкой IPI.

Тем не менее, я не думаю, что миграция потоков требует отправки IPI. По сути, поток переносится путем удаления его из некоторой структуры данных, связанной с одним ядром, и добавления ее к той, которая связана с целевым ядром. Поток может быть перенесен по многим причинам, например, когда изменяется сродство или когда планировщик решает перебалансировать нагрузку. Как упомянуто в Linux исходном коде , все пути миграции потоков в исходном коде заканчиваются выполнением следующего:

stop_one_cpu(cpu_of(rq), migration_cpu_stop, &arg)

, где arg содержит задачу для переноса и идентификатор ядра назначения. migration_cpu_stop - это функция, которая выполняет фактическую миграцию. Однако задача, которая должна быть перенесена, может в данный момент выполняться или ожидать в какой-либо очереди выполнения для запуска на исходном ядре (т. Е. На том ядре, на котором в данный момент запланирована задача). Требуется остановить задачу перед ее миграцией. Это достигается добавлением вызова функции migration_cpu_stop в очередь задачи-ограничителя, связанной с исходным ядром. stop_one_cpu затем устанавливает задание стопора как готовое к выполнению. Задание стопора имеет наивысший приоритет. Поэтому при следующем прерывании таймера на исходном ядре (которое может совпадать с текущим ядром) будет выбрана для запуска одна из задач с наивысшим приоритетом. В конце концов, задача-ограничитель запустится и выполнит migration_cpu_stop, что, в свою очередь, выполнит миграцию. Поскольку этот процесс включает аппаратное прерывание, все хранилища целевой задачи гарантированно будут глобально наблюдаемыми.


Похоже, что ошибка в x2apic_wrmsr_fence

Цель x2apic_wrmsr_fence - сделать все предыдущие хранилища глобально наблюдаемыми перед отправкой IPI. Как обсуждалось в этой ветке , SFENCE здесь недостаточно. Чтобы понять почему, рассмотрим следующую последовательность:

store
sfence
wrmsr

Ограничение хранилища здесь может заказать предыдущую операцию хранилища, но не запись MSR. Инструкция WRMSR не имеет свойств сериализации при записи в регистр API C в режиме x2API C. Это упомянуто в разделе 3.12.3 модуля 3 SDM Intel:

Для обеспечения эффективного доступа к регистрам API C в режиме x2API C семантика сериализации WRMSR ослаблена, когда запись в регистры API C.

Проблема здесь в том, что MFENCE также не гарантирует заказ более позднего WRMSR относительно предыдущих магазинов. На процессорах Intel задокументировано только упорядочение операций с памятью. Только на процессорах AMD гарантируется полная сериализация. Таким образом, чтобы он работал на процессорах Intel, должен быть LFENCE после того, как MFENCE (SFENCE не заказан с LFENCE, поэтому необходимо использовать MFENCE, даже если нам не нужно заказывать нагрузки). На самом деле Раздел 10.12.3 упоминает это.

0 голосов
/ 11 февраля 2020

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

Все остальное - платформа * 1006 Удельный *. Если у платформы есть кэш L1, то аппаратное обеспечение должно сделать этот кэш полностью связным, или потребуется некоторая форма аннулирования или очистки. На большинстве типичных современных процессоров аппаратное обеспечение делает кэш-память только частично согласованной, потому что чтение также может быть предварительно выбрано, а запись может быть опубликована. На процессорах x86 специальный аппаратный magi c решает проблему предварительной выборки (предварительная выборка становится недействительной, если строка кэша L1 отключена). Я считаю, что операционная система и / или планировщик должны специально отправлять записи sh, но я не совсем уверен, и это может варьироваться в зависимости от конкретного процессора.

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

0 голосов
/ 10 февраля 2020

Добавление двух моих битов здесь. На первый взгляд барьер кажется излишним (ответы выше)

Рассмотрите эту логику c: когда поток хочет записать в кеш-строку, включается когерентность HW-кеша, и нам нужно аннулировать все остальные копии кеш-линии, которая присутствует с другими ядрами в системе; запись не продолжается без признаний недействительными. Когда поток перепланируется на другое ядро, ему нужно будет извлечь строку кэша из L1-кэша, который имеет разрешение на запись, тем самым поддерживая последовательное поведение чтения после записи.

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...