- 14
- 56
Статью про вторую часть полиморфизма выпущу чуть позже.
--
Война между разработчиками на ассемблере и компиляторами длится уже несколько десятилетий, одни утверждают, что они намного лучше оптимизируют свою программу, другие утверждают, что компилятор оптимизирует лучше. В действительности же все не так просто, да, если поставить /O2 префикс при компиляции - ты возможно получишь максимально оптимизированную программу, но есть подвох, это сильно влияет на размер бинарного файла, а еще учитывая CRT, - компилятор тянет за собой хвосты инициализации, которые Junior на ассемблере просто не напишет, выиграв в размере файла еще до начала реальной работы. Но ладно, упустим.
Если поразмыслить объективно, включить логику, можно сделать вывод, что компилятор разрабатывался экспертами несколько лет, у компилятора есть свой алгоритм, ну, казалось бы, как обычный ассемблер разработчик сможет прыгнуть выше компилятора? Но нет, разработчики на ассемблере, ake любители старой школы все равно бросают вызовы современным компиляторам.
Сегодня, в этой статье, я бы хотел провести некий анализ, кто же все таки оптимизирует программы лучше:
1. Junior разработчик на ассемблере. У него имеются базовые знания в ассемблере, база в оптимизации программ.
2. Компилятор MSVC.
Оценивать мы будем по баллам, кто первый наберет 3 балла - победил.
Оптимизация у компилятора /О2.
Давайте же посмотрим на эту битву.
Самое важное из 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:
В качестве первого я решил выбрать классическую задачу - сравнение двух областей памяти. Программа предельно проста: у нас есть две строки по 16 байт и стандартная функция memcmp. Этот тест интересен тем, что современные компиляторы, видя фиксированный размер данных (16 байт) и знакомую функцию, часто отказываются от вызова стандартной библиотеки. Вместо этого они пытаются вклеить проверку прямо в машинный код (intrinsic-функции), используя мощь регистров процессора на полную катушку.
Основная интрига здесь кроется в том, как именно MSVC распорядится этими 16 байтами. Начнет ли он сравнивать их по одному байту в цикле, как сделал бы совсем неопытный программист, или задействует инструкции для работы с 64-битными регистрами (чтобы проверить всё за два шага), а может и вовсе использует SIMD-инструкции вроде SSE2/AVX?
Давайте же проверим!
Компилируем и смотрим, что программа работоспособная:
Теперь давайте дизассемблируем код и посмотрим, как оптимизировал компилятор нашу программу.
Так, ну, внимательный читать сразу поймет, что в полученном дизассемблерном листинге функции memcmp просто нет. Вместо честного сравнения двух областей памяти мы видим прямой переход к выводу строки "yes!". Произошло это потому, что современные компиляторы слишком умны для таких простых тестов. MSVC проанализировал код еще на этапе сборки, понял, что две константные строки идентичны, и заранее вычислил результат. В итоге он решил вырезать лишний вызов функции, оставив лишь финальный результат.. Вот она оптимизация /О2.
Нам придется усложнить условия и подать данные так, чтобы компилятор не смог предугадать ответ заранее:
P.S Важно, что строк нет. Нам нужно просто выяснить, что происходит капотом реализации memcmp.
Все таки чтобы узнать, что под капотом - я перенес данные из статических констант в аргументы командой строки argv, теперь содержимое строк становится известным только в момент запуска программы.
Компилятор не может заранее предугадать, будут ли строки одинаковыми или разными, следовательно, у него просто не остается выбор, ему придется честно сгенерировать код для сравнения байтов.
Проверим нашу гипотезу, иии:
А вот уже, что-то поинтереснее.
Так, компилятор зная, что нам нужно сравнить РОВНО 16 байт, применил стратегию широкого хвата, вместо побайтового перебора, который он бы сделал если бы не было оптимизации, он задействовал 64 битные регистры. чтобы проверить всю строку всего за две операции.
1. Команда sub rax, [argv] - фактически вычитает один огромный блок данных из другого, моментально определяя разницу.
Самое интересное то, что вместо классического нагромождения условных переходов if/else, которые так не любят современные процессоры, MSVC использовал элегантную инструкцию cmovnz. То есть, он заранее подготовил адреса строк "yes!" и "not!, а затем одной командой выбрал нужный результат в зависимость от исхода сравнения.
Пока-что победа в сторону компилятора, но давайте посмотрим что сделал мой Junior ассемблерийщик ake мой друг:
Вот тут тоже довольно интересная ситуация.
1. Junior ассемблерийщик внезапно неожидано для меня решил воспользоваться SSE2 инструкциями. Реализация memcmp использует 128 битные регистры XMM, чтобы сравнивать по 16 байт за один ТАКТ.
1.1 Команда movdqu берет и загружает данные из регистра rdx.
1.2 Далее идет сравнение двух регистров, кстати, важный нюанс работы инструкции pcmpeqb заключается в том, что она заполняет результирующий байт значением 0xFF (все единицы в двоичной системе), если байты в обеих строках совпали, в контексте memcmp - это работает идеально: если после сравнения 16 байт мы получаем в eax значение 0xFFFF, значит перед нами два абсолютных идентичных блока.
1.3 Также хочу подчеркнуть то, что Junior догадался реализовать функцию, которая в случае, если к примеру прочитал 16 байт. но осталось еще 3, побайтово проходит и читает их:
Тут уже с уверенностью можно сказать, что Junior-разраб стал сильным конкурентом для компилятора.
В низкоуровневом коде профессионализм проявляется не в основном цикле, а в умении обрабатывать конкретно финал, чтобы не насрать жидкого себе в штаны.
Вместо того чтобы пытаться запихнуть их в векторный регистр (что могло бы привести к чтению за пределами страницы памяти), junior переходит в классический побайтовый обход.
Давайте подведем итоги первого сравнения:
1. MSVC - хорош в микро-оптимизации конкретных 1 байт через cmovnz.
2. Junior - создал универсальный и неубиваемый memcmp, который одинаково эффективно справляется как с огромными массивами, так и с крошечными остатками через скалярный цикл, все просчитано.
Я считаю выбор кто победил оставлю за читателем статьи, но, лично по моему мнению победил именно Junior.
Кстати, давайте еще сравним размеры:
Результаты говорят сами за себя: разница в 25 раз. Сухая статистика ставит точку в споре о лишнем весе современных инструментов.
Но все же, надо признать, что 49,0 КБ учитывая грубую оптимизацию это не так уж и плохо.
Давайте посмотрим.
Код на С:
Strlen - одна из самых часто вызываемых функций в С, разработчики компиляторов я уверен оттачили ее)
MSVC может пойти двумя путями: либо использовать старую добрую команду repne scas, либо попытаться развернуть собственный цикл с использованием 64 битных регистров, как это было в прошлом раунде.
Компилируем и дизассемблируем, смотрим что же у нас вышло:
Вот этого никто не ожидал, вхвхвх, даже я.
Если в первом раунде MSVC поразил меня своим интеллектом, то здесь он внезапно решил сдать позиции.
Обратите внимание на метку loc_140011710, компилятор просто берет каждый следующий байт, и проверяет, не ноль ли это.
И через инкремент счетчика inc rdx, увеличивает длину на единицу и идет на следующую итерацию.
Почему эксперты из Microsoft решили оставить здесь такую примитивную логику при флаге /O2? Загадка остается загадкой, мне многие говорили что MSVC это дерьмо, так что не знаю.
Но, что же подготовил для нас Junior-ассемблерийщик?
Вот тут опять он решил прибежать к SSE2 и сделал верный выбор!
В этой битве исход стал ясен мне уже со второй же инструкции.
Пока хваленный MSVC топчется на месте, покорно перебирая каждый символ строки в примитивном цикле, Junior-ассемблерийщик решил задействовать всю мощь современного процессора воспользовавшись SSE2.
Он проверяет по 16 байт за один такт, используя связки pcmpeqb и pmovmskb превращает рутинную задачу в высокоскоростной конвейер.
Жду ваше мнение, и проявите активность, проверю много чего другого на уже других компиляторах по типу GCC/CLANG.
Исходные коды:
--
Война между разработчиками на ассемблере и компиляторами длится уже несколько десятилетий, одни утверждают, что они намного лучше оптимизируют свою программу, другие утверждают, что компилятор оптимизирует лучше. В действительности же все не так просто, да, если поставить /O2 префикс при компиляции - ты возможно получишь максимально оптимизированную программу, но есть подвох, это сильно влияет на размер бинарного файла, а еще учитывая CRT, - компилятор тянет за собой хвосты инициализации, которые Junior на ассемблере просто не напишет, выиграв в размере файла еще до начала реальной работы. Но ладно, упустим.
Если поразмыслить объективно, включить логику, можно сделать вывод, что компилятор разрабатывался экспертами несколько лет, у компилятора есть свой алгоритм, ну, казалось бы, как обычный ассемблер разработчик сможет прыгнуть выше компилятора? Но нет, разработчики на ассемблере, ake любители старой школы все равно бросают вызовы современным компиляторам.
Сегодня, в этой статье, я бы хотел провести некий анализ, кто же все таки оптимизирует программы лучше:
1. Junior разработчик на ассемблере. У него имеются базовые знания в ассемблере, база в оптимизации программ.
2. Компилятор MSVC.
Оценивать мы будем по баллам, кто первый наберет 3 балла - победил.
Оптимизация у компилятора /О2.
Давайте же посмотрим на эту битву.
Немного поговорим про SSE2
SSE2 - это набор инструкций процессора SIMD, разработанный intel впервые представленный в Pentium 4, который расширяет возможности обычного, первого SSE, добавляя 144 новые команды для ускорения вычисления с плавающей запятой двойной точность и обработки целых чисел. SSE2 служит для улучшения производительности при работе с видео, со звуком, с большими массивами, трехмерной графикой и т.д. Но разработчики на ассемблере любят прибегать к нему для оптимизации своих программ.Самое важное из 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?
Давайте же проверим!
Компилируем и смотрим, что программа работоспособная:
Теперь давайте дизассемблируем код и посмотрим, как оптимизировал компилятор нашу программу.
asm:
.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_blasthack@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 байт за один ТАКТ.
ASM:
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
1.2 Далее идет сравнение двух регистров, кстати, важный нюанс работы инструкции pcmpeqb заключается в том, что она заполняет результирующий байт значением 0xFF (все единицы в двоичной системе), если байты в обеих строках совпали, в контексте memcmp - это работает идеально: если после сравнения 16 байт мы получаем в eax значение 0xFFFF, значит перед нами два абсолютных идентичных блока.
1.3 Также хочу подчеркнуть то, что Junior догадался реализовать функцию, которая в случае, если к примеру прочитал 16 байт. но осталось еще 3, побайтово проходит и читает их:
ASM:
.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, который одинаково эффективно справляется как с огромными массивами, так и с крошечными остатками через скалярный цикл, все просчитано.
Я считаю выбор кто победил оставлю за читателем статьи, но, лично по моему мнению победил именно Junior.
Кстати, давайте еще сравним размеры:
Результаты говорят сами за себя: разница в 25 раз. Сухая статистика ставит точку в споре о лишнем весе современных инструментов.
Но все же, надо признать, что 49,0 КБ учитывая грубую оптимизацию это не так уж и плохо.
Подглава: Реализация STRLEN
В этой подглаве я бы хотел сравнить теперь реализацию strlen, кто же теперь победит?Давайте посмотрим.
Код на С:
С:
#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;
}
MSVC может пойти двумя путями: либо использовать старую добрую команду repne scas, либо попытаться развернуть собственный цикл с использованием 64 битных регистров, как это было в прошлом раунде.
Компилируем и дизассемблируем, смотрим что же у нас вышло:
Вот этого никто не ожидал, вхвхвх, даже я.
asm:
.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-ассемблерийщик?
ASM:
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 превращает рутинную задачу в высокоскоростной конвейер.
Заключение
В заключение хотел бы написать о том, что я присуждаю победу ассемблерийщику.Жду ваше мнение, и проявите активность, проверю много чего другого на уже других компиляторах по типу GCC/CLANG.
Исходные коды:
Вложения
Последнее редактирование: