Kiedy montaż jest szybszy niż C?


475

Jednym z podanych powodów znajomości asemblera jest to, że czasami można go użyć do napisania kodu, który będzie bardziej wydajny niż pisanie tego kodu w języku wyższego poziomu, w szczególności C. Jednak słyszałem też wielokrotnie, że chociaż nie jest to całkowicie fałszywe, przypadki, w których asembler może być rzeczywiście używany do generowania bardziej wydajnego kodu, są niezwykle rzadkie i wymagają specjalistycznej wiedzy i doświadczenia w asemblerze.

To pytanie nawet nie zagłębia się w fakt, że instrukcje asemblera będą specyficzne dla maszyny i nieprzenośne, ani w żadnym innym aspekcie asemblera. Poza tym oczywiście istnieje wiele dobrych powodów, by znać asembler, ale ma to być konkretne pytanie, które gromadzi przykłady i dane, a nie rozszerzony dyskurs na temat asemblera w porównaniu z językami wyższego poziomu.

Czy ktoś może podać konkretne przykłady przypadków, w których montaż będzie szybszy niż dobrze napisany kod C przy użyciu nowoczesnego kompilatora, i czy możesz wesprzeć to twierdzenie profilowaniem dowodów? Jestem przekonany, że te przypadki istnieją, ale naprawdę chcę dokładnie wiedzieć, jak ezoteryczne są te przypadki, ponieważ wydaje się, że jest to kwestia sporna.


17
poprawa skompilowanego kodu jest dość prosta. Każdy, kto ma solidną znajomość języka asemblera i języka C, może to zobaczyć, analizując wygenerowany kod. Każdy łatwy to pierwszy klif wydajnościowy, z którego wypadniesz, gdy zabraknie Ci rejestrów jednorazowych w skompilowanej wersji. Średnio kompilator poradzi sobie znacznie lepiej niż człowiek w przypadku dużego projektu, ale w projektach o przyzwoitych rozmiarach znalezienie problemów z wydajnością w skompilowanym kodzie nie jest trudne.
old_timer

14
Właściwie krótka odpowiedź brzmi: asembler jest zawsze szybszy lub równy prędkości C. Powodem jest to, że możesz mieć assembler bez C, ale nie możesz mieć C bez assemblera (w postaci binarnej, którą my w starym dni nazywane „kodem maszynowym”). To powiedziawszy, długa odpowiedź brzmi: kompilatory C są dość dobre w optymalizacji i „myśleniu” o rzeczach, o których zwykle nie myślisz, więc tak naprawdę zależy to od twoich umiejętności, ale zwykle zawsze możesz pokonać kompilator C; to wciąż tylko oprogramowanie, które nie potrafi myśleć i zdobywać pomysłów. Możesz także napisać przenośny asembler, jeśli używasz makr i jesteś cierpliwy.

11
Zdecydowanie nie zgadzam się z tym, że odpowiedzi na to pytanie muszą być „oparte na opiniach” - mogą być dość obiektywne - nie jest to coś w rodzaju próby porównania wydajności ulubionych języków zwierząt domowych, za które każdy z nich będzie miał mocne strony i wady. Jest to kwestia zrozumienia, jak daleko mogą nas zabrać kompilatory i od tego momentu lepiej jest przejąć kontrolę.
jsbueno

21
Wcześniej w swojej karierze pisałem dużo asemblera C i mainframe w firmie programistycznej. Jednym z moich rówieśników było to, co nazwałbym „purystą asemblera” (wszystko musiało być asemblerem), więc założę się, że mógłbym napisać daną procedurę, która działałaby szybciej w C niż to, co mógłby napisać w asemblerze. Wygrałem. Ale na koniec, po wygranej, powiedziałem mu, że chcę drugi zakład - że mogę napisać coś szybszego w asemblerze niż program C, który pokonał go na poprzednim zakładzie. Wygrałem to również, udowadniając, że większość z nich sprowadza się do umiejętności i zdolności programisty bardziej niż cokolwiek innego.
Valerie R

3
Jeśli twój mózg nie ma -O3flagi, prawdopodobnie lepiej zostawić optymalizację kompilatorowi C :-)
paxdiablo

Odpowiedzi:


272

Oto przykład z prawdziwego świata: Stałe punkty mnożą się na starych kompilatorach.

Są one przydatne nie tylko na urządzeniach bez zmiennoprzecinkowych, ale świecą, jeśli chodzi o precyzję, ponieważ zapewniają 32 bity precyzji z przewidywalnym błędem (liczba zmiennoprzecinkowa ma tylko 23 bity i trudniej jest przewidzieć utratę precyzji). tj. jednolita absolutna precyzja w całym zakresie, zamiast zbliżonej do jednakowej dokładności względnej ( float).


Nowoczesne kompilatory ładnie optymalizują ten przykład w punkcie stałym, więc dla bardziej nowoczesnych przykładów, które wciąż wymagają kodu specyficznego dla kompilatora, zobacz


C nie ma operatora pełnego mnożenia (wynik 2N-bitowy z wejść N-bitowych). Zwykłym sposobem wyrażenia tego w C jest rzutowanie danych wejściowych na szerszy typ i nadzieję, że kompilator rozpozna, że ​​górne bity danych wejściowych nie są interesujące:

// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
  long long a_long = a; // cast to 64 bit.

  long long product = a_long * b; // perform multiplication

  return (int) (product >> 16);  // shift by the fixed point bias
}

Problem z tym kodem polega na tym, że robimy coś, czego nie można bezpośrednio wyrazić w języku C. Chcemy pomnożyć dwie liczby 32-bitowe i uzyskać wynik 64-bitowy, z którego zwracamy środkowy 32-bitowy. Jednak w C ten mnożnik nie istnieje. Wszystko, co możesz zrobić, to podwyższyć liczby całkowite do 64-bitowych i zrobić 64 * 64 = 64 pomnożenie.

x86 (i ARM, MIPS i inne) mogą jednak wykonać mnożenie w pojedynczej instrukcji. Niektóre kompilatory ignorowały ten fakt i generowały kod, który wywołuje funkcję biblioteki wykonawczej w celu wykonania mnożenia. Przesunięcie o 16 jest również często wykonywane przez procedurę biblioteczną (również x86 może wykonywać takie przesunięcia).

Pozostaje nam jedno lub dwa wywołania biblioteczne tylko dla pomnożenia. Ma to poważne konsekwencje. Przesunięcie jest nie tylko wolniejsze, ale rejestry muszą być zachowywane w wywołaniach funkcji, a także nie pomaga wstawianie i rozwijanie kodu.

Jeśli przepiszesz ten sam kod w (wbudowanym) asemblerze, możesz uzyskać znaczne przyspieszenie.

Ponadto: korzystanie z ASM nie jest najlepszym sposobem na rozwiązanie problemu. Większość kompilatorów pozwala na użycie niektórych instrukcji asemblera w postaci wewnętrznej, jeśli nie można ich wyrazić w C. Kompilator VS.NET2008 na przykład wyświetla 32 * 32 = 64-bitowy mul jako __emul, a 64-bitowe przesunięcie jako __ll_rshift.

Używając funkcji wewnętrznych, możesz przepisać funkcję w taki sposób, aby kompilator C miał szansę zrozumieć, co się dzieje. Pozwala to na wstawianie kodu, przydzielanie rejestru, wspólną eliminację podwyrażeń i stałą propagację. W ten sposób uzyskasz ogromną poprawę wydajności w stosunku do ręcznie napisanego kodu asemblera.

Dla porównania: Rezultat końcowy dla mulda punktu stałego dla kompilatora VS.NET to:

int inline FixedPointMul (int a, int b)
{
    return (int) __ll_rshift(__emul(a,b),16);
}

Różnica wydajności podziału na punkty stałe jest jeszcze większa. Miałem ulepszenia do współczynnika 10 dla ciężkiego kodu stałego punktu dzielącego, pisząc kilka linii asm.


Korzystanie z Visual C ++ 2013 daje ten sam kod asemblera na oba sposoby.

gcc4.1 z 2007 roku ładnie optymalizuje również czystą wersję C. (Eksplorator kompilatora Godbolt nie ma zainstalowanych wcześniejszych wersji gcc, ale prawdopodobnie nawet starsze wersje GCC mogłyby to zrobić bez wewnętrznych elementów).

Zobacz source + asm dla x86 (32-bit) i ARM w eksploratorze kompilatorów Godbolt . (Niestety nie ma żadnych kompilatorów wystarczająco starych, aby wygenerować zły kod z prostej wersji w czystym C.)


