Zurück zur Startseite

Verknüpfte Listen: Cache-Verfehlungen und Benchmarks

Der Artikel zerlegt, warum verknüpfte Listen Arrays bei Cache-Verfehlungen hinterherhinken: Benchmarks zeigen 30-fache Verzögerung. Optimierungen beschrieben — Pools, entrollte Listen, intrusive Implementierungen. Beispiele aus RTOS und lock-free Strukturen.

Verknüpfte Listen töten den Cache: Benchmarks und Fixes
Advertisement 728x90

# Verkettete Listen und Cache-Verfehlungen: Warum sie Arrays in realen Anwendungen unterliegen

Verkettete Listen wirken ideal für dynamische Operationen: O(1)-Einfügungen und -Löschungen, keine Neuzuweisungen. In der Praxis verlieren sie jedoch oft gegen Arrays wegen Cache-Verfehlungen. Jeder Sprung über den next-Pointer belastet das Speichersystem und macht theoretische Vorteile zu Engpässen.

Tests mit 100.000 Elementen zeigen die Lücke:

| Vorgang | Array | Verkettete Liste | Rückstand |

Google AdInline article slot

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

| Sequentieller Durchlauf | 70 μs | 179 μs | ×2,5 |

| Zufallszugriff | 95 μs | 2847 μs | ×30 |

Google AdInline article slot

| Anhängen | 42 μs | 1234 μs | ×29 |

Sogar Einfügungen, bei denen Listen überlegen sein sollten, sind langsamer wegen Neuzuweisungen und Cache-Verfehlungen.

Mechanismus der Cache-Verfehlungen

CPU-Cache-Lines laden jeweils 64 Bytes. In einem Array liegen die Elemente sequentiell – ein Miss gibt Zugriff auf 16 ints. In einer verketteten Liste sind die Knoten über den Heap verteilt:

Google AdInline article slot
// Typical node
struct node {
    int value;     // 4 bytes
    node *next;    // 8 bytes
}; // 16 bytes with padding

Das Durchlaufen der Liste erzeugt einen Miss pro Knoten (~100 Takte Verzögerung). Bei 100.000 Knoten sind das 10 Millionen Takte gegenüber 625.000 für das Array. Das Array benötigt 400 KB, die Liste – 1,6 MB (4× Overhead).

Kosten der Speicherverwaltung

Das Erstellen der Liste erfordert 100.000 malloc()-Aufrufe:

for (int i = 0; i < 100000; i++) {
    node *n = malloc(sizeof(node)); // Heap search, metadata, fragmentation
    n->value = i;
    n->next = head;
    head = n;
}

Array – ein Aufruf. Benchmark-Unterschied: 42 μs vs. 1234 μs.

Seltene Szenarien, in denen Listen sinnvoll sind

Verkettete Listen eignen sich für Nischenfälle:

  • Intrusive Listen in Kernen (Linux list_head): Knoten eingebettet in Strukturen, bessere Lokalität.
  • Lock-free Strukturen: atomare CAS auf Zeigern einfacher als bei Arrays.
  • Kleine statische Datensätze mit seltenen Einfügungen.

Beispiel für Treibers lock-free Stack:

typedef struct node {
    int value;
    struct node *next;
} node_t;

void push(node_t **head, node_t *node) {
    do {
        node->next = *head;
    } while (!atomic_compare_exchange(head, &node->next, node));
}

Optimierungen, wenn Sie sie unbedingt nutzen müssen

Objektpools

Vorab ein Array von Knoten allokieren:

#define POOL_SIZE 10000
node_t pool[POOL_SIZE];
int idx = 0;

node_t *alloc() {
    return idx < POOL_SIZE ? &pool[idx++] : NULL;
}

Geschwindigkeit: 287 μs (×4,3 vs. malloc, aber ×6,8 vs. Array).

Entrollte Listen

Mehrere Elemente pro Knoten speichern:

#define N 16
typedef struct {
    int values[N];
    int count;
    struct node *next;
} unrolled_t;

Durchlauf: 45 μs (besser als Standardliste, vergleichbar mit Array bei sequentiellen Zugriffen).

XOR-Verkettete Listen

Speicher sparen durch XOR von prev^next:

typedef struct {
    int value;
    node *xor_ptr; // prev ^ next
} xor_node;

// Traversal requires tracking prev
node *next = (uintptr_t)prev ^ (uintptr_t)curr->xor_ptr;

Nachteile: Komplexität beim Debuggen, kein bidirektionaler Durchlauf. Nicht empfohlen.

RTOS-Fall: Warum Listen dort funktionieren

In FreeRTOS verwendet der Scheduler ein Array von Listen nach Priorität (32 Stufen):

list_head ready_tasks[MAX_PRIORITIES];

Erfolg durch:

  • Kleine Listen (1–5 Tasks).
  • Knoten eingebettet in task_struct.
  • O(1)-Operationen nach Priorität.

Benchmark auf Cortex-M4: Einfügung 0,8 μs, Löschung 0,6 μs.

Wichtige Erkenntnisse

  • Cache dominiert: Zeiger-Verfehlungen zerstören die Leistung selbst bei O(1)-Operationen.
  • Speicher-Overhead: 4× Wachstum durch Zeiger + Fragmentierung.
  • Dynamische Arrays bevorzugt: amortisierte O(1)-Einfügungen ohne Cache-Strafen.
  • Optimierungen helfen ein wenig: Pools und entrollte Listen verringern die Lücke, überholen aber nicht.
  • RTOS-Ausnahme: eingebettete Knoten + kleine Größe rechtfertigen die Wahl.

— Editorial Team

Advertisement 728x90

Weiterlesen