Процессор изнутри. Часть 0: Теория

Дата публикации 23 янв 2005

Процессор изнутри. Часть 0: Теория — Архив WASM.RU

Нам говорят «безумец» и «фантаст»,
Но выйдя из зависимости грустной
С годами, мозг мыслителя искусный
Мыслителя искусственно создаст.

И. Гёте, «Фауст»

Об этом мануале.

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

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

Для того чтобы «заглянуть» внутрь процессора, мы будем использовать счетчики производительности.

Сразу договоримся, что к софтовым счетчикам (тем, которые лежат в реестре в HKEY_PERFORMANCE_DATA) они никакого отношения не имеют. Софтовые счетчики реализованы чисто программно, ими управляет операционная система; счетчики же с которыми мы будем работать - аппаратные, то есть реализованы физически внутри процессора.

В рамках мануала будут рассмотрены  «внутренности» процессора и логика его работы. Без этого будет очень трудно понять, что именно считают счетчики и как то, что они считают, влияет на производительность процессора в целом. Мануал, кроме данной части, состоит еще из следующих:

1)     Intel P6 architecture

2)     Intel NetBurst

3)     AMD

4)     AMD64

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

В каждой части подробно рассматриваются четыре основных вопроса:

1)      Интерфейс процессора и системы.

2)      Кэширование.

3)      Исполнительное ядро.

4)      Мониторинг производительности.

В отдельную главу выносятся также некоторые детали специфичные для данного процессора, скажем, технология HyperThreading для процессора Pentium 4 или описание шины HyperTransport для процессоров с архитектурой AMD-64.

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

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

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

Физический интерфейс процессоров.

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

Любой сигнал имеет активный и неактивный уровни напряжения. Когда сигнал переключается из одного состояния в другое, напряжение на линии не может измениться мгновенно, процесс переключения всегда занимает некоторое время. Процесс подъема напряжения до активного уровня называется фронтом сигнала, а процесс его спада до пассивного уровня – спадом. Графически это можно показать так:

Частота, с которой происходит синхронизация сигналов, и является частотой системной или «фасадной» (Front side) шины.

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

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

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

Кэш.

Зачем нужен кэш?

Неотъемлемый атрибут всех современных процессоров – кэш-память. Она реализуется на дорогих элементах SRAM (static random access memory) доступ к которым происходит намного быстрее, чем к элементам DRAM (dynamic random access memory) на которых построена оперативная память. Еще одно достоинство элементов SRAM в том, что, в отличие от DRAM, они не требуют периодической регенерации.

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

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

1) На адресные линии банка памяти подается адрес строки (Row).

2) Подается строб доступа к строке (Row Access Strobe – RAS#).

3) На адресные линии подается адрес столбца (Column).

4) Подается строб доступа к столбцу (Column Access Strobe – CAS#).

5) На выходных линиях появляется считанное слово.

Еще контроллер и планка памяти обмениваются сигналами, определяющими операцию (чтение/запись), и выполняющими контроль ошибок (CRC или ECC), но нам эти сигналы сейчас неинтересны.

Между пунктами 2 и 4 происходит задержка, связанная с подготовкой строки – RAS-to-CAS delay – ее обычно можно настраивать в BIOS и ее значение колеблется от 3 до 2 тактов, чем меньше, тем естественно лучше. Между 4 и 5 также происходит задержка CAS latency связанная с передачей данных из «недр» микросхемы на выходные линии.

Банком памяти называется не планка и не микросхема, а сущность способная воспринимать сигналы RAS# и CAS# (это может быть группа микросхем на планке, зависит от реализации).

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

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

Тип кэша.

Кэш может быть построен по одной из 3-х основных схем:

1)      Кэш прямого отображения (Direct mapped cache).

2)      Наборно-ассоциативный кэш (Set-Associative cache).

3)      Полностью ассоциативный кэш (Fully-Associative cache).

В первом случае кэшируемый адрес однозначно определяет строку кэша в которой могут кэшироваться данные. Память делится на некоторое количество блоков (страниц) равных размеру кэш-памяти. Любой адрес в таком случае можно разбить на 2 компонента – индекс страницы, смещение в странице. Второй компонент и является индексом строки кэша. При кэшировании данных по какому-либо адресу памяти, этот адрес сам определяет, в какой строке кэша он должен быть размещен. Такой подход позволяет увеличить скорость работы, поскольку процессору не нужно ничего искать и не нужно смотреть никаких таблиц – ему нужно только знать адрес.

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

