테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

병렬 프로그래밍과 HPC

코어 하나가 더 빨라지지 않게 된 뒤로 성능은 일을 나누는 데서 나온다. 암달의 법칙, 원자 연산과 락 없는 자료 구조, 거짓 공유, SIMD와 GPU, 병렬 스캔, 작업 훔치기, MPI와 슈퍼컴퓨터, 그리고 동시성과 병렬의 차이를 직접 계산한 그림으로 따라간다.

목차 (16)
  1. 왜 병렬인가: 클럭이 멈춘 뒤
  2. 암달의 법칙과 구스타프슨의 법칙
  3. 스레드와 공유 메모리
  4. 원자 연산과 CAS
  5. 락 없는 스택과 ABA 문제
  6. 교착 너머: 라이브락, 기아, 우선순위 역전
  7. 거짓 공유
  8. SIMD 벡터화
  9. GPU 프로그래밍 모델
  10. 병렬 패턴: 맵, 리듀스, 스캔
  11. 작업 분배와 작업 훔치기
  12. 분산 메모리와 MPI
  13. HPC와 슈퍼컴퓨터
  14. 동시성과 병렬은 다르다
  15. 흔한 오해
  16. 핵심 자료

왜 병렬인가: 클럭이 멈춘 뒤

2000년대 초까지 프로그램은 가만히 있어도 빨라졌다. 새 CPU가 나올 때마다 클럭이 올랐기 때문이다. 트랜지스터를 줄이면 전압도 함께 내려가 전력이 늘지 않는다는 데나드 스케일링이 2005~2007년 무렵 깨지면서 이 공짜 점심이 끝났다. 클럭을 더 올리면 칩이 감당할 수 없을 만큼 뜨거워져, 클럭은 그 무렵부터 몇 GHz 언저리에 머물렀다. 트랜지스터 수는 계속 늘었으니 칩 회사들은 그 트랜지스터로 코어를 여러 개 만들었다. 전력 공식과 다크 실리콘 이야기는 컴퓨터 구조 노트의 전력 벽 절에 있다.

그 결과 한 갈래(스레드 하나)로 짠 프로그램은 코어가 16개든 64개든 하나만 쓴다. 더 빨라지려면 프로그래머가 일을 나눠야 한다. 나누는 단위는 여러 층에 있다. 명령 하나가 여러 데이터를 처리하는 SIMD, 코어 여러 개가 메모리를 함께 쓰는 스레드, 수천 갈래를 한꺼번에 돌리는 GPU, 메모리를 따로 가진 컴퓨터 수천 대가 메시지를 주고받는 분산 메모리(MPI)다. 이 노트는 아래에서 위로 이 층들을 훑는다. 컴퓨터 여러 대가 장애를 견디며 상태를 맞추는 문제는 분산 시스템 노트의 몫이다.

트랜지스터 수, 클럭, 코어 수가 시간에 따라 변하는 모양을 그린 개념도많음시간 →2005년 무렵데나드 스케일링이 멈춤트랜지스터 수클럭코어 수
클럭 정체의 모양을 그린 개념도(눈금 없음). 트랜지스터 수는 계속 늘었지만 2005년 무렵부터 클럭은 평평해지고 코어 수가 늘기 시작했다.

암달의 법칙과 구스타프슨의 법칙

프로그램의 비율 p만 병렬로 나눌 수 있고 나머지 1 − p는 순차로만 돈다면, 프로세서 n개로 얻는 속도 향상은 1 / ((1 − p) + p / n)이다. 1967년 진 암달이 제시한 이 식을 암달의 법칙이라 한다. n을 무한히 늘려도 1 / (1 − p)를 넘지 못한다. p = 0.95, 즉 95%를 병렬로 나눠도 상한은 19.999999999999982배이고, 8개면 5.9배, 64개면 15.4배, 1024개면 19.6배다. 남은 5%가 전체를 묶는다. 실제로는 나누고 모으는 통신·동기화 비용이 n과 함께 늘어 이보다 나쁘다.

1988년 존 구스타프슨은 다른 쪽을 봤다. 프로세서가 늘면 사람들은 같은 문제를 더 빨리 푸는 대신 더 큰 문제를 같은 시간에 푼다. 날씨 모델의 격자를 더 촘촘하게 하는 식이다. 문제가 커질 때 늘어나는 것은 대개 병렬 부분이므로, 병렬 기계에서 잰 순차 비율이 1 − p일 때 확장 속도 향상은 (1 − p) + p·n으로 거의 선형이다. p = 0.95, n = 64면 60.8배다. 두 법칙은 모순이 아니라 질문이 다르다. 같은 일을 빨리 끝내려면(강한 확장) 암달을, 더 큰 일을 같은 시간에 하려면(약한 확장) 구스타프슨을 본다.

병렬 비율별 암달 속도 향상 곡선과 구스타프슨 곡선을 로그 축으로 그린 그래프11배44배1616배6464배256256배10241024배프로세서 수구스타프슨 p = 0.95p = 0.5 → 2.0p = 0.9 → 9.9p = 0.95 → 19.6p = 0.99 → 91.2
병렬 비율 p에 따른 암달 속도 향상(실선)과 구스타프슨의 확장 속도 향상(점선, p = 0.95). 두 축 모두 로그 눈금이다. 오른쪽 숫자는 프로세서 1024개에서의 값이다.

스레드와 공유 메모리

