Является ли встроенный язык ассемблера медленнее, чем собственный код C ++?

183

Я попытался сравнить производительность встроенного языка ассемблера и кода 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 ++ и забыть о языке ассемблера?

user957121
источник
29
Довольно много. Сборка с ручным кодированием подходит в некоторых случаях, но необходимо позаботиться о том, чтобы версия сборки действительно была быстрее, чем та, которая может быть достигнута на языке более высокого уровня.
Магнус Хофф
161
Возможно, вам будет полезно изучить код, сгенерированный компилятором, и попытаться понять, почему он быстрее вашей версии сборки.
Пол Р
34
Да, похоже, компилятор лучше пишет asm, чем вы. Современные компиляторы действительно хороши.
Дэвид Хеффернан
20
Вы смотрели сборку GCC? Его возможный GCC использовал инструкции MMX. Ваша функция очень параллельна - вы можете потенциально использовать N процессоров для вычисления суммы в 1 / N-й раз. Попробуйте функцию, в которой нет надежды на распараллеливание.
Крис
11
Хм, я бы ожидал, что хороший компилятор сделает это примерно в 100000 раз быстрее ...
PlasmaHH

Ответы:

261

Да, в большинстве случаев.

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

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

Почему это?

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

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

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

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

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

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

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

Адриано Репетти
источник
9
Конечно нет. Я думаю, что это лучше, чем 95% людей в 99% случаев. Иногда, потому что это просто дорого (из-за сложной математики) или затрат времени (потом опять дорого). Иногда, потому что мы просто забыли об оптимизации ...
Адриано Репетти
62
@ ja72 - нет, писать код не лучше . Это лучше при оптимизации кода.
Майк Баранчак
14
Это нелогично, пока вы действительно не обдумаете это. Таким же образом, машины на основе виртуальных машин начинают проводить оптимизацию во время выполнения, которую компиляторы просто не имеют информации, которую нужно сделать.
Билл К
6
@ M28: компиляторы могут использовать одни и те же инструкции. Несомненно, они платят за это с точки зрения двоичного размера (потому что они должны предоставить запасной путь в случае, если эти инструкции не поддерживаются). Кроме того, по большей части «новые инструкции», которые будут добавлены, в любом случае являются инструкциями SMID, которые виртуальные машины и компиляторы используют довольно ужасно. Виртуальные машины оплачивают эту функцию тем, что им приходится компилировать код при запуске.
Билли ОНил
9
@BillK: PGO делает то же самое для компиляторов.
Билли Онеал
194

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

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

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

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

Гюнтер Пьез
источник
компиляторы могут по-прежнему генерировать loop(и многие «устаревшие» инструкции), если вы оптимизируете по размеру
phuclv
1
@phuclv ну да, но оригинальный вопрос был именно о скорости, а не о размере.
IGR94
60

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

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 += bX раз эквивалентно тому, что 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спецификатор to 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, а не сборочную, в коде, гораздо более портативный.

Матье М.
источник
Спасибо за ваш ответ. Ну, это немного сбивает с толку, что когда я взял класс с именем «Принципы компилятора», я узнал, что компилятор оптимизирует наш код разными способами. Означает ли это, что нам нужно оптимизировать наш код вручную? Можем ли мы работать лучше, чем компилятор? Это вопрос, который всегда смущает меня.
user957121
2
@ user957121: мы можем оптимизировать его лучше, когда у нас будет больше информации. В частности, вот что мешает компилятору - это возможное наложение между xи y. То есть компилятор не может быть уверен, что для всех i,jу [0, length)нас есть x + i != y + j. Если есть перекрытие, то оптимизация невозможна. Язык C ввел restrictключевое слово, чтобы сообщить компилятору, что два указателя не могут использовать псевдонимы, однако он не работает для массивов, потому что они все еще могут перекрываться, даже если они не имеют точно псевдоним.
Матье М.
Текущие GCC и Clang автоматически векторизуются (после проверки на отсутствие перекрытия, если вы пропустите __restrict). SSE2 является базовой для x86-64, и с тасовкой SSE2 может делать 2x 32-битные умножения одновременно (производя 64-битные продукты, следовательно, тасование, чтобы собрать результаты обратно). godbolt.org/z/r7F_uo . (SSE4.1 необходим для pmulld: упакованный 32x32 => 32-битное умножение). В GCC есть хитрый прием преобразования постоянных целочисленных множителей в сдвиг / сложение (и / или вычитание), что хорошо для умножителей с несколькими установленными битами. Переполненный в случайном порядке код Clang будет препятствовать пропускной способности в процессорах Intel.
Питер Кордес
41

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

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

