Lumen 사이드 8편 – 8 코어인데 8배가 아닌 이유, 메모리 대역폭의 벽

A close up view of an industrial manifold with pre

지금까지 시리즈에서 SIMD(5편) + 양자화(6편) + Transformer 디테일(7편)을 풀었어요. 이론적으로는 이걸 다 적용하면 단어당 시간이 매우 짧아질 것 같아요. 근데 실제 측정해 보면 “이론치만큼 안 나온다”는 사실에 부딪쳐요. 그 이유가 이번 편 주제예요.

8 코어 CPU에서 8 스레드를 써도 매트멀이 8배 빨라지지 않아요. 실제로는 1.5배 정도밖에 안 빨라져요. 처음 측정 결과 봤을 때 “스레딩 잘못 짠 거 아냐?” 의심했거든요. 알고 보니 코드 문제가 아니라 하드웨어 한계였어요. 메모리 대역폭이라는 단단한 벽이 있더라구요.

이 편의 핵심 비유: “한 우물에서 8명이 물 긷기”. 마을에 우물이 하나면 8명이 동시에 와도 물 긷는 속도가 8배 안 빨라져요. 우물 입구가 좁아서. CPU와 RAM 관계가 정확히 그래요.

산업용 배관 시스템 상세 뷰

멀티스레딩의 첫 번째 기대 – 8 코어면 8배?

8 코어 CPU에서 매트멀을 한다고 합시다. 일반적인 기대는 이거예요.

1 코어로 1초 걸리는 일을 8 코어가 나눠 하면 0.125초에 끝난다.

이론적으로 맞는 말입니다. 그래서 우리도 ThreadPool을 만들고 매트멀을 8 worker에 나눠줬어요. 측정해 보면 결과는 이렇게 나옵니다.

Lumen JIT (Zen 4, Qwen2.5-0.5B-Q8):
  1 thread: 45.6 tok/s
  2 thread: 60.8 tok/s
  4 thread: 66.5 tok/s
  8 thread: 67.4 tok/s

이론치 (8 thread = 1 thread × 8): 365 tok/s
실제: 67.4 tok/s (이론치의 18.5%)

8 스레드인데 1.48배만 가속. 이론치의 약 1/6이에요. 이 정체의 정체가 8편의 주제입니다.

저도 처음 이 측정 결과 봤을 때 충격이었어요. 사이드 4주차에 멀티스레딩 도입하고 측정. “8배 빨라야 하는데 왜 1.5배지?” 한참 디버깅했어요. 코드 검토, ThreadPool 재설계, 측정 도구 의심, 별의별 의심 다 함. 결론은 코드 문제가 아니라 하드웨어 한계라는 사실이었어요. 그게 와닿은 게 5주차쯤.

한 우물에서 8명이 물 긷기 비유

이 시리즈에서 가장 중요한 비유 하나가 여기 등장합니다.

마을에 우물이 하나 있어요. 한 명이 양동이 들고 가서 물 1L 긷는데 1분 걸린다고 합시다. 8명이 동시에 물을 긷는다면 8L를 8분이 아니라 1분에 다 길어 올 수 있을까요?

당연히 안 됩니다. 우물에는 동시에 한 사람만 가서 물을 담을 수 있어요. 8명이 한 줄로 서서 한 명씩 차례로 긷거나, 8명이 동시에 와도 우물 가에 비좁아서 더 느려질 수 있어요.

CPU 코어와 RAM의 관계가 정확히 이거예요. 8 코어가 동시에 RAM에서 데이터를 가져오려고 해도 RAM은 한 번에 한 코어한테만 데이터를 줄 수 있어요. 정확히는 메모리 컨트롤러가 한 명령씩 처리하니까 8 코어가 동시에 요청해도 순차 처리됩니다.

이게 메모리 대역폭 (memory bandwidth) 한계예요. 단위는 GB/s. “초당 RAM에서 가져올 수 있는 데이터의 최대량”이에요.

