![]() |
Профайлер это инструмент, при помощи которого можно определить время исполнения некоторого участка кода. Зачем это нужно? В первую очередь для оптимизации программ, и лишь потом любопытство и всё остальное. Поскольку в каждый момент времени многозадачная система далеко не стабильна, мы сталкиваемся здесь со-множеством проблем этического характера. Windows если и хочет сделать вид, что справляется со всеми задачами сразу, только у неё это плохо получается.. тем более, когда речь заходит о тесте производительности в пользовательском режиме. Профилирование требует к себе особого внимания, что для системы большая роскошь. Обсуждению этих проблем и посвящена данная статья.
--------------------------------------------- Аппаратные проблемы – конвейер CPU Хотим мы того или нет, но профилируемый код рано-или-поздно попадёт в конвейер процессора. Это устройство уровня микроархитектуры позволяет процессору выполнять сразу несколько микроопераций (µOps)за такт. Кого интересуют детали его реализации, можете почитатьстатью на Хабре, а здесь я рассмотрю лишь связанные с ним проблемы. В базовом варианте, исполнение процессором отдельных инструкций включает в себя 5-стадий. Поскольку в этих действиях просматривается строго определённая последовательность, её собрали в обобщённое понятие конвейер PipeLine: 1. Загрузка инструкции из кэша L1; 2. Разложение её на мелкие микрооперации – декодирование; 3. Загрузка привязанных к инструкции операндов (регистры/память); 4. Непосредственное исполнение микроопераций в блоках Execution ядра процессора; 5. Сброс результата обратно в регистры или память (отставка WriteBack). Процессор принято разделять на два территориальных субъекта – исполнительное ядро BackEnd, и обеспечивающий работу ядра транспортный цех FrontEnd(см.рис.ниже). Эти два термина фломастером проводят черту так, что этапы 1,2,3 конвейера оказываются на территории фронта (на входе в процессор), а 4 и 5 лежат в исполнительном тылу BackEnd. В ядре современного процессора могут находиться более 8-ми исполнительных блоков Execution-Unit: несколько целочисленных ALU, и отдельно для плавающей точки FPU. После декодирования инструкций на стадии[2], их микрокоды попадают в (жёлтый) блок-резервации, от куда в зависимости от типа инструкции подхватываются портами[0:2] исполнительных устройств: https://forum.antichat.xyz/attachmen...d3d624683c.png Cиним здесь выделены восемь мартеновских печей Execute, а зелёные блоки демонстрируют этап отставки WriteBack. Такому кол-ву блоков фронт должен поставлять непрерывный поток данных, иначе они будут по-просту голодать. Но построенные на базовых принципах конвейеры не справлялись с этой задачей, и пришлось к первой транспортной ленте добавить ещё 4 сдвинутых по фазе лент. Так конвейер стал скалярным и на продуктовую дотацию могут рассчитывать уже все исполнительные блоки ядра. Позже выяснилось, что программисты редко используют сопроцессор FPU – в основном вся нагрузка ложится на целочисленные ALU. Это послужило толчком к технологии Hyper-Threading идея которой в том, чтобы создать ещё одну ксеро-копию фронта, не затрагивая при этом BackEnd. В результате ядро осталось прежним, зато транспортных жил получи две. Такая схема известна как "параллелизм на уровне потоков", хотя внутри ядра имеется ещё и параллелизм на уровне микро-инструкций (в данном случае 5 за такт). Основное требование к любого типа конвейерам – одинаковые его ширина и глубина, иначе в нём теряется смысл. На рисунке ниже, стадии конвейера выделены цветом: [1]загрузка инструкции, [2]декодирование, [3]операнды, [4]исполнение, [5]запись результата. В сумме, процессор с уже прогретым 5-уровневым конвейером за один свой такт сможет выполнить 5 различных микроопераций в глубину(не путать с инструкциями, которые стоят в иерархии выше и представлены здесь в виде одной строки): https://forum.antichat.xyz/attachmen...857f934f5d.png Ещё у Pentium конвейер был 12-уровневым (я отобразил здесь только 4 в глубину), а на некоторых процессорах встречались даже 32-уровневые конвейеры. Создавая таких монстров инженеры преследовали всего одну цель – заставить процессор выполнить как-можно больше м/операций за единицу времени. Однако идея длинных конвейеров не прижилась из-за спекулятивного выполнения команд процессором. К примеру если переход был предсказан неверно, приходилось перезагружать его более длинной цепочкой. Поэтому в современном мире используются в среднем14-уровневые конвейеры, а на процессорах с поддержкой HT (т.е. все Intel и некоторые AMD) он ещё и гипер-скалярный. Применительно к профайлерам, экосистема их разработки требует соблюдения определённых правил. В частности, обязательным условием является полная очистка исполнительного конвейера от всех инструкций, которые могут находится в нём до начала профилирования. Рассмотрим такой код.. C-подобный: Код:
;Сбросить конвейер в дефолт позволяет т.н. сериализация потока команд, и ассемблер имеет специальные инструкции для этого. Выбрав одну из них, мы должны будем предварять ею каждое чтение счётчика TSC, чтобы профилируемый код не бежал впереди паровоза, а входил в девственный конвейер уже после чтения счётчика. Вот их перечень: C-подобный: Код:
;Код:
lfenceКод:
cpuidКод:
rdtscpC-подобный: Код:
;Код:
lfenceКод:
rdtscПроблемы ОС – планировщик потоков Чтобы создать иллюзию одновременного выполнения нескольких задач, Win использует основанную на приоритетах "вытесняющую многозадачность". В этой схеме, в первую очередь обслуживаются процессы с высоким приоритетом в диапазоне 31..16. Пользовательским приложениям система назначает нижнюю половину с номерами от 15 до 1 – с барского стола им может перепасть только тогда, когда насытятся буржуи. И даже в этом случае, если во-время исполнения нашего процесса вдруг проснётся процесс в более высоким приоритетом, он сразу-же отберёт наше время, хоть мы и не отработали его до конца. Именно поэтому схему назвали вытесняющей.. Приоритеты имеют свой класс, и назначается этот класс только процессам – это т.н. базовый приоритет. Теперь потоки процесса наследуют базовый приоритет своего родителя, и относительно него могут иметь ещё 7 приоритетов. Надеюсь таблица ниже прольёт свет на этот запутанный клубок: https://forum.antichat.xyz/attachmen...7c194f92d8.png По-умолчанию, пользовательским процессам система назначает класс Normal и соответственно потоки получают относительный приоритет [8]. Под этим флагом ходят почти все процессы в системе, и лишь некоторым из них она присваевает класс High. К примеру высокий приоритет имеет диспетчер-задач Taskmgr.exe, что позволяет ему вытеснять зависшие приложения. Если-бы у них был равный приоритет, это было-бы невозможно: https://forum.antichat.xyz/attachmen...0bf05baf02.png Система сажает на жёсткую диету все процессы с приоритетом Normal не оставляя им ни единого шанса получить квант до тех пор, пока работают процессы с более высоким приоритетом. Но тогда каким-же образом достигается компромисс, ведь наши потоки рискуют вообще никогда не попасть в поле зрения планировщика? Инженеры решили эту проблему просто.. Помимо приоритетов, с каждым потоком связывается понятие "состояние потока". Рассмотрим ситуацию, когда мы запустили сразу несколько процессов типа браузер, текстовый редактор, плеер и т.д. Но ведь в каждый момент времени мы работаем только с одним из них, а остальные окна не активны. В системах с вытесняющей многозадачностью попытка активировать сразу два окна обречена на провал, и второе окно неизбежно уйдёт в ожидание какого-нибудь события, типа нажатие клавиши. Планировщик выделяет время только активным окнам, и это даёт возможность работать потокам с более низким приоритетом. Потоки Thread могут находиться в одном из семи состояний, но только 4 из них заслуживают внимания. Обычной является ситуация, когда запущено два и более равноправных треда. К примеру сейчас у меня в фоне Total-Commander копирует файлы на флэшку, а в это время я печатаю текст. Тогда планировщик маятником выделяет квант мне, и по его истечению – квант тоталу, и так рекурсивно. Если-же в фоне имеется ещё один процесс но с более низким приоритетом (типа спуллер печати), он будет ждать, пока мы с тоталом не решим свои проблемы. https://forum.antichat.xyz/attachmen...c32f28cd99.png Процессорное время в виде квантов выделяется только тем потокам, которые находятся в состоянии "Running". В очередь-ожиданий поток может попасть или по нашему бездействию, или-же его могут вогнать туда функции типа Sleep() или WaitForХХObject(). В любом случае, пока не отработают все потоки из верхнего блока с приоритетом High, синему блоку с пулом Normal ничего не светит, но правда с небольшой поправкой. Не взирая на приоритеты, планировщик следит за всеми готовыми к исполнению потоками. Если в течении 4-сек готовый поток не получает управления, ему всё-же выделяется один квант, видимо из соображений жалости. Исходя из вышесказанного можно сделать вывод, что если мы планируем написать свой профайлер для измерения скорости выполнения программного кода, то обязательно должны присвоить его процессу приоритет как минимум выше-среднего, а лучше High (тогда поток получит относительный приоритет 13). В противном случае, в самый неподходящий момент планировщик может отобрать наш квант, и вернуть его по настроению, ..например через пару секунд. Оперировать приоритетами потоков позволяет функция SetThreadPriority(), а задавать базовые приоритеты процессам SetPriorityClass(). Причём делать это можно динамически, в произвольный момент времени, а не только при старте процесса/потока. Вот их прототипы (обе функции при ошибке возвращают нуль): C-подобный: [CODE] BOOL SetPriorityClass ( ; // 31.250 --> 46.875, и т.д.. Единственная функция, от которой можно поиметь выгоду это Sleep(). С её помощью, в течении 1-сек обычно калибруют какой-нибудь счётчик, чтобы узнать его частоту. Но и в этом случае мили-секунды принудительно округляются до величины, кратной тику системного таймера.[/FONT] Цитата:
2.QueryPerformanceFrequency() – возвращает частоту высокоточного таймера HPET (High-Precision-Event-Timer). Его использует заслуживающий доверие системный монитор производительности (Win+R-->perfmon). В отличии от предыдущего таймера 60 Hz, частота НРЕТ уже 14 MHz. Если HPET аппаратно не доступен, монитор садится на ACPI-таймер 3.579 MHz. Здесь мы имеем дело уже не с программным таймером Win, а с аппаратными девайсами чипсета. На этой частоте работает счётчик QueryPerformanceCounter(). Функция возвращает кол-во тиков высокоточного таймера (HPET или ACPI). Используя пару QPC+QPF, можно контролировать отрезки времени с интервалом: Код:
1 / 3579545 = 2793. CPU frequency – тактовая частота процессора. Если даже взять мин.частоту CPU=1.0 GHz, то получим разрешение в 1 нано-сек. Профайлер с таким разрешением позволит замерять не только блоки программного кода, но и время выполнения отдельных инструкций ассемблера. В качестве счётчика этой частоты используется специально предназначенный для этих целей TSC (Time-Stamp-Counter). Вот это наш клиент! Формула времени В компьютерной системе счётчик тиков служит для измерения длительности какого-либо события. Чтобы из этого счётчика получить время в секундах, мы должны знать частоту, на которой работает данный счётчик. Тогда разделив кол-во тиков на частоту, получим время: Код:
Time = Ticks / FrequencyПонятия "счётчик и его частота" жёстко привязаны друг-к-другу и нужно соблюдать между ними симметрию. К примеру, заведомо ложное время даст попытка деления счётчика TSC на частоту таймера HPET, поскольку TSC увеличивается с каждым тактом процессора, а не с тиком НРЕТ. Если мы считаем при помощи QueryPerformanceCounter(), то её показания должны делить строго на QueryPerformanceFrequency() и ни на что другое. Судя по списку выше, использовать в своём профайлере мы можем всего три устройства: 1. Системный таймер с нарушенным вестибулярным аппаратом и частотой всего 64 Гц – GetTickCount(); 2. Резидент внешней разведки – таймер HPET/ACPI со-своим счётчиком QueryPerformanceCounter(); 3. Несомненный лидер этого списка – счётчик процессора TSC. Математический сопроцессор FPU Поскольку центр тяжести смещается здесь в сторону TSC, то условимся в профайлере использовать именно его. Проблему больших частот и мелких отрезков времени придётся решать при помощи сопроцессора FPU и чисел с плавающей точкой (1 нано-сек, это 0.000000001 сек). Из инструкций сопра понадобятся всего три: • Код:
FILD• Код:
FIDIV• Код:
FSTPFPU имеет 8-регистров Код:
ST0..ST7Код:
ST0Код:
FILDКод:
ST0Код:
FSTКод:
FSTPhttps://forum.antichat.xyz/attachmen...a5e7882492.png Для вывода на консоль полученного времени, воспользуемся всё тем-же printf() со-спецификатором Код:
%.3fПрактическая часть Результаты замеров во многом зависят от текущей занятости процессора, поэтому профилируемый участок кода нужно прогонять в цикле по нескольку раз. Важно понимать, что код не может исполняться быстрее, чем это позволяет процессор, а потому прогон с минимальным значением и будет самым достоверным. Подглядывая за ходом работы процессора может возникнуть резонный вопрос – насколько правильные данные возвращает нам профайлер? ..т.е. не плохо-бы иметь под рукой эталонные значения. На эту роль претендует таблица растактовок Агнера Фога, которую можностянуть от сюда. Расход тактов на одну и туже инструкцию зависит от микро/архитектуры процессора и варьируется он в широких пределах. Агнер не поленился и собрал в одной доке информацию сразу обо всех актуальных процессорах. По этой причине, в демо-профайлере я предусмотрел вывод кодового имени CPU, прицепив к нему небольшую базу (пока только Intel, без AMD). Возвращает этот код CPUID с аргументом Код:
EAX=1Большинство штатных API определяют версию платформы (Win-7/8/10) из реестра. Однако интересней вытащить её без посторонней помощи из структуры РЕВ процесса. Это актуально например для шелл-кодов, которые должны выживать в природе без обращения к сервисам ОС. Нужные поля начинаются со-смещения Код:
0хА4https://forum.antichat.xyz/attachmen...4bbb46eff1.png C-подобный: Код:
buildTbl ddC-подобный: Код:
format pe consoleОбратите внимание, что время исполнения инструкций напрямую зависит от тактовой частоты процессора. На правом скрине частота ~1 GHz и потому время почти совпадает с тиками счётчика TSC, т.к. соотношение 1:1. Зато на левом.. с частотой процессора 2.5 GHz, потраченные на Код:
RDTSCКод:
LFENCEПосмотрите, сколько времени исполнялась функция Sleep() с 500 милли-сек задержкой, и сколько на неё тратят тактов CPU с различной тактовой частотой. Время в обоих случаях правильное и равно Код:
~500'000Заслуживает внимания и частота, которую возвращает нам функция QueryPerformanceFrequency(). Мой бук с десяткой юзает НРЕТ, а десктопный чипсет G41 с семёркой вообще что-то среднее между ACPI-таймером (частота 3.5 MHz) и древним таймером PIT. В отличии от перфмона, системный таймер ОС никуда не спешит и в обоих случаях период его активности ограничивается одним разом в 15.6 милли-сек. Кстати именно поэтому, если вскормить профайлеру Sleep() с аргументом в диапазоне 1..14, он будет возвращать Код:
15.6 msЗаключение В наше время, заниматься профилированием кода ради оптимизации программ не имеет смысла – это было актуально на заре компьютерной индустрии. Процессоры (в том числе и компиляторы, особенно С++) сейчас намного умнее некоторых программистов, и на автомате отсеивают бесполезный код. Тестирование производительности скорее носит спортивный интерес, позволяя продвигаться всё глубже в дебри аппаратных механизмов. Есть поговорка: -"глухой считает, что те-кто танцуют – сумасшедшие". Так и здесь.. Некоторым это занятие может показаться бесполезным, но только не тем, кто увлечён низкоуровневым программированием. Раз-уж ассемблер сам предлагает нам такие возможности, так почему-же ими не воспользоваться? |
Тимур, спасибо за статью!
|
Мне было очень интересно. Спасибо за статьи.
|
Цитата:
Машина: W10 x64 x64 приложение: https://forum.antichat.xyz/attachmen...7023661680.png x32 приложение: https://forum.antichat.xyz/attachmen...7023840418.png На машине W7 x64 ситуация та же (используется rdtsc), хоть и asm кода поменьше чем в W10. Спрашивается, откуда информация о том, что QPC использует HPET? |
Цитата:
https://forum.antichat.xyz/attachmen...3a8964f4ff.png а так, использование счётчиков системой зависит от версии ОС, аппаратной платформы, и в т.ч. от самого ЦП. Если процессор поддерживает инвариантный счётчик iTSC, то значение этого счётчика калибруется по HPET, о чём говорится в прикреплённой доке. |
| Время: 13:59 |