한 프로세스 안의 스레드들은 주소 공간을 함께 쓴다. 코어 여러 개에 스레드를 하나씩 올리면, 포인터 하나로 같은 배열을 나눠 처리할 수 있어 데이터를 복사할 필요가 없다. 이것이 공유 메모리 병렬이다. 스레드가 무엇이고 OS가 어떻게 스케줄하는지, 뮤텍스·세마포어·조건 변수·교착 상태 같은 기본 동기화는 운영체제 노트에 있다. 여기서는 성능 쪽 이야기를 한다.

반복문 하나를 코어 수만큼 나누는 일은 매우 흔해서, 직접 스레드를 만들지 않고 지시문 한 줄로 하는 OpenMP가 C·C++·Fortran 과학 계산의 표준이 됐다. 아래 코드는 내적을 스레드마다 나눠 더한 뒤 마지막에 부분합을 합친다. reduction이 없으면 모든 스레드가 sum 하나를 동시에 고쳐 경쟁 상태가 된다. Java의 병렬 스트림, C#의 Parallel.For, C++17의 병렬 알고리즘, Rust의 Rayon도 같은 일을 라이브러리로 한다.

double sum = 0.0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < n; i++) {
  sum += a[i] * b[i];
}

공유 메모리는 편한 만큼 함정이 있다. 같은 데이터를 동시에 고치면 결과가 실행 순서에 따라 달라지고, 그렇다고 락을 너무 많이 걸면 스레드들이 줄을 서서 다시 순차가 된다(암달의 1 − p가 늘어난다). 또 코어 사이의 데이터 공유는 결국 캐시 줄을 주고받는 일이라, 아래 거짓 공유 절처럼 하드웨어 수준의 비용이 숨어 있다. 메모리 칩이 여러 소켓에 나뉘어 있으면 가까운 메모리와 먼 메모리의 속도가 다른 NUMA도 신경 써야 한다.

스레드 네 개가 코어 네 개에서 돌며 사설 캐시와 공유 L3를 거쳐 같은 메모리를 쓰는 그림스레드 0코어 0 · 레지스터L1 · L2 (사설)스레드 1코어 1 · 레지스터L1 · L2 (사설)스레드 2코어 2 · 레지스터L1 · L2 (사설)스레드 3코어 3 · 레지스터L1 · L2 (사설)L3 (모든 코어가 함께 씀)메모리 — 한 주소 공간count스택은 스레드마다모든 스레드가 같은 count를 주소 하나로 가리킬 수 있다 — 빠른 대신 동시에 고치면 경쟁 상태가 된다.
공유 메모리 멀티코어. 코어마다 사설 L1·L2가 있고 L3와 메모리를 함께 쓴다. 모든 스레드가 같은 주소의 count를 볼 수 있는 대신, 동시에 고치면 경쟁 상태가 된다.

원자 연산과 CAS

count++는 읽기·더하기·쓰기 세 걸음이라 스레드 둘이 겹치면 갱신 하나를 잃는다. 락 없이 이를 막는 도구가 CPU의 원자 연산이다. 읽고 고치고 쓰는 과정을 다른 코어가 끼어들 수 없는 한 덩어리로 해 준다. "더하고 이전 값 가져오기"(fetch-and-add)처럼 정해진 연산도 있지만, 가장 일반적인 것은 CAS(compare-and-swap)다. "메모리 값이 아직 내가 본 old면 new로 바꾸고 성공을 알려라, 아니면 아무것도 하지 말고 실패를 알려라."

CAS 하나로 어떤 갱신이든 만들 수 있다. 값을 읽고, 새 값을 계산하고, CAS로 바꾸고, 실패하면(그새 누가 바꿨으면) 처음부터 다시 한다. 아래는 C++의 예다. compare_exchange_weak는 값이 같아도 가끔 그냥 실패할 수 있는데(LL/SC 명령을 쓰는 Arm 같은 플랫폼 사정), 어차피 루프 안이라 한 번 더 돌 뿐이고 그런 플랫폼에서 더 빠르다. 한 번만 시도할 때는 compare_exchange_strong을 쓴다.

std::atomic<int> count{0};

void add_one() {
  int old = count.load();
  // 실패하면 old가 지금 값으로 바뀌어 있으니 그대로 다시 시도한다
  while (!count.compare_exchange_weak(old, old + 1)) {
  }
}

원자 연산도 공짜는 아니다. 해당 캐시 줄을 독점(MESI의 M) 상태로 가져와야 하므로, 많은 코어가 같은 변수를 두드리면 줄이 코어 사이를 오가며 느려진다. 경합이 심하면 CAS 루프가 계속 실패해 헛돌기도 한다. 그래서 카운터는 스레드마다 따로 세고 마지막에 합치는 편이 대개 빠르다. 원자 연산의 메모리 순서(memory_order) 문제는 컴퓨터 구조 노트의 메모리 일관성 모델 절에 있다.

값을 읽고 계산하고 CAS로 바꾸며, 실패하면 다시 읽는 재시도 루프 그림① 읽기old = *p② 계산new = old + 1③ CAS(p, old, new)*p가 아직 old면 new로성공 → 끝실패: 그사이 다른 스레드가 바꿨다 → 다시 읽기하드웨어가 ③을 쪼갤 수 없는 한 명령으로 해 준다 (x86 lock cmpxchg, Arm LDXR/STXR·CAS)
CAS 재시도 루프. ③이 실패했다는 것은 다른 스레드가 먼저 바꿨다는 뜻이니, 새 값으로 다시 계산한다. 아무도 기다리게 하지 않으므로 한 스레드가 멈춰도 다른 스레드는 나아간다(lock-free).

락 없는 스택과 ABA 문제

