홈으로 돌아가기

연결 리스트: 캐시 미스와 벤치마크

이 기사는 연결 리스트가 캐시 미스로 인해 배열에 뒤처지는 이유를 분석: 벤치마크가 30배 지연을 보여줌. 설명된 최적화 — 풀, 언롤드 리스트, 침입적 구현. RTOS와 lock-free 구조 예제.

연결 리스트가 캐시를 죽인다: 벤치마크와 해결책
Advertisement 728x90

연결 리스트와 캐시 미스: 실무에서 배열에 밀리는 이유

연결 리스트는 동적 작업에 이상적인 선택처럼 보입니다: O(1) 삽입과 삭제, 재할당 없음. 하지만 실제로는 캐시 미스로 인해 배열에 자주 뒤처집니다. next 포인터를 통한 각 점프가 메모리 서브시스템에 부하를 주며, 이론적 우위를 병목 현상으로 바꿔버립니다.

10만 개 요소에 대한 테스트에서 그 차이가 드러납니다:

| 작업 | 배열 | 연결 리스트 | 지연 |

Google AdInline article slot

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

| 순차 순회 | 70 μs | 179 μs | ×2.5 |

| 랜덤 접근 | 95 μs | 2847 μs | ×30 |

Google AdInline article slot

| 추가 | 42 μs | 1234 μs | ×29 |

삽입 작업조차 연결 리스트가 우위를 점해야 할 곳에서 재할당과 캐시 미스로 인해 느려집니다.

캐시 미스 메커니즘

CPU 캐시 라인은 한 번에 64바이트를 가져옵니다. 배열에서는 요소가 순차적으로 배치되어 한 번의 미스로 16개의 int에 접근할 수 있습니다. 반면 연결 리스트에서는 노드가 힙 전체에 흩어져 있습니다:

Google AdInline article slot
// 전형적인 노드
struct node {
    int value;     // 4 bytes
    node *next;    // 8 bytes
}; // 패딩 포함 16 bytes

리스트를 순회할 때 각 노드마다 미스가 발생합니다(~100 사이클 지연). 10만 개 노드라면 배열의 62.5만 사이클에 비해 1천만 사이클이 소요됩니다. 배열은 400 KB를 사용하지만 리스트는 1.6 MB(4배 오버헤드)를 씁니다.

메모리 할당 비용

리스트 생성에는 10만 번의 malloc() 호출이 필요합니다:

for (int i = 0; i < 100000; i++) {
    node *n = malloc(sizeof(node)); // 힙 검색, 메타데이터, 단편화
    n->value = i;
    n->next = head;
    head = n;
}

배열은 한 번의 호출만으로 충분합니다. 벤치마크 차이: 42 μs 대 1234 μs.

연결 리스트가 정당화되는 드문 시나리오

연결 리스트는 특수한 경우에만 적합합니다:

  • 커널의 침투형 리스트 (Linux list_head): 구조체에 노드를 내장해 지역성을 개선.
  • 락-프리 구조체: 배열 대비 포인터의 원자적 CAS가 더 간단.
  • 작고 정적인 데이터셋에 드문 삽입.

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

사용해야 할 때의 최적화 기법

객체 풀

노드 배열을 미리 할당합니다:

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

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

속도: 287 μs (malloc 대비 ×4.3, 배열 대비 ×6.8).

언롤링 리스트

노드당 여러 요소 저장:

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

순회: 45 μs (표준 리스트보다 빠르며, 순차 접근 시 배열과 비슷).

XOR 연결 리스트

prev^next의 XOR로 메모리 절약:

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

// 순회 시 prev 추적 필요
node *next = (uintptr_t)prev ^ (uintptr_t)curr->xor_ptr;

단점: 디버깅 복잡, 양방향 순회 불가. 권장하지 않음.

RTOS 사례: 왜 거기서는 리스트가 잘 작동하나

FreeRTOS의 스케줄러는 우선순위별(32단계) 리스트 배열을 사용합니다:

list_head ready_tasks[MAX_PRIORITIES];

성공 이유:

  • 작은 리스트(1~5 태스크).
  • task_struct에 노드 내장.
  • 우선순위별 O(1) 작업.

Cortex-M4 벤치마크: 삽입 0.8 μs, 삭제 0.6 μs.

주요 교훈

  • 캐시가 지배적: O(1) 작업에서도 포인터 미스가 성능을 떨어뜨립니다.
  • 메모리 오버헤드: 포인터 + 단편화로 4배 증가.
  • 동적 배열 선호: 캐시 패널티 없이 완전 희석 O(1) 삽입.
  • 최적화는 어느 정도 도움: 풀과 언롤링 리스트가 격차를 좁히지만 앞서진 못함.
  • RTOS 예외: 내장 노드 + 작은 크기로 선택 정당화.

— Editorial Team

Advertisement 728x90

다음 읽기