Привет, Хабр!
Для начала хочу сказать огромное спасибо всем, кто прочитал и прокомментировал мою прошлую статью про Data-Oriented Design.
Честно говоря, я ожидал дискуссии, но такой накал страстей вокруг кэш-миссов, структур данных и «смерти ООП» меня приятно удивил. Приятно видеть, что оптимизация и понимание того, как данные текут через железо, всё еще волнуют сообщество.
Я внимательно изучил все ваши аргументы — от «компилятор сам всё сделает» до «это невозможно поддерживать в реальных проектах». И вместо того, чтобы отвечать каждому в ветке комментариев, я решил подготовить этот материал.
Многие из вас совершенно справедливо заметили: DOD — это круто, но какой в нем смысл, если мы упираемся в зоопарк архитектур? Мы раскладываем данные в памяти идеально ровно, но как только пытаемся применить к ним SIMD-инструкции (чтобы получить тот самый 10-кратный буст), мы попадаем в ловушку вендор-лока. Написал под Intel — не работает на ARM. Написал под ARM — не заведется в браузере.
Сегодня я хочу показать инструмент, который снимает этот вопрос и делает «низкоуровневую магию» действительно универсальной.
Речь пойдет о SIMDe (SIMD Everywhere).
О чем поговорим?
Если в прошлый раз мы говорили о том, как расположить данные (Layout), то сегодня мы поговорим о том, как их обрабатывать (Compute).
Мы разберем:
Как перестать бояться интринсиков и начать их использовать везде.
Как SIMDe позволяет писать код один раз под x86, а запускать его на Apple M-серии или в WebAssembly без боли.
И самое главное — почему это не «очередная медленная прослойка», а инструмент, который дает компилятору те самые подсказки, о которых мы спорили в прошлый раз.
Немного базы: Что мы вообще ускоряем?
Прежде чем лезть в дебри SIMDe, давайте синхронизируемся в терминах. Если вы профессионально занимаетесь HPC (High Performance Computing), можете пропустить этот раздел, но для остальных — краткий ликбез.
1. Что такое SIMD?
Обычно процессор работает в режиме SISD (Single Instruction, Single Data): одна команда — одна единица данных. Например, «сложи число А и число Б».
SIMD (Single Instruction, Multiple Data) — это когда процессор за один такт выполняет одну и ту же операцию над целым вектором данных. Представьте это как конвейер: вместо того чтобы красить каждую деталь вручную, вы окунаете в краску сразу корзину из 4, 8 или 16 деталей.
Вместо одного
floatмы берем регистр (длинную «ячейку» в процессоре).За один такт мы складываем сразу, например, 4 пары чисел.
Результат: теоретическое ускорение в 4 раза (для
float) или в 16 раз (дляint8).
2. Интринсики (Intrinsics) — это больно?
Чтобы заставить процессор работать с SIMD, у программиста есть три пути:
Автовекторизация: Надеемся, что компилятор увидит наш цикл и сам догадается использовать SIMD. (Спойлер из комментариев к прошлой статье: он догадывается не всегда).
Ассемблер: Путь для мазохистов.
Интринсики: Это специальные функции в Си/Си++, которые выглядят как обычный код, но напрямую маппятся (превращаются) в конкретные инструкции процессора.
Пример интринсика для Intel (SSE2):_mm_add_ps(a, b) — это команда «сложить два вектора по 4 float в каждом».
3. В чем главная проблема?
Проблема в диалектах.
У Intel это SSE, AVX, AVX-512.
У ARM (который в ваших смартфонах и новых Mac) это NEON.
У WebAssembly свои инструкции.
Если вы написали код на интринсиках Intel (_mm_add_ps), он физически не скомпилируется под ARM. У ARM эта команда называется иначе, у нее другие аргументы и логика.
И тут SIMDe
Библиотека SIMDe решает проблему «вавилонского столпотворения» среди процессоров. Она говорит: «Пиши на том языке, на котором тебе удобно (например, на интринсиках Intel), а я сама переведу это на язык того процессора, под который ты сейчас компилируешь».
Это не эмуляция в духе «запустим Windows на калькуляторе». Это умное перенаправление:
Если ты компилируешь код Intel под процессор Intel, SIMDe просто отходит в сторону. Потерь производительности — ноль.
Если ты компилируешь тот же код под ARM, SIMDe подменяет интеловский
mmadd_psна армовскийvaddq_f32.Если у процессора вообще нет подходящей инструкции, SIMDe развернет её в обычный быстрый Си-код, чтобы всё хотя бы просто заработало.
Что такое SIMD-регистры (и почему они «толстые»)?
Чтобы понять разницу между обычным кодом и векторным, нужно заглянуть внутрь процессора — в его регистры.
Регистры — это сверхбыстрая память прямо «на борту» вычислительного ядра. В обычном режиме (SISD) процессор использует стандартные регистры (например, RAX, RBX в x86_64). Они имеют размер 64 бита. В такой регистр помещается ровно одно целое число или одно число с плавающей точкой двойной точности.
SIMD-регистры устроены иначе:
Они широкие: В зависимости от поколения процессора, их ширина составляет 128, 256 или даже 512 бит.
Они работают как контейнеры: Процессор не смотрит на такой регистр как на одно гигантское число. Он видит в нем «слоты» для данных.
Как это выглядит в памяти:
Возьмем стандартный 128-битный регистр (в Intel это называется XMM, в ARM — V):
В него можно упаковать 4 числа
float(по 32 бита каждое).Или 2 числа
double(по 64 бита).Или 16 чисел
int8(символы или мелкие целые).
Простая аналогия: Обычный регистр — это легковой автомобиль на одного пассажира. SIMD-регистр — это автобус. Чтобы перевезти 40 человек, легковой машине придется съездить 40 раз, а автобус сделает это за один рейс.
Типы данных в коде
Когда мы используем SIMDe или нативные интринсики, мы перестаем работать с обычными float или int. Появляются новые типы данных, которые описывают содержимое этих «автобусов»:
__m128— это 128-битный регистр, в котором лежат 4float.__m256— 256-битный регистр (8float).__m128i— 128-битный регистр с целыми числами (внутри могут бытьint8,int32и т.д.).
Почему это важно для производительности?
Главная «магия» в том, что арифметический блок процессора (ALU) умеет выполнять операцию над всем регистром сразу. Когда вы вызываете команду сложения векторов, процессор физически замыкает контакты так, что все 4 или 8 чисел складываются одновременно за один такт.
Именно поэтому в прошлой статье про DOD мы говорили о выравнивании данных. Чтобы процессор мог мгновенно загрузить данные в такой «широкий» регистр, они должны лежать в памяти плотными, ровными блоками. Если данные разбросаны (как в обычном ООП), «автобус» будет стоять и ждать, пока каждого пассажира подвезут на такси из разных концов города.
Чуть больше инфы
Для глубокого понимания SIMDe важно разобраться, почему переход от SSE к AVX (Advanced Vector Extensions) стал таким событием в мире производительности. Это не просто «еще одна библиотека», это качественный скачок в архитектуре процессоров.
Давай добавим в статью блок теории, который объяснит, почему AVX — это «тяжелая артиллерия».
AVX: Когда 128 бит стало мало
Если SSE (Streaming SIMD Extensions) в свое время дала нам 128-битные регистры, то AVX, представленная Intel в 2011 году, удвоила ставки.
1. Ширина регистров: YMM против XMM
В процессоре появились новые регистры под названием YMM (Y-register Multi-Media).
SSE использовала регистры XMM (128 бит). В них влезало 4 числа
float.AVX использует регистры YMM (256 бит). В них влезает 8 чисел
floatили 4 числаdouble.
Что это дает на практике? Если твой алгоритм (как наш
ZooData) упирается в математические расчеты, переход на AVX теоретически в 2 раза быстрее, чем SSE, при той же тактовой частоте. Ты просто обрабатываешь в два раза больше данных за один «тик» процессора.
2. Трехоперандная логика (VEX-кодирование)
Это технический момент, который сильно упрощает жизнь процессору.
В старом SSE операции были двухоперандными:
A = A + B. Чтобы сохранить результат в новую переменную, процессору приходилось сначала копировать данные, а потом складывать.В AVX ввели схему
C = A + B. Теперь мы можем сложить два регистра и записать результат в третий за одну команду. Это уменьшает количество «мусорных» инструкций копирования.
3. Как SIMDe «дружит» AVX с другим железом?
Вот тут кроется самая интересная часть для твоей статьи.Проблема AVX в том, что это эксклюзив x86 (Intel и AMD). На процессорах ARM (Apple M1/M2/M3, Raspberry Pi) нативно регистры имеют ширину только 128 бит (стандарт NEON).
Что делает SIMDe, когда видит твой AVX-код на ARM?Она применяет технику Split/Join:
Берет твою 256-битную команду (например,
simde_mm256_add_ps).Разрезает её на две 128-битные операции, понятные ARM.
Выполняет их последовательно.
Результат: Твой код на AVX продолжает работать даже там, где физически нет 256-битных регистров. Да, это будет чуть медленнее, чем «родной» AVX на Intel, но это будет всё равно быстрее, чем обычный цикл, и — что самое важное — тебе не нужно переписывать ни одной строчки кода.
В чем отличие AVX от AVX2? (Важная деталь)
Стоит упом��нуть, что есть AVX и AVX2:
AVX: В основном про числа с плавающей точкой (
float,double).AVX2: Добавила полноценную поддержку целых чисел (
int8,int16,int32) в 256-битные регистры.
Примеры
Возьмем пример из прошлой статьи но немного изменим:
#include <simde/x86/avx.h> // Подключаем AVX через SIMDe void update_positions_avx(ZooData* zoo) { // Теперь создаем вектор из ВОСЬМИ единиц: [1.0, 1.0, ..., 1.0] // Тип данных __m256 (256 бит = 8 float) simde__m256 step = simde_mm256_set1_ps(1.0f); int i = 0; // Шагаем сразу по 8 элементов! for (; i <= zoo->count - 8; i += 8) { // Загружаем 8 координат X (256 бит данных) simde__m256 x_vec = simde_mm256_loadu_ps(&zoo->x[i]); // Складываем 8 пар чисел одновременно x_vec = simde_mm256_add_ps(x_vec, step); // Сохраняем результат simde_mm256_storeu_ps(&zoo->x[i], x_vec); // То же самое для Y simde__m256 y_vec = simde_mm256_loadu_ps(&zoo->y[i]); y_vec = simde_mm256_add_ps(y_vec, step); simde_mm256_storeu_ps(&zoo->y[i], y_vec); } // Добиваем остаток (если животных, например, 107, то последние 3) for (; i < zoo->count; i++) { zoo->x[i] += 1.0f; zoo->y[i] += 1.0f; } }
В двух словах: этот код перекладывает данные из «кучи» объектов в плотные массивы (DOD/SoA).
Структура: Вместо списка животных, где данные в памяти перемешаны (X, Y, тип, X, Y, тип...), он создает отдельные «линейки» только для X и только для Y.
Профит: Когда мы в цикле прибавляем
1.0f, процессор читает память ровной линией без прыжков. Это убирает кэш-миссы и готовит почву для SIMD, чтобы обрабатывать эти числа пачками по 8 штук.
Короче: это максимально дружелюбная для железа расстановка данных
Чтобы пример из «игрушечного» прибавления единицы превратился в реальную задачу, давай заставим наш «Зоопарк» делать что-то осмысленное. Например,
рассчитывать дистанцию до цели (скажем, до кормушки) и двигать животных в её сторону, если они находятся в радиусе обнаружения.
Здесь нам понадобятся: вычитание, умножение (квадрат расстояния) и сравнение. Реальная задача: Поиск целей в радиусе
В обычном коде мы бы считали корень
, но в SIMD извлечение корня — дорогая операция. Поэтому профи всегда сравнивают обратный корень
#include <simde/x86/avx.h> void update_zoo_logic_avx(ZooData* zoo, float target_x, float target_y) { simde__m256 t_x = simde_mm256_set1_ps(target_x); simde__m256 t_y = simde_mm256_set1_ps(target_y); simde__m256 speed = simde_mm256_set1_ps(0.5f); // Скорость движения int i = 0; for (; i <= zoo->count - 8; i += 8) { // 1. Загружаем пачку координат X и Y (8 животных) simde__m256 x = simde_mm256_loadu_ps(&zoo->x[i]); simde__m256 y = simde_mm256_loadu_ps(&zoo->y[i]); // 2. Вычисляем вектор направления (дистанция по осям) simde__m256 dx = simde_mm256_sub_ps(t_x, x); simde__m256 dy = simde_mm256_sub_ps(t_y, y); // 3. Считаем квадрат расстояния: d2 = dx*dx + dy*dy simde__m256 d2 = simde_mm256_add_ps(simde_mm256_mul_ps(dx, dx), simde_mm256_mul_ps(dy, dy)); // 4. МАГИЯ: Быстрый обратный квадратный корень (1/sqrt(d2)) // Это ОДНА инструкция процессора. Она дает приближенное значение 1/L simde__m256 inv_dist = simde_mm256_rsqrt_ps(d2); // 5. Нормализуем вектор и умножаем на скорость // Направление = Delta * (1/Distance) * Speed simde__m256 move_x = simde_mm256_mul_ps(simde_mm256_mul_ps(dx, inv_dist), speed); simde__m256 move_y = simde_mm256_mul_ps(simde_mm256_mul_ps(dy, inv_dist), speed); // 6. Обновляем координаты плотной пачкой simde_mm256_storeu_ps(&zoo->x[i], simde_mm256_add_ps(x, move_x)); simde_mm256_storeu_ps(&zoo->y[i], simde_mm256_add_ps(y, move_y)); } // Хвост (Remainder) обрабатываем стандартно... }
Магия rsqrt: Почему это быстрее обычного корня?
В нашем коде мы использовали инструкцию simde_mm256_rsqrt_ps. Чтобы понять, почему это «чит» для производительности, нужно сравнить два пути:
Классический путь: Вычислить корень из x, а затем разделить единицу на результата (1.0/res)
Проблема: Операция деления (
div) — одна из самых «дорогих» и медленных в процессоре. Она может занимать 10–20 тактов, пока остальные инструкции пролетают за 1.
Путь SIMD (
rsqrt): Процессор не считает корень честно. У него внутри есть аппаратная таблица заранее вычисленных значений.Как это работает: Процессор «подглядывает» в таблицу, получает приближенное значение и за 1 такт выдает результат с точностью около 1,5*10^-3(11-12 бит).
Профит: Это в десятки раз быстрее, чем связка
sqrt+div.
Зачем нам это в «Зоопарке»? Когда нам нужно передвинуть животное к цели, нам нужно нормализовать вектор. Вектор направления — это
Вместо того чтобы делить на корень, мы просто умножаем на обратный корень. А умножение в SIMD — это почти бесплатно.
Итоги
Мы взяли структуру данных из прошлой статьи (DOD) и наложили на неё векторную математику (SIMD).
Что мы получили?
Экономия тактов: Обработка 8 объектов за один проход.
Экономия памяти: Кэш забит только нужными координатами, а не «мусором» из объектов.
Масштабируемость: Код одинаково эффективно работает на Intel, AMD и ARM.
Завершая минимальный разбор SIMDe, важно не впасть в крайность. Я не призываю завтра же переписывать всё ваше приложение, от интерфейса до сетевого стека, на плотные массивы и интринсики. ООП — это великолепный инструмент для управления сложностью, построения бизнес-логики и создания гибких архитектур. Там, где важна читаемость и поддержка иерархий, объекты остаются королями.
Однако современная разработка требует от нас быть гибкими. Проблема наступает тогда, когда мы пытаемся натянуть «объектную» модель на вычислительно тяжелые задачи.
В чем реальная ценность связки DOD + SIMDe в 2026 году?
Локализация оптимизации: Нам не нужно отказываться от объектов везде. Но в «горячих» точках (обработка физики, частиц, сигналов, больших массивов данных) мы обязаны переходить на Data-Oriented Design. Подготовив данные в плотные структуры, мы даем процессору то, что он любит больше всего — предсказуемость.
Процессорная «ширина»: Современные CPU больше не растут в частоте (мы уперлись в физический потолок), они растут «вширь». Появляется больше ядер и более широкие регистры (AVX-512, новые итерации NEON). Если ваш код не умеет работать с векторами, вы просто не используете 90% мощности купленного железа. SIMDe — это мост, который позволяет использовать эту мощь, не превращая код в нечитаемое месиво под конкретный процессор.
Кроссплатформенность как стандарт: Раньше ручная оптимизация означала «заточку» под Intel. Сегодня, когда Apple Silicon захватил десктопы, а ARM — облака, это больше не работает. Библиотеки вроде SIMDe позволяют нам писать эффективный код один раз. Мы используем привычные абстракции (интринсики), а библиотека гарантирует, что они «взлетят» и на сервере, и на смартфоне.
Итог для инженера
Высокая производительность — это не магия, а уважение к ресурсам.
ООП — это наш инструмент для управления людьми и сложностью.
DOD + SIMD — это наш инструмент для управления железом.
Настоящее мастерство архитектора сегодня заключается в умении разделять эти уровни. Подготовьте данные, упакуйте их в плотные «ленты» там, где это критично для скорости, и используйте SIMDe, чтобы ваш код оставался универсальным.
Оптимизация перестала быть уделом «избранных», пишущих на ассемблере. Сегодня это вопрос гигиены кода и понимания того, как ваши данные текут через транзисторы. И чем раньше мы начнем учитывать интересы процессора, тем эффективнее и быстрее будут наши продукты.
P.S
маякните если кто-то что-то не понял и/или хочет продолжения :-)