AMD Zen 4 + DDR5-5200 dual channel:
  이론 메모리 대역폭: 약 80 GB/s
  실제 (Linux/Windows mixed load): 약 60~70 GB/s

1초에 60~70GB. 1 코어든 8 코어든 이게 한계입니다. 코어가 늘어나도 한 우물에서 긷는 물 양은 같아요.

도시 건축으로 풀면 — 도시 안 공사팀(CPU 코어)을 8개로 늘려도 자재 창고(RAM)에서 공사 현장(CPU)으로 가는 도로(메모리 버스)는 그대로 한 개예요. 8개 공사팀이 동시에 자재를 요청해도 자재 트럭이 한 줄로 도로를 지나가야 합니다. 도로 처리량이 정체.

이게 진짜 하드웨어 한계예요. 코드 잘 짠다고 풀 수 있는 게 아니에요. DDR5 메모리 채널이 dual이라 두 채널 동시 활용 가능하지만, 그게 80GB/s의 이론치예요. 그 이상은 새 메모리(HBM3 등)나 GPU 메모리(GDDR6X)로 가야 가능해요. CPU 환경에서는 80GB/s가 천장.

LLM 추론이 메모리 병목인 이유 – 수치로 확인

1편에서 얘기한 그 핵심 사실. LLM 추론은 계산보다 메모리 가져오기가 느려요.

수치로 봅니다. Qwen2.5-0.5B-Q8 한 단어 만들 때:

  • 매트멀 계산 양: 약 10억 번 곱셈
  • 메모리에서 가져올 데이터: 약 670MB (모델 전체 weight)
  • CPU 계산 능력: 1조 번/초 (1 core, AVX2 활용)
  • 메모리 대역폭: 80GB/s
계산 시간: 10억 / 1조 = 0.001초
메모리 시간: 0.67GB / 80GB = 0.0084초

메모리 시간이 계산 시간의 8배. 즉 계산이 끝나도 메모리에서 데이터 못 받아 CPU가 idle 상태가 됩니다. 계산이 빨라져도 메모리가 안 따라오면 의미가 없어요.

도시 비유로 풀면 — 공사팀의 실제 시공 시간(0.001초)보다 자재가 도로로 도착하는 시간(0.0084초)이 8배 더 걸려요. 공사팀이 자재 기다리며 손 놓고 있는 상황. 공사팀을 늘려도 자재가 8배 빨리 안 오면 의미 없어요.

이 사실이 LLM 추론을 빠르게 만드는 모든 시도의 핵심 제약이에요. SIMD로 계산을 16배 빠르게 해도 메모리에서 데이터가 안 와서 실제 가속은 적어요. 그래서 5편에서 SIMD 8배 가속이 실제론 5배 정도밖에 안 나오는 게 이거 때문.

메모리 활용도 차이 – Lumen 56% vs ggml 74%

이 사실 위에서 측정을 보면 흥미로운 패턴이 나옵니다.

8 thread tg32 (32 단어 만들기):
  Lumen v0.5: 67.4 tok/s
  ggml:       87.9 tok/s

같은 단어 만드는 동안 메모리에서 가져오는 데이터 양은 같음 (모델 같으니까).
시간만 다름. 메모리 처리량으로 환산:

  Lumen: 21.4 GB / 0.475s = 약 45 GB/s
  ggml:  21.4 GB / 0.364s = 약 59 GB/s

이론치 80 GB/s 대비:
  Lumen: 56% 활용
  ggml:  74% 활용

ggml은 메모리 대역폭의 74%를 활용하는데 Lumen은 56%만 활용. 그 차이가 격차(1.30배)의 정체예요.

도시 비유로 풀면 — 자재 도로의 이론 처리량이 시간당 80 트럭인데, ggml은 시간당 59 트럭(74%), Lumen은 45 트럭(56%) 운반 중. 같은 도로인데 운영 효율 차이. 도로 폭을 늘릴 수는 없으니 운영 효율을 올려야 함.

