Является ли встроенный язык ассемблера медленнее, чем собственный код C ++? - PullRequest
171 голосов
/ 07 марта 2012

Я попытался сравнить производительность встроенного языка ассемблера и кода C ++, поэтому я написал функцию, которая добавляет два массива размером 2000 для 100000 раз. Вот код:

#define TIMES 100000
void calcuC(int *x,int *y,int length)
{
    for(int i = 0; i < TIMES; i++)
    {
        for(int j = 0; j < length; j++)
            x[j] += y[j];
    }
}


void calcuAsm(int *x,int *y,int lengthOfArray)
{
    __asm
    {
        mov edi,TIMES
        start:
        mov esi,0
        mov ecx,lengthOfArray
        label:
        mov edx,x
        push edx
        mov eax,DWORD PTR [edx + esi*4]
        mov edx,y
        mov ebx,DWORD PTR [edx + esi*4]
        add eax,ebx
        pop edx
        mov [edx + esi*4],eax
        inc esi
        loop label
        dec edi
        cmp edi,0
        jnz start
    };
}

Вот main():

int main() {
    bool errorOccured = false;
    setbuf(stdout,NULL);
    int *xC,*xAsm,*yC,*yAsm;
    xC = new int[2000];
    xAsm = new int[2000];
    yC = new int[2000];
    yAsm = new int[2000];
    for(int i = 0; i < 2000; i++)
    {
        xC[i] = 0;
        xAsm[i] = 0;
        yC[i] = i;
        yAsm[i] = i;
    }
    time_t start = clock();
    calcuC(xC,yC,2000);

    //    calcuAsm(xAsm,yAsm,2000);
    //    for(int i = 0; i < 2000; i++)
    //    {
    //        if(xC[i] != xAsm[i])
    //        {
    //            cout<<"xC["<<i<<"]="<<xC[i]<<" "<<"xAsm["<<i<<"]="<<xAsm[i]<<endl;
    //            errorOccured = true;
    //            break;
    //        }
    //    }
    //    if(errorOccured)
    //        cout<<"Error occurs!"<<endl;
    //    else
    //        cout<<"Works fine!"<<endl;

    time_t end = clock();

    //    cout<<"time = "<<(float)(end - start) / CLOCKS_PER_SEC<<"\n";

    cout<<"time = "<<end - start<<endl;
    return 0;
}

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

А вот и результат.

Функция версии сборки:

Debug   Release
---------------
732        668
733        680
659        672
667        675
684        694
Average:   677

Функция версии C ++:

Debug     Release
-----------------
1068      168
 999      166
1072      231
1002      166
1114      183
Average:  182

Код C ++ в режиме выпуска почти в 3,7 раза быстрее кода сборки. Почему?

Полагаю, что написанный мной ассемблерный код не так эффективен, как сгенерированный GCC. Обычному программисту, как я, трудно писать код быстрее, чем его противник, сгенерированный компилятором. Значит ли это, что я не должен доверять производительности написанного моими руками ассемблера, сосредоточиться на C ++ и забыть о языке ассемблера?

Ответы [ 22 ]

12 голосов
/ 11 марта 2012

это очень интересная тема!
Я сменил MMX на SSE в коде Саши
Вот мои результаты:

Function of C++ version:      315
Function of assembly(simply): 312
Function of assembly  (MMX):  136
Function of assembly  (SSE):  62

Код сборки с SSE в 5 раз быстрее, чемC ++

10 голосов
/ 07 марта 2012

Компилятор победил вас. Я попробую, но не буду давать никаких гарантий. Я предполагаю, что «умножение» на TIMES предназначено для того, чтобы сделать его более актуальным тестом производительности, что y и x выровнены по 16, а length - ненулевое кратное 4. Это, вероятно все верно в любом случае.

  mov ecx,length
  lea esi,[y+4*ecx]
  lea edi,[x+4*ecx]
  neg ecx
loop:
  movdqa xmm0,[esi+4*ecx]
  paddd xmm0,[edi+4*ecx]
  movdqa [edi+4*ecx],xmm0
  add ecx,4
  jnz loop

Как я уже сказал, я не даю никаких гарантий. Но я буду удивлен, если это будет сделано намного быстрее - узким местом здесь является пропускная способность памяти, даже если все это удар L1.

6 голосов
/ 07 марта 2012

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

Конечно, можно побить компилятор, особенно если это небольшая локализованная часть кода, мне даже пришлось сделать это самому, чтобы получить прибл. Ускорение в 4 раза, но в этом случае мы должны полагаться на хорошее знание аппаратного обеспечения и многочисленные, казалось бы, нелогичные уловки.

5 голосов
/ 12 марта 2012

Как компилятор я бы заменил цикл с фиксированным размером на множество задач выполнения.

int a = 10;
for (int i = 0; i < 3; i += 1) {
    a = a + i;
}

будет производить

int a = 10;
a = a + 0;
a = a + 1;
a = a + 2;

