adr: add ADR-0046-0049 — close G4 coverage gaps from /report
Documents four cross-cutting surfaces that previously had no ADR backing, each surfaced as a G4 candidate by /report: - 0046 prog-tl-context-contract: the kernel-side tl.* API. Enumerates all primitives (ref/load/store/dot/composite/math/reduction/IPCQ/...), the two execution modes (command-list vs greenlet runner), scratch allocator semantics, dispatch-overhead model, and the kernel registry. - 0047 par-ahbm-ccl-backend: torch.distributed.init_process_group (backend="ahbm") install path. world_size priority (algorithm > defaults > topology), the 4-step init sequence (load ccl.yaml, import algorithm module, derive world_size, install SFR + IPCQ), greenlet- local rank registry, all_reduce dispatch via _defer_wait, barrier no-op rationale, and the explicit list of unsupported dist.* APIs. - 0048 mem-allocator-algorithms: VirtualAllocator + PEMemAllocator free-list semantics. Offset-keyed first-fit with coalescing, the no-validation trust model for free(), HBM/TCM channel separation, page-aligned VA allocation, the page_size dual-default (VirtualAllocator 2 MiB / _ensure_allocators 4 KiB fallback), and one-allocator-per-sub-unit rule. - 0049 ver-probe-subcommand: kernbench probe traffic-pattern catalog. H2D / D2H / PE DMA categories with their exact cube-index choices, the 32 KiB reference size, the 5-point utilization sweep, the formula vs actual column meanings, automatic invariant checks (monotonicity, D2H >= H2D, best < worst), per-case GraphEngine isolation, and the human-readable (not machine-parsable) output contract. Bilingual pair verifier passes for all four EN/KO pairs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
# ADR-0046: TLContext — Kernel-side `tl.*` API Contract
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-05-22).
|
||||
|
||||
`src/kernbench/triton_emu/` 의 `TLContext` 가 노출하는 `tl.*` primitive
|
||||
집합과 그 의미, 그리고 두 실행 모드 (command-list / greenlet runner) 의
|
||||
계약을 명시한다. ADR-0014/0020 가 PE 파이프라인과 2-pass 실행 모델을
|
||||
정의하나, **bench 의 kernel 함수가 호출하는 `tl.*` 표면 자체**는 ADR-level
|
||||
에 정리되어 있지 않았다.
|
||||
|
||||
## First action (제일 처음에 하는 일)
|
||||
|
||||
`TLContext(pe_id, num_programs, dispatch_cycles, runner, cube_id, num_cubes,
|
||||
scratch_base, scratch_size)` 생성 시 가장 먼저 다음 6개 필드를 초기화한다:
|
||||
|
||||
- `self._pe_id`, `self._num_programs`, `self._cube_id`, `self._num_cubes` —
|
||||
`tl.program_id` / `tl.num_programs` 가 반환할 값.
|
||||
- `self._dispatch_cycles` — 모든 `tl.*` API 호출 시작에서 자동으로 발행될
|
||||
`PeCpuOverheadCmd(cycles)` 의 cycle 수.
|
||||
- `self._runner` — `KernelRunner` 인스턴스 (있으면 greenlet 모드, 없으면
|
||||
command-list 모드).
|
||||
- `self._commands: list[PeCommand] = []` — command-list 모드에서 누적할
|
||||
command 시퀀스.
|
||||
- `self._handle_counter = 0`, `self._completion_counter = 0` — 새 TensorHandle /
|
||||
CompletionHandle id 생성용.
|
||||
- `self._scratch_base`, `self._scratch_size`, `self._scratch_cursor = 0` —
|
||||
PE-로컬 scratch 영역 (math/dot/composite 의 output handle 주소 할당용).
|
||||
|
||||
즉, **TLContext 의 첫 일은 "이 kernel 인스턴스가 어디서 (sip/cube/pe) 어떤
|
||||
규모 (num_programs/num_cubes) 로 실행되며, 어느 모드 (runner 유무) 로
|
||||
명령을 발사할지 메타데이터를 채우는 것"** 이다. 이 시점에 SimPy event 는
|
||||
없으며 command 도 발사되지 않는다.
|
||||
|
||||
런타임 첫 동작은 kernel 함수가 `tl.<api>()` 를 처음 호출할 때 발생한다.
|
||||
모든 `tl.*` API 의 표준 entry 동작은:
|
||||
|
||||
1. `self._emit_dispatch_overhead()` 호출 — `dispatch_cycles > 0` 인 경우
|
||||
`PeCpuOverheadCmd(dispatch_cycles)` 를 즉시 `_emit`.
|
||||
2. API 별 처리 (TensorHandle 생성, command 구성).
|
||||
3. `self._emit(cmd)` — runner 모드면 greenlet.switch 로 SimPy 측에 cmd 전달,
|
||||
아니면 `self._commands` 에 append.
|
||||
|
||||
## Context
|
||||
|
||||
`tl.*` 표면은 `TLContext` 가 노출하는 메소드들로 구성되며, kernel 함수가
|
||||
받는 `tl` 매개변수가 이 객체다. 사용자(bench 작성자) 입장에서 보이는
|
||||
contract:
|
||||
|
||||
- 어떤 primitive 가 있는가
|
||||
- 각 primitive 가 어떤 데이터 흐름을 발생시키는가 (DMA / compute / IPCQ /
|
||||
metadata-only)
|
||||
- TensorHandle 의 `space` 와 `addr` 가 어떻게 결정되는가
|
||||
- command-list 모드와 greenlet 모드의 차이
|
||||
|
||||
ADR-0014 (PE pipeline) 가 PE_SCHEDULER 가 받는 PeCommand 들을 정의하나,
|
||||
`tl.*` 가 이들을 어떻게 emit 하는지는 코드 컨벤션에만 존재한다. 또한
|
||||
ADR-0020 (2-pass data execution) 가 greenlet 모드의 존재를 D3 에서
|
||||
언급하나, runner / non-runner 두 경로의 시그너처 차이 (return value 처리)
|
||||
는 ADR-level 에 명시되어 있지 않다. 이 ADR 이 그 빈자리를 채운다.
|
||||
|
||||
## Decision
|
||||
|
||||
### D1. `tl` 매개변수는 `TLContext` 인스턴스다
|
||||
|
||||
bench 의 kernel 함수는 다음 시그너처를 따른다:
|
||||
|
||||
```python
|
||||
def _kernel(arg1, arg2, ..., tl, **kwargs):
|
||||
...
|
||||
```
|
||||
|
||||
`tl` 의 정체는 `kernbench.triton_emu.tl_context.TLContext` 인스턴스이다.
|
||||
real Triton 의 `triton.language` 모듈을 흉내내기 위한 이름이며, real
|
||||
Triton 모듈이 들어오는 것은 아니다.
|
||||
|
||||
kernel 함수는 일반 Python 함수이며 `yield` / `async` 가 없다. `tl.*`
|
||||
호출이 SimPy event 를 발생시키지만, 호출자(kernel) 쪽에서는 동기 호출처럼
|
||||
보인다 — greenlet 모드에서 KernelRunner 가 SimPy ↔ kernel 사이를 중계
|
||||
하기 때문 (ADR-0020 D3).
|
||||
|
||||
### D2. 두 실행 모드 — command-list / greenlet runner
|
||||
|
||||
- **command-list 모드 (`runner is None`)**: `tl.*` 호출이 `self._commands`
|
||||
리스트에 PeCommand 를 누적. DMA / GEMM / Math 가 실제 SimPy 시간을
|
||||
소비하지 않으며, return value 가 metadata-only TensorHandle (data=None) 다.
|
||||
이후 PE_SCHEDULER / sim_engine 가 command 시퀀스를 시간상 재생.
|
||||
|
||||
- **greenlet runner 모드 (`runner is not None`)**: `tl.*` 호출이
|
||||
`self._emit(cmd)` 를 통해 `runner.switch_to_simpy(cmd)` 로 부모 greenlet
|
||||
(SimPy) 으로 컨트롤을 넘김. 부모는 cmd 를 컴포넌트에 분배하여 SimPy 시간을
|
||||
소비한 뒤, DMA read 의 경우 실제 numpy 데이터를 반환. kernel 은 그
|
||||
결과를 받아 다음 line 으로 진행 (ADR-0020 D3 의 데이터 인지 실행 모델).
|
||||
|
||||
mode 선택은 KernelRunner 인스턴스를 TLContext 에 주입하는지 여부로 결정
|
||||
되며, `tl.*` 메소드들은 이 차이를 인지하지 않고 `_emit()` 헬퍼를 통해
|
||||
일관되게 동작한다.
|
||||
|
||||
### D3. Primitive 카테고리
|
||||
|
||||
#### D3.1. Reference (no DMA, metadata only)
|
||||
|
||||
- `tl.ref(ptr, shape, dtype="f16") -> TensorHandle`: HBM 데이터를 참조하는
|
||||
핸들만 만들고 DMA 는 발행하지 않음. composite scheduler 가 per-tile 로
|
||||
스트리밍할 때 사용 (예: GEMM 의 b 피연산자).
|
||||
|
||||
#### D3.2. Data movement (blocking, DMA engine)
|
||||
|
||||
- `tl.load(ptr, shape, dtype="f16") -> TensorHandle`: HBM → 결과 핸들.
|
||||
`DmaReadCmd` 발행. greenlet 모드에서는 결과 핸들의 `.data` 에 실제
|
||||
numpy 배열 첨부; command-list 모드에서는 placeholder. 반환 핸들의
|
||||
`space="hbm"`, `pinned=True`.
|
||||
- `tl.store(ptr, handle) -> None`: TCM → HBM. `DmaWriteCmd` 발행. greenlet
|
||||
모드에서는 `handle.data` 가 있을 때만 `_store.write("hbm", ptr, data)` 를
|
||||
먼저 호출 (visibility = issue time, ADR-0020 D3).
|
||||
|
||||
#### D3.3. GEMM / compute (blocking)
|
||||
|
||||
- `tl.dot(a, b) -> TensorHandle`: `a @ b`. 두 피연산자는 TCM 이어야 하며,
|
||||
shape (M,K) × (K,N) → (M,N). `GemmCmd` 발행, output handle 은
|
||||
`_make_compute_out(shape, dtype)` 로 PE-로컬 scratch 에 할당.
|
||||
- `tl.composite(op, a, b=None, out_ptr=0, math_op=None, epilogue=None,
|
||||
acc_dtype=None, tile_shape=None) -> CompletionHandle`: 비차단(non-blocking)
|
||||
tiled pipeline. `CompositeCmd` 발행. `epilogue` 는 dict list, 각 dict 는
|
||||
`"op"` 키 + op-specific 필드 + 옵션 `"scope"` (k_tile / output_tile);
|
||||
unknown op 나 missing field 는 즉시 ValueError. 반환된 CompletionHandle 은
|
||||
`tl.wait(h)` 로 동기화.
|
||||
|
||||
#### D3.4. Math: unary (blocking)
|
||||
|
||||
- `tl.exp(x)`, `tl.log(x)`, `tl.sqrt(x)`, `tl.abs(x)`, `tl.sigmoid(x)`,
|
||||
`tl.cos(x)`, `tl.sin(x)` — 모두 `MathCmd(op=<name>, inputs=(x,), out=)`
|
||||
발행. `out` 은 동일 shape/dtype 의 scratch 할당.
|
||||
|
||||
#### D3.5. Math: binary (blocking)
|
||||
|
||||
- `tl.maximum(a, b)`, `tl.minimum(a, b)` — `_binary_math`.
|
||||
- `tl.fma(a, b, c)` — `a*b + c`. inputs 3개.
|
||||
- `tl.clamp(x, min, max)` — `MathCmd(op="clamp", inputs=(x, min, max))`.
|
||||
- `tl.where(cond, a, b)` — `MathCmd(op="where", inputs=(cond, a, b))`.
|
||||
- `tl.softmax(x, axis=-1)` — 단일 MathCmd(op="softmax") 로 시간 회계는
|
||||
한 번에. Phase 2 DataExecutor 가 canonical (x-max → exp → sum → div) 로
|
||||
expand 한다.
|
||||
|
||||
#### D3.6. Reduction (blocking)
|
||||
|
||||
- `tl.sum(x, axis)`, `tl.max(x, axis)`, `tl.min(x, axis)` — 해당 axis 의
|
||||
크기를 1 로 줄인 output handle 을 반환. `MathCmd(op=<name>, inputs=(x,),
|
||||
out=, axis=axis)` 발행.
|
||||
|
||||
#### D3.7. Index / scalar (PE_CPU, no engine)
|
||||
|
||||
- `tl.program_id(axis=0) -> int`: `axis==0` → pe_id (cube-local PE 인덱스),
|
||||
`axis==1` → cube_id (ADR-0022).
|
||||
- `tl.num_programs(axis=0) -> int`: `axis==0` → num_programs (cube 당
|
||||
PE 수), `axis==1` → num_cubes.
|
||||
- `tl.arange(start, end, dtype="i32") -> TensorHandle`: TCM 의 인덱스
|
||||
range. command 발사 없이 metadata 만.
|
||||
- `tl.zeros(shape, dtype="f16") -> TensorHandle`, `tl.full(shape, value,
|
||||
dtype="f16") -> TensorHandle`: TCM 에 placeholder. command 발사 없음.
|
||||
|
||||
#### D3.8. Scalar helpers (no command, no engine)
|
||||
|
||||
- `TLContext.cdiv(a, b) -> int` (static): ceiling division
|
||||
`-(-a // b)`. real Triton 의 `tl.cdiv` 모방.
|
||||
|
||||
#### D3.9. Metadata-only (no compute, no DMA)
|
||||
|
||||
- `tl.trans(x) -> TensorHandle`: shape 의 마지막 두 dim 을 swap 한 새
|
||||
핸들. 같은 addr/data 를 공유, command 발사 없음.
|
||||
|
||||
#### D3.10. IPCQ (CCL) primitives (ADR-0023 D4)
|
||||
|
||||
- `tl.send(dir, src=None, *, src_addr=None, nbytes=None, shape=None,
|
||||
dtype="f16", space="tcm") -> None`: blocking send. handle 형태 또는
|
||||
raw 주소 형태 둘 다 허용. `IpcqSendCmd` 발행. handle 의 `.data` 스냅샷이
|
||||
명령에 실리는 경우, recv 측에서 받은 데이터의 race 회피.
|
||||
- `tl.recv(dir=None, shape=(), dtype="f16", space="tcm", dst_addr=None,
|
||||
dst_space=None) -> TensorHandle`: blocking recv. `dst_addr/dst_space`
|
||||
둘 다 주면 "copy_to_dst" 모드, 아니면 "return_slot" 모드. greenlet
|
||||
모드에서 핸들의 `.data` 에 실제 데이터 첨부.
|
||||
- `tl.recv_no_consume(dir=None, shape=(), dtype="f16") -> TensorHandle`:
|
||||
**DIAGNOSTIC ONLY**. recv blocking 동기화는 그대로 적용되나 slot-read
|
||||
latency (slot-IO + PE↔bank fabric drain) 는 건너뛴다. pe2pe overview
|
||||
플롯에서 `tl.store` 와의 apples-to-apples 비교용. production kernel 은
|
||||
사용 금지 — `consume=False` 라는 별도 명령 분기로 격리되어 있어 실수
|
||||
flag 가 작동하지 않는다.
|
||||
- `tl.recv_async(dir, shape=(), dtype="f16") -> RecvFuture`: non-blocking
|
||||
recv. `RecvFuture` 를 반환; 이후 `tl.wait(future)` 로 결과 수령.
|
||||
|
||||
#### D3.11. Composite + control
|
||||
|
||||
- `tl.composite(...)`: D3.3 에서 설명.
|
||||
- `tl.wait(handle=None)`: `CompletionHandle` (composite) 또는 `RecvFuture`
|
||||
(async recv) 또는 `None` (모든 pending composite) 대기.
|
||||
- `tl.cycles(n)`: PE_CPU scalar 실행 overhead 를 명시적으로 선언.
|
||||
`PeCpuOverheadCmd(cycles=n)` 발행.
|
||||
|
||||
### D4. TensorHandle 산술 연산자 — thread-local TLContext
|
||||
|
||||
`tl_context.py` 모듈 로드 시점에 `_enable_tensor_ops()` 가 호출되어
|
||||
`TensorHandle.__add__`, `__sub__`, `__mul__`, `__truediv__` 를 patch한다.
|
||||
각 연산자는 thread-local `_ctx` (모듈 변수) 에 저장된 active TLContext 의
|
||||
`_binary_math` 를 호출한다.
|
||||
|
||||
따라서 kernel 안에서 `c = a + b` 는 `MathCmd(op="add", inputs=(a,b),
|
||||
out=)` 발행 + new TensorHandle 반환 패턴과 동일하다.
|
||||
|
||||
active TLContext 관리:
|
||||
|
||||
- `TLContext._set_active(ctx)`: 현재 thread/greenlet 의 active ctx 설정.
|
||||
- `TLContext._get_active()`: 조회 (없으면 RuntimeError).
|
||||
- `run_kernel(kernel_fn, tl_ctx, *args, **kwargs)`: helper. 진입 시
|
||||
active 설정, kernel 실행, 종료 시 None 으로 복원.
|
||||
|
||||
`KernelRunner` 는 매 cmd 분배 시 `_switch_kernel` 가 직접 `_set_active(tl)`
|
||||
를 호출하여, 같은 thread 안의 다른 PE runner 가 active 를 덮어쓴 경우에도
|
||||
복원되도록 한다.
|
||||
|
||||
### D5. Scratch allocator — compute output handles
|
||||
|
||||
`tl.dot`, `tl.exp`, `tl.add` (TensorHandle `__add__`) 등 결과를 만드는 op 는
|
||||
`_make_compute_out(shape, dtype)` 를 호출하여 16-byte aligned scratch
|
||||
주소를 할당한다. 이 주소는 `space="tcm"` 로 발행되며, 이후 `tl.send` /
|
||||
`tl.store` 가 이 handle 을 source 로 사용할 수 있다.
|
||||
|
||||
`_scratch_base == 0` (command-list 모드 등) 이면 할당 주소가 0으로
|
||||
반환되어 handle 은 send/store 의 source 로 사용 불가 (이 경우 `tl.load`
|
||||
로 받은 핸들만 source 가 될 수 있다).
|
||||
|
||||
cursor 가 `_scratch_size` (default 1 MiB) 를 초과하면 RuntimeError.
|
||||
cursor 는 매 kernel invocation 시작 시 0 으로 리셋되어야 하나 (현재 코드는
|
||||
KernelRunner 가 새 TLContext 를 매번 생성하여 자연스럽게 리셋됨).
|
||||
|
||||
### D6. Dispatch overhead — `PeCpuOverheadCmd(dispatch_cycles)`
|
||||
|
||||
모든 non-metadata `tl.*` 호출의 entry 에서 `_emit_dispatch_overhead()` 가
|
||||
호출되며 `dispatch_cycles > 0` 일 때 `PeCpuOverheadCmd(dispatch_cycles)`
|
||||
를 발행한다. PE_CPU 가 명령 dispatch 자체에 소비하는 cycle 비용을
|
||||
모델링하기 위함이다.
|
||||
|
||||
기본값:
|
||||
|
||||
- `TLContext.__init__` 의 `dispatch_cycles` 매개변수 기본값: 1 cycle.
|
||||
- `KernelRunner` 가 만드는 TLContext: 0 cycles (greenlet 모드는 cycle
|
||||
회계가 별도, ADR-0020 D3 정신).
|
||||
|
||||
### D7. Kernel registry (`triton_emu/registry.py`)
|
||||
|
||||
별도의 `_kernels: dict[str, Callable]` 가 kernel 이름 → 함수 매핑을 보유:
|
||||
|
||||
- `register_kernel(name, fn)`: duplicate 등록 시 ValueError.
|
||||
- `get_kernel(name)`: 미등록 시 KeyError.
|
||||
- `clear_registry()`: 테스트 전용.
|
||||
|
||||
`RuntimeContext.launch(kernel_name, kernel_fn, *args)` 가 매 호출마다
|
||||
`_kernels[kernel_name] = kernel_fn` 으로 idempotent 덮어쓴다 (last call
|
||||
wins). 이는 ADR-0045 D8 의 launch 동작과 정합된다.
|
||||
|
||||
PE_CPU 는 `KernelRef.name` 으로 registry 에서 kernel 함수를 lookup 한 뒤
|
||||
KernelRunner 로 실행한다.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### A1. tl.* 를 ADR-0014 / ADR-0020 안으로 통합
|
||||
|
||||
기각. ADR-0014 는 PE pipeline (PeCommand 의 sim_engine 측 소비) 를, ADR-0020
|
||||
은 2-pass 실행 (Phase 1 timing / Phase 2 data) 을 다룬다. `tl.*` 는 kernel
|
||||
작성자가 만나는 API 표면이라 독립 분리하는 것이 검색성·온보딩 측면에서
|
||||
낫다.
|
||||
|
||||
### A2. command-list 모드 deprecation
|
||||
|
||||
기각 (현재). 단순한 unit test 와 kernel verification 에서 command-list
|
||||
모드가 가볍게 동작한다. greenlet 의존성 없이 PeCommand 시퀀스를 검사할 수
|
||||
있는 출입구로 유지한다. greenlet 모드만의 의미 (실데이터, Phase 2) 가
|
||||
필요하면 D2 의 mode 선택으로 명시적으로 들어간다.
|
||||
|
||||
### A3. TensorHandle 산술 연산자 제거
|
||||
|
||||
기각. real Triton 의 kernel 코드 가독성을 흉내내기 위함이며 (예: `c = a +
|
||||
b`), thread-local active ctx 패턴이 깔끔하게 작동 중. 명시적 `tl.add(a, b)`
|
||||
도 D3.5 에 노출되어 있어, 연산자가 헷갈리면 함수형 호출로 대체 가능.
|
||||
|
||||
### A4. softmax 를 명시적 시퀀스 (max → exp → sum → div) 로 expand
|
||||
|
||||
부분 채택. `tl.softmax` 는 단일 `MathCmd(op="softmax")` 로 timing 회계는
|
||||
한 번에 처리한다 (D3.5). 실 데이터 expansion 은 Phase 2 DataExecutor 가
|
||||
canonical 시퀀스로 풀어준다. 즉, 시간 모델은 atomic, 데이터 모델은
|
||||
expansion — 두 마리 토끼를 의도적으로 분리.
|
||||
|
||||
## Consequences
|
||||
|
||||
- bench 작성자가 만나는 모든 `tl.*` primitive 가 한 ADR 에 분류·정의됨.
|
||||
ADR-0045 D8 의 host-side surface (torch.empty 등) 와 짝을 이루어 "kernel
|
||||
안 / 밖" 양쪽 작성 가이드가 완성.
|
||||
- command-list / greenlet 두 모드의 차이가 D2 에 명시되어, 새로운 `tl.*`
|
||||
primitive 추가 시 `_emit()` 패턴만 따르면 양쪽 자동 호환됨.
|
||||
- thread-local active ctx 패턴 (D4) 이 ADR-level 에서 정당화되어, 향후
|
||||
multi-PE 동일-thread 실행 시 reset 책임이 어디인지 명확해짐
|
||||
(`_switch_kernel` 가 cmd 분배 시 active 복원 — KernelRunner.run 의
|
||||
contract).
|
||||
- `tl.recv_no_consume` 의 진단 전용 격리(D3.10) 가 ADR 에 굳어져, 실수로
|
||||
production kernel 에서 사용되는 것을 막는 layer 가 명확.
|
||||
- registry (D7) 가 별도 D 항목으로 분리되어, kernel 이름 충돌 / 동적
|
||||
재등록 동작의 사양이 명시.
|
||||
@@ -0,0 +1,243 @@
|
||||
# ADR-0047: AHBM CCL Backend — `torch.distributed`-compat shim
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-05-22).
|
||||
|
||||
`runtime_api/distributed.py` 의 `AhbmCCLBackend` + `DistributedContext` —
|
||||
즉 `torch.distributed.init_process_group(backend="ahbm")` 진입점이 실제로
|
||||
무엇을 설치하고 어떤 의미로 `all_reduce`/`barrier`/`get_rank` 등을
|
||||
구현하는지를 명시한다. ADR-0023 D11 이 "torch.distributed compatibility"
|
||||
의도를 언급하나, **backend 자체의 동작 모델**은 ADR-level 에 없었다.
|
||||
|
||||
## First action (제일 처음에 하는 일)
|
||||
|
||||
`RuntimeContext.__post_init__` 가 자동으로 `DistributedContext()` 인스턴스를
|
||||
만들어 `self.distributed` 에 attach 한다. 그 시점의 첫 일은:
|
||||
|
||||
1. `self._backend: AhbmCCLBackend | None = None` 으로 초기화 (아직 init
|
||||
되지 않은 상태).
|
||||
2. `self._rank_by_greenlet: dict = {}` 로 greenlet-local rank 레지스트리
|
||||
초기화 (ADR-0024 D2).
|
||||
3. 호출자(RuntimeContext) 측에서 `dc._ctx_ref = self` 로 back-reference 를
|
||||
심어, 이후 `init_process_group` 가 `ctx.engine` / `ctx.spec` / `ctx.launch`
|
||||
에 도달할 수 있게 한다.
|
||||
|
||||
즉, **DistributedContext 의 첫 일은 "RuntimeContext 에 자기 자신을
|
||||
back-reference 와 함께 부착하고 backend 슬롯을 비워두는 것"**. 실제 backend
|
||||
설치(IPCQ install, world_size 산출, 알고리즘 모듈 로드)는 사용자 코드의
|
||||
`torch.distributed.init_process_group(backend="ahbm")` 호출 시점에 비로소
|
||||
일어난다.
|
||||
|
||||
해당 시점의 `init_process_group` 의 첫 일은:
|
||||
|
||||
1. `backend != "ahbm"` 이면 즉시 `ValueError("Unsupported backend ...")`.
|
||||
2. `getattr(self, "_ctx_ref", None)` 가 None 이면
|
||||
`RuntimeError("DistributedContext not bound to a RuntimeContext")`.
|
||||
3. `self._backend = AhbmCCLBackend(torch_ctx=ctx)` — 이 생성자 안에서
|
||||
ccl.yaml load + 알고리즘 모듈 import + world_size 산출 + SFR 설정 +
|
||||
IPCQ install 이 모두 일어난다.
|
||||
4. `self._backend._dist_ctx = self` — backend 가 거꾸로
|
||||
`_rank_by_greenlet` 에 접근할 수 있게 함.
|
||||
|
||||
## Context
|
||||
|
||||
PyTorch DDP 의 collective 호출 (`init_process_group`, `all_reduce` 등) 을
|
||||
그대로 사용할 수 있게 만들어, bench 코드가 "진짜 DDP training script" 와
|
||||
동일한 모습이 되도록 하는 것이 `AhbmCCLBackend` 의 목적이다 (ADR-0024 +
|
||||
ADR-0027 의 launcher 모델과 정렬).
|
||||
|
||||
이 backend 가 책임지는 것:
|
||||
|
||||
- `init_process_group` 시점에 **IPCQ neighbor table 을 한 번 설치** (real
|
||||
NCCL communicator creation 과 유사).
|
||||
- `all_reduce(tensor, op="sum")` 호출 시 **설정된 algorithm 의 kernel 함수
|
||||
를 `ctx.launch(...)` 로 발사**.
|
||||
- `get_world_size` / `get_rank` 를 greenlet-local rank 레지스트리와
|
||||
ccl.yaml/topology 로부터 일관되게 답함.
|
||||
|
||||
ADR-0023 D10 (IPCQ install plan), ADR-0024 (SIP launcher) 가 부분적으로
|
||||
이를 다루나, **`AhbmCCLBackend` 자체의 책임 범위와 의사결정 순서**는
|
||||
어디에도 명시되어 있지 않다. 본 ADR 이 채운다.
|
||||
|
||||
## Decision
|
||||
|
||||
### D1. backend 는 `init_process_group(backend="ahbm")` 시점에만 생성된다
|
||||
|
||||
`DistributedContext` 는 `__init__` 시점에 `_backend = None` 으로 시작한다.
|
||||
backend 객체는 사용자가 `dist.init_process_group(backend="ahbm")` 를
|
||||
호출하기 전까지 존재하지 않으며, 그 외 API (`is_initialized`,
|
||||
`get_world_size`, `all_reduce`, `barrier`) 가 backend 가 None 인 채로
|
||||
호출되면 `RuntimeError("Default process group has not been initialized...")`
|
||||
를 던진다 (`_ensure_initialized` 헬퍼).
|
||||
|
||||
`backend != "ahbm"` 은 즉시 `ValueError`. 다른 backend 명 (nccl, gloo
|
||||
등) 은 인식하지 않는다.
|
||||
|
||||
### D2. world_size 산출 우선순위 — algorithm > defaults > topology
|
||||
|
||||
`AhbmCCLBackend._resolve_world_size` (ADR-0024 D1) 의 결정 순서:
|
||||
|
||||
1. `ccl.yaml` 의 algorithm entry 에 `world_size` 가 있으면 그 값.
|
||||
2. `defaults.world_size` 가 있으면 그 값.
|
||||
3. 둘 다 없으면 `spec.system.sips.count` (=topology 의 SIP 개수).
|
||||
|
||||
기본 의미는 **rank = SIP** (ADR-0024). cube/PE-level parallelism 은 각
|
||||
rank 안에서 DPPolicy 로 표현되며 world_size 에 영향을 주지 않는다. 명시적
|
||||
`ccl.yaml` 의 world_size override 가 있으면 legacy "rank = flat PE 인덱스"
|
||||
테스트 경로를 위해 그대로 존중된다.
|
||||
|
||||
`init_process_group(world_size=..., rank=...)` 의 사용자 인자는 **수신하나
|
||||
무시**된다 (real PyTorch 의 `RANK` / `WORLD_SIZE` env var 와 같은 의미).
|
||||
|
||||
### D3. `init_process_group` 가 즉시 하는 4가지 설치 작업
|
||||
|
||||
`AhbmCCLBackend.__init__` 안에서 다음이 순차 실행된다:
|
||||
|
||||
1. **ccl.yaml 로딩**: `kernbench.ccl.install.load_ccl_config()` →
|
||||
`resolve_algorithm_config(_cfg_all)` 로 `defaults.algorithm` (또는
|
||||
사용자가 지정한 알고리즘) 의 merged config 산출.
|
||||
2. **알고리즘 모듈 import**: `importlib.import_module(self._merged["module"])`.
|
||||
이 모듈은 `kernel` 함수, `kernel_args(world_size, n_elem, cube_w, cube_h)`
|
||||
helper, optional `TOPO_NAME_TO_KIND` 매핑을 노출해야 한다.
|
||||
3. **world_size 산출** (D2).
|
||||
4. **topology 메타 수집**: `spec` 으로부터 `n_sips`, `sip_topo` (`ring_1d`
|
||||
기본), `cube_w`/`cube_h`, `sips.w`/`sips.h`. SIP topology 가 ring_1d 가
|
||||
아니면 explicit `w`/`h` 또는 square root 로 (`w*h == n_sips` 보장)
|
||||
`_sip_topo_w/h` 산출. 불일치 시 `ValueError`.
|
||||
5. **SFR + IPCQ 설치**: `kernbench.ccl.sfr_config.configure_sfr_intercube_multisip
|
||||
(engine, spec, self._merged)` 를 호출. 이 함수가 모든 SIP/cube 의 pe0 에
|
||||
IPCQ neighbor table 을 푸시 (real NCCL communicator 의 일회성 설정에
|
||||
해당).
|
||||
|
||||
이 순서가 변하면 (예: SFR 전에 algorithm 모듈 load 가 실패하면) 부분 초기화
|
||||
상태가 발생할 수 있다. 따라서 D3 는 atomic 한 4-단계로 본다 — 실패 시
|
||||
backend 는 미설치 상태로 남는다.
|
||||
|
||||
### D4. greenlet-local rank 등록 (ADR-0024 D2)
|
||||
|
||||
`DistributedContext._rank_by_greenlet: dict[greenlet, int]` 은 spawn 된
|
||||
worker greenlet 각각에 rank 를 매핑한다. bench launcher (예:
|
||||
`torch.multiprocessing.spawn`) 가 worker 를 띄울 때
|
||||
`dc._bind_rank(g, rank)` 를 호출하여 등록한다.
|
||||
|
||||
`get_rank()` 는 `getcurrent()` 의 greenlet 을 lookup. 미등록 greenlet은
|
||||
fallback 으로 0 을 반환 — single-driver / 테스트 호환성 유지.
|
||||
|
||||
backend 는 `_dist_ctx._rank_by_greenlet` 를 통해 `all_reduce` 시 현재
|
||||
greenlet 의 rank 를 가져온다 (D5).
|
||||
|
||||
### D5. `all_reduce(tensor, op="sum")` 동작
|
||||
|
||||
검증 단계:
|
||||
|
||||
- `op != "sum"` → `NotImplementedError`. 현재 kernel 들은 add reduction만 구현.
|
||||
- `tensor._handle is None` → `RuntimeError("not deployed")`.
|
||||
- `tensor._handle.shards` 가 비면 `RuntimeError("no shards")`.
|
||||
|
||||
준비 단계:
|
||||
|
||||
- `n_elem = shards[0].nbytes // tensor.itemsize` — 단일 shard 의 element 수.
|
||||
- `kernel_fn = self._algo_module.kernel` — D3 에서 import 된 알고리즘 모듈의
|
||||
진입 함수.
|
||||
- effective cube dims 결정: 첫 번째 SIP 의 cube 갯수가 1 이면 (1,1) 으로
|
||||
scalar 처리, 아니면 토폴로지의 `cube_w`/`cube_h` 사용. TP 가 일부 cube
|
||||
만 쓰는 경우를 자연스럽게 흡수.
|
||||
- `kernel_args = self._algo_module.kernel_args(world_size, n_elem, cube_w,
|
||||
cube_h)` — 알고리즘이 자기 kernel 에 넘길 인자 셋을 결정.
|
||||
|
||||
dispatch:
|
||||
|
||||
- 현재 greenlet 의 rank 를 `_rank_by_greenlet.get(g, 0)` 로 lookup.
|
||||
- `extra_args = (sip_rank, sip_topo_kind, sip_topo_w, sip_topo_h)` 를 append.
|
||||
- `pending = self.ctx.launch(algorithm_name, kernel_fn, tensor, *kernel_args,
|
||||
*extra_args, _defer_wait=True)` — `_defer_wait=True` 로 collective drain
|
||||
을 메인 scheduler 에 위임 (ADR-0027 D0.4).
|
||||
|
||||
drain:
|
||||
|
||||
- 부모 greenlet 이 살아있으면 (multi-greenlet 모드) `_pending_collective_handles`
|
||||
에 enqueue 한 뒤 부모로 switch. 메인 scheduler 가 모든 rank 의 launch 후
|
||||
일괄 drain.
|
||||
- 단일-driver 모드면 inline 으로 `for h, _sip_id, meta in pending:
|
||||
self.ctx.wait(h, _meta=meta)` 즉시 drain.
|
||||
|
||||
### D6. `barrier()` 는 no-op 이다 (single-driver 모델)
|
||||
|
||||
kernbench 는 하나의 Python process 안에서 모든 rank 를 greenlet 으로 다룬다.
|
||||
process 간 동기화가 필요한 상황이 없으므로 `barrier()` 는 호출 가능하지만
|
||||
실제 어떤 동기화도 수행하지 않는다. real PyTorch DDP 와의 API 호환성을
|
||||
위해 유지 (호출자가 NotImplementedError 를 받지 않도록).
|
||||
|
||||
장래에 multi-process kernbench (예: SimPy event loop 가 process 별로
|
||||
독립) 가 도입되면 D6 를 supersede 하는 새 ADR 이 필요.
|
||||
|
||||
### D7. `get_rank` / `get_world_size` / `get_backend` 의 의미
|
||||
|
||||
- `get_rank()` (D4): 현재 greenlet 의 bound rank. 미등록은 0.
|
||||
- `get_world_size()` (D2): backend 가 D3 에서 산출한 world_size.
|
||||
- `get_backend()`: 항상 `"ahbm"` 문자열. backend 객체가 존재하지 않으면
|
||||
`_ensure_initialized` 에서 RuntimeError.
|
||||
|
||||
real PyTorch 와의 차이:
|
||||
|
||||
- real PyTorch `get_rank()` 는 process global 값이지만, kernbench 는
|
||||
greenlet-local. spawn 된 worker 안에서 호출하면 rank, main thread 에서
|
||||
호출하면 0. bench 작성자는 worker 함수 안에서만 의미 있는 rank 를 기대해야
|
||||
한다.
|
||||
|
||||
### D8. 지원하는 API 표면 (final)
|
||||
|
||||
`DistributedContext` 가 노출하는 API:
|
||||
|
||||
- `init_process_group(backend="ahbm", world_size=None, rank=None, **kwargs)`
|
||||
- `is_initialized() -> bool`
|
||||
- `get_world_size() -> int`
|
||||
- `get_rank() -> int`
|
||||
- `get_backend() -> str`
|
||||
- `all_reduce(tensor, op="sum") -> None`
|
||||
- `barrier() -> None`
|
||||
- (internal) `_bind_rank(g, rank)`
|
||||
|
||||
이외의 PyTorch distributed API (broadcast, reduce, all_gather, gather,
|
||||
scatter, send/recv 등) 는 **아직 구현되어 있지 않다**. kernel 레벨에서는
|
||||
`tl.send`/`tl.recv` (ADR-0046 D3.10) 로 직접 표현 가능하나, dist.* surface
|
||||
로는 노출되지 않는다. 추가 collective 가 필요해질 시 별도 알고리즘 모듈
|
||||
+ `DistributedContext` 메소드 한 쌍을 추가하여 D8 를 확장한다.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### A1. backend 를 `RuntimeContext.__init__` 에서 즉시 생성
|
||||
|
||||
기각. ccl.yaml 이 없거나 알고리즘 모듈을 import 할 수 없는 경우, bench 가
|
||||
distributed 기능을 안 쓰는데도 RuntimeContext 생성 자체가 실패하게 된다.
|
||||
"호출 시점에 비로소 설치" (D1) 가 lazy 의미상 옳다.
|
||||
|
||||
### A2. world_size 를 항상 topology 로부터 자동 산출 (override 금지)
|
||||
|
||||
기각. ADR-0024 D1 의 "explicit override" 경로가 legacy 테스트에서 사용 중.
|
||||
한 SIP 안에서 PE-level rank 를 따로 정의해야 하는 진단 시나리오를 위해
|
||||
유지.
|
||||
|
||||
### A3. `op != "sum"` 을 silent fallback 으로 처리
|
||||
|
||||
기각. 사용자가 `op="prod"` / `"max"` / `"avg"` 를 의도했는데 silently sum
|
||||
이 실행되면 결과 검증이 매우 어렵다. 명시적 `NotImplementedError` 가 안전.
|
||||
|
||||
### A4. `barrier` 를 SimPy event 로 구현
|
||||
|
||||
기각 (현재). single-driver 모델에서 cross-process 동기화 의미가 없으므로
|
||||
no-op 가 의미적으로 정확. SimPy fake-barrier 는 의미 없이 코드 복잡도만
|
||||
높임. multi-process kernbench 도입 시 재평가.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `torch.distributed.init_process_group(backend="ahbm")` 의 4-단계 설치
|
||||
(D3) 가 ADR-level 에서 굳어져, 향후 새 collective 알고리즘이 어디에
|
||||
훅을 걸어야 하는지 명확.
|
||||
- D2 의 우선순위 (algorithm > defaults > topology) 가 명시되어, ccl.yaml
|
||||
변경 시 영향 범위를 빠르게 가늠 가능.
|
||||
- D6 의 barrier no-op 결정이 ADR-level 에 굳어져, multi-process kernbench
|
||||
도입 시 별도 ADR 로 supersede 해야 함이 분명.
|
||||
- D8 의 미지원 API 목록이 명시되어, 사용자가 `dist.broadcast(...)` 를
|
||||
호출하려 할 때의 명확한 거절 근거 제공.
|
||||
@@ -0,0 +1,262 @@
|
||||
# ADR-0048: Memory Allocator Algorithms — VirtualAllocator + PEMemAllocator
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-05-22).
|
||||
|
||||
`policy/address/allocator.py` 의 `_FreeList` / `PEMemAllocator` 와
|
||||
`va_allocator.py` 의 `VirtualAllocator` 가 사용하는 free-list 알고리즘,
|
||||
페이지 정렬, coalescing 규칙을 명시한다. ADR-0001 (PhysAddr 레이아웃) 과
|
||||
ADR-0011 (PA/VA/LA 모델) 이 주소 스킴을 정의하나, **할당 알고리즘**은 별도
|
||||
ADR 이 없었다.
|
||||
|
||||
## First action (제일 처음에 하는 일)
|
||||
|
||||
### `_FreeList(capacity)`
|
||||
|
||||
생성 즉시 `self._capacity = capacity`, `self._used = 0`, `self._free =
|
||||
[(0, capacity)]` 로 초기화. 첫 일은 **전 영역을 single free block 으로
|
||||
세우는 것** — 즉 `(offset=0, size=capacity)` 한 튜플이 free list 의 유일한
|
||||
원소다.
|
||||
|
||||
### `PEMemAllocator(sip_id, die_id, pe_id, cfg)`
|
||||
|
||||
생성 즉시 두 개의 `_FreeList` 를 만든다:
|
||||
|
||||
- `self._hbm = _FreeList(cfg.hbm_slice_bytes)` — 이 PE 가 소유한 HBM
|
||||
slice 의 바이트 크기 (`hbm_bytes_per_cube // hbm_slices_per_cube`) 만큼.
|
||||
- `self._tcm = _FreeList(cfg.tcm_allocatable_bytes)` — `tcm_bytes_per_pe -
|
||||
tcm_scheduler_reserved_bytes` 만큼 (scheduler 예약분은 사전 분리).
|
||||
|
||||
따라서 PEMemAllocator 의 첫 일은 **이 PE 의 HBM slice 와 사용자
|
||||
TCM 영역을 각각 단일 free block 으로 세우는 것**.
|
||||
|
||||
### `VirtualAllocator(va_base, va_size, page_size=2*1024*1024)`
|
||||
|
||||
생성 즉시 `self._va_base = va_base`, `self._va_size = va_size`,
|
||||
`self._page_size = page_size`, `self._used = 0`, `self._free = [(va_base,
|
||||
va_size)]`. 첫 일은 **VA base 부터 size 까지 single block 으로 세우고
|
||||
page_size 를 회수**.
|
||||
|
||||
## Context
|
||||
|
||||
`runtime_api/context.py::_ensure_allocators` 는 다음 단계로 allocator 세트를
|
||||
구성한다:
|
||||
|
||||
1. spec 으로부터 `hbm_total_gb_per_cube`, `hbm_slices_per_cube`,
|
||||
`tcm_size_mb`, target_device 별 SIP 범위 등을 읽음.
|
||||
2. `AddressConfig` 로 모든 파라미터를 frozen 하게 패킹.
|
||||
3. target SIP 범위 × cube × PE 의 모든 조합에 대해
|
||||
`PEMemAllocator(sip, cube, pe, cfg)` 인스턴스를 1개씩 생성.
|
||||
4. `VirtualAllocator(va_base=0x1_0000_0000, va_size=64 GiB,
|
||||
page_size=pe_mmu.page_size)` 를 1개 생성.
|
||||
|
||||
allocator 들의 책임:
|
||||
|
||||
- **PEMemAllocator**: PE-로컬 HBM slice / TCM 의 PA-공간 할당 (PhysAddr
|
||||
encoding 까지 포함).
|
||||
- **VirtualAllocator**: device-wide VA 공간을 페이지 정렬로 할당. 이후
|
||||
`RuntimeContext._create_tensor` 가 VA → PA 매핑을 `MmuMapMsg` 로 fabric
|
||||
에 push.
|
||||
|
||||
이 알고리즘들은:
|
||||
|
||||
- **first-fit** 으로 단순.
|
||||
- 자유 블록 리스트는 **offset 정렬 (sorted by start)** 유지.
|
||||
- `free()` 시 **양쪽 인접 블록과 coalesce**.
|
||||
|
||||
이런 결정의 근거가 어디에도 없으므로, 향후 누군가 "왜 best-fit 이 아닌가",
|
||||
"왜 buddy allocator 가 아닌가", "왜 partial overlap free 가 silently
|
||||
허용되는가" 라는 질문에 답할 기준이 필요. 본 ADR 이 그 기준을 마련한다.
|
||||
|
||||
## Decision
|
||||
|
||||
### D1. `_FreeList` — offset-기반 first-fit + coalescing
|
||||
|
||||
`policy/address/allocator.py::_FreeList`:
|
||||
|
||||
- 내부 표현: `list[tuple[int, int]]` = `[(start_offset, size), ...]` —
|
||||
start offset 으로 정렬된 자유 블록의 sorted list.
|
||||
- `alloc(nbytes)`:
|
||||
1. free list 를 앞에서부터 순회 (first-fit).
|
||||
2. 처음 만나는 `size >= nbytes` 인 블록에서 앞부분을 잘라 사용.
|
||||
3. 정확히 일치하면 블록 통째로 제거; 아니면 `(start+nbytes, size-nbytes)`
|
||||
로 축소.
|
||||
4. `_used += nbytes`, 잘라낸 `start` 반환.
|
||||
5. 맞는 블록이 없으면 `AllocationError("overflow ... largest free block
|
||||
...")`.
|
||||
- `free(offset, nbytes)`:
|
||||
1. `_used -= nbytes`.
|
||||
2. `bisect_left(self._free, (offset,))` 로 삽입 위치 결정.
|
||||
3. 직전 블록과 인접 (`prev_start + prev_size == offset`) 하면 흡수.
|
||||
4. 직후 블록과 인접 (`offset+nbytes == next_start`) 하면 흡수.
|
||||
5. coalesced range 를 정렬 위치에 insert.
|
||||
|
||||
이 알고리즘은 fragmentation 에 약점이 있으나 (best-fit / buddy 대비), 본
|
||||
시뮬레이터의 워크로드 특성상 (deploy/free 패턴이 거의 stack-like) 충분
|
||||
하다는 것이 디자인 가정이다. 워크로드가 변하면 D1 supersede 후보.
|
||||
|
||||
### D2. partial overlap free 는 **검사하지 않는다**
|
||||
|
||||
`_FreeList.free(offset, nbytes)` 는 호출자가 정확한 (offset, nbytes) 를
|
||||
넘긴다고 신뢰한다. 다음을 검증하지 않는다:
|
||||
|
||||
- 그 range 가 실제로 alloc 된 것인지.
|
||||
- 그 range 가 다른 alloc 된 영역과 겹치지 않는지.
|
||||
|
||||
이유: 시뮬레이터 컨텍스트에서 호출자는 항상 `alloc()` 의 반환값을 그대로
|
||||
저장했다가 `free()` 에 넘기는 패턴이며, 외부 사용자 입력이 아니다. 안전성
|
||||
검사를 추가하면 매 free 마다 O(N) 비용이 들어 시뮬 wall-clock 에 영향.
|
||||
|
||||
이 신뢰 모델이 깨지면 (예: 두 텐서가 같은 PA 를 가리키는 코드 경로 도입)
|
||||
즉시 ADR-level 으로 재검토.
|
||||
|
||||
### D3. `PEMemAllocator` — HBM/TCM 두 채널 분리
|
||||
|
||||
`PEMemAllocator(sip_id, die_id, pe_id, cfg)` 는 두 `_FreeList` 를 보유:
|
||||
|
||||
- `_hbm`: `cfg.hbm_slice_bytes` 크기.
|
||||
- `_tcm`: `cfg.tcm_allocatable_bytes` (= `tcm_bytes_per_pe -
|
||||
tcm_scheduler_reserved_bytes`) 크기.
|
||||
|
||||
`alloc_hbm(nbytes) -> PhysAddr`:
|
||||
|
||||
- `_hbm.alloc(nbytes)` 로 offset 획득.
|
||||
- `PhysAddr.pe_hbm_addr(sip_id, die_id, pe_id, pe_local_hbm_offset=offset,
|
||||
slice_size_bytes=cfg.hbm_slice_bytes)` 로 PA 인코딩.
|
||||
- 실패 시 `AllocationError("HBM overflow ...")`.
|
||||
|
||||
`free_hbm(pa, nbytes)`:
|
||||
|
||||
- `pa.hbm_offset - pe_id * cfg.hbm_slice_bytes` 로 PE-local offset 복원.
|
||||
- `_hbm.free(offset, nbytes)`.
|
||||
|
||||
`alloc_tcm(nbytes) -> PhysAddr`: 유사하게 `PhysAddr.pe_tcm_addr` 로 인코딩.
|
||||
|
||||
`free_tcm(pa, nbytes)`: `pa.sub_offset` 을 그대로 사용 (TCM 은 PE-local
|
||||
offset 이 곧 sub_offset).
|
||||
|
||||
scheduler-reserved TCM 영역 (`cfg.tcm_scheduler_reserved_bytes`) 은
|
||||
allocator 가 인지하지 않는다 (`_tcm` 의 capacity 에서 사전 차감되어 있음).
|
||||
이는 ADR-0014 의 PE_SCHEDULER 내부 buffer 예약과 정합된다.
|
||||
|
||||
### D4. `VirtualAllocator` — 페이지 정렬 first-fit + coalescing
|
||||
|
||||
`policy/address/va_allocator.py::VirtualAllocator`:
|
||||
|
||||
- 내부 표현: `_FreeList` 와 동일한 sorted `list[tuple[int, int]]`.
|
||||
최초: `[(va_base, va_size)]`.
|
||||
- `_align_up(nbytes) = ceil(nbytes / page_size) * page_size`.
|
||||
- `alloc(nbytes) -> int`:
|
||||
1. `aligned = _align_up(nbytes)`.
|
||||
2. first-fit 으로 `size >= aligned` 인 블록 탐색.
|
||||
3. 블록 앞부분 `aligned` 만큼 잘라 사용. 정확히 일치하면 제거.
|
||||
4. `_used += aligned`. 블록 `start` (= aligned 된 VA) 반환.
|
||||
5. 실패 시 `VaAllocationError`.
|
||||
- `free(va, nbytes)`: `_align_up(nbytes)` 단위로 free. _FreeList 와 동일한
|
||||
coalesce 알고리즘.
|
||||
|
||||
`page_size` 의 실제 값은 두 곳에서 다른 기본을 갖는다:
|
||||
|
||||
- `VirtualAllocator.__init__` 의 매개변수 기본값: `2 MiB`. 직접 호출하는
|
||||
테스트가 그대로 받는다.
|
||||
- `RuntimeContext._ensure_allocators` 가 인스턴스화할 때:
|
||||
`pe_mmu.attrs.get("page_size", 4096)` — `topology.yaml` 의
|
||||
`pe_mmu.attrs.page_size` 가 있으면 그 값, 없으면 fallback 4 KiB.
|
||||
|
||||
두 기본이 다른 이유: VirtualAllocator 의 standalone 기본은 ADR-0039 의
|
||||
PE_MMU stopgap 기본 (2 MiB) 과 정합되어 직접 테스트가 자연스럽고, context
|
||||
fallback 의 4 KiB 는 topology 미설정 시 안전한 minimum page 다. 실제 사용
|
||||
경로는 항상 후자이며 (`_ensure_allocators` 가 인스턴스화하므로),
|
||||
`topology.yaml` 에서 `page_size` 가 명시되면 그 값이 양쪽 (MMU + VA
|
||||
allocator) 으로 일관되게 흐른다.
|
||||
|
||||
만약 이 일치가 깨지면 (예: VirtualAllocator 의 page_size 를 PE_MMU 와
|
||||
다르게 인스턴스화) MMU `map()` 가 서브-페이지 region 모드 (ADR-0039 D3) 로
|
||||
흐른다.
|
||||
|
||||
VA 기본 범위: `va_base = 0x1_0000_0000` (= 4 GiB), `va_size = 64 GiB`. 이
|
||||
값은 `_ensure_allocators` 에 하드코딩되어 있으며 ADR-0011 의 VA 모델에서
|
||||
직접적인 의미를 갖지는 않는다 — 단지 host 코드와 충돌하지 않을 만큼 큰
|
||||
주소 공간을 device-wide 로 잡아둔 것.
|
||||
|
||||
### D5. allocator 인스턴스의 lifecycle
|
||||
|
||||
- `RuntimeContext._ensure_allocators` 가 lazy 하게 호출됨 (`_create_tensor`
|
||||
의 첫 호출 시점).
|
||||
- 한 번 생성된 allocator dict (`self._allocators`) 는 RuntimeContext 의
|
||||
lifetime 동안 재사용. 같은 process 안의 두 번째 deploy 는 새 객체를
|
||||
만들지 않는다.
|
||||
- `RuntimeContext.cleanup()` 이 모든 living tensor 의 `_free_tensor()` 를
|
||||
호출 → MMU unmap + `va_allocator.free` + `pemem_allocator.free_hbm` 으로
|
||||
free list 가 원상복구. 다음 RuntimeContext 가 다시 만들면 초기 상태부터.
|
||||
|
||||
allocator 상태가 RuntimeContext 간에 공유되지 않는 점이 단일 process 안의
|
||||
연속 실행에서 deploy → cleanup → deploy 의 결정성을 보장한다.
|
||||
|
||||
### D6. Allocator 실패는 raise 한다 (silent OOM 금지)
|
||||
|
||||
`_FreeList.alloc` / `VirtualAllocator.alloc` 모두 충분한 free block 이
|
||||
없으면 `AllocationError` / `VaAllocationError` 를 던진다. 메시지에는
|
||||
"required size + largest available block" 가 포함되어, fragmentation
|
||||
인지 진짜 OOM 인지 진단 가능.
|
||||
|
||||
silent fallback (예: 가장 큰 블록만큼만 alloc) 는 절대 금지 — 부분 할당된
|
||||
텐서가 SimPy 단계에 들어가면 라우팅·DMA 가 잘못된 PA 를 인지하여 시뮬
|
||||
정확도가 깨진다.
|
||||
|
||||
### D7. address space 와 allocator 의 1:1 대응
|
||||
|
||||
물리 주소 공간 분리는 PhysAddr 의 sub-unit (ADR-0001 D2.3) 으로 표현되며,
|
||||
각 sub-unit 마다 별도 allocator 인스턴스를 둔다:
|
||||
|
||||
- HBM slice → `PEMemAllocator._hbm`.
|
||||
- PE TCM → `PEMemAllocator._tcm`.
|
||||
- (현재 미사용) M_CPU local memory, CUBE SRAM → 별도 allocator 필요. 현재
|
||||
구현은 아직 IPCQ-only slot 으로 처리 (ADR-0023 D9.7) 하며 PA 공간을
|
||||
share 하지 않으므로 별도 free-list 가 없음.
|
||||
|
||||
cube-level SRAM allocator 가 필요해지면 `_FreeList(cfg.sram_bytes_per_cube)`
|
||||
인스턴스를 cube 단위로 추가한다 (`cfg.sram_bytes_per_cube` 는 이미
|
||||
`AddressConfig` 에 정의되어 있어 데이터 모델은 준비됨).
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### A1. best-fit / buddy allocator
|
||||
|
||||
기각 (현재). 워크로드의 alloc/free 패턴이 stack-like (deploy 순서 = free
|
||||
순서) 라 first-fit + coalescing 으로 fragmentation 이 충분히 통제된다.
|
||||
LLM kernel sweep 에서 long-running fragmentation 이 관찰되면 buddy 로
|
||||
교체하는 ADR 을 별도로 만든다.
|
||||
|
||||
### A2. partial overlap free 검증 추가
|
||||
|
||||
기각. D2 의 신뢰 모델 + O(N) 검사 비용. 단, 디버그 모드 (`KERNBENCH_DEBUG`
|
||||
env var 등) 에서 활성화하는 옵션은 후속 작업으로 가능.
|
||||
|
||||
### A3. VA 와 PA 의 통합 allocator
|
||||
|
||||
기각. VA 공간 (64 GiB device-wide) 과 PA 공간 (slice 별 ~6 GiB) 는 의미
|
||||
차원이 다르다. VA 는 host kernel 의 view, PA 는 device sub-unit 의 view.
|
||||
ADR-0011 의 VA 모델 정신 (MMU 가 둘 사이를 매핑) 과 정합하기 위해
|
||||
allocator 도 분리.
|
||||
|
||||
### A4. page_size 의 multi-tier 지원 (large page + small page)
|
||||
|
||||
기각 (현재). 단일 page_size (현재 2 MiB) 가 LLM kernel 의 텐서 단위 (수
|
||||
MiB~수 GiB) 에 맞고, ADR-0039 D3 의 서브-페이지 region 으로 작은 매핑이
|
||||
필요할 때 흡수된다. multi-tier page 는 MMU 자체 모델을 확장해야 하므로
|
||||
별도 ADR 후보.
|
||||
|
||||
## Consequences
|
||||
|
||||
- allocator 알고리즘이 ADR-level 에서 굳어져 (D1·D3·D4), 새로운 시뮬
|
||||
시나리오에서 fragmentation 이슈가 발생할 때 "여기서 first-fit + coalesce
|
||||
를 쓰고 있다" 가 명확.
|
||||
- D2 의 신뢰 모델이 명시되어, 향후 사용자 입력으로부터 직접 alloc/free 를
|
||||
받는 경로가 도입되면 본 ADR supersede 가 필요함을 일찍 인지 가능.
|
||||
- D7 의 sub-unit별 allocator 1:1 대응이 명시되어, M_CPU/SRAM 별도 영역이
|
||||
필요해질 때 어디에 free-list 를 추가해야 하는지 명확.
|
||||
- `VirtualAllocator` 의 page_size 가 PE_MMU 설정과 일치해야 함이 D4 에
|
||||
적혀 있어, 향후 topology.yaml 의 page_size 변경 시 ADR-0039 stopgap 동작
|
||||
과의 상호작용을 빠르게 가늠 가능.
|
||||
@@ -0,0 +1,231 @@
|
||||
# ADR-0049: `kernbench probe` Subcommand — Traffic-Pattern Verification Harness
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-05-22).
|
||||
|
||||
`probes/probe.py` 의 `run_probe(...)` 가 노출하는 traffic-pattern catalog,
|
||||
formula vs actual 비교, 그리고 monotonicity / D2H≥H2D 같은 invariant
|
||||
체크의 의미를 명시한다. ADR-0010 (CLI surface) 가 `kernbench probe`
|
||||
subcommand 를 enumerate 하나, **probe 가 실제로 측정하는 것**과 **어떤
|
||||
invariant 를 PASS/FAIL 로 판정하는가**는 ADR-level 에 없었다.
|
||||
|
||||
## First action (제일 처음에 하는 일)
|
||||
|
||||
`run_probe(topology_path, case_filter=None)` 의 첫 4가지 작업:
|
||||
|
||||
1. `Path(topology_path).expanduser().resolve()` 로 절대 경로 산출.
|
||||
2. `load_topology(path)` → `TopologyGraph` 인스턴스 (그래프 + spec).
|
||||
3. `_build_edge_map(graph)` → `{(src, dst): Edge}` 빠른 lookup 테이블.
|
||||
4. `AddressResolver(graph)` + `PathRouter(graph)` 인스턴스화.
|
||||
|
||||
그 다음 `nbytes = 32768` (= 32 KiB, summary table 의 기준 데이터 크기) 와
|
||||
`show_all = (case_filter is None or case_filter == "all")` 를 설정.
|
||||
|
||||
즉, **probe 의 첫 일은 "토폴로지를 한 번 로드하여 edge map / resolver /
|
||||
router 를 준비하고, 32 KiB 라는 표준 측정 크기를 픽스하는 것"**. 그 이후
|
||||
H2D → D2H → PE DMA 세 카테고리의 case 들이 각각 별도의 `GraphEngine`
|
||||
인스턴스에서 실행된다 (case 간 cross-talk 차단).
|
||||
|
||||
## Context
|
||||
|
||||
`kernbench probe` 는 다음 의도로 도입된 verification 도구다:
|
||||
|
||||
- **수동 분석 ground truth**: 실 시뮬레이션 (`kernbench run --bench ...`)
|
||||
결과의 latency 가 비정상으로 보일 때, 단순 traffic pattern 의 정답을 별도
|
||||
로 얻어 비교.
|
||||
- **formula vs actual 비교**: 분석 모델 (wire latency + overhead + drain)
|
||||
과 시뮬레이션 결과 (`total_ns`) 가 일치하는지 확인. 일치하지 않으면 모델
|
||||
단순화 가정 (ADR-0033) 어디가 빠진 것인지 단서.
|
||||
- **monotonicity check**: hop 수가 늘면 latency 가 단조 증가해야 한다는
|
||||
invariant 의 자동 확인.
|
||||
- **utilization sweep**: 데이터 크기 (4 KiB ~ 1 MiB) 별 BW 활용률 표.
|
||||
|
||||
이 도구의 동작 사양이 ADR-level 에 없으면:
|
||||
|
||||
- 다른 형식의 traffic pattern (예: MCpuDma, IPCQ) 을 추가하려는 사람이 기존
|
||||
카테고리의 표 포맷 / 측정 단위를 일관되게 따르기 어렵다.
|
||||
- monotonicity 가 무엇을 기준으로 검사되는지 (hop 수? cube 거리? wire
|
||||
길이?) 모호.
|
||||
- 32 KiB 라는 기준 크기와 `[4 KiB, 16 KiB, 64 KiB, 256 KiB, 1 MiB]` sweep
|
||||
의 의미가 코드 grep 으로만 확인 가능.
|
||||
|
||||
## Decision
|
||||
|
||||
### D1. 세 가지 case category — H2D / D2H / PE DMA
|
||||
|
||||
각 category 는 토폴로지 상 별개의 데이터 경로를 가지며, 별도의 summary
|
||||
table + sweep table + route detail block 으로 출력된다.
|
||||
|
||||
- **H2D (Host→Device Write)**: `MemoryWriteMsg(dst_sip=0, dst_cube,
|
||||
dst_pe=0, pattern="zero")` 가 `pcie_ep → io_cpu → m_cpu → hbm_ctrl` 경로
|
||||
를 흐른다. cube 인덱스로 hop 수가 증가:
|
||||
- h2d-1hop: cube=0, hops=1
|
||||
- h2d-2hop: cube=4, hops=2
|
||||
- h2d-3hop: cube=8, hops=3
|
||||
- h2d-4hop: cube=12, hops=4
|
||||
- **D2H (Device→Host Read)**: `MemoryReadMsg(src_sip=0, src_cube, src_pe=0)`.
|
||||
forward command path + reverse data path 의 합 latency. 같은 4 hops
|
||||
카테고리.
|
||||
- **PE DMA (PE-initiated)**: `PeDmaMsg(src_sip, src_cube, src_pe, dst_pa)`.
|
||||
5 가지 케이스로 cube/PE 위치 변화:
|
||||
- pe-local-hbm: same cube, same PE
|
||||
- pe-same-half-hbm: same cube, different PE (PE 1)
|
||||
- pe-cross-half-hbm: same cube, far PE (PE 4)
|
||||
- pe-cross-cube-hbm-best: adjacent cube (cube 1)
|
||||
- pe-cross-cube-hbm-worst: diagonal far cube (cube 15)
|
||||
|
||||
cube 인덱스가 4/8/12 (H2D), 1/4/15 (PE DMA) 같이 의미 있는 이유는
|
||||
4x4 cube mesh (sip.cube_mesh.w=4, h=4) 에서의 거리 정의 — 추후 cube_mesh
|
||||
크기 변경 시 이 값들이 같이 갱신되어야 한다.
|
||||
|
||||
### D2. 표준 측정 크기 — `nbytes = 32768` (32 KiB)
|
||||
|
||||
모든 case 의 summary table 은 `nbytes=32768` 로 한 번 실행한 결과를
|
||||
보여준다. 32 KiB 가 선택된 이유:
|
||||
|
||||
- DMA overhead 와 BW drain 이 한쪽으로 치우치지 않는 적당한 크기.
|
||||
- 다수 sub-unit (TCM, register file) 의 1회 transfer 단위와 비교 가능.
|
||||
|
||||
크기별 utilization 변화는 별도 sweep table 이 보여준다 (D3).
|
||||
|
||||
### D3. Utilization sweep — `[4 KiB, 16 KiB, 64 KiB, 256 KiB, 1 MiB]`
|
||||
|
||||
`SWEEP_SIZES = [4096, 16384, 65536, 262144, 1048576]`, `SWEEP_LABELS =
|
||||
["4KB", "16KB", "64KB", "256KB", "1MB"]`. 매 size 마다 다음 공식:
|
||||
|
||||
```
|
||||
drain = nbytes / bottleneck_bw
|
||||
total = overhead + wire + drain
|
||||
eff_bw = nbytes / total
|
||||
util% = eff_bw / bottleneck_bw × 100
|
||||
```
|
||||
|
||||
`bn_bw is None or <= 0` 이면 그 컬럼은 0.0 % 로 출력. 의미: hop 수가 늘
|
||||
수록 작은 transfer 는 overhead-bound, 큰 transfer 는 drain-bound 가 되는
|
||||
패턴을 한 표에서 확인.
|
||||
|
||||
### D4. 측정 항목 — actual / formula / breakdown
|
||||
|
||||
각 case 행에 표시되는 컬럼:
|
||||
|
||||
- `Actual` (total_ns): SimPy 실행 결과의 `trace["total_ns"]`.
|
||||
- `Ovhd`: 경로상 모든 node 의 `node.attrs["overhead_ns"]` 합 (formula
|
||||
breakdown).
|
||||
- `Drain`: `nbytes / min(edge.bw_gbs over path)` (formula).
|
||||
- `Wire`: `Σ edge.distance_mm * (ns_per_mm from spec)`.
|
||||
- `Ovhd%` / `Drain%`: Ovhd/Drain 이 Actual 에서 차지하는 비율 (formula 의
|
||||
Wire 는 통상 매우 작아 표시하지 않음).
|
||||
- `Eff.BW`: `nbytes / total_ns` (실 측정 BW).
|
||||
- `BN.BW`: bottleneck bandwidth (formula). path 상 모든 edge 의 BW 중 최소.
|
||||
edge BW 가 없으면 "-".
|
||||
- `Util%`: `Eff.BW / BN.BW × 100`. 100% 면 single-stream BW upper bound 에
|
||||
도달.
|
||||
|
||||
formula 의 합 (`wire + ovhd + drain`) 과 actual 의 차이가 크면 모델
|
||||
단순화가 잡지 못하는 요소가 있다는 신호 (ADR-0033 의 가정 점검).
|
||||
|
||||
### D5. Invariant 자동 체크 — PASS/FAIL
|
||||
|
||||
다음 invariant 들이 자동으로 확인되어 `[v] PASS` / `[x] FAIL` 로 출력:
|
||||
|
||||
- **H2D / D2H monotonic increase**: hop 수가 늘면 actual latency 가
|
||||
단조 증가해야 함. `all(lats[i] < lats[i+1] for ...)`.
|
||||
- **D2H ≥ H2D**: 같은 hop 인덱스에서 D2H ≥ H2D (D2H 는 forward command
|
||||
+ reverse data 두 leg 이므로). `all(d2h[i].total >= h2d[i].total)`.
|
||||
- **PE DMA best < worst**: cross-cube best (adjacent) latency < cross-cube
|
||||
worst (diagonal) latency.
|
||||
- **PE DMA local vs remote**: local BN BW vs remote BN BW 의 비교 출력
|
||||
(PASS/FAIL 이 아닌 정보성).
|
||||
|
||||
체크가 FAIL 이면 사람이 즉시 모델/토폴로지 회귀를 인지할 수 있도록 한
|
||||
줄로 분명하게 출력.
|
||||
|
||||
### D6. Route detail — per-hop timestamp trace
|
||||
|
||||
summary 와 sweep 표 이후 각 case 의 path 와 per-hop 누적 시간 (
|
||||
`_hop_timestamps`) 가 별도 섹션에서 출력된다:
|
||||
|
||||
- H2D: leg1 (`pcie_ep → io_cpu`) + leg2 (`io_cpu → m_cpu`) + leg3
|
||||
(`m_cpu → hbm_ctrl`) + per-hop trace.
|
||||
- D2H: forward (cmd, no data) + reverse (data) trace 분리 표시.
|
||||
- PE DMA: `pe_dma → router → hbm_ctrl` path + per-hop trace.
|
||||
|
||||
각 hop 의 timestamp 는 cumulative `wire_ns + overhead_ns` 누적. terminal
|
||||
hop 의 annotation 에 `drain:Xns` 가 붙는다. bottleneck edge 는
|
||||
`<BN:XXGB/s>` 로 표시되어 시각적으로 식별 가능.
|
||||
|
||||
### D7. case_filter 인자의 의미
|
||||
|
||||
- `None` 또는 `"all"`: 모든 case 실행 (default).
|
||||
- 다른 문자열: 그 이름과 정확히 일치하는 case 만 실행. 예: `kernbench
|
||||
probe --case h2d-2hop`.
|
||||
|
||||
각 카테고리 안에서 `name != case_filter` 면 skip 되며, 그 카테고리의
|
||||
monotonicity / D2H≥H2D 비교는 데이터가 1개일 때 자연히 skip 된다.
|
||||
|
||||
CLI parser 의 `--case` 기본값은 `"all"`이라 인자 생략 시 전체 실행.
|
||||
|
||||
### D8. 매 case 별 fresh GraphEngine
|
||||
|
||||
H2D 4개, D2H 4개, PE DMA 5개의 case 가 각각 **새로운 GraphEngine**
|
||||
인스턴스에서 실행된다 (`engine = GraphEngine(graph)`). 이유:
|
||||
|
||||
- case 간 누적 상태 (op_log, completion 추적, allocator 등) 가 cross-talk
|
||||
하지 않도록 격리.
|
||||
- 한 case 의 traffic 이 다른 case 의 BW 측정에 영향을 주지 않도록 보장.
|
||||
|
||||
이 격리는 probe 의 측정 결과를 **각 case 단독 single-flow** 의 latency 로
|
||||
해석할 수 있게 한다. multi-flow contention 측정은 별도 도구 (예:
|
||||
`pe2pe_overview` 플롯, ADR-0033 의 multi-flow merging 모델) 책임.
|
||||
|
||||
### D9. 출력 포맷의 안정성
|
||||
|
||||
probe 의 stdout 출력은 사람이 읽기 위함이며, 정확한 컬럼 폭/구분자/공백 은
|
||||
machine-readable contract 가 아니다. 자동화된 도구가 probe 결과를 파싱
|
||||
하려면 별도 JSON 출력 모드를 추가해야 한다 (현재 미구현).
|
||||
|
||||
PASS/FAIL 줄의 `[v]` / `[x]` 접두사는 CI grep 용 anchor 로 안정 보장.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### A1. Probe 를 별도 bench 로 등록 (`@bench(name="probe")`)
|
||||
|
||||
기각. probe 는 bench 가 아니라 verification 도구로 의도된다 — sweep / 분석
|
||||
용 multi-engine 실행과 invariant PASS/FAIL 출력이 본질이며, ADR-0045 의
|
||||
"단일 디바이스 + 단일 RuntimeContext" bench 모델과 맞지 않는다.
|
||||
|
||||
### A2. monotonicity 위반 시 exit code 1
|
||||
|
||||
기각 (현재). 인간 검사 도구 위주로 의도되어 있어 PASS/FAIL 줄을 출력하고
|
||||
exit 0 로 종료. CI 가 violation 으로 fail 하길 원하면 별도 wrapper 가
|
||||
`grep "\[x\]"` 결과로 판단하면 됨. 후속으로 strict-mode flag (`--strict`)
|
||||
도입 가능.
|
||||
|
||||
### A3. probe 의 case 정의를 외부 YAML 로
|
||||
|
||||
기각 (현재). 8개 case (4 H2D + 4 D2H + 5 PE DMA — 합 13개) 는 코드에
|
||||
하드코딩되어 있고 의미가 토폴로지 mesh 구조에 단단히 묶여 있다. 외부
|
||||
YAML 로 옮기면 cube 인덱스의 의미 (4, 8, 12 / 1, 4, 15) 를 별도로 문서화
|
||||
해야 하므로 응집도 손실. 케이스 추가가 잦아지면 그때 별도 ADR 로 도입.
|
||||
|
||||
### A4. multi-flow contention 측정 추가
|
||||
|
||||
기각 (probe 범위 밖). D8 에서 명시한 single-flow 격리 모델이 probe 의 핵심
|
||||
의도. multi-flow contention 은 ADR-0033 latency model 의 다른 영역으로,
|
||||
별도 도구 또는 별도 case category 로 처리.
|
||||
|
||||
## Consequences
|
||||
|
||||
- probe 의 case catalog (D1) 와 측정 단위 (D2/D3) 가 ADR-level 에서 명시
|
||||
되어, 새 traffic 카테고리 추가 시 어떤 표 포맷을 따라야 하는지 분명.
|
||||
- formula vs actual 의 컬럼 의미 (D4) 가 굳어져, probe 결과를 보고 "왜
|
||||
Drain% 가 5% 인가 / 70% 인가" 같은 질문을 빠르게 ADR-0033 가정 점검으로
|
||||
연결 가능.
|
||||
- invariant 자동 체크 (D5) 가 ADR 에 굳어져, 향후 latency 모델 변경 시
|
||||
monotonicity / D2H≥H2D 회귀를 probe 가 즉시 잡아낸다는 안전망 정착.
|
||||
- D8 의 case 간 격리가 명시되어, probe 결과를 single-flow 측정으로 안전
|
||||
하게 해석 가능. multi-flow 측정이 필요해지면 별도 도구 트랙이 필요함이
|
||||
분명.
|
||||
- A2 의 strict-mode flag 가 후속 작업 후보로 기록되어, CI 통합 요구 시
|
||||
최소 추가 작업으로 도입 가능.
|
||||
Reference in New Issue
Block a user