Чтобы не мучить аудиторию длинными листингами на ассемблере, мы возьмём совсем несложный пример — выполним сложение миллиона с небольшим случайно генерированных чисел с плавающей точкой одинарной сложности.

Будем использовать Раст 1.95.0 под Windows. Всё как учит учебник:

mkdir r_asm
cd r_asm
cargo init
cargo add rand

Исходный код-затравка будет предельно прост, наверное каждый, изучавший Раст, делал что-то подобное:

use rand::prelude::*;

const N: usize = 1024 * 1024;

fn main() {
    let mut rng = rand::rng();
    let data: Vec<f32> = (0..N).map(|_| rng.random()).collect();
    let sum: f32 = data.iter().sum();
	println!("sum = {}", sum);
}

Результат очевиден, среднее у нас 0.5, так что в сумме набегает примерно полмиллиона:

>cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
     Running `C:\Users\Andrey\Desktop\r_asm\target\debug\r_asm.exe`
sum = 524350.56

Вопрос, на который мы хотим получить ответ — как выглядит машинный код на уровне ассемблера, который собственно складывает числа?

В принципе получить листинг можно несколькими способами, начиная от дизассемблирования исполняемого приложения (IDA или Ghidra), либо прогона его под профилировщиком (например Intel VTune) или отладчиком (x64dbg или WinDbg), но есть и несложный способ получить его прямо из Раста, выполнив сборку приложения с указанием опции --emit=asm (кстати, точно также можно получить и промежуточное llvm представление), вот как выглядит команда:

cargo rustc --release -- --emit=asm

Здесь -- является разделителем между флагами cargo (–release в данном случае) и параметрами, которые передаются компилятору rustc. Технически эту команду можно выполнить и так:

cargo rustc -r -- --emit asm

Листинг r_asm.s будет находиться в папке target\release\deps. Нас интересует именно релиз.

Теперь возникает вопрос — как отыскать в листинге искомый ассемблерный код? В данном конкретном случае это несложно, так как он недалеко от точки входа в функцию main, но в реальном большом проекте это может стать проблемой. Теоретически можно “подмешать” исходный код в листинг при помощи дополнительных опций, но несколько проще и нагляднее (на мой субъективный взгляд) добавить туда свои собственные строки-маркеры в виде комментариев, которые будут проброшены в листинг, это делается при помощи несложного трюка, добавлением в код вот такой конструкции, с макросом asm!:

    unsafe {
        asm!(
            "// === My Comment",
        );
    }

Чтобы не плодить сущности, сделаем нехитрый макрос-оснастку mark, обложим интересующий нас код комментариями, добавим заодно замер времени выполнения, вот полный код как есть, всё просто:

use rand::prelude::*;
use std::arch::asm;
use std::time::Instant;

macro_rules! mark {
    ($name:expr) => {
        unsafe {
            asm!(concat!("// === ", $name, " ==="));
        }
    };
}

const N: usize = 1024 * 1024;

fn main() {
    let mut rng = rand::rng();
    let data: Vec<f32> = (0..N).map(|_| rng.random::<f32>()).collect();

    let t_start = Instant::now();

    mark!("begin Vec<f32>:data.iter().sum():");
    let sum: f32 = data.iter().sum();  // < Это нас интересует
    mark!("end data.iter().sum().");

    println!(
        "Rust std Vec<f32>:\tSum={:.3}; time={:?}",
        sum,
        t_start.elapsed()
    );
}

И одно маленькое изменение в параметрах команды, чтобы получить листинг в формате синтаксиса Intel, а не AT&T - опция x86-asm-syntax=intel, можно также сделать несложный командный файл, куда добавить копирование листинга в корневую папку, чтобы не лазить в \deps:

cargo rustc -r -- --emit=asm -C "llvm-args=-x86-asm-syntax=intel"
copy target\release\deps\r_asm.s r_asm.s

Соберём и запустим разок наше приложение, чтобы примерно понять скорость выполнения, это примерно половина миллисекунды (на Xeon w5-2445):

>r_asm.exe
Rust std Vec<f32>:      Sum=524543.062; time=558.4µs

