Привет, Хабр!

Для начала хочу сказать огромное спасибо всем, кто прочитал и прокомментировал мою прошлую статью про Data-Oriented Design.

Честно говоря, я ожидал дискуссии, но такой накал страстей вокруг кэш-миссов, структур данных и «смерти ООП» меня приятно удивил. Приятно видеть, что оптимизация и понимание того, как данные текут через железо, всё еще волнуют сообщество.

Я внимательно изучил все ваши аргументы — от «компилятор сам всё сделает» до «это невозможно поддерживать в реальных проектах». И вместо того, чтобы отвечать каждому в ветке комментариев, я решил подготовить этот материал.

Многие из вас совершенно справедливо заметили: DOD — это круто, но какой в нем смысл, если мы упираемся в зоопарк архитектур? Мы раскладываем данные в памяти идеально ровно, но как только пытаемся применить к ним SIMD-инструкции (чтобы получить тот самый 10-кратный буст), мы попадаем в ловушку вендор-лока. Написал под Intel — не работает на ARM. Написал под ARM — не заведется в браузере.

Сегодня я хочу показать инструмент, который снимает этот вопрос и делает «низкоуровневую магию» действительно универсальной.

Речь пойдет о SIMDe (SIMD Everywhere).

О чем поговорим?

Если в прошлый раз мы говорили о том, как расположить данные (Layout), то сегодня мы поговорим о том, как их обрабатывать (Compute).

Мы разберем:

  1. Как перестать бояться интринсиков и начать их использовать везде.

  2. Как SIMDe позволяет писать код один раз под x86, а запускать его на Apple M-серии или в WebAssembly без боли.

  3. И самое главное — почему это не «очередная медленная прослойка», а инструмент, который дает компилятору те самые подсказки, о которых мы спорили в прошлый раз.

Немного базы: Что мы вообще ускоряем?

Прежде чем лезть в дебри 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, у программиста есть три пути:

  1. Автовекторизация: Надеемся, что компилятор увидит наш цикл и сам догадается использовать SIMD. (Спойлер из комментариев к прошлой статье: он догадывается не всегда).

  2. Ассемблер: Путь для мазохистов.

  3. Интринсики: Это специальные функции в Си/Си++, которые выглядят как обычный код, но напрямую маппятся (превращаются) в конкретные инструкции процессора.

