Ассемблер x86 против C — HackZona.Ru

Ассемблер x86 против C

Ассемблер x86 против C

Тип статьи:
Со старой ХакЗоны.
Источник:
Математика
В любом случае, я не думаю, что производительность ПО будет сильно выше, если его взять и переписать на ассемблере.

Во-первых, часто может быть выше в разы — хотя бы за счет деталей архитектуры, о которых компилятор «не знает». Скажем, FPU на x86 просто логарифм считать не умеет — только y*log2(x). Как ты думаешь, будет у меня разница если я попытаюсь вычислять такую функцию на сях и на асме?

Даже MSVC6 и то компилит из

void main(void)
{
int x = 66666*66666;
printf("%lf", 128*log(x) );
}

вот такое вот:

fldln2
sub esp, 8
fld QWORD PTR [email protected]@401a8e77be4000000000
fyl2x
fmul QWORD PTR [email protected]@40068000000000000000
fstp QWORD PTR [esp]
push OFFSET FLAT:
call DWORD PTR __imp__printf
add esp, 12

Intel — и то облажался

fld QWORD PTR 1_2il0floatpacket1 ;7.27
fldln2 ;7.27
fxch ;7.27
fyl2x ;7.27
fmul QWORD PTR 1_2il0floatpacket ;7.27
mov DWORD PTR [esp], OFFSET FLAT: ??_C@_04A@??6?@ ;7.27
fstp QWORD PTR [esp+4] ;7.27
call DWORD PTR __imp__printf ;7.27

Не говоря уж о том, что мне логарифм по основанию «e» вообще как-то нафиг не нужен :). Между тем log2 я в math.h отчего-то не вижу :(

Короче, уверяю тебя — математика даже минимальной сложности, написанная на асме, работает много быстрее :(.

Оптимизатор
Ну от ассемблера практическая польза у ЯВУ есть еще одна ;) Заключается она в том, что это не ассемблер ;) у меня такое ощущение, что руками делать то, что делает оптимизатор для кода (то есть, перестановку инструкций так, что бы они помещались в конвейер, оптимизацию использования кеша и т.д. и т.п.) руками не сделать. Точнее, не факт что будет лучше ;)

Гм. Это ты еще не знаешь, с кем связался :). Я вообще сишный компилятор (IntelC 4.5, в основном) использую чуть больше четырех месяцев — потому что завел сайт и оказалось, что некоторые мало того, что ничего не понимают в моих исходниках, так у них еще и на экзешники идиосинкразия :). В смысле, до того мне кроме асма как-то ничего не требовалось :) (ес-сно, для моих проектов; не для коммерческих) Так вот. Оптимизатора лучше, чем в IntelC, я не видел, но на то, что он компилит, все равно лучше не смотреть. Ни о какой автоматической оптимизации использования кэша просто речи быть не может (ты о выравнивании, что ли?). Просто потому, что развернутый код/выравненные данные очень легко могут в него не поместиться и я наблюдал подобное реально (душераздирающее зрелище — после установки inline на одну маленькую функцию, вызываемую два раза, программа вдруг начала работать раз в десять медленнее :).

В общем, единственное, от чего есть польза по скорости — это от разворачивания циклов с внутренними условиями — если это сделать вручную, то очень трудно будет потом что-то менять.

А вот по нескольким вещам, которые доступны мне при писании на асме, я очень скучаю. Во-первых, это макропроцессор — сишный несравнимо хуже и из-за этого в сложных проектах часто попадаются дополнительные препроцессоры etc. Во-вторых — глобальная оптимизация; например, я сделал макросов для определения/использования глобальных переменных и возможность доступа к ним относительно EBP (который должен при этом указывать в начало соответствующего буфера). Выглядит это так:

ifproc PrepareParamFiles
% define
local inpfile,outfile
NameBufLen = 256
dvd SourceLen
dvd SourceSeg
dvd TargetFil
buffer inpfile, NameBufLen
buffer outfile, NameBufLen
dvd SourceNam, offset inpfile
dvd TargetNam, offset outfile

PrepareParamFiles: pushad
mov esi,81h
mov edi,[d_SourceNam]
calls FetchFilename
edx,«Filename must be specified.»,13,10
jc ItsError

; copy filename to output buffer and change its extension
push edi; save inpfile
mov edi,[d_TargetNam]
calls FetchFilename
smov es,ds
jnc ReadOk
; generate output filename by self
mov esi,[s4s 0]
calls Truename
...
endm

а компилится в следующее:

60 pushad
BE81000000 mov esi,000000081
8B7D80 mov edi,[ebp][-0080]
E853000000 call 00000011E
BAD7050000 mov edx,0000005D7
7242 jb 000000114
57 push edi
8B7D84 mov edi,[ebp][-007C]
E843000000 call 00000011E
1E push ds
07 pop es
7313 jae 0000000F2
8B3424 mov esi,[esp]
E860000000 call 000000147
...

