Powrót do strony głównej

Stosy i kolejki w C: optymalizacja pamięci podręcznej i wydajności

Artykuł analizuje wpływ wyboru struktury danych na wydajność programów w języku C, koncentrując się na stosach i kolejkach. Przedstawia szczegółowe porównanie implementacji na tablicach i listach powiązanych, podkreślając krytyczne znaczenie lokalności pamięci podręcznej. Omawia zaawansowane techniki optymalizacji, takie jak bufor kołowy z rozmiarem będącym potęgą dwójki oraz implementacje bez blokad, istotne dla systemów wbudowanych i czasu rzeczywistego.

Optymalizacja stosów i kolejek w C: 35x szybsze działanie
Advertisement 728x90

Stosy i kolejki: optymalizacja pod kątem pamięci podręcznej i wydajności w C

Stos wywołań jest obecny w każdym programie, ale przy implementacji własnych stosów i kolejek wybór struktury danych krytycznie wpływa na wydajność. Listy powiązane powodują nawet do 37 razy więcej cykli z powodu chybień pamięci podręcznej i narzutu malloc/free. Bufor kołowy na tablicy zapewnia przewidywalne zużycie pamięci i przyspieszenie do 35 razy.

Implementacja stosu: tablica vs lista powiązana

Klasyczne akademickie implementacje stosów różnią się pod względem zachowania pamięci podręcznej.

Stos na tablicy (stały rozmiar, 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;
}

Stos na liście powiązanej (dynamiczny, 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;
}

Benchmarki (1000 operacji):

| Implementacja | Cykle | Chybienia pamięci podręcznej |

Google AdInline article slot

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

| Tablica | 12 tys. | 45 |

| Lista | 450 tys.| 2100 |

Google AdInline article slot

Przyczyny spowolnienia listy powiązanej:

  • Narzut malloc/free (~100 taktów na operację)
  • Rozproszenie węzłów po stercie (chybienia L1/L2)
  • Podążanie za wskaźnikami (zależność danych)

Rekomendacje wyboru:

  • Tablica: systemy wbudowane, czas rzeczywisty
  • Lista: nieprzewidywalny rozmiar + nadmiar pamięci

Bufor kołowy: podstawa wydajnych kolejek

Naiwna kolejka na tablicy psuje się po osiągnięciu końca bufora:

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

Problem: front==rear nie rozróżnia kolejki pustej i pełnej.

Bufor kołowy rozwiązuje problem za pomocą arytmetyki modularnej:

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

Benchmarki (1M operacji):

| Implementacja | Cykle | Chybienia pamięci podręcznej |

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

| Bufor kołowy | 15 mln | 1234 |

| Lista | 520 mln | 980 tys. |

Przyspieszenie 35 razy dzięki lokalności pamięci podręcznej.

Optymalizacja bufora kołowego

Operacja % (10-40 taktów) — wąskie gardło.

Optymalizacja 1: rozmiar będący potęgą dwójki

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

q->tail = (q->tail + 1) & MASK;  // 1 takt zamiast 30

Rezultat: przyspieszenie 1.76 razy (15M → 8.5M taktów).

Optymalizacja 2: bez licznika count

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

Kompromis: maksymalnie MAX_SIZE-1 elementów.

Bufor kołowy bez blokad (SPSC)

Dla pojedynczego producenta/konsumenta (przerwania, rdzenie):

typedef struct {
    volatile int data[MAX_SIZE];
    volatile int head;  // Tylko konsument
    volatile int tail;  // Tylko producent
} 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;
    }
}

Kluczowe elementy:

  • volatile przeciwko optymalizacjom kompilatora
  • Bariery pamięci dla słabych modeli (ARM, RISC-V)
  • Brak operacji atomowych

Wersja dla RISC-V:

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

Kolejka priorytetowa: kopiec binarny

Min-kopiec (element minimalny w korzeniu):

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;
        // zamiana
        int temp = h->data[i];
        h->data[i] = h->data[parent];
        h->data[parent] = temp;
        i = parent;
    }
}

Złożoność: O(log n), dobra lokalność pamięci podręcznej przy małych n.

Podsumowanie

  • Listy powiązane dla stosów/kolejek dają 30-40x spowolnienie z powodu chybień pamięci podręcznej
  • Bufor kołowy z rozmiarem 2^n przyspiesza 1.76x dzięki operacji bitowego AND
  • Bufor bez blokad SPSC idealny dla przerwań i RTOS
  • Kopce binarne zachowują zalety tablic przy O(log n)
  • W systemach wbudowanych stały rozmiar = determinizm

— Editorial Team

Advertisement 728x90

Czytaj dalej