Информация Гайд Ассемблер VS Компилятор MSVC. Искусство оптимизации программ.

Поздняков

Участник
Автор темы
14
56
Статью про вторую часть полиморфизма выпущу чуть позже.
--

Война между разработчиками на ассемблере и компиляторами длится уже несколько десятилетий, одни утверждают, что они намного лучше оптимизируют свою программу, другие утверждают, что компилятор оптимизирует лучше. В действительности же все не так просто, да, если поставить /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:
1770544567386.png


Практика: Ассемблер 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;
}
В качестве первого я решил выбрать классическую задачу - сравнение двух областей памяти. Программа предельно проста: у нас есть две строки по 16 байт и стандартная функция memcmp. Этот тест интересен тем, что современные компиляторы, видя фиксированный размер данных (16 байт) и знакомую функцию, часто отказываются от вызова стандартной библиотеки. Вместо этого они пытаются вклеить проверку прямо в машинный код (intrinsic-функции), используя мощь регистров процессора на полную катушку.
Основная интрига здесь кроется в том, как именно MSVC распорядится этими 16 байтами. Начнет ли он сравнивать их по одному байту в цикле, как сделал бы совсем неопытный программист, или задействует инструкции для работы с 64-битными регистрами (чтобы проверить всё за два шага), а может и вовсе использует SIMD-инструкции вроде SSE2/AVX?
Давайте же проверим!
Компилируем и смотрим, что программа работоспособная:
1770545166380.png

Теперь давайте дизассемблируем код и посмотрим, как оптимизировал компилятор нашу программу.
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
Так, ну, внимательный читать сразу поймет, что в полученном дизассемблерном листинге функции memcmp просто нет. Вместо честного сравнения двух областей памяти мы видим прямой переход к выводу строки "yes!". Произошло это потому, что современные компиляторы слишком умны для таких простых тестов. MSVC проанализировал код еще на этапе сборки, понял, что две константные строки идентичны, и заранее вычислил результат. В итоге он решил вырезать лишний вызов функции, оставив лишь финальный результат.. Вот она оптимизация /О2.
Нам придется усложнить условия и подать данные так, чтобы компилятор не смог предугадать ответ заранее:
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;
}
P.S Важно, что строк нет. Нам нужно просто выяснить, что происходит капотом реализации memcmp.

Все таки чтобы узнать, что под капотом - я перенес данные из статических констант в аргументы командой строки argv, теперь содержимое строк становится известным только в момент запуска программы.
Компилятор не может заранее предугадать, будут ли строки одинаковыми или разными, следовательно, у него просто не остается выбор, ему придется честно сгенерировать код для сравнения байтов.
Проверим нашу гипотезу, иии:
1770545837037.png

А вот уже, что-то поинтереснее.
Так, компилятор зная, что нам нужно сравнить РОВНО 16 байт, применил стратегию широкого хвата, вместо побайтового перебора, который он бы сделал если бы не было оптимизации, он задействовал 64 битные регистры. чтобы проверить всю строку всего за две операции.
1. Команда sub rax, [argv] - фактически вычитает один огромный блок данных из другого, моментально определяя разницу.
Самое интересное то, что вместо классического нагромождения условных переходов if/else, которые так не любят современные процессоры, MSVC использовал элегантную инструкцию cmovnz. То есть, он заранее подготовил адреса строк "yes!" и "not!, а затем одной командой выбрал нужный результат в зависимость от исхода сравнения.
Пока-что победа в сторону компилятора, но давайте посмотрим что сделал мой Junior ассемблерийщик ake мой друг:
1770546137043.png

Вот тут тоже довольно интересная ситуация.

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.1 Команда movdqu берет и загружает данные из регистра rdx.
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-разраб стал сильным конкурентом для компилятора.
В низкоуровневом коде профессионализм проявляется не в основном цикле, а в умении обрабатывать конкретно финал, чтобы не насрать жидкого себе в штаны.
Вместо того чтобы пытаться запихнуть их в векторный регистр (что могло бы привести к чтению за пределами страницы памяти), junior переходит в классический побайтовый обход.

Давайте подведем итоги первого сравнения:
1. MSVC - хорош в микро-оптимизации конкретных 1 байт через cmovnz.
2. Junior - создал универсальный и неубиваемый memcmp, который одинаково эффективно справляется как с огромными массивами, так и с крошечными остатками через скалярный цикл, все просчитано.

Я считаю выбор кто победил оставлю за читателем статьи, но, лично по моему мнению победил именно Junior.
Кстати, давайте еще сравним размеры:
1770546880954.png

Результаты говорят сами за себя: разница в 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;
}
Strlen - одна из самых часто вызываемых функций в С, разработчики компиляторов я уверен оттачили ее)
MSVC может пойти двумя путями: либо использовать старую добрую команду repne scas, либо попытаться развернуть собственный цикл с использованием 64 битных регистров, как это было в прошлом раунде.
Компилируем и дизассемблируем, смотрим что же у нас вышло:
1770547298315.png

Вот этого никто не ожидал, вхвхвх, даже я.
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
Если в первом раунде MSVC поразил меня своим интеллектом, то здесь он внезапно решил сдать позиции.
Обратите внимание на метку 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
Вот тут опять он решил прибежать к SSE2 и сделал верный выбор!
В этой битве исход стал ясен мне уже со второй же инструкции.
Пока хваленный MSVC топчется на месте, покорно перебирая каждый символ строки в примитивном цикле, Junior-ассемблерийщик решил задействовать всю мощь современного процессора воспользовавшись SSE2.
Он проверяет по 16 байт за один такт, используя связки pcmpeqb и pmovmskb превращает рутинную задачу в высокоскоростной конвейер.


Заключение
В заключение хотел бы написать о том, что я присуждаю победу ассемблерийщику.
Жду ваше мнение, и проявите активность, проверю много чего другого на уже других компиляторах по типу GCC/CLANG.
Исходные коды:
 

Вложения

  • 1770547445515.png
    1770547445515.png
    19 KB · Просмотры: 12
  • blasthack.rar
    1.4 KB · Просмотры: 0
Последнее редактирование:

SR_team

like pancake
BH Team
4,922
6,622
В MSVC на сколько я знаю по дефолту отключено использование векторных инструкций
 

SR_team

like pancake
BH Team
4,922
6,622
Вроде как ключ /aarch:SSE2 по умолчанию включен при /О2 на x64

А на x86 нет, там явно указывать его надо
ну мб - я ток x86 компилил в MSVC

P.S. Чекни еще, что он компилит с флагами на AVX или более современными SSE 4/4.1