СКОРОСТНЫЕ ВЫЧИСЛЕНИЯ КРАТКИЙ КУРС Цель методички - дать обзор по техникам оптимизации и быстрых вычислений для программистов на Си/С++. Для этого мы рассмотрим устройство современных микропроцессоров и вносимые ими особенности в процесс вычислений. ОПТИМИЗАЦИИ КЛАССИЧЕСКИХ ВЫЧИСЛЕНИЙ В этом деле лучший - Агнер Фог: https://agner.org По вопросам развертывания циклов, линий процессорного кеша, микроархитектуре Intel и SIMD-вычислениям отсылаем к его серии книг: https://www.agner.org/optimize/ СУПЕРСКАЛЯРНЫЕ МИКРОПРОЦЕССОРЫ В суперскалярных микропроцессорах высока степень избыточности вычислительных узлов. Целочисленных и вещественных АЛУ имеется несколько штук, есть блок предсказания ветвлений, есть теневой регистровый файл, и еще куча всего дублируется. Это дает возможность разбить последовательный код на куски и выполнять его параллельно. To be done БАРЬЕРЫ ПАМЯТИ Барьер памяти - это способ контроля внеочередного выполнения инструкций процессором и/или компилятором. Внеочередное выполнение кода неинтуитивно. Код ведь должен выполняться в том порядке, в котором он написан (на самом деле нет). Операции над не связанными явно (!) друг с другом данными могут происходить либо не по порядку, либо вообще одновременно в рамках одного и того же ядра, используя массивную избыточность вычислительных узлов процессора. Барьер памяти же (в грубом приближении) заставляет выполнить код так, как он написан на экране (последовательно и предсказуемо), а не так как хочет процессор с компилятором (в контр-интуитивном, но более оптимальном порядке). То есть, гарантирует, что код до барьера памяти выполнится частично либо полностью, к моменту завершения инструкции барьера. Барьер обычно является ассемблерной инструкцией (т.е. присутствует в системе команд процессора). Барьер обычно является хинтом "между этими данными есть неявная связь", но необязательно. В C++ есть три модели памяти для атомиков: 1. relaxed: гарантируется только то, что операции будут выполнены атомарно. В каком порядке - вопрос. - модификация переменной "появится" в другом потоке не сразу - поток thread2 "увидит" значения одной и той же переменной в том же порядке, в котором происходили её модификации в потоке thread1 - порядок модификаций разных переменных в потоке thread1 не сохранится в потоке thread2 relaxed-переменные можно использовать как счетчики или флаги остановки. Самая быстрая и самая ненадежная модель памяти. Аналог из транзакционной модели СУБД - READ UNCOMMITTED 2. sequential consistency, seq_cst: состояние памяти синхронизируется между всеми потоками программы. - порядок модификаций разных атомарных переменных в потоке thread1 сохранится в потоке thread2 - все потоки будут видеть один и тот же порядок модификации всех атомарных переменных. Сами модификации могут происходить в разных потоках - все модификации памяти (не только модификации над атомиками) в потоке thread1, выполняющей store на атомарной переменной, будут видны после выполнения load этой же переменной в потоке thread2 Самая медленная и самая надежная модель памяти. Аналог из транзакционной модели СУБД - SERIALIZED 3. acquire/release: синхронизация пары. - модификация атомарной переменной с release будет мгновенно видна в другом потоке, выполняющим чтение этой же атомарной переменной с acquire - все модификации памяти в потоке thread1, выполняющей запись атомарной переменной с release, будут видны после выполнения чтения той же переменной с acquire в потоке thread2 - процессор и компилятор не могут перенести операции записи в память ниже release операции в потоке thread1, и нельзя перемещать выше операции чтения из памяти выше acquire операции в потоке thread2 Позволяет делать синхронизацию только между двумя потоками (в отличие от всех потоков в 1 и 2). https://habr.com/ru/post/517918/ https://gcc.gnu.org/wiki/Atomic/GCCMM/AtomicSync https://habr.com/ru/company/JetBrains-education/blog/523298/ https://habr.com/ru/post/545996/ https://habr.com/ru/post/546222/ https://habr.com/ru/post/546880/ https://elixir.bootlin.com/linux/latest/source/Documentation/memory-barriers.txt ЗАМЕР ВРЕМЕНИ С НАНОСЕКУНДНОЙ ТОЧНОСТЬЮ Точные замеры времени на архитектуре x86/x64 - тема объемная. На скалярных процессорах с фиксированной частотой можно считать такты, а на x64 нельзя: результат будет различаться. Один и тот же код может выполняться разное время из-за разного состояния узлов системы, что (грубо) определяется общей нагрузкой на систему. Детерминизм отсутствует из-за: - внеочередного исполнения инструкций - суперскалярной архитектуры (конвеер, теневые регистры, состояние кэша и прочее дублирование) - плавающей частоты как процессора, так и отдельных частей системы (шина) Все усугубляется различиями реализаций между поколениями процессоров, между процессорами разных производителей, внутри одного поколения одного производителя. Ну и вдобавок, мы работаем в ОС общего назначения, с взаимовлиянием разных процессов и кода ядра друг на друга, что добавляет хаоса. Словом, несмотря на огромные скорости, для задач реального времени х64 так себе :) Можно говорить о вероятностях: общее время выполнения цикла, среднее время выполнения итерации, оценки снизу и сверху. В С++ есть chrono::high_resolution_timer. Но на разных компиляторах, разных ОС и процессорах его точность гуляет на три порядка. Самое точное значение я видел на Linux / Intel(R) Core(TM) i5-4690K CPU @ 3.50GHz / gcc 8.4, с точностью в 30 наносекунд. На Intel Core i5 480M @ 2.67GHz / Windows 7 / MSVC 2017 точность порядка 4000 наносекунд. На Intel Atom x7-Z8750 @ 1.6GHz / Windows 10 / MSVC 2017 точность 320 наносекунд. На x64 замеры времени можно делать с помощью Timestamp Counter (TSC) - имеющегося на всех поколениях х64 регистра MSR. На последних поколениях, в нем содержится число тактов таймера фиксированной частоты с момента сброса процессора. Для разных поколений и производителей процессоров смысл этого значения отличается. Гарантию фиксированной частоты таймера можно проверить по наличию флага constant_tsc в /proc/cpuinfo. Чтение таймера выполняется инструкциями RDTSC/RDTSCP, что в свою очередь имеет цену в тактах. Замеры времени сами по себе влияют на выполнение: меняют состояние конвеера, являются предметом внеочередного выполнения, влияют на кэш (ведь мы куда-то помещаем считанные данные). Методика замеров следующая: 1. Вычисляем цену инструкции RDTSC в тактах при ТЕКУЩЕЙ* нагрузке: - нагружаем конвеер циклом из этой инструкции, вычисляем среднее число тактов на инструкцию 2. Получаем (эмпирически**) текущую частоту таймера: - замеряем несколько раз, сколько тактов протикает за одну секунду*** 3. Определяем цену в наносекундах одного такта, на основании данных из 1 и 2. 4. Сэмплируем замеряемый участок инструкциями RDTSC, учитывая цену самой RDTSC 5. Интерпретируем многократные замеры. * При изменении нагрузки, цена RDTSC может поплыть - из-за плавающей частоты процессора, состояния конвеера. ** Точное и универсальное получение частоты таймера достаточно сложно, как из-за разного смысла самого этого значения на разных процессорах, так и из-за разницы в необходимых исходных данных и формулах. https://stackoverflow.com/questions/42189976/calculate-system-time-using-rdtsc Потому мы тут срезаем угол, уходим в эмпирику, но получаем достаточно правдоподобные значения. *** И тут мы полагаемся на реализацию функции sleep в ОС/компиляторе, обладающую огромным шумом. Интерпретация замеров: - нулевое значение времени означает внеочередное выполнение двух RDTSC подряд: выполнение замеряемого участка было заблокировано, и процессору пришлось выполнять второй замер вместо него. То есть, на самом деле этот код выполнялся не нулевое время, а наоборот дольше обычного. - большие всплески - это переключение контекста и ожидание, пока другой поток отработает свой квант времени. Кроме того это могут быть миграции потоков между ядрами - как связанное с этим время, так и рассинхрон таймеров между ядрами (гарантий их синхронности нет). - меньшие всплески - ожидание передачи данных по шине при промахах в кеше - остальные значения более-менее соответствуют истине. Как видим, методика несовершенна. Сильный шум не дает получить точные времянки, но все еще можно получить хотя бы качественные характеристики выполнения кода, и посчитать распределения вероятностей времён. В процессорах x86 есть встроенный блок слежения за производительностью — Performance Monitoring Unit (PMU), среди которых есть независимый от частоты регистр (Описать Coreclock register) ЗАДАЧИ РЕАЛЬНОГО ВРЕМЕНИ В УНИВЕРСАЛЬНЫХ ОС Наилучшая ОСРВ однозадачна. Любое переключение контекста вносит задержку в выполнение; цена этой задержки может быть неизмерима. В MS DOS контекст переключается только на прерываниях; в RT-11 еще при завершении асинхронного ввода-вывода. В современных ОСРВ добавились таймеры, примитивы синхронизации. Это тот максимум сервиса, который может позволить ОСРВ. Попытки привнести в ОСРВ многозадачность ухудшают её характеристики. Известен realtime-патч для Linux, он действительно повышает предсказуемость планировщика, снижает число переключений контекста и уменьшает задержки в работе процесса РВ. Это подходит для многих задач микросекундной точности. Наглядно про джиттер переключения контекста при обработке прерываний: https://habr.com/ru/post/562636/ Самый радикальный способ просто и дешево получить ОСРВ из обычного Linux - выделять отдельные ядра задачам РВ (thread affinity). Вся обработка прерываний и системные процессы при этом выносятся на другие ядра. Так устраняется сама возможность переключения контекста для процессов РВ (кроме отдельных обязательных прерываний). https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux_for_real_time/8/html/tuning_guide/chap-general_system_tuning СКОРОСТНЫЕ СЕТИ Для уменьшения латентности (минимизация времени отклика) применяются такой хак как Kernel Bypass: обработка сетевого потока минуя ядро, в userland. В частности такое применяется в сетевых картах SolarFlare (их термин OnLoading). Весь стек TCP/IP скомпонован в библиотеке, работающей в режиме пользователя. Прерывание от железа разумеется есть, и оно обрабатывается в ядре; но из приключений пакета в ядре исключаются сетевой фильтр (iptables) и все ненужное. Так убираются лишние переключения контекста kernel/user, внутриядерные блокировки (которые например могут быть зажатыми для нашего процесса чем-то еще), копирования буферов, вероятность повторного перепланирования слишком затянувшегося обработчика в ядре, итд. Это обычно совмещается с аффинизацией задач РВ (см.выше) на выделенном ядре. В библиотеке максимально используются спинлоки в качесте блокировок. Для программиста это выглядит как обычные сокеты. Bonding описать. Для увеличения пропускной способности используется RDMA - удаленный прямой доступ к памяти (другой машины в сети). При этом копирование в память компьютера происходит непосредственно с аппаратного приемного буфера сетевой карты, минуя обработку центральным процессором, минуя ОС как таковую (по сути без генерации прерывания). Низкая латентность здесь получается прицепом, но наиболее эффективно это именно для толстого постоянного потока данных (например, в кластеризированных СУБД). Пример железа - Infiniband, библиотека libibverbs. Применяется в interlinked-кластерах, кластерных СУБД. Общее место скоростных сетей - отказ от копирования буферов (zero copy). На гигабайтном потоке время копирования буфера *уже* уменьшает пропускную способность вдвое. На 100ГБ потоке посчитайте сами. Потому все расчеты и манипуляции с данными должны производиться непосредственно в передающих/приемных буферах. ТЕНЕВАЯ БУФЕРИЗАЦИЯ (SHADOW BUFFERING/DOUBLE BUFFERING) Для достижения высокой пропускной способности передачи данных (в сеть, при сбросе на диск) используется теневая буферизация: - выделяется несколько буферов, только один из них активен (готов к передаче) в один момент времени - неактивные буфера наполняются данными в отдельных потоках - из активного буфера тем временем передаются данные - по завершении передачи из активного буфера, он помечается как неактивный; выбирается следующий готовый к работе буфер. Это частный случай идеи кольцевого буфера. РАЗНОЕ Вычисления с плавающей точкой без погрешности https://habr.com/ru/post/523654/ Оптимизация математических вычислений и опция -ffast-math в GCC 11 https://habr.com/ru/company/ruvds/blog/586386/ Быстрый парсинг double https://habr.com/ru/company/ruvds/blog/542640/ https://github.com/fastfloat/fast_float Быстрая валидация UTF8 https://habr.com/ru/company/ruvds/blog/551060/ https://arxiv.org/pdf/2010.03090.pdf ASM today https://habr.com/ru/post/544786/ Кольцевой буфер без деления по модулю https://habr.com/ru/company/otus/blog/557310/ epoll и Windows IO Completion Ports: практическая разница https://habr.com/ru/company/infopulse/blog/415403/ Какой предел у предсказателя ветвлений? Проверили на x86 и M1 https://habr.com/ru/company/selectel/blog/557410/ ССЫЛКИ 1. Agner Fog, Optimization manuals https://www.agner.org/optimize/ 2. Стоимость операций в тактах ЦП https://habr.com/ru/company/otus/blog/343566/ 3. select / poll / epoll: практическая разница https://habr.com/ru/company/infopulse/blog/415259/ 4. Evaluating the Cost of Atomic Operations onModern Architectures https://spcl.inf.ethz.ch/Publications/.pdf/atomic-bench.pdf 5. Intel Intrinsics Guide https://software.intel.com/sites/landingpage/IntrinsicsGuide/ 6. Neon Intrinsics Reference https://developer.arm.com/architectures/instruction-sets/simd-isas/neon/intrinsics разное блоги https://easyperf.net/notes/ http://scrutator.me/