Nowoczesne procesory mogą robić rzeczy, C nie ma dla operatorów w ogóle , jak popcnti nieco skanowania do znalezienia pierwszego lub ostatniego zestawu trochę . (POSIX ma ffs()funkcję, ale jej semantyka nie pasuje do x86 bsf/ bsr. Zobacz https://en.wikipedia.org/wiki/Find_first_set ).

Niektóre kompilatory czasami rozpoznają pętlę, która zlicza liczbę ustawionych bitów w liczbie całkowitej i kompilują ją do popcntinstrukcji (jeśli jest włączona w czasie kompilacji), ale o wiele bardziej niezawodne jest używanie jej __builtin_popcntw GNU C lub na x86, jeśli jesteś tylko celowanie w sprzęt z SSE4.2: _mm_popcnt_u32z<immintrin.h> .

Lub w C ++, przypisz do std::bitset<32>i użyj .count(). (Jest to przypadek, w którym język znalazł sposób na przenośne udostępnienie zoptymalizowanej implementacji popcount poprzez standardową bibliotekę, w sposób, który zawsze kompiluje się do czegoś poprawnego i może wykorzystać wszystko, co obsługuje cel.) Zobacz także https : //en.wikipedia.org/wiki/Hamming_weight#Language_support .

Podobnie, ntohlmożna skompilować do bswap(x86 32-bitowa zamiana bajtów dla konwersji endian) na niektórych implementacjach C, które go mają.


Innym ważnym obszarem wewnętrznym lub ręcznie pisanym asmem jest ręczna wektoryzacja z instrukcjami SIMD. Kompilatory nie są złe z takimi prostymi pętlami dst[i] += src[i] * 10.0;, ale często źle działają lub wcale nie powodują automatycznej wektoryzacji, gdy sprawy stają się bardziej skomplikowane. Na przykład jest mało prawdopodobne, aby uzyskać coś takiego jak Jak wdrożyć atoi za pomocą SIMD? generowane automatycznie przez kompilator z kodu skalarnego.


6
Co powiesz na takie rzeczy jak {x = c% d; y = c / d;}, czy kompilatory są na tyle sprytne, aby uczynić z tego pojedynczego div lub idiv?
Jens Björnhager

4
Właściwie dobry kompilator wygenerowałby optymalny kod z pierwszej funkcji. Przesłanianie kodu źródłowego za pomocą elementów wewnętrznych lub wbudowanych bez absolutnie żadnych korzyści nie jest najlepszym rozwiązaniem.
slacker

65
Cześć Slacker, myślę, że nigdy nie musiałeś pracować nad kodem o krytycznym czasie, zanim ... wbudowany zespół może mieć * ogromną różnicę. Również dla kompilatora wartość wewnętrzna jest taka sama jak normalna arytmetyka w C. To jest istotna wartość wewnętrzna. Pozwalają korzystać z funkcji architektury bez konieczności radzenia sobie z wadami.
Nils Pipenbrinck

6
@slacker Właściwie kod tutaj jest dość czytelny: kod wbudowany wykonuje jedną unikalną operację, która jest natychmiast zrozumiała dla odczytu sygnatury metody. Kod utracił się powoli w czytelności, gdy użyta jest niejasna instrukcja. Liczy się tutaj to, że mamy metodę, która wykonuje tylko jedną wyraźnie identyfikowalną operację, i to naprawdę najlepszy sposób na wygenerowanie czytelnego kodu tych funkcji atomowych. Nawiasem mówiąc, nie jest to tak niejasne, że mały komentarz, taki jak / * (a * b) >> 16 * /, nie może go natychmiast wyjaśnić.
Dereckson,

5
Szczerze mówiąc, ten przykład jest kiepski, przynajmniej dzisiaj. Kompilatory C od dawna są w stanie wykonać pomnożenie 32x32 -> 64, nawet jeśli język nie oferuje tego bezpośrednio: rozpoznają, że kiedy rzucisz 32-bitowe argumenty na 64-bit, a następnie pomnożysz je, nie trzeba wykonaj pełne mnożenie 64-bitowe, ale to, że 32x32 -> 64 da sobie radę. Sprawdziłem i wszystkie clang, gcc i MSVC w ich bieżącej wersji mają rację . To nie jest nowe - pamiętam, że patrzyłem na dane wyjściowe kompilatora i zauważyłem to dziesięć lat temu.
BeeOnRope

143

Wiele lat temu uczyłem kogoś programowania w C. Ćwiczenie polegało na obracaniu grafiki o 90 stopni. Wrócił z rozwiązaniem, które zajęło kilka minut, głównie dlatego, że używał mnożeń i dzieleń itp.

Pokazałem mu, jak przekształcić problem za pomocą przesunięć bitowych, a czas przetwarzania skrócił się do około 30 sekund na nieoptymalizowanym kompilatorze, który posiadał.

Właśnie dostałem kompilator optymalizujący i ten sam kod obrócił grafikę w <5 sekund. Spojrzałem na kod asemblera generowany przez kompilator i na podstawie tego, co zobaczyłem, zdecydowałem, że moje dni pisania asemblera już minęły.


3
Tak, to był jednobitowy system monochromatyczny, a konkretnie monochromatyczne bloki obrazu w Atari ST.
lilburne

16
Czy kompilator optymalizacyjny skompilował oryginalny program lub twoją wersję?
Thorbjørn Ravn Andersen

Na jakim procesorze? W przypadku modelu 8086 oczekiwałbym, że optymalny kod dla obrotu 8x8 załaduje DI 16 bitami danych przy użyciu SI, powtórzenia add di,di / adc al,al / add di,di / adc ah,ahitp. Dla wszystkich ośmiu rejestrów 8-bitowych, a następnie ponownie wykona wszystkie rejestry 8, a następnie powtórzy całą procedurę trzy więcej razy i na koniec zapisz cztery słowa w ax / bx / cx / dx. Nie ma mowy, żeby asembler zbliżył się do tego.
supercat

1
Naprawdę nie mogę wymyślić żadnej platformy, w której kompilator prawdopodobnie dostałby się w granicach współczynnika lub dwóch optymalnego kodu dla obrotu 8x8.
supercat

65

Prawie za każdym razem, gdy kompilator widzi kod zmiennoprzecinkowy, wersja napisana ręcznie będzie szybsza, jeśli używasz starego, złego kompilatora. ( Aktualizacja 2019: Nie jest to ogólnie prawdą w przypadku nowoczesnych kompilatorów. Zwłaszcza podczas kompilacji dla czegokolwiek innego niż x87; kompilatory mają łatwiejszy czas z SSE2 lub AVX dla matematyki skalarnej, lub dla innych niż x86 z płaskim zestawem rejestrów FP, w przeciwieństwie do x87 rejestr stosu).

Głównym powodem jest to, że kompilator nie może wykonać żadnych solidnych optymalizacji. Zobacz ten artykuł z MSDN, aby uzyskać dyskusję na ten temat. Oto przykład, w którym wersja zestawu jest dwa razy szybsza niż wersja C (skompilowana z VS2K5):

#include "stdafx.h"
#include <windows.h>

float KahanSum(const float *data, int n)
{
   float sum = 0.0f, C = 0.0f, Y, T;

   for (int i = 0 ; i < n ; ++i) {
      Y = *data++ - C;
      T = sum + Y;
      C = T - sum - Y;
      sum = T;
   }

   return sum;
}

float AsmSum(const float *data, int n)
{
  float result = 0.0f;

  _asm
  {
    mov esi,data
    mov ecx,n
    fldz
    fldz
l1:
    fsubr [esi]
    add esi,4
    fld st(0)
    fadd st(0),st(2)
    fld st(0)
    fsub st(0),st(3)
    fsub st(0),st(2)
    fstp st(2)
    fstp st(2)
    loop l1
    fstp result
    fstp result
  }

  return result;
}

int main (int, char **)
{
  int count = 1000000;

  float *source = new float [count];

  for (int i = 0 ; i < count ; ++i) {
    source [i] = static_cast <float> (rand ()) / static_cast <float> (RAND_MAX);
  }

  LARGE_INTEGER start, mid, end;

  float sum1 = 0.0f, sum2 = 0.0f;

  QueryPerformanceCounter (&start);

  sum1 = KahanSum (source, count);

  QueryPerformanceCounter (&mid);

  sum2 = AsmSum (source, count);

  QueryPerformanceCounter (&end);

  cout << "  C code: " << sum1 << " in " << (mid.QuadPart - start.QuadPart) << endl;
  cout << "asm code: " << sum2 << " in " << (end.QuadPart - mid.QuadPart) << endl;

  return 0;
}

I niektóre numery z mojego komputera z domyślną wersją wydania * :

  C code: 500137 in 103884668
asm code: 500137 in 52129147

Zainteresowany zamieniłem pętlę na dec / jnz i nie miało to żadnego wpływu na taktowanie - czasem szybciej, a czasem wolniej. Wydaje mi się, że aspekt ograniczonej pamięci przewyższa inne optymalizacje. (Uwaga edytora: bardziej prawdopodobne jest, że wąskie gardło opóźnień w FP wystarcza, aby ukryć dodatkowy koszt loop. Wykonanie dwóch podsumowań Kahana równolegle dla elementów nieparzystych / parzystych i dodanie ich na końcu, może może to przyspieszyć 2-krotnie. )

Ups, uruchomiłem nieco inną wersję kodu, która wypisała liczby w niewłaściwy sposób (tzn. C był szybszy!). Naprawiono i zaktualizowano wyniki.


20
Lub w GCC możesz rozwiązać problemy kompilatora z optymalizacją zmiennoprzecinkową (o ile obiecujesz, że nie będziesz robić nic z nieskończonościami lub NaN) za pomocą flagi -ffast-math. Mają poziom optymalizacji, -Ofastktóry jest obecnie równoważny -O3 -ffast-math, ale w przyszłości mogą obejmować więcej optymalizacji, które mogą prowadzić do nieprawidłowego generowania kodu w przypadkach narożnych (takich jak kod oparty na NaEE IEEE).
David Stone,

2
Tak, zmiennoprzecinkowe nie są przemienne, kompilator musi robić DOKŁADNIE to, co napisałeś, w zasadzie to, co powiedział @DavidStone.
Alec Teal

2
Czy próbowałeś matematyki SSE? Wydajność była jednym z powodów, dla których MS całkowicie porzuciło x87 w x86_64 i 80-bitowe podwójne w x86
phuclv

4
@Praxeolitic: Dodanie FP jest przemienne ( a+b == b+a), ale nie asocjacyjne (zmiana kolejności operacji, więc zaokrąglanie półproduktów jest inne). re: ten kod: Nie sądzę, by niezakomentowane x87 i loopinstrukcja były bardzo niesamowitą demonstracją szybkiego asm. loopnajwyraźniej nie jest wąskim gardłem z powodu opóźnienia FP. Nie jestem pewien, czy obsługuje on operacje FP, czy nie; x87 jest trudny do odczytania przez ludzi. Dwie fstp resultsinsynuacje na końcu wyraźnie nie są optymalne. Usunięcie dodatkowego wyniku ze stosu lepiej byłoby zrobić w sklepie innym niż sklep. Jak fstp st(0)IIRC.
Peter Cordes

2
@PeterCordes: Ciekawą konsekwencją zamiany dodawania na przemienną jest to, że podczas gdy 0 + x i x + 0 są sobie równoważne, żadne z nich nie zawsze jest równoważne x.
supercat

58

Nie podając żadnego konkretnego przykładu ani dowodów profilera, możesz napisać lepszy asembler niż kompilator, jeśli wiesz więcej niż kompilator.

W ogólnym przypadku nowoczesny kompilator C wie znacznie więcej o tym, jak zoptymalizować dany kod: wie, jak działa potok procesora, może próbować zmieniać kolejność instrukcji szybciej niż człowiek, i tak dalej - to w zasadzie to samo, co komputer jest lepszy lub lepszy od najlepszych ludzi do gier planszowych itp. po prostu dlatego, że może szybciej wyszukiwać w obszarze problemów niż większość ludzi. Chociaż teoretycznie możesz działać tak dobrze, jak komputer w konkretnym przypadku, z pewnością nie możesz tego zrobić z tą samą prędkością, co czyni go nieosiągalnym w więcej niż kilku przypadkach (tzn. Kompilator z pewnością przewyższy cię, jeśli spróbujesz napisać więcej niż kilka procedur w asemblerze).

Z drugiej strony zdarzają się przypadki, w których kompilator nie ma tylu informacji - powiedziałbym przede wszystkim podczas pracy z różnymi formami zewnętrznego sprzętu, o których kompilator nie ma wiedzy. Podstawowym przykładem są prawdopodobnie sterowniki urządzeń, w których asembler w połączeniu z dogłębną znajomością danego sprzętu przez człowieka może dawać lepsze wyniki niż kompilator C.

Inni wspominali instrukcje specjalnego przeznaczenia, o czym mówię w powyższym akapicie - instrukcje, o których kompilator mógł mieć ograniczoną wiedzę lub nie mieć jej wcale, umożliwiając człowiekowi pisanie szybszego kodu.


Zasadniczo to stwierdzenie jest prawdziwe. Kompilator najlepiej robi w DWIW, ale w niektórych przypadkach asembler do ręcznego kodowania wykonuje zadanie, gdy wydajność w czasie rzeczywistym jest koniecznością.
spoulson

1
@Liedman: „może próbować zmienić kolejność instrukcji szybciej niż człowiek”. OCaml jest znany z tego, że jest szybki i, co zaskakujące, jego kompilator kodu natywnego ocamloptpomija planowanie instrukcji na x86 i zamiast tego pozostawia to procesorowi, ponieważ może bardziej efektywnie zmieniać kolejność w czasie wykonywania.
Jon Harrop

1
Współczesne kompilatory robią dużo i ręczne wykonanie ich zajęłoby zbyt wiele czasu, ale nie są one prawie idealne. Wyszukaj błędy w gcc lub llvm w poszukiwaniu błędów „nieudanej optymalizacji”. Jest wiele. Ponadto, pisząc w asm, możesz łatwiej skorzystać z warunków wstępnych, takich jak „to wejście nie może być ujemne”, które trudno byłoby kompilatorowi udowodnić.
Peter Cordes,

48

W mojej pracy są trzy powody, dla których znam i używam asemblera. W kolejności ważności:

  1. Debugowanie - często otrzymuję kod biblioteki, który zawiera błędy lub niekompletną dokumentację. Rozumiem, co robi, wkraczając na poziomie zespołu. Muszę to robić mniej więcej raz w tygodniu. Używam go również jako narzędzia do debugowania problemów, w których moje oczy nie dostrzegają błędu idiomatycznego w C / C ++ / C #. Spoglądanie na zespół mija to.

  2. Optymalizacja - kompilator radzi sobie dość dobrze w optymalizacji, ale gram na innym boisku niż większość. Piszę kod przetwarzania obrazu, który zwykle zaczyna się od kodu, który wygląda następująco:

    for (int y=0; y < imageHeight; y++) {
        for (int x=0; x < imageWidth; x++) {
           // do something
        }
    }

    „zrób coś” zazwyczaj ma miejsce kilka milionów razy (tj. od 3 do 30). Skrobanie cykli w tej fazie „robienia czegoś” znacznie zwiększa wydajność. Zwykle nie zaczynam od tego - zwykle zaczynam od napisania kodu, aby najpierw działał, a następnie staram się refaktoryzować C, aby był naturalnie lepszy (lepszy algorytm, mniejsze obciążenie w pętli itp.). Zwykle muszę czytać asembler, aby zobaczyć, co się dzieje i rzadko muszę go pisać. Robię to może co dwa lub trzy miesiące.

  3. robienie czegoś, na co język mi nie pozwala. Należą do nich - uzyskanie architektury procesora i określonych funkcji procesora, dostęp do flag nie znajdujących się w CPU (stary, naprawdę chciałbym, żeby C dał ci dostęp do flagi carry), itp. Robię to może raz w roku lub dwóch latach.


Nie układasz pętli? :-)
Jon Harrop

