(video) CPU design effects - Jakub Beránek
Детально рассказывается про предсказание ветвлений, из интересного - в GCC подсказка
41.53 - пример, где denorm float работает в 2 раза медленее.
51:56 - false sharing, как происходит синхронизация кэшей
#cpp #cpu_opt #intel_cpu
Детально рассказывается про предсказание ветвлений, из интересного - в GCC подсказка
__builtin_expect
игнорируется ЦП.41.53 - пример, где denorm float работает в 2 раза медленее.
51:56 - false sharing, как происходит синхронизация кэшей
#cpp #cpu_opt #intel_cpu
Ошибка в системе сборки, которая сразу обнаружилась через pragma detect_mismatch
Есть два проекта: основной и компилятор ресурсов. Компилятор ресурсов может собираться в другой конфигурации для ускорения компиляции ресурсов.
После рефакторинга пути для бинарников совпали и получилось так:
* сборка основного проекта - движок.
* сборка компилятора ресурсов, в результате чего менялись либы движка.
* сборка приложения используя либы движка и динамически подключаемый компилятор ресурсов.
* линковка приложения завершается с ошибкой, так как макросы не совпадают.
Без
#cpp #blog
Есть два проекта: основной и компилятор ресурсов. Компилятор ресурсов может собираться в другой конфигурации для ускорения компиляции ресурсов.
После рефакторинга пути для бинарников совпали и получилось так:
* сборка основного проекта - движок.
* сборка компилятора ресурсов, в результате чего менялись либы движка.
* сборка приложения используя либы движка и динамически подключаемый компилятор ресурсов.
* линковка приложения завершается с ошибкой, так как макросы не совпадают.
При этом во всем основном проекте макрос установлен в 1 и я долго не мог понять откуда приходит 0.
#ifdef MACRO
# pragma detect_mismatch( "MACRO", "1" )
#else
# pragma detect_mismatch( "MACRO", "0" )
endif
Без
pragma detect_mismatch
найти ошибку было бы еще сложнее.#cpp #blog
Создание World of Tanks Blitz на базе собственного движка DAVA
Как происходит переход из OOP/сценеграфа в ECS/DOD.
И продолжение: Blitz Engine & Battle Prime: ECS и сетевой код
#cpp #ecs #dod #rus
Как происходит переход из OOP/сценеграфа в ECS/DOD.
И продолжение: Blitz Engine & Battle Prime: ECS и сетевой код
#cpp #ecs #dod #rus
UDP vs TCP
Пост в блоге, о разнице между UDP и TCP для мультиплеерных игр.
Virtual Connection over UDP
Reliability and Congestion Avoidance over UDP
Как сделать свой аналог TCP поверх UDP. Это позволяет использовать только UDP для создания надежного соединения для критичных данных и ненадежного для часто обновляемых данных.
#cpp #backend
Пост в блоге, о разнице между UDP и TCP для мультиплеерных игр.
Virtual Connection over UDP
Reliability and Congestion Avoidance over UDP
Как сделать свой аналог TCP поверх UDP. Это позволяет использовать только UDP для создания надежного соединения для критичных данных и ненадежного для часто обновляемых данных.
#cpp #backend
(video) Уязвимости аллокаторов памяти – Павел Филонов
Простой пример как можно занести уязвимость в самописный аллокатор.
#cpp #security #rus
Простой пример как можно занести уязвимость в самописный аллокатор.
#cpp #security #rus
(video) Optimizing Binary Search - Sergey Slotin - CppCon 2022
3:50 - вариант с ветвлением, как в stl. Так было сделано для совместимости со всеми итераторами.
8:30 - детально разбирается почему ветвления это плохо.
12:29 - вариант без ветвлений, работает в 2 раза быстрее на небольших массивах.
Для большинства этого должно хватить, а дальше начинается магия оптимизаций.
#cpp #cpu_opt
3:50 - вариант с ветвлением, как в stl. Так было сделано для совместимости со всеми итераторами.
8:30 - детально разбирается почему ветвления это плохо.
12:29 - вариант без ветвлений, работает в 2 раза быстрее на небольших массивах.
Для большинства этого должно хватить, а дальше начинается магия оптимизаций.
#cpp #cpu_opt
C++20’s likely Attribute - Optimizations, Pessimizations, and unlikely Consequences (video)
Нюансы использования аттрибутов
Если кратко - все сильно зависит от кода и версии компилятора.
36:04 -
#cpp #cpu_opt
Нюансы использования аттрибутов
likely
, unlikely
.Если кратко - все сильно зависит от кода и версии компилятора.
36:04 -
likely
оказался на 10% быстрее unlikely
.#cpp #cpu_opt
C++ Russia 2017: Антон Полухин, Как делать не надо: C++ велосипедостроение для профессионалов
Иногда полезно пересматривать прошлые конференции, хотя кажется, что все очевидно и давно известно.
Но нашел кое-что новое для себя:
58:12 - нужно указывать noexcept для move-ctor, иначе STL контейнеры могут аллоцировать дополнительную память при копировании.
В коде VS я не нышел выделение дополнительной памяти, зато есть такое:
#cpp
Иногда полезно пересматривать прошлые конференции, хотя кажется, что все очевидно и давно известно.
Но нашел кое-что новое для себя:
58:12 - нужно указывать noexcept для move-ctor, иначе STL контейнеры могут аллоцировать дополнительную память при копировании.
В коде VS я не нышел выделение дополнительной памяти, зато есть такое:
То есть move конструктор в некоторых случаях не вызывается, а происходит полное копирование.
// _Resize_reallocate()
if constexpr (is_nothrow_move_constructible_v<_Ty> || !is_copy_constructible_v<_Ty>) {
_Uninitialized_move(_Myfirst, _Mylast, _Newvec, _Al);
} else {
_Uninitialized_copy(_Myfirst, _Mylast, _Newvec, _Al);
}
#cpp
What is Low Latency C++? CppNow 2023
Part 1 (pdf) (video)
С 44:29 начинаются примеры микрооптимизаций.
56:26 - примеры использования
1:06:15 - немного про новые атрибуты
Part 2 (pdf) (video)
Разбирается что не надо делать в low-latency коде, тут больше про блокировки и нестабильное время выполнения.
Есть ссылка на интересную реализацию спинлока, где ожидание сделано с постепенным увеличением паузы: progressive_backoff_wait
#cpp #threading #lockfree
Part 1 (pdf) (video)
С 44:29 начинаются примеры микрооптимизаций.
56:26 - примеры использования
assume
.1:06:15 - немного про новые атрибуты
noalias
, unsequenced
и тд. Похоже компиляторы ни на что не способны и надо вручную выставлять атрибуты.Part 2 (pdf) (video)
Разбирается что не надо делать в low-latency коде, тут больше про блокировки и нестабильное время выполнения.
Есть ссылка на интересную реализацию спинлока, где ожидание сделано с постепенным увеличением паузы: progressive_backoff_wait
#cpp #threading #lockfree
Как приостановить поток.
Вариантов достаточно много, начнем с самого простого: _mm_pause() на x64, аналогично YieldProcessor() в WinAPI.
Для ЦП с гипертредингом, где одновременно выполняются 2 потока на одном ядре, эта инструкция позволяет второму потоку начать выполнение.
Имеет смысл для циклов со спинлоком, когда второй поток использует тот же спинлок.
Инструкция выполняется за константное время (20-30нс), что позволяет приостанавливать ЦП на очень короткое время.
std::this_thread::yield() зависит от реализации, на WinAPI это соответствует SwitchToThread(), который предлагает ОС заменить текущий поток на другой, который ожидает выполнения.
В отличие от std::this_thread::yield(), SwitchToThread() возвращает bool - произошло ли переключение потоков или нет.
Если переключение потоков не произошло, а приостановить его все равно хочется, то используется Sleep() из WinAPI. Sleep(0) аналогично SwitchToThread(), а Sleep(1) занимает 15мс.
Если не трогать timeBeginPeriod() то ОС может переключать потоки 64 раза в секунду, отсюда и шаг в ~15мс на пробуждение потока.
Более частое переключение приведет к частым сменам контекста (context switch), что очень дорого.
Вызов функций ОС сам по себе занимает время, поэтому SwitchToThread() и Sleep(0) потратит 150нс, а если произойдет переключение потоков, то время увеличивается до 15мс+.
Вариант std::this_thread::sleep_for() и sleep_until() устроены немного сложнее. Для значений более 1мкс там вызывается Sleep(max(t,1)) и получается тот же шаг в ~15мс.
Для значений менее 1мкс поведение меняется и время ожидания снижается до 1мс - 6мс, причем все зависит от настроек компиляции, в дебаге - 1мс, в релизе - 4..6мс.
Более короткие интервалы дают таймеры: CreateWaitableTimerEx() с флагом CREATE_WAITABLE_TIMER_HIGH_RESOLUTION и SetWaitableTimerEx(). Шаг задается в 100нс, но тесты показывают ~10мкс для 100-1000нс и ~0.5мс для 10-100мкс, для >0.5мс погрешность составляет 0..0.5мс.
Флаг CREATE_WAITABLE_TIMER_HIGH_RESOLUTION появился в Win10 1803, без флага шаг таймера увеличивается до ~15мс. На более старых версиях использование флага приведет к ошибке создания таймера.
В итоге под Windows можно получить стабильные паузы:
100нс - 5-6 вызовов YieldProcessor().
0.5мс - SetWaitableTimerEx() с временем до 0.5мс.
15мс - Sleep(1..15), это мало применимо для нагруженных приложений (игр), но сгодится для тестов.
Ссылки:
progressive_backoff_wait - прогрессивное ожидание под x64 и Arm64.
Windows Timer Resolution: The Great Rule Change - про timeBeginPeriod.
#cpp
Вариантов достаточно много, начнем с самого простого: _mm_pause() на x64, аналогично YieldProcessor() в WinAPI.
Для ЦП с гипертредингом, где одновременно выполняются 2 потока на одном ядре, эта инструкция позволяет второму потоку начать выполнение.
Имеет смысл для циклов со спинлоком, когда второй поток использует тот же спинлок.
Инструкция выполняется за константное время (20-30нс), что позволяет приостанавливать ЦП на очень короткое время.
std::this_thread::yield() зависит от реализации, на WinAPI это соответствует SwitchToThread(), который предлагает ОС заменить текущий поток на другой, который ожидает выполнения.
В отличие от std::this_thread::yield(), SwitchToThread() возвращает bool - произошло ли переключение потоков или нет.
Если переключение потоков не произошло, а приостановить его все равно хочется, то используется Sleep() из WinAPI. Sleep(0) аналогично SwitchToThread(), а Sleep(1) занимает 15мс.
Если не трогать timeBeginPeriod() то ОС может переключать потоки 64 раза в секунду, отсюда и шаг в ~15мс на пробуждение потока.
Более частое переключение приведет к частым сменам контекста (context switch), что очень дорого.
Вызов функций ОС сам по себе занимает время, поэтому SwitchToThread() и Sleep(0) потратит 150нс, а если произойдет переключение потоков, то время увеличивается до 15мс+.
Вариант std::this_thread::sleep_for() и sleep_until() устроены немного сложнее. Для значений более 1мкс там вызывается Sleep(max(t,1)) и получается тот же шаг в ~15мс.
Для значений менее 1мкс поведение меняется и время ожидания снижается до 1мс - 6мс, причем все зависит от настроек компиляции, в дебаге - 1мс, в релизе - 4..6мс.
Более короткие интервалы дают таймеры: CreateWaitableTimerEx() с флагом CREATE_WAITABLE_TIMER_HIGH_RESOLUTION и SetWaitableTimerEx(). Шаг задается в 100нс, но тесты показывают ~10мкс для 100-1000нс и ~0.5мс для 10-100мкс, для >0.5мс погрешность составляет 0..0.5мс.
Флаг CREATE_WAITABLE_TIMER_HIGH_RESOLUTION появился в Win10 1803, без флага шаг таймера увеличивается до ~15мс. На более старых версиях использование флага приведет к ошибке создания таймера.
В итоге под Windows можно получить стабильные паузы:
100нс - 5-6 вызовов YieldProcessor().
0.5мс - SetWaitableTimerEx() с временем до 0.5мс.
15мс - Sleep(1..15), это мало применимо для нагруженных приложений (игр), но сгодится для тестов.
Ссылки:
progressive_backoff_wait - прогрессивное ожидание под x64 и Arm64.
Windows Timer Resolution: The Great Rule Change - про timeBeginPeriod.
#cpp
std::find() and memchr() Optimizations
Разбирается как оптимизировать memchr с помощью SIMD, но в статье SIMD не дает значительного ускорения.
По моим тестам стандартная реализация find() и memchr по производительности аналогична версии с uint64_t, а более новые ЦП в 4 раза быстрее выполняют AVX2 версию.
Для большей оптимизации можно добавить принудительный инлайнинг, а также аттрибуты likely/unlikely.
Разбирается как оптимизировать memchr с помощью SIMD, но в статье SIMD не дает значительного ускорения.
По моим тестам стандартная реализация find() и memchr по производительности аналогична версии с uint64_t, а более новые ЦП в 4 раза быстрее выполняют AVX2 версию.
Для большей оптимизации можно добавить принудительный инлайнинг, а также аттрибуты likely/unlikely.
for_likely (; i < e; i += 32)
{
__m256i x = _mm256_lddqu_si256(
reinterpret_cast<const __m256i *>( i ));
__m256i r = _mm256_cmpeq_epi8( x, q );
int z = _mm256_movemask_epi8( r );
if_unlikely( z ) {
auto* r2 = i + BitScanForward( z );
return Min( r2, e );
}
}
#cpp #cpu_optПутеводитель C++ программиста по неопределенному поведению
Очень подробно разбирается множество случаев неопределенного поведения в C++.
loop-counters-signed-vs-unsigned
В дополнение - NVidia рекомендует использовать неопределенное поведение при переполении знакового int чтобы компилятор CUDA лучше оптимизировал циклы.
Если переполнение не происходит (иначе это UB), то компилятор может использовать strength reduction - заменяет
#cpp #rus
Очень подробно разбирается множество случаев неопределенного поведения в C++.
loop-counters-signed-vs-unsigned
В дополнение - NVidia рекомендует использовать неопределенное поведение при переполении знакового int чтобы компилятор CUDA лучше оптимизировал циклы.
Если переполнение не происходит (иначе это UB), то компилятор может использовать strength reduction - заменяет
stride*i
на сложение:было:
for (i = 0; i < n; i++)
out[i] = in[offset + stride*i];
стало:
for (i = 0, k = 0; i < n; i++) {
out[i] = in[offset + k];
k += stride;
}
#cpp #rus
Обзор C++26
9.39 - Pattern matching
27.45 - Рефлексия. Появятся новые конструкции, зато будет кодогенерация.
#cpp #rus
9.39 - Pattern matching
27.45 - Рефлексия. Появятся новые конструкции, зато будет кодогенерация.
#cpp #rus
Как приостановить поток. Тесты на Mac M1 и Android.
Реализация
Кроме STD доступны и Unix функции
Функция
Функция
Для коротких пауз есть
На Windows компилятор инлайнит вызов
На ARM64 появилась инструкция WFE - Wait For Event (
Получаются предсказуемые интервалы:
30нс-3мкс для
1-30мкс для
500мкс для
15мс для
#cpp #threading
Реализация
std::this_thread::sleep_for()
и sleep_until()
под MacOS работает точнее, минимальное время 4-10мкс, далее результаты близки к требуемым, но погрешность может доходить до 20%. Например для 15мс результат 15.9мс, для 100мкс - 120мкс.Кроме STD доступны и Unix функции
nanosleep
и usleep
.Функция
nanosleep()
со стандартными настройками минимальное время пазуы: 6мкс, для 10мкс - 17мкс, для 100мкс - 130мкс. Чем больше время ожидания, тем лучше точность, но всегда больше 10%.Функция
usleep()
помечена как устаревшая и по точности не лучше nanosleep()
.Для коротких пауз есть
__builtin_arm_yield
(аналоги __yield
, asm volatile("yield")
) что занимает около 30нс. В отличие от x86 с гипертредингом, эта инструкция не совершает никаких действий, только информирует ЦП, что не нужно снижать частоту. На Android девайсах с более низкой частотой инструкция выполняется дольше, так на Cortex A53 занимает 1-3мкс, а на A55 - 90нс. Вызов инструкции внутри цикла ничего не меняет.На Windows компилятор инлайнит вызов
_mm_pause()
даже из cpp файла, а на Mac и Android - нет, поэтому при оборачивании инструкции в функцию следует разместить ее в хэдере и пометить как forceinline.На ARM64 появилась инструкция WFE - Wait For Event (
__builtin_arm_wfe
, __wfe
), которая переводит ЦП в экономичный режим в ожидании события. Если не использовать механизм событий, то функция приостановит поток на фиксированное время - ARM_BOARD_WFE_TIMEOUT_NS
, что около 1мкс.Получаются предсказуемые интервалы:
30нс-3мкс для
__builtin_arm_yield
1-30мкс для
__builtin_arm_wfe
500мкс для
nanosleep(420us)
15мс для
sleep_for(15ms)
Тесты на Windows#cpp #threading
Возможно ли ускорить memcpy?
Внутри memcpy проверяется выравнивание памяти, поддержка AVX/SSE и в зависимости от этого выбирается наилучший вариант копирования. Но есть нюанс.
В SSE/AVX есть два варианта копирования: store и stream. Store записывает значение в кэш, а stream - нет, результат сразу пишется в RAM и скорость ограничена пропускной способностью памяти. В memcpy используют оба варианта, но для разных случаев, так store используется для диапазона 512B - 2MB, после 2MB идет stream, а до 512B - не SIMD версия. 2MB выбрано как примерный размер L3 кэша, но реальный размер L3 сильно зависит от модели ЦП.
Получается на диапазоне от 2MB до 8MB (на Ryzen 3900X) можно обогнать memcpy используя
Многопоточный тест.
На 4х потоках store после 2MB сильно замедляется, а memcpy показывает максимальную производительность. Похоже все ЦП на x64 имеют кэш, настоенный под лимит в 2MB. Но нашелся старый AMD Phenom II на котором memcpy после 2MB выдает 4ГБ/с, а
#cpp #cpu_opt
Внутри memcpy проверяется выравнивание памяти, поддержка AVX/SSE и в зависимости от этого выбирается наилучший вариант копирования. Но есть нюанс.
В SSE/AVX есть два варианта копирования: store и stream. Store записывает значение в кэш, а stream - нет, результат сразу пишется в RAM и скорость ограничена пропускной способностью памяти. В memcpy используют оба варианта, но для разных случаев, так store используется для диапазона 512B - 2MB, после 2MB идет stream, а до 512B - не SIMD версия. 2MB выбрано как примерный размер L3 кэша, но реальный размер L3 сильно зависит от модели ЦП.
Получается на диапазоне от 2MB до 8MB (на Ryzen 3900X) можно обогнать memcpy используя
_mm256_storeu_si256
.Многопоточный тест.
На 4х потоках store после 2MB сильно замедляется, а memcpy показывает максимальную производительность. Похоже все ЦП на x64 имеют кэш, настоенный под лимит в 2MB. Но нашелся старый AMD Phenom II на котором memcpy после 2MB выдает 4ГБ/с, а
_mm_stream_si128
- 6ГБ/с, по какой-то причине в SSE версии memcpy используется только _mm_storeu_si128
и здесь можно ускорить.#cpp #cpu_opt
C++ Data Structures That Make Video Games Go Round
Простенько, но наглядно.
Рассматривается:
* оптимизация хэш мапы
* массив из чанков вместо std::vector
* квадродерево для проверки видимости
* направленный граф для рендер графа
#cpp
Простенько, но наглядно.
Рассматривается:
* оптимизация хэш мапы
* массив из чанков вместо std::vector
* квадродерево для проверки видимости
* направленный граф для рендер графа
#cpp
Unlocking Modern CPU Power - Next-Gen C++ Optimization Techniques
17:00 - про кэш и префетч, цена случайного доступа к памяти.
24:42 - операции могут выполняться параллельно на уровне инструкций, но все портит ветвление.
32:19 - branchless, когда выполняются обе ветви.
45:45 - дальше про оптимизации на NUMA (много ЦП с локальной и общей памятью).
#cpp #cpu_opt
17:00 - про кэш и префетч, цена случайного доступа к памяти.
24:42 - операции могут выполняться параллельно на уровне инструкций, но все портит ветвление.
32:19 - branchless, когда выполняются обе ветви.
45:45 - дальше про оптимизации на NUMA (много ЦП с локальной и общей памятью).
#cpp #cpu_opt
Предкомпилированные заголовки в C++
При компиляции каждого cpp файла происходит перекомпиляция всех подключаемых заголовков, что занимает много времени.
Предкомпилированные заголовки (pch) позволяют скомпилировать их один раз и переиспользовать, что значительно ускорит компиляцию.
Остается только правильно их использовать.
Первый вариант - для каждой либы проекта сделать pch с входными .h, это обычно std и другие либы.
Минус - для каждой либы придется создавать свой pch с повторной компиляцией, что в итоге раздувает размер проекта.
У меня такой способ ускорил компиляцию в 2 раза и увеличил размер проекта до 100Гб.
Второй вариант - сложить все заголовки проекта в pch, тогда каждый .cpp файл будет обращаться к предкомпилированным заголовкам.
Скорость компиляции выросла еще в 2 раза, на старом ЦП даже в 5 раз. Размер проекта также уменьшился, ведь больше нет десятков pch.
Недостатки тоже есть - все зависимости и макросы придется вынести в один проект, чтобы pch компилировался со всем необходимыми макросами. Из-за этого не получится изолировать макросы в пределах одной либы. Изменение одного заголовка приводит к перекомпиляции всего проекта, что лучше подходит для CI, чем для разработки.
Третий вариант - сложить все заголовки отдельной либы в pch. Это решит проблему с макросами и чуть ускорит сборку, но останется слишком большой размер проекта.
Последний вариант - перейти на модули, но это требует рефакторинга всего кода.
#cpp
При компиляции каждого cpp файла происходит перекомпиляция всех подключаемых заголовков, что занимает много времени.
Предкомпилированные заголовки (pch) позволяют скомпилировать их один раз и переиспользовать, что значительно ускорит компиляцию.
Остается только правильно их использовать.
Первый вариант - для каждой либы проекта сделать pch с входными .h, это обычно std и другие либы.
Минус - для каждой либы придется создавать свой pch с повторной компиляцией, что в итоге раздувает размер проекта.
У меня такой способ ускорил компиляцию в 2 раза и увеличил размер проекта до 100Гб.
Второй вариант - сложить все заголовки проекта в pch, тогда каждый .cpp файл будет обращаться к предкомпилированным заголовкам.
Скорость компиляции выросла еще в 2 раза, на старом ЦП даже в 5 раз. Размер проекта также уменьшился, ведь больше нет десятков pch.
Недостатки тоже есть - все зависимости и макросы придется вынести в один проект, чтобы pch компилировался со всем необходимыми макросами. Из-за этого не получится изолировать макросы в пределах одной либы. Изменение одного заголовка приводит к перекомпиляции всего проекта, что лучше подходит для CI, чем для разработки.
Третий вариант - сложить все заголовки отдельной либы в pch. Это решит проблему с макросами и чуть ускорит сборку, но останется слишком большой размер проекта.
Последний вариант - перейти на модули, но это требует рефакторинга всего кода.
#cpp