Chapter 15

그래프 탐색

10억 노드 위에서 BFS를 한 번 돌리려는 외로운 분투

15.1 배경: 그래프, 그리고 BFS의 위상

그래프(graph)는 정점(vertex)과 간선(edge)으로 이루어진 자료 구조다. 한 줄로 정의가 끝나지만, 그 위에서 다룰 수 있는 문제의 폭은 어마어마하다. 소셜 네트워크의 친구 추천, 길찾기 내비게이션, 검색 엔진의 PageRank, 코드 의존성 분석, 회로 시뮬레이션, 그리고 최근의 그래프 신경망(GNN)까지. 그래프 알고리즘은 컴퓨팅의 한 축이다.

가장 기초적이면서 가장 많이 쓰이는 연산이 그래프 탐색(graph traversal)이다. 시작 정점에서 출발해 도달 가능한 정점들을 체계적으로 방문하는 절차다. 두 가지 대표 방식이 있다. 너비 우선 탐색(BFS)은 거리(레벨)별로 동심원을 그리며 퍼져 나가고, 깊이 우선 탐색(DFS)은 한 갈래를 끝까지 따라 들어갔다 되돌아온다. GPU에서는 BFS가 압도적으로 자주 다뤄진다. DFS는 본질적으로 직렬에 가까워 병렬화가 어렵기 때문이다.

그래프를 메모리에 어떻게 담을지부터 정해야 한다. 인접 행렬(adjacency matrix)은 N × N 비트맵이라 N이 100만만 되어도 1 TB라 비현실적이다. 다행히 14장에서 봤던 CSR 형식이 그래프에도 그대로 들어맞는다. 사실 그래프의 인접 리스트(adjacency list)는 본질적으로 0/1 값을 갖는 희소 행렬의 CSR과 같다.

// 4-노드 그래프: 0-1, 0-2, 1-2, 2-3
int row_ptr[5] = {0, 2, 4, 7, 8};
int col[8]     = {1,2,  0,2,  0,1,3,  2};
// 정점 2의 이웃: col[4..7) = {0, 1, 3}

BFS 자체는 친숙하다. 시작 정점을 큐에 넣고, 큐에서 하나 꺼내 미방문 이웃을 모두 큐에 넣는다. 큐가 빌 때까지 반복. 하지만 GPU에서 이걸 돌리려면 직렬 큐가 아닌 레벨 단위(level-synchronous) 방식으로 재구성해야 한다. 같은 거리(level)에 있는 정점들은 서로 독립적이므로 한꺼번에 병렬로 펼칠 수 있다는 관찰을 이용한다.

15.2 BFS의 레벨 단위 병렬화

레벨 단위 BFS는 다음과 같이 돈다. 거리 배열 dist[]를 모두 무한대로 초기화하고 시작점만 0으로 둔다. 매 반복(레벨) k마다, 거리가 k인 정점을 모두 찾아 그 이웃들의 거리를 검사한다. 미방문(거리 = 무한대)인 이웃에 거리 k+1을 적는다. 새로 거리가 갱신된 정점이 한 개라도 있으면 다음 레벨로 넘어간다. 새 갱신이 없으면 종료.

Level 0: [0] ↓ 이웃 펼치기 Level 1: [1, 2] ↓ Level 2: [3] ↓ 갱신 없음 → 종료

그림 15.1 — 레벨 단위 BFS. 각 레벨이 한 번의 커널 호출에 대응한다.

이제 한 레벨을 어떻게 병렬화할지가 핵심 질문이다. 크게 세 가지 전략이 있다. vertex-centric, edge-centric, 그리고 frontier 기반이다. 셋 다 흥미로운 trade-off를 가지고 있어 그래프 분포에 따라 우열이 갈린다.

15.3 vertex-centric 병렬화: push와 pull

가장 자연스러운 방식은 한 정점 = 한 스레드다. 매 레벨마다 N개의 스레드를 띄워 각자 자기 정점을 본다. 두 가지 방향이 가능하다.

Push 모드: 거리가 현재 레벨 k인 정점이 자기 이웃을 보면서, 미방문 이웃에 k+1을 써 준다. "내가 활성이니 너희도 깨워 줄게" 식이다.

__global__ void bfsPush(int N, int level,
                         const int* row_ptr,
                         const int* col,
                         int* dist,
                         int* changed) {
  int u = blockIdx.x * blockDim.x + threadIdx.x;
  if (u >= N) return;
  if (dist[u] != level) return;     // 활성 정점만 일한다
  int s = row_ptr[u], e = row_ptr[u + 1];
  for (int j = s; j < e; ++j) {
    int v = col[j];
    if (dist[v] == INT_MAX) {
      dist[v] = level + 1;          // race 가능, 같은 값이라 OK
      *changed = 1;
    }
  }
}

Pull 모드: 모든 정점이 자기 이웃을 보면서, 활성(거리 = k)인 이웃이 있으면 자기 거리를 k+1로 갱신한다. "내가 미방문이고 활성 친구가 있으면 깨어날게" 식이다.

