병렬 프로그래밍과 HPC
코어 하나가 더 빨라지지 않게 된 뒤로 성능은 일을 나누는 데서 나온다. 암달의 법칙, 원자 연산과 락 없는 자료 구조, 거짓 공유, SIMD와 GPU, 병렬 스캔, 작업 훔치기, MPI와 슈퍼컴퓨터, 그리고 동시성과 병렬의 차이를 직접 계산한 그림으로 따라간다.
목차 (16)
왜 병렬인가: 클럭이 멈춘 뒤
2000년대 초까지 프로그램은 가만히 있어도 빨라졌다. 새 CPU가 나올 때마다 클럭이 올랐기 때문이다. 트랜지스터를 줄이면 전압도 함께 내려가 전력이 늘지 않는다는 데나드 스케일링이 2005~2007년 무렵 깨지면서 이 공짜 점심이 끝났다. 클럭을 더 올리면 칩이 감당할 수 없을 만큼 뜨거워져, 클럭은 그 무렵부터 몇 GHz 언저리에 머물렀다. 트랜지스터 수는 계속 늘었으니 칩 회사들은 그 트랜지스터로 코어를 여러 개 만들었다. 전력 공식과 다크 실리콘 이야기는 컴퓨터 구조 노트의 전력 벽 절에 있다.
그 결과 한 갈래(스레드 하나)로 짠 프로그램은 코어가 16개든 64개든 하나만 쓴다. 더 빨라지려면 프로그래머가 일을 나눠야 한다. 나누는 단위는 여러 층에 있다. 명령 하나가 여러 데이터를 처리하는 SIMD, 코어 여러 개가 메모리를 함께 쓰는 스레드, 수천 갈래를 한꺼번에 돌리는 GPU, 메모리를 따로 가진 컴퓨터 수천 대가 메시지를 주고받는 분산 메모리(MPI)다. 이 노트는 아래에서 위로 이 층들을 훑는다. 컴퓨터 여러 대가 장애를 견디며 상태를 맞추는 문제는 분산 시스템 노트의 몫이다.
암달의 법칙과 구스타프슨의 법칙
프로그램의 비율 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배다. 두 법칙은 모순이 아니라 질문이 다르다. 같은 일을 빨리 끝내려면(강한 확장) 암달을, 더 큰 일을 같은 시간에 하려면(약한 확장) 구스타프슨을 본다.
스레드와 공유 메모리
한 프로세스 안의 스레드들은 주소 공간을 함께 쓴다. 코어 여러 개에 스레드를 하나씩 올리면, 포인터 하나로 같은 배열을 나눠 처리할 수 있어 데이터를 복사할 필요가 없다. 이것이 공유 메모리 병렬이다. 스레드가 무엇이고 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도 신경 써야 한다.
원자 연산과 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) 문제는 컴퓨터 구조 노트의 메모리 일관성 모델 절에 있다.
락 없는 스택과 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를 머리로 올려 스택이 망가진다.
처음 스택은 A → B → C다. 스레드 1이 pop을 하려 한다.
막는 방법은 여럿이다. 포인터에 버전 꼬리표를 붙여 바꿀 때마다 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_, Java의 @Contended, Rust crossbeam의 CachePadded가 이 용도다. 더 좋은 방법은 스레드가 자기 지역 변수에 모았다가 끝에 한 번만 공유 변수에 쓰는 것이다. 캐시 줄과 MESI의 원리는 컴퓨터 구조 노트의 캐시·MESI 절에 있다.
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)로 확인한다. 컴파일러 쪽 이야기는 컴파일러 노트에 있다.
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)에 걸려 아무것도 안 한다.
하드웨어는 블록을 32스레드씩 워프로 잘라, 워프의 32스레드가 한 명령을 함께 실행한다(NVIDIA는 이를 SIMT라 부른다). 그래서 두 가지가 성능을 가른다. 첫째, 워프 안에서 if가 갈리면 두 갈래를 차례로 실행하고 그동안 다른 쪽 스레드는 쉰다(분기 발산). 둘째, 워프의 이웃 스레드가 이웃 주소를 읽으면 하드웨어가 요청을 몇 개의 큰 거래로 합쳐 준다(메모리 합치기, coalescing). 스레드 i가 a[i]를 읽으면 합쳐지고, a[32 * i]처럼 띄엄띄엄 읽으면 거래가 흩어져 같은 양의 데이터에 메모리 대역폭을 몇 배 쓴다. 2차원 배열을 열 방향으로 읽는 커널이 느린 흔한 이유다. 공유 메모리에 타일로 옮겨 놓고 다시 읽는 것이 행렬 곱 최적화의 기본기다.
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 스캔 구현의 표준이다.
입력 3, 1, 7, 0, 4, 1, 6, 3. 각 칸 앞쪽 값들의 합(배타 스캔)을 병렬로 구한다.
이 밖에 큰 데이터를 키로 묶어 처리하는 맵리듀스(빅데이터 노트), 단계마다 다른 일을 하는 일꾼을 줄 세우는 파이프라인, 격자의 각 칸을 이웃 값으로 갱신하는 스텐실(아래 MPI 절)이 자주 쓰는 패턴이다.
작업 분배와 작업 훔치기
일을 나눌 때 가장 쉬운 방법은 반복 범위를 스레드 수로 똑같이 자르는 정적 분배다. 반복마다 걸리는 시간이 비슷하면 이게 가장 싸다. 그러나 일의 크기가 고르지 않으면(어떤 픽셀은 계산이 오래 걸리는 렌더링, 재귀가 한쪽으로 깊은 트리 탐색) 몇 스레드는 일찍 끝나 놀고 전체는 가장 늦은 스레드를 기다린다. 동적 분배는 공용 큐에서 일을 조금씩 꺼내 가게 하는데, 큐 하나에 모두가 몰리면 그 큐가 병목이 된다.
작업 훔치기(work stealing)는 둘의 장점을 합친다. 일꾼마다 자기 덱(양쪽 끝이 열린 큐)을 두고, 새로 생긴 일은 자기 덱 한쪽 끝에 넣고 같은 끝에서 꺼낸다(최근 일이라 캐시에 남아 있다). 자기 덱이 비면 다른 일꾼의 덱 반대쪽 끝에서 하나를 훔친다. 반대쪽 끝에는 오래된, 대개 큰 일이 있어 한 번 훔치면 한동안 바쁘고, 주인과 도둑이 서로 다른 끝을 써서 잘 부딪치지 않는다. Cilk에서 시작해 Intel TBB, Java의 ForkJoinPool, Go 런타임, Rust의 Rayon·Tokio가 모두 이 방식을 쓴다. 아래 예에서는 일이 한 일꾼에게 몰렸을 때 훔치지 않으면 8박자, 훔치면 3박자에 끝난다.
분산 메모리와 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 코드의 흔한 모양이다.
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 같은 다른 벤치마크도 함께 본다. 다음 목록이 나오면 순위가 바뀔 수 있다.
동시성과 병렬은 다르다
두 말은 자주 섞이지만 뜻이 다르다. 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단위다.
흔한 오해
- "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 |
- CUDA C++ Programming Guide — NVIDIA
- GPU Gems 3, 39장 Parallel Prefix Sum (Scan) with CUDA — NVIDIA
- Rust Atomics and Locks — Mara Bos
- MPI Documents — MPI Forum
- OpenMP Specifications — OpenMP ARB
- TOP500 — June 2026 — TOP500
- How to Access Global Memory Efficiently in CUDA C/C++ Kernels — NVIDIA Technical Blog
- std::atomic compare_exchange — cppreference
- Concurrency is not parallelism — The Go Blog
- Gustafson's law — Wikipedia
- Dennard scaling — Wikipedia
- TOP500 Lists
- What really happened on Mars? — Glenn Reeves의 설명을 옮긴 글 (Cornell CS614)
- Priority inversion — Wikipedia
- Deadlock (computer science) — Livelock — Wikipedia
- Non-blocking algorithm — Wikipedia
- cv (const and volatile) type qualifiers — cppreference
- Java Language Specification 17장 Threads and Locks — Oracle