(A + B + C) ≠ (A + C + B) i zmiana kolejności kompilatora


108

Dodanie dwóch 32-bitowych liczb całkowitych może spowodować przepełnienie liczb całkowitych:

uint64_t u64_z = u32_x + u32_y;

Tego przepełnienia można uniknąć, jeśli jedna z 32-bitowych liczb całkowitych jest najpierw rzutowana lub dodawana do 64-bitowej liczby całkowitej.

uint64_t u64_z = u32_x + u64_a + u32_y;

Jeśli jednak kompilator zdecyduje się zmienić kolejność dodawania:

uint64_t u64_z = u32_x + u32_y + u64_a;

nadal może wystąpić przepełnienie całkowitoliczbowe.

Czy kompilatorom wolno dokonywać takiej zmiany kolejności, czy też możemy im ufać, że zauważą niespójność wyników i zachowają kolejność wyrażeń bez zmian?


15
W rzeczywistości nie pokazujesz przepełnienia liczb całkowitych, ponieważ wydajesz się być dodanymi uint32_twartościami - które nie przepełniają się, zawijają się. To nie są różne zachowania.
Martin Bonner wspiera Monikę

5
Zobacz sekcję 1.9 standardów c ++, która bezpośrednio odpowiada na twoje pytanie (jest nawet przykład, który jest prawie dokładnie taki sam jak twój).
Holt

3
@Tal: Jak już powiedzieli inni: nie ma przepełnienia liczb całkowitych. Niepodpisane są zdefiniowane do zawijania, dla podpisania jest to niezdefiniowane zachowanie, więc każda implementacja będzie działać, w tym demony nosowe.
zbyt szczery jak na tę stronę.