И вот наши инструкции, которые будет выполнять центральный процессор, теперь их легко найти в файле r_asm.s, просто поискав "=== ":

	# === begin Vec<f32>:data.iter().sum(): ===

	#NO_APP
	movss	xmm0, dword ptr [rip + __real@80000000]
	mov	eax, 7
	mov	rcx, qword ptr [rbp + 192]
	.p2align	4
.LBB5_15:
	addss	xmm0, dword ptr [rcx + 4*rax - 28]
	addss	xmm0, dword ptr [rcx + 4*rax - 24]
	addss	xmm0, dword ptr [rcx + 4*rax - 20]
	addss	xmm0, dword ptr [rcx + 4*rax - 16]
	addss	xmm0, dword ptr [rcx + 4*rax - 12]
	addss	xmm0, dword ptr [rcx + 4*rax - 8]
	addss	xmm0, dword ptr [rcx + 4*rax - 4]
	addss	xmm0, dword ptr [rcx + 4*rax]
	add	rax, 8
	cmp	rax, 1048583
	jne	.LBB5_15
	movss	dword ptr [rbp + 156], xmm0
	#APP

	# === end data.iter().sum(). ===

Дотошный читатель, вероятно спросит “насколько вообще точен замер времени при помощи Instant::now();?” и будет прав, но это несложно проконтролировать, там используется классический QueryPerformanceCounter(), об этом и в документации написано, а в коде выглядит так:

.text:000000014001BCE1                 xor     [rbp+var_10], rax
.text:000000014001BCE5                 call    cs:__imp_QueryPerformanceCounter
.text:000000014001BCEB                 mov     eax, dword ptr [rbp+PerformanceCount]

И хотя там дальше присутствует небольшой оверхед из-за двукратного вызова QueryPerformanceFrequency(), но этим можно пренебречь, для наших упражнений точности более чем достаточно, этот счётчик работает на частоте в один мегагерц (на данной платформе), и имеет разрешение в одну микросекунду. Помните, что он не обязан иметь такое разрешение, именно поэтому важно использовать QueryPerformanceFrequency(), но Раст делает это за нас. По-хорошему нам нужно прокрутить этот цикл несколько раз, лучше всего с прогревом кэша, и взять минимум, но аккуратный бенчмаркинг не входит в нашу задачу, однократного прогона нам для эксперимента достаточно, тем более что данных не так много — и они скорее всего уже находятся в кэше третьего уровня после заполнения массива случайными значениями.

Вернёмся к нашему “горячему” циклу, здесь всё несложно — в rcx лежит базовый адрес вектора (данных само собой, а не структуры), счётчик rax увеличивается на 8 на каждой итерации, для сложения используется инструкция addss:

	.p2align	4
.LBB5_15:
	addss	xmm0, dword ptr [rcx + 4*rax - 28]
	addss	xmm0, dword ptr [rcx + 4*rax - 24]
	addss	xmm0, dword ptr [rcx + 4*rax - 20]
	addss	xmm0, dword ptr [rcx + 4*rax - 16]
	addss	xmm0, dword ptr [rcx + 4*rax - 12]
	addss	xmm0, dword ptr [rcx + 4*rax - 8]
	addss	xmm0, dword ptr [rcx + 4*rax - 4]
	addss	xmm0, dword ptr [rcx + 4*rax]
	add	rax, 8
	cmp	rax, 1048583
	jne	.LBB5_15

Что здесь хорошо? Цикл восьмикратно развёрнут, что правильно, это уменьшает накладные расходы, связанные со счётчиком, начало цикла выровнено (align 4), что тоже хорошо для производительности. Интел вроде бы рекомендует 16, но адрес начала цикла несложно проверить в том же профилировщике, вот здесь виден выравнивающий nop и цикл начинается с адреса 0x1400018d0, вот как это выглядит в профайлере VTune при анализе “горячих точек”:

Также Раст достаточно умён, чтобы понять, что количество итераций нацело делится на 8. В теле проход по восьми элементам осуществляется начиная со смещения -28 и дальше увеличивается с шагом 4, это особенность llvm. А вот что нехорошо в этом цикле, так это то, что регистры xmm вообще говоря 128-и битные, они “могут больше” и не задействованы полностью, кроме того есть зависимость по данным — xmm0 используется в каждой инструкции сложения, что вообще говоря не даст им возможность исполняться параллельно, кроме того в конце конструкция add/cmp/jne может быть заменена на sub/jnz. Но даже при беглом взгляде видно, что этот цикл можно оптимизировать.