Полностью ассоциативный кэш – самый простой для понимания, но зато самый трудный в реализации. Строка кэша состоит из пары «адрес-данные». В любой строке может кэшироваться любой адрес, поиск нужного адреса должен вестись параллельно по всем строкам (это и есть основная сложность реализации).

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

Кэш напоминает кэш прямого отображения и состоит из наборов строк. Набор строк выбирается аналогично строке кэша прямого отображения, но далее, поиск идет параллельно по всем строками набора аналогично полностью ассоциативному кэшу. Набор обычно объединяет 2,4  либо 8 строк.

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

Наглядно 4-ассоциативный кэш можно показать так:

В полях S хранится состояние строки, TAG хранит тег (см. ниже), а в DATA - собственно данные. Индексируется только первая страница, при выборе в ней какой-то строки выбираются также и строки расположенные по этому же смещению в других страницах.

Строки, расположенные по одному смещению в разных страницах и образуют набор.

Поясню, зачем нужен именно набор строк. Рассмотрим кэш прямого отображения, в котором набор состоит из одной строки.

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

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

Политика записи.

Политика записи кэша определяет, как реализована запись в основную память.

Существуют две основные политики кэша: Write Through и Write Back (далее WT и WB).

В случае WT любая запись в память выходит на внешнюю шину и пишется в память сквозь (through) кэш. Во втором случае данные записываются в кэш, и устанавливается определенный флаг, указывающий на то, что эту строка модифицирована (ее копия в кэше отличается от того, что находится в памяти по данному адресу). Таким образом, кэш обслуживает обращения к памяти, как по чтению, так и по записи.

В случае одного процессора в системе политика WB работает прекрасно, в случае же двух и более процессоров нужно обеспечивать согласованность данных в разных кэшах.

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

Уровни кэша.

Разделение кэш-памяти на несколько уровней (до 3 в настоящее время) производится по следующему закону: кэш-память уровня N+1 всегда больше по размеру и медленнее по скорости обращения, чем кэш-память уровня N. Поясню, почему именно так.

Самой быстрой памятью является кэш-память первого уровня (она же L1-cache), по сути, она является неотъемлемой частью процессора, поскольку расположена на одном с ним кристалле и входит в состав функциональных блоков, без нее процессор не сможет функционировать. Память L1 работает на частоте процессора и обращение к ней занимает в общем случае один такт ядра, объем этой памяти обычно невелик - не более 64Кб. Второй по быстродействию является L2 (в отличие от L1 ее можно отключить с сохранением работоспособности процессора), кэш второго уровня, она обычно расположена либо на кристалле, как и L1, либо в непосредственной близости от ядра, например, в процессорном картридже (только в слотовых процессорах), в старых процессорах ее располагали на системной плате. Объем L2 побольше – от 128Кб до 1-2Мб.

Посмотрим, как работает эта иерархия: когда процессор обращается к памяти, считанное значение заносится в кэши всех уровней, так происходит всегда, новое значение в соответствии с некоторым алгоритмом замещает какую-то строку и в L1 и в L2. Если процессор в течение некоторого времени не будет обращаться к этим данным, очень вероятно, что они будут замещены другой строкой в L1, но поскольку размер L2 больше чем L1, данные сохраняются некоторое время в L2, до тех пор, пока не будут замещены и там. Если вдруг процессору понадобятся данные, которые были выгружены из L1, есть вероятность что данные есть в L2, обращение к которому хоть и дольше по времени чем к L1, но все же намного быстрее чем обращение к основной памяти. Из этих рассуждений понятно, почему размеры кэш-памяти должны расти с увеличением n в Ln, иначе теряется смысл в использовании дополнительного уровня – выгруженные из L1 данные имеют нулевые шансы сохраниться в L2.

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

Работа кэша.

Рассмотрим работу наборно-ассоциативного кэша, так как именно он применяется сегодня наиболее широко.

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