Оливер Чарльзуорт
источник
3
и только в том случае, если вы запустили инструмент профилирования на уровне сборки, такой как vtune для чипов Intel, чтобы увидеть, где вы можете улучшить ситуацию
Марк Маллин
1
Это технически отвечает на вопрос, но также совершенно бесполезно. А-1 от меня.
Навин
2
Очень длинный ответ: «Да, если вы не хотите менять весь код всякий раз, когда используется новый (er) процессор. Выберите лучший алгоритм, но позвольте компилятору выполнить оптимизацию»
Tommylee2k,
35

Я исправил свой код 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 ++.

саша
источник
18
Теперь, если вы начнете использовать SSE вместо MMX (имя регистра xmm0вместо mm0), вы получите еще одно ускорение в два раза ;-)
Гюнтер Пиз
8
Я изменил, получил 41 для сборки версии. Это в 4 раза быстрее :)
Саша
3
также может получить до 5% больше, если использовать все регистры xmm
Саша
7
Теперь, если вы думаете о времени, которое вам на самом деле потребовалось: сборка, около 10 часов или около того? C ++, несколько минут, я думаю? Здесь есть явный победитель, если только это не критичный для производительности код.
Калимо
1
Хороший компилятор будет уже автоматически векторизовать с paddd xmm(после проверки совпадения между xи y, так как вы не использовали int *__restrict x). Например, gcc делает это: godbolt.org/z/c2JG0- . Или после встраивания в mainнего не нужно проверять наличие совпадений, потому что он может видеть распределение и доказывать, что они не перекрываются. (И в некоторых реализациях x86-64 можно было бы принять 16-байтовое выравнивание, что не относится к автономному определению.) И если вы скомпилируете gcc -O3 -march=native, вы можете получить 256-битный или 512-битный векторизации.
Питер Кордес
24

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

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

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

jalf
источник
2
Я думаю, что для того, чтобы написать, что особенно для современного процессора x86, чрезвычайно сложно написать эффективный код сборки из-за наличия конвейеров, нескольких исполнительных блоков и других уловок внутри каждого ядра. Написание кода, который уравновешивает использование всех этих ресурсов для достижения максимальной скорости выполнения, часто приводит к коду с несложной логикой, которая "не должна" быть быстрой в соответствии с "обычной" мудростью сборки. Но для менее сложных процессоров, по моему опыту, генерация кода компилятора C может быть значительно улучшена.
Олоф Форшелл
4
Код компиляторов может быть , как правило , быть улучшен, даже на современном x86 CPU. Но вы должны хорошо понимать процессор, что сложнее сделать с современным процессором x86. Это моя точка зрения. Если вы не понимаете оборудование, на которое вы нацелены, вы не сможете оптимизировать его. И тогда компилятор, скорее всего, справится лучше
jalf
1
И если вы действительно хотите оторваться от компилятора, вы должны быть креативными и оптимизировать так, как этого не может компилятор. Это компромисс между временем и вознаграждением, поэтому C является языком сценариев для одних и промежуточным кодом для языков более высокого уровня для других. Для меня, однако, сборка больше для удовольствия :). очень похоже на grc.com/smgassembly.htm
Hawken
22

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

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

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

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

Фортран
источник
10
Есть и другие места для ASM, кроме этих двух. А именно, библиотека bignum обычно будет значительно быстрее в ASM, чем в C, благодаря наличию доступа для переноса флагов и верхней части умножения и тому подобного. Вы можете делать это и в переносном C, но они очень медленные.
Mooing Duck
@MooingDuck Это можно рассматривать как доступ к аппаратным аппаратным возможностям, которые не доступны непосредственно в языке ... Но пока вы просто переводите высокоуровневый код в сборку вручную, компилятор вас побьет.
Фортран
1
это так, но это не программирование ядра и не зависит от поставщика. Хотя с небольшими рабочими изменениями, он может легко попасть в любую категорию. Я полагаю, что ASM, когда вы хотите производительность команд процессора, которые не имеют отображения C.
Mooing Duck
1
@fortran По сути, вы просто говорите, что если вы не оптимизируете свой код, это будет не так быстро, как код, оптимизированный компилятором. Оптимизация - это причина, по которой можно написать сборку. Если вы имеете в виду перевод, а затем оптимизацию, то нет причин, по которым компилятор побьет вас, если вы не умеете оптимизировать сборку. Таким образом, чтобы победить компилятор, вы должны оптимизировать его так, как не может компилятор. Это довольно очевидно. Единственная причина написать ассемблер, если вы лучше компилятора / интерпретатора . Это всегда было практической причиной, чтобы написать сборку.
Хоукен
1
Просто скажу: Clang имеет доступ к флагам переноса, 128-битному умножению и т. Д. Через встроенные функции. И он может интегрировать все это в свои обычные алгоритмы оптимизации.
gnasher729
19

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

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

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

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

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