이 측정이 Lumen Phase 7.U의 진단이에요(9편에서 자세히). 격차를 좁히려면 메모리 활용도를 올려야 한다는 명확한 결론. 단순 “코드 더 잘 짜기”가 아니라 “메모리 운영 효율 올리기”가 핵심.

이 진단이 사이드 5주차에 가장 큰 발견이었어요. 그 전까진 “어디서 격차가 나는지” 막막했거든요. step-별 시간 측정, thread별 측정, memory bandwidth 측정을 다 해서 결론. 결국 격차의 정체가 메모리 활용도 차이라는 게 측정으로 확정되니까 그 다음 작업 방향이 명확해졌어요. 측정-주도 의사결정의 좋은 예시인데, 그건 9편에서 본격적으로 풀게요.

모노톤 대형 산업용 파이프

메모리 활용도를 올리는 5가지 방법

메모리 대역폭은 하드웨어 한계라 못 늘립니다. 그 안에서 활용도(%)를 올리는 방법이 몇 가지 있어요.

1. Prefetch 명령어

CPU에게 “이 데이터 곧 쓸 거니까 미리 가져와 둬”라고 힌트 주는 명령. 잘 쓰면 메모리 latency를 hide할 수 있어요. 단점은 잘못 쓰면 cache pollution으로 오히려 더 느려짐. Lumen은 Phase 8.C에서 prefetch 시도했다가 회귀. 1 thread는 win인데 8 thread는 -49% 회귀.

도시 비유로 풀면 — “다음 차에 쓸 자재 미리 보내 두세요” 신호. 잘 쓰면 공사 안 멈추는데 자재 도로가 혼잡할 땐 오히려 정체 가중. 1팀이서 prefetch 신호 보내면 자재가 미리 도착해서 win. 8팀에서 각자 prefetch 신호 보내면 도로가 신호 폭주로 혼잡 -49% 회귀.

2. 매트멀 형상 최적화

SIMD load가 cache line(64바이트)에 정렬되게 데이터 배치. row-major vs column-major 결정, transpose 회피 등. 같은 데이터를 메모리에서 가져올 때 인접한 데이터까지 같이 cache에 올라오니까, 다음 접근에서 cache hit. 64바이트 단위로 잘 정렬되면 메모리 한 번 가져올 때 16개 fp32 동시에.

3. Streaming load/store

한 번 쓰고 버릴 데이터에 non-temporal 명령 사용. cache 오염 없이 빠르게 처리. vmovntps 같은 명령. cache를 거치지 않고 직접 RAM에 쓰니까 cache 효율이 안 떨어져요. 매트멀 결과 같은 “쓰고 다음에 안 쓸” 데이터에 적합.

4. Cache blocking

큰 매트멀을 작은 tile로 쪼개서 cache 친화적으로 처리. 같은 데이터를 여러 번 쓰게 만들어 cache hit률 올림. CPU의 L1 cache(32KB)에 들어가는 크기로 매트멀을 나누면, 한 tile 처리하는 동안 데이터가 cache에 머물러서 RAM 접근 줄어요.

5. weight 재사용 – prefill batching

입력 단어 여러 개를 한 번에 처리해서 같은 weight를 N번 쓰게 만듦. 1편에서 본 그 prefill 가속의 원리. 도시 비유로 풀면 한 번 도착한 자재로 8가지 시공을 동시 처리. 자재 트럭 1대로 8건 시공.

이 5가지 중 Lumen은 일부 시도했고, 일부는 measurement-driven으로 부정됐어요(9편). 예를 들어 prefill batching은 일주일 작업했는데 측정에서 2.9배 회귀. 직관(weight 재사용이니까 당연히 빠를 것)이 측정으로 부정된 가장 큰 사례.

멀티스레딩이 그래도 도움 되는 이유 – Amdahl 법칙

