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
+12 -3
View File
@@ -62,6 +62,10 @@ After writing the document, report to the user in the chat response:
- **G2 gaps** — ADRs missing **Context** or **Decision**. Alternatives - **G2 gaps** — ADRs missing **Context** or **Decision**. Alternatives
and Consequences are optional; their absence is NOT a gap. and Consequences are optional; their absence is NOT a gap.
- **G3 gaps** — ADR cross-references without a back-reference. - **G3 gaps** — ADR cross-references without a back-reference.
Only flag when the referencer's ADR number is **less than** the
referenced ADR's number (older → newer). Newer ADRs citing older
infrastructure ADRs (higher number → lower number) are expected to
be one-way and are NOT flagged.
- **G4 suggestions** — areas where an ADR seems missing based on the - **G4 suggestions** — areas where an ADR seems missing based on the
ADR corpus + SPEC reading. Phrase as suggestions, not findings. Each ADR corpus + SPEC reading. Phrase as suggestions, not findings. Each
G4 item must say *why* it's suggested and remain falsifiable. G4 item must say *why* it's suggested and remain falsifiable.
@@ -99,7 +103,10 @@ For each `docs/adr/ADR-NNNN-*.md`:
- Record presence/absence of **Context** and **Decision** for G2. - Record presence/absence of **Context** and **Decision** for G2.
Alternatives and Consequences presence is recorded for use during Alternatives and Consequences presence is recorded for use during
authoring, but their absence is not a gap. authoring, but their absence is not a gap.
- Record ADR-NNNN cross-references for G3. - Record ADR-NNNN cross-references for G3, preserving the direction
(referencer → referenced). G3 evaluation uses ADR numbers to
distinguish older→newer (flagged when missing back-link) from
newer→older (not flagged; see *Output Contract* G3).
- Record Status (e.g., Accepted, Superseded, Draft) and any "supersedes - Record Status (e.g., Accepted, Superseded, Draft) and any "supersedes
ADR-NNNN" text in the body for G5a. ADR-NNNN" text in the body for G5a.
@@ -263,9 +270,11 @@ In **dry-run mode**, replace the `Wrote:` line with:
- ADR-NNNN: missing <Context|Decision> - ADR-NNNN: missing <Context|Decision>
- (or "none") - (or "none")
**G3 — Broken cross-references** **G3 — Broken cross-references** (older → newer only)
- ADR-NNNN cites ADR-MMMM; ADR-MMMM does not back-reference - ADR-NNNN cites ADR-MMMM (NNNN < MMMM); ADR-MMMM does not back-reference
- (or "none") - (or "none")
- Note: newer ADRs citing older infrastructure ADRs (NNNN > MMMM) are
not flagged here — one-way references are the expected pattern.
**G4 — Suggested topics that may warrant a new ADR (verify before acting)** **G4 — Suggested topics that may warrant a new ADR (verify before acting)**
- <topic>: <why agent thinks it may be missing — must be falsifiable> - <topic>: <why agent thinks it may be missing — must be falsifiable>
@@ -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을 따른다.
@@ -0,0 +1,139 @@
# ADR-0038: PCIE_EP Component Model
## Status
Accepted (2026-05-20).
Companion to ADR-0035 (M_CPU), ADR-0036 (IO_CPU), and
ADR-0037 (Forwarding) at the same component-model level.
## First action
Pull one Transaction from `_inbox` and let `_forward_txn` invoke `run()`, which
applies a single `env.timeout(node.attrs["overhead_ns"])` for PCIe protocol
handling. After that the standard `ComponentBase` worker rules take over: if
`next_hop` exists, put the advanced Transaction on `out_ports[next_hop]`;
otherwise consume `drain_ns` and call `txn.done.succeed()`.
In other words, **PCIE_EP's first (and only) act is to spend the configured
overhead as simulator time** — no routing decisions, no payload transformation,
no MMIO decoding.
## Context
PCIE_EP is the **host ↔ device boundary** in the topology graph. The builder
(`topology/builder.py`) creates an IO chiplet instance per SIP that contains
`pcie_ep`, `io_cpu`, and `io_noc`, and lays bidirectional edges between the
external `fabric.switch0` and each `pcie_ep`:
- `switch → pcie_ep`: host → device traffic (MemoryWrite, MemoryRead,
KernelLaunch).
- `pcie_ep → switch`: device-side outbound (e.g., cross-SIP IPCQ tokens).
Inside the IO chiplet there are bidirectional `pcie_ep ↔ io_noc` edges, and
from there traffic branches to `io_cpu` or to the cube-side `hbm_ctrl` path
(see ADR-0036 IO_CPU model). The router and resolver already know — per SPEC
R7 — that PCIE_EP is the endpoint for memory operations, so helpers like
`find_pcie_ep(sip)` and `find_memory_path(pcie_ep, dst_node)` treat PCIE_EP as
the start (or end) of the memory path.
The problem is that all of this dependency lives in builder/router/resolver,
while **PCIE_EP's own internal model has no ADR**. The consequence:
- "What latency does PCIE_EP model?" requires reading the source.
- The asymmetry with peer components (IO_CPU = ADR-0036, M_CPU = ADR-0035) is
awkward.
- Future decisions about a more detailed PCIe link-layer model (TLP credits,
retry, MPS chunking) lack a documented baseline.
This ADR pins down the current **thin PCIE_EP model** and records that this
thinness is intentional (aligned with ADR-0033's latency-model simplification
policy).
## Decision
### D1. PCIE_EP uses ComponentBase's generic forwarding worker as-is
`PcieEpComponent` extends `ComponentBase` and does **not** override `_worker` or
`_forward_txn`. Every Transaction flows through the standard sequence:
1. `_fan_in` accumulates inbound messages (and reassembles Flits, per ADR-0033
Phase 2c) into `_inbox`.
2. `_worker` pulls one message off `_inbox` and spawns
`env.process(self._forward_txn(env, txn))` for per-message pipelining.
3. `_forward_txn` calls the op_log start hook → `run()` for latency → op_log
end hook.
4. `run()` is a single line: `yield env.timeout(overhead_ns)`.
5. If a next hop exists, `out_ports[next_hop].put(txn.advance())`. Otherwise
(terminal arrival) consume `drain_ns` and call `txn.done.succeed()`.
### D2. The only timing parameter is `overhead_ns`
Only `node.attrs["overhead_ns"]` is accepted as a latency parameter. The code
default is `0.0`; `topology.yaml`'s IOChiplet `components.pcie_ep.attrs`
supplies the real value (current topology: `overhead_ns: 5.0` ns).
No separate BW-serialization resource (`simpy.Resource`), no queue depth, no
retry model is introduced. Link-level BW serialization is handled wire-side —
inside the IOChiplet by `pcie_ep_to_noc_bw_gbs = 256.0 GB/s`, and externally by
the system's `io_ep_to_switch` link BW (ADR-0015 port/wire model). PCIE_EP
itself takes no part in that accounting.
### D3. PCIE_EP is direction-aware in topology but direction-blind in code
The builder lays both `switch ↔ pcie_ep` and `pcie_ep ↔ io_noc` edges, so
PCIE_EP serves:
- inbound (host → device): forward Transactions arriving from the switch onto
io_noc-side next-hop.
- outbound (device → host): forward Transactions arriving from io_noc/io_cpu
back to the switch.
Both are handled by D1's generic forwarding worker; the component code never
distinguishes direction (it just follows `txn.next_hop`).
### D4. PCIE_EP is not Flit-aware (legacy reassembly path)
`_FLIT_AWARE` is left at the inherited `False`, so `_fan_in` reassembles
upstream-chunkified Flits into the parent Transaction before delivery to
`_inbox` (aligned with ADR-0033 Phase 2c incremental rollout).
A future PCIe TLP-level credit model would revisit D4.
### D5. PCIE_EP is a **named node** for routing helpers
`policy/routing/router.py` provides `find_pcie_ep(sip, io_id="io0")`,
`find_all_pcie_eps()`, and `find_memory_path(pcie_ep, dst_node)` — all of
which treat PCIE_EP as the start (or end) of the memory path. The component
itself supplies no information to these helpers; the naming convention
(`sip{S}.{io_id}.pcie_ep`) is guaranteed by the topology builder.
## Alternatives Considered
### A1. Full PCIe TLP-level model (credits, retry, MPS chunking)
Rejected. Violates ADR-0033's "current latency model = abstract overhead + BW
serialization" simplification. Host↔device protocol fidelity is explicitly
out-of-scope in SPEC §5 "Non-Goals".
### A2. Per-PCIE_EP `simpy.Resource` for in-flight cap
Rejected. Host traffic is not a contention bottleneck in current workloads.
Defer to a separate ADR if it becomes one (in which case D1 stays and D2 is
extended).
### A3. Merge PCIE_EP into IO_CPU
Rejected. PCIE_EP is the protocol-boundary node first hit on the host side;
IO_CPU is the device-side control-plane processing node (ADR-0036). Traffic
fan-out and command decoding costs concentrate in IO_CPU, while PCIE_EP only
expresses link-edge overhead. Merging them would mix two responsibilities and
violate the spirit of ADR-0007 (runtime API/sim_engine boundaries).
## Consequences
- PCIE_EP gets an explicit model ADR despite having near-zero code — consistent
with peer component ADRs, lower maintenance friction.
- Future PCIe-level refinement supersedes by extending D2/D4 in a new ADR.
- D5 makes the named-node dependency explicit, so any future renaming of
component IDs has a clearly bounded blast radius.
@@ -0,0 +1,203 @@
# ADR-0039: PE_MMU Component Model — Component + Utility Dual Role
## Status
Accepted (2026-05-20).
ADR-0011 (PA/VA/LA address model) only states that "the VA model translates
VA→PA via PE_MMU"; this ADR pins down **the PE_MMU component's own behavior
model**.
## First action
At construction, read `node.attrs["page_size"]` (default `2 MiB`) and
`node.attrs["tlb_overhead_ns"]` (default `0.0`) and instantiate the internal
`PeMMU` utility object (`policy.address.pe_mmu.PeMMU`) exactly once. That
object is the single owner of the page table, the sub-page region lists, and
the TLB overhead value.
At runtime the first action splits into two paths:
- **Component path (inbox consumption)**: `_worker` pulls a Transaction off
`_inbox`; if `request` is a `MmuMapMsg`, call `self._mmu.map(va, pa, size)`
for each entry and then `txn.done.succeed()`. For `MmuUnmapMsg`, call
`unmap(va, size)`. Any other type falls through to standard `_forward_txn`.
In other words, **the component's first act is "apply map/unmap commands to
the page table"**.
- **Utility path (direct call)**: a sibling PE engine (PE_DMA / PE_GEMM) calls
`pe_mmu.mmu.translate(va)` directly. This path produces no SimPy events;
the caller (when `overhead_ns > 0`) issues a `yield env.timeout(mmu.overhead_ns)`
in its own process.
## Context
ADR-0011 defined three address models (PA/VA/LA) and agreed that "VA model =
translation via PE_MMU". But in code, `PeMmuComponent` performs two
complementary roles simultaneously:
1. **A topology-graph component**: it receives `MmuMapMsg` / `MmuUnmapMsg`
sideband messages over the cube NoC and updates the page table.
2. **A PE-local utility**: PE_DMA / PE_GEMM on the same PE call
`translate(va)` directly with zero SimPy latency (the caller pays
`overhead_ns` if any).
Without an ADR covering both roles, the following questions are ambiguous:
- "Why isn't there a SimPy event for the MMU translate?" (Answer: the caller
pays it.)
- What is the sub-page region model, and why? (The code docstring has it, but
no ADR — only a memory note `project_mmu_subpage_stopgap`.)
- Who sends map/unmap, and when must they be visible? (Ordering contract.)
Additionally, `PeMMU.map()` has "append, last-write-wins on overlap"
semantics, which is impossible to express with a one-PA-per-entry page table.
That is a deliberate **simulator stopgap** to support DPPolicy sub-page sharding
(e.g., 128 B payloads against 4 KiB pages) without silent last-write-wins
misrouting. This deviation from real HW MMU semantics must be ADR-pinned.
## Decision
### D1. Explicit dual role — component and utility
`PeMmuComponent` exposes two interfaces from a single class:
- Component interface: `_inbox` consumption, `_worker` loop (handles MMU
sideband messages).
- Utility interface: the `mmu` property exposes the underlying `PeMMU` object,
which PE_DMA / PE_GEMM hold directly and invoke `translate()` on.
The latter is **not a layer skip**: inside a PE, the engines and PE_MMU are
siblings under the "components" layer (ADR-0007). Cross-layer violations only
apply to runtime API ↔ sim_engine ↔ components boundaries.
### D2. Latency model — `translate()` is pure; caller owns the timeout
`PeMMU.translate()` is a pure function and yields nothing in SimPy. The caller
(a PE engine) issues `if mmu.overhead_ns > 0: yield env.timeout(mmu.overhead_ns)`
in its own process after translation.
Rationale: the PE engine process already holds its own `record_start` /
`record_end` (op_log) hooks, so keeping timing inside the caller's process
preserves consistent timing accounting. A separate MMU process would split the
engine's processing flow and blur op_log / pipeline overlap semantics.
#### D2.1. Current implementation asymmetry — pipeline vs non-pipeline (known)
At the time of writing, `pe_dma.py` handles MMU overhead differently in its
two call paths:
- **non-pipeline (`handle_command`)**: after `translate()`, applies
`if self._mmu.overhead_ns > 0: yield env.timeout(self._mmu.overhead_ns)`.
- **pipeline (`_do_pipeline_dma`)**: calls `translate()` only, **omitting**
the overhead timeout — though the comment says "same logic as non-pipeline
path", the behaviors differ.
In the default topology, `tlb_overhead_ns = 0.0`, so this asymmetry does not
manifest. With `tlb_overhead_ns > 0`, however, GEMM/Math via the pipeline path
appears MMU-overhead faster than the equivalent non-pipeline workload.
The D2 contract states that **all** callers pay the overhead; the pipeline
omission is **not an intentional design** — ADR-0014 D6 (pipeline self-routing)
does not exempt it. Remediation options (require a separate Phase 1/2):
- (a) Add `if mmu.overhead_ns > 0: yield env.timeout(...)` in
`_do_pipeline_dma` to align with D2 — **preferred**.
- (b) Narrow the D2 contract to "non-pipeline only" and document the pipeline
exemption in an ADR-0014 update — discouraged, since it weakens the
overhead's meaning.
This ADR recommends (a) and assumes a small follow-up change either before or
just after acceptance.
### D3. Page table structure — sub-page region list (stopgap)
`self._table: dict[vpn, list[(start_in_page, end_in_page, pa_at_offset_zero)]]`
holds multiple disjoint regions per page.
- `map(va, pa, size)`: append regions when the range crosses a page boundary.
- `translate(va)`: look up regions for the VPN and iterate **in reverse** so
the most recent overlapping region wins (last-write-wins).
- `unmap(va, size)`: remove only regions whose extent is **fully contained**
within the unmap range; partial-overlap boundaries are left in place and the
caller is expected to unmap on the same boundaries used for map.
This is documented as a **simulator stopgap** that supplements the VA model
from ADR-0011. It prevents silent last-write-wins misrouting when DPPolicy
shards below page granularity. Memory note: `project_mmu_subpage_stopgap`.
### D4. PageFault signals PA fallback
If `translate()` is called with an unmapped VA, `PageFault` is raised. PE_DMA
catches the exception and **uses the original address as a PA** (the PA-only
backward-compatibility path from ADR-0011). PageFault is therefore not an
error — it is the signal for "no VA mapping, interpret as PA".
This path is intentional and preserves backward compatibility with the
ADR-0011 PA-only mode.
### D5. MMU sideband-message reception contract
`MmuMapMsg` / `MmuUnmapMsg` arrive over the fabric at PE_MMU's `_inbox`
(SPEC R10: "MMU map installation incurs measured fabric latency"). Schemas
live in `runtime_api/kernel.py`:
- `MmuMapMsg.entries: tuple[dict, ...]` — each dict is
`{"va": int, "pa": int, "size": int}`.
- `MmuUnmapMsg.entries: tuple[dict, ...]` — each dict is
`{"va": int, "size": int}`.
PE_MMU reception flow:
1. `_worker` does `_inbox.get()` for one message.
2. `hasattr(msg, "request")` confirms a Transaction wrapper.
3. `isinstance(msg.request, MmuMapMsg)` → for each entry, call
`self._mmu.map(va=e["va"], pa=e["pa"], size=e["size"])`.
4. `isinstance(msg.request, MmuUnmapMsg)` → for each entry, call
`self._mmu.unmap(va=e["va"], size=e["size"])`.
5. Both signal `msg.done.succeed()` after completion.
An external caller (runtime API) `await`ing `done` therefore receives a SimPy
guarantee that "the mapping is installed on-device" — this is the realization
of ADR-0011's "MMU map installation incurs measured fabric latency".
This ADR does **not** define the **sender or fan-out policy** for the sideband
message — those are runtime API responsibilities. Only the receive contract
belongs here.
### D6. Non-MMU Transactions delegate to generic forwarding
If a message pulled from `_inbox` is not `MmuMapMsg` / `MmuUnmapMsg` (or
lacks a `request` attribute), `_forward_txn` handles it normally. This keeps
the door open for future topologies where PE_MMU sits on a pass-through path —
current code never sends such traffic, but the routing remains safe.
## Alternatives Considered
### A1. Make `translate()` a SimPy generator
Rejected. As D2 explains, this blurs op_log / pipeline overlap accounting in
the PE engine.
### A2. Use small page size (e.g., 128 B) instead of sub-page regions
Rejected. Would explode page-table memory and cube-wide map message size. Most
mappings are 2 MiB; pushing the page size below that for the few DPPolicy
sharding cases inflates average cost.
### A3. Make PE_MMU a PE_CPU helper only (not a topology node)
Rejected. ADR-0011 requires that MMU map installation incur measured fabric
latency (via `MmuMapMsg`), which requires PE_MMU to be a node on the graph.
It also keeps cube NoC visualizer output consistent.
## Consequences
- PE_MMU's dual role is justified at ADR level, so future "unify into one"
refactor pressure has a documented counterpoint.
- The sub-page region model is explicitly labeled a stopgap, providing a
basis for deprecating it when LA model (ADR-0011) lands.
- The "`translate()` does not yield" contract is locked in (D2), so any
future proposal to add an internal MMU timeout can be denied with a
documented rationale.
- PA fallback (D4) is normalized, preventing defensive logic from treating
PageFault as an error.
@@ -0,0 +1,149 @@
# ADR-0040: PE_TCM Component Model — Dual-Channel BW Serialization
## Status
Accepted (2026-05-20).
ADR-0014 (PE Pipeline Execution Model, D1) references PE_TCM as a "BW-based
serialized scratchpad memory" but does not pin down the component's own model.
This ADR fills that gap.
## First action
When `start()` is invoked, immediately create two `simpy.Resource(env, capacity=1)`
instances and store them in `self._read_res` / `self._write_res`. These two
resources are the single decision points that serialize the **read channel**
and **write channel** to one in-flight request each.
The runtime first action: `_worker` pulls a message off `_inbox` and branches
by type:
- `TcmRequest` (from `pe_fetch_store`): spawn `env.process(self._handle_tcm_request)`.
Hence **TCM's first act is "acquire the lock matching the direction
(read/write)"**. After lock acquisition, if `bw > 0 and nbytes > 0`, yield
`env.timeout(delay_ns = nbytes / bw)`, then `req.done.succeed()`.
- Anything else (Transaction): spawn `env.process(self._forward_txn)` (legacy
fabric pass-through).
At construction, `node.attrs["read_bw_gbs"]` and `node.attrs["write_bw_gbs"]`
(default `512.0 GB/s` each) are captured and held.
## Context
In the PE pipeline (ADR-0014 D1, D6), PE_TCM receives two kinds of traffic:
1. **`TcmRequest` from PE_FETCH_STORE** — when moving data between TCM and
the register file, PE_FETCH_STORE sends a short sideband request to obtain
BW-serialized access latency (`direction = "read"` or `"write"`, `nbytes`,
`done` event).
2. **Legacy Transaction forwarding** — a fallback in case TCM ends up as a
pass-through node on the fabric graph (not used by the current critical
path, but preserved).
The problem: ADR-0014 only says "BW-based serialization" without specifying:
- Read and write are **independent channels** running in parallel; only
same-direction concurrency serializes at `capacity=1`.
- BW is split into two configurable values (`read_bw_gbs` / `write_bw_gbs`).
- The formula is `delay_ns = nbytes / bw_gbs` (loose unit convention:
GB/s × ns ≈ B).
- `nbytes == 0` still acquires the lock but skips the BW term.
- `run()`'s `overhead_ns` (default `0.0`) is only used in the legacy fabric
forwarding path.
Each of these requires an ADR. In particular, "why are read and write
separate channels" and "who owns the BW values" must be documented so that
future changes (e.g., `capacity=2`) have a clear basis.
## Decision
### D1. Dual channel — read and write are independent resources
`_read_res = simpy.Resource(env, capacity=1)`,
`_write_res = simpy.Resource(env, capacity=1)`.
Same-direction concurrent requests queue on the resource and serialize;
opposite-direction requests proceed in parallel. This matches the hardware
model where TCM has a dual-port (read + write) configuration, and it allows
the simulator to express the GEMM-pipeline case where fetch (read) and store
(write) overlap in time — modeled as BW-serialized inside each direction but
independent across directions.
### D2. Per-channel BW model — `nbytes / bw_gbs`
After lock acquisition, if `nbytes > 0 and bw > 0`, yield
`env.timeout(nbytes / bw_gbs)`. The unit convention is GB/s × ns ≈ B,
consistent with the simulator-wide loose convention (see ADR-0033).
- `nbytes == 0`: BW term is zero, but the lock is acquired and released. This
is intentional: when a plan generator emits an empty fetch/store on the
PE_FETCH_STORE side, the op_log / channel accounting on the TCM side still
records one consumption.
- `bw == 0` (config error): the timeout call is skipped (0-time pass). Should
not occur with normal settings.
### D3. BW values come from `node.attrs.read_bw_gbs` / `write_bw_gbs`
Defaults `512.0 GB/s`. The topology builder (`topology/builder.py`) passes
these attrs when instantiating TCM from `pe_template`. Default changes should
coincide with related decisions in ADR-0014 D1 or ADR-0033.
### D4. TcmRequest schema is owned by PE_TCM
`@dataclass TcmRequest(direction: str, nbytes: int, done: simpy.Event, tag: str = "")`
lives in `components/builtin/pe_tcm.py`. PE_FETCH_STORE imports the dataclass
and only constructs/sends it. The caller does not define the schema because:
- The meaning of BW serialization is TCM's responsibility — TCM decides which
fields drive serialization.
- The valid-value check for `direction` (must be `"read"` or `"write"`) lives
in `_handle_tcm_request`'s if/else branch.
### D5. Legacy Transaction forwarding path is preserved
When `_worker` receives a non-`TcmRequest` message, it dispatches to
`_forward_txn`, applying `run()`'s `overhead_ns`. The current standard PE
pipeline does not route Transactions through TCM, but the path is kept to
avoid breakage if fabric topology changes.
This path is accounted for via standard Transaction op_log; the BW channel
locks are **not** acquired (orthogonal to D1's usage).
### D6. PE_TCM is not a data store (timing only)
TCM models **time only**. The actual data payload is held by sim_engine's
`memory_store` (when present); the TCM component never updates it.
PE_FETCH_STORE obtains BW delay through `TcmRequest`, and register contents
are handled separately in the data path (ADR-0020 2-pass data execution —
Phase 2).
## Alternatives Considered
### A1. Single channel (`capacity=2` for shared read+write)
Rejected. Would artificially serialize the normal-case overlap of fetch
(read) and store (write) and yield an incorrect BW upper bound for the PE
pipeline.
### A2. `capacity > 1` (e.g., 2-banked TCM)
Rejected. Current hardware model assumes a single bank. Multi-bank extension
needs its own ADR that would supersede D1. Bumping capacity now would loosen
the nominal serialization without raising the BW upper bound, producing less
accurate modeling.
### A3. Generalize BW formula to `nbytes / bw + overhead_ns`
Rejected. `overhead_ns` is reserved for the legacy forwarding path (D5).
Additional fetch/store-path overhead, if needed, belongs in PE_FETCH_STORE's
`run()` or in a register-file access model — closer to the responsibility
boundary.
## Consequences
- TCM's BW accounting is locked at ADR level. Questions arising from op_log
in GEMM/Math sweeps — "why did fetch and store overlap?", "why do only
same-direction requests serialize?" — resolve quickly to D1.
- Future multi-bank TCM models or asymmetric read/write BW changes have a
clear blast radius (D1 / D2 / D3 — pick one).
- D6 ("TCM is not a data store") sharpens the responsibility boundary with
ADR-0020 2-pass execution.
@@ -0,0 +1,195 @@
# ADR-0041: Cube SRAM Component Model — terminal scratchpad on cube NoC
## Status
Accepted (2026-05-20).
ADR-0017 (Cube NOC and HBM Connectivity) describes SRAM as a cube-NoC
attachment but does not specify the SRAM component's own latency / response
model. This ADR fills that gap.
## First action
Inside `_worker`, immediately after pulling a Transaction off `_inbox`, the
very first action is `yield from self.run(env, txn.nbytes)`. Inside `run()`,
the component applies `env.timeout(node.attrs["overhead_ns"])`
(default `0.0`).
In short, **SRAM's first act is "express access overhead as simulator time"**.
After overhead, the worker yields `drain_ns` (the terminal BW-serialization
cost stamped on the Transaction) and then constructs and dispatches a
`ResponseMsg` on the reverse path.
This differs from a generic `ComponentBase._worker`: SRAM knows it is a
**terminal node**, so it does not go through `_forward_txn`. Its own worker
explicitly performs `run → drain → _send_response`.
## Context
The cube topology (`topology/builder.py`) creates the following named nodes
per cube:
- `sip{S}.cube{C}.m_cpu`
- `sip{S}.cube{C}.sram`
- `sip{S}.cube{C}.hbm_ctrl` (per-PE partitions)
- `sip{S}.cube{C}.pe{P}` (and its PE-internal sub-components)
SRAM is one of the cube-NoC attachments — `topology/mesh_gen.py` assigns it
to the nearest router by placement coordinates and adds `"sram"` to that
router's `attach` list. The builder lays bidirectional `sram ↔ router` edges
(BW: `sram_to_router_bw_gbs`, default `128.0 GB/s`).
SRAM has two intertwined roles:
1. **Fabric terminal**: the endpoint for cube-NoC memory-access Transactions
destined for SRAM. SRAM consumes access overhead + drain, then sends a
response back on the reverse path.
2. **One of the IPCQ slot tiers**: ADR-0023 D9.7 defines
`buffer_kind ∈ {tcm, sram, hbm}`; the `sram` tier's per-access cost is
`(512.0 GB/s, 2.0 ns)` in `common/ipcq_types._BUFFER_KIND_BW`. This is
separate from the SRAM node's `overhead_ns` attr; PE_DMA accounts for it
directly at the IPCQ slot-write moment.
Without an ADR covering both roles, the following questions are ambiguous:
- "What latency does SRAM model?" — fabric drain + overhead, or the IPCQ
tier slot latency? — answers scatter.
- What does the `size_mb` (`32`) attr mean in the future? Currently it is not
used; SRAM only models timing.
- Which cube router does SRAM attach to? (placement-based; lives in topology
code only.)
## Decision
### D1. SRAM is a terminal scratchpad node on the cube NoC
`SramComponent` extends `ComponentBase` but overrides `_worker` to express
terminal semantics directly:
```
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)
```
This pattern is necessary because SRAM must know the reverse path; the
generic `_forward_txn` (which forwards to the next hop) does not fit a
terminal.
#### D1.1. Currently dormant — the `_worker` override is an unused path
At the time of writing, **no component actually sends a Transaction to the
SRAM node**. The verified references to the SRAM node ID are:
- `policy/routing/router.py` and friends — guarantee path lookups.
- `components/builtin/pe_dma.py::_handle_ipcq_inbound` — for
`buffer_kind == "sram"`, computes the *path* to
`bank_node = f"{cube_prefix}.sram"` via `compute_drain_ns(path, ...)` and
yields a **local** timeout. The Transaction itself does not flow to the
SRAM node (see D4).
- `tests/test_routing.py` — checks connectivity via
`find_path("sip0.cube0.pe0", "sip0.cube0.sram")`.
So the `_worker` / `_send_response` override is currently a **dormant code
path**. It is preserved deliberately:
- Topology changes that route fabric Transactions to SRAM terminally (e.g.,
explicit M_CPU → SRAM accesses) would activate it immediately.
- ADR-0017's "cube-attached scratchpad" semantics naturally implies terminal
behavior; the override is an intentional placeholder.
A future ADR (or a revision to this one) will mark dormancy resolved when an
actual sender is added.
### D2. ResponseMsg construction and reverse-path dispatch
`_send_response`:
1. `reverse_path = list(reversed(txn.path))` — derive the reverse path.
2. Construct `ResponseMsg(correlation_id=txn.request.correlation_id,
request_id=..., src_cube=<this cube>, src_pe=-1, success=True)`.
3. Wrap in `Transaction(request=resp_msg, path=reverse_path, step=0,
nbytes=0, done=env.event(), is_response=True)` and put on
`out_ports[reverse_path[1]]`.
4. If the reverse path is too short (`< 2 hops`) or `ctx` is absent, fall
back to calling the original `txn.done.succeed()`.
`src_pe = -1` means "SRAM is not PE-localized". `src_cube` is parsed from the
node ID (`sip{S}.cube{C}.sram`).
### D3. Timing parameters: `overhead_ns` and wire-side `drain_ns`
- **Component-side latency**: `node.attrs["overhead_ns"]`. Default topology
uses `2.0 ns`.
- **Link-side serialization**: `drain_ns` arrives stamped on the Transaction
— the wire-side BW serialization result from ADR-0015. SRAM only yields it.
- The `size_mb` (default `32 MiB`) attr is currently timing-neutral. If a
capacity-aware model is added in the future, a separate ADR will give it
meaning.
### D4. IPCQ slot accounting is not modeled by the SRAM component
Per ADR-0023 D9.7, the IPCQ slot-write latency for the SRAM tier is incurred
inside PE_DMA's `_handle_ipcq_inbound`, which calls
`slot_io_latency_ns("sram", nbytes)` using `_BUFFER_KIND_BW["sram"]`. That is:
- When SRAM receives a fabric Transaction (D1, D2, D3 apply), it processes
normally.
- When an IPCQ slot lives on SRAM, PE_DMA pays the slot-write time directly —
independent of the SRAM component.
This separation is intentional: IPCQ is a fast path (sub-cycle slot
bookkeeping) and does not traverse fabric Transactions, so SRAM does not need
to know about IPCQ.
### D5. SRAM's cube-NoC attachment is placement-driven
`topology/mesh_gen.py` reads `placement.sram.pos_mm` (default `[1.5, 9.0]` in
`topology.yaml`) and adds `"sram"` to the nearest router's `attach`. The
builder (`topology/builder.py`'s attachment loop) then lays bidirectional
edges between the `sram` node and that router.
This decision lives outside the SRAM component (mesh_gen / builder); the
component does not know which router it sits on. It only relies on
`txn.path` / `reverse_path` to reach it via a router.
### D6. SRAM is not a data store (timing only)
Same context as ADR-0040 D6: the SRAM component models time only; the data
payload (if any) lives in sim_engine's `memory_store`.
## Alternatives Considered
### A1. Use `_forward_txn` and route responses via separate nodes (à la IO_CPU / HBM_CTRL)
Rejected. SRAM is a terminal on the cube NoC; adding a response node would
introduce meaningless hops and violate ADR-0017's simplification spirit.
### A2. Model BW serialization inside SRAM with its own resource
Rejected. Wire-side BW serialization (`drain_ns`) already captures it. An
internal `simpy.Resource` would double-count against ADR-0015 (port/wire
model).
### A3. Handle IPCQ slot accounting in the SRAM component
Rejected. As D4 makes explicit, IPCQ is a fast path that does not traverse
fabric Transactions. If SRAM knew about IPCQ, the responsibility would split
across two places and obscure reasoning.
### A4. Capacity-aware latency from `size_mb`
Rejected for now. The capacity is currently a visualizer label; introducing
a capacity-aware timing model requires a dedicated ADR.
## Consequences
- SRAM's timing model is pinned at ADR level as
`overhead_ns + drain_ns + ResponseMsg(reverse_path)`. Any proposal to push
IPCQ slot latency into the SRAM component can be refused with D4.
- D3 records that `size_mb` is timing-neutral today, so a future
capacity-aware model has a narrow compatibility scope.
- D5 documents the placement-driven attachment, so changes to the SRAM
coordinate have a clearly bounded impact (`mesh_gen` only).
@@ -0,0 +1,199 @@
# ADR-0042: Tile Plan Generators — GEMM/Math Pipeline Plan Builders
## Status
Accepted (2026-05-20).
This ADR pins down `tiling.py` as a **plan-generator
module**, not a SimPy component.
ADR-0014 (PE Pipeline Execution Model) D6 (tile plan / self-routing) does not
specify the tile-plan generation algorithm itself; this ADR fills that gap.
## First action
When `generate_gemm_plan(M, K, N, tile_m, tile_k, tile_n, ..., pe_prefix,
a_pinned, b_pinned, epilogue_specs)` is called, the very first action is
**computing tile counts and constructing the PE-component ID strings**:
```
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"
```
In short, **the plan generator's first act is "compute ceiling tile counts
and assemble the four sub-component IDs for this PE once"**. No SimPy event
or environment is touched — this module is a pure function.
`generate_math_plan(M, N, tile_m, tile_n, ..., math_op, src_addr, dst_addr,
pe_prefix)` likewise begins by computing `M_tiles`, `N_tiles` and assembling
three component IDs (`dma_id`, `fetch_id`, `math_id`).
## Context
ADR-0014 D6 agreed that "PE_SCHEDULER, on receiving a CompositeCmd, generates
a TilePlan and feeds self-routing tile tokens". But the **concrete plan
generation algorithm** lives in `src/kernbench/components/builtin/tiling.py`,
which:
- Defines no component — it is a pair of **pure functions**
(`generate_gemm_plan`, `generate_math_plan`).
- Does not depend on the SimPy environment, queues, op_log, or hooks.
- Returns a `PipelinePlan` (dataclass).
The original G4 analysis incorrectly described `tiling.py` as a component;
it is in fact a plan-builder helper consumed by PE_SCHEDULER. Pinning this
down in its own ADR (paired with ADR-0014 D6) prevents:
- Ambiguity over whether plan generation belongs to PE_SCHEDULER or a
separate module.
- Inconsistent rationale for stage sequences (e.g., FETCH/STORE position)
between GEMM and Math plans.
- Undocumented branching rationale for `a_pinned` / `b_pinned` /
`epilogue_specs`.
## Decision
### D1. `tiling` is a pure plan-generator module, not a component
`components/builtin/tiling.py` defines no `ComponentBase` subclass. It exports
two module-level functions:
- `generate_gemm_plan(...) -> PipelinePlan`
- `generate_math_plan(...) -> PipelinePlan`
There is no `tiling` node in the topology graph. It lives in `builtin/`
because it is a direct helper for PE_SCHEDULER (ADR-0014 D6) and is
conceptually a PE_SCHEDULER internal utility.
### D2. GEMM plan stage sequence — `M → N → K` order
For each `(m, n, k)` tile (default — no operand pinning, no epilogue):
```
[DMA_READ(A)] → [DMA_READ(B)] → FETCH → GEMM
(last k tile only) [MATH(output_tile)]* → STORE → DMA_WRITE
```
`k_tile` epilogue inserts a MATH stage immediately after GEMM on every
K-tile; `output_tile` epilogue inserts MATH stages once per `(m, n)` after
the final K-tile but before STORE/DMA_WRITE. The K-loop accumulator stays
in the register file across K-tiles — STORE/DMA_WRITE happens only when
`last_k`.
### D3. Operand pinning — `a_pinned` / `b_pinned`
If a caller passes `a_pinned=True`, **the A DMA_READ is omitted from every
(m, n, k) tile**. Semantically: the caller (e.g., `tl.composite`) has already
staged all of A in TCM via a prior `tl.load`, and signals so to the plan
generator.
The branch is made at plan time (not at runtime). Therefore the stage record
count in op_log changes deterministically with pinning, and sweep analyses
(e.g., gemm_sweep's stage record count) see this decision directly.
### D4. Epilogue scope — `k_tile` vs `output_tile`
`epilogue_specs` is an iterable of op-spec objects. Each op object is
expected to have:
- `op.kind: str` — math op name (e.g., `"dequant"`, `"bias"`, `"relu"`,
`"scale"`). Placed into the stage's `params["op_kind"]`.
- `op.scope: Scope``Scope.K_TILE` or `Scope.OUTPUT_TILE` (`Scope` enum
in `kernbench.common.pe_commands`).
- Op-specific extras (e.g., `bias`, `scale`, `factor`) — currently not used
by the plan generator; consumed at runtime by PE_MATH.
The plan generator partitions by `getattr(o, "scope", None)`:
- `scope == Scope.K_TILE`: adds a MATH stage right after GEMM on every K-tile.
- `scope == Scope.OUTPUT_TILE`: adds MATH stages just before STORE on the
last K-tile per `(m, n)`.
Ops with neither `scope` value (e.g., missing attribute) are **dropped
silently** — `getattr(..., None) == Scope.X` is False for both. Picking a
default (`output_tile`) is the **caller's responsibility** (e.g.,
`tl.composite`), not the plan generator's. This aligns with ADR-0014's
composite epilogue contract.
`Scope` is imported lazily inside the function to avoid the circular path
`pe_commands ← pe_types ← tiling`. This is intentional and not a refactor
target — keeping `tiling` free of compile-time `pe_commands` dependencies
preserves the module boundary (D1).
### D5. Math plan stage sequence — `M → N` order
For each `(m, n)` tile:
```
DMA_READ → FETCH → MATH → STORE → DMA_WRITE
```
There is no K dimension, so concepts like epilogue or accumulator residency
do not apply. PE_FETCH_STORE's register-file accounting follows the same
pattern as the GEMM plan.
### D6. Plans are data — no SimPy dependency
`PipelinePlan` is a dataclass in `pe_types.py` holding `tiles:
list[TilePlan]`. Each `TilePlan` holds `stages: tuple[Stage, ...]`. The plan
itself is near-immutable (only `Stage.params: dict` is mutable) and holds no
SimPy objects.
At runtime, PE_SCHEDULER consumes the plan's first stage, builds a `TileToken`,
and feeds it into the pipeline. The TileToken carries `plan: TilePlan`,
`stage_idx: int`, and a cached `params: dict`. Self-routing proceeds by
`TileToken.advance()` caching the next stage's `params` (ADR-0014 D6).
### D7. Plan generator contract — pure, deterministic, idempotent
Two calls with identical inputs return identical `PipelinePlan` instances
(including `TilePlan.stages` order). This contract aligns with ADR-0014 D6's
"deterministic tile dispatch order".
No side effects (no SimPy events, no file I/O, no global state) — tests can
call the generators directly without an environment object (some cases in
`tests/test_pe_pipeline.py` rely on this).
## Alternatives Considered
### A1. Make tiling a component (e.g., PE_PLANNER)
Rejected. Plan generation consumes no SimPy time — it is a pure decision
algorithm. Making it a component would (a) add unnecessary infrastructure
(inbox, resources), and (b) split PE_SCHEDULER's flow into "receive plan"
plus "feed tiles", inserting a meaningless hop.
### A2. Move plan generation into PE_SCHEDULER as methods
Rejected (currently). Module separation provides (1) testability and
(2) extensibility for additional plan algorithms (e.g., DTensor-aware) —
add a new function. If plan kinds proliferate enough to require explicit
dispatch, a future ADR can introduce a plan factory on PE_SCHEDULER.
### A3. Make plans fully immutable (frozen dataclass + tuple)
Partially adopted. `Stage` and `TilePlan` are dataclasses but not frozen,
because `Stage.params: dict` is populated at plan-generation time and read
at runtime (cached by TileToken on advance). Moving dict → frozendict pays
migration cost without enough benefit. Convention: do not mutate after
generation.
## Consequences
- `tiling.py` is documented as a plan-generator module, not a component —
preempting future G4-style "this component lacks an ADR" analyses.
- The GEMM plan's stage sequence (D2) and pinning / epilogue branching
(D3 / D4) are pinned, providing a clear interpretation basis for sweep
analyses (e.g., `scripts/gemm_sweep.py`'s stage record counts).
- The plan generator's pure contract (D7) enables environment-free testing
in line with ADR-0013 (verification strategy).
- Future plan kinds (DTensor-aware, K-major, ...) follow D1 / D6 / D7 as a
baseline — just add a new function.