Evg |
СОДЕРЖАНИЕ
Базовые сведения о микроархитектуре
Запись от Evg размещена 19.05.2018 в 18:23
Показов 12607
Комментарии 6
Из-за технических ограничений форумного движка статью про hyper-threading пришлось превратить в цикл из четырёх статей. Данная статья является третьей статьёй цикла. Четвёртая статья пока отсутствует
1. Предисловие В очередной раз получилось не так, как планировал. Изначально предполагалось написать статью про различные технологии, похожие на hyper-threading, но для лучшего понимания материала требовалось некоторое пояснение того, как процессор устроен "под капотом". В итоге подготовительный материал оказался увесистым и его пришлось вынести в отдельную статью. Данная статья описывает фундаментальные основы, а потому может рассматриваться как независимая статья (т.е. по смыслу её можно рассматривать вне цикла из четырёх статей). К сожалению, материал требует понимания хотя бы базовых основ программирования на ассемблере или понимания того, как выглядит программа с уровня системы команд процессора. Я попытался изложить материал таким образом, чтобы читателю, не имеющему этих навыков, хоть что-то было понятно. Но не уверен в том, что достиг этой цели Сразу же заострю внимание на том, что в данной статье речь пойдёт исключительно об одном простом процессорном ядре. Т.е. любое распараллеливание, о котором будет идти речь, является внутренней особенностью ОДНОГО процессорного ядра. Ни о какой многоядерности и ни о каких технологиях наподобие hyper-threading в данной статье речи не идёт. Это довольно важный момент, т.к. речь пойдёт о другом, более низком уровне параллельности, о котором кто-то, возможно, даже и не слышал В статье пойдёт техническое описание работы внутренностей процессора. По традиции я пытаюсь изложить материал в упрощённом виде. Из-за чего некоторые вещи формально описаны некорректно с точки зрения реальной технической реализации, однако общие принципы остаются верными. Речь пойдёт о тех внутренностях процессора, точное описание работы которых является закрытой частью документации, не доступной широкой публике. Поэтому о некоторых вещах я при всём желании не смог бы узнать достоверно точную информацию Буквально пару слов скажу о терминологии. Слово "операция" имеет широкий спектр значений. Это может быть как абстрактный термин "арифметическая операция", так и конкретный термин "машинная операция". Чтобы не путаться, я абстрактные операции буду называть словом "операция", а машинные операции - словом "инструкция" В статье будет рассмотрен абстрактный процессор со своей собственной системой команд. Для тех, кто уже имел дело с программированием на ассемблере под intel'овскую систему команд, будет немного непривычно видеть трёхаргументный ассемблер 2. Зависимости RAW и WAR Рассмотрим простейший пример на языке программирования: C a = a + 100; b = b + 200; Code # Листинг 1. Машинный код (1) load [a] -> r1 # читаем переменную "a" из памяти на регистр r1 (2) add r1, 100 -> r1 # складываем значение на регистре r1 и константу 100, результат записываем в регистр r1 (3) store r1 -> [a] # записываем регистр r1 в память в переменную "a" (4) load [b] -> r1 (5) add r1, 200 -> r1 (6) store r1 -> [b] Небольшое лирическое отступление на тему количества "шагов". В реальности надо учитывать, что, разные инструкции исполняются за разное время. Например, инструкция чтения из памяти может исполняться очень долго за счёт того, что данных в кэше не оказалось и их приходится читать непосредственно из планки памяти. В это время процессор будет вообще ничего не делать, а тупо ждать готовности данных, и занять это может несколько десятков или сотен машинных тактов. А вот инструкция сложения - очень быстрая, исполняется всего лишь 1 такт. Поэтому число шагов - это очень абстрактный параметр, на основании которого нельзя рассчитывать время исполнения программы. Но мы рассматриваем абстрактный процессоре и на его примере разбираемся с матчастью, поэтому пока мы будем игнорировать подобные тонкости В процессоре есть разные устройства (execution unit), которые предназначены для выполнения различных операций. Для простоты будем считать, что у нас есть три устройства. Первое устройство выполняет чтение данных из памяти на регистр, второе устройство выполняет целочисленное сложение над регистрами и константами, третье устройство выполняет запись данных из регистра в память Если смотреть на старые процессора, то приведённую выше последовательность машинных команд они исполняли строго по очереди. При этом исполнительные устройства так же работали по очереди. Сначала работало устройство чтения из памяти (считывало значение из переменной "a" на регистр r1), затем работало устройство, которое выполняло сложение (r1 + 100 -> r1), затем работало устройство записи в память (записывало регистр r1 в переменную "a"). Затем всё то же самое выполнялось для переменной "b" Со временем пришло понимание, что строить работу подобным образом не эффективно. Лучше сделать так, чтобы различные устройства могли работать одновременно (параллельно), обрабатывая независимые данные. Например, в то время, пока одно устройство считывает данные из памяти, второе устройство выполняет арифметические действия над другими данными. Предположим, наш процессор научился это делать. Что нам это даст для приведённого выше фрагмента кода? Сначала выполняется инструкция "(1) load [a] -> r1". Параллельно с ней нельзя выполнять инструкцию "(2) add r1, 100 -> r1", т.к. она работает над регистром r1, который мы ещё не сформировали. Т.е. инструкция (2) имеет зависимость от инструкции (1), а потому не может исполняться одновременно (параллельно) с инструкцией (1). По той же причине инструкция "(3) store r1 -> [a]" зависит от инструкции (2), которая зависит от инструкции (1). Таким образом мы имеем цепочку зависимых инструкций (1)-(2)-(3), которые можно исполнять только последовательно и никак по другому. В этой цепочке имеются две зависимости: инструкция (2) зависит от инструкции (1), инструкция (3) зависит от инструкции (2). Такой вид зависимости называется "Read After Write" (RAW-зависимость). Т.е. мы не можем прочитать значение в регистре, пока его не записали. Если посмотреть на цепочку инструкций (4)-(5)-(6), то она обладает тем же свойством - это цепочка RAW-зависимых инструкций, которые можно исполнять только последовательно Если рассуждать логически, то две операции "a = a + 100" и "b = b + 100" проводятся над двумя разными (независимыми) переменными, а потому логической зависимостью между собой не обладают. Это означает, что их можно выполнять параллельно друг с другом. Но если мы посмотрим на наш машинный код, то увидим, что между ними всё-таки есть зависимость. Инструкция (3) записывает данные в переменную "a" из регистра r1 (т.е. читает регистр r1), а инструкция (4) считывает данные из переменной "b", но в тот же самый регистр r1 (т.е. записывает в регистр r1). Поэтому мы не можем исполнить инструкции (4) и (3) одновременно, т.к. они работают над одним и тем же регистром. Но это уже другой вид зависимости, который называется "Write After Read" (WAR-зависимость). По своей природе это искусственная (ложная) зависимость. Она присутствует в тех местах, когда различные инструкции логически могут исполняться параллельно, но в реальности им мешает использование общего регистра Таким образом в нашей последовательности из 6 инструкций мы имеем две RAW-зависимые цепочки инструкций (1)-(2)-(3) и (4)-(5)-(6), которые связаны между собой WAR-зависимостью (3)-(4). Поэтому что-то исполнить параллельно тут не получится 3. Борьба с WAR-зависимостями 3.1. Теоретический пример переписывания кода На всякий случай предупреждаю, что в этом разделе пойдёт описание теоретического подхода. Это нужно только для того, чтобы лучше прочувствовать матчасть Как можно переписать машинный код, чтобы избавиться от WAR-зависимости? Зависимость у нас создаёт использование одного и того же регистра в двух разных RAW-зависимых цепочках. Но можно в разных цепочках использовать разные регистры, переписав машинный код следующим образом: Code # Листинг 2. Машинный код (1) load [a] -> r1 (2) add r1, 100 -> r1 (3) store r1 -> [a] (4) load [b] -> r2 # здесь заменили r1 на r2 (5) add r2, 200 -> r2 (6) store r2 -> [b] Code # Листинг 3. Run-time выполнение (1) load [a] -> r1 (2) add r1, 100 -> r1 (3) store r1 -> [a] ; (4) load [b] -> r2 (5) add r2, 200 -> r2 (6) store r2 -> [b] Наш машинный код мы можем в очередной переписать, чтобы подстроиться под работу улучшенного процессора. Для этого достаточно передвинуть повыше инструкцию (4), чтобы перемешать между собой инструкции из двух RAW-цепочек. Напомню, что после того, как во второй цепочке мы начали использовать другой регистр, цепочки стали независимыми между собой. Это позволяет перемешивать инструкции из разных цепочек, важно лишь то, чтобы внутри цепочки был выдержан правильный порядок инструкций. В последующем машинном коде я поменял местами инструкции, но для удобства сопоставления со старым кодом оставил старую нумерацию Code # Листинг 4. Машинный код (1) load [a] -> r1 (4) load [b] -> r2 (2) add r1, 100 -> r1 (3) store r1 -> [a] (5) add r2, 200 -> r2 (6) store r2 -> [b] Code # Листинг 5. Run-time выполнение (1) load [a] -> r1 (4) load [b] -> r2 ; (2) add r1, 100 -> r1 (3) store r1 -> [a] ; (5) add r2, 200 -> r2 (6) store r2 -> [b] Code # Листинг 6. Run-time выполнение (1) load [a] -> r1 (2) add r1, 100 -> r1 ; (4) load [b] -> r2 (3) store r1 -> [a] ; (5) add r2, 200 -> r2 (6) store r2 -> [b] Code # Листинг 7. Машинный код (1) load [a] -> r1 (4) load [b] -> r2 (2) add r1, 100 -> r1 (5) add r2, 200 -> r2 (3) store r1 -> [a] (6) store r2 -> [b] Code # Листинг 8. Run-time выполнение (1) load [a] -> r1 ; (4) load [b] -> r2 (2) add r1, 100 -> r1 ; (5) add r2, 200 -> r2 (3) store r1 -> [a] ; (6) store r2 -> [b] Таким образом, научив процессор параллельно исполнять инструкции, требующие различные устройства, и увеличив количество исполняющих устройств, мы смогли вдвое ускорить исполнение нашей простой программы. Т.е. программа стала выполняться быстрее за счёт того, что процессор научился работать с внутренней параллельностью нашей программы. При этом ускорение работы программы НЕ сопровождалось увеличением частоты процессора. Ускорение программы явилось следствием улучшения "подкапотной" части процессора. Хочется ещё раз подчеркнуть, что здесь речь идёт об ускорении одного-единственного программного потока, т.е. о работе исключительно внутри одного процессорного ядра. Ни о какой многоядерности или hyperthreading'е тут речи не идёт Правда во всём этом деле есть один тонкий момент. Чтобы задействовать возможности улучшенного процессора, нам пришлось переписать программу. Пусть всё переписывание свелось к тому, что мы просто поменяли местами инструкции и по другому использовали регистры, но факт остаётся фактом. Наш улучшенный процессор НЕ смог быстрее исполнить оригинальную версию программы в машинных кодах. Чтобы получить эффект от улучшений процессора, нам пришлось переписать программу. В реальной жизни программы, в основном, пишут на языках программирования, а потому программу не пришлось бы переписывать, её нужно было бы всего лишь перекомпилировать с настройками под конкретный процессор. Но конечный итог для пользователя был бы прежним - ему бы пришлось заменять конкретный исполняемый файл. Причём делать это почти после каждого обновления процессора. Это является тем моментом, который практически во всех случаях является неприемлимым для конечного пользователя. Пользователь ожидает, что для ускорения работы системы ему достаточно только заменить процессор и не заниматься заменой всего имеющегося софта. Поэтому приведённый подход является недостаточно хорошим для использования в реальной жизни На всякий случай напомню ещё раз, что здесь описывалось теоретическое развитие абстрактного процессора. Всё это было сделано для того, чтобы наглядно продемонстрировать, как можно ускорить работу процессор за счёт улучшения его внутреннего устройства, а не тупо нарастив частоту 3.2. Реальное устройство аппаратной реализации В предыдущем разделе мы уже поняли, что недостаточно добавить в процессор возможность параллельного исполнения инструкций. Нужно это сделать таким образом, чтобы коды, собранные под "старые" процессоры, на новом процессоре по возможности исполнялись эффективно, с учётом возможностей нового процессора. В наших примерах мы занимались тем, что переписывали код. Но переписывание по большому счёту сводилось только к двум примитивным вещам: переставить инструкции местами и использовать другие регистры. Этого было достаточно для того, чтобы процессор смог исполнять код с учётом внутренней параллельности программного потока. Единственной проблемой был сам факт того, что программу нужно переписать, т.к. это практически сводило на "нет" всю пользу от нового процессора. А что если код будет переписывать не программист, а сам процессор? Т.е. построить процессор таким образом, чтобы он, получив на вход "плохой" код, соответствующий листингу 1, сам внутри себя преобразовал его в код, похожий на листинг 7, чтобы в динамике получилось исполнение, соответствующие листингу 8. При таком раскладе мы получили бы процессор, который сам по себе умеет работать быстрее, и который автоматически ускоряет исполнение старых программ, не требуя их переписывания Старые процессоры содержали конвейер, который осуществлял полное исполнение машинных инструкций, начиная от чтения инструкции из памяти и заканчивая её полным исполнением. В современных процессорах можно считать, что конвейер поделён на две последовательные стадии, между которым расположен специальный буфер (reorder buffer), который может содержать несколько инструкций. Первая стадия (front-end) выполняет чтение инструкций из памяти и их дешифрацию, а затем дешифрированную инструкцию помещает в буфер. Вторая стадия (back-end) забирает инструкции из буфера и исполняет их. В качестве простой аналогии можно привести пример, когда нужно ящики из машины перенести на склад и расставить по полкам. Между машиной и складом установлен промежуточный стол. Один грузчик достаёт ящики из машины и ставит их на стол. Второй грузчик забирает ящики со стола и расставляет их по полкам. Важным моментом является то, что второй грузчик может забирать ящики со стола в произвольном порядке, более удобном для расстановки их по полкам Итак, после чтения инструкции и её дешифрации, инструкция попадает в reorder buffer, который, как уже говорилось, может хранить несколько инструкций. В процессе записи инструкции в буфер происходит процесс, называемый "переименование регистров" (register renaming). Во время переименования регистров все физические регистры системы команд (r1, r2, r3, ...) переименовываются в так называемые "виртуальные регистры" (обозначим их мнемониками vr1, vr2, vr3, ...). В первом приближении можно считать, что виртуальные регистры - это такие же регистры, но в отличие от реальных регистров системы команд, виртуальных регистров в процессоре имеется очень много. Процесс переименования выглядит довольно просто. Как только прочитали инструкцию, записывающую в регистр, например, r1, то этот регистр переименовывается, например, в vr1. Далее в последующих инструкциях, которые читают регистр r1, он везде будет переименован в vr1. Но как только встретилась повторная запись в регистр r1, то регистр vr1 как бы умирает (становится не нужным), а в этом месте рождается новый виртуальный регистр vr2, и регистр r1 будет переименован уже в vr2. После этого момента все чтения регистра r1 будут также переименованы в чтение регистра vr2. При очередной записи в регистр r1, виртуальный регистр vr2 умрёт, родится новый виртуальный регистр vr3, регистр r1 будет переименован в vr3 и так далее Продублирую "плохой" код из листинга 1, чтобы он был перед глазами: Code # Листинг 9. Машинный код (1) load [a] -> r1 (2) add r1, 100 -> r1 (3) store r1 -> [a] (4) load [b] -> r1 (5) add r1, 200 -> r1 (6) store r1 -> [b] Code # Листинг 10. Содержимое reorder buffer'а (1) load [a] -> vr1 (2) add vr1, 100 -> vr2 (3) store vr2 -> [a] (4) load [b] -> vr3 (5) add vr3, 200 -> vr4 (6) store vr4 -> [b] Для полноты картины нужно сказать, что количество виртуальных регистров ограничено. Но процессор умеет их переиспользовать по кругу, после того, как они закончатся Теперь посмотрим на back-end. На входе back-end'а есть reorder buffer, наполненный инструкциями, содержащими виртуальные регистры. Задача back-end'а заключается в том, чтобы из этого буфера выбрать инструкции, готовые к исполнению, т.е. те инструкции, у которых все регистры уже вычислены. В нашем случае логика работы back-end'а на первом шаге будет такая. Смотрим на инструкцию (1), у неё нет регистров на чтение, значит её уже можно исполнить. Смотрим на инструкцию (2), она читает регистр vr1, значение которого ещё не готово, значит пока её исполнять нельзя. Смотрим на инструкцию (3), она читает регистр vr2, значение которого ещё не готово, значит пока её исполнять нельзя. Смотрим на инструкцию (4), у неё нет регистров на чтение, значит её уже можно исполнить. Аналогичным образом инструкции (5) и (6) пока исполнять нельзя. Итого, back-end, проанализировав текущее состояние reorder buffer'а, нашёл там инструкции (1) и (4), которые уже готовы к исполнению. Далее эти две инструкции одновременно отправляются на исполнение и удаляются из reorder buffer'а. Таким образом после первого шага получим следующую динамику исполнения и состояние буфера: Code # Листинг 11 # Run-time выполнение (1) load [a] -> vr1 ; (4) load [b] -> vr3 # Содержимое reorder buffer'а (2) add vr1, 100 -> vr2 (3) store vr2 -> [a] (5) add vr3, 200 -> vr4 (6) store vr4 -> [b] Code # Листинг 12 # Run-time выполнение (1) load [a] -> vr1 ; (4) load [b] -> vr3 (2) add vr1, 100 -> vr2 ; (5) add vr3, 200 -> vr4 # Содержимое reorder buffer'а (3) store vr2 -> [a] (6) store vr4 -> [b] Code # Листинг 13 # Run-time выполнение (1) load [a] -> vr1 ; (4) load [b] -> vr3 (2) add vr1, 100 -> vr2 ; (5) add vr3, 200 -> vr4 (3) store vr2 -> [a] ; (6) store vr4 -> [b] # Содержимое reorder buffer'а <пусто> Здесь я всё описал очень упрощённо, на программе, состоящей всего из 6 инструкций, опустив при этом большое количество тонких моментов. В реальности reorder buffer постоянно находится в работе с обеих сторон. Front-end постоянно записывает в буфер инструкции, прочитанные из памяти, а back-end постоянно извлекает из буфера инструкции, готовые к исполнению в данный момент. Сам буфер имеет ограниченный размер, т.е. вполне может получиться так, что front-end не может ничего записать в буфер, т.к. инструкции в back-end'е исполняются медленно. При этом процесс заполнения буфера почти всегда происходит быстрее, чем исполнение инструкций, а потому состояние пустого буфера, как это было в простом примере, в реальной жизни практически не бывает. В нашем примере я предположил, что инструкции (1) и (4) исполнились одновременно, но вполне могло получиться так, что инструкция (1) прочитала данные из кэша, а потому отработала быстро, а для инструкции (4) данных в кэше не оказалось, и она застряла на чтении данных из памяти. В итоге две цепочки у нас могут начать исполняться совсем не параллельно. Точно так же арифметические инструкции могут исполняться за разное время - например, деление выполняется намного медленнее, чем сложение. Есть ещё миллион всяких тонких моментов, из-за которых реальная динамика исполнения может сильно отличаться от теоретической динамики на бумаге. Но общий смысл в том, что back-end постоянно анализирует буфер и пытается поставить на одновременное исполнение максимально возможное количество инструкций. Разумеется, с оглядкой на то, сколько и каких устройств имеется в процессоре 4. Масштабируемость процессора В современных процессорах имеется большое количество исполнительных устройств, которые могут работать одновременно. Многие устройства могут быть продублированы. Например, в процессорном ядре может быть несколько устройств для чтения из памяти или несколько устройств для вычисления арифметических операций. При таком раскладе процессор умеет исполнять N инструкций одновременно. Но чему конкретно равно значение N для конкретного процессора - эту информацию найти довольно сложно. Проблему создаёт то, что число N - маркетинговое. Существуют различные методы того, как посчитать это самое значение, а так же есть проблемы того вида, что не всегда понятно, что именно можно считать полезной инструкцией. Очевидным образом разработчик процессора выберет тот метод подсчёта, который выдаст наибольшее число (немного шире этот момент будет раскрыт в разделе 7). Насколько я себе представляю, в современных процессорах число N имеет величину порядка десяти одновременно исполняемых инструкций за такт, но большой уверенности в этом нет Важно понимать, что число N означает лишь то, сколько инструкций процессор в принципе МОЖЕТ исполнить одновременно. А сколько он будет выполнять в реальности - сильно зависит от конкретной программы. Допустим, процессор может одновременно исполнять 4 целочисленные и 4 вещественные инструкции, т.е. 8 инструкций за такт. Но если программа содержит только целочисленную арифметику, то за такт можно будет исполнить максимум 4 инструкции, а вещественные устройства будут пустовать. Степень загруженности устройств в том числе определяется и тем, насколько эффективно работает процесс переупорядочивания инструкций. Если программа представляет собой длинную последовательность зацепленных друг за друга инструкций (вторая инструкция читает результат первой инструкции, третья инструкция читает результат второй инструкции и т.п.), то такая программа представляет собой длинную RAW-зависимую цепочку, а потому не имеет внутренней параллельности. Т.е. в такой программе попросту нечего переупорядочивать, как итог все инструкции будут выполняться строго по очереди, а большинство устройств будет простаивать С виду кажется, что чем больше поставить исполнительных устройств и чем больше сделать размер reorder buffer'а, тем больше вероятности, что среднестатистическая программа ускорится за счёт того, что у процессора будет больше возможностей для поиска внутренней параллельности и её реализации в виде одновременного исполнения инструкций. Однако всю радужную картину очень сильно портят условные переходы 5. Условные переходы Рассмотрим простой пример на языке программирования: C if (c != 50) a = a + 100; else b = b + 200; Code # Листинг 14. Машинный код (1) load [c] -> r1 (2) cmp r1, 50 # сравниваем значения в регистре r1 и константу 50 (3) beq L1 # если значения равны, то перейти на адрес L1, иначе продолжить исполнение с инструкции (4) (4) load [a] -> r1 (5) add r1, 100 -> r1 (6) store r1 -> [a] (7) b L2 # безусловно перейти на адрес L2 L1: # инструкция (8) имеет адрес в памяти L1 (8) load [b] -> r1 (9) add r1, 200 -> r1 (10) store r1 -> [b] L2: # инструкция (11) имеет адрес в памяти L2 (11) ... Но современные процессоры идут другим путём. В момент дешифрации инструкций условных переходов, а в нашем случае это инструкция (3), процессор пытается угадать, по которому из двух путей пойдёт дальнейшее исполнение. Как выглядит процесс угадывания, напишу в разделе 6. Предположим, процессор решил, что последующее исполнение пойдёт по переходу. В этом случае после того, как в reorder buffer были помещены инструкции (1) и (2), следом за ними в reorder buffer попадут инструкции (8), (9) и (10). Ну а затем по порядку инструкция (11). В момент обработки инструкции (3) процессор мог решить, что исполнение пойдёт по провалу. В этом случае после инструкций (1) и (2) в reorder buffer попадут инструкции (4), (5) и (6). Дальнейшая инструкция безусловного перехода (7) не вызовет никаких затруднений, т.к. у неё есть только одно направление дальнейшего исполнения, а потому в redorder buffer следом попадёт инструкция (11) Таким образом в reorder buffer'е будут лежать инструкции, которые находятся в коде до условного перехода и инструкции по какому-то одному из направлений после условного перехода. А back-end уже будет искать внутреннюю параллельность в этой мешанине и строить одновременное исполнение инструкций Если к моменту вычисления инструкции (2) выяснится, что процессор правильно угадал направление перехода, то всё хорошо. Процессор уже начал исполнение тех инструкций, которые нужно было исполнить. Т.е. мы сэкономили время на том, что не стали ждать результат исполнения инструкции (2). То, что мы выполнили заранее, оказалось реально нужным и можно спокойно продолжать дальнейшую работу. Но если окажется, что направление перехода не угадали (mispredict, branch misprediction), то всё плохо. Мы выполнили инструкции, которые в нормальной работе программы не должны были исполняться. Это означает, что нам нужно отменить результаты выполнения этих инструкций. Т.е. по сути дела нужно откатить состояние процессора на тот момент времени, когда мы начали дешифрацию инструкции (3). В данной статье я вообще ничего не говорил о конвейеризированном исполнении инструкций, но для тех, кто знает об этом, скажу, что нужно в том числе отменить исполнение тех инструкций, которые уже поставлены на конвейер, но ещё не выполнены. А внутри конвейера могут вперемешку находиться "хорошие" инструкции, которые не нужно отменять, и "плохие" инструкции, которые нужно отменить Я не знаю, как технически выглядит процесс отмены исполнения инструкций в случае mispredict'а. Но могу сказать как факт, что этот процесс очень дорогостоящий (т.е. долгий по времени). Конкретные цифры я назвать не могу, но могу сказать, что по некоторым статьям конца 1990'ых годов выходило, что вероятность угадывания в 90% находится на грани рентабельности. Другими словами, если мы угадали направление условного перехода в девяти случаях из десяти, и в одном случае ошиблись, то в среднем мы не получим никакого прироста по скорости исполнения. Возможно, что на сегодняшний день соотношение уже не девять к одному, а меньше, но в любом случае оно очень далеко от соотношения один к одному. Исходя из этого несложно понять, насколько важным является механизм, который позволяет угадывать направление условного перехода. Этот механизм называется "предсказатель переходов" 6. Предсказатель условных переходов Как работает предсказание условных переходов (branch prediction)? В процессоре есть специальная ассоциативная таблица, которая хранит историю того, как работал условный переход. Индексом в таблицу является адрес инструкции условного перехода. Значением является специальный счётчик, описывающий наиболее вероятное направление перехода. Например, изначально для условного перехода было записано нулевое значение счётчика. Как только происходит вычисление точного направления перехода, то процессор модифицирует счётчик. Например, если случился переход, то счётчик увеличивается на единицу. Если случился провал, то счётчик уменьшается на единицу. Если случилось несколько переходов или несколько провалов подряд, то значение счётчика отдаляется от нулевого значения. Таким образом, зная адрес инструкции условного перехода можно быстро получить его наиболее вероятное направление, прочитав значение счётчика из таблицы. Если значение положительное, то вероятнее всего будет переход, если отрицательное, то вероятнее всего будет провал. Такая технология базируется на том, что статистическое большинство условных переходов в программе обладают тем свойством, что несколько раз подряд имеют одно и то же направление перехода. Я расписал один из возможных алгоритмов реализации предсказания переходов. Вполне возможно, что какие-то процессоры используют другие алгоритмы. Но суть от этого принципиально не меняется. Предсказание переходов делается на основании истории поведения отдельной инструкции условного перехода. По умному это называется "на основании профильной информации" Разумеется, таблица счётчиков предсказателя условных переходов обладает конечным размером. Поэтому одновременно историю можно хранить далеко не для всех переходов. Это чем-то напоминает кэш. Если мы взялись за исполнение условного перехода, истории которого ещё нет в таблице, а таблица уже полная, то придётся из таблицы выкинуть историю какого-то другого перехода и на его место записать историю нового перехода (с начальным нулевым значением счётчика). Диапазон значений счётчика не должен быть слишком большим, чтобы не было эффекта залипания. Допустим, 10 раз подряд исполнение перехода шло в одну сторону. В итоге значение счётчика увеличится от 0 до 10. Затем, например, 10 раз подряд исполнение перехода пошло в противоположную сторону. При этом значение счётчика будет уменьшаться от 10 до 0. И во всём этом диапазоне счётчик остаётся положительным, а потому направление перехода будет ошибочно предсказываться в "старую" сторону, т.е. будем иметь многократный mispredict. Из этих соображений разрядность счётчика ограничивается величиной порядка 2 или 3 бит, т.е. с диапазонами значений [-2;+1] или [-4;+3] соответственно Нетрудно догадаться, что при таком эвристическом подходе к предсказанию переходов всегда можно написать тест, в котором можно подогнать динамику исполнения условных переходов таким образом, чтобы предсказатель переходов всегда или почти всегда выдавал mispredict'ы, таким образом, сильно замедляя исполнение кода из-за постоянных откатов конвейера. Здесь ситуация чем-то напоминает hyper-threading - при определённых условиях технология ускорения может давать обратный эффект. К счастью, в подавляющем большинстве реальных случаев мы получаем именно ускорение, а не замедление Я знаю, что в системе команд процессора SPARC у инструкций условных переходов есть специальный бит, при помощи которого программист может подсказать аппаратуре наиболее вероятное направление перехода. Возможно, нечто подобное есть и на других процессорах. Такая подсказка с одной стороны повышает вероятность выбора правильного направления в предсказателе переходов, с другой стороны экономит одну запись в таблице счётчиков, т.к. для таких переходов нет необходимости копить профильную информацию 7. Векторные инструкции В современных процессорах есть инструкции, которые работая на целиковом регистре, логически выполняют одновременно несколько мелкоформатных операций Попробую пояснить на простом примере. Например, у нас имеется процессор, который имеет 64-битные целочисленные регистры. При обработке графических изображений, алгоритмы как правило работают над 8-битными значениями, хранящими цветовые компоненты отдельно взятого пикселя. При этом зачастую приходится выполнять одни и те же действия над различными 8-битными значениями. Для ускорения процесса вычисления в процессор вводят специальные инструкции, называемые векторными инструкциями или SIMD (single instruction multiple data). Значение, записанное в 64-битном регистре, можно трактовать, например, как 8 подряд идущих значений по 8 бит каждое. В процессор вводятся специальные инструкции, работающие над 64-битным значением, но трактующим это значение именно как 8 независимых 8-битных значений и выполняющих одну и ту же операцию над каждым 8-битным куском независимо от остальных 8-битных кусков. Например, инструкция векторного умножения получает на вход два 64-битных значения и трактует их как группы из 8-битных значений, над каждым из которых нужно провести операцию 8-битного умножения. Таком образом создаётся эффект того, что за одну машинную инструкцию над 64-битными значениями логически производится 8 отдельных 8-битных умножений, к тому же всё это делается за то же время, за которое выполняется одно умножение Принципиально такие векторные 64-битные инструкции ничем не отличаются от скалярных (т.е. "обычных" невекторных) 64-битных инструкций. Например, инструкция 64-битного скалярного умножения на вход принимает два 64-битных значения и проводит над ними некоторые манипуляции, которые логически можно трактовать как операцию 64-битного умножения. Можно немного изменить проводимые манипуляции, и это приведёт к тому, что их логически можно будет трактовать как, например, восемь 8-битных операций умножения. Другими словами, векторная инструкция с точки зрения реализации в процессоре принципиально ничем не отличается от скалярной, за исключением того, что это отдельная инструкция и требует дополнительного количества транзисторов Важным свойством векторных инструкций является то, что они могут появиться в программе только явным образом. Т.е. их должен вписать в программу программист или компилятор. Процессор НЕ может автоматически превращать набор инструкций скалярного умножения в инструкцию векторного умножения. Т.е. наличие в процессоре векторных инструкций НЕ позволяет автоматически ускорить старую программу Развитие процессоров пошло по тому пути, что векторные инструкции обычно позиционируются НЕ как часть основной системы команд процессора, а как расширение системы команд. Это означает, что их наличие в процессоре НЕ обязательно. Программа, желающая работать с векторными инструкциями, должна самостоятельно проверять, что текущий процессор, на котором происходит исполнение, их поддерживает. Поэтому многие программы имеют в своём составе условно два комплекта кода, выполняющих одни и те же действия: один комплект написан с использованием векторных инструкций (быстрый код), а другой - без их использования (медленный код). Далее при исполнении на конкретном процессоре в run-time выбирается, который из комплектов кода будет работать. В итоге внешне всё выглядит так, как будто при исполнении задачи на процессоре, имеющем векторные инструкции, задача автоматически ускоряется. Но это не так, задача ускоряется НЕ автоматически, об этом позаботился программист при написании программы. Таких расширений имеется несколько разных наборов, появившихся в разные времена: MMX, 3DNow!, SSE, AVX и т.п. - все они являются наборами расширений, включающих в себя те или иные комплекты векторных инструкций и дополнительных регистров для работы с ними В разделе 4 я уже упоминал о некоем числе одновременно исполняемых инструкций N. Векторные инструкции часто любят считать как несколько одновременно выполняемых арифметических операций, поскольку логически это именно так. Т.е. при вычислении числа N считать, что одна векторная инструкция выполняет, например, 8 операций. Однако с точки зрения процессора исполняется именно одна инструкция, которая технически принципиально ничем не отличается от скалярной. Но по маркетинговым соображениям удобно расписать так, что якобы процессор в этот момент одновременно выполняет 8 операций Для чего векторные инструкции оформляются именно как необязательное расширение системы команд? Это позволяет выпускать процессоры в разной "комплектации". Например, в серверных процессорах, специализированных под какую-нибудь сетевую маршрутизацию, вполне можно обойтись без векторных инструкций, которые критичны для программ обработки графики. Это позволяет сократить количество транзисторов, позволяя на той же площади кристалла разместить больше кэша или больше процессорных ядер 8. Заключение В данной статье я описал лишь небольшую часть того, что есть "под капотом" у процессора, да и то описал всё это в сильно упрощённой форме. В конечном итоге статья преследовала две цели: дать вводный материал для четвёртой статьи и наглядно показать, что производительность процессорного ядра определяется далеко не только одной лишь частотой После прочтения статьи, надеюсь, станет понятно, что можно выпустить разные процессоры, которые на вход будут принимать один и тот же машинный код, на выходе будут формировать одинаковый результат, но при этом всю внутреннюю работу будут проводить разными техническими способами. Вся совокупность внутренних "подкапотных" технологий, которые реализуют разные способы получения требуемого результата для входного машинного кода, называется словом "микроархитектура". Sandy Bridge, Coffee Lake, Bulldozer, Zen - всё это названия различных микроархитектур В качестве небольшого резюме по трём статьям попробую описать небольшой (и далеко не полный) список того, что влияет на производительность процессора:
В итоге многие улучшайзеры в процессоре применяются автоматически. Однако часть улучшайзеров требует обязательной работы со стороны программиста. Практически каждый улучшайзер обладает свойством, что в автоматическом режиме НЕ способен выжать 100% производительности от своих теоретических возможностей. Поэтому программист много чего может улучшить путём тонкой настройки (тюнинг) программного кода под конкретную модель процессора (и даже конкретную модель программного комплекса). Подобные усилия обычно обоснованы в тех случаях, когда предполагается многолетняя эксплуатация одной и той же программы на одном и том же железе - например, в промышленных или военных целях. Во всех случаях предполагается, что программист как минимум должен иметь прямые руки. Криво написанный программный код (так называемый гавн$код) почти наверняка будет работать плохо даже на самом совершенном процессоре Если нужно чисто теоретически сравнить производительность двух процессоров, зная все указанные выше технические характеристики, то подобная задача в общем случае не решаемая, т.к. нужно учесть слишком много различных факторов, как по отдельности, так и в их взаимодействии. Теоретическая прикидка обычно возможна в условиях, когда многие параметры являются близкими по значению. Например, можно грубо прикинуть разницу в производительности между двухъядерным процессором без hyper-threading и четырёхъядерным процессором с hyper-threading, имеющих одну и ту же микроархитектуру (даже с учётом различия частот и объёма кэша). Но практически нереально теоретически оценить разницу в производительности между серверным и ноутбучным процессорами, у которых одинаковая частота. Это как раз тот случай, когда частота не говорит практически ни о чём. У ноутбучных процессоров самым главным критерием является потребление электричества. Поэтому в ноутбучных процессорах очень сильно упрощают микроархитектурную часть. Например, в ноутбучном процессоре может быть упрощённый механизм предсказания переходов, или он может отсутствовать вовсе. При таком раскладе производительность процессора сильно уменьшается, но при этом сильно уменьшается и расход электричества. Поэтому вполне может получиться так, что серверный и ноутбучный процессоры имеют одинаковую частоту, но при этом имеют производительность одного ядра, отличающуюся на порядок. Этот момент важно понимать, чтобы не попасть впросак, оценивая производительности процессора, опираясь лишь на его частоту. Конкретно в данном примере решающим фактором окажется именно различие в микроархитектуре |
Размещено в Работа системы "на пальцах"
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 6
Комментарии
-
Порядок десяти или всё-таки порядка десяти?
Возможно, стоит добавить ссылку на другую твою запись в блоге: Влияние конвейера на скорость исполнения кода
В листинге 14 для разделения второго и третьего аргументов используется запятая, а в предшествующих листингах - стрелочка.
И еще по листингу 14.
Наверное,(5) add r1, 100 -> r1
ведьa
записана вr1
инструкцией (4).
Спасибо за статью.Запись от Croessmah размещена 19.05.2018 в 22:18 -
Опечатки поправил
Что касается конвейера, тут всё немного сложнее. Уж так получилось, что словом "конвейер" в разных контекстах пользуются с разным смыслом
Когда речь идёт о процессорах с одной и той же системой команд, но с разным внутренним устройством, то часто слово "конвейер" используют со смыслом "подкапотная часть" или, что то же самое, "микроархитектура". Так повелось в старые времена, когда микроархитектуры в современном понимании ещё не было, но уже были процессоры от разных производителей, у которых реальный конвейер был устроен по разному. Люди "тех времён" в литературе и словесных описаниях по привычке пользуются этим термином
Ну и второй смысл - это собственно сам процессорный конвейер (fetch, decode, execute, write) и т.п.
В статье по ссылке слово "конвейер" использовалось в первом смысле. Хотя временами я по ошибке словом "конвейер" называл ещё и "исполнительное устройство", которое само по себе выглядит тоже как конвейер. В общем, в той статье мне надо будет навести порядок с терминологией, а то действительно начну путать людей
А "конвейеризированное исполнение инструкций", о котором я упомянул в текущей статье в конце 5-го раздела - здесь слово "конвейер" используется во втором смысле. Про это самое уже есть много статей и книг, в которых матчасть рассказана довольно хорошо. Простым поиском можно найти что-то типа https://en.wikipedia.org/wiki/... pipelining (перевод в https://ru.wikipedia.org/wiki/... 0%B5%D1%80, но он какой-то кривоватый, а потому по возможности лучше читать оригинал)
Конвейеризированное исполнение появилось очень и очень давно (наверное, в 1970-х годах). Это ещё более низкий уровень распараллеливания, чем out-of-order execution. Это такое устройство процессора, при котором инструкции исполняются строго по одной штуке строго в порядке очереди, но при этом процесс выполнения одиночной инструкции внутри процессора разбивается на отдельные стадии исполнения: в простейшем случае это чтение инструкции из памяти (fetch), декодирование инструкции (decode), чтение input-регистров (read), исполнение (execute), запись output-регистров (write). Смысл конвейеризированного исполнения заключается в том, что, например, паять последовательных инструкций одновременно находятся в конвейере, но на разных стадиях исполнения. Пока 5-я инструкция только читается из памяти, 4-я уже декодируется, 3-я уже читает регистры, 2-я уже исполняется, 1-я уже записывает результат
После того, как поработал out-of-order execution, в такой конвейер инструкции попадают в том порядке, в котором back-end выудил их из reorder buffer'а. Дополнительной проблемой предсказания переходов является то, как в случае mispredict'а в таком конвейере отменить выполнение отдельно взятых инструкций. На картинках в конвейере обычно рисуют 4-5 стадий. На современных процессорах количество стадий наверное уже достигает 20. Более того, в back-end'е процессор устроен как VLIW, т.к. имеет широкие инструкции (wide instruction), в которых явным образом закодировано одновременное исполнение (т.е. внутренности устроены так же, как и явная система команд в itanium или elbrus). Это ещё сильнее усложняет механизмы отката после mispredcit'ов
В общем, в процессоре слишком много всяких сложных технологий, выстроенных одна поверх другой. Наверняка там есть много чего, о чём я даже и не слышал. Поэтому в статье я постарался описать лишь поверхностную часть в упрощённой и немного искажённой форме (по отношению к реальному устройству)Запись от Evg размещена 20.05.2018 в 11:15 -
Запись от Croessmah размещена 22.05.2018 в 19:54 -
Да фиг знает, сложно сказать. Старая статья про конвейер ориентирована в чистом виде на программистов. А в этой статье есть шанс, что и непрограммисты что-то поймут, за счёт того, что используются лишь примитивные примеры. Для порядку ссылку надо добавить и добавить описание, что для программистов. Но тут очередная засада с размером текста, он впритык к лимиту. И так много чего пришлось урезать. А аккуратно построенная фраза с ссылкой займёт слишком много драгоценных байтов, которые остались в резерве для исправления ошибок и неточных формулировок (типа тех, которые ты заметил выше). Так что пусть пока ссылка торчит хотя бы из комментария. При случае попробую ещё где-нибудь урезать слова-паразиты, чтобы хоть дышалось свободнее
Запись от Evg размещена 23.05.2018 в 00:29 -
Запись от Croessmah размещена 23.12.2018 в 22:05 -
Запись от Evg размещена 09.01.2019 в 19:49