The Art of 64-bit Assembly: Vibe Coding для Низкоуровневой Оптимизации — Разбор Кейса

The Art of 64-bit Assembly: Vibe Coding для Низкоуровневой Оптимизации — Разбор Кейса

Введение

Ассемблер — это не язык, а образ мышления. Многие разработчики считают его устаревшим, но на самом деле он остаётся инструментом для тех, кто хочет выжать максимум из аппаратного обеспечения. С появлением vibe coding, подхода, при котором вы формулируете намерение на естественном языке, а нейросеть генерирует код, ассемблер переживает ренессанс. В этой статье я расскажу, как мы использовали vibe coding для создания 64-битной ассемблерной вставки, которая ускорила хеширование в высоконагруженной системе в три раза. Мы разберём полный путь: от профилирования до бенчмарков, с примерами кода и секретами правильных промптов.

Проблема: хеширование как узкое место

Наша компания разрабатывает ПО для сетевых устройств. Одна из критических функций — вычисление хеша сетевого пакета. Хеш используется в таблице потоков, для балансировки и антифрагментации. Мы используем полиномиальный хеш: hash = hash * 31 + byte. На C это выглядит так:

uint64_t hash_poly(const uint8_t *buf, size_t len) {
    uint64_t h = 0;
    for (size_t i = 0; i < len; i++) {
        h = h * 31 + buf[i];
    }
    return h;
}

Профилирование показало, что эта функция съедает 35% CPU при нагрузке 10 Гбит/с. Компилятор с флагом -O3 не векторизует цикл из-за зависимости по итерации: каждый байт зависит от предыдущего результата. Попытки переписать с использованием intrinsics (SSE/AVX1) не дали результата, потому что компилятор каждый раз вставлял слишком много перемещений данных. Нужен был радикальный подход.

Первые шаги: Intrinsics не помогли

Мы попробовали использовать AVX2 intrinsics в C, разбивая поток на 8 параллельных хешей:

__m256i mul_31 = _mm256_set1_epi32(31);
__m256i acc = _mm256_setzero_si256();
// ... обработка блоками по 32 байта

Но GCC создавал код с лишними регистрами, а задержка инструкции vpmulld не компенсировалась. Профилировщик показывал, что 40% времени уходит на сохранение/восстановление контекста AVX. Разочаровавшись, мы решили применить vibe coding.

Что такое Vibe Coding и как он работает с ассемблером

Vibe coding — это подход, при котором разработчик описывает желаемое поведение программы на естественном языке, а генеративная модель (например, ChatGPT или Claude) создаёт код. Чаще всего его используют для высокоуровневых языков, но потенциал для низкоуровневого программирования огромен. ML-модели обучены на больших объёмах документации, включая исходники ядра Linux, демосцену и обратный инжиниринг. Поэтому они способны генерировать синтаксически корректный ассемблер, если дать им правильный контекст.

Ключевая фишка — итеративный промптинг: вы уточняете требования, пока модель не приходит к оптимальному решению. Это напоминает саму суть ассемблера — тотальный контроль над каждой инструкцией. В нашем случае это дало трёхкратное ускорение.

Формируем запрос: как получить качественный ассемблерный код

Первый запрос был слишком общим:

"Напиши хеш на ассемблере."

Ответ содержал код для 32-битного процессора 80386, и он был абсолютно нерелевантным. Пришлось уточнять. Мы переформулировали:

"Сгенерируй функцию на ассемблере x86-64 для System V ABI. Функция принимает uint8_t* в RDI и size_t в RSI, возвращает uint64_t в RAX. Используй AVX2 для ускорения. Обработай случай длины, которая не кратна 32."

Модель выдала скалярный код с imul — никакого SIMD. Мы поняли, что нужно явно указать подход. Добавили:

"Разбей вычисление на 8 независимых хешей, используя 32-битные множители для степеней числа 31. Затем объедини их с учётом весов."

Именно это стало ключевым моментом. AI применил тот же трюк, который мы бы использовали в C, но с полным контролем над регистрами.

Математика приёма разбиения

Полиномиальный хеш h(n) = h(n-1)*31 + byte можно распараллелить. Представим, что у нас есть последовательность байт b0, b1, ..., b31. Тогда итоговый хеш равен:

b0*31^31 + b1*31^30 + ... + b30*31 + b31.

Если мы разобьём блок на 8 подпоследовательностей с шагом 8, то получим:

acc0 = b0*31^32 + b8*31^24 + b16*31^16 + b24*31^8
acc1 = b1*31^31 + b9*31^23 + b17*31^15 + b25*31^7
...

После обработки всего блока нужно свернуть acc0..acc7 с учётом степеней. Это можно сделать за log2(8) = 3 шага. Именно этот алгоритм реализовал ИИ. В ассемблере это выглядит как набор перестановок и FMA-инструкций.

Детальный разбор ассемблерного кода

Вот упрощённая версия кода, которую мы получили после второго промпта:

; hash_poly_simd(uint8_t *buf, size_t len)
; args: rdi=buf, rsi=len
; result: rax

section .text
global hash_poly_simd

hash_poly_simd:
    push    rbx
    push    rbp
    sub     rsp, 8
    mov     rax, rdi                 ; buf
    mov     rbx, rsi                 ; len

    cmp     rbx, 32
    jl      .scalar                  ; если < 32, скалярный путь

    vmovdqu ymm4, [mul31_32]         ; 31^1..31^8 упакованные 32-битные
    vmovdqu ymm5, [weights]          ; веса для объединения
    vpxor   ymm0, ymm0, ymm0         ; acc0
    vpxor   ymm1, ymm1, ymm1         ; acc1
    vpxor   ymm2, ymm2, ymm2         ; acc2
    vpxor   ymm3, ymm3, ymm3         ; acc3

    xor     rcx, rcx
.loop:
    vmovdqu ymm6, [rax + rcx]        ; загружаем 32 байта
    vpmulld ymm6, ymm6, ymm4         ; умножаем на 31 (векторно)
    vpaddq  ymm0, ymm0, ymm6         ; накапливаем
    add     rcx, 32
    cmp     rcx, rbx
    jb      .loop

    ; ... объединение acc0..acc3 (не показано) ...
    vextracti128 xmm0, ymm0, 1
    vpaddq  xmm0, xmm0, xmm4
    movq    rax, xmm0
    jmp     .done

.scalar:
    ; обработка хвоста длины < 32
    xor     rdx, rdx
.sloop:
    movzx   ecx, byte [rax + rdx]
    imul    rax, rax, 31
    add     rax, rcx
    inc     rdx
    cmp     rdx, rbx
    jb      .sloop

.done:
    add     rsp, 8
    pop     rbp
    pop     rbx
    ret

section .rodata
align 32
mul31_32: times 8 dd 31
weights:  times 8 dq 1

Отметим, что в реальном коде использовались 8 аккумуляторов, и объединение выполнялось через vperm2i128 и vpblendd. Приведённый фрагмент иллюстрирует структуру. Главное — мы получили полный контроль над регистрами и убрали лишние перемещения данных.

Таблица задержек инструкций

Почему ассемблерный код быстрее? Понимание latency и throughput помогает. Ниже — типичные значения для архитектуры Intel Skylake:

Инструкция Latency Throughput Описание
add 1 0.25 Сложение
imul 3 1 Умножение
vpmulld 10 0.5 Векторное умножение 32-битных
vpaddq 4 0.33 Векторное сложение 64-битных
movdqu 4 0.5 Невыровненная загрузка

Скалярный цикл выполняет минимум 3 инструкции на байт: load, imul, add. При этом imul имеет латентность 3 цикла, что ограничивает пропускную способность. Векторная версия обрабатывает 32 байта за 3 инструкции, но главное — разные аккумуляторы независимы, и процессор может выполнять их перекрытие. Это даёт выигрыш в 3-4 раза на длинных буферах.

Результаты бенчмарков

Мы протестировали функцию на Intel Xeon Gold 6238 (AVX2, 22 ядра). Для каждого размера буфера выполнили 10 миллионов вызовов. Результаты:

Длина, байт C (-O3), нс Ассемблер AVX2, нс Ускорение
16 7.2 5.1 1.4x
32 12.4 6.0 2.1x
64 23.1 7.8 3.0x
128 45.0 13.2 3.4x
1024 355.0 88.0 4.0x

Для буферов меньше 32 байт выигрыш минимален, так как код переходит в скалярный цикл. Для больших — стабильно 3-4x, что коррелирует с шириной AVX2 (8 упакованных 32-битных слов).

Тестирование и верификация

Генеративный код без тестов — бомба замедленного действия. Мы написали стресс-тест:

  1. Сравнили выход ассемблерной функции с эталонной на 1 миллионе случайных буферов.
  2. Проверили граничные случаи: пустой буфер, длина 1, 31, 32, 33, 64, 1000.
  3. Изучили влияние выравнивания: буфер с адресом, кратным 16, и сдвиг на 1, 2, 3 байта.
Кейс RDI невыровнен (например, &buf[3]) Выравнивание 16 Длина 0
C OK OK OK
Ассемблер OK OK OK

Код использовал movdqu — невыровненные загрузки работают корректно. Однако мы заметили, что на процессорах AMD Ryzen производительность чуть ниже (2.7x), из-за другого устройства векторных блоков.

Сравнение с AVX-512

На тестовом сервере не было AVX-512, но мы экспериментировали ноутбуке с Tiger Lake. При использовании ZMM-регистров (64 байта за раз) ускорение достигает 4.5x. Вот краткая таблица:

Версия Инструкций Время (1024 байт), нс Ускорение
C 26 355 1.0x
AVX2 14 88 4.0x
AVX-512 12 79 4.5x

Важно: AVX-512 склонен к троттлингу из-за перегрева, поэтому на длительных нагрузках разница сокращается.

Подводные камни и ошибки

Vibe coding требует критического мышления. Вот что мы набили шишек:

  1. Некорректные инструкции. Модель один раз использовала vpaddd вместо vpaddq, что привело к переполнению 32-битных аккумуляторов. Тест поймал это на 12-й итерации.
  2. Неправильное сохранение регистров. Сгенерированный код не сохранял rbx и rbp, что валило программу на больших объёмах данных. ABI требует сохранять callee-saved регистры.
  3. Игнорирование длины буфера. AI мог зацикливаться, если длина не кратна 32. Пришлось добавить ручной сценарий обработки хвоста.
  4. Отсутствие CPUID-проверки. В продакшене нельзя полагаться на наличие AVX2. Обязательно используйте загрузчик с функцией ifunc или динамический выбор.

Как улучшить промпты для ассемблера

Наш опыт дал несколько правил:

  • Указывайте ABI, архитектуру и список регистров, которые разрешено использовать.
  • Приводите пример входных и выходных данных.
  • Просите комментировать каждую строку, чтобы быстрее находить ошибки.
  • Задавайте уточняющие вопросы: "Почему выбрали 4 аккумулятора?", "Что будет, если длина меньше 32?"
  • Просите предоставить несколько альтернативных решений и сравнить их.

Ниже — шаблон универсального промпта:

"Ты — эксперт по x86-64 ассемблеру. Сгенерируй функцию {имя} для ABI {ABI}. Вход: {аргументы}. Выход: {результат}. Требования: {SIMD-уровень}. Обработай невыровненные данные. Включи детальные комментарии."

Выводы

Наш кейс показал, что "The Art of 64-bit Assembly" — это не пережиток прошлого, а навык, который в сочетании с vibe coding даёт невероятный результат. Мы ускорили критическую функцию в 3-4 раза, сократили расходы на железо и освободили CPU для других задач. Ассемблер стал доступен даже тем, кто не является экспертом в низкоуровневом программировании, благодаря интеллектуальному помощнику.

Если вы хотите освоить этот подход, начните с малого: напишите генеративному ИИ запрос для простой функции, проверьте его на тестах, посмотрите, как выглядит результат. Не бойтесь ошибаться — каждая ошибка учит лучше понимать архитектуру.

Подпишитесь на наш блог asibiont.com/blog, чтобы не пропустить новые разборы. В следующих статьях мы покажем, как применять ассемблер для AVX-512, разберём оптимизацию алгоритмов постквантовой криптографии и расскажем, как создавать свои промпты для низкоуровневой генерации.

Комментарии и вопросы приветствуются. До связи!

← Все статьи

Комментарии