Физический адрес разбивается на 3 (в общем случае) составляющие. Offset зависит от размера строки кэша и используется для индексации нужного байта в строке, например, если в строке вмещается 32 байта, то размер поля offset  5 битов. Поле Index зависит уже от размера страницы кэш-памяти. Это поле служит индексом набора строк кэша (в процессорах AMD набор (set) называется банком (bank)).

Физический адрес:

TAG

INDEX

OFFSET

При обращении к кэшу, процессор с помощью поля index, находит нужную строку, далее,

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

В следующей части подробно рассматриваются обращения по чтению и записи на примере P6.

Размер кэшируемой области памяти.

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

Например, если кэш прямого отображения размером 1Мб должен кэшировать память объемом 4Гб, то размер TAG должен быть не менее 12 бит.

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

В общем случае если размер поля tag равен N бит, а размер страницы кэша равен S байт, то кэшироваться могут первые S*(2^N) байт памяти. Это ограничение применимо к кэш-памяти прямого отображения и к наборно-ассоциативному кэшу. Для полностью ассоциативного кэша работает другая формула  – размер кэшируемой области = 2^N, где N – разрядность поля TAG.

В случае наборно-ассоциативного кэша, размер страницы определяется путем деления размера кэша, на количество страниц – скажем, если кэш размером 256Кб является 4-ассоциативным, то размер его страницы – 256К/4=64К.

К сожалению, размер tag не всегда позволяет кэшировать всю память. Из MSR-регистра (см. ниже) по адресу 0x11E для P6 можно узнать размер кэшируемой области для L2.

Разделенный кэш.

В реальных процессорах кэш L1 обычно разделен на 2 части – кэш L1 для данных и L1 для инструкций. Это так называемая Гарвардская архитектура, в противоположность Принстонской с общим кэшем. Разделение кэшей позволяет оптимизировать каждый из них под определенные функции и разметить поближе к соответствующим функциональным блокам: L1 инструкций - поближе к устройству выборки команд, а L1 для данных - поближе к исполнительным устройствам.

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

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

TLB буферы.

По сути  TLB (Translation Look-aside Buffer) буфер тоже является разновидностью кэша, с той лишь разницей, что он кэширует не данные, как обычный кэш, а адреса.

Для того чтобы понять, какие адреса надо кэшировать, вспомним, как процессор работает в защищенном режиме с включенной страничной адресацией (рассмотрим только базовый механизм): адрес разбивается на 3 составляющие, 2 из которых  являются индексами с системных таблицах страниц подготовленных ОС, а 3-я часть смещением в странице. В теории, процессор должен вычислять новый физический адрес для каждого линейного. На практике, если процессор для любого адреса, генерируемого программой начнет лазить по памяти и искать там таблицы – это будет крайне медленно. Поэтому процессор кэширует в TLB соответствие логических и физических адресов для наиболее часто используемых страниц. Некоторые инструкции, вроде mov cr3, вызывают очистку буфера (это же, явно или неявно, делают еще несколько инструкций).

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

TLB первого уровня для данных в современных процессорах обычно делают полностью ассоциативными.

Ядро процессора.

Конвейер.

Рассмотрим гипотетический процессор с 4 функциональными блоками – выборки, декодирования, выполнения, сохранения результатов. (Структура этих блоков нас в данный момент не волнует).

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

Есть последовательность команд: К1,К2,К3,К4

Если выполнение каждой команды занимает 4 такта процессора (предположим что выполнение каждого из перечисленных выше этапов занимает 1 такт), то все они выполнятся за 4*4=16 тактов.

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

Такты

Выборка

Декодирование

Исполнение

Запись результата

1

К1

2

К1

3

К1

4

К1

5

К2

6

К2

7

К2

8

К2

9

К3

10

К3

11

К3

12

К3

13

К4

14

К4

15

К4

16

К4

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

Такты

Выборка

Декодирование

Выполнение

Восстановление

1

К1

2

К2

К1

3

К3

К2

К1

4

К4

К3

К2

К1

5

К4

К3

К2

6

К4

К3

7

К4

Как видно из таблицы выполнение данной последовательности произошло за 7 тактов, то есть получено более чем 2-кратное ускорение работы без повышения тактовой частоты. На 4 такте конвейер работает с максимальной эффективностью, он загружен на 100%, заняты все его исполнительные блоки.

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

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