Чем приятен Раст, так это тем, что один и тот же результат можно получить разными способами. Прежде чем мы погрузимся в пучину ассемблера, давайте сложим элементы при помощи крейта ndarray (в данный момент активна версия 0.17.2), это альтернативный способ:

cargo add ndarray

И код, здесь тоже однострочник:

    use ndarray::Array1;
    // перебрасываем Vec -> ndarray::Array1
    let data = Array1::from_vec(data);

    let t_start = Instant::now();
    mark!("begin ndarray::Array1<f32>:data.sum();:");
    let sum: f32 = data.sum(); // < Теперь складываем так
    mark!("end data.sum();.");
    println!(
        "ndarray::Array1<f32>:\tSum={:.3}; time={:?}",
        sum,
        t_start.elapsed()
    );

Что сделаем вначале, запустим, или сразу пойдём смотреть ассемблер? Давайте запустим:

>r_asm.exe
Rust std Vec<f32>:      Sum=523828.188; time=550.8µs
ndarray::Array1<f32>:   Sum=523836.469; time=238.3µs

Опа! Стало почти вдвое быстрее! Кроме того, результат сложения несколько отличается. Вот теперь точно надо идти смотреть листинг, он стал чуть длиннее:

	# === begin ndarray::Array1<f32>:data.sum(); ===

	#NO_APP
	xorps	xmm0, xmm0
	xor	eax, eax
	pxor	xmm3, xmm3
	pxor	xmm1, xmm1
	pxor	xmm2, xmm2
	mov	rcx, qword ptr [rbp + 200]
	.p2align	4
.LBB5_20:
	movsd	xmm4, qword ptr [rcx + 4*rax]
	addps	xmm4, xmm1
	movsd	xmm5, qword ptr [rcx + 4*rax + 8]
	addps	xmm5, xmm0
	movsd	xmm0, qword ptr [rcx + 4*rax + 16]
	addps	xmm0, xmm2
	movsd	xmm6, qword ptr [rcx + 4*rax + 24]
	addps	xmm6, xmm3
	movsd	xmm1, qword ptr [rcx + 4*rax + 32]
	addps	xmm1, xmm4
	movsd	xmm2, qword ptr [rcx + 4*rax + 48]
	addps	xmm2, xmm0
	movsd	xmm0, qword ptr [rcx + 4*rax + 40]
	addps	xmm0, xmm5
	movsd	xmm3, qword ptr [rcx + 4*rax + 56]
	addps	xmm3, xmm6
	add	rax, 16
	cmp	rax, 1048576
	jne	.LBB5_20
	addps	xmm0, xmm3
	addps	xmm1, xmm2
	xorps	xmm2, xmm2
	addss	xmm2, xmm1
	movshdup	xmm1, xmm1
	addss	xmm1, xmm2
	addss	xmm1, xmm0
	movshdup	xmm0, xmm0
	addss	xmm0, xmm1
	movss	dword ptr [rbp + 168], xmm0
	#APP

	# === end  data.sum(); ===

Прежде всего обращает на себя внимание то, что мы теперь прыгаем через 16 значений (add rax, 16), хотя по прежнему восемь команд сложения, но теперь это addps, а не addss, кроме того цикл начинается с нулевого смещения, добавляя по восемь байт, а не по четыре, увеличивая адреса. Также здесь нет зависимости по данным, так как используется несколько чередующихся аккумуляторов в разных регистрах, которые сложатся вместе после тела цикла, и это хорошо и правильно.

Таким образом “на вкус и цвет все крейты разные”, и производительность может заметно отличаться и тому есть рациональное объяснение.

Да, а почему результат сложения на одних и тех же данных отличается? Ассемблерный листинг даёт ответ и на этот вопрос — дело в том, что операции сложения чисел с плавающей точкой вообще говоря неассоциативны, то есть a+b+c вовсе не обязано быть равно c+b+a, порядок тут важен и он очевидным образом отличается в первом и втором случаях.

