ADR: add 0038-0042 (pcie_ep, pe_mmu, pe_tcm, sram, tiling)

Fill component-model coverage gaps surfaced by /report's G4 analysis.
Each ADR documents the component's First action, latency model, and
honest notes on dormant code or implementation asymmetries discovered
during re-evaluation against current code.

- 0038 pcie_ep: thin protocol-overhead model; ComponentBase forwarding
  worker as-is; named-node contract for router helpers
- 0039 pe_mmu: component + utility dual role; sub-page region stopgap;
  D2.1 flags pipeline path missing mmu.overhead_ns timeout (asymmetric
  with non-pipeline; not visible at default tlb_overhead_ns=0)
- 0040 pe_tcm: dual-channel BW serialization (read/write Resource cap=1);
  TcmRequest schema owned by TCM; timing-only (no data store)
- 0041 sram: terminal scratchpad model + ResponseMsg on reverse path;
  D1.1 flags _worker override as currently dormant (no Transaction
  actually targets the SRAM node today)
- 0042 tiling: pure plan-generator module, not a component; corrects
  the G4 misclassification; pins GEMM/Math stage sequences and
  epilogue scope contract

Also: /report skill G3 refinement — only flag older->newer asymmetric
cross-references; newer->older (e.g., 0034-0037 citing infrastructure
ADRs) are expected one-way and no longer reported.

