Tablice haszujące: jak unikać niepowodzeń pamięci podręcznej w realnych projektach
Tablice haszujące obiecują wyszukiwanie w O(1), ale w praktyce często przegrywają z liniowym skanowaniem tablicy z powodu konfliktów w pamięci podręcznej. W optymalizacji kompilatora tabela symboli na 500 elementów z 1024 kubełkami generowała 1,2 mln niepowodzeń pamięci podręcznej na 5 mln instrukcji. Zamiana na tablicę przyspieszyła wykonanie 3-krotnie. Problem tkwi w wzorcach dostępu powodujących usuwanie linii pamięci podręcznej.
Podstawowa implementacja i kolizje
Prosta tablica haszująca z bezpośrednim mapowaniem nie skaluje się z powodu kolizji. Oto podstawowy kod:
typedef struct {
char *key;
int value;
} entry_t;
#define TABLE_SIZE 1024
entry_t *table[TABLE_SIZE];
int hash(const char *key) {
unsigned int h = 0;
while (*key) {
h = h * 31 + *key++;
}
return h % TABLE_SIZE;
}
Wstawianie i wyszukiwanie wymagają alokacji pamięci i porównywania ciągów, ale bez obsługi kolizji tabela jest bezużyteczna.
Strategie rozwiązywania kolizji
Kolizja występuje, gdy różne klucze haszują się do tego samego indeksu. Dwa podejścia:
Łączenie w łańcuch
Lista powiązana w kubełku zwiększa rozproszenie pamięci:
typedef struct entry {
char *key;
int value;
struct entry *next;
} entry_t;
void insert(const char *key, int value) {
int index = hash(key);
entry_t *entry = malloc(sizeof(entry_t));
entry->key = strdup(key);
entry->value = value;
entry->next = table[index];
table[index] = entry;
}
Wyszukiwanie podąża za wskaźnikami, każdy krok to potencjalne niepowodzenie pamięci podręcznej.
Adresowanie otwarte
Liniowe sondowanie po tablicy zachowuje lokalność:
typedef struct {
char *key;
int value;
int occupied;
} entry_t;
entry_t table[TABLE_SIZE];
void insert(const char *key, int value) {
int index = hash(key);
while (table[index].occupied) {
index = (index + 1) % TABLE_SIZE;
}
table[index].key = strdup(key);
table[index].value = value;
table[index].occupied = 1;
}
Sekwencyjny dostęp minimalizuje niepowodzenia: pierwsza linia pamięci podręcznej obejmuje 7–8 elementów.
| Strategia | Niepowodzenia pamięci podręcznej na wyszukiwanie | Lokalność |
|-----------------|--------------------------------------------------|-----------|
| Łańcuch | 3–10 na łańcuch długości 3 | Słaba |
| Sondowanie | 1–2 | Dobra |
Analiza niepowodzeń pamięci podręcznej
W łańcuchu każdy węzeł wymaga osobnego wczytania: kubełek (1 niepowodzenie), element (2–3), next (1). Razem do 10 niepowodzeń.
Sondowanie wczytuje całą linię pamięci podręcznej, kolejne próby trafiają do pamięci podręcznej. Różnica 3–5 razy w niepowodzeniach.
Benchmark na 1000 wstawieniach, 10 000 wyszukiwaniach (load factor 0.5, 2048 kubełków):
- Łańcuch: wstawianie 450k cykli, wyszukiwanie 2.1M cykli, 45k niepowodzeń
- Sondowanie: wstawianie 180k cykli, wyszukiwanie 650k cykli, 12k niepowodzeń
Sondowanie jest 3,2 raza szybsze.
Jakość funkcji haszujących
Słaby hash (key[0] % size) grupuje klucze po pierwszym znaku, średnia długość łańcucha — 38.5.
FNV-1a rozkłada równomiernie:
uint32_t fnv1a_hash(const char *key) {
uint32_t hash = 2166136261u;
while (*key) {
hash ^= (uint8_t)*key++;
hash *= 16777619u;
}
return hash;
}
Specjalizowane hashe:
- Dla int:
return key; - Dla wskaźników:
(uintptr_t >> 3) * 2654435761u
Benchmark: FNV-1a daje łańcuchy długości 0.98 vs 38.5 u słabego hasha.
Zarządzanie obciążeniem i zmianą rozmiaru
Load factor = elementy / kubełki.
- Łańcuch: >1.0 dopuszczalne, ale degraduje
- Sondowanie: <0.7–0.8
Przy 0.9: 10.5 prób średnio, przy 0.95 — 20.5.
Zmiana rozmiaru przy przekroczeniu 0.7 (podwojenie, O(n) amortyzowane O(1)):
void insert(const char *key, int value) {
if (count >= table_size * 0.7) {
resize_table();
}
// wstawianie
}
Zoptymalizowana struktura pod pamięć podręczną
- Adresowanie otwarte + rozmiar 2^n (hash & mask zamiast %).
- Gęste pakowanie:
typedef struct {
uint32_t hash;
uint32_t key;
uint32_t value;
} entry_t; // 12 bajtów, 5 w linii
- Podział kluczy/wartości dla dużych payloadów.
- Sondowanie SIMD (AVX2 sprawdza 8 elementów naraz).
Haszowanie Robin Hood
Wariant sondowania: przy wstawianiu „okrucieństwo" elementów z mniejszą odległością sondowania.
Przykład: key4 (hash=1) wypiera key2 (dist=1 > 0), balansując długości łańcuchów. Zmniejsza wariancję prób do minimum.
Co najważniejsze
- Adresowanie otwarte z liniowym sondowaniem jest 3–5 razy efektywniejsze od łańcuchów pod kątem pamięci podręcznej.
- Dobra funkcja haszująca (FNV-1a) zmniejsza kolizje 40-krotnie.
- Load factor <0.7 + zmiana rozmiaru przez podwojenie zapewnia amortyzowane O(1).
- Gęste pakowanie i rozmiar 2^n minimalizują niepowodzenia.
- Haszowanie Robin Hood optymalizuje rozproszenie sondowania.
— Editorial Team
Brak komentarzy.