Если какой-то блок конвейера вносит задержку, то тормозится работа всего конвейера:  образуется так называемый “пузырь” (pipeline bubble), который должен пройти от места своего возникновения, до самого конца конвейера (если, например, возникла задержка на ступени декодирования, то в следующем такте блок выполнения от него ничего не получит, а через 2 такта, соответственно блок сохранения результатов ничего не получит от блока выполнения) . Таким образом, скоростью конвейера является скорость самой медленной его ступени.

Возможно, кому-то это покажется интересным, но именно конвейеризация приводит к «отложенным переходам» характерным для архитектуры SPARC. Отложенным переходом называется ситуация когда процессором сначала исполняются одна или несколько команд следующих ПОСЛЕ безусловного или условного перехода. Происходит это по следующей причине – процессор «узнает» что выбранная им команда – команда перехода только на этапе ее декодирования, причем, согласно логике конвейеризации, команда, следующая за переходом, уже выбрана блоком выборки, поэтому с точки зрения производительности было бы удобно не отбрасывать ее, а выполнять, как если бы она находилась ДО перехода. Так и происходит. Величина слота перехода (слот перехода – количество команд выполняемых безусловно, как если бы они находились до перехода в коде программы) зависит от того насколько рано процессор «узнает» о том что только что выбранная им команда – команда перехода, чем позднее это происходит – тем больше слот перехода. Это свойство в наибольшей степени соответствует философии RISC, потому что у CISC процессоров (которыми являются все процессоры x86) инструкции имеют разную длину, в связи с чем, проще отбросить их на этапе выборки.

Нелинейный конвейер.

Конвейер в реальных процессорах  сильно отличается от его теоретической модели. В большинстве случаев применяется единый нелинейный конвейер, его отличие от линейного показано на рисунке:

Сверху показан линейный конвейер, снизу – нелинейный.

Нелинейный конвейер на рисунке – гипотетический, такой конвейер нигде не применяется, я привожу его просто для демонстрации.

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

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

Суть декодирования в том, что команда разбивается на элементарные составляющие называемые микрооперациями, например, команду add eax,[esi] можно разбить на 2 микрооперации – первая считывает значение из памяти по адресу ESI, а вторая – складывает это значение с регистром EAX.

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

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

Наконец, выполнение – обычно самая медленная операция, она может выполняться 5-ю и более устройствами параллельно (имеется ввиду, что за один такт может быть выполнено 5 и более команд, а не выполнение одной команды 5-ю устройствами).

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

Конфликты в конвейере.

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

Конфликты бывают 3-х основных типов:

1)      Конфликт по данным.

2)      Конфликт по управлению.

3)      Структурный конфликт.

Конфликт по данным происходит, когда команда N использует результат команды N-1. При исполнении в конвейере первая команда выполняется и переходит на ступень сохранения результатов, в этот же момент вторая команда (использующая результаты первой) поступает на ступень выполнения, но так как результаты первой команды еще не сохранены, то возникает конфликт – второй команде приходится ждать – возникает «пузырь» со всеми его негативными последствиями.

Конфликт по управлению возникает в случае, если команды изменяют значение счетчика команд (EIP). При этом нарушается естественный линейный ход программы и возникает конфликт.

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

Предсказание переходов.

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

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

Процессор оказывается в затруднительной ситуации – откуда выбирать команды дальше? Самое простое решение – останавливать выборку до вычисления условия. В таком случае (с учетом того, что переходы встречаются в среднем через каждые 20-30 команд) “пузыри” в конвейере сведут на нет все плюсы конвейеризации.

Для минимизации проблем связанных с переходами был разработан механизм прогнозирования. Суть его в том, что процессор пытается (по определенным критериям) «угадать» будет переход или не будет. В случае успеха, работа конвейера не прервется, и к моменту фактического исполнения перехода исполнительными устройствами процессор уже будет иметь выбранные и декодированные команды после него, в случае же промаха придется сбрасывать все команды, декодированные и выполненные после перехода и перезагружать конвейер в соответствии с новым адресом. Механизмы прогнозирования ветвлений обсуждаются для каждого процессора отдельно.

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

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

Переименование регистров.

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

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

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

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

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

Как можно увидеть, после декодирования команды N которая пишет в логический регистр R, все прочие команды, использующие в качестве операнда R будут обращаться к физическому регистру, выделенному для команды N. При этом, если какая-то команда после N будет писать в тот же логический регистр ей будет выделен новый регистр и все команды после нее будут использовать уже новый регистр. Это лучше показать на примере.