Можно ли ещё улучшить производительность этого кода? Да, можно. Вообще Раст позиционируется как “безопасная” альтернатива Си, что ж давайте столкнём их вместе, расчехлим Visual Studio 2026 (будем использовать v.18.5.0) и подключим “тяжёлую артиллерию” в виде Intel OneAPI 2025.3.2.

Код на Си, соревнующийся с Растом, у нас будет примитивнейший, это то, что называется “в лоб”:

INTELSUM_API float fn_intel_sum(float *data, size_t n)
{
    float sum = 0.0;
    for (size_t i = 0; i < n; i++) sum += data[i];
    return sum;
}

Не оставим Расту никаких шансов, включив кодогенерацию AVX2 и оптимизацию под наш конкретный процессор:

Упражняемся мы сегодня вот на таком камушке:

Получить ассемблерный листинг при использовании Intel OneAPI под Visual Studio несколько нетривиально (эту опцию не пробросить из Студии, надо пользоваться командной строкой и вызывать компилятор напрямую), так что самый простой способ — просто дизассемблировать это дело при помощи IDA, смотрите какая красота неописуемая:

fn_intel_sum_a  proc near 
data = rcx
n = rdx
	test    n, n
	jz      short loc_180003BE1
	mov     r8, n	; в R8 количество элементов
; --- опустим обнуление аккумуляторов и остальные проверки

	xchg    ax, ax	; Это выравнивание адреса начала цикла
loc_180003BA0: ; Это "горячий цикл" сложения
 ^  vaddps  ymm0, ymm0, ymmword ptr [data+r10*4] ; восемь элементов одной инструкцией
 |  vaddps  ymm1, ymm1, ymmword ptr [data+r10*4+20h] ; и ещё
 |  vaddps  ymm2, ymm2, ymmword ptr [data+r10*4+40h]
 |  vaddps  ymm3, ymm3, ymmword ptr [data+r10*4+60h]
 |  add     r10, 20h ; Скачем по 32 элемента
 |  cmp     r10, r9 ; все элементы?
 +--jbe     short loc_180003BA0
	vaddps  ymm0, ymm3, ymm0 ; сложили два аккумулятора
	vaddps  ymm1, ymm1, ymm2 ; и ещё два
	vaddps  ymm0, ymm0, ymm1 ; и вместе
	vextractf128 xmm1, ymm0, 1
	vaddps xmm0, xmm0, xmm1  ; [ a  b  c  d ]
	vhaddps xmm0, xmm0, xmm0 ; [ a+b, c+d, a+b, c+d ]
	vhaddps xmm0, xmm0, xmm0 ; [ a+b+c+d, a+b+c+d,... ]
	; теперь в xmm0 результат
	; --- опустим хвост
	retn

В нашем цикле ровно четыре команды сложения, но использующих регистры ymm, размер которых 256 бит.

Теоретически на Расте мы могли бы просто вызвать эту функцию из DLL через FFI. Но это слишком уж просто и неспортивно, давайте останемся в рамках “чистого” Раста и напишем функцию на ассемблере прямо в теле функции, используя макрос asm!, утащив заготовку у Интела. Здесь как раз тот пример, когда оптимизирующий компилятор неплохо справляется и ручное написание на ассемблере не даст сильного выигрыша.

Здесь надо сделать небольшое отступление. При программировании на ассемблере в рамках Раста вы должны быть предельно осторожны. Это небезопасный код (оттого он и помечен unsafe), и используя его бездумно, можно не то что “выстрелить себе в ногу”, но и вообще отстрелить всё, что только можно. Дело в том, что Раст не знает и не хочет знать, что вы там такое делаете, это можно показать на простом примере.

Смотрите, вот код, который просто пишет некоторое значение по адресу, куда указывает мутабельная ссылка, при помощи одной-единственной инструкции mov:

use std::arch::asm;

#[inline(never)]
fn writer_mut(addr: &mut i32, val: i32) {
    unsafe {
        asm!(
        "mov dword ptr [{addr}], {val:e}", // 32 бита
        addr = in(reg) addr,
        val = in(reg) val,
        );
    }
}

