Является ли встроенный язык ассемблера медленнее, чем собственный код 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 ]

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

Да, чаще всего.

Прежде всего, вы исходите из неверного предположения, что язык низкого уровня (в данном случае ассемблер) всегда будет генерировать более быстрый код, чем язык высокого уровня (в данном случае C ++ и C).дело).Это неправда.Всегда ли код C быстрее, чем код Java?Нет, потому что есть другая переменная: программист.То, как вы пишете код и знание деталей архитектуры, сильно влияет на производительность (как вы видели в этом случае).

Вы можете всегда создать пример, где код сборки вручную лучше, чем скомпилированный код, но обычно это вымышленный пример или отдельная подпрограмма, а не истинная программа из 500 000+ строк кода C ++).Я думаю, что компиляторы будут производить лучший ассемблерный код в 95% раз, а иногда, только в редких случаях, вам может понадобиться написать ассемблерный код для небольшой, короткой, высокой производительности , производительностикритические подпрограммы или когда вам нужно получить доступ к функциям, которые ваш любимый язык высокого уровня не раскрывает.Хотите прикосновения этой сложности?Прочитайте этот удивительный ответ здесь, на SO.

Почему это?

Прежде всего потому, что компиляторы могут выполнять оптимизации, которые мы даже не можем себе представить (см. этот короткий список ), и они будут делать их в секунд (когда нам могут потребоваться дни ).

Когда вы кодируете в ассемблере, вы должны создавать четко определенные функции с четко определенным интерфейсом вызовов.Однако они могут принимать во внимание оптимизация всей программы и межпроцедурная оптимизация , например распределение регистров , постоянное распространение , исключение общих подвыражений , планирование команд и другие сложные, неочевидные оптимизации (например, модель многогранника ).На архитектуре RISC парни перестали беспокоиться об этом много лет назад (например, очень трудно настроить расписание команд вручную ), а современные CISC процессоры имеют оченьlong pipelines тоже.

Для некоторых сложных микроконтроллеров даже библиотеки system написаны на C вместо ассемблера, потому что их компиляторы производят лучший (и простой в обслуживании) конечный код.

Компиляторы иногда могут автоматически использовать некоторые инструкции MMX / SIMDx сами по себе, и если вы их не используете, вы просто не можете их сравнивать (другие ответы уже рассмотрели ваш код сборки очень хорошо).Просто для циклов это короткий список оптимизаций цикла из того, что обычно проверено компилятором (как вы думаете, вы могли бы сделать это самостоятельно, когда ваш график был выбран дляПрограмма на C #?) Если вы пишете что-то на ассемблере, я думаю, вам нужно рассмотреть хотя бы некоторые простых оптимизаций .Пример учебника для массивов: развернуть цикл (его размер известен во время компиляции).Сделайте это и запустите тест снова.

В наши дни очень редко нужно использовать язык ассемблера по другой причине: множество различных процессоров .Вы хотите поддержать их всех?Каждый имеет определенную микроархитектуру и несколько определенных наборов инструкций .Они имеют разное количество функциональных блоков, и необходимо подготовить инструкции по сборке, чтобы все они были заняты .Если вы пишете на C, вы можете использовать PGO , но при сборке вам потребуются глубокие знания этой конкретной архитектуры (и переосмыслите и переделайте все для другой архитектуры ).Для небольших задач компилятор обычно делает это лучше, а для сложных задач обычно работа не оплачивается (и компилятор может лучше в любом случае).

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

Все это говорит о том, что даже когда вы можете создавать сборочный код в 5-10 раз быстрее, вы должны спросить своих клиентов, предпочитают ли они платить одну неделю вашего времени купите на 50 $ более быстрый процессор . Чрезвычайная оптимизация чаще всего (особенно в LOB-приложениях) от большинства из нас просто не требуется.

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

Ваш ассемблерный код неоптимальный и может быть улучшен:

  • Вы нажимаете и выталкиваете регистр ( EDX ) во внутреннем цикле.Это должно быть удалено из цикла.
  • Вы перезагружаете указатели массива на каждой итерации цикла.Это должно выйти из цикла.
  • Вы используете инструкцию loop, которая, как известно, очень медленно работает на большинстве современных процессоров (возможно, в результате использования древней сборочной книги *)
  • Вы не пользуетесь возможностью ручного развертывания петли.
  • Вы не используете доступные SIMD инструкции.

Так что, если вы не сильноулучшить свои навыки в отношении ассемблера, для вас не имеет смысла писать код на ассемблере для производительности.

* Конечно, я не знаю, действительно ли вы получили инструкцию loop из древней сборкикнига.Но вы почти никогда не видите его в коде реального мира, так как каждый компилятор достаточно умен, чтобы не испускать loop, вы видите это только в ИМХО плохих и устаревших книгах.

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

Даже до углубления в сборку существуют преобразования кода, которые существуют на более высоком уровне.

static int const 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];
    }
  }
}