Как ты можешь заметить, однобайтовые относительные адреса несколько короче абсолютных ;). (Не говоря уж о том, что функции, определенные по ifproc/endm компилятся только в том случае, если в скомпилированном коде будет к ним обращение; по calls, например)

Но это все мелочи. А вот о том, что на любой из современных архитектур с поддержкой виртуальной памяти использование каких-то специальных средств для динамической аллокации памяти и т.п. — это полный бред, ты знаешь? Потому что для расширения (выравненного на страницу) блока памяти необходимо всего лишь отмапить дополнительную страничку по соответствующему адресу. В общем, все средства для эффективной работы с динамической памятью входят в состав любой современной ОС. (Любая RTL использует их + навернутый сверху собственный менеджер памяти).

Только вот использовать их при программировании на сях, например, я затрудняюсь. Фишка простая — при изменении размера ОСового блока памяти у него может поменяться адрес. А поскольку компиляторы ничего, кроме flat-модели не поддерживают, то автоматически использовать такой способ аллокации нельзя — каждый указатель в середину блока должен «знать» начальный адрес этого блока. А архитектура x86, между тем, поддерживает такое понятие, как «селектор».

Оказывается возможным, например, следующее:

...
mov edi,[d_BufferPtr]
cmp edi,[d_BufferSize]
jae ResizeOutBuffer0
ContinueWriting0: stos [e4y smp_Value]
mov [d_BufferPtr],edi
...

ResizeOutBuffer0: pushad
add [d_BufferSize],BufferSizeIncrement
mov eax,es
mov ecx,[d_BufferSize]
calls mResize
mov es,eax
popad
jmp ContinueWriting0

Причем все указатели на этот же блок, хранящиеся где-то в памяти в виде селектор: смещение будут _всегда_ ссылаться именно на этот блок, куда бы он ни уехал по памяти.

А компиляторы Си (из соображений портабельности? :) указателей с селекторами не поддерживает в принципе. остается либо при каждом обращении по указателю суммировать его с базой блока (подобное возможно за счет одного из «дополнений» в MSVC — тага __based — пример:

void *vpBuffer;
struct llist_t
{
void __based( vpBuffer ) *vpData;
llist_t __based( vpBuffer ) *llNext;
};

) либо перебазировать каждый раз все ссылающиеся на блок указатели, либо как-то эмулировать селекторную систему.

Может я и напишу когда-нибудь замену malloc etc на системных вызовах и __based'ных указателях… только в сложных случаях это может оказаться менее эффективно, чем стандартный способ — регистры закончатся в два раза быстрее. А вот селекторный вариант определенно быстрее — я проверял.

А уж как чудно malloc'омания отражается на функциональности программ… :). В частности, архиваторам нужна опция для указания размера словаря, в первую очередь, именно чтобы его не realloc'ить.

Ассемблер vs C
А вот алгоритмическая часть значительно важнее, потому как ошибки там обычно стоят порядки…

Вот-вот. Как ты думаешь, навязывание мне авторами компилятора flat-модели может влиять на выбор алгоритмов? Или… вспоминается мне, например, прикол, когда я написал макрос для выравнивания функций по parity младшего байта адреса :).

mov bp,offset OpTable
GetNextChar: lodsb
mov ah,0
shl ax,1
add bp,ax
mov bp,[word ss:bp]
inc bp
jp ExecuteOperator
loop GetNextChar
...
ExecuteOperator: jmp bp

Это я интерпретатор писал, еще в XT'шные времена :). Там было некоторое множество операторов, и для их разбора я изобразил trie в виде наборов табличек, индексируемых очередным символом и содержащих либо адрес очередной таблички, либо адрес функции-обработчика. Которые отличались, как видно, по parity младшего байта адреса минус один — это оказалось несколько быстрее всех прочих приходивших мне в голову решений :). Дык вот. Слабо заставить компилятор сей скомпилить функцию по адресу с такими ограничениями? :)

Или более серьезный пример — понадобился мне как-то словарь в виде тернарного дерева. Так вот когда я сделал ветвление после сравнения символов не по больше/меньше, а по parity… можешь себе представить, насколько улучшились характеристики.

Или битовые операции те же. Ты знаешь, что на x86 есть возможность работать с битовыми массивами длины до 2^31, причем в обе «стороны» от указанного адреса? Т.е. команды для этого есть. Типа

mov eax,-10000
m0:
btr [ArrayEnd],eax
inc eax
jnz m0

(это к вопросу о соотношении строк в сишном/асмовском исходниках)

