Что надо знать программисту об устройстве современного процессора

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

Эволюция микропроцессора

Когда первые CMOS-микропроцессоры в начале 70-ых были представлены на рынке, они вызывали смех у гигантов компьютеростроения. Вычислительные мощности микропроцессоров были ничтожными, быстродействие смехотворным, а возможности — просто детскими. По своим характеристикам они подходили разве что для микрокалькуляторов и для игрушечных наручных часов. Для серьезной вычислительной техники гиганты индустрии создавали своих монстров на другой технологической базе и считали, что мелкашки всегда останутся в нише несерьезной техники.

Но с начала 70-ых CMOS-технология не стояла на месте. Эмпирический закон масштабирования Деннарда подсказал, что CMOS-технология способна повысить быстродействие микросхем, если уменьшить размеры её элементов. Поэтому с начала 80-ых годов начался неумолимый ход уменьшения технологического процесса в производстве микроэлектроники.

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

Чтобы процессор работал быстрее

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

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

  • Уменьшение техпроцесса (см. выше)
  • Перенос на подложку процессора многих деталей (графическая подсистема, кэши, контроллеры памяти, PCI, системы ввода-вывода) (system-on-a-chip)
  • Реализация в железе того, что до этого делалось программно (floating point arithmetics, vector operations)
  • Иерархия кэшей (mulit-level cache hierarchy)
  • Конвейер (pipelining)
  • Внеочередное исполнение инструкций (out-of-order execution)
  • Предсказание переходов (branch prediction)
  • Отложенная запись (write-back)
  • Суперскалаярность (super-scalarity)

Расскажу о некоторых из них.

Иерархия кэшей

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

В иерархии кэшей в современных процессорах может быть три уровня таких модулей. Первый уровень L1 делится на две секции: L1d, где кешируются данные (data), и L1i, где кешируются инструкции (instructions). Чуть дальше от ядра процессора расположен кэша 2-ого уровня — уровень L2. Он по размерам больше кэша первого уровня, и хранит оба типа данных. И наконец — третий уровень L3 — самый большой, но он еще и является общим для всех ядер многоядерного процессора. О скорости доступа к кэшам различных уровней рассказано в знаменитой статье.

Иллюстрация взята с сайта

О том, как работает эта иерархия кэшей, прекрасно и подробно описано в книгах и статьях «о железе».

Программисту следует помнить, что при выборке данных из памяти, в кэш записывается сразу блок таких данных (cache line). И если ваши данные разбросаны по памяти (например, ваши Java-объекты «размазаны» по разным уголкам heap), процессор затратит больше времени на обход этих данных, чем если б они были расположены в памяти рядом друг с другом. Исходя из вышесказанного, хранение данных в массивах (array) иногда выгоднее, чем хранение тех же данных в связанном списке (linked list).

Также следует помнить, что и команды процессор получает из памяти тоже блоками. Следовательно, если в вашем коде много переходов (jumps) с одного места на другое, процессору потребуется больше времени на исполнение такого кода, так как надо будет всякий раз обращаться за новым куском кода в память. В этой связи старайтесь избегать большого количества if/else, switch конструкций. Старайтесь по возможности делать код линейным.

Исходя из вышесказанного, ваш код должен быть «дружелюбным» к кэшу процессора (cache-friendly). В этом случае он будет исполняться на несколько порядков быстрее.

Размеры кэшей будут расти в ближайшем будущем благодаря все более совершенной технологии изготовления чипов. AMD, например, недавно представила технологию V-Cache, благодаря которой в ее процессорах Ryzen размер кэша третьего уровня вырастет до фантастических 192 Мб!

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

В современных процессорах есть специальный блок предсказания переходов (branch prediction). По мере выполнения вашего кода, процессор собирает статистику, какие ветки кода (условные переходы if/else) выполняются чаще всего. Предполагается с большой долей вероятности, что при следующем выполнении того же кода, будет вызвана та же ветка, что и в предыдущий раз. С этой целью кусок кода, который принадлежит этой ветке извлекается из памяти, декодируется и исполняется заранее в надежде, что когда условный переход будет вычислен, у процессора уже будет готовый исполненный код этой ветки (execution path). Если так оно и оказывается, процессор быстро «пролетает» этот кусок кода и двигается дальше.

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

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

Конвейер

О том, что такое конвейер и как он работает прекрасно изложено как в Википедии так и в книгах по железу. С помощью конвейера (pipeline) процессор за один такт делает сразу несколько операций: выборку следующей команды, декодирование, исполнение, запись. В ранних процессорах все эти операции происходили последовательно: сначала команда считывалась из памяти (fetch), потом декодировалась (decode), потом исполнялась (exec), потом результат записывался в память (write-back). Когда, скажем, команда считывалась из памяти, все остальные модули процессора простаивали в ожидании, пока до них дойдет очередь. Как только бюджет транзисторов это позволил, в микропроцессор был добавлен конвейер (например, для архитектуры x86 это произошло в процессоре 80486), который значительно ускорил исполнение кода и уменьшил время простоя процессора в пустых ожиданиях.

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

Внеочередное исполнение инструкций

Но при некоторых операциях конвейеру приходилось простаивать в ожидании поступления данных из памяти или с диска. Но ведь в коде всегда присутствуют места, которые независимы друг от друга и результат исполнения одного кусочка кода никак не связан с результатом другого. Потенциально эти кусочки кода можно исполнить одновременно в любом порядке. Так в микропроцессорах появилась реализация идеи внеочередного исполнения инструкций (out-of-order execution).

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

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

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

Отложенная запись

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

Более того, из-за внеочередного исполнения инструкций, результаты в память могут быть записаны в другом порядке. А это приводит к некоторым интересным последствиям при написании многопоточных программ. Здесь становится важным понимание таких концепций как volatile, барьеры памяти (memory fence) , синхронизация, мутексы, семафоры, модель памяти и проч.

Суперскалярность

Как только транзисторный бюджет позволил, в микропроцессорах было реализовано несколько конвейеров. Так микропроцессоры стали суперскалярными (super-scalar).

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

Параллелизм уровня инструкций

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

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

Параллелизм уровня потоков и процессов

Если данный конкретный поток команд уже невозможно уже больше распараллелить, то нельзя ли просто в самой программе разбить операции на несколько потоков, которые бы исполнялись одновременно и независимо друг от друга. Здесь мы начинаем говорить о параллелизме уровня потоков (thread level parallelismTLP). Идея может быть реализована несколькими способами.

Многопроцессорность

Если один процессор у нас занят одной задачей, то почему бы не установить в компьютер еще один процессор? Тогда мы могли бы исполнять на двух процессорах две разные задачи и таким образом удвоить свою производительность! А если таких процессоров поставить четыре? Шесть? Сто шестьдесят шесть?

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

Двухпроцессорные рабочие станции и сервера появились в начале 90-ых. Это был самый простой и естественный способ увеличения производительности машин. Такие машины называлась симметричными мультпроцессорами. Поскольку все процессоры в системе были равноправными и имели одинаковый доступ к общей памяти (shared memory).

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

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

Многопоточность

Если в суперскалярном микропроцессоре какие-то конвейеры вынуждены простаивать из-за отсутствия команд, которые можно исполнить параллельно, почему бы на этом конвейере не исполнить тогда команды другого потока? Если у процессора два конвейера, каждый конвейер можно отвести под исполнение одного потока. А если таких конвейеров четыре? Тогда под каждый поток можно отвести по два конвейера. Либо под каждый поток отвести только один конвейер и получить одновременное исполнение 4-х потоков!

Конкретная реализация такого механизма в микропроцессорах зависит от бюджета транзисторов и предпочтения инженеров. Известно два подхода: одновременная многопоточность (simultaneous multi-threading — SMT) и временная многопоточность (temporal multithreading).

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

Многоядерность

В середине 2000-ых годов бюджет транзисторов позволил инженерам создать многоядерные процессоры. Достигнуто это было за счет увеличения количества вычислительных ядер в CPU путем простого их размножения.

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

Посмотрите, как выглядит эта красота под микроскопом! Четыре повторяющиеся части в центре изображения — это четыре вычислительных ядра. Ячейки рядом с ядрами — это ячейки кешей второго уровня L2. Ряд ячеек между ядрами — это кэш третьего уровня L3 общий для всех ядер. По краям подложки микропроцессора — системы ввода-вывода, контроллеры памяти и прочее. Перед вами 4-х ядерный процессор AMD Opteron!

А теперь всё вместе