CAS로 자료 구조 전체를 락 없이 만들 수도 있다. 가장 유명한 예가 Treiber 스택이다. 스택을 연결 리스트로 두고 머리 포인터 하나만 CAS로 바꾼다. push는 새 노드의 next를 지금 머리로 두고 CAS(머리, 옛 머리, 새 노드), pop은 머리 A와 그 다음 B를 읽고 CAS(머리, A, B)다. 락이 없으니 한 스레드가 중간에 멈춰도(선점, 페이지 폴트) 다른 스레드가 막히지 않는다.

함정은 ABA 문제다. CAS는 "값이 그대로인가"만 볼 뿐 "그사이 아무 일도 없었나"는 모른다. 스레드 1이 A와 B를 읽고 멈춘 사이, 스레드 2가 A·B를 꺼내고 메모리를 재사용해 같은 주소의 A를 다시 넣으면 머리는 다시 A다. 스레드 1의 CAS는 성공하고, 이미 해제된 B를 머리로 올려 스택이 망가진다.

락 없는 스택에서 ABA 문제가 생기는 과정과 버전 꼬리표로 막는 과정을 비교하는 단계 그림스레드 1: 머리를 읽는다스레드 1: 머리의 다음을 읽고 — 여기서 밀려난다스레드 2: pop 한 번(꺼낸 노드는 해제)스레드 2: pop 한 번(꺼낸 노드는 해제)스레드 2: 해제된 A를 재사용해 push스레드 1: CAS(머리, 본 머리, 본 다음)스레드 1(pop을 세 걸음으로)과 스레드 2(한 번에)포인터만머리 v0ABC해제됨: 없음머리 v0ABC해제됨: 없음머리 v0ABC해제됨: 없음머리 v1BC해제됨: A머리 v2C해제됨: A, B머리 v3AC해제됨: B머리 v4BC해제됨: A, B꼬리표 함께머리 v0ABC해제됨: 없음머리 v0ABC해제됨: 없음머리 v0ABC해제됨: 없음머리 v1BC해제됨: A머리 v2C해제됨: A, B머리 v3AC해제됨: B머리 v3AC해제됨: B스레드 1이 본 값: 머리 A, 다음 -스레드 1이 본 값: 머리 A, 다음 B스레드 1이 본 값: 머리 A, 다음 B스레드 1이 본 값: 머리 A, 다음 B스레드 1이 본 값: 머리 A, 다음 B스레드 1이 본 값: 머리 A, 다음 B · CAS 성공(포인터만) / 실패(꼬리표)
1 / 7

처음 스택은 A → B → C다. 스레드 1이 pop을 하려 한다.

ABA 문제. CAS는 '값이 그대로인가'만 본다. 그사이 A가 빠졌다가 같은 주소로 다시 들어오면 아무 일 없던 것처럼 보여, 낡은 '다음' 포인터로 스택을 덮어쓴다.

막는 방법은 여럿이다. 포인터에 버전 꼬리표를 붙여 바꿀 때마다 1씩 올리고, 포인터와 꼬리표를 함께 CAS한다(두 배 폭 CAS나 포인터의 안 쓰는 비트를 쓴다). 다른 스레드가 아직 보고 있을 수 있는 노드는 바로 해제하지 않는 해저드 포인터나 에포크 기반 회수도 쓰인다. 쓰레기 수집이 있는 언어(Java, Go)에서는 누가 참조하는 동안 노드가 재사용되지 않으니 이 문제가 대부분 사라진다. 락 없는 자료 구조는 정확하게 짜기가 매우 어려워, 직접 만들기보다 검증된 라이브러리(Java의 java.util.concurrent, C++의 Folly·TBB, Rust의 crossbeam)를 쓰는 편이 낫다.

교착 너머: 라이브락, 기아, 우선순위 역전

서로 상대의 락을 기다리며 멈추는 교착 상태와 그 네 조건은 운영체제 노트의 동기화 절에 있다. 멈추지는 않는데 일이 안 되는 경우도 있다.

  • 라이브락: 스레드들이 계속 움직이지만 아무도 나아가지 못한다. 좁은 복도에서 마주친 두 사람이 동시에 같은 쪽으로 비켜서기를 되풀이하는 꼴이다. 교착을 피하려고 "락을 못 얻으면 쥔 것을 놓고 다시 시도"하게 만든 코드에서 흔히 생긴다. 다시 시도하기 전에 무작위로 기다리면(위 CAS 루프의 경합에도 쓰는 백오프) 대칭이 깨진다. Wikipedia는 라이브락을 기아의 한 특수한 경우로 설명한다(Deadlock 문서).
  • 기아: 전체로는 일이 진행되는데 특정 스레드만 계속 밀린다. 공정하지 않은 락에서 늘 같은 스레드가 다시 락을 낚아채거나, 우선순위가 낮은 스레드가 CPU를 못 받을 때다.
  • 우선순위 역전: 높은 우선순위 스레드가 낮은 스레드가 쥔 락을 기다리는데, 그 사이 중간 우선순위 스레드가 CPU를 차지해 낮은 스레드가 락을 놓지 못한다. 결과적으로 높은 스레드가 중간 스레드에게 밀린다. 1997년 화성에 내린 Mars Pathfinder가 이것으로 시스템 재시작을 되풀이했다. 감시 타이머가 데이터 버스 작업이 오래 돌지 않은 것을 보고 전체를 재시작한 것이다. 고친 방법은 그 뮤텍스에 우선순위 상속(락을 쥔 낮은 스레드가 잠시 기다리는 높은 스레드의 우선순위를 받는다)을 켜는 것이었다(What really happened on Mars?). 락을 쥘 때 미리 정한 높은 우선순위로 올리는 우선순위 상한 프로토콜도 있다(Priority inversion).