и в конечном итоге он будет знать, что "a= а + 0; "бесполезен, поэтому он удалит эту строку.Надеюсь, что-то в вашей голове теперь готовы прикрепить некоторые варианты оптимизации в качестве комментария.Все эти очень эффективные оптимизации сделают скомпилированный язык быстрее.

4 голосов
/ 08 марта 2012

Мне нравится этот пример, потому что он демонстрирует важный урок о низкоуровневом коде. Да, вы можете писать ассемблер так же быстро, как ваш C-код. Это тавтологически верно, но не обязательно означает что-либо. Ясно, что кто-то может, иначе ассемблер не узнает о соответствующих оптимизациях.

Аналогично, тот же принцип применяется, когда вы поднимаетесь по иерархии языковой абстракции. Да, вы можете написать синтаксический анализатор на C, который работает так же быстро, как быстрый и грязный Perl-скрипт, и многие это делают. Но это не значит, что, поскольку вы использовали C, ваш код будет быстрым. Во многих случаях языки высокого уровня выполняют оптимизации, которые вы, возможно, даже не рассматривали.

4 голосов
/ 07 марта 2012

Это именно то, что это значит. Оставьте микрооптимизации для компилятора.

3 голосов
/ 05 июля 2014

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

inline void set_port_high(void)
{
  (*((volatile unsigned char*)0x40001204) = 0xFF);
}

Компилятор для 32-битного кода ARM, учитывая вышеизложенное, скорее всего будет отображать что-то вроде:

ldr  r0,=0x40001204
mov  r1,#0
strb r1,[r0]
[a fourth word somewhere holding the constant 0x40001204]

или, возможно,

ldr  r0,=0x40001000  ; Some assemblers like to round pointer loads to multiples of 4096
mov  r1,#0
strb r1,[r0+0x204]
[a fourth word somewhere holding the constant 0x40001000]

Это может быть немного оптимизировано в собранном вручную коде, например:

ldr  r0,=0x400011FF
strb r0,[r0+5]
[a third word somewhere holding the constant 0x400011FF]

или

mvn  r0,#0xC0       ; Load with 0x3FFFFFFF
add  r0,r0,#0x1200  ; Add 0x1200, yielding 0x400011FF
strb r0,[r0+5]

Для обоих подходов, собранных вручную, потребуется 12 байт пространства кода, а не 16; последний заменит «нагрузку» на «добавление», что на ARM7-TDMI выполнит два цикла быстрее. Если бы код собирался выполняться в контексте, где r0 не знал / не заботился, версии на ассемблере были бы несколько лучше, чем скомпилированная версия. С другой стороны, предположим, что компилятор знал, что какой-то регистр [например, r5] должен был содержать значение, которое было в пределах 2047 байтов от желаемого адреса 0x40001204 [например, 0x40001000] и далее знал, что какой-то другой регистр [например, r7] собирался содержать значение, младшие биты которого были 0xFF. В этом случае компилятор может оптимизировать C-версию кода до простого:

strb r7,[r5+0x204]

Гораздо короче и быстрее, чем даже оптимизированный вручную ассемблерный код. Далее, предположим, что set_port_high произошел в контексте:

int temp = function1();
set_port_high();
function2(temp); // Assume temp is not used after this

Совсем неправдоподобно при кодировании для встроенной системы. Если в коде ассемблера записано set_port_high, компилятору придется переместить r0 (который содержит возвращаемое значение из function1) куда-то еще до вызова кода ассемблера, а затем переместить это значение обратно в r0 (поскольку function2 будет ожидать свой первый параметр в r0), поэтому для «оптимизированного» кода сборки потребуется пять инструкций. Даже если компилятор не знает ни одного регистра, содержащего адрес или значение для хранения, его версия из четырех команд (которую он может адаптировать для использования любых доступных регистров - не обязательно r0 и r1) превзойдет «оптимизированную» сборку языковая версия. Если бы компилятор имел необходимые адреса и данные в r5 и r7, как описано ранее, function1 не изменил бы эти регистры, и, таким образом, он мог бы заменить set_port_high одной инструкцией strb - на четыре команды меньше и быстрее , чем «оптимизированный вручную» код сборки.

Обратите внимание, что оптимизированный вручную ассемблерный код может часто опережать компилятор в тех случаях, когда программист знает точный ход программы, но компиляторы работают лучше в тех случаях, когда фрагмент кода написан до того, как известен его контекст, или когда один фрагмент исходного кода код может быть вызван из нескольких контекстов [если set_port_high используется в пятидесяти различных местах кода, компилятор может самостоятельно решить для каждого из них, как лучше его расширить].

В целом, я хотел бы предположить, что ассемблер имеет тенденцию давать наибольшие улучшения производительности в тех случаях, когда к каждому куску кода можно подходить из очень ограниченного числа контекстов, и это может нанести ущерб производительности в местах, где К фрагменту кода можно подходить из разных контекстов. Интересно (и удобно), что случаи, когда сборка наиболее выгодна для производительности, часто бывают такими, где код наиболее прост и удобен для чтения. Места, в которых код на ассемблере превращается в неприятный беспорядок, - это те места, где написание на ассемблере дает наименьшее преимущество в производительности.