1. Умножение
Для процессора, реализация умножения с выдачей двойного слова на выходе является очень логичной. (Аналогично с делением). Т.е. если сделать не так, то умножение в длинной арифметике придется делать сдвигами — что совсем не рулез :). ЯВУ, который бы это понимал, мне пока не попадался :(. Поэтому большинство арифметических кодеров, использующих деление, дают на несколько сотых процента худшее сжатие. Полкилобайта на метр архива — считай мусор, который обязан своим существованием ограничениям языка :).

Причина: компилятор Си поймет запись

low += cumFreq * (range/= totFreq);

правильно, а вот

low += (cumFreq*range)/totFreq

— увы, нет. Насильное приведение к __int64 вызовет ужасные тормоза… а на асме я запишу

mov eax,cumFreq
mul range
div totFreq

и получу _точный_ результат :).

2. Самомодификация
Были времена, когда это была чуть ли не панацея. После появления кэша кода, увы, с этим стало хуже :). Только вот в случаях, когда тебе нужно вызывать некую функцию на каждой итерации цикла, пока не случится нечто, а потом уже не нужно… Нет, можно, конечно, сделать две копии цикла :) Правда, никакой оптимизатор в этом тебе помогать не захочет. А вручную — задолбаешься синхронно вносить изменения во все копии.

В общем, вот есть у процессора (все еще :) возможность модифицировать код. А из ЯВУ это доступно с трудом, непортабельно etc. Ну и кто в этом виноват? :)

2a. Генерация кода
Это финальная стадия оптимизации. Только в runtime ты можешь знать точно, какой код тебе понадобится — и сгенерить его. Это, к счастью, кое-где все-таки доступно. Но не в сях :) И с оптимизацией там… того :)

3. Передачи управления
Не знаю, как у кого, а у меня редко, но возникает необходимость в использовании функций с несколькими точками _входа_. При использовании рекурсии, в основном, но не только. Сделать это даже на встроенном асме… очень сложно.

Аналогично, изредка требуется возможность вернуться из функции несколько не туда, откуда вызывали. longjmp и setjmp, вон, даже сочинили. Вот только это решение за счет потери производительности :(.

Ну и есть еще один трюк, который я очень люблю… но уж он-то к ЯВУ отношения не имеет точно :)

00000057:
BEDF01 mov si,001DF; загружаем смещение
84BE2202 test [bp][00222],bh; никуда ничего не пишем
^^^^^^
58 pop ax
9C pushf
0E push cs
E8C9FF call 00000002D — (2)
E80200 call 000000069 — (3)
5E pop si
CF iret

0000005B:
BE2202 mov si,00222
^^^^^^
58 pop ax
9C pushf
0E push cs
E8C9FF call 00000002D — (1)
E80200 call 000000069 — (2)
5E pop si
CF iret

Это, конечно, оптимизация по размеру кода в чистом виде, но нередко может помочь и по скорости — все-таки избавляемся от лишнего перехода.

4. Флаги
На большинстве процессоров кроме обычных регистров есть еще и флаги результата и переходы по ним. Никакой их поддержки в ЯВУ не присутствует, а понимать, что

uint tmp=low;
low += cumFreq * (range/= totFreq);
if (low<tmp) Carry++;

нужно компилить как

[...]
add low,eax
adc Carry,0

, не громоздя лишних проверок… Это выше способностей IntelC, по крайней мере :)

5. Стек
Опять же, обычно он есть. Только вот кое-кто его узурпировал для хранения параметров функции. А что можно что-нибудь туда пихать, чтобы выбрать потом в обратном порядке (и дофига других применений можно придумать) — посчитано лишним :).

Не говоря уж о том, что в современных ОС при возникновении прерывания в стек программы ну никак не может ничего записаться. Так что ESP, как бы, можно пользоваться вполне свободно — что очень важно, когда это один из всего восьми доступных регистров. Но увы.

А уж о том, что стековыми операциями можно очень быстро (и удобно) читать что-то из памяти, ходить по связанным структурам по POP ESP etc… лучше и вовсе не вспоминать, чтоб ностальгия не замучала :)

Резюме
Мне очень сложно писать это заключение, потому что переписка, я надеюсь, еще не окончена, а резюме обычно выглядит как подведение итогов… но, imho, рассматривать этот текст надо через призму специфики работы. К примеру, мне достаточно сложно выйти за пределы своего текущего проекта, где основную сложность составляют не вычисления и производительность кода, а отложенная запись данных на жесткий диск и связанные с этим проблемы транзакций, кеширования… тем не менее, это очень полезно — думать за пределами контекста текущих задач.

Но даже если все написанное выше не особенно понятно (после публикации этого текста мне стали приходить письма с общим содержанием вида «какие люди!»), то осознание того, что языки программирования C или C++ не всегда подходят для решения любых задач, очень полезно. Мало того, как можно видеть, эти языки программирования не являются близкими к ассемблеру, хотя это заблуждение является общепринятым.


Нравится
Не нравится

Комментарии

Нет комментариев. Ваш будет первым!