Volver al inicio

Optimización de pilas y colas en C: rendimiento y caché

Este artículo compara implementaciones de pilas y colas en C, analizando su impacto en el rendimiento debido al comportamiento de la caché. Explica por qué las estructuras basadas en arreglos, como los búferes circulares, superan significativamente a las listas enlazadas, y ofrece técnicas de optimización avanzadas para escenarios de alto rendimiento y sistemas embebidos.

Pilas y Colas en C: Claves para un Rendimiento Óptimo
Advertisement 728x90

Pilas y Colas: Optimización de Caché y Rendimiento en C

La pila de llamadas está presente en todos los programas, pero al implementar pilas y colas personalizadas, la elección de la estructura de datos impacta críticamente el rendimiento. Las listas enlazadas pueden causar hasta 37 veces más ciclos debido a fallos de caché y sobrecarga de malloc/free. Los búferes circulares basados en arreglos ofrecen un uso de memoria predecible y una aceleración de hasta 35x.

Implementación de Pila: Arreglo vs. Lista Enlazada

Las implementaciones clásicas de pilas de los libros de texto difieren en su comportamiento de caché.

Pila basada en arreglo (tamaño fijo, O(1)):

Google AdInline article slot
#define MAX_SIZE 1000

typedef struct {
    int data[MAX_SIZE];
    int top;
} stack_t;

void push(stack_t *s, int value) {
    if (s->top < MAX_SIZE) {
        s->data[s->top++] = value;
    }
}

int pop(stack_t *s) {
    if (s->top > 0) {
        return s->data[--s->top];
    }
    return -1;
}

Pila basada en Lista Enlazada (dinámica, O(1)):

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

typedef struct {
    node_t *top;
} stack_t;

void push(stack_t *s, int value) {
    node_t *node = malloc(sizeof(node_t));
    node->value = value;
    node->next = s->top;
    s->top = node;
}

int pop(stack_t *s) {
    if (s->top) {
        node_t *node = s->top;
        int value = node->value;
        s->top = node->next;
        free(node);
        return value;
    }
    return -1;
}

Benchmarks (1000 operaciones):

| Implementación | Ciclos | Fallos de Caché |

Google AdInline article slot

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

| Arreglo | 12K | 45 |

| Lista Enlazada | 450K | 2100 |

Google AdInline article slot

Razones de la Lentitud de la Lista Enlazada:

  • Sobrecarga de malloc/free (~100 ciclos por operación)
  • Nodos dispersos en el montón (fallos de caché L1/L2)
  • Seguimiento de punteros (dependencia de datos)

Recomendaciones de Selección:

  • Arreglo: sistemas embebidos, aplicaciones en tiempo real
  • Lista Enlazada: tamaño impredecible + memoria disponible

Búfer Circular: La Base de las Colas Eficientes

Una cola ingenua basada en arreglo falla al llegar al final del búfer:

void enqueue(queue_t *q, int value) {
    if (q->rear < MAX_SIZE) {
        q->data[q->rear++] = value;
    }
}

Problema: front==rear no puede distinguir entre una cola vacía y una llena.

El Búfer Circular resuelve el problema usando aritmética modular:

typedef struct {
    int data[MAX_SIZE];
    int head;
    int tail;
    int count;
} ring_buffer_t;

void enqueue(ring_buffer_t *q, int value) {
    if (q->count < MAX_SIZE) {
        q->data[q->tail] = value;
        q->tail = (q->tail + 1) % MAX_SIZE;
        q->count++;
    }
}

int dequeue(ring_buffer_t *q) {
    if (q->count > 0) {
        int value = q->data[q->head];
        q->head = (q->head + 1) % MAX_SIZE;
        q->count--;
        return value;
    }
    return -1;
}

Benchmarks (1M operaciones):

| Implementación | Ciclos | Fallos de Caché |

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

| Búfer Circular | 15M | 1234 |

| Lista Enlazada | 520M | 980K |

Una aceleración de 35x debido a la localidad de caché.

Optimizando el Búfer Circular

La operación % (10-40 ciclos) es un cuello de botella.

Optimización 1: Tamaño Potencia de Dos

#define MAX_SIZE 1024
#define MASK (MAX_SIZE - 1)

q->tail = (q->tail + 1) & MASK;  // 1 ciclo en lugar de 30

Resultado: aceleración de 1.76x (15M → 8.5M ciclos).

Optimización 2: Eliminando la Variable de Conteo

int is_empty(ring_buffer_t *q) {
    return q->head == q->tail;
}

int is_full(ring_buffer_t *q) {
    return ((q->tail + 1) & MASK) == q->head;
}

Compromiso: máximo de MAX_SIZE-1 elementos.

Búfer Circular Sin Bloqueos (SPSC)

Para escenarios de productor único/consumidor único (interrupciones, núcleos):

typedef struct {
    volatile int data[MAX_SIZE];
    volatile int head;  // Solo consumidor
    volatile int tail;  // Solo productor
} spsc_ring_buffer_t;

void enqueue(spsc_ring_buffer_t *q, int value) {
    int next_tail = (q->tail + 1) & MASK;
    if (next_tail != q->head) {
        q->data[q->tail] = value;
        __sync_synchronize();
        q->tail = next_tail;
    }
}

Elementos Clave:

  • volatile para evitar optimizaciones del compilador
  • Barreras de memoria para modelos de memoria débiles (ARM, RISC-V)
  • No se requieren operaciones atómicas

Versión RISC-V:

asm volatile("fence w, w" ::: "memory");

Cola de Prioridad: Montículo Binario

Min-Montículo (elemento mínimo en la raíz):

typedef struct {
    int data[MAX_SIZE];
    int size;
} heap_t;

void heap_push(heap_t *h, int value) {
    int i = h->size++;
    h->data[i] = value;
    while (i > 0) {
        int parent = (i - 1) / 2;
        if (h->data[i] <= h->data[parent]) break;
        // intercambiar
        int temp = h->data[i];
        h->data[i] = h->data[parent];
        h->data[parent] = temp;
        i = parent;
    }
}

Complejidad: O(log n), buena localidad de caché para n pequeño.

Conclusiones Clave

  • Las listas enlazadas para pilas/colas causan una ralentización de 30-40x debido a fallos de caché
  • Los búferes circulares con tamaño 2^n ganan 1.76x de aceleración usando AND a nivel de bits
  • Los búferes SPSC sin bloqueos son ideales para interrupciones y RTOS
  • Los montículos binarios conservan las ventajas de los arreglos con complejidad O(log n)
  • En sistemas embebidos, tamaño fijo = determinismo

— Editorial Team

Advertisement 728x90

Leer después