이런 문제를 피하는 정도를 진행 보장으로 나눈다(Non-blocking algorithm). 락을 쓰는 코드는 락을 쥔 스레드가 멈추면 모두 멈출 수 있는 블로킹이다. lock-free는 시스템 전체로는 늘 누군가 나아간다는 보장이다. 위 CAS 루프처럼 내 CAS가 실패했다면 남의 CAS가 성공한 것이다. 다만 한 스레드가 계속 지는 기아는 남는다. wait-free는 거기에 더해 모든 스레드가 정해진 단계 안에 끝난다는 가장 강한 보장이다. 가장 약한 obstruction-free는 다른 스레드를 모두 멈췄을 때 혼자서는 정해진 단계 안에 끝난다는 뜻이라, 경합 중의 라이브락은 막지 못한다.

거짓 공유

코어 사이의 데이터 공유는 변수가 아니라 캐시 줄 단위로 일어난다. 스레드 0은 a만, 스레드 1은 b만 고쳐서 코드상으로는 아무것도 공유하지 않아도, a와 b가 같은 줄에 있으면 한쪽이 쓸 때마다 캐시 일관성 프로토콜이 상대 캐시의 사본을 무효화한다. 다음 쓰기 때 줄을 다시 가져와야 하고, 이것이 반복된다. 이를 거짓 공유(false sharing)라 한다. 스레드별 카운터를 배열 하나에 나란히 둔 코드가 대표적인 피해자로, 스레드를 늘렸는데 오히려 느려지는 이상한 현상으로 나타난다.

번갈아 네 번씩 쓴다고 하면, 같은 줄일 때는 처음 한 번을 빼고 쓸 때마다 줄이 넘어가 전송이 7번, 64바이트 떼어 놓으면 처음 가져오기 2번 뒤로 전송이 0번이다. 고치는 법은 자주 쓰는 변수를 줄 크기에 맞춰 떼어 놓는 것이다. C++17의 alignas(std::hardware_destructive_interference_size), Java의 @Contended, Rust crossbeam의 CachePadded가 이 용도다. 더 좋은 방법은 스레드가 자기 지역 변수에 모았다가 끝에 한 번만 공유 변수에 쓰는 것이다. 캐시 줄과 MESI의 원리는 컴퓨터 구조 노트의 캐시·MESI 절에 있다.

두 카운터가 같은 캐시 줄에 있을 때와 다른 줄에 있을 때 줄 전송 횟수를 비교한 그림코어 0은 a만, 코어 1은 b만 4번씩 번갈아 쓴다 (칸 하나 = 8바이트)같은 줄ab캐시 줄 64바이트줄 전송 7번 — 쓸 때마다 상대 캐시의 사본을 무효화하고 가져온다떼어 놓기a줄 0b줄 1줄 전송 0번 — 처음 가져오기 2번뿐, 각자 자기 줄을 쓴다코드에서는 a와 b가 아무것도 공유하지 않는데도 하드웨어는 줄 단위로 주인을 정한다.
거짓 공유. 위는 a와 b가 한 캐시 줄에 있어 두 코어가 번갈아 쓸 때마다 줄이 오간다. 아래처럼 서로 다른 줄에 두면 각자 자기 줄을 계속 쓴다. 전송 횟수는 시뮬레이션 함수로 셌다.

SIMD 벡터화

코어 하나 안에도 병렬이 있다. SIMD(single instruction, multiple data) 명령은 넓은 레지스터에 값 여러 개를 담아 한 번에 같은 연산을 한다. x86의 SSE·AVX·AVX-512, Arm의 NEON·SVE, RISC-V의 V 확장이 그것이다. 128비트 레지스터에는 32비트 float가 4개, 256비트에는 8개 들어가니, 덧셈 반복문이 이론상 그만큼 빨라진다. 이미지 처리·오디오·행렬 연산·문자열 검색·압축이 모두 SIMD로 빨라진다.

대부분은 컴파일러가 반복문을 보고 알아서 자동 벡터화한다. 다만 조건이 있다. 반복끼리 의존이 없어야 하고(a[i] = a[i-1] + 1은 안 된다), 두 포인터가 같은 메모리를 가리키지 않는다는 것을 컴파일러가 알아야 하고, 반복 안에 분기나 함수 호출이 적어야 한다. 데이터를 구조체 배열(AoS)보다 배열 구조체(SoA)로 두면 같은 종류의 값이 연속해 놓여 벡터로 읽기 좋다. 길이가 레인 수로 나누어떨어지지 않으면 남은 꼬리를 따로 처리한다. 컴파일러가 실제로 벡터화했는지는 최적화 보고서(-fopt-info-vec, -Rpass=loop-vectorize)로 확인한다. 컴파일러 쪽 이야기는 컴파일러 노트에 있다.

배열 10칸을 4칸짜리 벡터 명령 두 번과 스칼라 두 번으로 더하는 그림abc012345678901234567890123456789벡터 명령 1 (4칸 한꺼번에)벡터 명령 2 (4칸 한꺼번에)꼬리c[i] = a[i] + b[i] — 명령 수: 스칼라 10번 → 벡터 2번 + 꼬리 2번 = 4번128비트 레지스터에 32비트 float 4개(SSE·NEON). AVX2는 256비트라 8개, AVX-512는 16개다.컴파일러 자동 벡터화는 반복끼리 의존이 없고 메모리가 겹치지 않음을 알 때만 일어난다.
c[i] = a[i] + b[i]를 10칸에 대해 4칸 벡터로 계산한다. 4칸씩 두 번(점선 묶음)과 남은 꼬리 두 칸(빨간 테두리)으로 명령 수가 10번에서 4번으로 준다. 수는 벡터화 함수로 계산했다.

