CG & C++ blog
56 subscribers
13 photos
2 files
128 links
Краткий обзор публикаций, презентаций, докладов по графике и C++
Download Telegram
(video) CPU design effects - Jakub Beránek
Детально рассказывается про предсказание ветвлений, из интересного - в GCC подсказка __builtin_expect игнорируется ЦП.
41.53 - пример, где denorm float работает в 2 раза медленее.
51:56 - false sharing, как происходит синхронизация кэшей
#cpp #cpu_opt #intel_cpu
Ошибка в системе сборки, которая сразу обнаружилась через pragma detect_mismatch

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

#ifdef MACRO
# pragma detect_mismatch( "MACRO", "1" )
#else
# pragma detect_mismatch( "MACRO", "0" )
endif
При этом во всем основном проекте макрос установлен в 1 и я долго не мог понять откуда приходит 0.
Без pragma detect_mismatch найти ошибку было бы еще сложнее.
#cpp #blog
Создание World of Tanks Blitz на базе собственного движка DAVA
Как происходит переход из 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
(video) Уязвимости аллокаторов памяти – Павел Филонов
Простой пример как можно занести уязвимость в самописный аллокатор.
#cpp #security #rus
Moderness C++
Статьи по C++, много публикаций о соврменных стандартах.
#cpp
(video) Optimizing Binary Search - Sergey Slotin - CppCon 2022

3:50 - вариант с ветвлением, как в stl. Так было сделано для совместимости со всеми итераторами.
8:30 - детально разбирается почему ветвления это плохо.
12:29 - вариант без ветвлений, работает в 2 раза быстрее на небольших массивах.

Для большинства этого должно хватить, а дальше начинается магия оптимизаций.
#cpp #cpu_opt
C++20’s likely Attribute - Optimizations, Pessimizations, and unlikely Consequences (video)
Нюансы использования аттрибутов likely, unlikely.
Если кратко - все сильно зависит от кода и версии компилятора.
36:04 - likely оказался на 10% быстрее unlikely.
#cpp #cpu_opt
C++ Russia 2017: Антон Полухин, Как делать не надо: C++ велосипедостроение для профессионалов
Иногда полезно пересматривать прошлые конференции, хотя кажется, что все очевидно и давно известно.
Но нашел кое-что новое для себя:
58:12 - нужно указывать noexcept для move-ctor, иначе STL контейнеры могут аллоцировать дополнительную память при копировании.

В коде VS я не нышел выделение дополнительной памяти, зато есть такое:

// _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);
}
То есть move конструктор в некоторых случаях не вызывается, а происходит полное копирование.
#cpp
What is Low Latency C++? CppNow 2023

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
std::find() and memchr() Optimizations

Разбирается как оптимизировать 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 - заменяет 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
Как приостановить поток. Тесты на Mac M1 и Android.

Реализация 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 используя _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
Unlocking Modern CPU Power - Next-Gen C++ Optimization Techniques

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