Linie buforu: rzeczywista jednostka współdzielenia
Programmerzy myślą w terminach zmiennych, poszczególnych nazwanych fragmentów danych, takich jak licznik typu integer lub pole struktury, ale hierarchia pamięci nie ma pojęcia o zmiennej jako takiej. Bufor cache przemieszcza, przechowuje i śledzi dane wyłącznie w ustalonej wielkości bloków nazywanych liniami buforu, 64 bajtów na większości głównych procesorów x86 i ARM obecnie, co oznacza, że każdy pojedynczy dostęp do bajtu przyciąga całą 64-bajtową blok listowana wokół niego. Jeśli programista zadeklaruje dwa liczniki typu integer, każdy tylko 4 bajty, obok siebie w strukturze lub jako sąsiednie zmienne globalne, kompilator i alokator pamięci prawdopodobnie umiescą je w tym samym bloku 64-bajtowym, ponieważ nic nie gwarantuje aligmentu na linię buforu dla zwykłego deklarowania zmiennej. To oznacza, że z punktu widzenia protokołu współzależności sprzętowej, dwa logicznie niezależne liczniki są rzeczywiście jednym jednolitym elementem: pisanie do każdego z nich wymaga dokładnie takiej samej maszyny do odtrącenia i przekazywania na skalę linii buforu jak pisanie do obu jednocześnie. Niezgodność między mentalnym modeliem programisty, niezależnymi zmiennymi, a rzeczywistą granicą śledzenia sprzętowej, całe linie buforu, jest przyczyną fałszywej współdzielenia, i jest ona całkowicie niewidoczna podczas czytania kodu źródłowego, ponieważ nic nie wskazuje na to, że deklaracje zmiennych są niepoprawne; problem istnieje jedynie w tym, jak się okazuje, że są ułożone w pamięci na poziomie adresacji bajtowej.
Jak MESI Konwertuje Bliskość na Bug Wynajomy
Pamiętaj, że protokoły synchronizacji buforów pamięci, takie jak MESI, gwarantują, że w momencie, gdy jedna karta zapisuje się do lini buforu, każda inna karta musi wykaszlić swoje skopiowane linię przed rozpoczęciem zapisu, co gwarantuje, że wszystkie karty ostatecznie obserwują zgodny widok pamięci. Ta mecha jest całkowicie poprawna i niezbędna, gdy karty prawidłowo dzielą się danymi, ale nie może rozróżnić między prawdziwym współdzieleniem a fałszywym współdzieleniem, ponieważ zawsze widzi całe linie buforu, nigdy jednostki zmiennych w nich zawartych. Jeśli karta A powtarzająco zapisuje się do zmiennej X a karta B powtarzająco zapisuje się do zmiennej Y, a X i Y przypadkowo dzielą wspólną linię buforu, każda zapisana przez jedną kartę wymusza wykaszliwanie tej linii na drugiej karcie, więc ta linia ping-ponga między stanami Modyfikowane dwukartowego buforu ciągle, nawet jeśli A nigdy nie dotyka Y a B nigdy nie dotyka X, a nie ma żadnej rzeczywistej walki o dane ani zależności między tymi zmiennymi. Każdy ping-pong kosztuje pełny tur do gwarancji zgodności, wykaszliwanie, potwierdzenie i przeniesienie danych, co zwykle wynosi dziesiątki do ponad stu cykli w zależności od systemu, w porównaniu z kilkoma cyklami dla zapisu, który trafia prosto w prywatny bufor danej karty bez konfliktów. Pomnoż to przez miliony zapisów na sekundę w ciepłym pętli, która jest dokładnie typem kodu najbardziej prawdopodobnie paraliżowanego dla celu poprawy wydajności od początku, a fałszywe współdzielenie może przekształcić zamiarne przyspieszenie ze względu na paraliżację w program, który działa wolniej z wieloma wątkami niż z jednym, prawdziwie nieintuicyjny i frustrujący wynik dla każdego spotkanego z nim po raz pierwszy.
Poprawa: Padding i Wyrównanie
Standardowa metoda leczenia fałszywego współdzielenia jest prosta, gdy zostanie wykryta: upewnij się, że zmiennych pisanych przez różne wątki umieszczono na osobnych liniach pamięci podręcznej, tak aby pisanie każdego jądra niezawsze niszczyło linię, której inny jądro nie interesuje. W praktyce oznacza to dodanie wyraźnego paddingu bajtów między polami, które różne wątki piszą niezależnie, często używając specyficznych dla kompilatora instrukcji wyrównania, aby zmusić strukturę lub pole do rozpoczęcia się na nowym granicznym brzegu 64 bajtów, lub korzystając z funkcjonalności języka, takich jak std::hardware_destructive_interference_size C++17, które odkrywają rozmiar linii pamięci podręcznej platformy, aby kod mógł przeciwstawić się jej w sposób portowy. Ta poprawa kosztuje rzeczywistą pamięć: padding 4-bajtowego licznika do pełnej lini 64 bajtów spowoduje stratę 60 bajtów na licznik, co jest powodem dla którego padding stosowany jest selektywnie, zazwyczaj tylko do ciepłych, często kontrowersyjnych pól, takich jak liczniki w poszczególnych wątkach, stan blokad spinowych lub wskaźniki głównej i końcowej okręgu buforu, a nie szeroko stosowany do każdego struktury kodu. Niektóre projekty idą dalej i świadomie grupują dane, które zawsze są dostępne razem na tym samym brzegu, technika często podsumowana jako struktury danych według wzoru dostępu zamiast logicznej grupy, ponieważ dwie pola, które zawsze są czytane i pisanego razem przez ten sam wątek, korzystają z dzielenia się brzegiem, pobierając oba w jednym braku pamięci podręcznej, podczas gdy pola dotykane różnymi wątkami powinny być utrzymywane oddalone. Ten konflikt, pakuj dane z tego samego wątku skupiając je gęsto, ale rozdziawiaj dane kontrowersyjne między wątkami szeroko, jest wielokrotnie powtarzalnym tematem w programowaniu systemowym orientowanym na wydajność i stanowi właściwie całą lekcję, którą fałszywe współdzielenie naucza.
NUMA: Gdzie nawet naprawa ma lokalizację
Na systemach pamięci jednosocketowych i uniformnych wszystkie rdzenne procesora osiągają dowolny adres w RAM z podobną latencją, więc po eliminacji fałszywego współdzielenia dalsze dostosowanie mostly wiąże się tylko z zachowaniem buforów podrzędnych. Ale serwery wielosocketowe używają projektu Non-Uniform Memory Access (NUMA), w którym cała pamięć RAM jest fizycznie podzielona na banki, każdy elektronicznie połączony i własny dla jednego konkretnego socketu rdzeniowego, nazywanego NUMA node. Rdzeń może szybko dostarczyć dostęp do pamięci lokalnej swojego NUMA node, ale dostępu do pamięci innego node’a wymaga przejście przez połączenie między socketami, takie jak UPI Intela lub Infinity Fabric AMD, co dodatkowo zwiększa latencję o znaczące wartości, czasem o 30-60 procent lub więcej, a także zużywa podzielne przepustowość połączeń między socketami, która jest potrzebna również innym ruchom. To oznacza, że koszt dowolnego dostępu do pamięci oraz szczególnie koszt zachowawczy, kiedy rdzenie z różnych socketów konkurencje za tę samą linię buforu podrzędnego, zależy znacząco od tego, gdzie w fizycznej pamięci ta data rzeczywiście znajduje się względem tego rdzenia, który do niej dostaje. Systemy operacyjne oferują polityki alokacji świadome NUMA, takie jak alokacja pierwszego dotarcia, w której strona jest fizycznie umieszczana na node’u, na którym pierwszy raz dostęp do niej ma rdzeń, oraz wyraźne interfejsy API do zpinania pamięci lub wątków do konkretnych node’ów, dokładnie tak, aby oprogramowanie czujne na wydajność mogło zapewnić, że często dostępowane przez wątek dane fizycznie są blisko tego rdzenia. Fałszywe współdzielenie między NUMA node’mi jest szczególnie poważnym przypadkiem: nie tylko każda zapisywań spowoduje zwalnianie zachowania, ale sam proces zwalniania i transfer danych musi przekroczyć wolniejsze połączenie między socketami, co zagnieżdża dwa źródła obciążenia w jedno znacznie gorszą karyę.
Diagnozowanie tych problemów w rzeczywistym oprogramowaniu
Bo zarówno fałszywe współdzielenie, jak i słabe umieszczenie NUMA produkcji wyniki poprawne i pozostawiają w źródłowym kodzie niewyraźny ślad, są zwykle odkrywane przez profilowanie zamiast przeglądu kodu. Liczniki wydajności pamięci podręcznej na współczesnych procesorach mogą zgłaszać bezpośrednio zdarzenia konkurencji linii pamięciowej oraz liczby dostępow do pamięci odległej, a narzędzia takie jak Intel VTune, Linux perf c2c, które specjalnie wizualizują konkurencje linii pamięciowej między rdzeniami, oraz numastat dla statystyk lokalności NUMA są zaprojektowane do wykrywania tych inaczej niewidocznych kosztów. Znaczne objawy fałszywej współdzielenia to program wielowątkowy, który źle skali lub nawet ulega odwrotnej skalowalności, mimo że obciążenie wydaje się bezludnie równoległe i brak autentycznych zależności logicznych między danymi wątków. Znaczne objawy niepoprawnego umieszczenia NUMA to nierównomierne wydajność zależna od konkretnych rdzeni, na które przypadają wątki, ponieważ wątek losowo umieszczony na podzespołu daleko od danych będzie działać wolniej niż jeden umieszczone bliżej, nawet jeśli kod i dane są identyczne. Obie problemy dzielą wspólny podstawowy wniosek, który ten symulator jest zaprojektowany, aby uczynić fizycznym: fizyczna disposycja i umiejscowienie danych w pamięci nie jest szczegółem estetycznego implementacyjnego, to pierwsza klasa zmiennych wydajnościowych, równorzędnych z złożonością algorytmiczną, która doświadczonych programistów systemowych zaprojektowuje wokół, a nie odkrywa przypadkowo.
Często zadawane pytania
Jak opisać fałszywe współdzielanie w jednym zdaniu?
Fałszywe współdzielanie to kariera wykonywalności, która występuje, gdy dwa wątki piszą do logicznie niepowiązanych zmiennych, które przypadkowo dzielą tę samą linię pamięci cache, co powoduje niewy:animated_cache_coherence_traffic_miedzy_koreami. Nie tworzy ono błędów, tylko spowalnia wykonywanie.
Jaka jest typowa wielkość linii pamięci cache i dlaczego ma to znaczenie?
Większość współczesnych procesorów x86 i ARM używa linii pamięci cache o wielkości 64 bajtów, co oznacza, że każda operacja coherencyjna dotyczy całości bloku 64 bajtów, niezależnie od tego, ile z nich rzeczywiście jest używane. Dwa zmienne 4-bajtowe umieszczone w tym samym bloku 64 bajtów są dla sprzętu traktowane jako jedno jednostka współdzielania.
Jak naprawić fałszywe współdzielanie?
Standardowym rozwiązaniem jest dodawanie przestrzeni lub aliniowanie kontrowersyjnych zmiennych tak, aby każda z nich spadła na osobną linię pamięci cache, często używając atrybutów aliniowania kompilatora lub portownego stałą jak std::hardware_destructive_interference_size w C++17. To wymienia dodatkowe użycie pamięci za cenę zakończenia niewy:animated_coherence_traffic_miedzy_koreami.
Co oznacza NUMA i dlaczego jest to ważne?
NUMA oznacza Non-Uniform Memory Access, opisując systemy wielosocketowe, w których każdy procesor ma szybszy dostęp do własnej lokalnej banku pamięci niż do pamięci podłączonej do innego socketa. Gdy fałszywe współdzielanie występuje między koreami z różnych NUMA nodes, to operacja coherencyjna musi również przechodzić przez wolniejszą połączenie między socketami, powiększając karierę.
Jak można wykryć fałszywe współdzielanie w rzeczywistym programie?
Ponieważ nie zostawia śladu w kodzie źródłowym, fałszywe współdzielanie jest zwykle wykrywane poprzez profilowanie, używając narzędzi takich jak perf c2c Linuxa, które specjalnie wizualizują konflikt na poziomie linii pamięci cache między koreami, lub analizę dostępu do pamieci Intel VTune. Popularnym objawem jest program równoległy, który skali się słabo lub nawet wolniej działa, mimo że nie ma prawdziwych logicznych zależności danych.
Wypróbuj na żywo
Wszystko powyżej działa bezpośrednio w Twojej przeglądarce — otwórz NUMA Memory Access & False Sharing i zmieniaj parametry podczas działania. Nic nie jest instalowane ani przesyłane na serwer, cały model działa w jednej karcie.
▶ Otwórz symulację NUMA Memory Access & False Sharing