1
@plinth: jak rozumiesz „cykle skrobania”?
lang2 24.04.13

@ lang2: oznacza to pozbycie się jak największej ilości zbędnego czasu spędzonego w wewnętrznej pętli - wszystko, czego kompilatorowi nie udało się wyciągnąć, co może obejmować użycie algebry do podniesienia mnożenia z jednej pętli, aby dodać wewnątrz, itp.
cokół

1
Kafelkowanie pętli wydaje się niepotrzebne, jeśli wykonujesz tylko jedno przejście danych.
James M. Lay

@ JamesM.Lay: Jeśli dotkniesz każdego elementu tylko raz, lepsze uporządkowanie może dać ci lokalizację przestrzenną. (np. użyj wszystkich bajtów dotkniętej linii pamięci podręcznej, zamiast zapętlać kolumny macierzy, używając jednego elementu na linię pamięci podręcznej).
Peter Cordes,

42

Tylko podczas korzystania z niektórych zestawów instrukcji specjalnego kompilator nie obsługuje.

Aby zmaksymalizować moc obliczeniową nowoczesnego procesora z wieloma potokami i predykcyjnym rozgałęzianiem, musisz ustrukturyzować program asemblowania w sposób, który sprawia, że ​​a) prawie niemożliwe jest napisanie przez człowieka b) jeszcze trudniejsze do utrzymania.

Ponadto lepsze algorytmy, struktury danych i zarządzanie pamięcią zapewnią co najmniej rząd wielkości wyższą wydajność niż mikrooptymalizacje, które można wykonać w asemblerze.


4
+1, mimo że ostatnie zdanie tak naprawdę nie należy do tej dyskusji - można by założyć, że asembler wchodzi w grę dopiero po zrealizowaniu wszystkich możliwych ulepszeń algorytmu itp.
mghie

