Статья писалась только для LIMB.
Введение
Война между разработчиками на ассемблере и компиляторами длится уже несколько десятилетий, одни утверждают, что они намного лучше оптимизируют свою программу, другие утверждают, что компилятор оптимизирует лучше. В действительности же все не так просто, да, если поставить
Если поразмыслить объективно, включить логику, можно сделать вывод, что компилятор разрабатывался экспертами несколько лет, у компилятора есть свой алгоритм, ну, казалось бы, как обычный ассемблер разработчик сможет прыгнуть выше компилятора? Но нет, разработчики на ассемблере, ake любители старой школы все равно бросают вызовы современным компиляторам.
Сегодня, в этой статье, я бы хотел провести некий анализ, кто же все таки оптимизирует программы лучше:
1. Junior разработчик на ассемблере. У него имеются базовые знания в ассемблере, база в оптимизации программ.
2. Компилятор MSVC.
Оценивать мы будем по баллам, кто первый наберет 3 балла - победил.
Оптимизация у компилятора /О2.
Давайте же посмотрим на эту битву.
Немного про SIMD/SSE2
Или вот еще пример: одна инструкция может выполнять одну и ту же операцию сразу над несколькими элементами. Например, если у нас есть два массива чисел с плавающей точкой, мы можем загрузить по четыре числа в два XMM-регистра и выполнить сложение сразу для всех четырёх элементов одной инструкцией. Таким образом, вместо четырёх обычных операций сложения процессор выполнит одну SIMD-операцию.
Самое важное из SSE2:
1. В SSE2 используется 16 регистров
Они позволяют упаковать в один регистр:
1.1 Два числа типа double (64 бит каждое)
1.2 Четыре числа типа int (32 бит)
1.3 Восемь чисел типа short (16 бит)
1.4 Шестнадцать чисел типа byte (8 бит)
2. Про команды MOV, расскажу про самые важные, про те, которые мы будем использовать в статье:
2.1 MOVDQA - используется для копирования 128 бит данных. Требует, чтобы данные в памяти были выровнены по границе 16 байт, если данные не выровнены - программа упадает с исключением. Это самый быстрый способ, но... в статье я буду использовать именно MOVDQU чтобы не мучить себя с выравниванием.
2.2 MOVDQU - тут уже дозволено работать с невыровненными данными. Работает чуть медленнее (на старых процессорах тем более), но безопаснее, если вы не контролируете расположение массива в памяти к примеру.
2.2 MOVD / MOVQ - Используется для пересылки данных между обычными регистрами (rcx/rax) и XMM-регистрами.
3. Обнуление XMM регистров.
3.1 Для быстрого обнуления регистра, используется PXOR xmm1. xmm1, идентичен обнулению обычных регистров (xor rax, rax).
--
Как пример работы с SSE2:
Практика: Ассемблер VS Компилятор
В первую очередь проверим, как компилятор сравнивает байты, код:
В качестве первого я решил выбрать классическую задачу - сравнение двух областей памяти. Программа предельно проста: у нас есть две строки по 16 байт и стандартная функция memcmp. Этот тест интересен тем, что современные компиляторы, видя фиксированный размер данных (16 байт) и знакомую функцию, часто отказываются от вызова стандартной библиотеки. Вместо этого они пытаются вклеить проверку прямо в машинный код (intrinsic-функции), используя мощь регистров процессора на полную катушку.
Основная интрига здесь кроется в том, как именно
Давайте же проверим!
Компилируем и смотрим, что программа работоспособная:
Теперь давайте дизассемблируем код и посмотрим, как оптимизировал компилятор нашу программу:
Так, ну, внимательный читать сразу поймет, что в полученном дизассемблерном листинге функции memcmp просто нет. Вместо честного сравнения двух областей памяти мы видим прямой переход к выводу строки "yes!". Произошло это потому, что современные компиляторы слишком умны для таких простых тестов. MSVC проанализировал код еще на этапе сборки, понял, что две константные строки идентичны, и заранее вычислил результат. В итоге он решил вырезать лишний вызов функции, оставив лишь финальный результат.. Вот она оптимизация /О2.
Нам придется усложнить условия и подать данные так, чтобы компилятор не смог предугадать ответ заранее:
P.S Важно, что строк нет. Нам нужно просто выяснить, что происходит капотом реализации memcmp.
Все таки чтобы узнать, что под капотом - я перенес данные из статических констант в аргументы командой строки argv, теперь содержимое строк становится известным только в момент запуска программы.
Компилятор не может заранее предугадать, будут ли строки одинаковыми или разными, следовательно, у него просто не остается выбор, ему придется честно сгенерировать код для сравнения байтов.
Проверим нашу гипотезу, иии:
А вот уже, что-то поинтереснее.
Так, компилятор зная, что нам нужно сравнить РОВНО 16 байт, применил стратегию широкого хвата, вместо побайтового перебора, который он бы сделал если бы не было оптимизации, он задействовал 64 битные регистры. чтобы проверить всю строку всего за две операции.
1. Команда
Самое интересное то, что вместо классического нагромождения условных переходов if/else, которые так не любят современные процессоры, MSVC использовал элегантную инструкцию cmovnz. То есть, он заранее подготовил адреса строк "yes!" и "not!, а затем одной командой выбрал нужный результат в зависимость от исхода сравнения.
Пока-что победа в сторону компилятора, но давайте посмотрим что сделал мой Junior ассемблерийщик ake мой друг:
1. Junior ассемблерийщик внезапно неожидано для меня решил воспользоваться SSE2 инструкциями. Реализация memcmp использует 128 битные регистры XMM, чтобы сравнивать по 16 байт за один ТАКТ.
1.1 Команда
1.2 Далее идет сравнение двух регистров, кстати, важный нюанс работы инструкции pcmpeqb заключается в том, что она заполняет результирующий байт значением 0xFF (все единицы в двоичной системе), если байты в обеих строках совпали, в контексте
1.3 Также хочу подчеркнуть то, что Junior догадался реализовать функцию, которая в случае, если к примеру прочитал 16 байт. но осталось еще 3, побайтово проходит и читает их:
Тут уже с уверенностью можно сказать, что Junior-разраб стал сильным конкурентом для компилятора.
В низкоуровневом коде профессионализм проявляется не в основном цикле, а в умении обрабатывать конкретно финал, чтобы не насрать жидкого себе в штаны.
Вместо того чтобы пытаться запихнуть их в векторный регистр (что могло бы привести к чтению за пределами страницы памяти), junior переходит в классический побайтовый обход.
Давайте подведем итоги первого сравнения:
1.
2. Junior - создал универсальный и неубиваемый memcmp, который одинаково эффективно справляется как с огромными массивами, так и с крошечными остатками через скалярный цикл, все просчитано.
Подглава: Реализация strlen
В этой подглаве я бы хотел сравнить теперь реализацию strlen, кто же теперь победит?
Давайте посмотрим.
Код на С:
MSVC может пойти двумя путями: либо использовать старую добрую команду repne scas, либо попытаться развернуть собственный цикл с использованием 64 битных регистров, как это было в прошлом раунде.
Компилируем и дизассемблируем, смотрим что же у нас вышло:
Вот тут конечно неожиданно:
Если в первом раунде MSVC поразил меня своим интеллектом, то здесь он внезапно решил сдать позиции.
Обратите внимание на метку loc_140011710, компилятор просто берет каждый следующий байт, и проверяет, не ноль ли это.
И через инкремент счетчика inc rdx, увеличивает длину на единицу и идет на следующую итерацию.
Почему эксперты из Microsoft решили оставить здесь такую примитивную логику при флаге /O2? Загадка остается загадкой, мне многие говорили что MSVC это дерьмо, так что не знаю.
Но, что же подготовил для нас Junior-ассемблерийщик?
Вот тут опять он решил прибежать к SSE2 и сделал верный выбор!
В этой битве исход стал ясен мне уже со второй же инструкции.
Пока хваленный
Он проверяет по 16 байт за один такт, используя связки pcmpeqb и pmovmskb превращает рутинную задачу в высокоскоростной конвейер.
Заключение
Если подвести итог всей этой небольшой битвы, то ответ на главный вопрос статьи оказывается не таким однозначным, как может показаться на первый взгляд.
Современные компиляторы - это огромные инженерные проекты, над которыми годами работают десятки специалистов. Внутри них заложены сложные алгоритмы оптимизации, анализ зависимостей, инлайнинг функций, переупорядочивание инструкций и множество других техник, которые позволяют автоматически генерировать весьма эффективный код. В большинстве реальных задач компилятор действительно справляется со своей работой отлично и выдаёт результат, который будет быстрее или как минимум не хуже того, что напишет обычный разработчик.
Однако низкоуровневое программирование всегда оставляет пространство для ручной оптимизации. Особенно это заметно в узких и часто вызываемых участках кода - таких как memcmp, strlen и другие примитивы работы с памятью. В подобных местах разработчик, хорошо понимающий архитектуру процессора, может сознательно использовать SIMD-инструкции, учитывать особенности кэша, ширину регистров и поведение конвейера процессора. Иногда даже быть лучше компилятора.
Введение
Война между разработчиками на ассемблере и компиляторами длится уже несколько десятилетий, одни утверждают, что они намного лучше оптимизируют свою программу, другие утверждают, что компилятор оптимизирует лучше. В действительности же все не так просто, да, если поставить
/O2 префикс при компиляции - ты возможно получишь максимально оптимизированную программу, но есть подвох, это сильно влияет на размер бинарного файла, а еще учитывая CRT, - компилятор тянет за собой хвосты инициализации, которые Junior на ассемблере просто не напишет, выиграв в размере файла еще до начала реальной работы. Но ладно, упустим.Если поразмыслить объективно, включить логику, можно сделать вывод, что компилятор разрабатывался экспертами несколько лет, у компилятора есть свой алгоритм, ну, казалось бы, как обычный ассемблер разработчик сможет прыгнуть выше компилятора? Но нет, разработчики на ассемблере, ake любители старой школы все равно бросают вызовы современным компиляторам.
Сегодня, в этой статье, я бы хотел провести некий анализ, кто же все таки оптимизирует программы лучше:
1. Junior разработчик на ассемблере. У него имеются базовые знания в ассемблере, база в оптимизации программ.
2. Компилятор MSVC.
Оценивать мы будем по баллам, кто первый наберет 3 балла - победил.
Оптимизация у компилятора /О2.
Давайте же посмотрим на эту битву.
Немного про SIMD/SSE2
SSE - это набор инструкций процессора SIMD, разработанный intel(рассказывал подробно тут) впервые представленный в Pentium 4, который расширяет возможности обычного, первого SSE, добавляя 144 новые команды для ускорения вычисления с плавающей запятой двойной точность и обработки целых чисел. SSE2 служит для улучшения производительности при работе с видео, со звуком, с большими массивами, трехмерной графикой и т.д. Но разработчики на ассемблере любят прибегать к нему для оптимизации своих программ, т.к бывают моменты, например в циклах, когда вместо того чтобы проходить по опредленному участку памяти побайтово, мы можем проходить сразу по 16 байт.Или вот еще пример: одна инструкция может выполнять одну и ту же операцию сразу над несколькими элементами. Например, если у нас есть два массива чисел с плавающей точкой, мы можем загрузить по четыре числа в два XMM-регистра и выполнить сложение сразу для всех четырёх элементов одной инструкцией. Таким образом, вместо четырёх обычных операций сложения процессор выполнит одну SIMD-операцию.
Самое важное из SSE2:
1. В SSE2 используется 16 регистров
XMM (в 64 битных системах, в 32 битных их 8), каждый из них имеет ширину 128 бит. В отличие от давно ушедшего в закат MMX, который заимствовал регистры у математического сопроцессора FPU, регистры XMM - это отдельное физические хранилища.Они позволяют упаковать в один регистр:
1.1 Два числа типа double (64 бит каждое)
1.2 Четыре числа типа int (32 бит)
1.3 Восемь чисел типа short (16 бит)
1.4 Шестнадцать чисел типа byte (8 бит)
2. Про команды MOV, расскажу про самые важные, про те, которые мы будем использовать в статье:
2.1 MOVDQA - используется для копирования 128 бит данных. Требует, чтобы данные в памяти были выровнены по границе 16 байт, если данные не выровнены - программа упадает с исключением. Это самый быстрый способ, но... в статье я буду использовать именно MOVDQU чтобы не мучить себя с выравниванием.
2.2 MOVDQU - тут уже дозволено работать с невыровненными данными. Работает чуть медленнее (на старых процессорах тем более), но безопаснее, если вы не контролируете расположение массива в памяти к примеру.
2.2 MOVD / MOVQ - Используется для пересылки данных между обычными регистрами (rcx/rax) и XMM-регистрами.
3. Обнуление XMM регистров.
3.1 Для быстрого обнуления регистра, используется PXOR xmm1. xmm1, идентичен обнулению обычных регистров (xor rax, rax).
--
Как пример работы с SSE2:
Не удалось прогрузить медиа
Практика: Ассемблер VS Компилятор
В первую очередь проверим, как компилятор сравнивает байты, код:
C:
#include <stdio.h>
#include <string.h>
#include <windows.h>
const char str1[] = "This is a test!!";
const char str2[] = "This is a test!!";
int main() {
size_t len = 16;
int result = memcmp(str1, str2, len);
if (result == 0) {
printf("yes!\n");
}
else {
printf("not!\n");
}
return 0;
}
Основная интрига здесь кроется в том, как именно
MSVC распорядится этими 16 байтами. Начнет ли он сравнивать их по одному байту в цикле, как сделал бы совсем неопытный программист, или задействует инструкции для работы с 64-битными регистрами (чтобы проверить всё за два шага), а может и вовсе использует SIMD-инструкции вроде SSE2/AVX?Давайте же проверим!
Компилируем и смотрим, что программа работоспособная:
Не удалось прогрузить медиа
Теперь давайте дизассемблируем код и посмотрим, как оптимизировал компилятор нашу программу:
Code:
.text:00000001400116C0 main proc near ; CODE XREF: j_main↑j
.text:00000001400116C0 ; DATA XREF: .pdata:000000014001C818↓o
.text:00000001400116C0 sub rsp, 28h
.text:00000001400116C4 lea rcx, __148AD58C_@c ; JMC_flag
.text:00000001400116CB call j___CheckForDebuggerJustMyCode
.text:00000001400116D0 lea rcx, _Format ; "yes!\n"
.text:00000001400116D7 call j_printf
.text:00000001400116DC xor eax, eax
.text:00000001400116DE add rsp, 28h
.text:00000001400116E2 retn
.text:00000001400116E2 main endp
.text:00000001400116E2
Нам придется усложнить условия и подать данные так, чтобы компилятор не смог предугадать ответ заранее:
C:
#include <stdio.h>
#include <string.h>
int main(int argc, char** argv) {
if (argc < 3) {
printf("usage test\n");
return 1;
}
int result = memcmp(argv[1], argv[2], 16);
if (result == 0) {
printf("yes!\n");
}
else {
printf("not!\n");
}
return 0;
}
Все таки чтобы узнать, что под капотом - я перенес данные из статических констант в аргументы командой строки argv, теперь содержимое строк становится известным только в момент запуска программы.
Компилятор не может заранее предугадать, будут ли строки одинаковыми или разными, следовательно, у него просто не остается выбор, ему придется честно сгенерировать код для сравнения байтов.
Проверим нашу гипотезу, иии:
Не удалось прогрузить медиа
А вот уже, что-то поинтереснее.
Так, компилятор зная, что нам нужно сравнить РОВНО 16 байт, применил стратегию широкого хвата, вместо побайтового перебора, который он бы сделал если бы не было оптимизации, он задействовал 64 битные регистры. чтобы проверить всю строку всего за две операции.
1. Команда
sub rax, [argv] - фактически вычитает один огромный блок данных из другого, моментально определяя разницу.Самое интересное то, что вместо классического нагромождения условных переходов if/else, которые так не любят современные процессоры, MSVC использовал элегантную инструкцию cmovnz. То есть, он заранее подготовил адреса строк "yes!" и "not!, а затем одной командой выбрал нужный результат в зависимость от исхода сравнения.
Пока-что победа в сторону компилятора, но давайте посмотрим что сделал мой Junior ассемблерийщик ake мой друг:
1. Junior ассемблерийщик внезапно неожидано для меня решил воспользоваться SSE2 инструкциями. Реализация memcmp использует 128 битные регистры XMM, чтобы сравнивать по 16 байт за один ТАКТ.
C:
memcmp:
xor rax, rax
test r8, r8
jz .done
.loop16:
cmp r8, 16
jl .scalar_tail
movdqu xmm0, [rcx]
movdqu xmm1, [rdx]
pcmpeqb xmm0, xmm1
pmovmskb eax, xmm0
movdqu берет и загружает данные из регистра rdx.1.2 Далее идет сравнение двух регистров, кстати, важный нюанс работы инструкции pcmpeqb заключается в том, что она заполняет результирующий байт значением 0xFF (все единицы в двоичной системе), если байты в обеих строках совпали, в контексте
memcmp - это работает идеально: если после сравнения 16 байт мы получаем в eax значение 0xFFFF, значит перед нами два абсолютных идентичных блока.1.3 Также хочу подчеркнуть то, что Junior догадался реализовать функцию, которая в случае, если к примеру прочитал 16 байт. но осталось еще 3, побайтово проходит и читает их:
C:
.found:
not ax
bsf ax, ax
movzx rax, ax
add rcx, rax
add rdx, rax
movzx eax, byte[rcx]
movzx edx, byte[rdx]
sub eax, edx
ret
.scalar_tail:
test r8, r8
jz .done_eq
.s_loop:
mov al, [rcx]
sub al, [rdx]
jnz .s_diff
inc rcx
inc rdx
dec r8
jnz .s_loop
.done_eq:
xor rax, rax
ret
.s_diff:
movsx rax, al
.done:
ret
В низкоуровневом коде профессионализм проявляется не в основном цикле, а в умении обрабатывать конкретно финал, чтобы не насрать жидкого себе в штаны.
Вместо того чтобы пытаться запихнуть их в векторный регистр (что могло бы привести к чтению за пределами страницы памяти), junior переходит в классический побайтовый обход.
Давайте подведем итоги первого сравнения:
1.
MSVC - хорош в микро-оптимизации конкретных 1 байт через cmovnz.2. Junior - создал универсальный и неубиваемый memcmp, который одинаково эффективно справляется как с огромными массивами, так и с крошечными остатками через скалярный цикл, все просчитано.
Подглава: Реализация strlen
В этой подглаве я бы хотел сравнить теперь реализацию strlen, кто же теперь победит?
Давайте посмотрим.
Код на С:
C:
#include <stdio.h>
#include <string.h>
int main(int argc, char** argv) {
if (argc < 2) {
printf("strlentessssstset\n", argv[0]);
return 1;
}
size_t length = strlen(argv[1]);
printf("Length: %zu\n", length);
return 0;
}
strlen - одна из самых часто вызываемых функций в С, разработчики компиляторов я уверен оттачили ее)MSVC может пойти двумя путями: либо использовать старую добрую команду repne scas, либо попытаться развернуть собственный цикл с использованием 64 битных регистров, как это было в прошлом раунде.
Компилируем и дизассемблируем, смотрим что же у нас вышло:
Не удалось прогрузить медиа
Вот тут конечно неожиданно:
Code:
.text:00000001400116FF loc_1400116FF: ; CODE XREF: main+1E↑j
.text:00000001400116FF mov rax, [rdi+8]
.text:0000000140011703 mov argv, 0FFFFFFFFFFFFFFFFh
.text:000000014001170A nop word ptr [rax+rax+00h]
.text:0000000140011710
.text:0000000140011710 loc_140011710: ; CODE XREF: main+57↓j
.text:0000000140011710 inc argv
.text:0000000140011713 cmp byte ptr [rax+argv], 0
.text:0000000140011717 jnz short loc_140011710
.text:0000000140011719 lea rcx, aLengthZu ; "Length: %zu\n"
.text:0000000140011720 call j_printf
.text:0000000140011725 mov rbx, [rsp+28h+arg_0]
.text:000000014001172A xor eax, eax
.text:000000014001172C add rsp, 20h
.text:0000000140011730 pop rdi
.text:0000000140011731 retn
.text:0000000140011731 main endp
.text:0000000140011731
Обратите внимание на метку loc_140011710, компилятор просто берет каждый следующий байт, и проверяет, не ноль ли это.
И через инкремент счетчика inc rdx, увеличивает длину на единицу и идет на следующую итерацию.
Почему эксперты из Microsoft решили оставить здесь такую примитивную логику при флаге /O2? Загадка остается загадкой, мне многие говорили что MSVC это дерьмо, так что не знаю.
Но, что же подготовил для нас Junior-ассемблерийщик?
C:
strlen_sse2:
mov rdx, rcx
pxor xmm1, xmm1
.loop16:
movdqu xmm0, [rcx]
pcmpeqb xmm0, xmm1
pmovmskb eax, xmm0
test eax, eax
jnz .found
add rcx, 16
jmp .loop16
.found:
bsf eax, eax
sub rcx, rdx
add rax, rcx
ret
В этой битве исход стал ясен мне уже со второй же инструкции.
Пока хваленный
MSVC топчется на месте, покорно перебирая каждый символ строки в примитивном цикле, Junior-ассемблерийщик решил задействовать всю мощь современного процессора воспользовавшись SSE2.Он проверяет по 16 байт за один такт, используя связки pcmpeqb и pmovmskb превращает рутинную задачу в высокоскоростной конвейер.
Заключение
Если подвести итог всей этой небольшой битвы, то ответ на главный вопрос статьи оказывается не таким однозначным, как может показаться на первый взгляд.
Современные компиляторы - это огромные инженерные проекты, над которыми годами работают десятки специалистов. Внутри них заложены сложные алгоритмы оптимизации, анализ зависимостей, инлайнинг функций, переупорядочивание инструкций и множество других техник, которые позволяют автоматически генерировать весьма эффективный код. В большинстве реальных задач компилятор действительно справляется со своей работой отлично и выдаёт результат, который будет быстрее или как минимум не хуже того, что напишет обычный разработчик.
Однако низкоуровневое программирование всегда оставляет пространство для ручной оптимизации. Особенно это заметно в узких и часто вызываемых участках кода - таких как memcmp, strlen и другие примитивы работы с памятью. В подобных местах разработчик, хорошо понимающий архитектуру процессора, может сознательно использовать SIMD-инструкции, учитывать особенности кэша, ширину регистров и поведение конвейера процессора. Иногда даже быть лучше компилятора.
👍67
🔥18
😢12
💯11
LIMB