하드웨어(PCIe · DMA · NVLink)부터 스케줄러(device plugin · DRA · gang scheduling), 2×3090 실습까지 — 학습 세션 종합 문서 · 2026-06
대상 독자. Kubernetes·GPU 배경지식이 없어도 따라올 수 있도록 1·2장을 배경지식 장으로 두었다. 두 분야에 익숙하다면 4장부터 시작해도 되고, 모르는 용어는 부록 D 용어집에서 찾는다.
색상 규약(문서 전체 공통). 보라 = 벤더 컴포넌트·GPU 청록 = kubelet · NVLink 파랑 = API server · PCIe 주황 = 스케줄러 호박 = 할당 결과 · 활성 경로
인터랙티브 패널. [이전]·[다음] 버튼으로 단계를 진행하면 도식의 해당 경로가 켜진다. 6장의 시뮬레이터는 모드를 전환해 같은 상황을 두 방식으로 비교한다.
검토 반영. 원 대화 대비 정밀화·정정 5건이 본문에 반영되어 있으며(호박색 노트 박스), 목록은 부록 A에 있다.
배경지식 장. GPU 스케줄링의 모든 설계 결정은 이 장의 원칙 위에 서 있다.
Kubernetes(K8s)는 여러 대의 서버(노드)를 하나의 자원 풀로 묶어, 컨테이너(Pod)를 어느 노드에서 실행할지 자동으로 결정·유지하는 시스템이다. 등장인물은 넷이면 충분하다: 상태의 중앙 게시판인 API server, 배치를 결정하는 kube-scheduler, 각 노드에 상주하며 실제로 컨테이너를 띄우는 kubelet, 그리고 게시판의 저장소인 etcd.
K8s의 제1원칙: 컴포넌트들은 서로 직접 통신하지 않는다. 모두가 API server의 오브젝트(Node, Pod 등)를 읽고(get), 쓰고(update), 변경을 구독(watch)하는 방식으로만 협업한다. 스케줄러가 kubelet에게 명령을 "보내는" 일은 없다 — 스케줄러가 오브젝트에 결정을 써두면, kubelet이 그것을 읽어간다. 예외는 노드 내부의 로컬 통신(kubelet ↔ 컨테이너 런타임, kubelet ↔ device plugin)뿐이다.
kubelet은 시작할 때 내장 cAdvisor로 /proc/cpuinfo, /proc/meminfo를 직접 읽어 머신의 CPU 코어 수와 메모리 총량을 파악하고, 이를 Node 오브젝트의 status에 기록한 뒤 주기적으로 갱신한다. 생존 신호는 별도의 Lease 오브젝트로 보낸다.
| 필드 | 의미 | 예 |
|---|---|---|
status.capacity | 머신의 물리 총량 | CPU 64, 메모리 512Gi |
status.allocatable | Pod에 배정 가능한 양 = capacity − 시스템·K8s 데몬 예약분 | CPU 62, 메모리 500Gi |
스케줄러는 노드의 실제 사용량을 보지 않는다. 보는 것은 장부다: 노드의 allocatable에서 그 노드에 이미 배정된 Pod들의 requests 합을 뺀 잔여량과, 새 Pod의 requests를 비교한다. 예: allocatable CPU 62, 기존 Pod들의 requests 합 40이면 잔여 22 — 새 Pod이 request 4를 내면 통과. 실제 CPU 사용률이 10%든 90%든 이 계산에는 등장하지 않는다.
결정은 binding으로 기록된다: Pod 오브젝트의 spec.nodeName에 노드 이름을 써넣는 것. 그러면 해당 노드의 kubelet이 watch로 "내게 배정된 Pod"을 발견하고, 최종 확인(admission) 후 컨테이너를 실행한다. 결정조차 오브젝트 쓰기라는 점이 허브-스포크 원칙의 일관성이다.
CPU·메모리는 커널이 표준 인터페이스(/proc, cgroup)를 제공하므로 kubelet이 스스로 발견하고 격리·회수까지 관리할 수 있다. GPU는 다르다 — 발견·할당·헬스가 전부 벤더 드라이버 영역이고, 커널 표준 경로가 없다. kubelet에 NVIDIA 전용 코드를 넣을 수는 없으므로, K8s는 "발견과 할당만 벤더 에이전트에 위임하는 확장점"을 만들었다. 그것이 4장의 device plugin이다.
배경지식 장. "GPU 2장 달라"가 왜 스케줄링 문제가 되는지는 데이터가 다니는 물리 경로를 알아야 보인다.
흔히 "CPU"라 부르는 칩(패키지) 안에는 세 가지가 함께 산다: 코어(연산 유닛), 메모리 컨트롤러(RAM과의 인터페이스), PCIe root complex(모든 PCIe 링크의 뿌리). 과거에는 뒤의 둘이 메인보드의 별도 칩(노스브리지)이었으나 현대 CPU에 전부 통합되었다. 이 사실이 이 장의 모든 질문 — "데이터가 CPU를 지나가는가?" — 의 답을 결정한다.
RAM은 메모리 컨트롤러에 전용 버스로 직결되고, 그 외의 모든 고속 장치(GPU, NVMe SSD, NIC)는 PCIe로 CPU에 연결된다. 핵심 성질: PCIe는 옛 PCI처럼 한 가닥에 여러 장치가 매달리는 공유 버스가 아니라, 각 장치가 root complex와 1:1 전용 링크를 갖는 점대점 트리다. 링크의 폭이 lane(차선)이며, 슬롯 규격 x1/x4/x8/x16이 lane 수를 뜻한다.
| 세대 | lane 1개 (단방향) | x16 슬롯 (단방향) | 비고 |
|---|---|---|---|
| PCIe Gen3 | ~1 GB/s | ~16 GB/s | 세대마다 lane당 속도 2배 |
| PCIe Gen4 | ~2 GB/s | ~32 GB/s | RTX 3090의 연결 규격 |
| PCIe Gen5 | ~4 GB/s | ~64 GB/s | H100 세대 서버 |
lane은 한정 자원이다: 컨슈머 CPU가 ~20–24개, 서버 CPU가 ~128개를 제공한다. GPU 여러 장 + NIC + NVMe를 다는 서버 설계는 결국 이 lane을 어떻게 분배하느냐의 문제이며, 대형 GPU 서버는 CPU 직결 대신 PCIe 스위치 계층을 두어 lane을 확장하기도 한다.
모델 weight를 RAM에서 GPU의 VRAM으로 올릴 때, 데이터는 구조상 반드시 CPU 패키지의 실리콘을 통과한다 — RAM의 출구(메모리 컨트롤러)와 GPU로 가는 길의 입구(root complex)가 둘 다 그 안에 있기 때문이다. 그러나 복사 노동은 코어가 하지 않는다. 이것이 DMA(Direct Memory Access)다: GPU 안의 전용 복사 하드웨어인 DMA 엔진(copy engine)이 PCIe read 요청을 직접 발행해 데이터를 끌어가고, 코어는 "어디서 어디로 얼마나"를 적은 지시서(디스크립터) 작성과 완료 통보에만 등장한다. 아래 스테퍼로 5단계를 따라가 보자.
일반(pageable) 메모리는 커널이 페이지를 언제든 옮기거나 스왑할 수 있어 DMA 엔진이 직접 읽기에 안전하지 않다. 그래서 CUDA는 내부의 고정(pinned) 스테이징 버퍼로 코어가 한 번 memcpy를 수행한 뒤 DMA를 시작한다 — 복사가 두 번 일어나는 셈이다. cudaHostAlloc이나 PyTorch DataLoader의 pin_memory=True는 처음부터 고정 메모리를 할당해 이 스테이징 단계를 제거하고 DMA 직행을 만드는 옵션이다.
DMA의 "read 요청"은 마법이 아니다. PCIe 장치들은 CPU와 같은 물리 주소 공간에 매핑되며(MMIO), GPU는 RAM의 주소를, CPU는 VRAM의 주소(그 창의 크기가 Resizable BAR)를 서로 load/store할 수 있다. DMA란 이 공유 주소 공간 위에서 전용 엔진이 수행하는 대량 load/store일 뿐이다. 이 원리를 확장하면 GPU↔NVMe(GPUDirect Storage), GPU↔NIC(GPUDirect RDMA)처럼 RAM 경유 자체를 건너뛰는 장치 간 직접 전송이 된다 — 5장의 "NIC과 GPU를 같은 스위치에 정렬"이라는 요구가 여기서 나온다.
"어차피 전부 전기 신호"라는 직관은 옳다. 차이는 어느 구리선을, 어느 컨트롤러가, 어느 프로토콜로 쓰느냐다.
GPU 칩(다이)은 송수신 물리 계층 회로(PHY)를 두 종류 내장한다. PCIe PHY는 카드 아랫면 단자를 통해 슬롯 → 메인보드 → CPU로 이어지는 회선을 담당하고, NVLink PHY는 별도의 핀으로 나와 피어 GPU와의 전용 회선을 담당한다. NVLink는 소프트웨어 기능이 아니라 이 하드웨어이므로, 회로가 없는 카드에서는 어떤 드라이버로도 만들 수 없다.
RTX 3090 같은 카드에서는 NVLink PHY가 카드 윗면의 금색 핑거로 노출된다. 두 카드를 나란히 꽂고, 핑거 위에 브리지라는 작은 PCB를 압입하면 GPU↔GPU 직결 회선이 완성된다 — 메인보드를 전혀 경유하지 않는다. 카드는 여전히 슬롯에 꽂혀 있는데, 그 슬롯(PCIe)은 CPU와의 통신·전원 일부를 계속 담당한다. 즉 두 회선이 동시에 존재한다.
A100/H100급 서버는 8장을 일일이 브리지로 이을 수 없으므로 카드라는 형태 자체를 버린다. GPU는 SXM 모듈로 전용 베이스보드에 소켓 장착되고, NVLink 배선은 보드에 구리선으로 인쇄되어 NVSwitch라는 교환 칩에 모인다 — 케이블도 브리지도 없이 보드 자체가 배선이다. PCIe는 베이스보드에서 위로, CPU 호스트 보드와의 연결에만 쓰인다.
이제 아래 도식으로 두 회선의 폭을 비교한다. 분산 학습이 매 스텝 GPU 간에 gradient 수~수십 GB를 교환한다는 사실(6장)을 염두에 두면 이 숫자 차이가 곧 학습 속도 차이다.
GPU 간 데이터가 어느 길로 다니는지가 분산 학습 성능을 결정한다. 파란 길(PCIe)과 초록 길(NVLink)의 폭 차이 ~10배.
개인 연결점: 홈랩 RTX 3090은 PCIe Gen4 x16으로 CPU에 연결되어 있다(~32GB/s). 3090 두 장을 NVLink 브리지로 묶으면 ~112.5GB/s — 같은 원리의 컨슈머 버전. A100 8장 서버는 NVSwitch로 all-to-all 직결.
island = NVLink(또는 NVSwitch)로 상호 직결된 GPU 집합. 여기서 흔한 오해 하나를 바로잡는다: 대역폭은 데이터의 속성이 아니라 "통신하는 GPU 쌍 사이에 어떤 회선이 물리적으로 존재하는가"의 속성이다. "경계를 넘는 순간 추락한다"는 표현은 데이터가 이동 중에 떨어진다는 뜻이 아니라, 쌍이 island 경계를 가로지르면 그 둘 사이에는 NVLink 회선 자체가 깔려 있지 않아 느린 길이 유일한 길이라는 뜻이다. 아래에서 쌍을 바꿔가며 확인한다.
이것이 스케줄링 문제가 되는 마지막 고리: all-reduce 같은 집합 통신은 참여 쌍 중 가장 느린 링크에 전체 속도가 묶인다. 8장 중 7쌍이 NVLink여도 한 쌍이 PCIe면 그 스텝의 통신은 PCIe 속도로 끝난다. 그래서 성능 요구사항은 "GPU 4장"이 아니라 "전부 같은 island인 4장"이 된다 — 5장에서 이것을 선언적으로 표현하는 방법(matchAttribute)을 본다.
| 카드 | NVLink 하드웨어 | GPU↔GPU 통신 수단 |
|---|---|---|
| RTX 3090 (2020, Ampere) | 있음 — 윗면 핑거 | 브리지 장착 시 NVLink 양방향 합산 ~112.5GB/s |
| RTX 4090 (2022, Ada) | 없음 — 회로·단자 제거 | PCIe뿐. GeForce 40 시리즈는 PCIe P2P도 공식 미지원 — RAM 스테이징 경유가 기본 |
| RTX 5090 (2025) | 없음 | PCIe뿐 |
| A100 / H100 / B200 (데이터센터) | 있음, 세대마다 강화 | 600 → 900 → 1,800 GB/s |
K8s 스케줄러가 네이티브로 아는 자원은 CPU와 메모리뿐이다. GPU 같은 하드웨어는 extended resource로 노출된다: Node status에 nvidia.com/gpu: 8처럼 적히는 이름 붙은 정수 카운터. 이 카운터를 채우는 것이 device plugin — 각 노드에 DaemonSet으로 떠서 kubelet에 GPU를 보고하는 벤더 에이전트다. 핵심 인식: 스케줄러에게 GPU는 의미를 모르는 숫자다. 8이 80GB A100인지 24GB 3090인지, NVLink로 묶였는지 전혀 모른다. 이 한 문장이 이후 모든 한계의 뿌리다. 1장의 베이스라인(스텝 0)에서 시작해 흐름을 따라가자.
스케줄러는 "어느 노드"를, kubelet+plugin은 "어느 디바이스"를 결정한다. 단계를 넘기며 화살표를 따라가기.
GetPreferredAllocation으로 선호 조합 힌트를 줄 수 있고, Allocate(선택된 ID들)는 그 디바이스를 컨테이너에 준비(디바이스 파일·환경변수 명세 반환)시키는 호출이다. "노드 위에서, Pod 도착 후에 디바이스가 정해진다"는 구조적 결론은 동일하다.흐름에서 확인했듯 결정이 둘로 찢어져 있다 — "어느 노드"는 스케줄러가, "어느 디바이스"는 kubelet+plugin이. 전체를 보는 주체가 없고, 스케줄러가 보는 것은 속성 없는 정수다. 여기서 세 가지 한계가 나온다.
| # | 한계 | 원인 | 현장의 땜질 |
|---|---|---|---|
| 1 | 속성 표현 불가 | 카운터에 메모리·모델·MIG 프로파일이 없음 | node label + nodeSelector, 또는 nvidia.com/mig-1g.5gb 같은 자원 이름 폭발 |
| 2 | 토폴로지 무지 | 디바이스 선택이 kubelet 단계 → 스케줄러가 NVLink/PCIe/NUMA 배치를 고려 불가 | plugin 쪽 best-effort 정렬뿐, 노드 선택 자체는 못 바꿈 |
| 3 | 정수 전부-아니면-전무 | 분수 할당·공유·동적 분할 개념 없음 | time-slicing, MIG 정적 사전 분할 |
땜질이 왜 땜질인가. 한계 1의 결과: 클러스터에 A100 80GB/40GB와 MIG 프로파일 여러 종이 섞이면 자원 이름이 십여 개로 폭발하고, 각각이 독립 카운터라 "메모리 ≥ 30GB면 아무거나"를 표현할 방법이 없다. 한계 2의 결과: 같은 노드 8장 중 어느 조합을 받을지 복불복 — 3장에서 본 대역폭 절벽 때문에 같은 매니페스트가 실행마다 다른 성능을 내는데, 원인이 스케줄링 계층에서 보이지 않는다.
DRA(Dynamic Resource Allocation, K8s 1.34에서 GA)의 본질은 한 문장이다: 디바이스의 속성을 API 오브젝트로 노출하고, 디바이스 선택 결정을 kubelet에서 스케줄러로 이동. 오브젝트 모델은 스토리지의 PV/PVC 패턴과 정확히 닮았다.
| 스토리지 | DRA | 역할 |
|---|---|---|
| StorageClass | DeviceClass | "이런 부류의 디바이스" 정의 (드라이버가 제공, cluster-scoped) |
| PVC | ResourceClaim | "이런 조건의 디바이스 N개" 요청 |
| PV 인벤토리 | ResourceSlice | 드라이버가 게시하는 실제 디바이스 목록 + 속성 |
| volumeClaimTemplate | ResourceClaimTemplate | Pod마다 claim 자동 생성 |
비유가 깨지는 지점 세 곳. ① PV는 동적 프로비저닝으로 새로 만들어질 수 있지만 GPU는 고정 인벤토리라 게시된 것 중 선택만 가능하다. ② PV:PVC는 1:1이지만 claim 하나가 디바이스 여러 개를 받을 수 있고 여러 Pod이 한 claim을 공유할 수도 있다. ③ 스토리지는 원격 attach가 되지만 GPU는 노드 로컬이라 디바이스 할당이 곧 노드 선택을 구속한다. 흐름을 단계로 따라가자 — 3단계에서 GPU-3(24Gi)이 조건 미달로 흐려지는 것을 관찰한다.
속성이 ResourceSlice로 게시되고, 노드와 디바이스가 한 결정으로 선택된다. GPU-3(24Gi)이 CEL에서 탈락하는 과정을 관찰.
스테퍼의 "요청"에 등장한 CEL(Common Expression Language)은 구글이 만든 임베디드 식 언어로, K8s가 정책·검증 전반(ValidatingAdmissionPolicy 등)에 채택한 표준이다. 역할을 정확히 하자: CEL은 속성을 정의하지 않는다. 속성은 driver가 ResourceSlice에 게시하고, CEL selector는 그 위에서 디바이스마다 평가되어 boolean을 반환하는 필터다. SQL에 비유하면 —
ResourceSlice = 테이블 (디바이스 = 행, 속성 = 열) CEL selector = WHERE 절 스케줄러 = 쿼리 실행기 — 행마다 식을 평가해 true인 것만 후보로
| 디바이스 | memory | memory.compareTo(quantity("40Gi")) >= 0 | 후보 |
|---|---|---|---|
| GPU-0 | 80Gi | 80 ≥ 40 → true | ✓ |
| GPU-1 | 80Gi | 80 ≥ 40 → true | ✓ |
| GPU-2 | 40Gi | 40 ≥ 40 → true (경계 포함) | ✓ |
| GPU-3 | 24Gi | 24 ≥ 40 → false | 탈락 |
후보 3개에서 count: 2를 고르는 것은 CEL의 일이 아니라 스케줄러의 일이다 — CEL은 자격 심사, 선택은 스케줄러. quantity()와 compareTo()가 붙는 이유: "40Gi"는 단순 숫자가 아닌 K8s Quantity 타입(Gi/Mi/m 단위 체계)이라 기본 비교 연산자를 쓸 수 없어 타입 전용 함수를 거친다.
비유가 깨지는 지점: SQL WHERE는 JOIN으로 행 간 관계를 걸 수 있지만, CEL selector는 디바이스 1개씩 독립 평가되는 술어(predicate)다. "선택된 디바이스들이 서로 같은 NVLink 도메인일 것" 같은 디바이스 간 관계는 selector로 표현 불가 — 그래서 별도 메커니즘인 constraints.matchAttribute가 있다(선택된 디바이스 전원이 지정 속성에서 같은 값을 가져야 함). 3장의 island 요구가 여기서 선언적 표현을 얻는다.
DRA driver(예: NVIDIA k8s-dra-driver)가 설치되어 DeviceClass gpu.nvidia.com과 ResourceSlice가 게시된 클러스터 기준. 속성 이름은 드라이버 버전에 따라 다르므로 kubectl get resourceslices -o yaml로 실제 게시 속성을 확인 후 맞춘다.
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
name: training-gpus
spec:
spec:
devices:
requests:
- name: gpus
exactly:
deviceClassName: gpu.nvidia.com
count: 2
selectors:
- cel:
expression: >-
device.capacity["gpu.nvidia.com"].memory
.compareTo(quantity("40Gi")) >= 0
constraints:
- requests: ["gpus"]
matchAttribute: "gpu.nvidia.com/nvlinkDomain" # 2장이 같은 도메인
---
apiVersion: v1
kind: Pod
metadata:
name: trainer
spec:
restartPolicy: Never
containers:
- name: train
image: nvcr.io/nvidia/pytorch:24.10-py3
command: ["python", "-c", "import torch; print(torch.cuda.device_count())"]
resources:
claims:
- name: gpus
resourceClaims:
- name: gpus
resourceClaimTemplateName: training-gpus
| 4장의 한계 | DRA의 해법 |
|---|---|
| 속성 표현 불가 | ResourceSlice가 속성 게시 + CEL 조건 요청 → 자원 이름 폭발 소멸 |
| 토폴로지 무지 | 노드와 디바이스를 스케줄러가 한 결정으로 선택 + matchAttribute → island 정렬 표현 가능 |
| 정수 전부-아니면-전무 | partitionable device(MIG 동적 분할), claim 공유 |
DRA가 풀지 않는 문제도 있다: 노드 간 정렬. "GPU와 같은 PCIe 스위치의 RDMA NIC을 함께 달라"(2.5절의 GPUDirect RDMA) 같은 멀티-드라이버 교차 제약은 현재진행형 영역이다.
DRA가 "Pod 1개 ↔ 디바이스" 문제를 풀었다면, 분산 학습은 "Pod N개가 원자적으로" 문제를 추가한다. 용어부터: 분산 학습에서 Job 1개 = worker Pod N개이고 각 Pod이 GPU를 잡는다. rank는 worker의 번호(0…N−1)다.
왜 N개가 한 몸인가. 데이터 병렬(data parallel) 학습은 모델을 N개로 복제하고 데이터를 나눠 계산한 뒤, 매 스텝 끝마다 all-reduce로 전원의 gradient를 평균낸다. all-reduce는 집합 연산(collective operation) — 참가자 전원이 도착해야 완료되는 barrier다. 회의에 비유하면 8명 전원 참석해야만 시작되는 회의가 매 스텝 열리는 셈인데, 비유의 한계도 명확하다: 회의는 7명으로 시작할 수도 있지만 all-reduce는 수학적으로 전원의 텐서가 필요해 부분 진행 자체가 불가능하다. worker 8개 중 7개만 떠 있으면 7개는 첫 all-reduce에서 GPU를 점유한 채 8번째를 영원히 기다린다 — 점유는 100%, 진행은 0.
"원자적"의 의미는 DB 트랜잭션의 원자성과 같다: N개 Pod 배치가 전부 성공하거나 전부 안 하거나. 중간 상태(절반만)는 자원만 태우는 무의미한 상태이므로 존재 자체를 금지해야 한다. 그런데 기본 스케줄러의 결정 단위는 Pod이다 — Job 경계를 모른 채 큐에서 하나씩 꺼내 배치(greedy)하므로, 두 Job이 동시에 오면 인터리브되어 반반 점유에 도달한다. 이 상태는 OS 데드락과 조건까지 동형이다.
| Coffman 데드락 조건 | GPU 스케줄링에서의 대응 |
|---|---|
| 상호 배제 | GPU는 배타 점유 자원 |
| 점유와 대기 (hold & wait) | 각 Job이 절반을 잡은 채 나머지를 대기 |
| 비선점 | 실행 중 Pod의 GPU를 강제 회수하지 않음 |
| 순환 대기 | A는 B의 해제를, B는 A의 해제를 기다림 |
아래 시뮬레이터에서 같은 상황(8-GPU 클러스터, 8-GPU Job 둘 동시 제출)을 두 모드로 끝까지 실행해 비교하라 — 기본 모드의 결말과 Gang 모드의 결말, 그리고 마지막 스텝의 배너 문구.
8 GPU 클러스터에 8-GPU Job A·B가 동시 제출되는 같은 상황. 두 모드를 번갈아 끝까지 실행해 비교.
해법은 데드락 조건 중 "점유와 대기"를 원천 제거하는 것: all-or-nothing 입장(admission) — Job 전체 요구량이 한 번에 수용될 때만 Pod들을 풀어주고, 아니면 1개도 주지 않는다. 이것이 gang scheduling이며, K8s 생태계의 표준 구현이 Kueue다. Kueue는 Job을 suspend 상태로 잡아뒀다가 quota가 확보되면 resume하는 방식으로 동작한다 — kube-scheduler를 대체하지 않고 그 앞단의 게이트로 협업한다. gang이 막는 것은 교착뿐이며, "어느 Job부터"(우선순위·팀 간 quota·선점·기아 방지)는 Kueue의 ClusterQueue/cohort/preemption 영역이다 — 부록 E의 다음 학습 주제.
학습 가치의 핵심은 "NVLink가 있어서 빠르다"가 아니라, 한 시스템 안에 빠른 길과 느린 길이 공존해 끄고 켜며 대조 실험을 할 수 있다는 점이다. 3장의 island 절벽을 책상 위에서 직접 측정한다.
구매 순서: 보드 슬롯 간격 확인 → 브리지 pitch 결정 → 카드 구매.
| 항목 | 체크 내용 | 함정 |
|---|---|---|
| 메인보드 슬롯 | x16 물리 슬롯 2개가 CPU 직결이고 x8/x8 분기 지원 | 두 번째 슬롯이 칩셋 경유면 탈락(DMI 병목). 보드 매뉴얼의 블록 다이어그램으로 확인 |
| 슬롯 간격(pitch) | 두 슬롯 간 거리 = 브리지 pitch와 정확히 일치 | GeForce 정품 브리지는 4-슬롯 간격 단일 SKU. 2/3-슬롯 보드면 RTX A6000용 브리지(동일 NVLink 3세대) 사용 — 호환 보고 다수이나 정품 보증 밖 |
| 두 번째 3090 | 브랜드 혼용 무방(NVLink는 칩 기능) | 카드 두께가 다르면 핑거 높이가 어긋날 수 있음 — 비슷한 두께 권장. 중고는 채굴 이력 리스크 |
| 전원 | 1000W 이상, 권장 1200W | 3090은 순간 스파이크가 큼. 운영 시 nvidia-smi -pl 280 수준 제한이 현실적 |
| 발열·공간 | 3-슬롯급 2장 — 케이스 폭, 카드 간 흡기 간격 | 위 카드가 아래 카드 배기를 흡기 → power limit + 팬커브 필수 |
KVM/libvirt 환경이라면 검증은 베어메탈(호스트)에서 먼저 한다. NVLink 쌍의 VM passthrough는 두 GPU+브리지를 같은 VM에 넣어야 하고 IOMMU 그룹 이슈가 겹친다 — 변수를 분리할 것.
양쪽 카드의 핑거 커버를 제거하고 브리지를 위에서 압입. 부팅 후:
nvidia-smi topo -m
GPU0 GPU1 GPU0 X NV4 <- NVLink 4링크. PHB/PIX/SYS면 미인식(브리지 접촉 불량 의심) GPU1 NV4 X
nvidia-smi nvlink --status # 기대: GPU당 Link 0~3 각각 14.062 GB/s (4링크 × 14.06 = 단방향 56.25GB/s)
git clone https://github.com/NVIDIA/cuda-samples.git cd cuda-samples && mkdir build && cd build && cmake .. make -j"$(nproc)" p2pBandwidthLatencyTest ./Samples/5_Domain_Specific/p2pBandwidthLatencyTest/p2pBandwidthLatencyTest
검증 포인트 셋: ① Peer access from GPU0 → GPU1: Yes ② 단방향 대역폭 P2P enabled ~50GB/s(이론 56.25의 약 90%) vs disabled(RAM 경유) 수 배 낮음 ③ 레이턴시 enabled ~1–2µs vs disabled ~10µs+. 2장의 그림과 연결하면: disabled는 GPU0→RAM 스테이징→GPU1의 두 번 복사, enabled는 GPU0의 DMA 엔진이 GPU1 VRAM 주소로 직접 쓰기다.
git clone https://github.com/NVIDIA/nccl-tests && cd nccl-tests make CUDA_HOME=/usr/local/cuda -j"$(nproc)" # 실험군: NVLink 경로 ./build/all_reduce_perf -b 8M -e 1G -f 2 -g 2 # 대조군: P2P 강제 차단 -> 호스트 경유로 추락 NCCL_P2P_DISABLE=1 ./build/all_reduce_perf -b 8M -e 1G -f 2 -g 2 # transport 선택 로그 확인 NCCL_DEBUG=INFO ./build/all_reduce_perf -b 64M -e 64M -g 2 2>&1 | grep -i via
# 실험군 로그 기대: ... via NVL (NVLink transport) # 대조군 로그 기대: ... via SHM (호스트 공유메모리 경유) # busbw @ 1GiB: 실험군 ~40-50GB/s vs 대조군 한 자릿수 GB/s (환경 의존, 대략값)
관찰 포인트: 큰 메시지(≥256M)의 busbw 비율이 직접 측정한 island 경계 절벽이다. 작은 메시지 구간은 대역폭이 아니라 레이턴시가 지배하므로 busbw 곡선이 메시지 크기에 따라 꺾인다 — 이 곡선 모양 자체가 집합 통신의 성격이다.
# ddp_bench.py — 2-GPU DDP 스텝 시간 벤치마크 (약 400M 파라미터, fp32)
import time, torch, torch.nn as nn
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
def main():
dist.init_process_group("nccl")
rank = dist.get_rank()
torch.cuda.set_device(rank)
model = nn.Sequential(*[nn.Linear(4096, 4096) for _ in range(24)]).cuda()
ddp = DDP(model, device_ids=[rank])
opt = torch.optim.SGD(ddp.parameters(), lr=1e-3)
x = torch.randn(32, 4096, device="cuda")
y = torch.randn(32, 4096, device="cuda")
loss_fn = nn.MSELoss()
for i in range(60):
if i == 10:
torch.cuda.synchronize(); dist.barrier()
t0 = time.time()
opt.zero_grad(set_to_none=True)
loss = loss_fn(ddp(x), y)
loss.backward() # 여기서 gradient all-reduce 발생
opt.step()
torch.cuda.synchronize()
if rank == 0:
print(f"avg step: {(time.time()-t0)/50*1000:.2f} ms")
dist.destroy_process_group()
if __name__ == "__main__":
main()
# 1 GPU 기준선 / NVLink / P2P 차단 — 세 값을 비교 torchrun --standalone --nproc_per_node=1 ddp_bench.py torchrun --standalone --nproc_per_node=2 ddp_bench.py NCCL_P2P_DISABLE=1 torchrun --standalone --nproc_per_node=2 ddp_bench.py
해석: (2-GPU 스텝 시간 − 1-GPU 스텝 시간)이 통신+동기화 비용의 근사치이고, P2P 차단 값과의 차이가 NVLink가 실제로 사주는 것이다. 추론 쪽은 tensor parallel이 매 레이어마다 all-reduce를 발생시켜 효과가 더 즉각적이다:
vllm serve Qwen/Qwen2.5-32B-Instruct-AWQ --tensor-parallel-size 2 --max-model-len 8192 # 24GB ×2로 단일 카드에 못 올리던 30B급 서빙 — 4bit 양자화 기준
kubectl describe node에서 nvidia.com/gpu: 2 카운터만 노출되고 NVLink 정보가 없음을 확인 — 4장 한계 ②의 체험.kubectl get resourceslices -o yaml로 두 GPU가 속성과 함께 게시됨을 확인 — 5장 스테퍼의 오브젝트를 실물로 조회.| 증상 | 유력 원인 | 조치 |
|---|---|---|
| topo -m에 NV4가 아닌 PHB/SYS | 브리지 접촉 불량, pitch 불일치 | 브리지 재장착·좌우 균등 압입, 카드 높이 확인 |
| Peer access: No | PCIe ACS가 P2P 라우팅 차단, IOMMU 간섭 | BIOS에서 ACS 비활성, 또는 커널 파라미터 조정 후 재시도 |
| NCCL이 항상 via SHM | P2P 미가용 상태에서 NCCL이 자동 폴백 | 7.2를 먼저 통과시킬 것 — NCCL 문제가 아님 |
| 성능이 기대의 절반 이하 | power limit 미설정으로 스로틀, 배기 재흡입 | nvidia-smi -pl 설정, 팬커브, 카드 간격 확보 |
| 간헐 Xid 에러·행업 | 전원 순간 스파이크, 라이저 사용 | PSU 상향/케이블 분리 배선, 라이저 제거 후 재현 확인 |
| 측정 항목 | 명령 | NVLink | P2P 차단 | 비율 |
|---|---|---|---|---|
| P2P 단방향 대역폭 | p2pBandwidthLatencyTest | |||
| P2P 레이턴시 | p2pBandwidthLatencyTest | |||
| all_reduce busbw @1GiB | nccl-tests | |||
| DDP avg step (vs 1-GPU: ___ms) | ddp_bench.py | |||
| vLLM TP=2 tokens/s | vllm bench |
| # | 내용 | 위치 |
|---|---|---|
| ① | 대역폭 비교 기준 통일: NVLink 수치는 양방향 합산, PCIe는 단방향 — 같은 기준으로 A100은 PCIe Gen4 x16의 약 9–10배 | 3.4 |
| ② | NVSwitch 기반 현대 8-GPU 서버는 노드 전체가 단일 island. "노드 안 복수 island"는 브리지 페어·구형 토폴로지의 경우 | 3.5 |
| ③ | "3090이 마지막 NVLink"는 GeForce 라인 기준 — 워크스테이션은 Ampere 세대까지 유지 | 3.6 |
| ④ | device plugin의 디바이스 선택 주체는 kubelet device manager(plugin은 GetPreferredAllocation 힌트). Allocate()는 준비 호출 | 4장 |
| ⑤ | 실습 수치는 전형값(환경 의존). 목표 측정치는 절대값이 아니라 NVLink/차단 비율. cuda-samples는 최신 기준 cmake 빌드 | 7.7 |
K8s의 모든 협업은 API server 오브젝트를 경유한다. kubelet이 실물을 보고하고(capacity/allocatable), 스케줄러는 장부(allocatable − Σrequests)로 결정해 binding으로 기록한다. GPU는 커널 표준 인터페이스가 없어 device plugin이 벤더 대리인으로 카운터를 채우는데, 카운터에는 속성이 없고 디바이스 선택이 kubelet 단계로 밀리며 정수라 나눌 수 없다. 한편 하드웨어 쪽에서 GPU 간 통신 속도는 쌍 사이에 어떤 회선이 존재하느냐로 결정된다 — 같은 island면 NVLink, 아니면 PCIe, 노드 밖이면 네트워크. 집합 통신은 가장 느린 쌍에 묶이므로 "같은 island의 N장"이 성능 요구사항이 된다. DRA는 속성을 ResourceSlice로 게시하고 CEL 필터·matchAttribute로 조건을 선언하게 하며, 노드와 디바이스를 스케줄러의 한 결정으로 합친다. 분산 학습은 여기에 Pod N개의 원자성을 추가한다: all-reduce barrier 때문에 부분 배치는 점유 100%·진행 0이고, Pod 단위 greedy 배치는 데드락과 동형인 상태를 만든다. gang scheduling(all-or-nothing admission)이 그 조건을 제거하며, Kueue가 suspend/resume 게이트로 이를 구현해 kube-scheduler와 협업한다. 남는 질문 — 어느 Job부터, 누구의 quota로 — 이 다음 학습 주제다. 그리고 2×3090+브리지는 이 전부를 책상 위에서 대조 실험으로 재현하는 최소 장비다.
| 주제 | 요지 | 연결 |
|---|---|---|
| GPUDirect Storage / RDMA | RAM 경유를 건너뛰는 장치 간 직접 DMA. NVMe·NIC과 GPU의 배치 정렬이 새로운 스케줄링 요구가 됨 | 2.5 · 5.3 |
| PCIe 스위치 계층 | 대형 GPU 서버는 RC 직결이 아니라 스위치 트리 — 같은 스위치 아래 정렬이 P2P 성능을 좌우 | 2.2 |
| 노드 간 정렬 | DRA는 노드 내 결정. 멀티노드 Job의 네트워크 인지 배치(rail-optimized)는 미해결 영역 | 5.3 |
| gang이 못 정하는 것 | 순서·우선순위·선점·기아 방지 = Kueue admission의 영역 | 6장 |
| 컨슈머 P2P 차단 | GeForce 40 시리즈는 NVLink 제거에 더해 PCIe P2P도 공식 미지원 — 홈랩에서 3090의 특수 지위 | 3.6 · 7장 |
| x8/x8의 영향 범위 | 슬롯 분기는 NVLink 트래픽과 무관 — 호스트↔GPU 구간(모델 적재, pageable 경유)에만 영향 | 7.0 |