Comments 45
А теперь посмотрите, какой код вам сгенерирует оптимизирующий компилятор при делении на константу :)
Вы скорее всего намекаете на то, что он будет использовать умножение и сдвиг. Да, все так, но если он не знает заранее число ему все равно придется использовать деление = )
Именно. Ну и код для деления не на константу компилятор сделает без лишних зависимостей.
Что именно вы понимаете под лишними зависимостями? Общий случай деления на x86 невозможен без div/idiv.
То с чем вы боретесь в статье :)
Вы ответили не топикстартеру, да неважно. Ещё 15 или 20 лет назад, посмотрев на выдачу компилятора я понял, что на современных процессорах на ассемблере руками писать бессмысленно. Даже зная кучу трюков.
Зная особенности архитектуры, можно и на высоком уровне писать так, чтобы компилятору было легче оптимизировать код. Например, если сравнение с нулём занимает меньше тактов, чем сравнение с константой - в счётчике циклов декрементировать константу до нуля, а не инкрементировать от нуля до константы.
Драйвера то полюбому наверно без ассемблера не обходятся до сих пор ? Может асм вечен ? И вписан в железо навсегда ?
Да вполне обходятся. Практически все драйвера в Linux написаны на Си, без всяких ассемблерных вставок.
asm более или менее оправдан в крипте, да и то, на нем там не пишут, а прячут за кодогенерацией и/или макросами.
Точно навсегда. Вот банально взять UEFI — у него есть SEC фаза. Вся эта фаза на чистом asm, потому что стек ещё не инициализирован. Компилятор C не может работать без стека. Собственно инициализация стека и есть одна из задач SEC. Ну и в целом она архитектурно-специфична.
То есть, у меня не получится написать программу на С, потом скомпилировать её и залить бинарник в BIOS? Мне нужно будет написать на асме, прогнать через препроцессор и залить бинарник в BIOS? Всё верно?
Для SEC — нет, не получится. Начиная с PEI — уже можно на C. SEC на asm по двум причинам: во-первых, стека нет. Во-вторых, архитектурно-специфичный код — переключение в защищённый режим, потом в 64-битный. Это делается через специальные регистры (CR0, MSR, GDT), для которых нужны конкретные инструкции.
Но сколько нужно программистов для этого дела? 300 человек на весь мир?)
Нужно гораздо больше, чем кажется)) Есть архитектурный слой, который ну никак не выкинуть, ну вот хоть ты тресни — железо разное, процессоры разные. Это фундаментальный вызов разнообразия железа. Собственно потому и появился C — как попытка скрыть железо за более высокими абстракциями. И он хорошо справляется. Но не везде и не всегда.
Я не припомню где бы си не справился) В этом мире даже самая распоследняя ручка имеет компилятор на Си)
Я помнится какой-то температурный датчик программировал, у него даже ассемблера не было, а компилятор на Си был) Компилировал он сразу в unsigned char массив, который ты хоть азбукой морзе загоняй по SPI внутрь этого контроллера)
Я тут немного изучил вопрос. Если вкратце, топиктартер прав, самое начало работы компа пишется на асме, выдача компилятора может прямо сильно удивить для таких задач. Кроме того, некоторые инструкции (подозреваю, много) всё равно придётся писать даже на С с ассемблерными вставками. Температурный датчик, конечно, хорошо, но вряд ли у него есть защищённый режим и такая тонна легаси, как в х86. (INT 10 INT 13 любоввввь)))
Я не спорю, что всегда есть такое местечко, где ну вот вообще никак, хоть убей, но 300 строчек на асме надо написать)
Но сколько таких мест? И сколько надо программистов для них? И не справится ли LLM с этой задачей?)
Вопросы для меня не имеющие очевидного ответа, я просто не живу в мире настолько низкоуровневого программирования и не знаю никого, кто бы жил
В одном только ядре Linux — больше миллиона строк asm, 5000+ контрибьюторов. По Stack Overflow 2024, ~5% профессиональных разработчиков регулярно используют asm — это десятки тысяч человек. По моим прикидкам, людей, которые регулярно пишут asm — тысячи, не сотни. Так что область живее всех живых.
P.S. LLM рано или поздно со всем справится, не только с asm xD
Я сам лично писал на asm ещё 3 года назад, но я был такой один на всю компанию в 1500 человек. Ну, позже мне выдали ещё джуна и нас стало двое) Но в итоге вы вернулись к интринсикам, т.к. поддержка asm слишком неудобна оказалась.
И я пока не вижу зачем эти 5% продолжают писать на asm)
Intrinsics — это по сути тот же asm, завёрнутый в C-синтаксис. Вы не ушли от asm, вы сменили обёртку) А 5% — это не только SIMD оптимизации. Это ядра ОС, UEFI, переключение контекста, обработчики прерываний, криптография — вещи, где intrinsics не помогут. Но вы безусловно правы, что кода на asm нужно сильно меньше, чем на C.
Intrinsics — это по сути тот же asm, завёрнутый в C-синтаксис.
Почти так, но не совсем. Дело в том, что разные компиляторы могут компилять его чуть по-разному, ведь мы не работаем с регистрами, а объявляем обычные переменные, и один компилятор может насовать кучу зависимостей (работать в одном регистре, к примеру), а другой - более оптимально. Я это уверенно утверждаю, потоиу что что у меня была пара примеров, когда Intel OneAPI и MSVC выдавали заметно разный код с одного и того же исходника. Так что при работе с интринсиками желательно всегда заглянуть в ассемблерный выхлоп и проверить.
Месяцев 5-6 назад, написав код под микроархитектуру Haswell (2013-й год), я понял, что код, написаный руками на ассемблере быстрее кода который выдают компиляторы GCC и Clang. Так что, тут вопрос только к тому кто его пишет. Как говориться: ингредиенты не виноваты...
Полностью согласен. Более того, есть специфика архитектуры и мы никуда от нее не уйдем. Всегда будет архитектурно специфичный слой. Это фундаментально. А что Вы писали, если не секрет? И почему именно под эту микроархитектуру?
Решал задачи на highload.fun. Там тесты прогоняются именно на этой микроархитектуре.
Тоже интересно, с чем именно не справился компилятор. Знания о конкретном железе в него заложены в файликах типа такого - но, конечно, не факт, что они приводят к оптимальному коду.
Отвечу я - иногда компилятору не хватает знаний о решаемой задаче и возможных значениях переменных. У меня был случай году в 2018, когда пришлось несколько раз править кусок кода на C пока компилятор (последний msvc) не смог сгенерировать хороший ассемблерный код. Там приходилось прибегать к развороту цикла, подбору битности и знаковости переменных, даже создавать промежуточные переменные, чтобы результаты операций правильно и эффективно считались. Но, конечно, это все точечные истории, и остальная кодовая база на 2.5 млн строк собиралась без такого фанатизма.
Там вообще было интересно, мы переходили на x64 и как раз менялись поколения Xeon’ов и замеры показывали, как на старой платформе x86 код выполнялся быстрее x64 кода на этой же платформе, а на новой уже наоборот - x64 был быстрее x86 (бинарники, естественно, одни и те же для старой и новой). Наверное Интел слегка подзабила на оптимизацию x86 стека.
Я сейчас точно не вспомню да и особо не вникал в то что создал компилятор, просто написал код на ассемблере и он оказался быстрее. Не с первого раза, конечно. Это заняло довольно много времени.
Если интересна производительность, есть огромный смысл использовать компиляторы от Intel, a не clang/gcc. Особенно, если разговор идёт про FP.
поэтому пара
dec+jnzстоит всего 1 такт.
Есть ещё loop.
Приятно было снова встретить старую добрую парочку AH, AL
Не только в Intel, но и во многих архитектурах операции с укороченными словами дороже.
mov ecx, (100/10) # <- для теста: 10 итетрации и 100 - итерации
mov r8, 1000
mov r9, 0
push rcx
1:
mov rcx, 0x2034
rdtscp
# начало теста
mov rdi, rdx
mov rsi, rax
mov (r/e)cx, 0x2034
mov (r/e)dx, 0x0008
mov (r/e)rax, 0x2B7C
div (r/e)cx
# конец теста
rdtscp
sub eax, esi
sbb edx, edi
# наименьшее значение
cmp r8w, ax
cmova r8w, ax
# наибольшее значение
cmp r9w, ax
cmovb r9w, ax
pop rcx
loop 1bВ цикле проводим 10 и 100 итерации, выбираем среди них минимальное (R8) и максимальное (R9) значения выполнения тестируемого участка кода . Результаты теста проводились на старом i3-3240:
для 10 итерации:
16 бит: 0x3C (мин) и 0x54 (макс) тактов
32 бит: 0x5C (мин) и 0x90 (макс) тактов
64 бит: 0x74 (мин) и 0x88 (макс) тактов
для 100 итерации:
16 бит: 0x34 (мин) и 0x20С (макс) тактов
32 бит: 0x2C (мин) и 0x40 (макс) тактов
64 бит: 0x7с (мин) и 0x9С (макс) тактов
Как видно из замеров диапазон значений для манипуляции достаточен. Но мало кто знает, почему такой большой разброс между минимальным и максимальным значениями (для 16 бит с 100 итерациями максимальное значение =0x20C (524) тактов):
- инструкции сами по себе в процессоре или в кеше не появятся, их прежде всего нужно прочитать из памяти, то есть в тест вкрадывается время обращения к памяти;
- количество тактов выполнения одного и того же участка кода это не фиксированное значение, оно может "плавать" в достаточном диапазоне (поправка - это справедливо в многопроцессорных системах); в однопроцессорных Пеньках можете запустить N раз количество тестов и большую половину получить фиксированное значение тактов выполнения одного и того же участка кода, а остальные в пределах несколько процентов отклонения (многократно убеждался на своем опыте).
То есть как вы поняли скорость зависит от загруженности "соседа". Если у вас Xeon c 56 потоками обращается в один и тот же канал памяти, то максимальной скорости выполнения вашей программы не ждите. Это и есть ошибка многих тестеров, которые делают прогон программы на незагруженной машине, а потом выкатывают ее на загруженный сервак и не понимают, что с ней происходит...
Доклад окончен
Интересные замеры! Но есть нюанс: rdtscp внутри цикла — сериализующая инструкция, она сбрасывает pipeline. Плюс между каждым div — десяток инструкций overhead (cmp, cmov, pop, loop). Получается измеряется не столько div, сколько div + overhead + два pipeline flush. Отсюда и разброс — pipeline каждую итерацию прогревается заново. Если вынести rdtsc наружу и гонять 2M итераций, разброс уходит почти в ноль.
Инструкция RDTSCP не «сбрасывает» конвейер в прямом смысле, но обеспечивает сериализацию - гарантирует, что все предыдущие инструкции будут выполнены до её вызова. Это позволяет получить точные замеры времени выполнения кода.
Вы ошибаетесь, еще раз повнимательнее посмотрите код, кроме инструкций передач данных и деления, никакие инструкции сравнения и циклы после RDTSCP не учитываются:
start = rdtscp() # Замер начала# код для замераend = rdtscp() # Замер концаduration = end - start # Расчёт длительностиИные способы подсчета времени выполнения кода, кроме тех которые предлагает инструкция RDTSCP, сомнительны и не точны.
Вы, безусловно, «в теме» и в общем всё верно пишете, но я, пожалуй, побуду дотошным занудой.
Во-первых, RDTSCP не рекомендуется для начала измерений. Для конца — да, но не для начала, там лучше взять пару LFENCE/RDTSC или, по-старинке, CPUID/RDTSC. Дело в том, что RDTSCP представляет собой «частичный барьер» (half-fence, не знаю, как лучше сказать по-русски). Она гарантирует, что все инструкции до неё закончат выполнение на момент получения счётчика, но она совершенно не гарантирует, что инструкции после неё не выполнятся перед ней; она сериализует только инструкции до себя, но не предотвращает уход последующих инструкций вперёд. Соответственно, часть кода бенчмарка может начать выполняться чуть раньше, чем хотелось бы.
Дальше, во-вторых, надо понимать, что пара RDTSC/RDTSCP возвращает количество тиков на базовой частоте процессора, а не на той частоте, на которой работает ядро. Так что надо выключить турбо-буст (и при этом всё равно надо дотошно убедиться, что частота ядра стала равна базовой частоте, а если нет — это, в общем, не обязательно, — то вводить поправку). При включённом же турбо-бусте количество тактов ядра и количество тиков TSC расходятся: процессор на данном участке кода может всегда отрабатывать одно и то же количество тактов, а вот разница RDTSC/RDTSCP будет «гулять», тем более что ядра связаны — если другие будут заняты, то частота будет падать. Как альтернатива, можно снимать RDPMC — вот этот счётчик вернёт честное число тактов процессора и от частоты ядра не зависит; можно оставить турбо-буст включённым, он не окажет особого влияния на результат (хотя я где-то видел, что кто-то мерял эффект переходных процессов DVFS — это когда при изменении частоты или напряжения ядру надо время, чтобы синхронизироваться, там фазовая автоподстройка и всё такое).
В-третьих, что Windows, что Линукс априори многопоточные и не являются ОСРВ, и мы не можем получить ядро в «эксклюзивное» пользование, так что чем тест «длиннее», тем выше вероятность, что нам «насуют» дополнительных инструкций из соседних потоков. Поэтому для синтетических бенчмарков максимумы замеров нам вообще неинтересны, а интересны лишь минимумы при достаточном числе прогонов. В принципе, тот же RDPMC с соответствующим параметром может вернуть именно количество инструкций, исполненных процессором, а поскольку их количество в тесте детерминированно и известно заранее, то это дело можно использовать для контроля того, что кроме нашего теста ядро ничем иным не занималось. Очевидно, что поток, в котором исполняется тест, обязательно должен быть «прибит» к одному из ядер, иначе планировщик ОС (и Линукс, и Windows) подпортит нам жизнь, перебрасывая нас на другое ядро в самый неподходящий момент, а это весьма «дорогая» акция.
Вообще «установившиеся» и «прогретые» бенчмарки (которые циклически повторяются и вписываются в L1) делать несложно — ну там латентность или пропускную способность инструкций. А вот когда хочется реально оценить эффект пенальти от загрузки инструкций в кэш либо промах предсказателя переходов, то чуть сложнее.
Чтобы не быть голословным и показать разницу между rdtsc/rdtscp и rdpmc, коммент с кодом вдогонку.
Код на Расте (там довольно много копипасты, можно на макросах поэлегантнее сделать, но это просто пример "в обеденный перерыв"):
Пара сотен строк
use std::arch::asm;
use windows::Win32::System::Threading::{GetCurrentThread, SetThreadAffinityMask};
fn pin_to_core(core: usize) {
// Logical CPU → bitmask
let mask: usize = 1usize << core;
unsafe {
let prev = SetThreadAffinityMask(GetCurrentThread(), mask);
if prev == 0 {
panic!("SetThreadAffinityMask failed for core {}", core);
}
}
}
#[inline(always)]
fn rdtsc_start() -> u64 {
let low: u32;
let high: u32;
unsafe {
asm!(
"lfence",
"rdtsc",
out("eax") low,
out("edx") high,
options(nomem, nostack, preserves_flags),
);
}
((high as u64) << 32) | (low as u64)
}
#[inline(always)]
fn rdtscp_end() -> u64 {
let low: u32;
let high: u32;
unsafe {
asm!(
"rdtscp",
"lfence",
out("eax") low,
out("edx") high,
out("ecx") _, // IA32_TSC_AUX (must be declared)
options(nomem, nostack, preserves_flags),
);
}
((high as u64) << 32) | (low as u64)
}
#[inline(always)]
fn rdpmc(counter: u32) -> u64 {
let low: u32;
let high: u32;
unsafe {
asm!(
"lfence",
"rdpmc",
"lfence",
out("eax") low,
out("edx") high,
in("ecx") counter,
options(nomem, nostack, preserves_flags),
);
}
((high as u64) << 32) | (low as u64)
}
#[inline(always)]
fn measured_div_64() {
unsafe {
asm!(
".rept 1000",
"mov rcx, 0x2034",
"mov rdx, 0x0008",
"mov rax, 0x2B7C",
"div rcx",
".endr",
lateout("rax") _, // div writes quotient
lateout("rdx") _, // div writes remainder
lateout("rcx") _, // rcx is modified by us
options(nostack, nomem),
);
}
}
#[inline(always)]
fn measured_div_32() {
unsafe {
asm!(
".rept 1000",
"mov ecx, 0x2034",
"mov edx, 0x0008",
"mov eax, 0x2B7C",
"div ecx",
".endr",
lateout("rax") _, // div writes quotient
lateout("rdx") _, // div writes remainder
lateout("rcx") _, // rcx is modified by us
options(nostack, nomem),
);
}
}
fn get_core_from_args() -> usize {
std::env::args()
.nth(1) // first user argument
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0)
}
fn main() {
const ITER: usize = 1_000_000;
const PMC_CYCLES: u32 = 0x40000001; // PMC1 Unhalted Core Cycles
const PMC_INSTRUCTIONS: u32 = 0x40000000; // PMC0 Instructions Retired
let mut min_ticks = u64::MAX;
let mut min_cycles = u64::MAX;
let mut min_instructions = u64::MAX;
let core: usize = get_core_from_args();
pin_to_core(core);
println!("Running {} iterations on core {}", ITER, core);
for _ in 0..ITER {
let start = rdtsc_start();
measured_div_64();
let end = rdtscp_end();
let delta = end.wrapping_sub(start);
if delta < min_ticks {
min_ticks = delta;
}
}
for _ in 0..ITER {
let start = rdpmc(PMC_CYCLES);
measured_div_64();
let end = rdpmc(PMC_CYCLES);
let delta = end.wrapping_sub(start);
if delta < min_cycles {
min_cycles = delta;
}
}
for _ in 0..ITER {
let start = rdpmc(PMC_INSTRUCTIONS);
measured_div_64();
let end = rdpmc(PMC_INSTRUCTIONS);
let delta = end.wrapping_sub(start);
if delta < min_instructions {
min_instructions = delta;
}
}
let ipc = min_instructions as f64 / min_cycles as f64;
println!(
"64-bit registers: Ticks: {}, Cycles: {}, Instructions: {}, IPC: {:.2}",
min_ticks, min_cycles, min_instructions, ipc
);
min_ticks = u64::MAX;
min_cycles = u64::MAX;
min_instructions = u64::MAX;
for _ in 0..ITER {
let start = rdtsc_start();
measured_div_32();
let end = rdtscp_end();
let delta = end.wrapping_sub(start);
if delta < min_ticks {
min_ticks = delta;
}
}
for _ in 0..ITER {
let start = rdpmc(PMC_CYCLES);
measured_div_32();
let end = rdpmc(PMC_CYCLES);
let delta = end.wrapping_sub(start);
if delta < min_cycles {
min_cycles = delta;
}
}
for _ in 0..ITER {
let start = rdpmc(PMC_INSTRUCTIONS);
measured_div_32();
let end = rdpmc(PMC_INSTRUCTIONS);
let delta = end.wrapping_sub(start);
if delta < min_instructions {
min_instructions = delta;
}
}
let ipc = min_instructions as f64 / min_cycles as f64;
println!(
"32-bit registers: Ticks: {}, Cycles: {}, Instructions: {}, IPC: {:.2}",
min_ticks, min_cycles, min_instructions, ipc
);
}Тестировать будем так, это ваши инструкции из коммента, это 64-бит:
fn measured_div_64() {
unsafe {
asm!(
".rept 1000",
"mov rcx, 0x2034",
"mov rdx, 0x0008",
"mov rax, 0x2B7C",
"div rcx",
".endr",
lateout("rax") _, // div writes quotient
lateout("rdx") _, // div writes remainder
lateout("rcx") _, // rcx is modified by us
options(nostack, nomem),
);
}
}Здесь последовательность из четырёх инструкций повторена 1000 раз директивой ".rept 1000", то есть у нас 4000 инструкций всего в пайплайне. А для 32-бит вот так:
".rept 1000",
"mov ecx, 0x2034",
"mov edx, 0x0008",
"mov eax, 0x2B7C",
"div ecx",
".endr",Замерять счётчики будем так, через rdpmc:
fn rdpmc(counter: u32) -> u64 {
let low: u32;
let high: u32;
unsafe {
asm!(
"lfence",
"rdpmc",
"lfence",
out("eax") low,
out("edx") high,
in("ecx") counter,
options(nomem, nostack, preserves_flags),
);
}
((high as u64) << 32) | (low as u64)
}В ecx мы кладём счётчик — мы меряем либо количество инструкций, либо тактов, rdpmc это позволяет:
const PMC_INSTRUCTIONS: u32 = 0x40000000; // PMC0
const PMC_CYCLES: u32 = 0x40000001; // PMC1rdtsc/rdtscp ровно также, только параметра там нет само собой.
Запускать будем миллион раз и брать минимум, типа так:
const ITER: usize = 1_000_000;
for _ in 0..ITER {
let start = rdpmc(PMC_CYCLES);
measured_div_64();
let end = rdpmc(PMC_CYCLES);
let delta = end.wrapping_sub(start);
if delta < min_cycles {
min_cycles = delta;
}
}Вот результаты на i7-13850HX, он гибридный, я могу его запускать либо на Р либо на Е ядрах, для этого Affinity устанавливается через SetThreadAffinityMask().

