Volver al inicio

Listas enlazadas: fallos de caché y benchmarks

El artículo desglosa por qué las listas enlazadas se quedan atrás de los arrays debido a fallos de caché: benchmarks muestran retraso 30 veces mayor. Optimizaciones descritas — pools, listas desenrolladas, implementaciones intrusivas. Ejemplos de RTOS y estructuras lock-free.

Listas enlazadas matan la caché: benchmarks y correcciones
Advertisement 728x90

Listas enlazadas y fallos de caché: Por qué quedan cortas frente a los arrays en tareas del mundo real

Las listas enlazadas parecen ideales para operaciones dinámicas: inserciones y eliminaciones en O(1), sin realocaciones. Pero en la práctica, a menudo pierden frente a los arrays debido a los fallos de caché. Cada salto a través del puntero next sobrecarga el subsistema de memoria, convirtiendo ventajas teóricas en cuellos de botella.

Las pruebas con 100.000 elementos muestran la diferencia:

| Operación | Array | Lista enlazada | Retraso |

Google AdInline article slot

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

| Recorrido secuencial | 70 μs | 179 μs | ×2.5 |

| Acceso aleatorio | 95 μs | 2847 μs | ×30 |

Google AdInline article slot

| Inserciones al final | 42 μs | 1234 μs | ×29 |

Incluso las inserciones, donde las listas deberían destacar, terminan siendo más lentas debido a las realocaciones y fallos de caché.

Mecanismo de fallos de caché

Las líneas de caché de la CPU cargan 64 bytes a la vez. En un array, los elementos están dispuestos de forma secuencial: un fallo da acceso a 16 enteros. En una lista enlazada, los nodos están dispersos por el montón:

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

Recorrer la lista genera un fallo por cada nodo (~100 ciclos de retraso). Para 100.000 nodos, eso son 10 millones de ciclos frente a 625 mil para el array. El array usa 400 KB, la lista: 1.6 MB (sobrecarga 4×).

Costes de asignación de memoria

Crear la lista requiere 100.000 llamadas a malloc():

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: una llamada. Diferencia en benchmark: 42 μs vs. 1234 μs.

Escenarios raros donde las listas están justificadas

Las listas enlazadas tienen sentido en casos nicho:

  • Listas intrusivas en kernels (Linux list_head): nodos embebidos en estructuras, mejor localidad.
  • Estructuras sin bloqueos: CAS atómico en punteros más simple que en arrays.
  • Conjuntos de datos estáticos pequeños con inserciones raras.

Ejemplo de la pila sin bloqueos de Treiber:

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));
}

Optimizaciones para cuando tengas que usarlas

Pools de objetos

Preasignar un array de nodos:

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

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

Velocidad: 287 μs (×4.3 vs. malloc, pero ×6.8 vs. array).

Listas desenrolladas

Almacenar múltiples elementos por nodo:

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

Recorrido: 45 μs (mejor que lista estándar, comparable a array para acceso secuencial).

Listas enlazadas XOR

Ahorrar memoria vía XOR de 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;

Desventajas: complejidad en depuración, no recorrido bidireccional. No recomendado.

Caso RTOS: Por qué funcionan allí

En FreeRTOS, el planificador usa un array de listas por prioridad (32 niveles):

list_head ready_tasks[MAX_PRIORITIES];

Éxito debido a:

  • Listas pequeñas (1–5 tareas).
  • Nodos embebidos en task_struct.
  • Operaciones O(1) por prioridad.

Benchmark en Cortex-M4: inserción 0.8 μs, eliminación 0.6 μs.

Puntos clave

  • El caché domina: fallos de punteros matan el rendimiento incluso en operaciones O(1).
  • Sobrecarga de memoria: crecimiento 4× por punteros + fragmentación.
  • Arrays dinámicos preferidos: inserciones amortizadas O(1) sin penalizaciones de caché.
  • Optimizaciones ayudan algo: pools y listas desenrolladas cierran la brecha pero no superan.
  • Excepción RTOS: nodos embebidos + tamaño pequeño justifican la elección.

— Editorial Team

Advertisement 728x90

Leer después