GPU 프로그래밍 모델

GPU는 같은 일을 수천 갈래로 동시에 하도록 만든 프로세서다(왜 그렇게 생겼는지는 컴퓨터 구조 노트의 가속기 절). NVIDIA의 CUDA는 이를 이렇게 보여 준다. 프로그래머는 스레드 하나가 할 일을 커널 함수로 쓰고, 그 커널을 스레드 수천~수백만 개로 띄운다. 스레드는 블록으로 묶이고(현재 GPU에서 블록당 최대 1024스레드), 블록들이 모여 그리드가 된다. 스레드는 blockIdx·blockDim·threadIdx로 자기 번호를 계산해 맡을 데이터를 고른다. 블록은 서로 독립이라 어떤 순서로든 돌 수 있어야 하고, 그 덕에 같은 프로그램이 SM(스트리밍 멀티프로세서)이 많은 GPU에서는 저절로 더 빨리 돈다. 같은 블록 안 스레드는 빠른 공유 메모리와 장벽(__syncthreads())으로 협력한다.

__global__ void add(const float* a, const float* b, float* c, int n) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) {
    c[i] = a[i] + b[i];
  }
}

// 1000개를 256스레드 블록으로: 블록 4개(1024스레드), 24개는 범위 밖이라 쉰다
add<<<(n + 255) / 256, 256>>>(a, b, c, n);

예를 들어 블록 크기 256에서 블록 2의 스레드 5는 2 × 256 + 5 = 517번 칸을 맡는다. 원소 1000개를 덮으려면 블록이 4개 필요하고, 남는 24스레드는 if (i < n)에 걸려 아무것도 안 한다.

블록 네 개와 스레드 여덟 개로 이루어진 CUDA 그리드에서 한 스레드가 배열 칸 하나를 맡는 그림그리드 = 블록 4개 (blockDim.x = 8)blockIdx.x = 001234567blockIdx.x = 101234567blockIdx.x = 201234567blockIdx.x = 301234567i = blockIdx.x × blockDim.x + threadIdx.x = 2 × 8 + 5 = 21배열 a[0…31]a[21]블록끼리는 순서 없이 아무 SM에서나 돈다. 블록 안 스레드는 공유 메모리로 협력한다.하드웨어는 블록을 32스레드 워프로 잘라 한 명령씩 함께 실행한다.
CUDA 그리드. 그림에서는 블록 크기를 8로 줄였다. 각 스레드는 자기 번호로 배열 칸 하나를 맡는다. 실제로는 블록 크기를 32의 배수(128·256 등)로 잡는다.

하드웨어는 블록을 32스레드씩 워프로 잘라, 워프의 32스레드가 한 명령을 함께 실행한다(NVIDIA는 이를 SIMT라 부른다). 그래서 두 가지가 성능을 가른다. 첫째, 워프 안에서 if가 갈리면 두 갈래를 차례로 실행하고 그동안 다른 쪽 스레드는 쉰다(분기 발산). 둘째, 워프의 이웃 스레드가 이웃 주소를 읽으면 하드웨어가 요청을 몇 개의 큰 거래로 합쳐 준다(메모리 합치기, coalescing). 스레드 i가 a[i]를 읽으면 합쳐지고, a[32 * i]처럼 띄엄띄엄 읽으면 거래가 흩어져 같은 양의 데이터에 메모리 대역폭을 몇 배 쓴다. 2차원 배열을 열 방향으로 읽는 커널이 느린 흔한 이유다. 공유 메모리에 타일로 옮겨 놓고 다시 읽는 것이 행렬 곱 최적화의 기본기다.

이웃 스레드가 연속 주소를 읽을 때와 멀리 떨어진 주소를 읽을 때 메모리 구간 수를 비교한 그림연속: a[i]t0t1t2t3t4t5t6t7워프 32스레드 → 구간 1개간격 32: a[32 × i]t0t1t2t3t4t5t6t7워프 32스레드 → 구간 32개구간은 그림을 위해 128바이트로 단순화했다. 실제 거래 단위는 세대마다 다르다.오른쪽은 같은 양의 데이터를 위해 메모리를 32배 더 읽는다(나머지 바이트는 버려진다).
메모리 합치기. 왼쪽은 이웃 스레드가 이웃 칸을 읽어 한 덩어리로 합쳐지고, 오른쪽은 128바이트씩 떨어진 칸을 읽어 스레드마다 다른 구간을 건드린다. 구간 수는 32스레드 워프 기준으로 계산했다.

CUDA 말고도 AMD의 HIP(CUDA와 거의 같은 모양), 여러 회사가 함께 쓰는 OpenCL·SYCL, 그래픽 API의 컴퓨트 셰이더(Vulkan, Metal, WebGPU)가 같은 모델이다. 그래픽카드로서의 GPU와 고르는 법은 GPU 노트, 딥러닝 학습에서 GPU 여러 장·여러 노드를 묶는 이야기는 AI 인프라 노트에 있다.

병렬 패턴: 맵, 리듀스, 스캔