두 방식의 차이를 본다. Push는 활성 정점이 적을 때 효율적이다. 일을 시작하는 스레드가 적기 때문. 반대로 모든 정점이 활성에 가까운 후반 레벨에서는 push가 노느니만 못하다 — 이미 방문된 이웃을 다시 검사한다. Pull은 활성 정점이 많을 때, 즉 BFS 중반에 더 효율적이다. 미방문 정점이 줄어드니 검사할 정점도 줄어든다.

감 잡기

이 비대칭 때문에 등장한 게 direction-optimized BFS(Beamer-Asanovic-Patterson, 2012)다. 매 레벨 시작 시 활성 비율을 보고 push/pull을 자동 전환한다. 소셜 네트워크 같은 작은 지름의 그래프에서 5~10배까지 빨라진다.

15.4 edge-centric 병렬화: 간선 단위로 일하기

vertex-centric의 한 약점은 부하 불균형이다. 정점마다 차수(degree)가 다르니 한 워프 내에서도 어떤 스레드는 1000개의 이웃을, 어떤 스레드는 0개의 이웃을 다룬다. 차수 분포가 멱법칙(power-law)을 따르는 그래프(소셜, 웹)에서는 이게 치명적이다.

대안은 한 간선 = 한 스레드다. 모든 간선을 한 줄로 펼쳐 놓고 스레드를 균일하게 배정한다. 한 간선을 처리하는 스레드는 양 끝 정점의 거리를 본 뒤 갱신 조건이 맞으면 도착 정점의 거리를 갱신한다.

__global__ void bfsEdge(int nnz, int level,
                         const int* edge_src,    // 길이 nnz
                         const int* edge_dst,
                         int* dist,
                         int* changed) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i >= nnz) return;
  int u = edge_src[i], v = edge_dst[i];
  if (dist[u] == level && dist[v] == INT_MAX) {
    dist[v] = level + 1;
    *changed = 1;
  }
}

장점은 부하 균형이 거의 완벽하다는 점이다. 모든 스레드가 정확히 같은 일을 한다. 그러나 단점도 분명하다. 매 레벨마다 모든 간선을 검사한다. 활성 정점이 한 개여도 m개의 간선을 모두 훑는다. 멱법칙 그래프에서는 이게 누적되어 vertex-centric보다 느려질 수 있다. 또한 edge_src가 활성이 아닐 가능성이 높아 대부분의 검사가 헛수고가 된다(redundant work).

15.5 frontier로 work-efficient하게

두 방식의 약점을 한 방에 잡는 아이디어가 frontier 기반이다. 매 레벨에서 활성 정점만 작은 큐(frontier)에 모아 두고, 다음 레벨에서는 그 큐의 정점들만 펼친다. 이렇게 하면 work-efficient — 즉 알고리즘이 실제로 봐야 할 일만 본다.

Level k frontier: [u0, u1, u2] ↓ 각 정점의 이웃을 펼친다 새 frontier: [v0, v1, v2, v3, ...] (미방문 이웃만)

그림 15.2 — frontier 기반 BFS. 활성 정점만 들고 다닌다.

구현은 다음과 같다. 두 개의 큐 frontier_in, frontier_out을 둔다. 한 레벨의 커널은 frontier_in의 각 정점 u에 대해 이웃을 검사하고 미방문이면 거리를 갱신하면서 frontier_out에 추가한다. 다음 레벨에서는 두 큐를 swap.

__global__ void bfsFrontier(
    int level,
    const int* frontier_in, int in_size,
    int* frontier_out, int* out_size,
    const int* row_ptr, const int* col,
    int* dist) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i >= in_size) return;
  int u = frontier_in[i];
  int s = row_ptr[u], e = row_ptr[u + 1];
  for (int j = s; j < e; ++j) {
    int v = col[j];
    // 거리 갱신을 atomicCAS로: 미방문일 때만 성공
    if (atomicCAS(&dist[v], INT_MAX, level + 1) == INT_MAX) {
      // frontier_out에 추가
      int pos = atomicAdd(out_size, 1);
      frontier_out[pos] = v;
    }
  }
}

이론적 work는 더할 나위 없이 효율적이다. 그러나 atomicAdd(out_size, ...)가 한 변수에 모든 스레드의 갱신을 몰아넣는다. 이게 contention의 원흉이다. 큰 프런티어를 만들 때 한 카운터에 수만 번의 atomic이 직렬화된다. SM마다 L2를 거쳐서 싱글 변수에 도달하는 길이 좁아져 처리량이 곤두박질친다.

15.6 사유화로 contention 줄이기

