Powrót do strony głównej

Tablice haszujące i konflikty cache: benchmarki

Rozdział o tablicach haszujących ujawnia wpływ konfliktów cache na wydajność. Porównywane są łańcuchy i otwarta adresacja, analizowane funkcje haszujące i optymalizacje jak Robin Hood. Benchmarki potwierdzają przewagę sondowania w rzeczywistych scenariuszach.

Dlaczego tablice haszujące są wolniejsze od tablic: analiza cache
Advertisement 728x90

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.

Google AdInline article slot

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.

Google AdInline article slot

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ść |

Google AdInline article slot

|-----------------|--------------------------------------------------|-----------|

| Ł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

Advertisement 728x90

Czytaj dalej