병렬 프로그램 대부분은 몇 가지 패턴의 조합이다. 패턴으로 생각하면 스레드·락을 직접 다루지 않고도 병렬 코드를 쓸 수 있고, 라이브러리가 하드웨어에 맞게 구현해 준다.

  • 맵: 원소마다 독립적으로 같은 함수를 적용한다. 위 CUDA 덧셈이 맵이다. 서로 기댈 것이 없어 가장 쉽고 가장 잘 늘어난다.
  • 리듀스: 모든 원소를 결합 법칙이 성립하는 연산(합, 최댓값)으로 하나로 줄인다. 짝끼리 더하는 나무 모양으로 하면 n개를 log₂n단계에 줄인다. 부동소수점 덧셈은 엄밀히는 결합 법칙이 성립하지 않아, 병렬 합은 순차 합과 마지막 자리가 다를 수 있다.
  • 스캔(누적 합, prefix sum): 각 칸까지의(또는 그 앞까지의) 합을 모두 구한다. 순차로는 앞에서부터 더하면 되지만 병렬로는 머리를 써야 한다. 스캔은 쓸모가 넓다. 조건에 맞는 원소만 골라 빈칸 없이 모으기(stream compaction), 기수 정렬, 히스토그램, 희소 행렬 계산의 기본 부품이다.

가장 단순한 병렬 스캔(Hillis–Steele)은 거리 1, 2, 4…마다 모든 칸이 앞 칸을 더해 log₂n 단계면 끝나지만, 덧셈을 n log n 가까이 해서 순차보다 일을 많이 한다. Blelloch 스캔은 리듀스 나무를 올라갔다(up-sweep) 내려오는(down-sweep) 두 단계로, 순차와 같은 차수인 O(n)번의 덧셈으로 끝낸다. GPU 스캔 구현의 표준이다.

Blelloch 병렬 스캔의 올라가기와 내려가기 단계를 한 줄씩 쌓은 단계 그림[0][1][2][3][4][5][6][7]입력31704163올라가기 d=134774569올라가기 d=23471145614올라가기 d=43471145625뿌리 지우기347114560내려가기 d=4347045611내려가기 d=23074411616내려가기 d=10341111151622
1 / 8

입력 3, 1, 7, 0, 4, 1, 6, 3. 각 칸 앞쪽 값들의 합(배타 스캔)을 병렬로 구한다.

병렬 누적 합(스캔). 올라가기는 짝끼리 더해 합을 나무 위로 모으는 리듀스와 같고, 내려가기는 그 부분합을 거꾸로 나눠 각 칸 앞쪽의 합을 만든다. 단계 수는 log₂n의 두 배다.

이 밖에 큰 데이터를 키로 묶어 처리하는 맵리듀스(빅데이터 노트), 단계마다 다른 일을 하는 일꾼을 줄 세우는 파이프라인, 격자의 각 칸을 이웃 값으로 갱신하는 스텐실(아래 MPI 절)이 자주 쓰는 패턴이다.

작업 분배와 작업 훔치기

일을 나눌 때 가장 쉬운 방법은 반복 범위를 스레드 수로 똑같이 자르는 정적 분배다. 반복마다 걸리는 시간이 비슷하면 이게 가장 싸다. 그러나 일의 크기가 고르지 않으면(어떤 픽셀은 계산이 오래 걸리는 렌더링, 재귀가 한쪽으로 깊은 트리 탐색) 몇 스레드는 일찍 끝나 놀고 전체는 가장 늦은 스레드를 기다린다. 동적 분배는 공용 큐에서 일을 조금씩 꺼내 가게 하는데, 큐 하나에 모두가 몰리면 그 큐가 병목이 된다.

작업 훔치기(work stealing)는 둘의 장점을 합친다. 일꾼마다 자기 덱(양쪽 끝이 열린 큐)을 두고, 새로 생긴 일은 자기 덱 한쪽 끝에 넣고 같은 끝에서 꺼낸다(최근 일이라 캐시에 남아 있다). 자기 덱이 비면 다른 일꾼의 덱 반대쪽 끝에서 하나를 훔친다. 반대쪽 끝에는 오래된, 대개 큰 일이 있어 한 번 훔치면 한동안 바쁘고, 주인과 도둑이 서로 다른 끝을 써서 잘 부딪치지 않는다. Cilk에서 시작해 Intel TBB, Java의 ForkJoinPool, Go 런타임, Rust의 Rayon·Tokio가 모두 이 방식을 쓴다. 아래 예에서는 일이 한 일꾼에게 몰렸을 때 훔치지 않으면 8박자, 훔치면 3박자에 끝난다.

일꾼 네 명이 작업을 처리하는 박자 그림으로 작업 훔치기가 있을 때와 없을 때를 비교처음 덱: 일꾼 0 8개 · 일꾼 1 2개 · 일꾼 2 1개 · 일꾼 3 1개 (모두 12개, 하나에 1박자)훔치기 없음 — 8박자일꾼 0자기자기자기자기자기자기자기자기일꾼 1자기자기일꾼 2자기일꾼 3자기작업 훔치기 — 3박자일꾼 0자기자기자기일꾼 1자기자기훔침일꾼 2자기훔침훔침일꾼 3자기훔침훔침점선 = 놂. 주인은 덱 한쪽 끝, 도둑은 반대쪽 끝을 써서 잘 부딪치지 않는다.
일꾼 네 명의 박자별 기록. 위는 처음 받은 일만 하고, 아래는 빈 일꾼이 가장 긴 덱에서 훔친다. 모든 일이 1박자짜리일 때 시뮬레이션 함수로 계산했다.

분산 메모리와 MPI