1

Команда пишет в R0

С этого момента R0 соответствует выделенному для команды регистру PHY0

2

Команда читает из R0

Читает из PHY0

3

Команда пишет в R0

С этого момента R0 соответствует выделенному для команды регистру PHY1

4

Команда читает из R0

Читает из PHY1

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

В современных процессорах набор архитектурных регистров отделен от файла временных регистров. Запись в архитектурные регистры возможна только в результате восстановления команды.

Механизм динамического переименования регистров не относится к очевидным вещам, поэтому если Вы чего-то не понимаете – это нормально. Признаюсь, что и я сам полностью постиг суть и принципы работы технологии лишь спустя пару месяцев после первого с ней знакомства.

Технология Data forwarding.

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

Технология Data forwarding была разработана как раз с целью устранить реальные зависимости по данным в последовательных командах.

Прежде чем рассматривать ее подробно, разберемся с тем, как работает классическое АЛУ (арифметико-логическое устройство) – оно имеет 2 входа для операндов, входы для указания операции, один выход для результата и выходы для установки флагов по результатам выполнения операции.

Для того чтобы посчитать C=A+B нужно проделать следующие действия:

1)      подать первый операнд на шину A

2)      подать второй операнд на шину  B

3)      установить код операции «сложение»

после этого на выходе C появится результат.

Какие регистры подавать на какие шины, и какую операцию делать АЛУ определяют поля микрооперации выполняемой в данный момент.

Если следующая команда использует этот результат, ей нужно ждать пока значение C перепишется из выходов АЛУ в регистровый файл (RRF – retirement register file), после чего его можно будет снова подать на шину одного из операндов и использовать при выполнении следующей операции.

При этом результат команды совершает следующее путешествие – выход АЛУ – регистровый файл – вход АЛУ. Регистровый файл в данном случае – лишнее звено, он только вносит дополнительную задержку.

Процессор, поддерживающий технологию Data forwarding, поступает по другому – при исполнении команды, если ее результат нужен следующей, сигналы с выходов АЛУ подаются обратно на входы.

Имеется специальное устройство, условно назовем его DF. Оно управляется частями процессора ответственными за переименование регистров. Если процессор определяет, что текущий операнд нужен следующей команде, он указывает DF какую шину нужно соединить с выходом АЛУ. В следующем такте параллельно начинается сохранение результата предыдущей команды и выполнение текущей.

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

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

В реальных процессорах все немного по-другому, но на данном этапе их рассматривать еще рано.

Суперскалярные процессоры.

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

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

Возникает резонный вопрос: «Если последовательность команд нарушить, в общем случае получим неверные результаты?». Ответ – не получим, применение специальной техники позволяет делать еще и не такое.

Типичный суперскалярный процессор можно представить в таком виде:

В эту схему, правда, с некоторыми оговорками, вписываются все рассматриваемые нами процессоры от Pentium II до AMD Opteron.

Работает это так: блок выборки и декодирования выбирает команды из памяти, декодирует их на микрооперации, после чего последние попадают в пул инструкций (также называемый буфером команд или резервационной станцией), откуда их по мере готовности выхватывает исполняющее ядро, порядок их исполнения может отличаться от предписанного программой. В результате стараний блока переименования регистров, микрооперации, попадающие в пул команд, зависят друг от друга только там где реально есть зависимость. Готовность микрооперации определяется готовностью ее операндов и готовностью исполнительного блока, который может выполнить ее. Как только микрооперация становится готова к исполнению, она тут же передается в функциональное устройство. Важно понимать, что выполнение микрооперации может начаться и ДО того момента как до нее дойдет очередь в потоке команд.

После исполнения, микрооперация вместе с результатами своего исполнения (которое хранится обычно либо в полях микрооперации, либо во временных регистрах) возвращается обратно в пул инструкций. Устройство восстановления последовательности ищет выполненные команды в пуле команд и восстанавливает их в последовательности программного кода. Операция восстановления включает запись информации из временных регистров в постоянные и инкремент на длину команды указателя инструкций (регистра EIP в процессорах х86). Команда считается выполненной не тогда, когда она собственно выполняется исполнительными устройствами, а тогда, когда она восстанавливается.

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