fn main() {
    let mut a = 1;
    writer_mut(&mut a, 2);
    println!("a = {}", a);
    assert!(a == 2);
    println!("Done!");
}

Здесь всё хорошо, Раст не будет иметь ничего против, после вызова writer_mut() переменная а примет значение “2”, assert будет доволен, вам напечатают a = 2 и следом Done!.

По-хорошему, вероятно будет лучше (идиоматичнее, что ли) объявить функцию вот так:

#[inline(never)]
fn writer_mut(addr: *mut i32, val: i32) {
    unsafe {
        asm!(
        "mov dword ptr [{addr}], {val:e}",
        addr = in(reg) addr,
        val = in(reg) val,
        );
    }
}

Но вызов от этого не изменится, так как &mut a неявно приводится к &mut a as *mut i32, это одно и то же:

writer_mut(&mut a, 2);
writer_mut(&mut a as *mut i32, 2);

Но ситуация в корне изменится, если сделать вот так, передав иммутабельную ссылку в мутирующий asm, мы просто уберём mut отовсюду:

use std::arch::asm;

#[inline(never)]
fn writer_ub(addr: &i32, val: i32) {
    unsafe {
        asm!(
        "mov dword ptr [{addr}], {val:e}",
        addr = in(reg) addr,
        val = in(reg) val,
        );
    }
}

fn main() {
    let b = 1;
    writer_ub(&b, 2);
    println!("b = {}", b);
    assert_eq!(b, 1);
    println!("Done!");
}

Обратите внимание на assert — он теперь ожидает единицу.

Раст по-прежнему не будет иметь ничего против, и такой код компилируется без предупреждений — ему совершенно нет дела до того, чем вы там занимаетесь в unsafe коде, а ведь он перезаписывает значение. Но вот результат выполнения может вызвать удивление. В Debug режиме assert сработает и запаникует от наличия двойки:

>cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running `target\debug\r_mut_asm.exe`
b = 2

thread 'main' (132568) panicked at src\main.rs:18:5:
assertion `left == right` failed
  left: 2
 right: 1
error: process didn't exit successfully: `debug\r_mut_asm.exe` (exit code: 101)

А вот в релизе он уже не сработает и будет напечатана строка “Done!”:

>cargo run --release
    Finished `release` profile [optimized] target(s) in 0.03s
     Running `target\release\r_mut_asm.exe`
b = 2
Done!

Это происходит оттого, что Раст видит иммутабельную переменную, ссылка на неё уходит в writer_ub(&b, 2);, но он размышляет примерно так: "переменная иммутабельна, она не может измениться в writer_ub() и останется единицей, таким образом, зачем нам assert?!. Нет, нам assert не нужен, и спокойно выкинет его, оттого исполнение благополучно поедет дальше и мы увидим Done!. В данном случае &i32 обещает компилятору иммутабельность, а вот asm! нарушает это обещание. Короче, будьте аккуратны. По идее нам нужно всегда добавлять правильные опции options(…), где мы укажем компилятору, например nostack — что означает что этот asm-блок не трогает стек: не делает push/pop, не меняет rsp, либо preserves_flags, что говорит о том, что флаги CPU (ZF, CF, OF, …) после asm останутся как были, или же очень важный флаг memory, говорящий о том, что этот asm может читать или писать произвольную память и так далее.

Но нас unsafe код пугать не должен, так что не откажем себе в удовольствии перенести логику интеловского компилятора в Раст. Не будем далеко ходить, вот код:

fn avx2_sum(data: *const f32, len: usize) -> f32 {
    let sum: f32;
    debug_assert!(len % 32 == 0);
    unsafe {
        asm!(
        "vxorps ymm0, ymm0, ymm0", // обнуляем аккумуляторы
        "vxorps ymm1, ymm1, ymm1",
        "vxorps ymm2, ymm2, ymm2",
        "vxorps ymm3, ymm3, ymm3",

        "mov     r11, rsi", // r11 = len / 32 (число итераций)
        "shr     r11, 5",   // делим на 32
        "xor     r10, r10", // r10 = текущий индекс (в элементах)

        "2:", // 4× развёрнутый AVX2‑цикл (32 float за итерацию)
        "vaddps ymm0, ymm0, [rdi + r10*4]",
        "vaddps ymm1, ymm1, [rdi + r10*4 + 32]",
        "vaddps ymm2, ymm2, [rdi + r10*4 + 64]",
        "vaddps ymm3, ymm3, [rdi + r10*4 + 96]",

        "add     r10, 32",  // хвост цикла
        "dec     r11", // счётчик
        "jnz     2b",

        "vaddps ymm0, ymm0, ymm1", // редукция аккумуляторов
        "vaddps ymm2, ymm2, ymm3",
        "vaddps ymm0, ymm0, ymm2",

        "vextractf128 xmm1, ymm0, 1", // горизонтальная редукция до скаляра
        "vaddps xmm0, xmm0, xmm1",  // [ a  b  c  d ]
        "vhaddps xmm0, xmm0, xmm0", // [ a+b, c+d, a+b, c+d ]
        "vhaddps xmm0, xmm0, xmm0", // [ a+b+c+d, a+b+c+d,... ]

        in("rdi") data,
        in("rsi") len,

        lateout("xmm0") sum, // Результат в xmm0

        // этим мы говорм Расту, что изменили значения этих регистров:
        out("ymm1") _,
        out("ymm2") _,
        out("ymm3") _,
        out("r10") _,
        out("r11") _,

        options(nostack, preserves_flags)
        );
    }
    sum
}

Чем, кстати, хорош современный ИИ, так это тем, что мы можем просто взять листинг Иды, скормить его копилоту и получить почти готовую функцию на Расте. “Почти”, потому что здесь убраны проверки и cmp/jbe заменено на dec/jnz (хотя на современных процессорах они практически равноценны), плюс немного косметических улучшений и упрощений, всё-таки мы находимся в рамках учебного примера. Но синтаксис чуть отличается, и от рутинной работы мы в общем избавлены.

Вызывать эту функцию мы будем вот так:

    let t = Instant::now();
    let sum_asm = avx2_sum(data.as_ptr(), data.len());
    println!(
        "AVX2 assembly sum:\tSum={:.3}; time={:?}",
        sum_asm,
        t.elapsed()
    );

Что нам это даст по сравнению с ndarray? А вот:

ndarray::Array1<f32>:   Sum=524493.875; time=248.5µs
AVX2 assembly sum:      Sum=524477.875; time=116.2µs

Вдвое меньше итераций и мы уже “выбежали” из двухсот микросекунд и приближаемся к ста, по сравнению с изначальным вариантом у нас почти пятикратное преимущество.

Здесь возникает логичный вопрос — а как этого монстра отлаживать?

Один из самых простых способов — добавить в начало int 3 да запустить под отладчиком, дать ему выполниться до этой инструкции, на ней он остановится, потом перешагнуть через неё и вот весь он как на ладошке, бежит по циклу и складывает значения, это самый низкий уровень, что называется “ниже некуда”:

В принципе ассемблерный код, написанный вручную, принципиально ничем не отличается от того, который сгенерирован компилятором Раста, но требует аккуратности, тщательной верификации и проверки, большого количества проверок, ведь если мы решим прогуляться за пределы массива, нам уже никто не сможет запретить.

Впрочем, способ избавиться от чистого ассемблера, конечно есть, ведь ровно ту же функцию можно дать копилоту ещё раз и попросить переписать всё на интринсиках, и он это сделает и даже развернёт цикл восьмикратно, здесь уже не будет asm!, но сильно безопаснее он от этого, конечно не станет, так как интринсики небезопасны сами по себе:

#[target_feature(enable = "avx2")]
fn avx2_sum_simd(data: *const f32, len: usize) -> f32 {
    debug_assert!(len % 32 == 0);
    unsafe { // попрежнему небезопасный!
        // 8 аккумуляторов обнуляем:
        let mut s0 = _mm256_setzero_ps();
        let mut s1 = _mm256_setzero_ps();
        // ... и т.д., все восемь

        let mut ptr = data;
        let end = data.add(len);
        
        mark!("begin simd2");
        while ptr < end { // 64 значений за итерацию
            s0 = _mm256_add_ps(s0, _mm256_loadu_ps(ptr.add(0)));
            s1 = _mm256_add_ps(s1, _mm256_loadu_ps(ptr.add(8)));
            s2 = _mm256_add_ps(s2, _mm256_loadu_ps(ptr.add(16)));
			// восемь раз
			//...
            ptr = ptr.add(64);
        }
        mark!("end simd2");
        
        let s0 = _mm256_add_ps(s0, s4); // складываем 8 → 4 → 2 → 1 YMM
        let s1 = _mm256_add_ps(s1, s5);
        let s2 = _mm256_add_ps(s2, s6);
        let s3 = _mm256_add_ps(s3, s7);
        let s0 = _mm256_add_ps(s0, s2);
        let s1 = _mm256_add_ps(s1, s3);
        let sum = _mm256_add_ps(s0, s1);

        let hi = _mm256_extractf128_ps(sum, 1); // YMM → в скаляр
        let lo = _mm256_castps256_ps128(sum);
        let sum128 = _mm_add_ps(lo, hi);
        let sum128 = _mm_hadd_ps(sum128, sum128);
        let sum128 = _mm_hadd_ps(sum128, sum128);

        _mm_cvtss_f32(sum128) // результат
    }
}

Здесь единственный важный момент — в enable = “avx2” перед объявлением функции. Это важно, так как иначе сгенерированный код даже на таких интринсиках не будет использовать AVX2 и скорость станет даже медленнее первоначального варианта с вектором. Поскольку процессор, как было показано выше, поддерживает инструкции вплоть до AVX512, то в cargo.toml добавлено

[build]
rustflags = ["-C", "target-feature=+avx512f"]

А опция эта также включает AVX2. Ну а AVX512 код в общем идентичен, просто используются 512-бит регистры zmm. Но даже используя интринсики, имеет смысл поглядывать в листинг, поскольку не все они переводятся в машинный код один-в-один.

Ах да, мы совершенно забыли о результатах, вот они (все измерения приведены для ориентировки и не являются строгим бенчмарком):

Метод

Время выполнения

Rust std Vec

554.7 µs

ndarray::Array1

248.5 µs

AVX2 assembly sum

116.2 µs

AVX2 SIMD intrisics

92.6 µs

AVX512 assembly

81.8 µs

AVX512 SIMD sum

77.5 µs

Как мы видим, AVX2 версия работает заметно быстрее ndarray, и вроде бы восьмикратный разворот цикла на интринсиках также дал немного. Ещё надо понимать, что замер неточный, от запуска к запуску значения могут меняться довольно сильно, но принцип должен быть понятен. Код на AVX512 сравним по производительности с AVX2 — так бывает, когда мы достигаем предела по производительности памяти. На самом деле существует не так много алгоритмов, где AVX512 давал бы “драматический” (двукратный или больше) прирост, отчасти это связано и с тем, что при интенсивном использовании этих инструкций частота ядра, где они выполняются, начинает снижаться, кроме того их пропускная способность и латентность могут быть несколько ниже аналогичных из набора AVX2.

Пользуясь вышеизложенным подходом, можно проводить анализ и оптимизацию реальных приложений, выводя производительность практически на границу возможностей, предоставляемых центральным процессором. Само собой разумеется, что следующим этапом может быть использование многопоточности, но в конкретном данном примере это не имеет большого смысла, поскольку мы уже начинаем приближаться к пропускной способности памяти — именно она является “бутылочным горлышком”, но на двенадцатиканальной памяти можно получить выигрыш, однако всегда имеет смысл вначале оптимизировать одиночный поток, а не компенсировать недостатки алгоритма и посредственную производительность за счёт многопоточности, впрочем это тема отдельной статьи. Также нет смысла оптимизировать всё и вся без особой необходимости, так как это порой может приводить к небезопасному и трудно поддерживаемому коду, как было показано выше. Следует активно пользоваться профилировщиком для выявления “горячих точек”. Ну и не стоит забывать слова великого Дональда Кнута — “преждевременная оптимизация — корень всех зол”.

Код “на поиграть” на Rust Playground. AVX512, правда, туда не завезли.

Полезные ссылки: Inline assembly (документация) и Inline assembly (Rust By Example)

Всем добра и быстрого кода!

Комментарии (30)