컴퓨터 한 대의 메모리와 코어로 모자라면 여러 대를 쓴다. 이때 각 노드는 자기 메모리만 직접 볼 수 있고, 다른 노드의 데이터가 필요하면 네트워크로 메시지를 주고받아야 한다. 과학 계산에서 이를 위한 표준이 MPI(Message Passing Interface)다. 같은 프로그램을 프로세스 수백~수만 개(랭크)로 띄우고, 각 랭크가 자기 번호를 보고 맡을 데이터를 고른다(SPMD). 2025년 6월 MPI 5.0이 승인됐다.

  • 점대점 통신: MPI_Send·MPI_Recv로 두 랭크가 주고받는다. 기다리지 않는 MPI_Isend·MPI_Irecv로 통신과 계산을 겹친다.
  • 집합 통신: 한 랭크의 값을 모두에게(MPI_Bcast), 모두의 값을 하나로 줄여 모두에게(MPI_Allreduce). 딥러닝 분산 학습에서 기울기를 합치는 것도 올리듀스다.
double local = partial_sum(my_slice);
double total = 0.0;
// 모든 랭크의 local을 더해 모두에게 돌려준다
MPI_Allreduce(&local, &total, 1, MPI_DOUBLE, MPI_SUM, MPI_COMM_WORLD);

물리 시뮬레이션은 보통 공간 격자를 조각으로 잘라 랭크마다 하나씩 준다(영역 분할). 각 칸은 이웃 칸의 값으로 갱신되므로, 조각 가장자리를 계산하려면 이웃 랭크의 경계 한 줄이 필요하다. 그 줄을 미리 받아 두는 칸이 헤일로(ghost cell)이고, 매 단계 경계를 주고받는 것이 헤일로 교환이다. 계산은 조각의 넓이에, 통신은 둘레에 비례하므로 조각이 작아질수록 통신 비율이 커진다. 노드 한 대 안에서는 OpenMP나 GPU로, 노드 사이는 MPI로 나누는 MPI + X 혼합이 요즘 HPC 코드의 흔한 모양이다.

격자를 네 랭크로 나누고 경계를 주고받는 헤일로 교환과, 랭크 수에 따른 통신 비율 그림랭크 0랭크 1랭크 2랭크 3점선 = 랭크 0이 갖는 범위(자기 칸 + 헤일로)색칠 = 이웃에게 받아 둔 경계 한 줄1024 × 1024 격자, 한 걸음마다 경계 교환랭크 4개 → 한 변 512칸경계 2,048 / 계산 262,144 = 0.78%랭크 1,024개 → 한 변 32칸경계 128 / 계산 1,024 = 12.5%조각이 작아질수록 계산에 비해 통신이 커진다 —랭크를 늘려도 빨라지지 않는 지점이 온다.
2차원 격자를 랭크 넷에 나눈 영역 분할. 랭크 0은 자기 칸에 더해 이웃 경계 한 줄(색칠)을 받아 둔다. 오른쪽은 1024 × 1024 격자를 랭크 4개와 1024개로 나눴을 때 계산 칸 대비 경계 칸 비율이다.

HPC와 슈퍼컴퓨터

HPC(고성능 컴퓨팅)는 날씨·기후, 유체, 핵융합, 신약 분자 동역학, 우주론처럼 컴퓨터 한 대로는 몇 년이 걸릴 계산을 수천 노드로 나눠 푸는 분야다. 슈퍼컴퓨터는 특별한 한 대가 아니라 CPU·GPU 노드 수천 개를 아주 빠르고 지연이 짧은 인터커넥트로 묶고, 모든 노드가 함께 읽고 쓰는 병렬 파일 시스템을 붙인 것이다. 일반 데이터 센터와 가장 다른 점이 인터커넥트다. MPI 집합 통신이 자주 일어나므로 노드 사이 지연과 대역폭이 성능을 가른다. 작업은 배치 스케줄러(Slurm 등)에 "노드 몇 개를 몇 시간" 하고 신청해 돌린다.

슈퍼컴퓨터 순위는 TOP500이 해마다 6월과 11월에 발표한다. 밀집 선형 연립방정식을 푸는 LINPACK(HPL) 벤치마크의 실측 성능(Rmax)으로 줄을 세운다. 2026년 6월에 나온 제67차 목록의 1위는 중국 선전 국가슈퍼컴퓨팅센터(NSCS)의 LineShine으로, Rmax 2,198.40 PFlop/s(약 2.2 EFlop/s)다. 2위는 미국 로런스 리버모어 연구소의 El Capitan, 3위는 오크리지 연구소의 Frontier다. LINPACK은 계산 밀도가 높아 실제 응용보다 좋은 숫자가 나오므로, 메모리·통신 쪽을 더 보는 HPCG 같은 다른 벤치마크도 함께 본다. 다음 목록이 나오면 순위가 바뀔 수 있다.

노드 여러 개가 인터커넥트로 이어지고 병렬 파일 시스템을 함께 쓰는 슈퍼컴퓨터 구조 그림노드 0CPUGPU자기 메모리노드 1CPUGPU자기 메모리노드 2CPUGPU자기 메모리… 수천 개고속 인터커넥트 — MPI 메시지가 다니는 길병렬 파일 시스템(공유 저장소)
슈퍼컴퓨터의 층. 노드 안은 공유 메모리(OpenMP·GPU), 노드 사이는 인터커넥트 위의 메시지(MPI)다. 모든 노드가 병렬 파일 시스템을 함께 쓴다.

동시성과 병렬은 다르다

두 말은 자주 섞이지만 뜻이 다르다. Go 블로그가 정리한 롭 파이크의 말로는 동시성(concurrency)은 여러 일을 한꺼번에 다루는 구조이고, 병렬(parallelism)은 여러 일을 한꺼번에 하는 실행이다. 코어 하나에서도 동시성은 가능하다. 웹 서버가 요청 천 개를 붙들고 있으면서 각각이 데이터베이스 응답을 기다리는 동안 다른 요청을 처리하는 것이 동시성이다. 병렬은 계산 자체를 여러 코어에서 동시에 하는 것이고 코어가 여러 개여야 한다.