MSR регистры.

Что такое MSR?

MSR расшифровывается как Model-Specific Registers – модельно-специфические регистры.

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

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

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

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

Программное взаимодействие с MSR.

Для взаимодействия с MSR введены (с Pentium) две новые команды RDMSR и WRMSR. RDMSR принимает в ECX номер MSR-регистра (эти регистры не имеют имен, к ним обращаются по номерам), а возвращает значение 64 битного регистра в EDX:EAX. Вторая команда аналогично принимает в ECX номер и переписывает содержимое пары EDX:EAX  в выбранный MSR регистр.

Счетчики производительности.

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

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

В ранних процессорах считывание счетчиков могло осуществляться только через команды доступа к MSR – RDMSR. Начиная с процессоров P6, появилась новая команда – RDPMC (ReaD Performance Monitoring Counters). Эта команда принимает в ECX номер счетчика, и после своего выполнения возвращает в EDX:EAX значение выбранного счетчика.

Эта команда очень удобна, ее выполнение можно разрешить на любом уровне привилегий (через регистр CR4), тогда как RDMSR можно выполнять только при CPL=0.

Kernel-mode драйвер MSR.SYS.

Доступ к MSR должен быть разрешен только коду с CPL=0. Неосторожное изменение параметров в этих регистрах может привести к разным последствиям от перезагрузки до изменения аппаратной конфигурации процессора на время сеанса. Например, в P6 можно одной командой WRMSR отключить L2 (операционная система при этом зависает, так как кэш отключается сразу, обратная запись не выполняется, и данные теряются).

Если не знать что и как туда писать то с вероятностью 70% вы получите BSOD (Blue screen of death) при записи (а в некоторых случаях даже при чтении) под w2k/XP, а с вероятностью 20% компьютер вообще перезагрузится. 

Так как управление счетчиками мониторинга происходит именно через MSR нужно как-то получить доступ в самое «сердце» процессора. Эту задачу и решает драйвер режима ядра msr.sys.

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

Драйвер в процедуре своей инициализации DriverEntry устанавливает 8-ой бит в регистре CR4, чем разрешает выполнение команды RDPMC на любом уровне привилегий, это нужно для считывания счетчиков мониторинга производительности на CPL=3.

Далее драйвер создает виртуальное устройство с именем msr и символьную ссылку на него с тем же именем.

Реализованы 2 функции – запись в msr регистр и чтение из него. Программа передает драйверу функцию (чтение или запись) и 12 байт в буфере, 4 первые – индекс регистра, после этого 8 байт – значение регистра.

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

Код (Text):
  1.  
  2. .if eax == MSR_READ            
  3.    mov ecx,dword ptr [ebx]    ;Извлекаем из буфера адрес MSR
  4.    rdmsr                      ;Читаем этот регистр.
  5.    mov dword ptr [ebx+4],eax  ;Помещаем в буфер младшие 4 байта
  6.    mov dword ptr [ebx+8],edx  ;и старшие 4 байта
  7.    mov dwBytesReturned,12     ;Размер передаваемых данных - 12 байт (адрес
  8.                               ;переданный программой тоже ей возвращается)
  9.  .elseif eax == MSR_WRITE
  10.    mov ecx,dword ptr [ebx+0]    ;Извлекаем из буфера индекс регистра
  11.    mov eax,dword ptr [ebx+4]    ;и 8 байт его нового значения
  12.    mov edx,dword ptr [ebx+8]
  13.    wrmsr                    ;И пишем значение в регистр
  14.    mov dwBytesReturned,0            ;Программе ничего в буфере не возвращаем
  15. …                     ;Обработка неверного номера функции
  16. .endif

Полный исходник драйвера прилагается к мануалу. Компилить сие творение надо MASM-ом, bat-файл кимпилящий драйвер также прилагается. Для компиляции нужно скопировать инклуды и либы в соответствующие каталоги, после чего скопировать папку содежащую 3 файла – asm bat и rc в каталог masm32/bin. После отработки bat-а в той же папке появится свежескомпиленный msr.sys.

msrdrv.h

