핵심 4단계
- 모델 레이어 -> 그래프 IR 로 정적 계획
- 텐서 메모리 (디바이스) -> 아레나로 주소 확정/재사용
- 노드 -> 각 독립 모듈의 호출 스텁 ( 속성 + 파라미터 + WS 포함 )
- 런 시점 -> 노드를 순회하며 포인터와 스트림을 넘겨 제로-카피 호출
1) IR (GraphIR) : 레이어 시퀀스를 노드 + 텐서로 바꾸는 중간 표현
Node 가 무엇인가?
- Node(kind, inputs, outputs, attrs, layer_ref, ws_fwd, ws_bwd)
- kind : 어떤 연산인지 식별 ( Conv2D, GEMM, ReLU, Flatten, ... )
- inputs / outputs : 텐서 ID 의 리스트, 노드 간 데이터 흐름은 텐서 ID 로 연결됨
- attrs : 이 노드가 필요한 하이퍼파라미터 ( Stride, Padding, Act ... ) , C++/CUDA 런처에 그대로 매핑된 값
- layer_ref : 파이썬 레이어 객체에 대한 참조
- ws_fwd/ws_bwd : 빠른 경로용 워크스페이스 핸들 (디바이스 배열들)
Node 는 독립 모듈을 호출하기 위한 콜 시트
입력/출력은 텐서 ID 로 커널에 넘길 러닝타임 속성은 attrs 로, 가중치는 layer_ref 에서 포인터로 뽑아 쓴다.
TensorDesc 가 무엇인가?
- TensorDesc(shape, dtype, buffer_id, offset)
- buffer_id + offset 조합은 아레나 내 1D 디바이스 버퍼의 부분 뷰
- 같은 buffer_id 를 공유하면 인플레이스/앨리어싱이 가능
- dtype 은 대부분 cp.float32 로 고정
GraphCompiler.compile 흐름
- 입력 텐서 하날르 먼저 등록 ( buffer_id = 0 )
- 모델 레이어를 순회하며
- 레이어 종류별로 출력 shape 를 계산 -> 새 TensorDesc 를 만들고, ir.tensors 에 등록
- 해당 연산에 맞는 Node 를 만들어 ir.nodes 에 추가
- Conv(groups == 1) 인 경우 fast path WS 를 미리 할당해 node.ws_fwd 에 꽂아둠
- ReLU 는 in-place alis 를 위해 입력과 동일한 buffer_id/offset 의 TensorDesc 를 하나 더 만들어 뷰만 변경
- 마지막으로 ir.output_ids 지정
즉, 컴파일 단계에서 이미 노드 그래프와 텐서-버퍼 매핑이 확정됨
2) MemoryArena : "한 장짜리 디바이스 메모리(아레나)" 에서 텐서를 뷰로 잘라쓰기
plan_memory
- 모든 TensorDesc 를 훑어 buffer_id 별 필요한 최대 길이 (=offset+numel) 를 계산
- 그 길이만큼 cp.empty 로 디바이스 1D 버퍼를 한 번만 할당
- 이후 실행 중 할당/해제 없음
arena.view(desc)
- 아레나의 1D 버퍼에서 offset : offset+numel 범위를 잘라 reshape 하여 디바이스 뷰를 돌려줌
- 이게 곧 텐서의 장소가 됨, 이 뷰의 data.ptr 이 커널에 들어감
더보기
효과 : 레이어 사이 텐서 복사 최소화, 인플레이스/앨리어시 제어
커널 런칭은 모든 메모리 준비가 끝난 상태
3) 독립 모듈바인딩과 주소 연결 : 포인터를 직접 넘기는 구조
포인터(주소) 전달
- _run_impl 에서 노드를 순회하며, 입력/출력 텐서를 arena.view(...) 로 가져옴 - 이건 디바이스 배열 (cuda.ndarray)
- 저수준 런처를 쓸 때 (_ops_conv2d.forwad 같은 pybind 바인딩 )
- int(X.data.ptr), int(W.data.ptr), int(Y.data.ptr) 식으로 디바이스 포인터를 넘김
- 스트림도 cp.cuda.get_current_stream().ptr 로 그대로 전달
- 고수준 헬퍼를 쓸 때 (conv_ops.forward, gemm_ops.forward)
- cupy 배열을 넘기면 내부에서 필요한 포인터를 꺼내 커널 호출 ( 편하지만 fast-path 최적화는 제한적 )
- 출력 버퍼 out=Y 로 지정해 추가 할당/복사 방지.
워크스페이스(ws) 주입
- Conv fast path(groups==1) 에서 미리 확보한 ws_fwd 를 포인터로 주입
- 이렇게 하면 런타임 동적 할당 없이 곧바로 커널을 쓸 수 있어 CUDA Graph 캡처 안정성 + 성능을 동시에 확보
Z alias ( = 중간활성 저장/혹은 act 전의 pre-activation)
- fast path 에서 Z 가 필요한 구조라면 Z 를 별도의 버퍼 대신 Y 에 alias 로 박음 (추가 메모리 제로)
실행 경로 : 일반 실행 vs CUDA Graph 실행
일반 실행 ( _run_impl )
- 입력을 아레나 입력 뷰에 써 넣고
- for node in ir.nodes 로 순회하며 연산 호출
- 최종 출력 텐서의 뷰를 리턴
CUDA Graph 캡처(compile(..., use_cuda_graph=True)
- 워밍업 1회로 모든 계획/WS 가 고정되게 함.
- 별도의 캡처 스트림에서 _run_imple(None) 을 수행하며 begin_capture ~ end_capture.
- 그래프 객체에 대해 3종 경로 지원
- Graph 객체가 upload/launch 메서드를 제공 : graph.upload(stream), 이후 graph.launch(stream)
- 모듈 함수 스타일 : cp.cuda.graph.upload(graph, stream) - cp.cuda.graph.launch(graph, stream)
- 런타임 포인터 경로 : graph.graph 또는 graph.ptr 추출 - cudaGraphInstantiate / graphUpload / graphLaunch 직접 호출 ( self._use_runtime_graph_api=True 로 분기 )
- run(x) 에서는
- 입력 뷰에 x 를 써 넣고
- 위 3종 경로 중 해당 방식으로 그래프 런치
- 스트림 sync 후 출력 뷰를 .copy() 해 반환
더보기
포인트 : 캡처 중에는 어떤 H2D/동적할당도 일어나면 안 됨,
아레나/WS 선할당, 파이썬 측 수학은 CPU 에서만, 스트림은 항상 같은걸 넘기는 게 중요
레이어 연결이 "주소 차원" 에서 어떻게 이어지나? (데이터 흐름 예시)
예: Conv2D → ReLU → Flatten → Dense
- Conv2D
- 입력: tensor#0 (buffer_id=0, offset=0)
- 출력: tensor#1 (buffer_id=1, offset=0) ← 새 버퍼
- 호출 시 _gconv.forward(X.ptr, W.ptr, Y.ptr, ...)로 직접 포인터 전달.
- ReLU (in-place alias)
- 입력: tensor#1
- 출력: tensor#2 (같은 buffer_id=1, 같은 offset) ← 같은 장소
- cp.maximum(X,0,out=Y)지만 X,Y가 같은 버퍼를 가리키므로 사실상 in-place.
- Flatten
- 출력: tensor#3는 shape만 (N, C*H*W)로 바뀐 같은 버퍼 뷰(여전히 buffer_id=1).
- 호출단에선 아무 것도 안 함(뷰 변경만으로 충분).
- Dense/GEMM
- 입력: tensor#3(buffer_id=1)
- 출력: tensor#4(buffer_id=2) ← 새 버퍼
- gemm_ops.forward(A, W, b, out=Y)로 출력 버퍼를 지정해 고정된 장소에 결과를 씀.
이렇게 버퍼 ID/offset이 실질적인 “주소계약”이 되고, 모든 노드는 그 계약에 따라 직접 포인터를 받아 자신의 커널을 실행해. 중간에 불필요한 할당/복사가 없다 보니 성능/캡처 안정성이 좋아짐.
각각의 독립 모듈과의 바인딩 관계
- Conv2D fast path: graph_executor_v2.ops._ops_conv2d (pybind 바인딩)
- 완전 저수준 경로: 포인터/shape/attrs/스트림/WS를 정수로 직통 전달
- C++/CUDA 런처(launcher.cu/cpp)가 그대로 받아 <<<grid,block,stream>>> 커널을 호출.
- Conv2D fallback / GEMM: graph_executor_v2.ops.conv2d, graph_executor_v2.ops.gemm
- cupy ndarray를 받아 내부에서 포인터 꺼내고 필요한 임시 텐서를 관리.
- out=를 주면 아레나 버퍼에 바로 써주도록 유도.
- ReLU/BN/Flatten:
- ReLU는 cupy elementwise(단순)로 out=Y 지정.
- BN은 간단한 학습모드 식으로 구현(cpu-side는 아님, cupy 연산으로 디바이스상에서 mean/var 계산).
- Flatten은 IR단에서 “뷰 변경”만 하므로 런타임 계산 없음.
스트림과 캡처 안정성
- 항상 cp.cuda.get_current_stream().ptr를 그대로 바인딩에 전달.
- 캡처 시점에는 “현재 스트림 == 캡처 스트림”이라 0(디폴트 스트림)로 던지면 안 됨.
- 코드에 주석도 있음: # ★ 현재 스트림 사용 (0 금지: 캡처 스트림과 분리 위험)
- 워밍업 한 번으로 모든 메모리(아레나/WS)와 경로가 고정 → 캡처에서 동적 이벤트 없음.
확장/유지보수 팁
- 새 연산 추가
- compile에서 레이어 → shape 추론 → TensorDesc/Node 생성 규칙만 추가.
- 가능한 한 out 버퍼를 미리 정해서 바인딩에 out=로 넘겨 추가 할당 금지.
- fast path가 있다면 ws_fwd를 노드에 매달아 포인터 주입.
- 메모리 최적화
- 지금은 “buffer_id=증가하는 새 버퍼” 기본 + 일부 in-place alias(ReLU/Flatten).
- 사용 끝난 텐서의 buffer_id를 재사용하는 라이프타임 기반 플래너를 넣으면 메모리 폭 감소.
- dtype 풀 분리(float16/bfloat16 등)도 확장 가능(현재는 float32 단일 아레나).
- 역전파(학습)
- 지금 코드는 v0 forward 중심.
- Node.ws_bwd에 bwd용 WS를 준비하고, Node에 grad_inputs, grad_outputs 정의 후
bwd패스에서 동일한 아레나 정책(buffer_id 재사용/alias)을 적용하면 됨. - CUDA Graph로 학습 루프까지 캡처하려면(optimizer step 포함) 모든 버퍼/워크스페이스/랜덤상태가
캡처 안정적으로 고정되도록 설계를 더 탄탄히 해야 함.
- 안전장치
- 캡처 중 H2D를 막기 위해 shape 계산/math.prod는 파이썬 CPU side에서만.
- 헬퍼 내부에서 갑자기 새로운 cupy 배열을 할당하지 않도록 out=...를 일관되게 제공.
- 스트림 전달은 항상 “현재 스트림 포인터”.
'dev_AI_framework' 카테고리의 다른 글
| backward 까지 capture 하려면 (0) | 2025.10.07 |
|---|---|
| 저수준 바인딩(.pyd) 와 파이썬 래퍼(헬퍼) - 헬퍼를 최소화하여 성능 향상을 기대해보자잇 (0) | 2025.10.07 |
| Graph Executor v2 — Forward 및 Training Graph 설계 문서 (0) | 2025.10.07 |
| GEMM(+bias+act)에서 Z(pre-activation) 저장/활용 설계 (0) | 2025.10.06 |
| Trainer 구현 필요 - (현재 forward, backword 의 파편 호출과 파이썬에서 opimizer 갱신하고 있음 이를 개선) (0) | 2025.10.04 |