I/O를 기다리는 시간이 대부분인 일(네트워크 서버, 크롤러)에는 동시성이 답이다. 요청마다 스레드를 하나씩 두면 스레드 수천 개의 스택 메모리와 문맥 교환이 부담이 되므로, 스레드 하나의 이벤트 루프가 OS에 "이 소켓들 중 준비된 것을 알려 달라"(epoll·kqueue·io_uring) 하고 준비된 것만 처리하는 비동기 I/O가 널리 쓰인다. Node.js, Python의 asyncio, Rust의 Tokio가 이 방식이고, async·await는 이를 순차 코드처럼 쓰게 해 주는 문법이다. Go의 고루틴은 가벼운 스레드를 런타임이 몇 개의 OS 스레드에 얹어(작업 훔치기로) 동시성과 병렬을 함께 준다. 반대로 CPU 계산이 대부분인 일(이미지 처리, 시뮬레이션)은 비동기로 바꿔도 빨라지지 않고, 코어를 늘리는 병렬이 필요하다. 아래 예에서 요청 셋을 블로킹으로 차례대로 처리하면 36단위, 이벤트 루프로 기다림을 겹치면 코어 하나로도 16단위다.

요청 세 개를 블로킹, 이벤트 루프, 스레드 세 개로 처리할 때의 타임라인 비교CPU 일I/O 기다림블로킹코어 1 · 차례대로36이벤트 루프코어 1 · 동시성16스레드 3개코어 3 · 병렬12시간(단위) →
같은 요청 셋(CPU 2 + 기다림 10)을 처리하는 세 방식. 이벤트 루프는 코어 하나로 기다림을 겹치고(동시성), 스레드 셋은 코어 셋에서 CPU 일까지 겹친다(병렬). 시간은 타임라인 함수로 계산했다.

흔한 오해

  • "volatile을 붙이면 스레드 사이에 안전하다": C·C++의 volatile은 컴파일러가 그 접근을 지우거나 재배치하지 못하게 할 뿐, 원자성도 스레드 사이 순서도 보장하지 않는다. cppreference는 volatile이 시그널 핸들러와의 통신에는 맞지만 다른 스레드와의 통신에는 맞지 않다고 적고 std::atomic과 메모리 순서를 가리킨다(cppreference). Java의 volatile은 뜻이 달라 한 스레드의 쓰기가 그 뒤 다른 스레드의 읽기와 동기화되지만(JLS 17장), count++ 같은 읽기·고치기·쓰기는 여전히 세 걸음이라 원자적이지 않다.
  • "스레드를 늘리면 늘 빨라진다": 암달의 법칙이 상한을 정하고, 그 아래에서도 락 경합, 거짓 공유, 메모리 대역폭이 먼저 막힌다. CPU 계산만 하는 스레드를 코어 수보다 많이 두면 대개 문맥 교환만 는다. 스레드 수를 바꿔 가며 실제로 재는 수밖에 없다.
  • "async로 바꾸면 계산이 빨라진다": 위 동시성과 병렬 절처럼 async는 기다림을 겹칠 뿐이다. CPU 계산은 이벤트 루프 스레드를 붙잡아 다른 요청까지 멈추게 하므로, 별도 스레드나 프로세스로 넘긴다. 브라우저 쪽 이벤트 루프는 웹 개발 노트의 이벤트 루프 절에 있다.
  • "락 없는 자료 구조는 늘 더 빠르다": lock-free는 진행 보장이지 속도 보장이 아니다. 경합이 심하면 CAS 재시도와 캐시 줄 이동이 늘고, 경합이 적으면 잘 만든 락도 대개 충분히 빠르다. 위 원자 연산 절의 비용 이야기와 같다.

핵심 자료

캐시 일관성·메모리 순서 같은 하드웨어 원리는 컴퓨터 구조 노트, 기본 락과 스케줄링은 운영체제 노트, 측정과 병목 찾기는 성능 노트에 있다.

자료종류요지링크
CUDA C++ Programming Guide공식 문서그리드·블록·스레드 계층, 공유 메모리, 동기화, 메모리 접근 규칙을 정의한 원전. 블록당 스레드 한도 같은 수치도 여기서 확인한다.NVIDIA
GPU Gems 3, 39장 Parallel Prefix Sum (Scan) with CUDA책 한 장Hillis–Steele과 작업 효율적인 Blelloch 스캔을 GPU에서 구현하며 뱅크 충돌까지 다룬다. 이 노트의 스캔 예와 같은 입력을 쓴다.NVIDIA
Rust Atomics and Locks무료 책원자 연산, 메모리 순서, 스핀락·채널·뮤텍스를 직접 만들며 x86과 Arm에서 무엇이 달라지는지 보여 준다. 언어가 달라도 개념은 같다.Mara Bos
MPI Documents표준분산 메모리 메시지 전달 표준 MPI의 공식 문서 모음. 2025년 6월 MPI 5.0이 승인됐다.MPI Forum
OpenMP Specifications표준공유 메모리 병렬화 지시문(#pragma omp)의 명세. 현재 정식판은 2024년 11월의 6.0이다.OpenMP ARB
TOP500 — June 2026순위LINPACK 성능으로 매긴 세계 슈퍼컴퓨터 순위. 해마다 6월과 11월에 새 목록이 나온다.TOP500