Числа с плавающей точкой и SIMD
Контекст: x86-64, NASM, Linux. Целочисленная арифметика — основы и длинные числа. Примеры в справочнике (сумма массива SSE).
Два мира вещественной арифметики на x86
| Подсистема | Регистры | Статус сегодня |
|---|---|---|
| x87 FPU | стек ST(0)..ST(7) | legacy; встречается в старом коде и некоторых ABI |
| SSE / SSE2 | XMM0–XMM15 (128 бит) | стандарт для float/double в 64-битных ОС |
| AVX / AVX2 | YMM0–YMM15 (256 бит) | векторные пакеты по 8 float или 4 double |
| AVX-512 | ZMM (512 бит) | серверные и HPC-линейки |
Новый ассемблерный код для float почти всегда использует SSE2 (movss, addss, mulsd …), а не стек x87.
Хорошее практическое правило: сначала добейтесь корректности в скалярной версии (addss/addsd), затем переходите к пакетным операциям (addps/addpd) только в подтверждённо "горячем" участке.
Формат IEEE 754
- single (
float) — 32 бита — знак, экспонента, мантисса. - double — 64 бита.
В памяти на x86 — little-endian, как целые. Константы в NASM:
section .data
pi dd 3.141592 ; 32-bit float
e dq 2.718281828 ; 64-bit double
Разбор:
section .dataоткрывает секцию инициализированных данных.ddразмещает 32-битное значениеfloatв формате IEEE 754.dqразмещает 64-битное значениеdouble.- Метки
piиeпозволяют обращаться к константам из кода через адрес. - Константы хранятся в памяти в little-endian представлении x86.
Сравнение и порядок — по правилам IEEE; флаги целочисленного CMP на float не подходят — используют COMISS/UCOMISS или сравнение через вычитание с проверкой статуса.
Регистры XMM
XMM0–XMM7 в типичном вызове передают вещественные аргументы (System V: XMM0–XMM7). Возврат float/double — часто в XMM0.
Один регистр XMM — 128 бит. Может хранить:
- один
double(нижние 64 бита), - два
float(пакет), - четыре int32 (SSE2 integer pack),
- шестнадцать int8 (для SIMD-обработки байт).
Скалярные операции SSE (один float)
section .data
a dd 1.5
b dd 2.0
r dd 0.0
section .text
movss xmm0, [rel a]
addss xmm0, [rel b] ; xmm0 = 3.5
movss [rel r], xmm0
Разбор:
movss xmm0, [rel a]загружает одинfloatиз памяти в младшие 32 битаXMM0.- Суффикс
ssобозначает scalar single-precision операцию. addss xmm0, [rel b]прибавляет второйfloatк текущему значению вXMM0.- Результат
3.5остаётся вXMM0. movss [rel r], xmm0сохраняет итог обратно в память.- Верхняя часть регистра XMM в таких операциях не считается результатом и требует аккуратности в смешанных packed-сценариях.
Суффикс ss — scalar single (один float в младшей части XMM). Для double — sd — movsd, addsd, mulsd.
Скалярное сложение double:
section .data
x dq 1.25
y dq 2.75
z dq 0.0
section .text
movsd xmm0, [rel x]
addsd xmm0, [rel y] ; xmm0 = 4.0
movsd [rel z], xmm0
Разбор:
- Суффикс
sd— scalar double: работает с однимdoubleв младших 64 битах XMM. dqв.dataразмещает 64-битные константы IEEE 754.movsd/addsd/movsd— прямой аналог скалярного примера дляfloat, но с 64 битами.- Результат
4.0оказывается вXMM0, затем сохраняется вz. - Для вызовов из C возвращаемый
doubleтоже передают черезXMM0.
Обнулить верхние биты XMM после "грязных" операций иногда требуют xorps xmm0, xmm0 / movss — иначе старые данные в старших ланах влияют на некоторые packed-операции.
Векторное сложение (несколько float за раз)
section .data
align 16
vec_a dd 1.0, 2.0, 3.0, 4.0
vec_b dd 10.0, 20.0, 30.0, 40.0
section .text
movaps xmm0, [rel vec_a]
addps xmm0, [rel vec_b] ; четыре сложения параллельно
movaps [rel vec_a], xmm0
Разбор:
align 16выравнивает данные по 16-байтной границе для безопасной/быстрой загрузкиmovaps.vec_aиvec_bсодержат по четыреfloat, упакованных в один XMM-регистр.movaps xmm0, [rel vec_a]загружает 128 бит (4 элемента) изvec_a.addps xmm0, [rel vec_b]выполняет четыре независимых сложения lane-by-lane.movaps [rel vec_a], xmm0записывает вvec_aвектор результатов.- Суффикс
psозначает packed single-precision, то есть обработку массива в одном регистре.
movaps требует адрес, кратный 16. Невыровненный доступ — movups (медленнее на старых CPU) или выравнивание буфера через .align 16.
Сравнение и ветвления
movss xmm0, [a]
movss xmm1, [b]
ucomiss xmm0, xmm1 ; сравнить, установить ZF/PF/CF
ja greater ; "выше" для упорядоченных float
Разбор:
movssзагружает сравниваемые значенияfloatвXMM0иXMM1.ucomissвыполняет сравнение с учётом IEEE 754 и выставляет флагиZF/PF/CF.ja greaterпереходит, если первый операнд строго больше второго и сравнение упорядочено.- При
NaNфлаги интерпретируются иначе, поэтому для надёжной логики проверяют unordered-сценарий отдельно. - Такой шаблон используется, когда нужен переход по результату сравнения float без конвертации в integer.
Для сложной логики чаще вызывают код на C или используют маски SIMD (cmpps + movmskps), чтобы избежать серии переходов.
Сохранение XMM при вызовах
По System V AMD64 регистры XMM0–XMM15 — caller-saved (вызываемая функция может их портить). Если нужны после call — сохраняйте на стек (movdqu [rsp], xmm0 …) или не трогайте до возврата.
При смешивании с C-кодом с плавающей точкой соблюдайте то же правило, что для целочисленного ABI.
Сумма массива float в цикле (скалярный SSE):
Код ITЗагрузка примера кода…
Разбор:
xorps xmm0, xmm0обнуляет аккумулятор перед циклом.movssзагружает очередной элемент,addssдобавляет к сумме вXMM0.add rbx, 4сдвигает указатель на следующийfloat(4 байта).dec rcx/jnz— обычный счётчик итераций по длине массива.retоставляет итог вXMM0для вызова из C (float sum_array(void)или с аргументами).- Это базовый шаг перед векторизацией через
addpsна 4 элемента за раз.
Горизонтальная сумма четырёх float из одного XMM:
movaps xmm0, [rel vec_a] ; [a0, a1, a2, a3]
movaps xmm1, xmm0
shufps xmm1, xmm0, 0x4E ; поменять половины
addps xmm0, xmm1 ; попарное сложение
movaps xmm1, xmm0
shufps xmm1, xmm1, 0xB1
addss xmm0, xmm1 ; финальная скалярная сумма в младшей лане
Разбор:
- После
addpsв регистре лежат промежуточные суммы пар, а не одно число. shufpsпереставляет 32-битные ланы внутри XMM для "схлопывания" вектора.- Повторное
addpsиaddssсводят четыре компонента к одномуfloatв младших битахXMM0. - Такой приём нужен после пакетных операций, когда требуется одно скалярное значение (например, сумма всех элементов).
- Для учебных целей достаточно понимать идею; в продакшене часто используют intrinsics или
-O3.
Кратко про стек x87
Инструкции FLD, FADD, FSTP работают со стеком ST. Это другая модель — задержки, сложность оптимизации, отдельные правила округления. В 32-битном Windows раньше встречалось в "чистом" asm; в 64-битном коде компиляторы генерируют SSE.
Для чтения legacy-бинарников достаточно знать: длинные цепочки fld/fistp — почти наверняка x87.
AVX и AVX2
YMM — 256 бит: восемь float или четыре double за операцию (vaddps, vmovaps с префиксом VEX).
Требования:
- выравнивание 32 байта для
vmovapsпо памяти; - проверка поддержки CPU (
cpuid); - сохранение
YMMпри переключении контекста потока — ответственность ОС; в user-коде при вызовах — смотреть ABI.
Для учебного asm чаще достаточно SSE2; AVX имеет смысл в горячих циклах обработки массивов (графика, ML, кодеки).
Пример AVX2 — сложение восьми float:
section .data
align 32
avx_a dd 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0
avx_b dd 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0
section .text
vmovaps ymm0, [rel avx_a]
vaddps ymm0, [rel avx_b]
vmovaps [rel avx_a], ymm0
Разбор:
YMM0вмещает 256 бит — восемьfloatза одну операцию.align 32нужен для выровненной загрузкиvmovaps(требование AVX).- Префикс
vу мнемоник (vmovaps,vaddps) указывает на инструкции AVX/VEX. vaddpsскладывает восемь пар элементов параллельно.- Перед использованием в реальном коде проверяют поддержку AVX через
cpuid.
Десятичная арифметика (BCD)
Редкий случай: упакованный BCD для финансовых расчётов без ошибок двоичного float. x86 имеет инструкции DA/DF (x87) и арифметику десятичных полей. В современном прикладном коде обычно используют целые в минимальных денежных единицах (копейки) и длинную целую, а не BCD.
Когда писать SIMD руками
Имеет смысл, если:
- профилировщик показал горячий цикл на массивах фиксированного размера;
- нужна детерминированная последовательность инструкций (ядро, драйвер);
- компилятор не векторизует из-за aliasing или сложных границ.
В остальных случаях пишут на C/C++/Rust с -O3 -march=native и смотрят листинг (gcc -S).
Когда SIMD руками лучше не писать
Ручной SIMD обычно не окупается, если:
- Участок не в горячем профиле.
- Данные маленькие и редко обрабатываются пакетно.
- Код важнее по переносимости, чем по пиковой производительности.
- Команда не готова сопровождать ISA-ветки (
SSE2,AVX2, fallback).
В этих случаях лучше оставить векторизацию компилятору и проверить результат в профилировщике.
Связанные материалы
- Побайтовые циклы без SIMD — строковые инструкции.
- Разбор бинарника с XMM-операциями — чтение листинга.
- Windows и другой ABI для вызовов — WinAPI и x64.