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


29
Досить. Збірка, кодована вручну, є доцільною у деяких обставинах, але слід подбати про те, щоб версія збірки була дійсно швидшою, ніж те, що можна досягти мовою вищого рівня.
Магнус Хофф

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

34
Так, схоже, що компілятор краще пише ASM, ніж ти. Сучасні компілятори справді досить непогані.
Девід Геффернан

20
Ви подивилися на складений GCC? Можливий GCC використовував інструкції MMX. Ваша функція дуже паралельна - ви могли потенційно використовувати N процесорів для обчислення суми за 1 / N -й час. Спробуйте функцію, де немає надії на паралелізацію.
Кріс

11
Гм, я б очікував, що хороший компілятор зробить це ~ 100000 разів швидше ...
PlasmaHH

Відповіді:


261

Так, більшість разів.

Перш за все, ви починаєте з помилкового припущення, що мова низького рівня (збірка в цьому випадку) завжди створюватиме швидший код, ніж мова високого рівня (C ++ і C в цьому випадку). Це неправда. Чи завжди код C швидше, ніж код Java? Ні, тому що є ще одна змінна: програміст. Те, як ви пишете код і знання деталей архітектури, сильно впливають на продуктивність (як ви бачили в цьому випадку).

Ви завжди можете навести приклад, коли код складання вручну краще, ніж компільований код, але зазвичай це вигаданий приклад або окрема рутина, а не справжня програма з 500 000+ рядків коду С ++). Я думаю, що компілятори вироблять кращий код складання в 95% разів, а іноді, лише в деяких рідкісних випадках, вам може знадобитися написати код складання для декількох, коротких, високо використовуваних , критичних процедур виконання або коли вам доведеться отримати доступ до функцій улюбленої мови високого рівня не виставляє. Ви хочете доторкнутись до цієї складності? Прочитайте цю дивовижну відповідь тут на ТАК.

Чому це?

Перш за все тому, що компілятори можуть робити оптимізацію, яку ми навіть не уявляємо (див. Цей короткий список ), і вони зроблять їх за лічені секунди (коли нам можуть знадобитися дні ).

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

Для деяких складних мікроконтролерів навіть системні бібліотеки записуються на С замість складання, оскільки їх компілятори створюють кращий (і простий у обслуговуванні) кінцевий код.

Компілятори іноді можуть автоматично використовувати деякі інструкції MMX / SIMDx самостійно, а якщо ви не використовуєте їх, ви просто не можете порівняти (інші відповіді вже дуже добре переглянули ваш збірковий код). Тільки для циклів це короткий список оптимізацій циклу того, що зазвичай перевіряється компілятором (ви думаєте, що ви могли це зробити самостійно, коли ваш графік був визначений для програми C #?) Якщо ви щось пишете в збірці, я думаю, вам доведеться врахувати хоча б кілька простих оптимізацій . Прикладом шкільної книги для масивів є розкручування циклу (його розмір відомий під час компіляції). Зробіть це і запустіть свій тест ще раз.

У наші дні також дуже рідко потрібно використовувати мову збирання з іншої причини: безліч різних процесорів . Ви хочете підтримати їх усіх? Кожен має специфічну мікроархітектуру та певні набори інструкцій . Вони мають різну кількість функціональних блоків, і інструкції по збірці повинні бути організовані, щоб вони не зайняті . Якщо ви пишете на C, ви можете використовувати PGO, але при складанні вам знадобляться чудові знання цієї конкретної архітектури (і переосмислюйте і переробляйте все для іншої архітектури ). Для невеликих завдань компілятор зазвичай робить це краще, і для складних завдань , як правило , робота не погашена (ікомпілятор може зробити краще все-таки).

Якщо ви сідете і поглянете на свій код, ймовірно, побачите, що ви отримаєте більше, щоб переробити свій алгоритм, ніж перекласти на збірку (читайте цей чудовий пост тут на SO ), є оптимізації високого рівня (і підказки до компілятора), які ви можете ефективно застосувати до того, як вам потрібно вдаватися до мови складання. Напевно, варто згадати, що часто використовуючи внутрішню техніку, ви отримаєте підвищення продуктивності, яку шукаєте, і компілятор все одно зможе виконати більшість його оптимізацій.

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


9
Звичайно, ні. Я думаю, що це краще 95% людей у ​​99% разів. Іноді тому, що просто дорого (через складну математику) або витратити час (потім знову дорого). Іноді тому, що ми просто забули про оптимізацію ...
Адріано Репетті

62
@ ja72 - ні, це не краще в написанні код. Краще в оптимізації коду.
Майк Баранчак

14
Це контр-інтуїтивно, поки ви справді не зважаєте на це. Таким же чином машини на основі VM починають проводити оптимізацію виконання, про що компілятори просто не мають інформації.
Білл К

6
@ M28: Компілятори можуть використовувати ті самі інструкції. Звичайно, вони платять за це у вигляді двійкового розміру (тому що вони повинні надати резервний шлях у випадку, якщо ці інструкції не підтримуються). Крім того, здебільшого "нові інструкції", які були б додані - це інструкції SMID у будь-якому випадку, які і VM, і компілятори досить жахливі при використанні. Віртуальні машини платять за цю функцію тим, що їм потрібно зібрати код при запуску.
Біллі ONeal

9
@BillK: PGO робить те ж саме для компіляторів.
Біллі ONeal

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];
    }
  }
}

можна перетворити на цикл обертання :

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кваліфікувач до xта y. Дійсно, без цього обмеження, x[j]і він y[j]може мати псевдонім у тому самому місці, що робить цю трансформацію помилковою. [закінчити редагувати]

У всякому разі, це , я думаю, оптимізована версія С. Вже це набагато простіше. Виходячи з цього, ось мій тріск в 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

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


Дякую за вашу відповідь. Ну, трохи заплутано, що коли я взяв клас під назвою "Принципи компілятора", я дізнався, що компілятор оптимізує наш код багатьма способами. Чи означає це, що нам потрібно оптимізувати наш код вручну? Чи можемо ми зробити кращу роботу, ніж компілятор? Це питання, яке мене завжди бентежить.
користувач957121

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 має акуратний трюк перетворення постійних цілих множників на зсув / додавання (та / або віднімання), що добре для множників з кількома встановленими бітами. Код Кланг у важких випадках буде вузьким місцем при пропускній здатності перетасовки на процесорах Intel.
Пітер Кордес

41

Коротка відповідь: так.

Довга відповідь: так, якщо ви дійсно не знаєте, що робите, і не маєте приводу це зробити.


3
і лише тоді, якщо ви запустили інструмент для профілювання рівня складання, як vtune для інтелектуальних мікросхем, щоб побачити, де ви можете вдосконалитись,
Марк Маллін

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
sasha

7
Тепер, якщо ви подумаєте про час, який вам насправді знадобився: збірка, приблизно 10 годин чи так? C ++, я думаю, кілька хвилин? Тут чіткий переможець, якщо тільки це не критичний для продуктивності код.
Калімо

1
Хороший компілятор вже автоматично векторизує з paddd xmm(після перевірки на накладення між xі y, оскільки ви не використовували int *__restrict x). Наприклад, gcc робить це: godbolt.org/z/c2JG0- . Або після вставки в mainнього не слід перевіряти наявність перекриттів, оскільки він може бачити розподіл і доводити, що вони не перетинаються. (І було б припустити 16-байтове вирівнювання в деяких реалізаціях x86-64, що не стосується окремого визначення.) І якщо ви компілюєте gcc -O3 -march=native, ви можете отримати 256-бітний або 512-бітний векторизація.
Пітер Кордес

24

Це означає, що я не повинен довіряти мові асемблера, написаному моїми руками

Так, саме це означає, і це правда для кожного мови. Якщо ви не знаєте, як написати ефективний код мовою X, то ви не повинні довіряти своїй здатності писати ефективний код у X. І тому, якщо ви хочете, щоб ефективні коди, ви повинні використовувати іншу мову.

Асамблея особливо чутлива до цього, тому що, ну, те, що ти бачиш, те, що отримуєш. Ви пишете конкретні інструкції, якими потрібно виконати процесор. З мовами високого рівня в betweeen є компілятор, який може перетворити ваш код і усунути багато неефективності. З монтажем, ви самостійно.


2
Я думаю, що для того, щоб написати, що особливо для сучасного процесора x86 надзвичайно складно написати ефективний код складання через наявність трубопроводів, декількох одиниць виконання та інших трюків всередині кожного ядра. Написання коду, який врівноважує використання всіх цих ресурсів для досягнення максимальної швидкості виконання, часто призводить до коду з непростою логікою, яка "не повинна" бути швидкою відповідно до "звичайної" мудрості складання. Але для менш складних процесорів, на моєму досвіді, генерація коду компілятора C може значно покращитися.
Олоф Форшелл

4
Код компіляторів може бути , як правило , бути поліпшений, навіть на сучасному x86 CPU. Але ви повинні добре зрозуміти процесор, що складніше зробити із сучасним процесором x86. Це моя суть. Якщо ви не розумієте обладнання, на яке орієнтуєтесь, то оптимізувати його не зможете. І тоді компілятор, ймовірно, зробить кращу роботу
jalf

1
І якщо ви дійсно хочете зняти компілятор, ви повинні бути креативними та оптимізувати способи, які компілятор не може. Це компроміс за час / винагороду, тому C - це сценарій мови для одних і проміжний код для мови вищого рівня для інших. Для мене, хоча, складання більше для задоволення :). так само, як grc.com/smgassembly.htm
Хокен

22

Єдиною причиною використання мовної збірки на сьогоднішній день є використання деяких функцій, недоступних мовою.

Це стосується:

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

Але поточні компілятори досить розумні, вони навіть можуть замінити два окремі оператори, як- d = a / b; r = a % b;от одну інструкцію, яка обчислює поділ та залишок за один раз, якщо вони доступні, навіть якщо у C немає такого оператора.


10
Крім цих двох місць для ASM. А саме, бібліотека bignum зазвичай буде значно швидшою в ASM, ніж C, через доступ до перенесення прапорів та верхньої частини множення тощо. Ці речі можна робити і в портативному С, але вони дуже повільні.
Mooing Duck

@MooingDuck Це може розглядатися як доступ до апаратних функцій апаратного забезпечення, які не доступні безпосередньо мовою ... Але поки ви просто перекладаєте код високого рівня на збірку вручну, компілятор буде бити вас.
фортран

1
це те, але це не ядро ​​програмування, а не конкретний постачальник. Хоча з незначними змінами роботи, це може легко потрапити в будь-яку категорію. Я здогадуюсь ASM, коли ви хочете виконувати інструкції процесора, які не мають C-зіставлення.
Mooing Duck

1
@fortran В основному просто кажучи, що якщо ви не оптимізуєте свій код, він не буде настільки швидким, як код, оптимізований компілятором. Оптимізація - це причина, в якій можна було б написати збірку в першу чергу. Якщо ви маєте на увазі перекладати, то оптимізуйте, немає причин, що компілятор буде бити вас, якщо ви не налаштовані на оптимізацію складання. Отже, щоб перемогти компілятор, вам доведеться оптимізувати способи компілятора. Це досить пояснює себе. Єдина причина писати збірку - це якщо ти кращий за компілятор / інтерпретатор . Це завжди було практичним приводом для складання зборів.
Хоукен

1
Просто кажучи: Кланг має доступ до прапорців для перенесення, 128-розрядне множення тощо, завдяки вбудованим функціям. І він може інтегрувати все це в свої звичайні алгоритми оптимізації.
gnasher729

19

Це правда, що сучасний компілятор виконує дивовижну роботу з оптимізації коду, але все ж я б закликав вас продовжувати навчатись монтажу.

Перш за все, вас явно не залякує, це великий, великий плюс, далі - ви на правильному шляху, профлююючи, щоб перевірити або відкинути свої припущення щодо швидкості , ви просите ввести досвідчених людей , і ви мають найбільший оптимізуючий інструмент, відомий людству: мозок .

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

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

"немає такого поняття, як найшвидший код" - Майкл Абраш


2
Я вважаю, що одна з книг Майкла Абраша - це чорна книга графічного програмування. Але він не єдиний, хто використовує збірку, Кріс Сойєр написав перші два ігри з магнат на американських магнатах в зборах сам.
Хоукен

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, швидкість складання коду залежить від програміста


Так, мій код справді потрібно оптимізувати. Гарна робота для вас і спасибі!
користувач957121

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 ++


12

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

Наприклад, навіть у тому, що я не впевнений, що це більше так: :)

Виконуємо:

mov eax,0

коштують більше циклів, ніж

xor eax,eax

що робить те саме.

Компілятор знає всі ці хитрощі та використовує їх.


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] скрізь, ви уникнете 1 затримки циклу за інструкцією, що прискорює цикл циклів. (Якщо у вас є остання версія Skylake, це не стосується). Додавання reg, reg просто робить цикл більш жорстким, що може чи не допоможе.
Йоган

@Johan, це не повинно бути затримкою, а лише додатковою затримкою циклу, але впевнений, що це не завадить не мати її. Я написав цей код для Core2, у якого не було цієї проблеми. Чи не є r + r також "складним" btw?
Гарольд

7

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

Це тому, що навіть найменша оптимізація, яку робить компілятор, краще, ніж ваш жорсткий код, без оптимізації.

Звичайно, можна обіграти компілятор, особливо якщо це невелика локалізована частина коду, мені навіть довелося це зробити самому, щоб отримати ок. У чотири рази швидше, але в цьому випадку ми повинні сильно покластися на хороші знання обладнання та численні, здавалося б, протиінтуїтивні хитрощі.


3
Я думаю, це залежить від мови та компілятора. Я можу собі уявити надзвичайно неефективний компілятор С, вихід якого легко переміг би людською простою збіркою. GCC, не так багато.
Кейсі Родармор

Оскільки компілятори C / ++ є таким завданням, і лише 3 основні з них, вони, як правило, досить хороші в тому, що роблять. За певних обставин все-таки (дуже) можливо, що складання, написане вручну, буде швидше; велика кількість математичних бібліотек падає на зону, щоб краще обробляти декілька / широкі значення. Тож хоча гарантоване трохи занадто сильне, це, ймовірно.
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;" марно, тому вона видалить цю лінію. Сподіваємось, щось у вашій голові зараз готове долучити деякі коментарі до оптимізації як коментар. Усі ці дуже ефективні оптимізації зроблять мову компіляції швидше.


4
І якщо aвона не є мінливою, є хороший шанс, що компілятор буде робити це int a = 13;з самого початку.
vsz


4

Мені подобається цей приклад, тому що він демонструє важливий урок про код низького рівня. Так, ви можете написати збірку, яка так само швидко, як і ваш C код. Це тавтологічно вірно, але не обов'язково нічого означає . Ясно хтось може, інакше асемблер не знає відповідних оптимізацій.

Так само застосовується той самий принцип, коли ви рухаєтесь за ієрархією абстракції мови. Так, ви можете написати синтаксичний синтаксичний аналізатор на C, який є таким же швидким, як швидкий і брудний сценарій Perl, і багато хто це робить. Але це не означає, що оскільки ви використовували C, ваш код буде швидким. У багатьох випадках мови вищого рівня роблять оптимізацію, про яку ви, можливо, ніколи не думали.


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. У такому випадку компілятор може оптимізувати версію коду С просто:

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 мс.


2

Останнім часом усі оптимізації швидкості, які я робив, замінювали повільний код пошкодженого мозку просто розумним кодом. Але оскільки швидкість була дійсно критичною, і я доклав серйозних зусиль, щоб зробити щось швидке, результат завжди був ітераційним процесом, коли кожна ітерація давала більше розуміння проблеми, знаходячи способи вирішити проблему за допомогою меншої кількості операцій. Кінцева швидкість завжди залежала від того, наскільки я зрозумів проблему. Якби на будь-якому етапі я використовував код збірки або код С, який був надмірно оптимізований, процес пошуку кращого рішення зазнав би страждання, а кінцевий результат буде повільнішим.


2

C ++ швидше, якщо ви не використовуєте правильну мову монтажу з більш глибокими знаннями.

Коли я кодую в ASM, я реорганізую інструкції вручну, щоб процесор міг виконувати більшість з них паралельно, коли це можливо логічно. Я ледве використовую оперативну пам'ять, коли кодую в ASM, наприклад: У ASM може бути 20000 ліній коду, і я ніколи не використовував push / pop.

Ви потенційно можете стрибнути посеред коду, щоб самостійно змінити код та поведінку без можливого покарання коду, що самовиправляється. Доступ до регістрів займає 1 галочку (іноді займає .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, який повільний. Це тому, що сама концепція програмування графічного процесора для 3D робить швидкість мови, яка надсилає команди до GPU, майже не має значення.

Швидкість самостійного збирання на голому металі неспростовна. Чи може бути все повільніше всередині C ++? - Це може бути тому, що ви пишете код складання разом із компілятором, не використовуючи асемблер для початку.

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


1

Усі відповіді тут, мабуть, виключають один аспект: іноді ми не пишемо код для досягнення конкретної мети, але для задоволення від цього. Не витрачати час на це може бути невигідним, але, мабуть, немає більшого задоволення, ніж побиття найшвидшого оптимізованого фрагмента коду за швидкістю з альтернативою ручної роботи asm.


Коли ви просто хочете обіграти компілятор, зазвичай простіше взяти його вихід ASM для своєї функції і перетворити його в окрему функцію asm, яку ви налаштовуєте. Використання вбудованого asm - це безліч додаткових робіт, щоб отримати інтерфейс між C ++ та asm правильним та перевірити, чи він відповідає оптимальному коду. (Але принаймні, просто роблячи це для розваги, вам не доведеться турбуватися про це, перемагаючи оптимізацію, як постійне розповсюдження, коли функція вбудовується в щось інше. Gcc.gnu.org/wiki/DontUseInlineAsm ).
Пітер Кордес

Дивіться також Collatz-гіпотезу C ++ проти власноруч написаного ASM Q&A для отримання додаткової інформації про побиття компілятора для задоволення :) А також пропозиції щодо використання того, що ви навчитеся змінювати C ++, щоб допомогти компілятору зробити кращий код.
Пітер Кордес

@PeterCordes Отож, що ви говорите, ви згодні.
мадоки

1
Так, Asm - це задоволення, за винятком того, що вбудований ASM - це неправильний вибір навіть для гри. Це технічно запитання, що відповідає вказівкам, тому було б добре хоча б вирішити цю точку у своїй відповіді. Крім того, це дійсно більше коментаря, ніж відповіді.
Пітер Кордес

ОК погодився. Я раніше був лише хлопцем, але це були 80-ті.
мадоки

-2

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