На иллюстрации ниже вы видите примерную серверную конфигурацию 2012 года с 2-мя процессорами Intel Sandy Bridge, каждый из которых имеет N ядер. У всех ядер процессора общий кэш третьего уровня L3. Каждый из процессоров имеет прямую связь со своим банком памяти, и имеет доступ к банку памяти соседнего процессора через быструю шину связи QuickPath Interconnect. Через эту же шину процессоры согласуют свои кэши, так что запись данных в кэше одного процессора видна всем ядрам другого процессора.

Иллюстрация взята с сайта Mechanical Sympathy

Если на процессоре имеется 8 вычислительных ядер, и таких процессоров на сервере два, в распоряжении многопоточного приложения оказывается 16 полноценных вычислительных ядер, которые потенциально могут выполнять 16 потоков приложения одновременно. А если каждое ядро еще и поддерживает многопоточность, то вы в итоге можете получить параллельное исполнение 32 потоков!

Из-за такой конфигурации при исполнении программы на ней проявятся интересные нюансы:

  • если поток программы исполняется на ядре C1 процессора в сокете Socket 1, а данные находятся в банке памяти, принадлежащем процессору из Socket 2, доступ к этим данным будет медленнее через QPI, чем если они были в родном банке памяти. Из-за того что банки памяти разделены, мы наблюдаем неединообразный доступ к памяти (Non-uniform memory access, NUMA). Следовательно, чтобы данные извлекались быстро, они должны быть в банке памяти того процессора, где исполняется ваша программа
  • если ваш поток исполнялся на C1 процессора в сокете Socket 1, а потом планировщик перебросил его на C2 процессора в сокете Socket 2, потоку после «пробуждения» придется заново вытаскивать из памяти все данные, так как они остались в кэшах процессора Socket 1. Следовательно, чтобы поток работал быстро, надо его «прикрепить» (pin) к определенному процессору и не давать планировщику перетаскивать его на другой процессор. Будет еще лучше, если поток все время будет работать только на одном и том же ядре, так как у него всегда будут «под рукой» данные из кэша L1 и L2. А еще лучше, чтобы вообще никакой другой поток на этом ядре не работал. Тогда в кэшах L1 и L2 всегда будут данные только данного потока.
  • у каждого процессора имеется свой собственный кэш L1 и L2. Как было сказано выше, данные в кэш из памяти выбираются блоками (cache line). Если ваши данные не влеказют в размер такого блока, процессору придется тратить дополнительное время на выборку данныъ из памяти. Следовательно, чтобы этого не происходило, при тонкой настрйоке приложения постарайтесь подогнать размеры ваших объектов под размеры cache-line процессора на PROD. А еще было бы идеально, чтобы вся ваша программа помещалась в кэше процессора. К счастью размеры кэшей современных процессоров сейчас стали просто гиганстскими.

Многопоточное и параллельное программирование

На одноядерном процессоре несколько потоков не выполняются одновременно, на самом деле процессор просто быстро переключается между потоками, выполняя сначала один поток некоторое время, потом другой, потом возвращается к первому и так далее. Создается лишь иллюзия, что они исполняются одновременно, на самом же деле в каждый конкретный момент времени исполняется только один поток, а все остальные — находятся в ожидании своей очереди. Мы говорим что они выполняются конкурентно (concurrent execution), но не параллельно. Лишь на многоядерном, многопоточном процессоре или на многоядерной машине мы достигаем настоящего параллельного исполнения кода (parallel execution).

Поэтому программистам следует четко различать термины concurrent, concurrency и parallel, parallelism. Они не равнозначны и обозначают похожие, но по сути разные понятия.

Многопоточность в операционной системе

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

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

Многопоточность в Java

Многопоточное программирование было заложено в Java с самого начала создания языка. Процессоры тогда были одноядерными и даже не суперскалярными. Многопоточные Java-программы на них работали конкурентно (concurrent), но не параллельно (parallel) в истинном смысле этого слова. Лишь с приходом многопроцессорных многоядерных машин, многопоточные программы стали исполнять потоки параллельно. Как результат многие «косяки» многопоточности, невидимые на одноядерном процессоре, стали совершенно неожиданно проявлять себя на многоядерных машинах.

Из-за того, что теперь все микропроцессоры — многоядерные, программисты просто не могут игнорировать темы корректного написания многопоточных приложений. Знание этой темы отличает профессионала от любителя. В low-latency системах, где бюджет времени исполнения кода исчисляется микро- и нано-секундами, легко без глубоких знаний и понимания concurrency наступить на грабли с синхронизацией, data race, data corruption, dead / live lock, jitter и прочие «прелести».

Что почитать еще

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s