может быть преобразовано в Loop Rotation :

static int const TIMES = 100000;

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

, что намного лучше с точки зрения локальности памяти.

Это можно оптимизировать и дальше, выполнение a += b X раз эквивалентно выполнению a += X * b, поэтому мы получаем:

static int const TIMES = 100000;

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

однако кажется, что мой любимый оптимизатор (LLVM) не выполняет это преобразование.

[править] Я обнаружил, что преобразование выполняется, если у нас есть квалификатор restrict для xи y.В самом деле, без этого ограничения x[j] и y[j] могут иметь псевдоним в одном и том же месте, что делает это преобразование ошибочным. [конец редактирования]

В любом случае, это , я думаю, оптимизированная версия C.Уже намного проще.Исходя из этого, вот мой взлом в ASM (я позволил Clang генерировать его, я бесполезен в этом):

calcuAsm:                               # @calcuAsm
.Ltmp0:
    .cfi_startproc
# BB#0:
    testl   %edx, %edx
    jle .LBB0_2
    .align  16, 0x90
.LBB0_1:                                # %.lr.ph
                                        # =>This Inner Loop Header: Depth=1
    imull   $100000, (%rsi), %eax   # imm = 0x186A0
    addl    %eax, (%rdi)
    addq    $4, %rsi
    addq    $4, %rdi
    decl    %edx
    jne .LBB0_1
.LBB0_2:                                # %._crit_edge
    ret
.Ltmp1:
    .size   calcuAsm, .Ltmp1-calcuAsm
.Ltmp2:
    .cfi_endproc

Боюсь, я не понимаю, откуда все эти инструкции, однако вывсегда можно повеселиться и попробовать посмотреть, как она сравнивается ... но я бы все же использовал оптимизированную версию C, а не сборочную, в коде, гораздо более переносимую.

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

Краткий ответ: да.

Длинный ответ: да, если вы действительно не знаете, что делаете, и у вас нет причины для этого.

33 голосов
/ 09 марта 2012

Я исправил свой код asm:

  __asm
{   
    mov ebx,TIMES
 start:
    mov ecx,lengthOfArray
    mov esi,x
    shr ecx,1
    mov edi,y
label:
    movq mm0,QWORD PTR[esi]
    paddd mm0,QWORD PTR[edi]
    add edi,8
    movq QWORD PTR[esi],mm0
    add esi,8
    dec ecx 
    jnz label
    dec ebx
    jnz start
};

Результаты для релизной версии:

 Function of assembly version: 81
 Function of C++ version: 161

Код ассемблера в режиме релиза почти в 2 раза быстрее, чем C ++.

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

Означает ли это, что я не должен доверять производительности языка ассемблера, написанного моими руками

Да, это именно то, что оно означает, и это верно для каждого язык.Если вы не знаете, как писать эффективный код на языке X, то вы не должны доверять своей способности писать эффективный код на X. И поэтому, если вы хотите эффективный код, вам следует использовать другой язык.

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

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

В настоящее время единственной причиной использования ассемблера является использование некоторых функций, недоступных для языка.

Это относится к:

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

Но современные компиляторы достаточно умны, они могут даже заменить два отдельных оператора, например d = a / b; r = a % b; с одной инструкцией, которая вычисляет деление и остаток за один раз, если он доступен, даже если в C такого оператора нет.

19 голосов
/ 09 марта 2012

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

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

По мере того, как ваш опыт возрастает, вы узнаете, когда и где его использовать (обычно это самые тесные, самые внутренние циклы в вашем коде после глубокой оптимизации на алгоритмическом уровне).

Для вдохновения я бы порекомендовал вам посмотреть статьи Майкла Абраша (если вы от него не слышали, он гуру по оптимизации; он даже сотрудничал с Джоном Кармаком в оптимизации программного обеспечения Quake рендерер!)

«нет самого быстрого кода» - Майкл Абраш

14 голосов
/ 09 марта 2012

Я изменил код asm:

 __asm
{ 
    mov ebx,TIMES
 start:
    mov ecx,lengthOfArray
    mov esi,x
    shr ecx,2
    mov edi,y
label:
    mov eax,DWORD PTR [esi]
    add eax,DWORD PTR [edi]
    add edi,4   
    dec ecx 
    mov DWORD PTR [esi],eax
    add esi,4
    test ecx,ecx
    jnz label
    dec ebx
    test ebx,ebx
    jnz start
};

Результаты для версии выпуска:

 Function of assembly version: 41
 Function of C++ version: 161

Код сборки в режиме выпуска почти в 4 раза быстрее, чем C ++.ИМХо, скорость сборки кода зависит от программатора

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

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

Например, даже если я не уверен, что это больше правильно :):

Выполнение:

mov eax,0

стоит больше циклов, чем

xor eax,eax

, который делает то же самое.

Компилятор знает все эти приемы и использует их.

...