메모리 병목이라면 멀티스레딩이 의미가 없을까요? 그렇진 않아요. 8 코어 활용이 1.48배 가속이라도 0배가 아니에요. 메모리 외에 다른 부분(계산, sync 등)에서는 멀티스레딩이 도움이 됩니다.

또 매트멀이 정말로 메모리 100% bound는 아니에요. 70~80% 메모리 bound + 20~30% 계산 bound 정도. 그래서 계산 bound 부분만큼은 코어 늘면 가속됩니다.

이론적 최대 가속 (Amdahl's law):
  계산 부분이 30%, 메모리 부분이 70%라면
  N 코어 가속 = 1 / (0.7 + 0.3/N)
  N=8: 1 / (0.7 + 0.0375) = 1.36배

실제 측정: 1.48배 (8 thread / 1 thread)

이론치와 거의 일치해요. 즉 우리 측정은 “메모리 70% + 계산 30%” 모델에 잘 맞고, 멀티스레딩이 정확히 그 모델만큼의 가속을 주고 있어요. 더 짠다고 8배 가속이 나올 물리적 가능성이 없습니다.

이 사실을 받아들이는 게 중요해요. “더 짜면 더 빨라질 거다”라는 직관은 메모리 병목 분야에서는 틀려요. 한계가 명확해서 어디까지 좁힐 수 있는지 측정으로 정해야 합니다.

도시 비유로 풀면 — 자재 도로 1차선, 공사팀 8개 상황. 공사팀이 30%는 도면 검토 같은 자체 작업, 70%는 자재 대기. 공사팀 늘려도 자재 대기 시간은 안 줄어들고 자체 작업만 8 등분 됨. 결과 가속 약 1.36배. 더 늘려도 1.4배 근방에서 한계.

이 Amdahl 법칙이 사이드의 마음 가짐을 바꿔준 개념이에요. “이론적 한계가 1.4배”라는 사실 받아들이고 가니까 5주차에 정신적으로 가벼워졌어요. “더 짜야 한다”는 강박이 사라지고 “어디까지 가능한지 측정으로 확정” 모드로 전환. 그 마음가짐이 6주 사이드를 결말 본 핵심 요인 중 하나예요.

ThreadPool 첫 디자인 – mutex + channel의 함정

코드 레벨에서 멀티스레딩이 어떻게 작동하는지 봅시다. Lumen은 ThreadPool이라는 구조를 사용해요. 8개 worker 스레드를 미리 만들어 두고, 매트멀 매번 다음 일을 나눠줍니다.

도시 비유로 풀면 — 도시에 상시 대기 공사 인부 8명이 있어요. 일이 들어오면 8명에게 분배하고, 다 끝나면 다시 대기.

매트멀을 8개 worker에 나누는 방식:

한 매트멀의 output 차원이 4864개 row라면
  worker 0: row 0~607 처리
  worker 1: row 608~1215 처리
  worker 2: row 1216~1823 처리
  ...
  worker 7: row 4256~4863 처리

각 worker가 독립적으로 자기 row만 매트멀 계산. 다 끝나면 결과 합쳐서 다음 단계로. 이런 패턴을 parallel-for라고 부릅니다.

처음 짠 디자인 – mutex 기반

처음 Lumen이 만든 ThreadPool은 이런 구조였어요(Phase 7.L).

main thread:
  for chunk in chunks:
    channel.send(task)         # 큐에 작업 추가
  wait_all_done()

worker thread (× 8):
  loop:
    task = channel.recv()      # 큐에서 가져오기 (mutex)
    task.execute()
    signal_done()

main이 큐에 task 8개 push하고, 8 worker가 각자 큐에서 1개씩 꺼냄. 단순한 구조.

문제: 모든 worker가 같은 mutex를 두고 경쟁합니다. 큐에서 꺼낼 때마다 mutex lock. 8 worker가 동시에 lock 시도하면 7명이 대기, 1명만 진행. 이게 dispatch overhead예요.

도시 비유로 풀면 — 작업 분배를 위해 공사 책임자(mutex) 한 명이 명단을 들고 있고, 인부들이 차례로 가서 “다음 일 뭐예요” 물어보고 받아오는 구조. 8명이 한꺼번에 가면 7명이 줄 서야 해요.

매트멀 한 번에 매트멀 5개 × 24 layer = 120번 dispatch. 32 단어 만들면 3840번 dispatch. 매번 mutex 경쟁. 이게 작지만 누적되면 큰 오버헤드입니다.

두 번째 디자인 – atomic counter, 번호표 발급기

Phase 8.A에서 이걸 재설계했어요. mutex 없이 atomic counter 사용.

shared_state:
  next_task: atomic_int = 0
  done_count: atomic_int = 0

main thread:
  next_task = 0
  done_count = 0
  signal_workers_start()
  wait until done_count == n_workers

worker thread (× 8):
  loop:
    i = next_task.fetch_and_increment()  # 원자적으로 1 더하고 이전 값 받기
    if i >= n_tasks:
      done_count.increment()
      break
    process_task(i)

핵심은 fetch_and_increment 한 명령. 여러 worker가 동시에 호출해도 CPU가 원자적으로 처리해서 충돌 없어요. mutex가 필요 없습니다.

도시 비유로 풀면 — 책임자 없이 번호표 발급기 하나 두는 거예요. 인부가 가서 손잡이 한 번 당기면 다음 번호 표 출력. 8명이 동시에 와도 기계가 한 명씩 처리해서 충돌 없음. 책임자에게 물어보는 줄 서기 없음.

장점:

  • mutex contention 없음
  • 매 dispatch당 메모리 할당 없음 (이전 디자인은 task box 만들었음)
  • 단순함

측정 결과(Phase 8.A):

  • 1 thread: +9% 가속
  • 2 thread: +14% 가속
  • 8 thread: +3% (이미 메모리 병목이라 dispatch 개선의 효과 작음)

dispatch overhead가 thread 적을 때만 큰 비중이고 thread가 많아지면 메모리가 더 큰 병목이라는 사실. 이걸 측정으로 확인했어요. 1~2 thread에서는 명확한 가속, 8 thread는 노이즈 영역이지만 그래도 회귀는 아니라 merge.

이게 Lumen v0.5.0의 핵심 변경 중 하나였어요. 코드 한 100줄 정도 재설계인데 1~2 thread에서 +14% 가속을 만들었어요. 단순한 변경이 큰 효과 만든 경우. atomic primitives의 가치를 한 번 체험한 사례예요.

멀티스레딩이 어려운 함정들

코드 레벨에서 멀티스레딩은 함정이 많아요. 디자인 자체는 단순해도 디버깅이 매우 어렵습니다. 몇 가지 흔한 문제.

1. Race condition

두 thread가 같은 변수에 동시 쓰면 결과가 불확정. atomic이나 mutex로 보호해야 함. 도시 비유로 풀면 — 두 인부가 같은 자재를 동시에 가져가려고 하면 자재가 어디로 가는지 결과가 불확정.

2. Deadlock

두 thread가 서로의 lock을 기다리며 영원히 멈춤. 도시 비유로 풀면 — 두 인부가 서로 상대방이 가진 자재를 기다리며 영원히 시공 못 함.

3. False sharing

두 thread가 다른 변수를 쓰는데 같은 cache line에 있어서 cache가 계속 invalidate되며 느려짐. 도시 비유로 풀면 — 인부 두 명이 다른 자재를 보지만 같은 자재 창고 칸을 함께 봐서 한 명이 꺼낼 때마다 다른 명이 다시 가서 확인해야 함.

4. 순서 보장 (memory ordering)

atomic 변수도 어떤 순서로 메모리에 보이는지가 까다로움. Acquire/Release/SeqCst 등의 옵션을 이해해야 함. Rust의 std::sync::atomic이 이걸 명확하게 강제해서 좋아요.

이걸 다 직접 짜는 게 사이드 프로젝트로 가능한 이유는 매트멀이 특별히 단순한 패턴이기 때문이에요. 각 worker가 독립적인 메모리 영역을 쓰니까 false sharing 가능성 낮고, 한 매트멀 끝나야 다음으로 가니까 race 없고, 단순 parallel-for라 deadlock 가능성 없음.

복잡한 멀티스레드 자료 구조(concurrent hashmap 등) 짜는 건 차원이 다른 어려움입니다. 운영체제 책 한 권 분량의 지식이 필요해요. 사이드 프로젝트로는 단순한 패턴(parallel-for, producer-consumer 등)으로 시작하는 게 좋아요. Lumen도 단순 parallel-for 패턴 하나로 매트멀 멀티스레딩 했어요. 200줄 정도.

저는 회사 일에서 멀티스레딩 코드 안 만들거든요. 보안 분야는 멀티스레딩 자체보다는 정적 분석/네트워크 패킷 검사 같은 일이라. 그래서 멀티스레딩 직접 짜본 사이드 경험이 진짜 신선했어요. atomic, mutex, channel, race condition 같은 단어들이 책에서만 있던 게 코드로 부딪치는 경험. 사이드 자산의 좋은 예시.

8편 정리

  1. 8 코어 CPU에서 8 스레드 가속이 1.5배 정도. 메모리 대역폭이 코어 수와 무관한 하드웨어 한계이기 때문. “한 우물에서 8명이 물 긷기”.
  2. LLM 추론은 메모리 병목. 계산 시간 0.001초, 메모리 시간 0.0084초. 메모리가 8배 더 걸림. 공사팀(CPU)이 자재(데이터)를 기다리며 idle.
  3. 메모리 활용도 차이가 격차의 정체. Lumen 56%, ggml 74%. 같은 모델, 같은 하드웨어에서 처리 시간 차이는 자재 도로 운영 효율 차이.
  4. ThreadPool의 핵심은 dispatch overhead 최소화. mutex 대신 atomic counter 쓰면 1~2 thread에서 큰 가속, 8 thread에서는 노이즈 영역. “책임자에게 물어보기”에서 “번호표 발급기”로.
  5. 멀티스레딩이 어려운 분야지만 매트멀은 단순한 패턴. parallel-for 한 패턴만 잘 짜도 LLM 추론에 충분.

이번 편이 시리즈에서 가장 “직관과 다른” 영역이에요. “8 코어니까 8배”라는 직관이 메모리 병목 앞에서 무너져요. Amdahl 법칙 한 번 받아들이고 나면 그 다음부턴 “한계 안에서 최대치”로 마음가짐이 바뀌어요. 이게 사이드의 큰 학습이었어요.

다음 편 미리보기

9편은 측정-주도 의사결정입니다. 시리즈 전반에서 자주 나온 “측정으로 부정”, “가설 11번 중 8번 부정”이 정확히 어떤 사이클이고, 왜 그 패턴이 사이드 프로젝트의 본질인지 풀어 봅니다.

도시 비유로 풀면 “건물을 짓기 전에 지반 측량부터 하기”의 원리. Lumen v0.5 cycle에서 부정된 8번의 가설, win 2번의 가설을 하나씩 살펴보고, 측정 인프라를 어떻게 짜야 11번 사이클이 가능한지를 다룹니다. AVX-512 ZMM이 -4.5%였던 일화(5편에서 잠깐 풀었던 거)도 자세히. prefill batching이 2.9배 회귀한 8.D 사이클도.

이 시리즈 본문 전체는 GitHub에도 정리해 뒀어요. github.com/redchupa/lumen/docs/tutorial에서 원문 격으로 보실 수 있어요.

그럼 다음 편에서 뵐게요. 멀티스레딩 또는 메모리 병목 관련 질문 있으시면 댓글 남겨주세요.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