# 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.()` 를 처음 호출할 때 발생한다. 모든 `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=, 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=, 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 이름 충돌 / 동적 재등록 동작의 사양이 명시.