Для облегчения установки и удаления драйвера я разработал включаемый файл, в котором реализованы все необходимые функции и подключено все что нужно. Объявление экземпляра объекта класса MSRInterface приводит к автоматической проверке наличия файла msr.sys в каталоге программы и если он там есть – производится создание виртуального устройства, символьной ссылки и регистрация драйвера в системе. Для программиста все это происходит прозрачно, нужно только объявить объект, после чего становятся доступны функции <имя_объекта>.WriteToMSR и <имя_объекта>.ReadFromMSR.

Значение msr-регистра описывается объединением (имеется ввиду структура данных в С++ «объединение») MSR описание которого можно увидеть в исходниках. Данный подход позволяет обращаться к значению регистра по двойным словам и учетверенным словам.

MSR tool.

 

Специально для любителей самостоятельно ковыряться во внутренностях своих кремниевых друзей я разработал одну небольшую утилиту – MSR tool. Данная утилита позволяет читать и писать любые значения в любые MSR регистры.

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

Тем, кто решит самостоятельно экспериментировать с программой, советую вначале внимательно ознакомиться с разделом Model-Specific Registers в мануале по тому процессору, на котором происходят эксперименты, иначе результат этих экспериментов непредсказуем.

Окно программы выглядит так:

В верхнее поле заносится значение MSR регистра, поле допускает ввод только шестнадцатеричных чисел. В нижнее поле заносится индекс или адрес MSR регистра, который нужно считать или записать, в этом поле можно вводить числа в 10-ой или 16-ой системе счисления, 16-ые числа должны быть указаны с префиксом “0x”.

Read считывает значение указанного регистра и отображает его в верхнем поле. Write записывает значение регистра указанное в верхнем поле в нужный MSR. Clear очищает оба поля.

В качестве демонстрации можете провести следующий безобидный эксперимент – считать счетчик тактов не через команду RDTSC, а напрямую из регистра. Так, счетчик является во всех рассматриваемых процессорах MSR регистром с адресом 16 (0x10).

Введите в нижнее поле значение “0x10” и нажмите read. В верхнем поле отобразится текущее значение счетчика тактов. Должно получиться что-то вроде этого:

Важный момент: если Вы пренебрегли чтением мануала и компьютер перезагрузился во время работы программы то закрытие хэндлов драйвера не произошло корректно и драйвер остался «висеть» в реестре.

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

Чтобы вылечить этот недуг нужно вручную удалить раздел реестра:

HKLM\SYSTEM\CurrentControlSet\Services\MSR

И после этого перезагрузиться.

Processor spy – performance monitoring packet.

В следующих частях мы будем отслеживать события микроархитектуры процессора используя разработанный мной пакет программ Processor spy. Этот пакет пока включает в себя 4 программы – по одной для каждого типа процессора из тех, которые мы рассматриваем. В объединении всех процессоров в одной программе я смысла не вижу, так как процессор в системе не меняется в течение длительного времени, проще один раз скачать то, что нужно и использовать, не таская с собой «балласт» кода предназначенного для других процессоров. Программа написана на C++ Builder 6.0. Именно этот компилятор был выбран по тем соображениям, что он имеет встроенный компонент TPerformanceGraph – график производительности. У меня не было особого желания реализовывать его самому, поэтому я использовал готовый – стандартный программистский подход J.

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

Весь пакет переделывался и переписывался по крайней мере 4 раза, поэтому когда были написаны статьи, я решил не изобретать велосипед и оставить все в той форме в которой это было протестировано. Любая оптимизация приводила к тому, что нужно было снова искать нужный процессор и тестировать на нем новую версию, поэтому поймите меня правильно J.

Заключение.

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

Хотелось бы выразить благодарность Four-F замечательные статьи которого очень помогли в создании драйвера.

Еще хочу поблагодарить E}I{-а редактировавшего статью, и фактически являющегося соавтором, а также всех людей на чьих компьютерах я тестировал (и разрабатывал) программы (в алфавитном порядке):

это bucher, decibel, GRM, korn, Mr_Depth, Proton, Vanadix, ][-vir и другие.

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

В споре рождается истина, так что…

мой почтовый ящик Dark_Master@tut.by  

Кстати, все любители поговорить о процессорах и их внутреннем устройстве приглашаются на IRC канал #system на сервере IRC.BY или IRC.BYNETS.ORG.

(С) Dark_Master 2004

© Dark_Master

0 4.308
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532