W komentarzach dotyczących niektórych szczegółów / tła było wiele błędnych domysłów.
Patrzysz na zoptymalizowaną implementację C w glibc zoptymalizowaną pod kątem awarii. (Dla ISA, które nie mają odręcznej implementacji asm) . Lub stara wersja tego kodu, który wciąż znajduje się w drzewie źródeł glibc. https://code.woboq.org/userspace/glibc/string/strlen.c.html to przeglądarka kodów oparta na bieżącym drzewie git glibc. Najwyraźniej jest nadal używany przez kilka głównych celów glibc, w tym MIPS. (Dzięki @ wyzwolenie).
W popularnych programach ISA, takich jak x86 i ARM, glibc używa ręcznie napisanego asm
Motywacja do zmiany czegokolwiek w tym kodzie jest więc mniejsza niż mogłoby się wydawać.
Ten kod bithack ( https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord ) nie jest tym, co faktycznie działa na twoim serwerze / komputerze stacjonarnym / laptopie / smartfonie. Jest to lepsze niż naiwna pętla bajt po czasie, ale nawet ten bithack jest dość zły w porównaniu do wydajnego asm dla współczesnych procesorów (szczególnie x86, gdzie AVX2 SIMD pozwala na sprawdzenie 32 bajtów za pomocą kilku instrukcji, pozwalając od 32 do 64 bajtów na zegar cykl w głównej pętli, jeśli dane są gorące w pamięci podręcznej L1d na nowoczesnych procesorach z obciążeniem wektora 2 / zegar i przepustowością ALU, tj. dla średnich łańcuchów, w których nie dominuje narzut startowy.)
glibc używa dynamicznych sztuczek łączących, aby rozwiązać strlen
optymalną wersję dla twojego procesora, więc nawet w x86 jest wersja SSE2 (wektory 16-bajtowe, linia bazowa dla x86-64) i wersja AVX2 (wektory 32-bajtowe).
x86 ma wydajny transfer danych między rejestrami wektorowymi i rejestrami ogólnego przeznaczenia, co czyni go wyjątkowo (?) dobrym do użycia SIMD do przyspieszenia funkcji na ciągach o niejawnej długości, w których kontrola pętli zależy od danych. pcmpeqb
/ pmovmskb
umożliwia testowanie 16 oddzielnych bajtów jednocześnie.
glibc ma wersję AArch64 taką jak ta przy użyciu AdvSIMD oraz wersję dla procesorów AArch64, w których rejestr vector-> GP blokuje potok, więc faktycznie używa tego bithacka . Ale używa zer wiodących, aby znaleźć bajt w rejestrze, gdy tylko zostanie trafiony, i korzysta z efektywnego, niewyrównanego dostępu AArch64 po sprawdzeniu przejścia strony.
Powiązane również: Dlaczego ten kod jest 6.5x wolniejszy z włączonymi optymalizacjami? ma więcej szczegółów na temat tego, co jest szybkie w porównaniu z asmem x86, strlen
z dużym buforem i prostą implementacją asm, które mogą być dobre dla gcc, aby wiedzieć, jak wstawić. (Niektóre wersje gcc są nierozsądnie wbudowane, rep scasb
co jest bardzo powolne, lub 4-bajtowe bithack w tym czasie. Więc przepis GCC wymaga aktualizacji lub wyłączenia.)
Asm nie ma „niezdefiniowanego zachowania” w stylu C ; dostęp do bajtów w pamięci jest bezpieczny, jak chcesz, a wyrównane obciążenie, które obejmuje dowolne prawidłowe bajty, nie może winić. Ochrona pamięci ma miejsce przy uziarnieniu strony; wyrównany dostęp jest węższy niż ten, który nie może przekroczyć granicy strony. Czy bezpiecznie jest czytać poza końcem bufora na tej samej stronie na x86 i x64? To samo rozumowanie dotyczy kodu maszynowego, który ten hack C zmusza kompilatory do stworzenia dla autonomicznej, nie-wbudowanej implementacji tej funkcji.
Kiedy kompilator emituje kod w celu wywołania nieznanej funkcji nieliniowej, musi założyć, że funkcja modyfikuje dowolne / wszystkie zmienne globalne i każdą pamięć, do której może mieć wskaźnik. tzn. wszystko oprócz mieszkańców, którzy nie mieli ucieczki adresu, muszą być zsynchronizowane w pamięci podczas połączenia. Dotyczy to oczywiście funkcji napisanych w asm, ale także funkcji bibliotecznych. Jeśli nie włączysz optymalizacji czasu łącza, dotyczy to nawet oddzielnych jednostek tłumaczeniowych (plików źródłowych).
Dlaczego jest to bezpieczne w ramach glibc, ale nie inaczej.
Najważniejszym czynnikiem jest to, że strlen
nie może się to wiązać z niczym innym. Nie jest to do tego bezpieczne; zawiera ściśle aliasing UB (odczyt char
danych przez an unsigned long*
). char*
wolno aliasować cokolwiek innego, ale odwrotność nie jest prawdą .
Jest to funkcja biblioteczna dla skompilowanej biblioteki z wyprzedzeniem (glibc). Nie zostanie wprowadzony z optymalizacją czasu łącza dla dzwoniących. Oznacza to, że musi się skompilować do bezpiecznego kodu maszynowego dla autonomicznej wersji strlen
. Nie musi być przenośny / bezpieczny C.
Biblioteka GNU C musi się kompilować tylko z GCC. Najwyraźniej nie jest obsługiwane kompilowanie go za pomocą clang lub ICC, nawet jeśli obsługują rozszerzenia GNU. GCC to kompilatory z wyprzedzeniem, które przekształcają plik źródłowy C w plik obiektowy kodu maszynowego. Nie interpreter, więc jeśli nie wstawi się w czasie kompilacji, bajty w pamięci są tylko bajtami w pamięci. tzn. ścisłe aliasing UB nie jest niebezpieczne, gdy dostęp do różnych typów odbywa się w różnych funkcjach, które nie są ze sobą powiązane.
Pamiętaj, że strlen
jego zachowanie jest określone przez normę ISO C. Ta nazwa funkcji jest szczególnie częścią implementacji. Kompilatory takie jak GCC nawet traktują nazwę jako funkcję wbudowaną, chyba że używasz -fno-builtin-strlen
, więc strlen("foo")
może być stałą czasową kompilacji 3
. Definicja w bibliotece jest używana tylko wtedy, gdy gcc decyduje się na faktyczne wywołanie jej zamiast wstawiania własnego przepisu lub czegoś takiego.
Kiedy UB nie jest widoczny dla kompilatora w czasie kompilacji, dostajesz rozsądny kod maszynowy. Kod maszyna musi pracować dla przypadku no-UB, a nawet jeśli chciał się, że nie ma sposobu na asm wykryć jakie rodzaje rozmówca celu wprowadzenia danych do wskazywanego w pamięci.
Glibc jest kompilowany do autonomicznej biblioteki statycznej lub dynamicznej, która nie może się równać z optymalizacją czasu łącza. Skrypty budowania glibc nie tworzą „grubych” bibliotek statycznych zawierających kod maszynowy + wewnętrzną reprezentację GIMPLE GIMP dla optymalizacji czasu łącza podczas wstawiania do programu. (tzn. libc.a
nie weźmie udziału w -flto
optymalizacji czasu łącza do programu głównego). Budowanie glibc w ten sposób byłoby potencjalnie niebezpieczne dla celów, które faktycznie z niego korzystają.c
.
W rzeczywistości, jak komentuje @zwol, LTO nie może być użyte podczas budowania samego glibc , ponieważ taki „łamliwy” kod może się zepsuć, jeśli możliwe jest wstawianie między plikami źródłowymi glibc. (Istnieją pewne zastosowania wewnętrzne strlen
, np. Może w ramach printf
wdrożenia)
To strlen
powoduje pewne założenia:
CHAR_BIT
jest wielokrotnością liczby 8 . Prawda na wszystkich systemach GNU. POSIX 2001 gwarantuje nawet CHAR_BIT == 8
. (Wygląda to bezpiecznie na systemy z CHAR_BIT= 16
lub 32
, podobnie jak niektóre DSP; pętla bez sizeof(long) = sizeof(char) = 1
wyrównania -prologu zawsze będzie uruchamiać 0 iteracji, ponieważ ponieważ każdy wskaźnik jest zawsze wyrównany i p & sizeof(long)-1
ma zawsze zero.) Ale jeśli masz zestaw znaków spoza ASCII, gdzie znaki to 9 lub szerokość 12 bitów, 0x8080...
to zły wzór.
- (być może)
unsigned long
ma 4 lub 8 bajtów. A może to faktycznie działałoby dla dowolnego rozmiaru unsigned long
do 8 i używa tego, assert()
aby to sprawdzić.
Te dwa nie są możliwe UB, są po prostu nieprzenośne na niektóre implementacje C. Ten kod jest (lub był) częścią implementacji języka C na platformach, na których działa, więc nie ma sprawy.
Kolejnym założeniem jest potencjalny C UB:
- Wyrównane ładowanie, które zawiera dowolne prawidłowe bajty, nie może powodować błędu i jest bezpieczne, dopóki zignorujesz bajty poza obiektem, którego faktycznie chcesz. (Prawda w asm na wszystkich systemach GNU i na wszystkich normalnych procesorach, ponieważ ochrona pamięci ma miejsce przy uziarnieniu strony. Czy bezpiecznie jest czytać poza końcem bufora na tej samej stronie na x86 i x64? Bezpiecznie w C, gdy UB nie jest widoczny w czasie kompilacji. Bez inliniowania tak jest w tym przypadku. Kompilator nie może udowodnić, że odczytanie pierwszego
0
jest UB; może to być na przykład char[]
tablica C zawierająca {1,2,0,3}
)
Ten ostatni punkt sprawia, że można bezpiecznie czytać poza końcem obiektu C. Jest to całkiem bezpieczne, nawet jeśli korzystasz z obecnych kompilatorów, ponieważ myślę, że obecnie nie traktują tego, że sugerowanie ścieżki wykonania jest nieosiągalne. Ale tak czy inaczej, ścisłe aliasing jest już hitem, jeśli kiedykolwiek pozwolisz na to.
Miałbyś wtedy problemy, takie jak stare niebezpieczne memcpy
CPP jądra Linuxa, które używało rzutowania na unsigned long
( gcc, ścisłe aliasing i horrory ).
To strlen
sięga czasów, kiedy można było uciec od takich rzeczy w ogóle ; niegdyś było to całkiem bezpieczne bez zastrzeżenia „tylko wtedy, gdy nie jest inline” przed GCC3.
UB, który jest widoczny tylko, gdy patrzy się przez granice połączeń / połączeń, nie może nas skrzywdzić. (np. wywoływanie tego char buf[]
zamiast na tablicy unsigned long[]
rzutowania na a const char*
). Gdy kod maszynowy jest już w kamieniu, zajmuje się tylko bajtami w pamięci. Wywołanie funkcji innej niż wbudowana musi zakładać, że odbiorca odczytuje dowolną / całą pamięć.
Pisanie tego bezpiecznie, bez ścisłego aliasingu UB
Atrybut typ GCCmay_alias
daje rodzajem takiego samego traktowania alias-coś tak char*
. (Sugerowane przez @KonradBorowsk). Nagłówki GCC używają go obecnie do typów wektorów SIMD x86, __m128i
dzięki czemu zawsze możesz to zrobić bezpiecznie _mm_loadu_si128( (__m128i*)foo )
. (Zobacz Czy `reinterpret_cast`ing między sprzętowym wskaźnikiem wektorowym a odpowiednim typem jest niezdefiniowanym zachowaniem ?, aby uzyskać więcej informacji na temat tego, co to oznacza i co nie oznacza.)
strlen(const char *char_ptr)
{
typedef unsigned long __attribute__((may_alias)) aliasing_ulong;
aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
for (;;) {
unsigned long ulong = *longword_ptr++; // can safely alias anything
...
}
}
Możesz także użyć aligned(1)
do wyrażenia typu alignof(T) = 1
.
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;
Przenośnym sposobem wyrażania obciążenia aliasingowego w ISO jest to, za pomocąmemcpy
którego nowoczesne kompilatory potrafią wstawiać jako instrukcję pojedynczego obciążenia. na przykład
unsigned long longword;
memcpy(&longword, char_ptr, sizeof(longword));
char_ptr += sizeof(longword);
Działa to również w przypadku niezaangażowanych obciążeń, ponieważ memcpy
działa tak, jakby char
dostęp był możliwy tylko w określonym czasie. Ale w praktyce współczesne kompilatory rozumieją memcpy
bardzo dobrze.
Niebezpieczeństwo polega na tym, że jeśli GCC nie wie na pewno, że char_ptr
jest wyrównany do słów, nie wstawi go na niektórych platformach, które mogą nie obsługiwać niezrównanych obciążeń w asm. np. MIPS przed MIPS64r6 lub starszy ARM. Jeśli masz rzeczywiste wywołanie funkcji, aby memcpy
po prostu załadować słowo (i zostawić je w innej pamięci), byłoby to katastrofą. GCC czasami widzi, kiedy kod wyrównuje wskaźnik. Lub po pętli char-at-a-time, która osiąga długą granicę, której możesz użyć
p = __builtin_assume_aligned(p, sizeof(unsigned long));
Nie omija to możliwego UB odczytu-przeszłości-obiektu, ale przy obecnym GCC nie jest to niebezpieczne w praktyce.
Dlaczego ręcznie zoptymalizowane źródło C jest konieczne: obecne kompilatory nie są wystarczająco dobre
Asm zoptymalizowany ręcznie może być jeszcze lepszy, gdy chcesz uzyskać ostatni spadek wydajności dla powszechnie używanej standardowej funkcji biblioteki. Specjalnie dla czegoś takiego memcpy
, ale także strlen
. W tym przypadku korzystanie z SSE2 nie byłoby znacznie łatwiejsze do użycia C z elementami x86.
Ale tutaj mówimy tylko o wersji naiwnej vs. bithack C bez funkcji specyficznych dla ISA.
(Myślę, że możemy przyjąć to jako strlen
powszechnie stosowane, dlatego ważne jest, aby działało to tak szybko, jak to możliwe. Pytanie więc brzmi, czy możemy uzyskać wydajny kod maszynowy z prostszego źródła. Nie, nie możemy.)
Obecne GCC i clang nie są zdolne do automatycznego wektoryzowania pętli, w których liczba iteracji nie jest znana przed pierwszą iteracją . (np. musi być możliwe sprawdzenie, czy pętla wykona co najmniej 16 iteracji przed uruchomieniem pierwszej iteracji). np. możliwe jest autowektoryzowanie memcpy (bufor o jawnej długości), ale nie strcpy lub strlen (ciąg o długości niejawnej), biorąc pod uwagę bieżący kompilatory.
Obejmuje to pętle wyszukiwania lub dowolne inne pętle z danymi zależnymi, if()break
a także licznik.
ICC (kompilator Intela dla x86) może automatycznie wektoryzować niektóre pętle wyszukiwania, ale nadal robi naiwny asm po bajcie tylko dla prostego / naiwnego C, strlen
takiego jak użycie libc w OpenBSD. ( Godbolt ). (Z odpowiedzi @ Peske ).
Ręcznie zoptymalizowana biblioteka libc strlen
jest niezbędna do działania z obecnymi kompilatorami . Przesuwanie 1 bajta na raz (z rozwijaniem może 2 bajtów na cykl na szerokich superkalarnych procesorach) jest żałosne, gdy pamięć główna może nadążyć za około 8 bajtami na cykl, a pamięć podręczna L1d może dostarczyć 16 do 64 na cykl. (2x 32-bajtowe obciążenia na cykl we współczesnych procesorach głównego nurtu x86 od Haswell i Ryzen. Nie licząc AVX512, który może zmniejszyć prędkość taktowania tylko przy użyciu wektorów 512-bitowych; dlatego glibc prawdopodobnie nie śpieszy się z dodaniem wersji AVX512 , Mimo, że 256-bitowych wektorów AVX512VL + BW maskowana porównać do maski i ktest
lub kortest
mogłoby strlen
bardziej przyjazny hyperthreading'u poprzez redukcję UOPs / iteracji).
Podaję tutaj nie-x86, to jest „16 bajtów”. np. większość procesorów AArch64 może przynajmniej tak zrobić, a niektóre z pewnością więcej. Niektóre mają wystarczającą przepustowość wykonywania, strlen
aby nadążyć za tą przepustowością obciążenia.
Oczywiście programy, które działają z dużymi łańcuchami, powinny zwykle śledzić długości, aby uniknąć konieczności powtarzania często szukania długości łańcuchów C. Jednak wydajność od krótkiej do średniej nadal korzysta z ręcznie napisanych implementacji i jestem pewien, że niektóre programy używają strlen na łańcuchach średniej długości.