источник
2
Я считаю, что одна из книг Майкла Абраша - черная книга по графическому программированию. Но он не единственный, кто использует сборку, Крис Сойер сам написал первые две игры магната на американских горках в сборке.
Hawken
14

Я изменил код 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 ++. IMHo, скорость сборки кода зависит от программиста

саша
источник
Да, мой код действительно должен быть оптимизирован. Хорошая работа для вас и спасибо!
user957121
5
Это в четыре раза быстрее, потому что вы выполняете только четверть работы :-) Это shr ecx,2излишне, потому что длина массива уже задана, intа не в байтах. Таким образом, вы в основном достигаете той же скорости. Вы можете попробовать padddответить от Гарольда, это действительно будет быстрее.
Гюнтер Пиз
13

это очень интересная тема!
Я изменил 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 ++

salaoshi
источник
12

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

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

Выполнение:

mov eax,0

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

xor eax,eax

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

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

Nuno_147
источник
4
Все еще верно, см. Stackoverflow.com/questions/1396527/… . Не из-за используемых циклов, а из-за уменьшенного объема памяти.
Гюнтер Пьез
10

Компилятор победил тебя. Я попробую, но не буду давать никаких гарантий. Я буду считать , что «умножение» на 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.

Гарольд
источник
Я думаю, что сложная адресация замедляет ваш код, если вы измените код на, mov ecx, length, lea ecx,[ecx*4], mov eax,16... add ecx,eaxа затем просто будете использовать [esi + ecx] везде, где вы будете избегать остановки одного цикла на инструкцию, ускоряя циклы. (Если у вас последняя версия Skylake, это не относится). Добавление reg, reg только делает цикл более тесным, что может или не может помочь.
Йохан
@ Джохан, это не должно быть срывом, просто дополнительная задержка цикла, но, конечно, не повредит, если его не будет .. Я написал этот код для Core2, у которого не было этой проблемы. Разве r + r тоже не "сложный", кстати?
Гарольд
7

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

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

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

ВСЗ
источник
3
Я думаю, что это зависит от языка и компилятора. Я могу представить себе чрезвычайно неэффективный компилятор C, чей вывод может быть легко побежден простой сборкой, написанной человеком. GCC, не так много.
Кейси Родармор
С такими компиляторами C / ++, и всего лишь 3 из них, они, как правило, хорошо справляются со своими задачами. В определенных обстоятельствах все еще (очень) возможно, что рукописная сборка будет быстрее; многие математические библиотеки переводятся в asm, чтобы лучше обрабатывать множественные / широкие значения. Так что, хотя гарантированный немного слишком силен, вполне вероятно.
ssube
@peachykeen: Я не имел в виду, что сборка гарантированно будет медленнее, чем C ++ в целом. Я имел в виду эту «гарантию» в том случае, если у вас есть код C ++ и вслепую переводите его построчно в сборку. Прочитайте последний абзац моего ответа тоже
vsz
5

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

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

Miah
источник
4
И если он не aявляется изменчивым, есть хороший шанс, что компилятор просто сделает это int a = 13;с самого начала.
вс
4

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

Лучиан Григоре
источник
4

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

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

tylerl
источник
3

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

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_highstrbчетыре инструкции меньше и быстрее, чем «оптимизированный вручную» код сборки.

Обратите внимание, что оптимизированный вручную ассемблерный код может часто превосходить компилятор в тех случаях, когда программист знает точный поток программы, но компиляторы работают лучше в тех случаях, когда фрагмент кода написан до того, как известен его контекст, или когда один фрагмент исходного кода может быть вызывается из нескольких контекстов [если 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 мс.

Supercat
источник
2

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

gnasher729
источник
2

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 ++? - Это может быть потому, что вы пишете ассемблерный код с компилятором, не использующим ассемблер для начала.

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


источник
1

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

madoki
источник
Когда вы просто хотите побить компилятор, обычно проще взять его вывод asm для вашей функции и превратить его в отдельную функцию asm, которую вы настраиваете. Использование встроенного asm - это дополнительная работа, чтобы правильно настроить интерфейс между C ++ и asm и убедиться, что он компилируется в оптимальный код. (Но, по крайней мере, когда вы делаете это просто для удовольствия, вам не нужно беспокоиться об этом, побеждая оптимизацию, такую ​​как постоянное распространение, когда функция встраивается во что-то другое. Gcc.gnu.org/wiki/DontUseInlineAsm ).
Питер Кордес
См. Также гипотезу Коллатца C ++ против рукописных asm Q & A для получения дополнительной информации об избиении компилятора для развлечения :), а также предложения о том, как использовать то, что вы узнали, для модификации C ++, чтобы помочь компилятору создавать лучший код.
Питер Кордес
@PeterCordes Итак, что вы говорите, вы согласны.
Мадоки
1
Да, asm - это весело, за исключением того, что встроенный asm обычно является неправильным выбором даже для игры. Технически, это вопрос inline-asm, поэтому было бы хорошо, по крайней мере, ответить на этот вопрос в своем ответе. Кроме того, это действительно больше комментарий, чем ответ.
Питер Кордес
ОК согласился. Раньше я был единственным парнем, но это были 80-е.
Мадоки
-2

Компилятор c ++ после оптимизации на организационном уровне генерирует код, который будет использовать встроенные функции целевого процессора. HLL никогда не будет опережать или превосходить ассемблер по нескольким причинам; 1.) HLL будет скомпилирован и выведен с кодом Accessor, проверкой границ и, возможно, встроенным сборщиком мусора (ранее рассматривавшим область действия в манере ООП), все требующие циклов (триггеры и флопсы). В наши дни HLL отлично справляется со своей задачей (включая более новый C ++ и другие, такие как GO), но если они превосходят ассемблер (а именно ваш код), вам необходимо обратиться к документации по процессору - сравнения с неаккуратным кодом, безусловно, неубедительны, и компилируемые языки, такие как ассемблер, все решают вплоть до кода операции HLL абстрагирует детали и не устраняет их, иначе приложение не будет запущено, даже если оно будет распознано операционной системой хоста.

Большая часть кода на ассемблере (прежде всего объекты) выводится как «безголовый» для включения в другие исполняемые форматы с гораздо меньшей необходимой обработкой, следовательно, это будет намного быстрее, но гораздо более небезопасно; если исполняемый файл выводится ассемблером (NAsm, YAsm и т. д.), он все равно будет работать быстрее, пока полностью не совпадет с кодом HLL по функциональности, тогда результаты могут быть точно взвешены.

Вызов объекта кода на основе ассемблера из HLL в любом формате по своей сути добавит накладные расходы на обработку, а также вызовы пространства памяти, использующие глобально распределенную память для переменных / постоянных типов данных (это относится как к LLL, так и к HLL). Помните, что конечный результат в конечном итоге использует ЦП как его api и abi относительно аппаратного обеспечения (код операции), и ассемблеры, и «компиляторы HLL» по существу / принципиально идентичны, за исключением единственного истинного исключения - читаемости (грамматика).

Консольное приложение Hello world в ассемблере, использующем FAsm, имеет размер 1,5 КБ (а в Windows даже меньше во FreeBSD и Linux) и превосходит все, что GCC может выбросить в свой лучший день; Причины - неявное заполнение с помощью nops, проверка доступа и проверка границ. Настоящая цель - чистые библиотеки HLL и оптимизируемый компилятор, который нацелен на процессор «хардкорным» способом, и большинство делает это в наши дни (наконец). GCC не лучше, чем YAsm - это практика кодирования и понимание разработчика, которые находятся под вопросом, и «оптимизация» приходит после изучения новичка и промежуточного обучения и опыта.

Компиляторы должны связывать и собирать для вывода в том же коде операции, что и ассемблер, потому что эти коды - это все, что ЦП, кроме (CISC или RISC [PIC тоже]). YAsm оптимизировал и значительно очистил ранние NAsm, что в конечном итоге ускорило весь вывод этого ассемблера, но даже тогда YAsm, как и NAsm, по-прежнему создает исполняемые файлы с внешними зависимостями для библиотек ОС от имени разработчика, поэтому пробег может варьироваться. В завершение C ++ находится в точке, которая невероятна и намного более безопасна, чем ассемблер на 80+ процентов, особенно в коммерческом секторе ...

Ворон
источник
1
В C и C ++ нет никакой проверки границ, если вы не попросите об этом, и нет сборки мусора, если вы не реализуете ее самостоятельно или не используете библиотеку. Реальный вопрос заключается в том, делает ли компилятор лучшие циклы (и глобальные оптимизации), чем человек. Обычно да, если человек действительно не знает, что он делает, и тратит на это много времени .
Питер Кордес
1
Вы можете создавать статические исполняемые файлы, используя NASM или YASM (без внешнего кода). Они оба могут выводить в плоском двоичном формате, так что вы можете сами собрать заголовки ELF, если вы действительно не хотите запускать ld, но это не имеет значения, если вы не пытаетесь действительно оптимизировать размер файла (а не только размер файла). текстовый сегмент). См. Вихревое руководство по созданию действительно исполняемых исполняемых файлов ELF для Linux .
Питер Кордес
1
Возможно, вы думаете о C # или std::vectorскомпилированы в режиме отладки. С ++ массивы не такие. Компиляторы могут проверять вещи во время компиляции, но если вы не включите дополнительные параметры защиты, проверка во время выполнения не будет. Посмотрите, например, функцию, которая увеличивает первые 1024 элемента int array[]аргумента. Выходные данные asm не проверяются во время выполнения: godbolt.org/g/w1HF5t . Все, что он получает, это указатель rdi, без информации о размере. Программист должен избежать неопределенного поведения, никогда не вызывая его с массивом, меньшим 1024.
Питер Кордес
1
Все, о чем вы говорите, не является простым массивом C ++ (выделите с помощью new, удалите вручную delete, без проверки границ). Вы можете использовать C ++ для создания дерьмового раздутого ассм / машинного кода (как и большинство программного обеспечения), но это ошибка программиста, а не C ++. Вы даже можете использовать, allocaчтобы выделить пространство стека в виде массива.
Питер Кордес
1
Ссылка на пример gcc.godbolt.org из g++ -O3генерации границ проверки коды для простого массива, или делать все остальное , что вы говорите. C ++ делает его гораздо проще генерировать раздутые двоичные файлы (и на самом деле вы должны быть осторожны , не к тому, если вы стремитесь к производительности), но это не в буквальном смысле неизбежно. Если вы понимаете, как C ++ компилируется в asm, вы можете получить код, который несколько хуже, чем вы могли бы писать вручную, но с встраиванием и постоянным распространением в большем масштабе, чем вы могли бы управлять вручную.
Питер Кордес
-3

Сборка может быть быстрее, если ваш компилятор генерирует много кода поддержки OO .

Редактировать:

Для downvoters: ОП написал "я должен ... сосредоточиться на C ++ и забыть о языке ассемблера?" и я поддерживаю мой ответ. Вы всегда должны следить за кодом, генерируемым ОО, особенно при использовании методов. Не забывать о языке ассемблера означает, что вы будете периодически просматривать сборку, создаваемую вашим ОО-кодом, которая, я считаю, необходима для написания хорошо работающего программного обеспечения.

На самом деле, это относится ко всему компилируемому коду, а не только к ОО.

Олоф Форшелл
источник
2
-1: я не вижу никакой используемой функции ОО. Ваш аргумент такой же, как «сборка также может быть быстрее, если ваш компилятор добавляет миллион NOP».
Sjoerd
Мне было неясно, это на самом деле вопрос C. Если вы пишете код на C для компилятора C ++, вы не пишете код на C ++, и вы не получите ничего из ОО. Как только вы начнете писать на реальном C ++, используя OO, вы должны быть очень хорошо осведомлены, чтобы компилятор не генерировал код поддержки OO.
Олоф Форшелл
так твой ответ не о вопросе? (Кроме того, пояснения приводятся в ответе, а не в комментариях. Комментарии могут быть удалены в любое время без уведомления, уведомления или истории.
Mooing Duck
1
Не уверен, что именно вы подразумеваете под ОО «код поддержки». Конечно, если вы используете много RTTI и тому подобного, компилятор должен будет создать множество дополнительных инструкций для поддержки этих функций, но любая проблема, которая достаточно высокого уровня для ратификации использования RTTI, слишком сложна, чтобы ее можно было записать в сборке. , Конечно, вы можете писать только абстрактный внешний интерфейс как OO, отправляя его в чистый процедурный код с оптимизированной производительностью, где это критично. Но, в зависимости от приложения, C, Fortran, CUDA или просто C ++ без виртуального наследования могут быть лучше, чем сборка.
оставил около
2
По крайней мере, не очень вероятно. В C ++ есть вещь, называемая правилом нулевых издержек, и это применяется в большинстве случаев. Узнайте больше о OO - вы обнаружите, что в конечном итоге это улучшает читабельность вашего кода, улучшает качество кода, увеличивает скорость кодирования, повышает надежность. Также для встроенного - но используйте C ++, так как он дает вам больше контроля, встроенный + OO путь Java будет стоить вам.
Зейн