[Незначительное примечание: в некоторых местах ассемблерный код может использоваться для создания гипероптимизированного тупого беспорядка; например, один фрагмент кода, который я сделал для ARM, должен был извлечь слово из ОЗУ и выполнить одну из примерно двенадцати подпрограмм, основанных на верхних шести битах значения (многие значения сопоставлены одной и той же подпрограмме). Я думаю, что я оптимизировал этот код до чего-то вроде:

ldrh  r0,[r1],#2! ; Fetch with post-increment
ldrb  r1,[r8,r0 asr #10]
sub   pc,r8,r1,asl #2

Регистр r8 всегда содержал адрес основной таблицы диспетчеризации (в цикле, где код тратит 98% своего времени, ничто никогда не использовало его для каких-либо других целей);все 64 записи относятся к адресам в 256 байтах, предшествующих ему.Поскольку основной цикл имел в большинстве случаев жесткий предел времени выполнения около 60 циклов, выборка и отправка из девяти циклов были очень полезны для достижения этой цели.Использование таблицы из 256 32-битных адресов было бы на один цикл быстрее, но поглотило бы 1 КБ очень ценной оперативной памяти [флэш-память добавила бы более одного состояния ожидания].Использование 64 32-битных адресов потребовало бы добавления инструкции для маскировки некоторых битов из извлеченного слова, и все равно поглотило бы на 192 байт больше, чем таблица, которую я фактически использовал.Использование таблицы 8-битных смещений позволило получить очень компактный и быстрый код, но я не ожидал, что компилятор когда-нибудь придумает;Я также не ожидал бы, что компилятор выделит регистр «полный рабочий день» для хранения адреса таблицы.

Приведенный выше код был разработан для работы в качестве автономной системы;он может периодически вызывать код C, но только в определенные моменты, когда аппаратное обеспечение, с которым оно обменивалось данными, может безопасно переводиться в состояние «ожидания» на два интервала примерно в одну миллисекунду каждые 16 мс.

2 голосов
/ 10 марта 2014

В последнее время все мои оптимизации скорости заменяли медленный код с поврежденным мозгом просто разумным кодом.Но так как скорость была действительно критической, и я приложил серьезные усилия, чтобы сделать что-то быстрое, результатом всегда был итеративный процесс, где каждая итерация давала более глубокое понимание проблемы, находя способы решения проблемы с меньшим количеством операций.Конечная скорость всегда зависела от того, насколько я понял проблему.Если бы на каком-то этапе я использовал ассемблерный код или код C, который был чрезмерно оптимизирован, процесс поиска лучшего решения пострадал бы, а конечный результат был бы медленнее.

2 голосов
/ 08 февраля 2017

C ++ быстрее, если вы не используете язык ассемблера с более глубоким знание с правильным путем.

Когда я кодирую в ASM, я реорганизую инструкции вручную, чтобы ЦП мог выполнять больше их параллельно, когда это логически возможно. Например, я почти не использую ОЗУ, когда кодирую в ASM: в ASM может быть более 20000 строк кода, и я никогда не использовал push / pop.

Вы могли бы потенциально перейти в середину кода операции, чтобы самостоятельно модифицировать код и поведение без возможного штрафа за изменение кода. Доступ к регистрам занимает 1 такт (иногда занимает 0,25 тактов) процессора. Доступ к ОЗУ может занять сотни.

В моем последнем приключении с ASM я ни разу не использовал ОЗУ для хранения переменной (для тысяч строк ASM). ASM может быть потенциально невообразимо быстрее, чем C ++. Но это зависит от множества переменных факторов, таких как:

1. I was writing my apps to run on the bare metal.
2. I was writing my own boot loader that was starting my programs in ASM so there was no OS management in the middle.

Я сейчас изучаю C # и C ++, потому что я понял, что производительность имеет значение !! В свободное время вы можете попытаться создать самые быстрые мыслимые программы, используя только ASM. Но для того, чтобы что-то произвести, используйте язык высокого уровня.

Например, последняя программа, которую я написал, использовала JS и GLSL, и я никогда не замечал проблем с производительностью, даже говоря о JS, которая работает медленно. Это потому, что простая концепция программирования GPU для 3D делает скорость языка, который посылает команды в GPU, почти неактуальной.

Скорость только ассемблера на голом металле неопровержима. Может ли это быть еще медленнее в C ++? - Это может быть потому, что вы пишете ассемблерный код с компилятором, для начала не использующим ассемблер.

Мой личный совет - никогда не писать ассемблерный код, если вы можете его избежать, даже если я люблю ассемблер.

0 голосов
/ 21 декабря 2016

Все ответы здесь, кажется, исключают один аспект: иногда мы не пишем код для достижения конкретной цели, но ради просто удовольствия этого.Возможно, это не экономно тратить время на это, но, возможно, нет большего удовлетворения, чем победа по быстродействующему фрагменту кода, оптимизированному для компилятора, с помощью альтернативной альтернативы asm.

...