18
@Matt: Ręcznie napisany ASM jest często o wiele lepszy na niektórych małych procesorach EE, które mają złą obsługę kompilatora dostawcy.
Zan Lynx

5
„Tylko przy użyciu niektórych zestawów instrukcji specjalnego przeznaczenia”? Prawdopodobnie nigdy wcześniej nie napisałeś ręcznie zoptymalizowanego kodu asm. Umiarkowanie intymna znajomość architektury, nad którą pracujesz, daje dużą szansę na wygenerowanie lepszego kodu (rozmiaru i szybkości) niż kompilator. Oczywiście, jak skomentował @mghie, zawsze zaczynasz kodować najlepsze algos, z którymi możesz się spotkać. Nawet w przypadku bardzo dobrych kompilatorów naprawdę musisz napisać swój kod C w sposób, który prowadzi kompilator do najlepszego skompilowanego kodu. W przeciwnym razie wygenerowany kod będzie nieoptymalny.
ysap

2
@ysap - na rzeczywistych komputerach (a nie małych, słabo wbudowanych układach scalonych) w rzeczywistym użyciu, „optymalny” kod nie będzie szybszy, ponieważ dla każdego dużego zestawu danych wydajność będzie ograniczona przez dostęp do pamięci i błędy stron ( a jeśli nie masz dużego zestawu danych, i tak będzie to szybkie i nie ma sensu go optymalizować) - w dzisiejszych czasach pracuję głównie w C # (nawet c), a wydajność osiąga dzięki menedżerowi kompaktowania pamięci - zważyć obciążenie związane z odśmiecaniem, kompaktowaniem i kompilacją JIT.
Nir,

4
+1 za stwierdzenie, że kompilatory (zwłaszcza JIT) mogą wykonać lepszą pracę niż ludzie, jeśli są zoptymalizowane pod kątem sprzętu, na którym są uruchomione.
Sebastian

38