해법은 흔한 패턴 — 사유화(privatization)다. 블록마다 자체 로컬 큐를 셰어드 메모리에 두고, 블록 안의 스레드들은 atomic을 셰어드 메모리에서만 친다. 블록 끝에서 한 번만 글로벌 큐로 머지한다. 글로벌 atomic 횟수가 (스레드 수)에서 (블록 수)로 줄어든다. 1024 스레드 × 64 블록이라면, 65536번의 글로벌 atomic이 64번으로 줄어 1000배 감소.

__global__ void bfsFrontierPriv(
    int level,
    const int* frontier_in, int in_size,
    int* frontier_out, int* out_size,
    const int* row_ptr, const int* col,
    int* dist) {
  __shared__ int sQ[1024];
  __shared__ int sQSize;
  if (threadIdx.x == 0) sQSize = 0;
  __syncthreads();

  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < in_size) {
    int u = frontier_in[i];
    int s = row_ptr[u], e = row_ptr[u + 1];
    for (int j = s; j < e; ++j) {
      int v = col[j];
      if (atomicCAS(&dist[v], INT_MAX, level + 1) == INT_MAX) {
        int p = atomicAdd(&sQSize, 1);   // 셰어드 atomic
        if (p < 1024) sQ[p] = v;
      }
    }
  }
  __syncthreads();

  // 블록당 한 번 글로벌 큐에 머지
  __shared__ int blockBase;
  if (threadIdx.x == 0) {
    blockBase = atomicAdd(out_size, sQSize);
  }
  __syncthreads();
  for (int k = threadIdx.x; k < sQSize; k += blockDim.x) {
    frontier_out[blockBase + k] = sQ[k];
  }
}

로컬 큐가 가득 차면 어떻게 하느냐는 디테일이 따라붙는다. 보통 두 가지 안전장치가 들어간다. 첫째, 로컬 큐가 임계점에 닿으면 즉시 글로벌로 비우고 카운터를 리셋. 둘째, 로컬 큐 크기를 충분히 잡고 워프 단위 prefix sum으로 더 작은 단위에서 미리 정렬해 한 워프가 한 chunk를 글로벌에 코얼레싱으로 쓴다.

15.7 추가 최적화: direction-optimized와 부하 균형

실제 그래프 라이브러리(Gunrock, cuGraph, NVGRAPH)는 위 토대 위에 여러 층의 최적화를 쌓는다.

direction-optimized BFS: 매 레벨 시작 시 frontier 크기 |F|와 미방문 정점 수 |U|를 비교한다. |F| · 평균차수 > |U|이면 push 대신 pull로 전환한다. 활성 비율이 높을 때 pull이 압도적으로 유리하기 때문. 이 한 줄짜리 휴리스틱이 멱법칙 그래프에서 5~10배 가속을 준다.

부하 균형: 한 frontier 정점의 차수가 100만, 옆 정점의 차수가 1이면 한 워프 안에서도 다이버전스가 심하다. 해결책은 정점을 차수 구간별로 나누어 다른 커널 또는 다른 매핑 방식으로 처리하는 것이다(Gunrock의 LB(Load Balanced) 매핑). 작은 차수는 한 스레드가, 중간 차수는 한 워프가, 거대 차수는 한 블록이 협력해서 처리.

차수 구간처리 단위이유
0~31한 스레드오버헤드 최소
32~1023한 워프워프 셔플로 협력 효율적
1024+한 블록셰어드 메모리 활용, 대규모 펼치기

커널 호출 오버헤드: 레벨이 매우 깊은 그래프(체인 그래프 등)는 매 레벨마다 커널을 새로 띄워야 하는 부담이 누적된다. 한 호출의 launch latency가 5~10 마이크로초라면 1만 레벨에서 50~100ms의 오버헤드. 이를 줄이려면 CUDA Graph를 캡처하거나 cooperative groups의 grid 동기화로 한 커널 안에서 여러 레벨을 처리하는 기법을 쓴다.

메모리 액세스 함정

BFS의 col[] 액세스는 코얼레싱이 잘 되지만, dist[col[j]] 액세스는 col이 가리키는 위치가 무작위다. 특히 큰 그래프에서는 캐시 미스율이 90%를 넘기도 한다. 이 때문에 BFS는 본질적으로 메모리 latency-bound 워크로드에 가깝다. 비트맵으로 방문 여부를 1비트만 표현해 메모리 풋프린트를 줄이는 게 흔한 트릭.

15.8 정리

그래프 탐색은 GPU에 친절하지 않은 워크로드의 대표 사례다. 메모리 액세스가 무작위이고, 차수 분포가 편향되며, 레벨마다 일의 양이 변한다. 그래도 vertex-centric의 push/pull, edge-centric의 균일성, frontier의 work-efficiency, 그리고 사유화로 contention을 누르는 패턴을 조합하면 CPU 대비 5~30배의 가속을 안정적으로 얻는다. 이번 장의 패턴은 BFS만이 아니라 SSSP, connected components, PageRank, Betweenness Centrality 등 거의 모든 그래프 알고리즘에 그대로 응용된다.

이 챕터에서 챙길 것