Більшість кодів асемблера (насамперед об'єкти) виводяться як "безголові" для включення до інших виконуваних форматів із значно меншою необхідною обробкою, отже, це буде набагато швидше, але набагато більш незахищеним; якщо виконуваний файл виводиться асемблером (NAsm, YAsm; і т. д.), він все одно буде працювати швидше, поки повністю не відповідає коду HLL у функціональності, то результати можуть бути точно зважені.

Виклик кодового об'єкта на основі асемблера з HLL у будь-якому форматі по суті додасть обробку накладних витрат також на додаток до викликів простору пам'яті, використовуючи глобально виділену пам'ять для змінних / постійних типів даних (це стосується як LLL, так і HLL). Пам'ятайте, що підсумковий вихід використовує процесор в кінцевому підсумку як його api та abi відносно обладнання (опкод), і обидва, асемблери та "компілятори HLL" по суті / принципово ідентичні, єдиним справжнім винятком є ​​читабельність (граматична).

Привіт, приклад світової консолі в асемблері, що використовує FAsm, становить 1,5 Кб (а це в Windows ще менше у FreeBSD та Linux) і перевершує все, що може GCC викинути в найкращий день; Причини - неявна оббивка носами, перевірка доступу та перевірка меж, щоб назвати їх декілька. Справжня мета - це чисті HLL libs та оптимізується компілятор, який націлює на процесор в "хардкор" манері, і більшість робить це сьогодні (нарешті). 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складені в режимі налагодження. Масиви C ++ не такі. Компілятори можуть перевіряти матеріали під час компіляції, але якщо ви не включите додаткові параметри загартовування, перевірки часу роботи не проводиться. Дивіться, наприклад, функцію, яка збільшує перші 1024 елементи int array[]аргументу. Вихід ASM не має перевірок виконання: godbolt.org/g/w1HF5t . Все, що він отримує, - це вказівник rdi, інформація про розмір відсутня. Програміст повинен уникати невизначеної поведінки, ніколи не називаючи її масивом менше 1024.
Пітер Кордес

1
Що б ви не говорили, це не простий масив C ++ (виділіть new, видаліть вручну delete, не перевіряючи межі). Ви можете використовувати C ++, щоб створити неприємний ASM / машинний код (як і більшість програм), але це помилка програміста, а не C ++. Ви навіть можете використовувати allocaдля виділення простору стека як масив.
Пітер Кордес

1
Пов’яжіть приклад на gcc.godbolt.org з g++ -O3генерування коду перевірки меж для простого масиву або виконання всього іншого, про що ви говорите. C ++ робить його набагато простіше генерувати роздуті виконавчі файли (і насправді ви повинні бути обережні , НЕ до того, якщо ви прагнете до продуктивності), але це не в буквальному сенсі неминуче. Якщо ви розумієте, як C ++ компілюється в ASM, ви можете отримати код, який є лише дещо гіршим, ніж ви могли написати вручну, але з вбудованим і постійним розповсюдженням в більшому масштабі, ніж ви могли керувати вручну.
Пітер Кордес

-3

Складання може бути швидше, якщо ваш компілятор генерує багато коду підтримки OO .

Редагувати:

Короткам особам: ОП написало: "чи слід зосередитись на C ++ і забути про мову складання?" і я стою біля своєї відповіді. Завжди потрібно стежити за кодом, який створює ОО, особливо при використанні методів. Не забуваючи про мову складання означає, що ви будете періодично переглядати збірку, створює ваш OO-код, який, на мою думку, є необхідним для написання продуктивного програмного забезпечення.

Насправді це стосується всього компільованого коду, а не лише ОО.


2
-1: Я не бачу жодної функції ОО. Ваш аргумент такий же, як "збірка також може бути швидшою, якщо ваш компілятор додасть мільйон NOP".
Sjoerd

Мені було незрозуміло, це насправді питання С. Якщо ви пишете код C для компілятора C ++, ви не пишете код C ++, і ви не отримаєте жодного матеріалу OO. Після того, як ви почнете писати на справжньому C ++, використовуючи матеріали OO, ви повинні бути дуже обізнаними, щоб компілятор не створював код підтримки OO.
Олоф Форшелл

Так що ваша відповідь не стосується питання? (Також роз'яснення надходять у відповідь, а не коментарі. Коментарі можна видалити будь-коли без повідомлення, повідомлення чи історії.
Mooing Duck

1
Не впевнений, що саме ви маєте на увазі під "кодом підтримки" OO. Звичайно, якщо ви використовуєте багато RTTI і подібних, компілятору доведеться створити безліч додаткових інструкцій для підтримки цих функцій - але будь-яка проблема, достатньо високого рівня, щоб ратифікувати використання RTTI, є надто складною, щоб бути зручною для запису в монтажі . Звичайно, ви можете зробити лише абстрактний зовнішній інтерфейс як OO, відправлення на оптимізований для виконання чистий процедурний код там, де це критично важливо. Але, залежно від програми, C, Fortran, CUDA або просто C ++ без віртуального успадкування може бути кращим, ніж збірка тут.
близько

2
Ні. Принаймні, не дуже ймовірно. Є в C ++ річ, яка називається нульовим накладним правилом, і це стосується більшості випадків. Дізнайтеся більше про ОО - ви дізнаєтесь, що врешті-решт це покращує читабельність вашого коду, покращує якість коду, збільшує швидкість кодування, підвищує надійність. Також для вбудованих - але використовуйте C ++, оскільки це дає більше контролю, вбудований + OO спосіб Java обійдеться вам.
Зейн
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.