Вот видите — набегает в два с половиной раза меньше тиков, хотя процессор набирает гораздо больше реальных тактов (и это даёт возможность вычислить истинное значение IPC). А всё потому, что базовая частота у него — 2,3 ГГц, а гонится он почти до пяти на Р ядрах и до 4 на Е и за 10К реальных тактов набегает меньше пяти тысяч тиков.
А теперь я закомментируем деление, оставим три mov (вся эта конструкция по-прежнему повторяется 1000 раз директивой “.rept 1000”):
"mov ecx, 0x2034",
"mov edx, 0x0008",
"mov eax, 0x2B7C",
// "div ecx",И IPC улетит в небеса:

Обратите также внимание на измеренное количество инструкций - стало 3000, всё честно. +7/8 - это оверхед от rdpmc+lfence. Причём от запуска к запуску всё стоит как влитое, плюс минус буквально несколько единиц. Вот три честных запуска подряд, ничего не редактируя в логе:
C:\Users\Andrey\Desktop\r-pmc01\target\release>r-pmc01.exe
Running 1000000 iterations on core 0
64-bit registers: Ticks: 4534, Cycles: 10057, Instructions: 4007, IPC: 0.40
32-bit registers: Ticks: 2728, Cycles: 6057, Instructions: 4007, IPC: 0.66
C:\Users\Andrey\Desktop\r-pmc01\target\release>r-pmc01.exe
Running 1000000 iterations on core 0
64-bit registers: Ticks: 4534, Cycles: 10058, Instructions: 4007, IPC: 0.40
32-bit registers: Ticks: 2728, Cycles: 6057, Instructions: 4007, IPC: 0.66
C:\Users\Andrey\Desktop\r-pmc01\target\release>r-pmc01.exe
Running 1000000 iterations on core 0
64-bit registers: Ticks: 4534, Cycles: 10058, Instructions: 4008, IPC: 0.40
32-bit registers: Ticks: 2728, Cycles: 6057, Instructions: 4007, IPC: 0.66Да, для возможности выполнения rdpmc в пользовательском режиме нужен Intel® PCM. Как-то так.
Не знал про такую неконсистентность системы команд х86-64 (запись в AL и AX не изменяет старшие биты, в EAX - сбрасывает их). Инженеры АМД из 1999 молодцы, что это увидели и пошли на такой шаг.
Мувы бесплатны: они уходят на свободные ALU-порты
В современных процессорах они ещё бесплатнее и выполняются на стадии переименования регистров не занимая порты вообще.
Как одна буква в ассемблере стоит 3× производительности