5
@Tal: Nonsens! Jak już pisałem: standard jest bardzo czytelny i wymaga zawijania, a nie nasycania (byłoby to możliwe z sygnowanym, bo to jest UB jak na standard.
zbyt szczery jak na tę stronę.

15
@rustyx: Niezależnie od tego, czy nazwiesz to zawijaniem, czy przepełnieniem, pozostaje kwestia, która ((uint32_t)-1 + (uint32_t)1) + (uint64_t)0skutkuje 0, podczas gdy (uint32_t)-1 + ((uint32_t)1 + (uint64_t)0)wyniki są 0x100000000, a te dwie wartości nie są równe. Dlatego ważne jest, czy kompilator może zastosować tę transformację. Ale tak, standard używa słowa „przepełnienie” tylko dla liczb całkowitych ze znakiem, a nie bez znaku.
Steve Jessop,

Odpowiedzi:


84

Jeśli optymalizator dokona takiej zmiany kolejności, nadal jest on powiązany ze specyfikacją C, więc taka zmiana kolejności będzie wyglądać następująco:

uint64_t u64_z = (uint64_t)u32_x + (uint64_t)u32_y + u64_a;

Racjonalne uzasadnienie:

Zaczynamy od

uint64_t u64_z = u32_x + u64_a + u32_y;

Dodawanie odbywa się od lewej do prawej.

Reguły promocji liczb całkowitych określają, że w pierwszym dodatku w oryginalnym wyrażeniu u32_xzostanie podwyższony poziom uint64_t. W drugim dodatku u32_yzostanie również awansowany na uint64_t.

Tak więc, aby uzyskać zgodność ze specyfikacją C, każdy Optimiser musi wspierać u32_xi u32_yna 64-bitowe wartości znaku. Jest to równoważne z dodaniem obsady. (Właściwa optymalizacja nie jest wykonywana na poziomie C, ale używam notacji C, ponieważ jest to notacja, którą rozumiemy).


Czy to nie jest lewicowe skojarzenie, więc (u32_x + u32_t) + u64_a?
Bezużyteczne

12
@Useless: Klas przesłał wszystko do wersji 64-bitowej. Teraz kolejność nie ma żadnego znaczenia. Kompilator nie musi przestrzegać asocjatywności, po prostu musi wygenerować dokładnie taki sam wynik, jak gdyby to zrobił.
gnasher729

2
Wydaje się, że kod OP byłby oceniany w ten sposób, co nie jest prawdą.
Bezużyteczne

@Klas - zadbaj o wyjaśnienie, dlaczego tak się dzieje i jak dokładnie dotrzesz do próbki kodu?
rustyx

1
@rustyx To wymagało wyjaśnienia. Dzięki za popychanie mnie do dodania jednego.
Klas Lindbäck,

28

Kompilator może zmienić kolejność tylko w ramach reguły as if . Oznacza to, że jeśli zmiana kolejności zawsze da ten sam wynik, co określone uporządkowanie, to jest to dozwolone. W przeciwnym razie (jak w twoim przykładzie) nie.

Na przykład, biorąc pod uwagę następujące wyrażenie

i32big1 - i32big2 + i32small

który został starannie skonstruowany, aby odjąć dwie wartości, o których wiadomo, że są duże, ale podobne, a dopiero potem dodać drugą małą wartość (unikając w ten sposób przepełnienia), kompilator może zmienić kolejność na:

(i32small - i32big2) + i32big1

i polegać na fakcie, że platforma docelowa używa arytmetyki z dwoma uzupełnieniami z zawijaniem, aby zapobiec problemom. (Taka zmiana kolejności może być sensowna, jeśli kompilator jest wciśnięty dla rejestrów, a zdarza się, że i32smallma już rejestr).


Przykład OP używa typów bez znaku. i32big1 - i32big2 + i32smallimplikuje typy ze znakiem. W grę wchodzą dodatkowe obawy.
chux - Przywróć Monikę

@chux Absolutnie. Chodziło mi o to, że chociaż nie mogłem pisać (i32small-i32big2) + i32big1(ponieważ może to spowodować UB), kompilator może skutecznie zmienić kolejność, ponieważ kompilator może mieć pewność, że zachowanie będzie poprawne.
Martin Bonner wspiera Monikę

3
@chux: Dodatkowe obawy, takie jak UB, nie wchodzą w grę, ponieważ mówimy o zmianie kolejności kompilatora zgodnie z zasadą as-if. Określony kompilator może skorzystać ze znajomości własnego zachowania związanego z przepełnieniem.
MSalters

16

Istnieje zasada „jak gdyby” w C, C ++ i Objective-C: kompilator może robić, co tylko zechce, o ile żaden zgodny program nie może stwierdzić różnicy.

W tych językach a + b + c oznacza to samo, co (a + b) + c. Jeśli potrafisz odróżnić to od na przykład a + (b + c), wówczas kompilator nie może zmienić kolejności. Jeśli nie możesz odróżnić, kompilator może zmienić kolejność, ale to w porządku, ponieważ nie możesz odróżnić różnicy.

W twoim przykładzie, gdy b = 64 bity, a i c 32 bity, kompilator będzie mógł obliczyć (b + a) + c lub nawet (b + c) + a, ponieważ nie możesz odróżnić różnicy, ale nie (a + c) + b, ponieważ możesz dostrzec różnicę.

Innymi słowy, kompilator nie może zrobić niczego, co spowodowałoby, że kod zachowywałby się inaczej niż powinien. Nie jest wymagane, aby produkować kod, który uważasz, że to produkować, albo że uważasz powinien produkować, ale kod będzie Ci dokładnie te wyniki należy.


Ale z dużym zastrzeżeniem; kompilator może swobodnie zakładać żadne niezdefiniowane zachowanie (w tym przypadku przepełnienie). Jest to podobne do sposobu, w jaki if (a + 1 < a)można zoptymalizować kontrolę przepełnienia .
csiz

7
@csiz ... na podpisanych zmiennych. Zmienne bez znaku mają dobrze zdefiniowaną semantykę przepełnienia (zawijanie).
Gavin S. Yancey

7

Cytując ze standardów :

[Uwaga: Operatory mogą być przegrupowane zgodnie ze zwykłymi regułami matematycznymi tylko wtedy, gdy są one rzeczywiście asocjatywne lub przemienne.7 Na przykład w poniższym fragmencie int a, b;

/∗ ... ∗/
a = a + 32760 + b + 5;

wyrażenie wyrażenie zachowuje się dokładnie tak samo jak

a = (((a + 32760) + b) + 5);

ze względu na asocjatywność i pierwszeństwo tych operatorów. W ten sposób wynik sumy (a + 32760) jest następnie dodawany do b, a wynik ten jest następnie dodawany do 5, co daje w wyniku wartość przypisaną do a. Na maszynie, w której przepełnienia powodują wyjątek i w którym zakres wartości reprezentowanych przez int to [-32768, + 32767], implementacja nie może przepisać tego wyrażenia jako

a = ((a + b) + 32765);

ponieważ jeśli wartości a i b wynosiłyby odpowiednio -32754 i -15, suma a + b spowodowałaby wyjątek, podczas gdy pierwotne wyrażenie nie; nie można też przepisać wyrażenia jako

a = ((a + 32765) + b);

lub

a = (a + (b + 32765));

ponieważ wartości a i b mogły wynosić odpowiednio 4 i -8 lub -17 i 12. Jednak na maszynie, w której przepełnienia nie powodują wyjątku i na której wyniki przepełnienia są odwracalne, powyższa instrukcja wyrażenia może zostać przepisany przez implementację w dowolny z powyższych sposobów, ponieważ wystąpi ten sam wynik. - notatka końcowa]


4

Czy kompilatorom wolno dokonywać takiej zmiany kolejności, czy też możemy im ufać, że zauważą niespójność wyników i zachowają kolejność wyrażeń bez zmian?

Kompilator może zmienić kolejność tylko wtedy, gdy daje ten sam wynik - tutaj, jak zauważyłeś, nie.


Możliwe jest napisanie szablonu funkcji, jeśli chcesz taki, który promuje wszystkie argumenty std::common_typeprzed dodaniem - byłoby to bezpieczne i nie polegałoby na kolejności argumentów ani ręcznym rzutowaniu, ale jest dość niezgrabne.


Wiem, że powinno się używać rzutowania jawnego, ale chciałbym poznać zachowanie kompilatorów, gdy takie rzutowanie zostało omyłkowo pominięte.
Tal,

1
Jak powiedziałem, bez wyraźnego rzutowania: lewe dodanie jest wykonywane jako pierwsze, bez integralnej promocji, a zatem podlega zawijaniu. Wynikiem tego, ewentualnie dodatkowo owinięta jest następnie awansował na uint64_tdodatek do prawej skrajnej wartości.
Bezużyteczne

Twoje wyjaśnienie zasady „as-if” jest całkowicie błędne. Na przykład język C określa, jakie operacje powinny być wykonywane na abstrakcyjnej maszynie. Zasada „jak gdyby” pozwala mu robić absolutnie wszystko, na co ma ochotę, o ile nikt nie jest w stanie dostrzec różnicy.
gnasher729

Oznacza to, że kompilator może robić, co chce, o ile wynik jest taki sam, jak określony przez lewostronną asocjatywność i przedstawione reguły konwersji arytmetycznej.
Bezużyteczne

1

To zależy od szerokości bitowej unsigned/int.

Poniższe 2 nie są takie same (gdy unsigned <= 32bity). u32_x + u32_ystaje się 0.

u64_a = 0; u32_x = 1; u32_y = 0xFFFFFFFF;
uint64_t u64_z = u32_x + u64_a + u32_y;
uint64_t u64_z = u32_x + u32_y + u64_a;  // u32_x + u32_y carry does not add to sum.

Są takie same (jeśli są unsigned >= 34bitami). Promocje całkowite powodowały u32_x + u32_ydodawanie do 64-bitowej matematyki. Porządek nie ma znaczenia.

To jest UB (gdy unsigned == 33bity). Promocje w postaci liczb całkowitych powodowały dodawanie do 33-bitowej matematyki ze znakiem, a przepełnienie ze znakiem to UB.

Czy kompilatorom wolno dokonywać takiej zmiany kolejności ...?

(32-bitowa matematyka): Ponowna kolejność tak, ale muszą wystąpić te same wyniki, więc nie jest to propozycja ponownego zamówienia OP. Poniżej są takie same

// Same
u32_x + u64_a + u32_y;
u64_a + u32_x + u32_y;
u32_x + (uint64_t) u32_y + u64_a;
...

// Same as each other below, but not the same as the 3 above.
uint64_t u64_z = u32_x + u32_y + u64_a;
uint64_t u64_z = u64_a + (u32_x + u32_y);

... czy możemy im ufać, że zauważą niespójność wyniku i zachowają kolejność wyrażeń bez zmian?

Zaufaj tak, ale cel kodowania OP nie jest krystalicznie jasny. Czy u32_x + u32_ycarry powinno się przyczyniać? Jeśli OP chce tego wkładu, kod powinien być

uint64_t u64_z = u64_a + u32_x + u32_y;
uint64_t u64_z = u32_x + u64_a + u32_y;
uint64_t u64_z = u32_x + (u32_y + u64_a);

Ale nie

uint64_t u64_z = u32_x + u32_y + u64_a;
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.