Chociaż C jest „bliski” manipulacji 8-bitowymi, 16-bitowymi, 32-bitowymi, 64-bitowymi danymi na niskim poziomie, istnieje kilka operacji matematycznych nieobsługiwanych przez C, które często można wykonać elegancko w niektórych instrukcjach montażu zestawy:

  1. Mnożenie w punktach stałych: Iloczyn dwóch liczb 16-bitowych to liczba 32-bitowa. Ale reguły w C mówią, że iloczyn dwóch liczb 16-bitowych jest liczbą 16-bitową, a iloczyn dwóch liczb 32-bitowych jest liczbą 32-bitową - dolna połowa w obu przypadkach. Jeśli chcesz uzyskać górną połowę mnożnika 16 x 16 lub 32 x 32, musisz grać w gry z kompilatorem. Ogólna metoda polega na rzutowaniu na większą niż potrzebną szerokość bitu, pomnożeniu, przesunięciu w dół i ponownym rzutowaniu:

    int16_t x, y;
    // int16_t is a typedef for "short"
    // set x and y to something
    int16_t prod = (int16_t)(((int32_t)x*y)>>16);`

    W takim przypadku kompilator może być wystarczająco inteligentny, aby wiedzieć, że tak naprawdę próbujesz uzyskać górną połowę mnożenia 16x16 i zrobić dobrą rzecz z natywnym multiplikatorem 16x16 maszyny. Lub może to być głupie i wymagać wywołania biblioteki, aby wykonać mnożenie 32x32, co jest nadmierną przesadą, ponieważ potrzebujesz tylko 16 bitów produktu - ale standard C nie daje ci żadnej możliwości wyrażenia siebie.

  2. Niektóre operacje przesuwania bitów (rotacja / przenoszenie):

    // 256-bit array shifted right in its entirety:
    uint8_t x[32];
    for (int i = 32; --i > 0; )
    {
       x[i] = (x[i] >> 1) | (x[i-1] << 7);
    }
    x[0] >>= 1;

    Nie jest to zbyt nieeleganckie w C, ale znowu, chyba że kompilator jest wystarczająco inteligentny, aby zdawać sobie sprawę z tego, co robisz, wykona wiele „niepotrzebnej” pracy. Wiele zestawów instrukcji montażu pozwala obracać lub przesuwać w lewo / prawo z wynikiem w rejestrze przenoszenia, dzięki czemu można wykonać powyższe czynności w 34 instrukcjach: załaduj wskaźnik na początek tablicy, wyczyść przenoszenie i wykonaj 32 8- bit przesuwa się w prawo, używając automatycznego przyrostu wskaźnika.

    Dla innego przykładu, istnieją liniowe rejestry przesuwne sprzężenia zwrotnego (LFSR), które są elegancko wykonywane w asemblerze: weź kawałek N bitów (8, 16, 32, 64, 128 itd.), Przesuń całość o 1 (patrz wyżej) algorytm), a jeśli wynikowe przeniesienie wynosi 1, to XOR we wzorcu bitowym reprezentującym wielomian.

Powiedziawszy to, nie użyłbym tych technik, chyba że miałbym poważne ograniczenia wydajności. Jak powiedzieli inni, montaż jest znacznie trudniejszy do udokumentowania / debugowania / przetestowania / obsługi niż kod C: wzrost wydajności wiąże się z pewnymi poważnymi kosztami.

edycja: 3. Wykrywanie przepełnienia jest możliwe w asemblerze (tak naprawdę nie można tego zrobić w C), co znacznie ułatwia niektóre algorytmy.


23

Krótka odpowiedź? Czasami.

Technicznie każda abstrakcja ma swój koszt, a język programowania jest abstrakcją dla działania procesora. C jest jednak bardzo blisko. Lata temu pamiętam, jak się śmiałem, gdy zalogowałem się na swoje konto UNIX i otrzymałem następujący komunikat o fortunie (gdy takie rzeczy były popularne):

Język programowania C - język, który łączy elastyczność asemblera z jego potęgą.

To zabawne, bo to prawda: C jest jak przenośny język asemblera.

Warto zauważyć, że język asemblera działa tak, jak go piszesz. Istnieje jednak kompilator pomiędzy C a generowanym przez niego językiem asemblera, co jest niezwykle ważne, ponieważ szybkość twojego kodu C ma bardzo dużo wspólnego z tym, jak dobry jest twój kompilator.

Kiedy pojawił się gcc, jedną z rzeczy, które uczyniły go tak popularnym, było to, że często był o wiele lepszy niż kompilatory C dostarczane z wieloma komercyjnymi wersjami UNIX. Nie tylko był to ANSI C (żaden z tych śmieci K&R C), ale był bardziej niezawodny i zazwyczaj produkował lepszy (szybszy) kod. Nie zawsze, ale często.

Mówię ci to wszystko, ponieważ nie ma ogólnej reguły dotyczącej prędkości C i asemblera, ponieważ nie ma obiektywnego standardu dla C.

Podobnie, asembler różni się bardzo w zależności od używanego procesora, specyfikacji systemu, zestawu instrukcji i tak dalej. Historycznie istniały dwie rodziny architektury procesorów: CISC i RISC. Największym graczem w CISC była i nadal jest architektura Intel x86 (i zestaw instrukcji). RISC zdominowało świat UNIX (MIPS6000, Alpha, Sparc i tak dalej). CISC wygrał bitwę o serca i umysły.

W każdym razie popularną mądrością, kiedy byłem młodszym programistą, było to, że odręcznie napisane x86 może często być znacznie szybsze niż C, ponieważ sposób, w jaki działała architektura, miał złożoność, z której korzystał człowiek. Z drugiej strony RISC wydawało się zaprojektowane dla kompilatorów, więc nikt (wiedziałem) nie napisałby, że mówi asembler Sparc. Jestem pewien, że tacy ludzie istnieli, ale bez wątpienia obaj oszaleli i do tej pory zostali zinstytucjonalizowani.

Zestawy instrukcji są ważnym punktem nawet w tej samej rodzinie procesorów. Niektóre procesory Intel mają rozszerzenia takie jak SSE do SSE4. AMD miało własne instrukcje SIMD. Zaletą języka programowania, takiego jak C, było to, że ktoś mógł napisać swoją bibliotekę, aby była zoptymalizowana pod kątem dowolnego procesora, na którym pracujesz. To była ciężka praca w asemblerze.

W asemblerze można jeszcze wprowadzić optymalizacje, których żaden kompilator nie mógłby wykonać, a dobrze napisany algorytm asemblera będzie tak szybki lub szybszy niż jego odpowiednik C. Większe pytanie brzmi: czy warto?

Ostatecznie asembler był produktem swoich czasów i był bardziej popularny w czasach, gdy cykle procesora były drogie. Obecnie procesor, którego produkcja kosztuje 5–10 USD (Intel Atom), może zrobić wszystko, co tylko zechce. Jedynym prawdziwym powodem do napisania asemblera w tych dniach są rzeczy niskiego poziomu, takie jak niektóre części systemu operacyjnego (mimo że ogromna większość jądra Linuksa jest napisana w C), sterowniki urządzeń, ewentualnie urządzenia osadzone (chociaż C ma tam tendencję dominować też) i tak dalej. Lub tylko dla kopnięć (co jest nieco masochistyczne).


Było wiele osób, które używały asemblera ARM jako języka z wyboru na maszynach Acorn (wczesne lata 90-te). IIRC powiedzieli, że mały zestaw instrukcji Risc sprawił, że było łatwiej i przyjemniej. Ale podejrzewam, że dzieje się tak, ponieważ kompilator C spóźnił się z Acornem, a kompilator C ++ nigdy nie został ukończony.
Andrew M,

3
„... ponieważ nie ma subiektywnego standardu dla C.” Masz na myśli cel .
Thomas

@AndrewM: Tak, pisałem aplikacje w języku mieszanym w asemblerze BASIC i ARM przez około 10 lat. Nauczyłem się C w tym czasie, ale nie było to bardzo przydatne, ponieważ jest tak kłopotliwe jak asembler i wolniejsze. Norcroft dokonał niesamowitych optymalizacji, ale myślę, że zestaw instrukcji warunkowych stanowił problem dla dzisiejszych kompilatorów.
Jon Harrop

1
@AndrewM: cóż, tak naprawdę ARM jest rodzajem RISC wykonanym wstecz. Inne ISA RISC zostały zaprojektowane począwszy od tego, czego używałby kompilator. Wygląda na to, że ARM ISA został zaprojektowany zaczynając od tego, co zapewnia procesor (przesunięcie lufy, flagi stanu → ujawnijmy je w każdej instrukcji).
ninjalj

16

Przypadek użycia, który może już nie mieć zastosowania, ale dla twojej nerdowej przyjemności: na Amiga procesor i układy graficzne / audio walczyłyby o dostęp do określonego obszaru pamięci RAM (konkretnie pierwsze 2 MB pamięci RAM). Gdy więc masz tylko 2 MB pamięci RAM (lub mniej), wyświetlanie złożonej grafiki i odtwarzanie dźwięku może zabić wydajność procesora.

W asemblerze można przeplatać kod w tak sprytny sposób, że procesor spróbuje uzyskać dostęp do pamięci RAM tylko wtedy, gdy układy graficzne / audio są zajęte wewnętrznie (tj. Gdy magistrala jest wolna). Tak więc, zmieniając kolejność instrukcji, sprytnie wykorzystując pamięć podręczną procesora, taktowanie magistrali, można osiągnąć pewne efekty, które po prostu nie były możliwe przy użyciu języka wyższego poziomu, ponieważ trzeba było zsynchronizować każde polecenie, a nawet wstawić tu i tam NOP, aby zachować różne chipy z siebie radar.

To kolejny powód, dla którego instrukcja NOP (brak operacji - nic nie rób) procesora może faktycznie przyspieszyć działanie całej aplikacji.

[EDYCJA] Oczywiście technika zależy od konkretnej konfiguracji sprzętowej. To był główny powód, dla którego wiele gier Amigi nie radziło sobie z szybszymi procesorami: czas wykonywania instrukcji był wyłączony.


Amiga nie miała 16 MB pamięci RAM, więcej niż 512 kB do 2 MB w zależności od chipsetu. Ponadto wiele gier Amigi nie działało z szybszymi procesorami z powodu technik, które opisujesz.
bk1e

1
@ bk1e - Amiga wyprodukowała szeroką gamę różnych modeli komputerów, Amiga 500 dostarczana z ramą 512K rozszerzoną do 1 Meg w moim przypadku. amigahistory.co.uk/amiedevsys.html to amiga z 128Meg Ram
David Waters

@ bk1e: Stoję poprawiony. Moja pamięć może mnie zawieść, ale czy pamięć RAM układu nie była ograniczona do pierwszej 24-bitowej przestrzeni adresowej (tj. 16 MB)? A Fast został zamapowany powyżej tego?
Aaron Digulla

@Aaron Digulla: Wikipedia ma więcej informacji na temat różnic między pamięcią RAM / układem szybkim / wolnym: en.wikipedia.org/wiki/Amiga_Chip_RAM
bk1e

@ bk1e: Mój błąd. Procesor 68k miał tylko 24 linie adresowe, dlatego miałem 16 MB w głowie.
Aaron Digulla

15

Wskaż jeden, który nie jest odpowiedzią.
Nawet jeśli nigdy się w nim nie programujesz, uważam, że warto znać przynajmniej jeden zestaw instrukcji asemblera. Jest to część niekończących się poszukiwań programistów, aby dowiedzieć się więcej i tym samym być lepszym. Przydaje się również przy wchodzeniu w frameworki, do których nie masz kodu źródłowego i masz co najmniej ogólne pojęcie o tym, co się dzieje. Pomaga także zrozumieć JavaByteCode i .Net IL, ponieważ oba są podobne do asemblera.

Aby odpowiedzieć na pytanie, gdy masz małą ilość kodu lub dużo czasu. Najbardziej przydatny do stosowania we wbudowanych układach scalonych, gdzie niska złożoność układów i niska konkurencja w kompilatorach atakujących te układy mogą przechylić równowagę na korzyść ludzi. Również w przypadku urządzeń z ograniczeniami często wymieniasz rozmiar kodu / rozmiar pamięci / wydajność w sposób, który trudno byłoby poinstruować kompilator. np. wiem, że ta akcja użytkownika nie jest często wywoływana, więc będę mieć mały rozmiar kodu i słabą wydajność, ale ta inna funkcja, która wygląda podobnie, jest używana co sekundę, więc będę miał większy rozmiar kodu i większą wydajność. Jest to rodzaj kompromisu, z którego może skorzystać wykwalifikowany programista.

Chciałbym również dodać, że jest dużo pośredniego miejsca, w którym można kodować w kompilacji C i badać wyprodukowane Zgromadzenie, a następnie albo zmienić kod w C, albo dostosować i zachować jako asembler.

Mój przyjaciel pracuje na mikrokontrolerach, obecnie chipach do sterowania małymi silnikami elektrycznymi. Pracuje w kombinacji niskiego poziomu c i zestawu. Kiedyś powiedział mi o dobrym dniu w pracy, w którym zmniejszył główną pętlę z 48 instrukcji do 43. Stoi też przed wyborem, jak kod urósł, aby wypełnić 256k chip, a firma chce nowej funkcji, prawda?

  1. Usuń istniejącą funkcję
  2. Zmniejszenie rozmiaru niektórych lub wszystkich istniejących funkcji może być kosztem wydajności.
  3. Opowiedz się za przejściem na większy układ o wyższym koszcie, wyższym zużyciu energii i większej obudowie.

Chciałbym dodać jako komercyjny programista z dość dużym portfolio lub językami, platformami, rodzajami aplikacji, których nigdy nie czułem potrzeby nurkowania w pisaniu asemblera. Jak zawsze doceniałem wiedzę na ten temat. I czasami debuguje się w tym.

Wiem, że znacznie więcej odpowiedziałem na pytanie „dlaczego mam się uczyć asemblera”, ale uważam, że jest to ważniejsze pytanie, kiedy jest szybsze.

więc spróbujmy jeszcze raz Powinieneś pomyśleć o montażu

  • działa na niskim poziomie funkcji systemu operacyjnego
  • Praca na kompilatorze.
  • Praca na bardzo ograniczonym układzie, systemie osadzonym itp

Pamiętaj, aby porównać swój zestaw z generowanym kompilatorem, aby zobaczyć, który jest szybszy / mniejszy / lepszy.

David.


4
+1 za rozpatrzenie aplikacji osadzonych na małych chipach. Zbyt wielu inżynierów oprogramowania tutaj nie uważa wbudowanych lub uważa, że ​​oznacza to smartfon (32-bitowy, MB RAM, MB flash).
Martin

1
Aplikacje osadzone w czasie są doskonałym przykładem! Często pojawiają się dziwne instrukcje (nawet bardzo proste, takie jak avr sbii cbi), których kompilatory kiedyś (a czasem nadal nie wykorzystują), z powodu ograniczonej wiedzy o sprzęcie.
felixphew

15

Dziwię się, że nikt tego nie powiedział. strlen()Funkcja jest znacznie szybciej, jeśli napisane w montażu! W C najlepsze, co możesz zrobić, to

int c;
for(c = 0; str[c] != '\0'; c++) {}

podczas montażu możesz go znacznie przyspieszyć:

mov esi, offset string
mov edi, esi
xor ecx, ecx

lp:
mov ax, byte ptr [esi]
cmp al, cl
je  end_1
cmp ah, cl
je end_2
mov bx, byte ptr [esi + 2]
cmp bl, cl
je end_3
cmp bh, cl
je end_4
add esi, 4
jmp lp

end_4:
inc esi

end_3:
inc esi

end_2:
inc esi

end_1:
inc esi

mov ecx, esi
sub ecx, edi

długość jest w ecx. To porównuje 4 znaki na raz, więc jest 4 razy szybsze. I pomyśl, używając słowa eax i ebx o wysokim porządku, stanie się 8 razy szybsze niż poprzednia procedura w C!


3
Jak to porównać z tymi w strchr.nfshost.com/optimized_strlen_function ?
ninjalj

@ninjalj: są tym samym :) nie sądziłem, że można to zrobić w ten sposób w C. Myślę, że można to nieco poprawić
BlackBear

Przed każdym porównaniem w kodzie C jest jeszcze operacja bitowa AND. Możliwe, że kompilator byłby na tyle sprytny, by zredukować to do porównań wysokich i niskich bajtów, ale nie postawiłbym na to pieniędzy. W rzeczywistości istnieje szybszy algorytm pętli oparty na właściwości, która (word & 0xFEFEFEFF) & (~word + 0x80808080)wynosi zero, jeśli wszystkie bajty w słowie są niezerowe.
user2310967

@MichaWiedenmann prawda, powinienem załadować bx po porównaniu dwóch znaków w toporze. Dziękuję
BlackBear,

14

Operacje na macierzach przy użyciu instrukcji SIMD są prawdopodobnie szybsze niż kod generowany przez kompilator.


Niektóre kompilatory (VectorC, jeśli dobrze pamiętam) generują kod SIMD, więc nawet to prawdopodobnie nie jest już argumentem za użyciem kodu asemblera.
OregonGhost,

Kompilatory tworzą kod
rozpoznający

5
W wielu z tych sytuacji możesz użyć SSE intrisics zamiast montażu. To sprawi, że twój kod będzie bardziej przenośny (gcc visual c ++, 64bit, 32bit itp.) I nie będziesz musiał przypisywać rejestrów.
Laserallan

1
Jasne, że tak, ale pytanie nie zadało pytania, gdzie powinienem używać asemblera zamiast C. Mówiło, że kompilator C nie generuje lepszego kodu. Zakładałem, że źródło C nie korzysta z bezpośrednich wywołań SSE ani wbudowanego zestawu.
Mehrdad Afshari

9
Mehrdad ma jednak rację. Poprawienie SSE jest dość trudne dla kompilatora, a nawet w oczywistych (dla ludzi) sytuacjach większość kompilatorów go nie używa.
Konrad Rudolph

13

Nie mogę podać konkretnych przykładów, ponieważ było to zbyt wiele lat temu, ale było wiele przypadków, w których ręcznie napisany asembler mógł przewyższyć dowolny kompilator. Przyczyny:

  • Możesz odstąpić od konwencji wywoływania, przekazując argumenty do rejestrów.

  • Możesz dokładnie rozważyć sposób korzystania z rejestrów i uniknąć przechowywania zmiennych w pamięci.

  • W przypadku takich tabel skoków można uniknąć konieczności sprawdzania indeksu.

Zasadniczo, kompilatory wykonują całkiem niezłą robotę optymalizacyjną, i to prawie zawsze jest „wystarczająco dobre”, ale w niektórych sytuacjach (takich jak renderowanie grafiki), gdzie płacisz drogo za każdy cykl, możesz skorzystać ze skrótów, ponieważ znasz kod , gdzie kompilator nie mógł, ponieważ musi być po bezpiecznej stronie.

W rzeczywistości słyszałem o graficznym kodzie renderującym, w którym procedura, taka jak procedura rysowania linii lub wypełniania wielokątów, faktycznie generowała mały blok kodu maszynowego na stosie i wykonywała go tam, aby uniknąć ciągłego podejmowania decyzji o stylu linii, szerokości, wzorze itp.

To powiedziawszy, chcę, aby kompilator wygenerował dla mnie dobry kod asemblera, ale nie był zbyt sprytny, a oni w większości to robią. W rzeczywistości jedną z rzeczy, których nienawidzę w Fortranie, jest szyfrowanie kodu w celu „zoptymalizowania” go, zwykle bez większego celu.

Zwykle gdy aplikacje mają problemy z wydajnością, jest to spowodowane marnotrawstwem projektu. W dzisiejszych czasach nigdy nie polecałbym asemblera ze względu na wydajność, chyba że ogólna aplikacja została już dostrojona w calu swojego życia, wciąż nie była wystarczająco szybka i cały czas spędzała w ciasnych wewnętrznych pętlach.

Dodano: Widziałem wiele aplikacji napisanych w języku asemblera, a główną przewagą szybkości nad językiem takim jak C, Pascal, Fortran itp. Było to, że programista był znacznie bardziej ostrożny podczas kodowania w asemblerze. On lub ona będzie pisać około 100 wierszy kodu dziennie, niezależnie od języka, w języku kompilatora, który będzie równy 3 lub 400 instrukcji.


8
+1: „Możesz odstąpić od konwencji wywoływania”. Kompilatory C / C ++ mają tendencję do zasysania przy zwracaniu wielu wartości. Często używają formy sret, w której stos wywołujący przydziela ciągły blok dla struktury i przekazuje do niego odwołanie, aby odbiorca go wypełnił. Zwracanie wielu wartości w rejestrach jest kilka razy szybsze.
Jon Harrop

1
@Jon: Kompilatory C / C ++ robią to dobrze, gdy funkcja jest wstawiana (funkcje niewbudowane muszą być zgodne z ABI, nie jest to ograniczenie C i C ++, ale model łączący)
Ben Voigt

@BenVoigt: Oto licznik przykład flyingfrogblog.blogspot.co.uk/2012/04/…
Jon Harrop

2
Nie widzę wstawiania żadnego wywołania funkcji.
Ben Voigt

13

Kilka przykładów z mojego doświadczenia:

  • Dostęp do instrukcji, które nie są dostępne z C. Na przykład wiele architektur (takich jak x86-64, IA-64, DEC Alpha i 64-bitowe MIPS lub PowerPC) obsługuje mnożenie 64-bitowe na 64-bitowe, co daje wynik 128-bitowy. GCC niedawno dodało rozszerzenie zapewniające dostęp do takich instrukcji, ale przed tym montażem było wymagane. A dostęp do tej instrukcji może mieć ogromną różnicę w procesorach 64-bitowych podczas implementacji czegoś takiego jak RSA - czasami nawet o 4-krotny wzrost wydajności.

  • Dostęp do flag specyficznych dla procesora. Tym, który bardzo mnie ugryzł, jest flaga carry; podczas dodawania z wieloma precyzjami, jeśli nie masz dostępu do bitu przenoszenia procesora, musisz zamiast tego porównać wynik, aby zobaczyć, czy nie został on przepełniony, co wymaga 3-5 dodatkowych instrukcji na kończynę; i gorzej, które są dość szeregowe pod względem dostępu do danych, co zabija wydajność współczesnych superskalarnych procesorów. Podczas przetwarzania tysięcy takich liczb całkowitych z rzędu, możliwość korzystania z addc to ogromna wygrana (istnieją problemy superskalarne z rywalizacją o bit przenoszenia, ale współczesne procesory radzą sobie z tym całkiem dobrze).

  • SIMD. Nawet kompilatory autowektoryzujące potrafią robić tylko stosunkowo proste przypadki, więc jeśli chcesz dobrej wydajności SIMD, niestety często trzeba pisać kod bezpośrednio. Oczywiście możesz używać funkcji wewnętrznych zamiast asemblera, ale kiedy jesteś już na poziomie wewnętrznym, w zasadzie i tak piszesz asembler, używając kompilatora jako przydziału rejestrów i (nominalnie) harmonogramu instrukcji. (Zwykle używam funkcji wewnętrznych dla SIMD po prostu dlatego, że kompilator może generować prologi funkcji i tak dalej, więc mogę używać tego samego kodu w systemie Linux, OS X i Windows bez konieczności zajmowania się zagadnieniami ABI, takimi jak konwencje wywoływania funkcji, ale inne poza tym cechy wewnętrzne SSE naprawdę nie są zbyt ładne - Altivec wydają się lepsze, choć nie mam z nimi dużego doświadczenia).bitslicing AES lub SIMD error error - można sobie wyobrazić kompilator, który może analizować algorytmy i generować taki kod, ale wydaje mi się, że taki inteligentny kompilator jest co najmniej 30 lat od istnienia (w najlepszym razie).

Z drugiej strony, maszyny wielordzeniowe i systemy rozproszone przesunęły wiele z największych zwycięstw w drugą stronę - uzyskaj dodatkowe 20% przyspieszenia pisania wewnętrznych pętli w zespole lub 300% przez uruchomienie ich na wielu rdzeniach, lub 10000% przez uruchamiając je w klastrze maszyn. I oczywiście optymalizacje na wysokim poziomie (takie jak kontrakty futures, zapamiętywanie itp.) Są często znacznie łatwiejsze w języku wyższego poziomu, takim jak ML lub Scala niż C lub asm, i często mogą zapewnić znacznie większą wygraną. Jak zwykle trzeba dokonać kompromisów.


2
@Dennis, dlatego napisałem: „Oczywiście możesz używać funkcji wewnętrznych zamiast asemblera, ale kiedy jesteś już na poziomie wewnętrznym, w zasadzie i tak piszesz asembler, używając kompilatora jako przydziału rejestrów i (nominalnie) harmonogramu instrukcji”.
Jack Lloyd,

Ponadto, wewnętrzny kod SIMD jest mniej czytelny niż ten sam kod napisany w asemblerze: Znaczna część kodu SIMD opiera się na niejawnej reinterpretacji danych w wektorach, co jest PITA związane z typami kompilatora typów danych.
cmaster

10

Ciasne pętle, na przykład podczas zabawy obrazami, ponieważ obraz może zawierać miliony pikseli. Siadanie i zastanawianie się, jak najlepiej wykorzystać ograniczoną liczbę rejestrów procesorów, może mieć znaczenie. Oto próbka z prawdziwego życia:

http://danbystrom.se/2008/12/22/optimizing-away-ii/

Wówczas często procesory mają pewne ezoteryczne instrukcje, które są zbyt wyspecjalizowane, aby kompilator mógł nimi zawracać głowę, ale czasami programista asemblera może z nich skorzystać. Weźmy na przykład instrukcję XLAT. Naprawdę świetnie, jeśli potrzebujesz przeglądać tabele w pętli, a tabela jest ograniczona do 256 bajtów!

Zaktualizowano: Och, przyjdź pomyśleć o tym, co jest najważniejsze, gdy mówimy ogólnie o pętlach: kompilator często nie ma pojęcia, ile iteracji będzie to typowy przypadek! Tylko programista wie, że pętla będzie iterowana WIELU razy i dlatego korzystne będzie przygotowanie się do niej z dodatkowym nakładem pracy, lub jeśli będzie ona powtarzana tak mało razy, że konfiguracja faktycznie potrwa dłużej niż iteracje spodziewany.


3
Optymalizacja kierowana profilem dostarcza kompilatorowi informacji o tym, jak często używana jest pętla.
Zan Lynx

10

Częściej niż myślisz, C musi robić rzeczy, które wydają się zbędne z punktu widzenia kodera asemblera tylko dlatego, że tak mówią standardy C.

Na przykład promocja liczb całkowitych. Jeśli chcesz przesunąć zmienną char w C, zwykle można oczekiwać, że kod wykona właśnie to, przesunięcie o jeden bit.

Jednak standardy wymuszają na kompilatorze wykonanie przed rozszerzeniem znaku rozciągającego się na int i następnie przycinają wynik do char, co może skomplikować kod w zależności od architektury procesora docelowego.


Wysokiej jakości kompilatory dla małych mikroskali od lat unikają przetwarzania górnych części wartości w przypadkach, w których takie postępowanie nigdy nie mogłoby znacząco wpłynąć na wyniki. Reguły promocji powodują problemy, ale najczęściej w przypadkach, gdy kompilator nie ma możliwości dowiedzenia się, które przypadki narożne są i nie są istotne.
supercat

9

Nie wiesz, czy dobrze napisany kod C jest naprawdę szybki, jeśli nie spojrzałeś na dezasemblację tego, co wytwarza kompilator. Wiele razy na to patrzysz i widzisz, że „dobrze napisany” był subiektywny.

Więc nie trzeba pisać w asemblerze, aby uzyskać najszybszy kod, ale z pewnością warto znać asembler z tego samego powodu.


2
„Więc nie trzeba pisać w asemblerze, aby uzyskać najszybszy kod w historii”. Cóż, nie widziałem, żeby kompilator optymalnie działał w każdym przypadku, co nie było trywialne. Doświadczony człowiek potrafi lepiej niż kompilator w praktycznie wszystkich przypadkach. Dlatego absolutnie konieczne jest pisanie w asemblerze, aby uzyskać „najszybszy kod w historii”.
cmaster

@cmaster Z mojego doświadczenia wynika, że ​​dane wyjściowe kompilatora są dobrze losowe. Czasami jest naprawdę dobry i optymalny, a czasem „jak można wyrzucić te śmieci”.
sharptooth

9

Przeczytałem wszystkie odpowiedzi (ponad 30) i nie znalazłem prostego powodu: asembler jest szybszy niż C, jeśli przeczytałeś i ćwiczyłeś Podręcznik referencyjny optymalizacji architektury Intel® 64 i IA-32 , więc powód, dla którego montaż może być wolniej jest, że ludzie, którzy piszą taki wolniejszy zestaw, nie przeczytali Podręcznika optymalizacji .

W dawnych dobrych czasach Intel 80286 każda instrukcja była wykonywana przy stałej liczbie cykli procesora, ale od czasu wydania Pentium Pro w 1995 r. Procesory Intel stały się superskalarne, wykorzystując złożone przetwarzanie potokowe: wykonywanie poza zamówieniem i zmiana nazwy rejestru. Wcześniej, na Pentium, wyprodukowanym w 1993 r., Istniały rurociągi U i V: podwójne linie rur, które mogłyby wykonywać dwie proste instrukcje w jednym cyklu zegara, jeśli nie były od siebie zależne; ale to nie było nic do porównania z tym, co to jest wykonywanie poza zamówieniem i zmiana nazwy pojawiła się w Pentium Pro i prawie nie zmieniła się w dzisiejszych czasach.

Aby wyjaśnić w kilku słowach, najszybszy kod jest tam, gdzie instrukcje nie zależą od poprzednich wyników, np. Zawsze powinieneś wyczyścić całe rejestry (przez movzx) lub użyć add rax, 1zamiast tego lubinc rax usunąć zależność od poprzedniego stanu flag itp.

Możesz przeczytać więcej na temat wykonywania poza zamówieniem i zmiany nazwy rejestru, jeśli czas na to pozwala, w Internecie dostępnych jest wiele informacji.

Istnieją również inne ważne kwestie, takie jak przewidywanie gałęzi, liczba jednostek ładowania i przechowywania, liczba bramek, które wykonują mikrooperacje itp., Ale najważniejszą rzeczą do rozważenia jest mianowicie wykonanie poza kolejnością.

Większość ludzi po prostu nie wie o wykonywaniu poza kolejnością, więc piszą swoje programy asemblerowe, jak w przypadku 80286, oczekując, że wykonanie instrukcji zajmie określony czas niezależnie od kontekstu; podczas gdy kompilatory C są świadome wykonywania poza kolejnością i poprawnie generują kod. Dlatego kod takich nieświadomych ludzi jest wolniejszy, ale jeśli się dowiesz, Twój kod będzie szybszy.


8

Myślę, że ogólnym przypadkiem, gdy asembler jest szybszy, jest to, że inteligentny programista asemblera patrzy na dane wyjściowe kompilatora i mówi: „jest to kluczowa ścieżka dla wydajności i mogę to napisać, aby być bardziej wydajnym”, a następnie ta osoba poprawia ten asembler lub przepisuje go od zera.


7

Wszystko zależy od obciążenia pracą.

W codziennych operacjach C i C ++ są w porządku, ale są pewne obciążenia (wszelkie transformacje obejmujące wideo (kompresja, dekompresja, efekty graficzne itp.)), Które wymagają złożenia, aby były wydajne.

Zazwyczaj wymagają one również stosowania specyficznych dla procesora rozszerzeń mikroukładów (MME / MMX / SSE / cokolwiek), które są dostosowane do tego rodzaju operacji.


6

Mam operację transpozycji bitów, która musi zostać wykonana, na 192 lub 256 bitach co przerwanie, co dzieje się co 50 mikrosekund.

Dzieje się tak za pomocą stałej mapy (ograniczenia sprzętowe). Wykonanie C zajęło około 10 mikrosekund. Kiedy przetłumaczyłem to na asembler, biorąc pod uwagę specyficzne cechy tej mapy, specyficzne buforowanie rejestru i użycie operacji zorientowanych na bity; wykonanie zajęło mniej niż 3,5 mikrosekundy.




5

Prosta odpowiedź ... Ten, kto dobrze zna asemblację (znany również jako referencja i korzysta z każdej małej pamięci podręcznej procesora i funkcji potoku itp.), Jest w stanie wygenerować znacznie szybszy kod niż jakikolwiek inny kompilator.

Jednak różnica w tych dniach po prostu nie ma znaczenia w typowym zastosowaniu.


1
Zapomniałeś powiedzieć „mając dużo czasu i wysiłku” oraz „tworząc koszmar utrzymania”. Mój kolega pracował nad optymalizacją krytycznej pod względem wydajności sekcji kodu systemu operacyjnego, i pracował w C znacznie więcej niż przy montażu, ponieważ pozwoliło mu to zbadać wpływ zmian wysokiego poziomu na wydajność w rozsądnych ramach czasowych.
Artelius

Zgadzam się. Czasami używasz makr i skryptów do generowania kodu asemblera w celu zaoszczędzenia czasu i szybkiego rozwoju. Obecnie większość asemblerów ma makra; jeśli nie, możesz zrobić (prosty) preprocesor makra za pomocą (dość prostego skryptu RegEx) Perla.

To. Dokładnie. Kompilator do pokonania ekspertów domeny nie został jeszcze wynaleziony.
cmaster

4

Jedną z możliwości wersji CP / M-86 programu PolyPascal (od siostrzanego do Turbo Pascal) było zastąpienie funkcji „use-bios-to-output-character-to-the-screen” w języku maszynowym, który w istocie podano x i y oraz ciąg znaków, który należy tam umieścić.

To pozwoliło zaktualizować ekran znacznie, znacznie szybciej niż wcześniej!

W pliku binarnym było miejsce na osadzenie kodu maszynowego (kilkaset bajtów) i były tam też inne rzeczy, więc konieczne było ściśnięcie jak najwięcej.

Okazuje się, że ponieważ ekran miał wymiary 80 x 25, obie współrzędne mogły zmieścić się w jednym bajcie, więc oba mogły zmieścić się w dwubajtowym słowie. Pozwoliło to na wykonanie obliczeń potrzebnych w mniejszej liczbie bajtów, ponieważ pojedynczy dodatek może manipulować obiema wartościami jednocześnie.

Według mojej wiedzy nie ma kompilatorów C, które mogłyby łączyć wiele wartości w rejestrze, wykonywać na nich instrukcje SIMD i rozdzielać je później (i nie sądzę, że instrukcje maszyny i tak będą krótsze).


4

Jeden z bardziej znanych fragmentów asemblera pochodzi z pętli mapowania tekstur Michaela Abrasha ( szczegółowo opisanej tutaj ):

add edx,[DeltaVFrac] ; add in dVFrac
sbb ebp,ebp ; store carry
mov [edi],al ; write pixel n
mov al,[esi] ; fetch pixel n+1
add ecx,ebx ; add in dUFrac
adc esi,[4*ebp + UVStepVCarry]; add in steps

Obecnie większość kompilatorów wyraża zaawansowane instrukcje specyficzne dla procesora jako elementy wewnętrzne, tj. Funkcje, które są kompilowane do rzeczywistej instrukcji. MS Visual C ++ obsługuje elementy wewnętrzne dla MMX, SSE, SSE2, SSE3 i SSE4, więc musisz mniej martwić się o zejście do montażu, aby skorzystać z instrukcji specyficznych dla platformy. Visual C ++ może również wykorzystać rzeczywistą architekturę, na którą celujesz, z odpowiednim ustawieniem / ARCH.


Co więcej, te cechy wewnętrzne SSE są określone przez Intela, więc w rzeczywistości są dość przenośne.
James

4

Biorąc pod uwagę odpowiedniego programistę, programy Asemblera mogą zawsze być tworzone szybciej niż ich odpowiedniki C (przynajmniej marginalnie). Trudno byłoby stworzyć program w języku C, w którym nie można było pobrać co najmniej jednej instrukcji asemblera.


Byłoby to nieco bardziej poprawne: „Trudno byłoby stworzyć nietrywialny program C, w którym ...” Alternatywnie można powiedzieć: „Trudno byłoby znaleźć program C w prawdziwym świecie , w którym ...” Punkt jest , istnieją trywialne pętle, dla których kompilatory generują optymalną wydajność. Niemniej jednak dobra odpowiedź.
cmaster


4

gcc stał się szeroko stosowanym kompilatorem. Ogólnie jego optymalizacje nie są tak dobre. Znacznie lepiej niż przeciętny programista zajmujący się pisaniem asemblerów, ale dla prawdziwej wydajności, nie tak dobrze. Istnieją kompilatory, które są po prostu niesamowite w kodzie, który produkują. Tak więc ogólną odpowiedzią będzie wiele miejsc, w których można wejść do wyjścia kompilatora i dostosować asembler pod kątem wydajności i / lub po prostu ponownie napisać procedurę od zera.


8
GCC wykonuje wyjątkowo inteligentne optymalizacje „niezależne od platformy”. Jednak nie jest tak dobry w wykorzystywaniu poszczególnych zestawów instrukcji w pełnym zakresie. W przypadku takiego przenośnego kompilatora wykonuje on bardzo dobrą robotę.
Artelius

2
Zgoda. Jego przenośność, nadchodzące języki i wychodzące cele są niesamowite. Bycie przenośnym może i naprawdę przeszkadza w byciu jednym językiem lub celem. Możliwości lepszego radzenia sobie przez człowieka są zatem możliwe w przypadku konkretnej optymalizacji określonego celu.
old_timer

+1: GCC z pewnością nie jest konkurencyjny w generowaniu szybkiego kodu, ale nie jestem pewien, czy to dlatego, że jest przenośny. LLVM jest przenośny i widziałem, że generuje kod 4x szybciej niż GCC.
Jon Harrop

Wolę GCC, ponieważ jest solidny od wielu lat, a ponadto jest dostępny dla prawie każdej platformy, na której można uruchomić nowoczesny przenośny kompilator. Niestety nie byłem w stanie zbudować LLVM (Mac OS X / PPC), więc prawdopodobnie nie będę mógł się na nią przełączyć. Jedną z dobrych rzeczy w GCC jest to, że jeśli piszesz kod, który buduje się w GCC, najprawdopodobniej trzymasz się standardów i masz pewność, że można go zbudować na prawie każdą platformę.

4

Longpoke, jest tylko jedno ograniczenie: czas. Jeśli nie masz zasobów, aby zoptymalizować każdą zmianę kodu i poświęcić czas na przydzielanie rejestrów, zoptymalizować kilka wycieków, a co nie, kompilator wygrywa za każdym razem. Dokonujesz modyfikacji kodu, rekompilujesz i mierzysz. Powtórzyć w razie potrzeby.

Możesz także wiele zrobić na wysokim poziomie. Również sprawdzenie wynikowego zestawu może dać WRAŻENIE, że kod jest badziewny, ale w praktyce będzie działał szybciej niż myślisz, że byłby szybszy. Przykład:

int y = dane [i]; // zrób kilka rzeczy tutaj .. call_function (y, ...);

Kompilator odczyta dane, wypchnie je na stos (rozleje), a następnie odczyta ze stosu i przekaże jako argument. Brzmi gówno? W rzeczywistości może to być bardzo skuteczna kompensacja opóźnień i skutkować szybszym uruchomieniem.

// zoptymalizowana wersja funkcja call_funkcja (dane [i], ...); // mimo wszystko nie tak zoptymalizowany ..

Ideą zoptymalizowanej wersji było zmniejszenie presji rejestru i uniknięcie rozlania. Ale tak naprawdę wersja „gówniana” była szybsza!

Patrząc na kod asemblera, po prostu patrząc na instrukcje i wyciągając wniosek: więcej instrukcji, wolniej, byłoby błędnym osądem.

Należy zwrócić uwagę: wielu ekspertów montażowych uważa , że dużo wie, ale bardzo mało. Reguły również zmieniają się z architektury na następną. Na przykład nie ma kodu x86 w srebrnej kuli, który zawsze jest najszybszy. W te dni lepiej stosować reguły praktyczne:

  • pamięć jest wolna
  • pamięć podręczna jest szybka
  • spróbuj lepiej użyć pamięci podręcznej
  • jak często będziesz tęsknić? czy masz strategię kompensacji opóźnień?
  • możesz wykonać 10-100 instrukcji ALU / FPU / SSE dla pojedynczego braku pamięci podręcznej
  • architektura aplikacji jest ważna ..
  • .. ale to nie pomaga, gdy problem nie dotyczy architektury

Zaufanie do kompilatora w magiczny sposób przekształcającego źle przemyślany kod C / C ++ w „teoretycznie optymalny” kod jest również życzeniem. Musisz znać kompilator i łańcuch narzędzi, których używasz, jeśli zależy ci na „wydajności” na tym niskim poziomie.

Kompilatory w C / C ++ na ogół nie są zbyt dobre w ponownym zamawianiu podwyrażeń, ponieważ funkcje mają efekty uboczne, na początek. Języki funkcjonalne nie cierpią z powodu tego zastrzeżenia, ale nie pasują tak dobrze do obecnego ekosystemu. Istnieją opcje kompilatora pozwalające na swobodne reguły precyzji, które pozwalają na zmianę kolejności operacji przez kompilator / linker / generator kodu.

Ten temat jest trochę ślepy zaułek; dla większości nie ma to znaczenia, a reszta i tak wie, co robi.

Wszystko sprowadza się do tego: „aby zrozumieć, co robisz”, to trochę różni się od wiedzy o tym, co robisz.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.