Bilingual pair verifier (tools/verify_adr_lang_pairs.py) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:43:03 -07:00
parent 049e3d8bb3
commit 1f36baa898
11 changed files with 1747 additions and 3 deletions
@@ -0,0 +1,133 @@
# ADR-0038: PCIE_EP Component Model
## Status
Accepted (2026-05-20).
ADR-0035 (M_CPU), ADR-0036 (IO_CPU), ADR-0037 (Forwarding)
와 같은 결의 컴포넌트-레벨 ADR.
## First action (제일 처음에 하는 일)
`_inbox`에서 Transaction을 한 건 꺼내 `_forward_txn`을 통해 `run()`을 호출하고,
그 안에서 `node.attrs["overhead_ns"]` 만큼 `env.timeout()`으로 PCIe 프로토콜
처리 지연을 적용한다. 그 이후 시점부터는 일반 `ComponentBase` 워커가 정의한
forwarding 규약을 따른다 (다음 hop이 있으면 `out_ports[next_hop].put(...)`,
아니면 `drain_ns`를 소비하고 `txn.done.succeed()`).
즉, **PCIE_EP의 첫 번째 일은 "PCIe 프로토콜 오버헤드를 시간으로 표현하는 것"**
하나뿐이고, 라우팅·페이로드 변환·MMIO 디코딩 같은 부가 의사결정은 하지 않는다.
## Context
PCIE_EP는 토폴로지 그래프에서 **호스트와 디바이스 사이의 단방향 경계 포인트**
역할을 한다. 빌더 (`topology/builder.py`)는 SIP마다 IO chiplet 인스턴스를
생성하고 그 안에 `pcie_ep`, `io_cpu`, `io_noc`을 둔 뒤, 외부 호스트 측의 cross-SIP
switch와 `pcie_ep` 사이에 양방향 엣지를 깐다:
- `switch → pcie_ep`: host → device 트래픽 (MemoryWrite, MemoryRead, KernelLaunch).
- `pcie_ep → switch`: device-side outbound (예: cross-SIP IPCQ 토큰).
IOChiplet 내부적으로는 `pcie_ep ↔ io_noc` 양방향 엣지가 깔리고, 그 다음 hop이
`io_cpu`나 cube 측 hbm_ctrl 경로로 분기된다 (ADR-0036 IO_CPU 모델 참고).
라우터·리졸버는 SPEC R7이 요구하는 "PCIE_EP는 메모리 오퍼레이션을 위한
엔드포인트"라는 계약을 이미 인지하고 있어, `find_pcie_ep(sip)`,
`find_memory_path(pcie_ep, dst_node)` 같은 helper가 PCIE_EP를 시작점으로 한다.
문제는 이 모든 의존 관계가 builder/router/resolver 쪽에는 있으나, **PCIE_EP
자신의 내부 모델을 명시하는 ADR이 없다**는 것이다. 결과적으로:
- "PCIE_EP는 어떤 latency를 모델링하나?"가 코드를 읽어야만 답이 나온다.
- 다른 컴포넌트(IO_CPU=ADR-0036, M_CPU=ADR-0035)와의 비대칭이 발생한다.
- 향후 PCIe link-layer 모델(예: TLP credit, retry)을 더 정교하게 만들지에 대한
의사결정 근거가 흩어진다.
이 ADR은 현재의 **얇은 (thin) PCIE_EP 모델**을 명시적으로 못 박고, 그것이
의도된 단순화임을 기록한다 (ADR-0033 latency model 단순화 정책과 정렬).
## Decision
### D1. PCIE_EP는 ComponentBase의 일반 forwarding 워커를 그대로 사용한다
`PcieEpComponent``ComponentBase`를 상속하며 `_worker`/`_forward_txn`
오버라이드하지 않는다. 따라서 모든 Transaction은 다음 순서로 처리된다:
1. `_fan_in`이 들어오는 메시지(또는 Flit reassembly된 Transaction)를 `_inbox`
적재한다.
2. `_worker``_inbox`에서 하나 꺼내 `env.process(self._forward_txn(env, txn))`
포크한다 (per-message 파이프라이닝).
3. `_forward_txn`이 op_log 시작 hook → `run()` 지연 → op_log 종료 hook 순서로
호출한다.
4. `run()`은 단 한 줄: `yield env.timeout(overhead_ns)`.
5. 다음 hop이 있으면 `out_ports[next_hop].put(txn.advance())`, 없으면 (terminal로
도착한 경우) `drain_ns`를 소비 후 `txn.done.succeed()`.
### D2. PCIE_EP의 유일한 시간 모델은 `overhead_ns`다
`node.attrs["overhead_ns"]`만 latency 파라미터로 인정한다. 코드 기본값은
`0.0`이며, `topology.yaml` 의 IOChiplet `components.pcie_ep.attrs` 가 실제 값을
지정한다 (현재 토폴로지: `overhead_ns: 5.0` ns).
별도의 BW 직렬화 자원(simpy.Resource), 큐 깊이, retry 모델은 두지 않는다.
링크-레벨 BW 직렬화는 wire-side에서 처리된다 — IOChiplet 내부는
`pcie_ep_to_noc_bw_gbs = 256.0 GB/s` 링크, 외부는 system의 `io_ep_to_switch`
링크 BW가 적용된다 (ADR-0015 port/wire 모델). PCIE_EP 컴포넌트 자체는 이
BW 회계에 관여하지 않는다.
### D3. PCIE_EP는 양방향 사용을 인지하지만, 방향에 따라 동작을 바꾸지 않는다
토폴로지 빌더가 `switch ↔ pcie_ep``pcie_ep ↔ io_noc` 양방향 엣지를 깐다.
따라서 PCIE_EP는:
- inbound (host→device): switch에서 도착한 Transaction을 io_noc 쪽으로 다음 hop
계산을 통해 forward.
- outbound (device→host): io_noc/io_cpu에서 도착한 Transaction을 switch 쪽으로
forward.
두 경우 모두 D1의 일반 forwarding 워커가 처리하며, 컴포넌트 코드 자체는 방향을
구분하지 않는다 (`txn.next_hop`만 따른다).
### D4. PCIE_EP는 Flit-aware가 아니다 (legacy reassembly 경로)
`_FLIT_AWARE``True`로 두지 않는다. 따라서 `_fan_in`이 상류에서 chunkify된
Flit들을 부모 Transaction으로 재조립하여 `_inbox`에 넣는다 (ADR-0033 Phase 2c
점진적 rollout 정책과 정렬).
PCIE_EP가 PCIe TLP-level credit 모델을 갖도록 확장될 미래에 D4를 재평가한다.
### D5. PCIE_EP는 라우팅 helper의 **명명된 노드**다
`policy/routing/router.py``find_pcie_ep(sip, io_id="io0")`,
`find_all_pcie_eps()`, `find_memory_path(pcie_ep, dst_node)`는 PCIE_EP를 메모리
경로의 시작점(또는 종점)으로 간주한다. 컴포넌트 본체는 이 helper에 어떤 정보도
제공하지 않으며, 명명 규칙(`sip{S}.{io_id}.pcie_ep`)은 토폴로지 빌더가 보장한다.
## Alternatives Considered
### A1. PCIe TLP-level 모델 (credit, retry, MPS 분할)
기각. ADR-0033이 명시한 "현재 latency 모델은 abstract overhead + BW 직렬화로
표현"이라는 단순화 원칙에 어긋난다. 호스트↔디바이스 protocol 정합성은 SPEC §5
"Non-Goals"에 의해 의도적으로 out-of-scope이다.
### A2. PCIE_EP에 자체 simpy.Resource로 inflight 제한 두기
기각. 현재 워크로드에서 호스트 트래픽은 컨텐션 병목이 아니다. 필요해지는 시점에
별도 ADR로 도입한다 (호환성 측면에서 D1은 그대로 두고 D2를 확장하는 형태).
### A3. PCIE_EP를 IO_CPU와 합치기
기각. PCIE_EP는 host-side에서 처음 만나는 protocol boundary 노드이고, IO_CPU는
디바이스-쪽 control-plane 처리 노드다 (ADR-0036). 트래픽 fan-out·command 디코딩
같은 의사결정 비용은 IO_CPU에 모이며, PCIE_EP는 link-edge overhead만 표현하는
것이 의미가 있다. 합치면 두 책임이 섞여 ADR-0007 (runtime API/sim_engine 경계)
정신에 어긋난다.
## Consequences
- PCIE_EP는 코드 라인이 거의 0인 채로 명시적인 모델 ADR을 갖게 된다 — 일관성
↑, 유지보수 비용 ↓.
- 향후 PCIe-level 정밀화가 필요해지면 D2/D4를 확장하는 새 ADR을 만들어
supersede한다.
- `find_memory_path` 등 router helper가 PCIE_EP를 명명된 노드로 의존한다는
사실이 D5에서 명시되므로, 컴포넌트 ID 명명 규칙 변경 시 영향 범위가 명확해진다.
@@ -0,0 +1,194 @@
# ADR-0039: PE_MMU Component Model — 컴포넌트 + 유틸리티 이중 역할
## Status
Accepted (2026-05-20).
ADR-0011 (PA/VA/LA address model) 의 VA 모델에서 "PE_MMU가 VA→PA 변환"이라고만
선언되어 있는데, **PE_MMU 컴포넌트 자신의 동작 모델**을 별도로 못 박는 ADR.
## First action (제일 처음에 하는 일)
생성 시점에 `node.attrs["page_size"]` (default `2 MiB`) 와
`node.attrs["tlb_overhead_ns"]` (default `0.0`) 를 읽어 내부 `PeMMU` 객체
(`policy.address.pe_mmu.PeMMU`) 를 단 한 번 인스턴스화한다. 이 객체가 페이지
테이블·서브페이지 region 리스트·TLB 오버헤드의 단일 보유자(single owner)이다.
런타임에서의 첫 동작은 두 갈래로 갈린다:
- **컴포넌트 경로 (inbox 소비)**: `_worker``_inbox`에서 Transaction을 한 건
꺼내, 그 `request``MmuMapMsg`이면 각 엔트리에 대해
`self._mmu.map(va, pa, size)`를 호출하고 `txn.done.succeed()`.
`MmuUnmapMsg`이면 `unmap(va, size)`, 그 외 타입이면 표준 `_forward_txn`으로
떨군다. 즉 **MMU의 첫 일은 "map/unmap 명령을 페이지 테이블에 반영하는 것"**.
- **유틸리티 경로 (직접 호출)**: PE_DMA / PE_GEMM 같은 동일 PE 내부 엔진이
`pe_mmu.mmu.translate(va)`를 직접 호출한다. 이 경로에서는 SimPy 이벤트가
발생하지 않으며, 호출자가 (overhead_ns > 0인 경우) 본인 process에서
`yield env.timeout(mmu.overhead_ns)`를 처리한다.
## Context
ADR-0011은 PA/VA/LA 세 가지 주소 모델을 정의하고 "VA 모델 = PE_MMU를 통한 변환"
이라고만 합의했다. 그러나 코드 상의 `PeMmuComponent`는 두 가지 상호 보완적인
역할을 동시에 수행한다:
1. **토폴로지 그래프 상의 컴포넌트**: cube NoC에서 `MmuMapMsg` / `MmuUnmapMsg`
sideband 메시지를 수신하여 페이지 테이블을 갱신한다.
2. **PE-로컬 유틸리티 객체**: 동일 PE의 PE_DMA / PE_GEMM이 latency 0으로 (혹은
호출자 측에서 `overhead_ns`만 부담하면서) 직접 `translate(va)`를 호출한다.
이 두 역할을 모두 다루는 ADR이 없어 다음 모호함이 발생한다:
- "왜 MMU 변환에 SimPy 이벤트가 안 잡히나?" (실제로는 호출자 측에서 잡고 있음)
- 서브페이지 region 모델은 무엇이고, 왜 그 모델인가? (코드 docstring에는 있으나
ADR이 없음 — `project_mmu_subpage_stopgap`라는 memory note 참조만 존재)
- map/unmap 메시지가 **누구로부터** 와서 **언제까지** 갱신되어야 하는가
(ordering 계약)?
또한 `PeMMU.map()` 은 "later append, last-write-wins (역방향 탐색)" 의미를 갖는데,
이것은 단순한 단일-PA 페이지 테이블 모델로는 표현 불가능한 DPPolicy의 서브페이지
샤딩 (예: 128B 페이로드 × 4KB 페이지) 시나리오를 위해 의도적으로 추가된
**stopgap**이다. 진짜 HW MMU와는 다른 단순화임을 ADR로 못 박을 필요가 있다.
## Decision
### D1. 이중 역할의 명시 — 컴포넌트와 유틸리티
`PeMmuComponent`는 단일 클래스 안에서 다음 두 인터페이스를 노출한다:
- 컴포넌트 인터페이스: `_inbox` 소비, `_worker` 루프 (MMU sideband 메시지 처리).
- 유틸리티 인터페이스: `pe_mmu.mmu` 속성으로 underlying `PeMMU` 객체를 노출 —
PE_DMA / PE_GEMM이 이 객체를 직접 들고 `translate()`를 호출.
후자는 **layer skip이 아니다**: PE 내부는 ADR-0007이 정의한 "components" 레이어
하나 안의 sibling 관계이고, 같은 PE prefix에서 가져온 PE_MMU 객체에 대한 직접
호출은 cross-layer가 아니다. cross-layer 위반은 runtime API / sim_engine /
components 경계를 넘는 경우에만 적용된다.
### D2. Latency 모델: `translate()`는 순수 함수, overhead는 호출자 책임
`PeMMU.translate()`는 순수 함수이며 SimPy yield를 하지 않는다. 호출자(PE 엔진)
가 변환 후 `if self._mmu.overhead_ns > 0: yield env.timeout(self._mmu.overhead_ns)`
를 자기 process에서 발생시킨다.
이유: PE 엔진의 SimPy process는 이미 자체 record_start / record_end (op_log)
hook을 들고 있어 timing을 일관되게 잡을 수 있다. MMU가 별도의 process를 만들면
PE 엔진의 처리 흐름을 두 갈래로 쪼개 op_log/pipeline overlap 의미가 흐려진다.
#### D2.1. 현재 구현의 비대칭 — pipeline vs non-pipeline (Known asymmetry)
본 ADR 작성 시점의 `pe_dma.py` 구현은 두 호출 경로에서 overhead 처리가 다르다:
- **non-pipeline (`handle_command`)**: `translate()` 직후
`if self._mmu.overhead_ns > 0: yield env.timeout(self._mmu.overhead_ns)`
발생시킨다.
- **pipeline (`_do_pipeline_dma`)**: `translate()` 만 호출하고 overhead timeout을
**생략**한다 — 함수 주석에 "same logic as non-pipeline path"라고 적혀 있으나
실제로는 일치하지 않는다.
기본 토폴로지에서 `tlb_overhead_ns = 0.0` 이라 이 차이는 timing에 직접 드러나지
않으나, `tlb_overhead_ns > 0` 으로 설정한 시뮬레이션에서는 pipeline 경로의
GEMM/Math 가 non-pipeline 동일 워크로드 대비 MMU overhead 만큼 빠르게 측정된다.
D2의 계약은 "**모든** 호출자가 overhead를 책임진다" 이며, pipeline 경로의 누락은
**의도된 설계가 아니라 구현 비일관성**이다. ADR-0014 D6 (pipeline self-routing)
이 이 overhead를 면제한다고 명시한 부분은 없다.
조치 선택지(별도 Phase 1/2 제안 필요):
- (a) `_do_pipeline_dma` 에서도 `if mmu.overhead_ns > 0: yield env.timeout(...)`
를 추가하여 D2 계약과 일치시킨다 — 권장.
- (b) D2 계약을 "non-pipeline 경로에만 적용" 으로 좁히고, pipeline 경로의 면제를
ADR-0014 D6 갱신과 함께 정당화한다 — overhead 의미가 약해지므로 비권장.
본 ADR은 (a) 를 권장하며, accept 전 또는 직후의 별도 작은 변경으로 이를
교정하는 것을 가정한다.
### D3. 페이지 테이블 구조 — 서브페이지 region 리스트 (stopgap)
`self._table: dict[vpn, list[(start_in_page, end_in_page, pa_at_offset_zero)]]`
구조로 한 페이지 안에 여러 disjoint region을 보유할 수 있다.
- `map(va, pa, size)`: 페이지를 가로지르면 region들을 **append**한다.
- `translate(va)`: VPN으로 region 리스트를 가져온 후, **역방향**으로 순회하며
처음 매칭되는 region을 채택 (last-write-wins).
- `unmap(va, size)`: extent가 unmap 범위에 **완전히 포함된** region만 제거한다.
경계가 어긋난 부분 overlap은 그대로 남기며, 매핑 호출자는 mapping과 동일한
경계로 unmap할 책임을 진다.
이는 진짜 HW MMU와는 다른 **시뮬레이터 stopgap**임을 ADR-0011 VA 모델 보강
요소로 명시한다. DPPolicy 서브페이지 샤딩 시 last-write-wins overwrite로 인한
조용한 미스라우팅을 방지하기 위함이다 (메모리 노트: project_mmu_subpage_stopgap).
### D4. PageFault는 PA fallback 신호다
매핑이 없는 VA로 `translate()`가 호출되면 `PageFault`가 발생한다. PE_DMA는 이
예외를 잡아 **원본 주소를 PA로 그대로 사용**한다 (ADR-0011의 PA fallback 호환
경로). 따라서 PageFault는 에러가 아닌 "VA 매핑 부재 시 PA로 해석한다"는 신호다.
이 호환 경로는 ADR-0011이 합의한 PA-only 모드와의 후방 호환을 유지하기 위한
의도된 동작이다.
### D5. MMU sideband 메시지의 수신 계약
`MmuMapMsg` / `MmuUnmapMsg`는 fabric을 통해 PE_MMU 컴포넌트의 `_inbox`
도달한다 (R10이 명시하는 "MMU map 설치는 fabric latency를 따른다"). 메시지
schema는 runtime API (`runtime_api/kernel.py`) 가 정의하며, 현재 형식:
- `MmuMapMsg.entries: tuple[dict, ...]` — 각 dict는 `{"va": int, "pa": int,
"size": int}` 키를 갖는다.
- `MmuUnmapMsg.entries: tuple[dict, ...]` — 각 dict는 `{"va": int, "size": int}`
키를 갖는다.
PE_MMU 측 수신 처리:
1. `_worker` 가 `_inbox.get()` 에서 메시지 한 건을 꺼낸다.
2. `hasattr(msg, "request")` 로 Transaction wrapper 인지 확인.
3. `isinstance(msg.request, MmuMapMsg)` 이면 각 entry 에 대해
`self._mmu.map(va=e["va"], pa=e["pa"], size=e["size"])`.
4. `isinstance(msg.request, MmuUnmapMsg)` 이면 각 entry 에 대해
`self._mmu.unmap(va=e["va"], size=e["size"])`.
5. 둘 다 `msg.done.succeed()` 로 완료 통지.
외부 호출자(runtime API 측)가 `done`을 await하면 "매핑이 디바이스에 설치된
시점"이 SimPy 시간으로 보장된다 — 이 wait이 ADR-0011이 요구하는 "MMU map
installation incurs measured fabric latency" 의 실현이다.
이 ADR은 sideband 메시지의 **sender 와 fan-out 정책**을 정의하지 않는다 —
그것은 runtime API 책임이다. 본 ADR은 PE_MMU 측 수신 계약만 명시한다.
### D6. 비-MMU Transaction은 일반 forwarding으로 위임
`_worker`가 inbox에서 꺼낸 메시지의 `request`가 `MmuMapMsg` / `MmuUnmapMsg`가
아닌 경우 (또는 `request` 속성이 없는 경우) `_forward_txn`으로 떨군다. 이는
미래에 PE_MMU가 cube-internal NOC 상의 통과 노드로 사용될 가능성을 차단하지
않기 위함이다 (현재는 그런 통과 트래픽이 없으나, 토폴로지 변경에 대해 안전).
## Alternatives Considered
### A1. translate()를 SimPy generator로 만들기
기각. D2에서 설명한 대로, PE 엔진의 op_log/pipeline overlap 의미가 흐려진다.
호출자 측에서 timeout을 일으키는 현재 패턴이 op_log 회계와 일치한다.
### A2. 서브페이지 region 리스트 대신 페이지 크기 자체를 작게 하기 (예: 128B)
기각. 페이지 테이블 메모리 폭발과 cube-wide map message 크기 폭발을 초래한다.
DPPolicy 샤딩이 128B를 요구한다 해도 그 외 대다수 매핑은 2MiB 단위이므로,
페이지 크기를 작게 잡는 것은 평균 비용이 비대해진다.
### A3. PE_MMU를 컴포넌트가 아닌 PE_CPU의 내장 헬퍼로만 두기
기각. ADR-0011이 요구하는 "fabric을 통해 측정된 latency로 MMU map 설치"
(MmuMapMsg 경로)를 표현하려면 토폴로지 그래프 상의 노드여야 한다. 또한 cube NoC
visualizer에서 PE_MMU가 노드로 보여야 디버깅·진단이 일관된다.
## Consequences
- PE_MMU의 이중 역할(컴포넌트 + 유틸리티)이 ADR-level에서 정당화되어, 미래의
refactor 압박 (둘 중 하나로 통일하라)에 대한 논거가 생긴다.
- 서브페이지 region 모델이 시뮬레이터 stopgap임을 ADR이 명시 — 이후 LA 모델
(ADR-0011) 도입 시 이 stopgap 제거 가능성을 평가하는 기준이 된다.
- `translate()`가 yield하지 않는다는 계약이 ADR로 굳어지므로, 향후 누군가
"MMU에 자체 timeout을 넣자"는 제안을 할 때 D2를 근거로 거절할 수 있다.
- PA fallback (D4) 이 정상 흐름임이 명시되어, PageFault를 에러로 오인하여
방어 로직을 추가하는 일을 막는다.
@@ -0,0 +1,142 @@
# ADR-0040: PE_TCM Component Model — 듀얼 채널 BW 직렬화
## Status
Accepted (2026-05-20).
ADR-0014 (PE Pipeline Execution Model) 가 "PE_TCM은 BW-기반 직렬화 scratchpad
memory" 라고 언급하나 (D1), TCM 컴포넌트 자체의 정확한 동작 모델을 별도로
명시한다.
## First action (제일 처음에 하는 일)
`start()`가 호출되면 즉시 두 개의 `simpy.Resource(env, capacity=1)`을 만들고
`self._read_res` / `self._write_res`에 보관한다. 이 두 자원이 **읽기 채널**과
**쓰기 채널**을 각각 1-in-flight로 직렬화하는 단일 결정 포인트다.
런타임 첫 동작: `_worker``_inbox`에서 메시지를 한 건 꺼내 타입 분기:
- `TcmRequest` (`pe_fetch_store`에서 옴): `env.process(self._handle_tcm_request)`
포크. 즉 **TCM의 첫 일은 "방향 (read/write)에 맞는 채널 락을 잡는 것"**.
락 획득 후 `bw > 0 and nbytes > 0` 이면 `delay_ns = nbytes / bw` 만큼
`env.timeout`, 그리고 `req.done.succeed()`.
- 그 외 (Transaction): `env.process(self._forward_txn)`로 포크 (legacy fabric
통과 경로).
생성 시점에 `node.attrs["read_bw_gbs"]` / `node.attrs["write_bw_gbs"]`
(default 각 `512.0 GB/s`) 를 읽어 보관해 둔다.
## Context
PE 파이프라인 (ADR-0014 D1, D6) 에서 PE_TCM은 다음 두 종류의 트래픽을 받는다:
1. **PE_FETCH_STORE → PE_TCM의 `TcmRequest`** — TCM ↔ Register File 전송 시,
PE_FETCH_STORE가 TCM의 BW로 직렬화된 access latency를 받아오기 위해 짧은
sideband 요청을 보낸다 (`direction = "read"` 또는 `"write"`, `nbytes`,
`done` 이벤트).
2. **legacy Transaction forwarding** — 토폴로지 그래프 상에서 TCM이 통과 노드로
잡힐 가능성에 대비한 일반 forwarding 경로 (현재 critical path에서는 사용되지
않으나 보존됨).
문제: ADR-0014는 "PE_TCM은 BW-기반 직렬화"라고만 언급한다. 그러나 코드에는
명시적으로:
- **읽기와 쓰기는 별도 채널이며 동시 진행 가능**, 다만 같은 방향끼리는
cap=1로 직렬화된다.
- BW는 `read_bw_gbs` / `write_bw_gbs` 두 값으로 분리 설정 가능하다.
- `delay_ns = nbytes / bw_gbs` 공식 (단위 환산: GB/s × ns ≈ B 라는 약식).
- nbytes==0이면 BW 항을 건너뛰지만 채널 락은 잡는다.
- `run()``overhead_ns` (default 0.0) 만큼 yield 하나, 이는 legacy fabric
경로(Transaction forwarding)에서만 사용된다.
이 모든 사항을 별도 ADR로 못 박을 필요가 있다. 특히 "왜 read/write가 분리
채널인가" 와 "BW는 누가 결정하는가" 는 향후 누군가가 capacity=2 등으로 변경하려
할 때 명확한 근거가 필요한 항목이다.
## Decision
### D1. 듀얼 채널 — read와 write는 독립 자원
`_read_res = simpy.Resource(env, capacity=1)`,
`_write_res = simpy.Resource(env, capacity=1)`.
같은 방향의 동시 요청은 자원 큐에서 직렬화되나, 다른 방향끼리는 동시에 진행 가능.
이는 실제 HW에서 TCM이 듀얼 포트 (read port + write port) 로 운용되는 모델과
정합되며, GEMM 파이프라인에서 fetch(read)와 store(write)가 시간상 겹치는 정상
케이스를 BW-직렬화 모델로 표현하기 위해 의도된 분리다.
### D2. 단일 채널의 BW 모델 — `nbytes / bw_gbs`
채널 락 획득 후, `nbytes > 0 and bw > 0`이면 `yield env.timeout(nbytes / bw_gbs)`.
단위 약식은 GB/s × ns ≈ B 로, 시뮬레이터 전체에서 사용하는 BW 공식과 동일
(ADR-0033 참고 — 시뮬레이터는 일관된 약식 단위를 사용한다).
- `nbytes == 0`: BW 항은 0이지만 락은 잡혔다가 즉시 풀린다. 이 케이스가 의도된
이유: 빈 fetch/store를 보내는 plan generator가 PE_FETCH_STORE 측에서 `nbytes`
0으로 채워 보내는 경우에도, TCM 측의 op_log / 채널 회계가 일관되게 한 번
소비된다.
- `bw == 0` (config 실수): timeout 호출 자체를 skip하므로 0-time pass. 정상
세팅에서는 발생하지 않는다.
### D3. BW는 `node.attrs`의 `read_bw_gbs` / `write_bw_gbs`로 설정
기본값 `512.0 GB/s`. 토폴로지 빌더 (`topology/builder.py`) 가 `pe_template`에서
TCM을 인스턴스화할 때 해당 attrs를 전달한다. 기본값 변경은 ADR-0014 D1 또는
ADR-0033 latency model 측의 의사결정과 함께 가야 한다.
### D4. TcmRequest의 schema는 PE_TCM이 owner다
`@dataclass TcmRequest(direction: str, nbytes: int, done: simpy.Event, tag: str = "")`
`components/builtin/pe_tcm.py`에 정의된다. PE_FETCH_STORE는 이 dataclass를
import해서 생성·송신만 한다. 호출자 측이 schema를 정의하지 않는 이유:
- BW 직렬화의 의미는 TCM 측 책임 — 어떤 필드가 직렬화 결정에 쓰이는가는 TCM이
결정한다.
- `direction` 문자열을 `"read"` / `"write"` 둘로 좁히는 유효값 검증도 TCM 측에
서 담당 (`_handle_tcm_request`의 if/else 분기).
### D5. legacy Transaction forwarding 경로의 보존
`_worker``TcmRequest`가 아닌 메시지를 받으면 `_forward_txn`으로 보낸다. 이때
`run()``overhead_ns`가 적용된다. 현재 표준 PE 파이프라인에서는 TCM이
Transaction의 통과 노드로 잡히지 않으나, fabric 토폴로지가 향후 변경될 때를
위해 보존한다 (D1 의 사용 패턴과 직교).
이 경로는 op_log 측에서 일반 Transaction 회계로 잡히며, BW 채널 락은 잡지 않는다.
### D6. PE_TCM은 자체 데이터 저장소가 아니다 (timing only)
TCM은 **시간만** 모델링한다. 실제 데이터 페이로드는 sim_engine의 별도
`memory_store` (있다면) 가 보관하고, TCM 컴포넌트는 그것을 갱신하지 않는다.
PE_FETCH_STORE도 TcmRequest를 통해 BW 지연만 받아오고 실제 register 컨텐츠는
별도 경로로 다룬다 (ADR-0020 2-pass data execution 모델 — Phase 2에서 데이터
처리).
## Alternatives Considered
### A1. 단일 채널 (capacity=2 의 read+write 공유)
기각. fetch(read)와 store(write)가 시간상 겹치는 정상 케이스를 인공적으로
직렬화하게 되어 PE 파이프라인의 BW upper bound가 잘못 모델링된다.
### A2. 채널 capacity > 1 (예: 2-banked TCM)
기각. 현재 HW 모델은 단일 bank 가정. 멀티-bank로 확장하고 싶다면 별도 ADR이
필요하며, 그때 D1을 supersede한다. 지금 단계에서 capacity를 늘리면 BW upper
bound는 그대로인데 명목상의 직렬화만 헐거워져 실제 모델 정확도 ↓.
### A3. BW 공식을 `nbytes / bw + overhead_ns`로 일반화
기각. `overhead_ns`는 D5의 legacy forwarding 경로에만 사용한다. fetch/store
critical path에 추가 overhead가 필요해지면, 그것은 TCM이 아니라 PE_FETCH_STORE
`run()` 또는 register-file access 모델에 두는 것이 책임 경계 측면에서 더
적절하다.
## Consequences
- TCM의 BW 회계가 ADR-level에서 굳어지므로, GEMM/Math sweep의 op_log 해석 시
"왜 fetch와 store가 동시에 진행되었나" / "왜 같은 방향만 직렬화되나" 같은
질문이 빠르게 D1으로 해결된다.
- 미래의 멀티-bank TCM이나 read/write 비대칭 BW 모델 변경 시 영향 범위가
명확해진다 (D1·D2·D3 중 어디를 수정하는지).
- TCM이 데이터 저장소가 아니라는 점(D6)이 명시되어, ADR-0020 2-pass execution
과의 책임 경계가 견고해진다.
@@ -0,0 +1,187 @@
# ADR-0041: Cube SRAM Component Model — terminal scratchpad on cube NoC
## Status
Accepted (2026-05-20).
ADR-0017 (Cube NOC and HBM Connectivity) 에서 SRAM이 cube NoC의 attachment로
존재한다고만 언급되는 점을 보완하여, SRAM 컴포넌트 자체의 latency/response
모델을 명시한다.
## First action (제일 처음에 하는 일)
`_worker``_inbox`에서 Transaction을 한 건 꺼낸 직후 가장 먼저 하는 일은
`yield from self.run(env, txn.nbytes)` 호출이고, 그 안에서
`node.attrs["overhead_ns"]` (default `0.0`) 만큼 `env.timeout()`을 발생시킨다.
즉, **SRAM의 첫 일은 "access overhead를 시간으로 표현하는 것"**이다.
overhead 소비 이후에 `drain_ns` (그 Transaction에 부여된 terminal BW 직렬화 비용)
를 yield하고, 그 다음에 reverse path로 `ResponseMsg`를 생성하여 발사한다.
이는 일반 `ComponentBase._worker`와 다른 점이 있다: SRAM은 **terminal node**
임을 알고 있어서 `_forward_txn`을 거치지 않고 자체 워커가 `run → drain →
_send_response` 순서를 명시한다.
## Context
cube 토폴로지 (`topology/builder.py`) 는 cube마다 다음 명명된 노드를 만든다:
- `sip{S}.cube{C}.m_cpu`
- `sip{S}.cube{C}.sram`
- `sip{S}.cube{C}.hbm_ctrl` (PE당 partition)
- `sip{S}.cube{C}.pe{P}` (PE 내부 sub-component들)
SRAM은 cube NoC 의 attachment 중 하나로, 가장 가까운 router에 부착된다
(`topology/mesh_gen.py`가 placement 좌표로 nearest router 결정 후 `attach`
추가). 빌더는 `sram ↔ router` 양방향 엣지를 깐다 (BW: `sram_to_router_bw_gbs`,
기본 `128.0 GB/s`).
SRAM의 두 가지 핵심 역할:
1. **fabric terminal**: cube NoC에서 SRAM으로 향한 메모리 access Transaction의
끝점. SRAM이 access overhead와 drain을 소비하고 response를 reverse path로
되돌린다.
2. **IPCQ slot tier 중 하나**: ADR-0023 D9.7 가 정의한 `buffer_kind ∈ {tcm,
sram, hbm}` 중 `sram` 티어의 slot bw/overhead를
`common/ipcq_types._BUFFER_KIND_BW`에서 참조 — 현재 값 `(512.0 GB/s, 2.0 ns)`.
이 값은 SRAM 노드 attrs의 `overhead_ns`와는 별도이며, IPCQ slot 회계 시점에서
PE_DMA가 시간으로 환산한다.
이 두 역할은 하나의 SRAM 컴포넌트에서 동시에 충족되는데, 별도 ADR이 없으면:
- "SRAM은 어떤 latency를 모델링하나?" — fabric drain + overhead, 아니면 IPCQ
티어의 slot latency? — 답이 흩어진다.
- 미래에 SRAM 크기 (`size_mb`) attr이 실제로 어떤 의미를 갖는지 불명확. 현재
코드는 size를 사용하지 않으며 timing만 모델링한다.
- SRAM이 cube의 어떤 router에 붙는지 (placement-based)에 대한 의사결정 근거가
토폴로지 코드 안에만 있다.
## Decision
### D1. SRAM은 cube NoC의 terminal scratchpad 노드다
`SramComponent`는 `ComponentBase`를 상속하나 `_worker`를 오버라이드해서 terminal
의미를 직접 표현한다:
```
while True:
txn = yield self._inbox.get()
yield from self.run(env, txn.nbytes) # overhead_ns
if drain_ns > 0: yield env.timeout(drain_ns)
yield from self._send_response(env, txn)
```
이 패턴은 SRAM이 reverse path를 알아야 하므로 일반 `_forward_txn` (다음 hop으로
forward)이 아닌 자체 워커가 필요하다.
#### D1.1. 현재 미사용 — `_worker` 오버라이드는 dormant 경로다
본 ADR 작성 시점의 코드베이스에서는, **어떤 컴포넌트도 SRAM 노드로 Transaction
을 실제로 전송하지 않는다**. 확인된 SRAM 노드 ID 참조 위치:
- `policy/routing/router.py` 등 routing helper — path 조회 가능성만 보장.
- `components/builtin/pe_dma.py::_handle_ipcq_inbound` — IPCQ slot의
`buffer_kind == "sram"` 일 때 `bank_node = f"{cube_prefix}.sram"` 의 *path*
만 조회하여 `compute_drain_ns(path, ...)` 로 환산, **로컬에서 timeout** 한다.
Transaction 자체는 SRAM 노드로 흘러가지 않는다 (D4 참고).
- `tests/test_routing.py` — `find_path("sip0.cube0.pe0", "sip0.cube0.sram")`
로 connectivity만 검증.
따라서 `_worker`/`_send_response` 오버라이드는 **dormant code path** 이다.
삭제하지 않고 보존하는 이유:
- 향후 SRAM이 실제 fabric Transaction의 종점(예: M_CPU → SRAM 명시 access)이
되는 토폴로지 변경 시 즉시 사용 가능.
- ADR-0017 (Cube NOC) 가 정의한 cube-attached scratchpad 의미에서 종점 동작은
의미상 자연스러우므로, 의도된 placeholder 다.
이 dormant 상태가 종료되는 시점은 별도 ADR(또는 본 ADR의 후속 revision)이
명시한다.
### D2. ResponseMsg 생성과 reverse path 발사
`_send_response`는:
1. `reverse_path = list(reversed(txn.path))`로 역방향 경로 산출.
2. `ResponseMsg(correlation_id=txn.request.correlation_id, request_id=...,
src_cube=<this cube>, src_pe=-1, success=True)` 생성.
3. `Transaction(request=resp_msg, path=reverse_path, step=0, nbytes=0,
done=env.event(), is_response=True)` 로 감싸 `out_ports[reverse_path[1]]` 로
put.
4. reverse path가 비정상이거나 (`< 2 hops`) ctx가 없으면, fallback으로 원본
`txn.done.succeed()` 만 호출.
`src_pe = -1`은 "SRAM은 PE-localized가 아니다"를 의미한다. `src_cube`은 노드
ID (`sip{S}.cube{C}.sram`) 의 cube 인덱스를 파싱해 채운다.
### D3. Timing 파라미터는 `overhead_ns`와 wire-side `drain_ns`로 분리
- **컴포넌트 측 latency**: `node.attrs["overhead_ns"]`. 기본 토폴로지에서는 `2.0
ns` 정도로 세팅.
- **링크 측 직렬화**: `drain_ns`는 Transaction이 도착 시점에 carry해 온 값으로,
ADR-0015 (port/wire 모델) 의 wire-side BW 직렬화 결과다. SRAM은 이를 그대로
yield하기만 한다.
- `size_mb` (default `32 MiB`) attr은 현재 timing에 사용되지 않는다 — 향후
capacity-aware 모델이 도입되면 그때 의미를 부여한다 (별도 ADR에서).
### D4. IPCQ slot 회계는 SRAM 컴포넌트가 직접 모델링하지 않는다
ADR-0023 D9.7 에 따른 IPCQ slot의 SRAM-티어 write latency는 PE_DMA의
`_handle_ipcq_inbound`가 직접 `slot_io_latency_ns("sram", nbytes)`를 호출하여
시간을 소비한다 (그 함수는 `common/ipcq_types._BUFFER_KIND_BW["sram"]` 의 값을
사용). 즉:
- SRAM 컴포넌트가 fabric Transaction을 받아 처리할 때는 **D1·D2·D3** 만 적용.
- IPCQ slot이 SRAM에 살 때는 PE_DMA가 IPCQ slot-write 시점에 별도로 시간을
지불 — 이는 SRAM 컴포넌트 코드와 무관하며, IPCQ 측 회계다.
이 분리는 의도된 것: IPCQ는 fast path (sub-cycle slot bookkeeping) 라 fabric
Transaction을 거치지 않으므로, SRAM이 IPCQ를 인지할 필요가 없다.
### D5. SRAM의 cube NoC 부착 위치는 placement-driven
`topology/mesh_gen.py`는 `placement.sram.pos_mm` (`topology.yaml` 기본
`[1.5, 9.0]`)을 보고 가장 가까운 router의 `attach`에 `"sram"`을 추가한다. 빌더
(`topology/builder.py` 의 attachment 루프)가 그 attach 정보를 보고 `sram` 노드와
router 사이에 양방향 엣지를 깐다.
이 의사결정은 SRAM 컴포넌트 코드 외부 (mesh_gen / builder) 에 있으며, 컴포넌트
는 어느 router에 붙었는지 알 필요가 없다. 컴포넌트는 `txn.path` / `reverse_path`
가 router를 거쳐 자신에게 도달한다는 사실만 알면 된다.
### D6. SRAM은 자체 데이터 저장소가 아니다 (timing-only)
ADR-0040 D6 과 같은 맥락: SRAM 컴포넌트는 시간만 모델링하며, 실제 데이터
페이로드는 sim_engine의 `memory_store` (있을 때) 가 보관한다.
## Alternatives Considered
### A1. SRAM이 `_forward_txn`을 그대로 사용하고 IO_CPU / HBM_CTRL 처럼 별도 응답 노드를 두기
기각. cube NoC 상에서 SRAM은 terminal이며, 응답을 받아 줄 별도 노드를 두면
의미 없는 hop이 늘어나고 ADR-0017 의 cube NoC 단순화 정신에 어긋난다.
### A2. SRAM이 BW 직렬화를 자체 resource로 모델링
기각. 링크 측 BW 직렬화 (`drain_ns`) 가 이미 의미를 충분히 잡고 있다. 컴포넌트
내부에 또 `simpy.Resource`를 두면 ADR-0015 wire-side 모델과 이중계산을 야기.
### A3. SRAM이 IPCQ slot 회계를 컴포넌트 측에서 처리
기각. D4에서 명시한 대로 IPCQ는 fast path며 fabric Transaction을 통과하지
않는다. SRAM이 IPCQ를 인지하면 책임이 두 갈래로 갈라져 추론이 어려워진다.
### A4. `size_mb`로 capacity-aware latency 모델
기각 (현재 단계). capacity는 토폴로지 visualizer 측 라벨링 정도에만 쓰이며,
실제 timing 영향은 아직 모델링하지 않는다. 필요해지면 별도 ADR로 도입.
## Consequences
- SRAM의 timing 모델이 `overhead_ns + drain_ns + ResponseMsg(reverse_path)`로
ADR-level에서 굳어지므로, 누군가 IPCQ slot latency를 SRAM 컴포넌트에 추가하려
할 때 D4를 근거로 거절할 수 있다.
- `size_mb` 가 현재 timing-neutral 임이 명시되어 (D3), 미래의 capacity-aware
모델 도입 시 호환성 영향 범위가 좁다.
- placement-driven router 부착 (D5) 이 명시되어, SRAM 좌표 이동 시 어떤 부분에
파급이 있는지 (`mesh_gen`만) 명확해진다.
@@ -0,0 +1,194 @@
# ADR-0042: Tile Plan Generators — GEMM/Math 파이프라인 plan 빌더
## Status
Accepted (2026-05-20).
본 ADR은 `tiling.py`가 SimPy 컴포넌트가 아니라
**plan-generator 모듈**임을 명시한다.
ADR-0014 (PE Pipeline Execution Model) 의 D6 (tile plan / self-routing) 가
tile-plan 생성 알고리즘을 직접 정의하지 않으므로, 본 ADR이 그 비어 있는 자리를
채운다.
## First action (제일 처음에 하는 일)
`generate_gemm_plan(M, K, N, tile_m, tile_k, tile_n, ..., pe_prefix, a_pinned,
b_pinned, epilogue_specs)`이 호출되면 가장 먼저 하는 일은 **타일 수 계산과
컴포넌트 ID 문자열 구성**이다:
```
M_tiles = max(1, ceil(M / tile_m))
K_tiles = max(1, ceil(K / tile_k))
N_tiles = max(1, ceil(N / tile_n))
dma_id = f"{pe_prefix}.pe_dma"
fetch_id = f"{pe_prefix}.pe_fetch_store"
gemm_id = f"{pe_prefix}.pe_gemm"
math_id = f"{pe_prefix}.pe_math"
```
즉 **plan generator의 첫 일은 "타일 개수를 ceiling으로 산출하고, 이 PE의
sub-component ID 4개를 한 번에 짜놓는 것"**이다. SimPy 이벤트나 환경 객체는
일절 다루지 않는다 — 이 모듈은 순수 함수다.
`generate_math_plan(M, N, tile_m, tile_n, ..., math_op, src_addr, dst_addr,
pe_prefix)` 도 마찬가지로 `M_tiles`, `N_tiles` 산출과 component ID 3개
(`dma_id`, `fetch_id`, `math_id`) 구성이 첫 일이다.
## Context
ADR-0014 D6은 "PE_SCHEDULER가 CompositeCmd를 받으면 TilePlan을 생성하고
self-routing tile token을 피드한다"고만 합의했다. 그러나 코드에서는 **plan
생성 알고리즘의 구체적 내용**이 `src/kernbench/components/builtin/tiling.py`
모듈에 자리잡고 있고, 이 모듈은:
- 컴포넌트가 아니라 **순수 함수**의 모음이다 (`generate_gemm_plan`,
`generate_math_plan`).
- SimPy 환경, 큐, op_log, hook 등에 의존하지 않는다.
- 결과로 `PipelinePlan` (dataclass) 를 돌려준다.
기존 G4 분석은 `tiling.py`를 컴포넌트로 잘못 가정했으나, 실제는 PE_SCHEDULER에
주입되는 plan-builder 함수다. 이 차이는 ADR-0014 의 D6 와 짝을 이루는 별도
ADR로 못 박혀야 한다 — 그렇지 않으면:
- "tile plan을 만드는 책임이 PE_SCHEDULER인가 별도 모듈인가" 가 모호.
- GEMM plan과 Math plan의 stage sequence 가 일관성 있는지 (예: FETCH/STORE 위치)
의사결정 근거가 흩어진다.
- `a_pinned` / `b_pinned` / `epilogue_specs` 같은 옵션이 왜 plan 단에서 분기되는지
근거 없음.
## Decision
### D1. tiling은 순수 plan-generator 모듈이며 컴포넌트가 아니다
`components/builtin/tiling.py`는 ComponentBase 하위 클래스를 정의하지 않는다.
모듈-레벨 함수 두 개만 노출한다:
- `generate_gemm_plan(...) -> PipelinePlan`
- `generate_math_plan(...) -> PipelinePlan`
토폴로지 그래프에서 `tiling` 이라는 노드는 존재하지 않는다. 명명상 `builtin/`
디렉터리에 있는 이유는 PE_SCHEDULER (ADR-0014 D6) 의 직접 helper이기 때문이며,
의미상으로는 PE_SCHEDULER 내부 utility에 가깝다.
### D2. GEMM plan의 stage 시퀀스 — `M → N → K` order
각 (m, n, k) 타일에 대한 stage 시퀀스 (operand pinning과 epilogue 미적용 기본):
```
[DMA_READ(A)] → [DMA_READ(B)] → FETCH → GEMM
(last k tile only) [MATH(output_tile)]* → STORE → DMA_WRITE
```
`k_tile` epilogue는 매 K-타일마다 GEMM 직후, `output_tile` epilogue는 (m,n)당
마지막 K-타일에서 STORE/DMA_WRITE 직전에 한 번. K-루프 누적자(accumulator) 는
RegFile에 남아 K 타일들 사이에 STORE/DMA_WRITE가 발생하지 않는다 (last_k에서만
출력).
### D3. Operand pinning — `a_pinned` / `b_pinned`
호출자가 `a_pinned=True`로 호출하면 **모든 (m, n, k) 타일에서 A DMA_READ를
생략**한다. 의미: 호출자(예: `tl.composite`)가 사전에 `tl.load`로 A 전체를
TCM에 한 번 적재했음을 plan generator에 알리는 신호.
이 분기는 plan 단에서 결정한다 (런타임 분기 아님). 따라서 op_log 상의 stage
record 수는 pinning에 따라 결정적으로 달라지며, sweep 분석 측 (예: gemm_sweep
의 stage record count) 이 이 결정을 그대로 본다.
### D4. Epilogue scope — `k_tile` vs `output_tile`
`epilogue_specs`는 op-spec 객체의 iterable이다. 각 op 객체는 다음 속성을 갖는
다고 가정한다:
- `op.kind: str` — math op 이름 (예: `"dequant"`, `"bias"`, `"relu"`, `"scale"`).
stage의 `params["op_kind"]` 로 들어간다.
- `op.scope: Scope``Scope.K_TILE` 또는 `Scope.OUTPUT_TILE` (`Scope`
`kernbench.common.pe_commands` 에 정의된 enum).
- op-별 추가 필드 (예: `bias`, `scale`, `factor`) — 현재 plan generator는 사용
하지 않으며 런타임 (PE_MATH) 측이 소비.
plan generator는 `getattr(o, "scope", None)` 기준으로 두 그룹으로 분기:
- `scope == Scope.K_TILE`: 매 K-타일 GEMM 직후 MATH stage 추가.
- `scope == Scope.OUTPUT_TILE`: (m, n)당 마지막 K-타일 STORE 직전 MATH stage
추가.
`scope` 속성이 없거나 두 enum 어느 쪽도 아닌 op는 **plan에 포함되지 않는다**
(`getattr(..., None) == Scope.X` 가 둘 다 False). 기본값(`output_tile`) 채택은
**호출자(예: `tl.composite`) 측 책임**이며, plan generator는 이미 채워진 scope
값을 보고 분기할 뿐이다 (ADR-0014 의 composite epilogue 계약과 정렬).
`Scope` 임포트는 `pe_commands ← pe_types ← tiling` 의 순환 참조를 피하기 위해
함수 내부에서 lazy import 한다. 이는 의도된 패턴이며 개선 대상이 아니다 (D1의
"tiling은 PE_SCHEDULER의 utility" 관점에서, pe_commands에 대한 컴파일타임 의존
이 없는 편이 모듈 경계를 깔끔히 유지함).
### D5. Math plan의 stage 시퀀스 — `M → N` order
각 (m, n) 타일에 대한 stage 시퀀스:
```
DMA_READ → FETCH → MATH → STORE → DMA_WRITE
```
K 차원이 없으므로 epilogue / accumulator residency 같은 개념은 적용되지 않는다.
PE_FETCH_STORE의 register-file 회계는 GEMM plan과 동일한 방식으로 다뤄진다.
### D6. plan은 데이터다 — SimPy 의존성 없음
`PipelinePlan``pe_types.py`에 정의된 dataclass로, `tiles: list[TilePlan]`
보유. 각 `TilePlan``stages: tuple[Stage, ...]` 를 보유. plan 자체는
immutable에 가까운 데이터 구조이며 (Stage 의 `params: dict` 만 mutable),
SimPy 객체나 event를 갖지 않는다.
런타임 시점에 PE_SCHEDULER가 plan 의 첫 stage를 보고 `TileToken`을 생성하여
파이프라인에 피드하며, TileToken 이 `plan: TilePlan`, `stage_idx: int`,
`params: dict` 를 들고 다닌다. self-routing은 `TileToken.advance()` 가 다음
stage의 `params`를 캐시하는 방식으로 진행된다 (ADR-0014 D6).
### D7. plan generator의 contract — pure, deterministic, idempotent
같은 입력으로 두 번 호출하면 같은 PipelinePlan을 돌려준다 (`TilePlan.stages`
순서까지 deterministic). 이 contract는 ADR-0014 D6 의 "결정적 tile dispatch
순서" 요구와 정렬된다.
부수효과(SimPy event, file I/O, 글로벌 상태) 없음 — 테스트에서 환경 객체 없이
호출 가능 (`tests/test_pe_pipeline.py`의 일부 케이스가 이 방식 사용).
## Alternatives Considered
### A1. tiling을 컴포넌트로 만들기 (e.g., PE_PLANNER)
기각. plan 생성은 SimPy 시간을 소비하지 않는 결정 알고리즘이다. 컴포넌트로
만들면 (a) inbox·자원 등 불필요한 인프라가 따라붙고, (b) PE_SCHEDULER 가
"plan 받기" → "tile 피드" 두 단계를 분리해 받게 되어 의미 없는 hop이 생긴다.
### A2. plan 생성을 PE_SCHEDULER 클래스 메서드로 옮기기
기각 (현재). 모듈 분리가 (1) 테스트 용이성, (2) 다른 plan 알고리즘 (예:
DTensor-aware plan) 도입 시 추가 함수만 정의하면 되는 확장성을 준다. 만약 향후
plan 종류가 많아져 명시적 dispatch가 필요해지면, 그때 PE_SCHEDULER에 plan
factory를 두는 것을 별도 ADR로 도입한다.
### A3. plan을 immutable로 강제 (frozen dataclass + tuple)
부분 채택. `Stage``TilePlan` 은 dataclass지만 frozen은 아니다. 이유:
`Stage.params: dict` 가 plan generator 시점에 채워지고 런타임에서 읽히기만 한다
(TileToken 이 advance 시 캐시할 뿐). 완전 frozen은 dict → frozendict 마이그레이션
비용 대비 이득이 적다. 다만 plan 단계 외에는 mutation 하지 말 것을 컨벤션으로
유지한다.
## Consequences
- `tiling.py`가 컴포넌트가 아니라 plan-generator 모듈임이 ADR-level에서
명시되어, G4 같은 미래의 "이 컴포넌트는 ADR이 없다"는 분석을 차단한다.
- GEMM plan의 stage sequence (D2) 와 pinning/epilogue 분기 (D3·D4) 가 ADR로
굳어지므로, sweep 분석 (`scripts/gemm_sweep.py`)의 stage record count 해석
근거가 명확해진다.
- plan generator의 pure contract (D7) 덕분에 테스트가 환경 없이 plan 검증
가능 — ADR-0013 (verification strategy) 의 "behavior validated by tests with
meaningful input cases" 정신과 정렬.
- 향후 DTensor-aware plan, K-major plan 등 새 plan 종류 추가 시 본 ADR이
baseline 역할 — 새 함수만 추가하고 D1·D6·D7을 따른다.