Пример интринсика для 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) процессор использует стандартные регистры (например, RAXRBX в 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-битный регистр, в котором лежат 4 float.

  • __m256 — 256-битный регистр (8 float).

  • __m128i — 128-битный регистр с целыми числами (внутри могут быть int8int32 и т.д.).

Почему это важно для производительности?

Главная «магия» в том, что арифметический блок процессора (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:

  1. Берет твою 256-битную команду (например, simde_mm256_add_ps).

  2. Разрезает её на две 128-битные операции, понятные ARM.

  3. Выполняет их последовательно.

Результат: Твой код на AVX продолжает работать даже там, где физически нет 256-битных регистров. Да, это будет чуть медленнее, чем «родной» AVX на Intel, но это будет всё равно быстрее, чем обычный цикл, и — что самое важное — тебе не нужно переписывать ни одной строчки кода.

В чем отличие AVX от AVX2? (Важная деталь)

Стоит упом��нуть, что есть AVX и AVX2:

  • AVX: В основном про числа с плавающей точкой (floatdouble).

  • AVX2: Добавила полноценную поддержку целых чисел (int8int16int32) в 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).

  1. Структура: Вместо списка животных, где данные в памяти перемешаны (X, Y, тип, X, Y, тип...), он создает отдельные «линейки» только для X и только для Y.

  2. Профит: Когда мы в цикле прибавляем 1.0f, процессор читает память ровной линией без прыжков. Это убирает кэш-миссы и готовит почву для SIMD, чтобы обрабатывать эти числа пачками по 8 штук.

Короче: это максимально дружелюбная для железа расстановка данных

Чтобы пример из «игрушечного» прибавления единицы превратился в реальную задачу, давай заставим наш «Зоопарк» делать что-то осмысленное. Например,

рассчитывать дистанцию до цели (скажем, до кормушки) и двигать животных в её сторону, если они находятся в радиусе обнаружения. 

Здесь нам понадобятся: вычитание, умножение (квадрат расстояния) и сравнение. Реальная задача: Поиск целей в радиусе 

В обычном коде мы бы считали корень

 \sqrt{(x_{1}-x_{2})^{2}+(y_{1}-y_{2})^{2}}

, но в 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. Чтобы понять, почему это «чит» для производительности, нужно сравнить два пути: 

  1. Классический путь: Вычислить корень из x, а затем разделить единицу на результата (1.0/res)

    • Проблема: Операция деления (div) — одна из самых «дорогих» и медленных в процессоре. Она может занимать 10–20 тактов, пока остальные инструкции пролетают за 1.

  2. Путь SIMD (rsqrt): Процессор не считает корень честно. У него внутри есть аппаратная таблица заранее вычисленных значений.

    • Как это работает: Процессор «подглядывает» в таблицу, получает приближенное значение и за 1 такт выдает результат с точностью около 1,5*10^-3(11-12 бит).

    • Профит: Это в десятки раз быстрее, чем связка sqrt + div

Зачем нам это в «Зоопарке»? Когда нам нужно передвинуть животное к цели, нам нужно нормализовать вектор. Вектор направления — это

V_{norm}=\frac{V}{Length}=V\cdot \frac{1}{\sqrt{\Delta x^{2}+\Delta y^{2}}}

Вместо того чтобы делить на корень, мы просто умножаем на обратный корень. А умножение в SIMD — это почти бесплатно.

Итоги

Мы взяли структуру данных из прошлой статьи (DOD) и наложили на неё векторную математику (SIMD).

Что мы получили?

  • Экономия тактов: Обработка 8 объектов за один проход.

  • Экономия памяти: Кэш забит только нужными координатами, а не «мусором» из объектов.

  • Масштабируемость: Код одинаково эффективно работает на Intel, AMD и ARM.

Завершая минимальный разбор SIMDe, важно не впасть в крайность. Я не призываю завтра же переписывать всё ваше приложение, от интерфейса до сетевого стека, на плотные массивы и интринсики. ООП — это великолепный инструмент для управления сложностью, построения бизнес-логики и создания гибких архитектур. Там, где важна читаемость и поддержка иерархий, объекты остаются королями.

Однако современная разработка требует от нас быть гибкими. Проблема наступает тогда, когда мы пытаемся натянуть «объектную» модель на вычислительно тяжелые задачи.

В чем реальная ценность связки DOD + SIMDe в 2026 году?

  1. Локализация оптимизации: Нам не нужно отказываться от объектов везде. Но в «горячих» точках (обработка физики, частиц, сигналов, больших массивов данных) мы обязаны переходить на Data-Oriented Design. Подготовив данные в плотные структуры, мы даем процессору то, что он любит больше всего — предсказуемость.

  2. Процессорная «ширина»: Современные CPU больше не растут в частоте (мы уперлись в физический потолок), они растут «вширь». Появляется больше ядер и более широкие регистры (AVX-512, новые итерации NEON). Если ваш код не умеет работать с векторами, вы просто не используете 90% мощности купленного железа. SIMDe — это мост, который позволяет использовать эту мощь, не превращая код в нечитаемое месиво под конкретный процессор.

  3. Кроссплатформенность как стандарт: Раньше ручная оптимизация означала «заточку» под Intel. Сегодня, когда Apple Silicon захватил десктопы, а ARM — облака, это больше не работает. Библиотеки вроде SIMDe позволяют нам писать эффективный код один раз. Мы используем привычные абстракции (интринсики), а библиотека гарантирует, что они «взлетят» и на сервере, и на смартфоне.

Итог для инженера

Высокая производительность — это не магия, а уважение к ресурсам.

  • ООП — это наш инструмент для управления людьми и сложностью.

  • DOD + SIMD — это наш инструмент для управления железом.

Настоящее мастерство архитектора сегодня заключается в умении разделять эти уровни. Подготовьте данные, упакуйте их в плотные «ленты» там, где это критично для скорости, и используйте SIMDe, чтобы ваш код оставался универсальным.

Оптимизация перестала быть уделом «избранных», пишущих на ассемблере. Сегодня это вопрос гигиены кода и понимания того, как ваши данные текут через транзисторы. И чем раньше мы начнем учитывать интересы процессора, тем эффективнее и быстрее будут наши продукты.

P.S

маякните если кто-то что-то не понял и/или хочет продолжения :-)

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
было полезно/интересно?
76.47%да13
0%нет0
5.88%low-level для гиков1
5.88%компилятор сделает за меня1
11.76%я все знаю2
Проголосовали 17 пользователей. Воздержавшихся нет.