Jak przyspieszyć kod przy użyciu pamięci podręcznych procesora

Pamięć podręczna procesora zmniejsza opóźnienia w dostępie do danych z głównej pamięci systemowej. Programiści mogą i powinni korzystać z pamięci podręcznej procesora, aby poprawić wydajność aplikacji.

Jak działają pamięci podręczne procesora

Nowoczesne procesory mają zwykle trzy poziomy pamięci podręcznej, oznaczone jako L1, L2 i L3, co odzwierciedla kolejność, w jakiej procesor je sprawdza. Procesory często mają pamięć podręczną danych, pamięć podręczną instrukcji (dla kodu) i zunifikowaną pamięć podręczną (dla czegokolwiek). Dostęp do tych pamięci podręcznych jest znacznie szybszy niż dostęp do pamięci RAM: zwykle pamięć podręczna L1 jest około 100 razy szybsza niż pamięć RAM w przypadku dostępu do danych, a pamięć podręczna L2 jest 25 razy szybsza niż pamięć RAM w przypadku dostępu do danych.

Gdy oprogramowanie działa i musi pobierać dane lub instrukcje, najpierw sprawdzane są pamięci podręczne procesora, następnie wolniejsza systemowa pamięć RAM, a na końcu znacznie wolniejsze dyski. Dlatego chcesz zoptymalizować swój kod, aby najpierw wyszukać to, co prawdopodobnie będzie potrzebne w pamięci podręcznej procesora.

Twój kod nie może określić, gdzie znajdują się instrukcje dotyczące danych i dane - robi to sprzęt komputerowy - więc nie możesz wymusić pewnych elementów w pamięci podręcznej procesora. Ale możesz zoptymalizować swój kod, aby pobrać rozmiar pamięci podręcznej L1, L2 lub L3 w systemie za pomocą Instrumentacji zarządzania Windows (WMI), aby zoptymalizować, kiedy aplikacja uzyskuje dostęp do pamięci podręcznej, a tym samym jej wydajność.

Procesory nigdy nie uzyskują dostępu do bajtu po bajcie pamięci podręcznej. Zamiast tego odczytują pamięć w wierszach pamięci podręcznej, które są zwykle fragmentami pamięci o rozmiarze 32, 64 lub 128 bajtów.

Poniższy kod ilustruje, w jaki sposób można pobrać rozmiar pamięci podręcznej procesora L2 lub L3 w systemie:

public static uint GetCPUCacheSize (string cacheType) {try {using (ManagementObject managementObject = new ManagementObject ("Win32_Processor.DeviceID = 'CPU0'")) {return (uint) (managementObject [cacheType]); }} catch {return 0; }} static void Main (string [] args) {uint L2CacheSize = GetCPUCacheSize ("L2CacheSize"); uint L3CacheSize = GetCPUCacheSize ("L3CacheSize"); Console.WriteLine ("L2CacheSize:" + L2CacheSize.ToString ()); Console.WriteLine ("L3CacheSize:" + L3CacheSize.ToString ()); Console.Read (); }

Firma Microsoft ma dodatkową dokumentację dotyczącą klasy WMI Win32_Processor.

Programowanie wydajności: przykładowy kod

Gdy masz obiekty na stosie, nie ma narzutu na wyrzucanie elementów bezużytecznych. Jeśli używasz obiektów opartych na stercie, generacyjne wyrzucanie elementów bezużytecznych zawsze wiąże się z kosztami zbierania lub przenoszenia obiektów w stercie lub kompaktowania pamięci sterty. Dobrym sposobem na uniknięcie obciążenia związanego z wyrzucaniem elementów bezużytecznych jest użycie struktur zamiast klas.

Pamięci podręczne działają najlepiej, jeśli używasz sekwencyjnej struktury danych, takiej jak tablica. Kolejność sekwencyjna pozwala procesorowi CPU odczytywać z wyprzedzeniem, a także odczytywać z wyprzedzeniem spekulatywnie w oczekiwaniu na to, co prawdopodobnie będzie żądane w następnej kolejności. Dlatego algorytm, który uzyskuje dostęp do pamięci sekwencyjnie, jest zawsze szybki.

Jeśli uzyskujesz dostęp do pamięci w kolejności losowej, procesor potrzebuje nowych linii pamięci podręcznej za każdym razem, gdy uzyskujesz dostęp do pamięci. To zmniejsza wydajność.

Poniższy fragment kodu implementuje prosty program, który ilustruje korzyści płynące z używania struktury nad klasą:

 struct RectangleStruct {public int width; publiczny int wysokość; } class RectangleClass {public int width; public int height; }

Poniższy kod profiluje wydajność korzystania z tablicy struktur względem tablicy klas. Dla celów ilustracyjnych użyłem miliona obiektów do obu, ale zazwyczaj nie potrzebujesz tak wielu obiektów w swojej aplikacji.

static void Main (string [] args) {const int size = 1000000; var structs = new RectangleStruct [rozmiar]; var classes = new RectangleClass [rozmiar]; var sw = new Stopwatch (); sw.Start (); for (var i = 0; i <size; ++ i) {structs [i] = new RectangleStruct (); structs [i] .breadth = 0 structs [i] .height = 0; } var structTime = sw.ElapsedMilliseconds; sw.Reset (); sw.Start (); for (var i = 0; i <size; ++ i) {classes [i] = new RectangleClass (); klasy [i] .breadth = 0; klasy [i] .height = 0; } var classTime = sw.ElapsedMilliseconds; sw.Stop (); Console.WriteLine ("Czas zajęty przez tablicę klas:" + classTime.ToString () + "milisekundy."); Console.WriteLine ("Czas zajęty przez tablicę struktur:" + structTime.ToString () + "milisekundy."); Console.Read (); }

Program jest prosty: tworzy 1 milion obiektów struktur i przechowuje je w tablicy. Tworzy również 1 milion obiektów klasy i przechowuje je w innej tablicy. Szerokość i wysokość właściwości mają przypisaną wartość zero w każdym wystąpieniu.

Jak widać, używanie struktur przyjaznych dla pamięci podręcznej zapewnia ogromny wzrost wydajności.

Podstawowe zasady dotyczące lepszego wykorzystania pamięci podręcznej procesora

Jak więc napisać kod, który najlepiej wykorzystuje pamięć podręczną procesora? Niestety nie ma magicznej formuły. Ale jest kilka praktycznych zasad:

  • Unikaj algorytmów i struktur danych, które wykazują nieregularne wzorce dostępu do pamięci; zamiast tego używaj liniowych struktur danych.
  • Używaj mniejszych typów danych i organizuj dane tak, aby nie było żadnych dziur w wyrównaniu.
  • Weź pod uwagę wzorce dostępu i wykorzystaj liniowe struktury danych.
  • Popraw lokalność przestrzenną, która wykorzystuje każdą linię pamięci podręcznej w maksymalnym stopniu po zmapowaniu jej do pamięci podręcznej.