Перейти к основному содержимому

Числа с плавающей точкой и SIMD

Разработчику Архитектору

Контекст: x86-64, NASM, Linux. Целочисленная арифметика — основы и длинные числа. Примеры в справочнике (сумма массива SSE).


Два мира вещественной арифметики на x86

ПодсистемаРегистрыСтатус сегодня
x87 FPUстек ST(0)..ST(7)legacy; встречается в старом коде и некоторых ABI
SSE / SSE2XMM0XMM15 (128 бит)стандарт для float/double в 64-битных ОС
AVX / AVX2YMM0YMM15 (256 бит)векторные пакеты по 8 float или 4 double
AVX-512ZMM (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

XMM0XMM7 в типичном вызове передают вещественные аргументы (System V: XMM0XMM7). Возврат 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 — sdmovsd, 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 регистры XMM0XMM15caller-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 обычно не окупается, если:

  1. Участок не в горячем профиле.
  2. Данные маленькие и редко обрабатываются пакетно.
  3. Код важнее по переносимости, чем по пиковой производительности.
  4. Команда не готова сопровождать ISA-ветки (SSE2, AVX2, fallback).

В этих случаях лучше оставить векторизацию компилятору и проверить результат в профилировщике.


Связанные материалы