Чтобы не мучить аудиторию длинными листингами на ассемблере, мы возьмём совсем несложный пример — выполним сложение миллиона с небольшим случайно генерированных чисел с плавающей точкой одинарной сложности.
Будем использовать Раст 1.95.0 под Windows. Всё как учит учебник:
mkdir r_asmcd r_asmcargo initcargo 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.exeRust std Vec<f32>: Sum=524543.062; time=558.4µs
И вот наши инструкции, которые будет выполнять центральный процессор, теперь их легко найти в файле r_asm.s, просто поискав «=== «:
# === begin Vec<f32>:data.iter().sum(): ===#NO_APPmovssxmm0, dword ptr [rip + __real@80000000]moveax, 7movrcx, qword ptr [rbp + 192].p2align4.LBB5_15:addssxmm0, dword ptr [rcx + 4*rax - 28]addssxmm0, dword ptr [rcx + 4*rax - 24]addssxmm0, dword ptr [rcx + 4*rax - 20]addssxmm0, dword ptr [rcx + 4*rax - 16]addssxmm0, dword ptr [rcx + 4*rax - 12]addssxmm0, dword ptr [rcx + 4*rax - 8]addssxmm0, dword ptr [rcx + 4*rax - 4]addssxmm0, dword ptr [rcx + 4*rax]addrax, 8cmprax, 1048583jne.LBB5_15movssdword 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:
.p2align4.LBB5_15:addssxmm0, dword ptr [rcx + 4*rax - 28]addssxmm0, dword ptr [rcx + 4*rax - 24]addssxmm0, dword ptr [rcx + 4*rax - 20]addssxmm0, dword ptr [rcx + 4*rax - 16]addssxmm0, dword ptr [rcx + 4*rax - 12]addssxmm0, dword ptr [rcx + 4*rax - 8]addssxmm0, dword ptr [rcx + 4*rax - 4]addssxmm0, dword ptr [rcx + 4*rax]addrax, 8cmprax, 1048583jne.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.exeRust std Vec<f32>: Sum=523828.188; time=550.8µsndarray::Array1<f32>: Sum=523836.469; time=238.3µs
Опа! Стало почти вдвое быстрее! Кроме того, результат сложения несколько отличается. Вот теперь точно надо идти смотреть листинг, он стал чуть длиннее:
# === begin ndarray::Array1<f32>:data.sum(); ===#NO_APPxorpsxmm0, xmm0xoreax, eaxpxorxmm3, xmm3pxorxmm1, xmm1pxorxmm2, xmm2movrcx, qword ptr [rbp + 200].p2align4.LBB5_20:movsdxmm4, qword ptr [rcx + 4*rax]addpsxmm4, xmm1movsdxmm5, qword ptr [rcx + 4*rax + 8]addpsxmm5, xmm0movsdxmm0, qword ptr [rcx + 4*rax + 16]addpsxmm0, xmm2movsdxmm6, qword ptr [rcx + 4*rax + 24]addpsxmm6, xmm3movsdxmm1, qword ptr [rcx + 4*rax + 32]addpsxmm1, xmm4movsdxmm2, qword ptr [rcx + 4*rax + 48]addpsxmm2, xmm0movsdxmm0, qword ptr [rcx + 4*rax + 40]addpsxmm0, xmm5movsdxmm3, qword ptr [rcx + 4*rax + 56]addpsxmm3, xmm6addrax, 16cmprax, 1048576jne.LBB5_20addpsxmm0, xmm3addpsxmm1, xmm2xorpsxmm2, xmm2addssxmm2, xmm1movshdupxmm1, xmm1addssxmm1, xmm2addssxmm1, xmm0movshdupxmm0, xmm0addssxmm0, xmm1movssdword 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 = rcxn = rdxtest n, njz short loc_180003BE1mov 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_180003BA0vaddps ymm0, ymm3, ymm0 ; сложили два аккумулятораvaddps ymm1, ymm1, ymm2 ; и ещё дваvaddps ymm0, ymm0, ymm1 ; и вместеvextractf128 xmm1, ymm0, 1vaddps 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 = 2thread 'main' (132568) panicked at src\main.rs:18:5:assertion `left == right` failed left: 2 right: 1error: 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 = 2Done!
Это происходит оттого, что Раст видит иммутабельную переменную, ссылка на неё уходит в 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 (хотя на современных процессорах они практически равноценны), плюс немного косметических улучшений и упрощений, всё-таки мы находимся в рамках учебного примера. Но синтаксис чуть отличается, и от рутинной работы мы в общем избавлены.
Что нам это даст по сравнению с ndarray? А вот:
ndarray::Array1<f32>: Sum=524493.875; time=248.5µsAVX2 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)
Всем добра и быстрого кода!
ссылка на оригинал статьи https://habr.com/ru/articles/1026218/