adr: add ADR-0050-0053 — close /report's second-pass G4 candidates

Documents four cross-cutting surfaces one layer deeper than the prior
G4 batch:

- 0050 par-ccl-algorithm-module-contract: how to author a new CCL
  algorithm in src/kernbench/ccl/algorithms/. Pairs with ADR-0045's
  bench-module contract. Pins the four required public symbols
  (kernel, kernel_args, TOPO_NAME_TO_KIND constants, kernel alias),
  the 9 + tl standardized kernel signature, the kernel_args tuple
  format, sip_topo_kind dispatch, and the ccl.yaml entry workflow.

- 0051 lat-routing-helper-api: every public method of AddressResolver
  (resolve, find_m_cpu, find_pcie_ep, find_io_cpu, find_all_pcie_eps)
  and PathRouter (find_path, find_path_with_distance,
  find_mcpu_dma_path, find_memory_path, find_node_path + 2 shims).
  Pins the four adjacency graphs (_adj_all / _adj / _adj_mcpu_dma /
  _adj_local) and the edge-kind exclusion sets they use, plus the
  single-owner naming convention.

- 0052 dev-oplog-memory-store-schemas: OpRecord's 7 fields, the
  per-op_name params matrix (dma_read, dma_write, gemm_*, math, math
  reduction, composite_gemm, ipcq_copy, unknown), snapshot timing
  rules (math = all inputs, dma_write = HBM-only — ADR-0027 race
  avoidance), TileToken stage_type capture, and MemoryStore's
  (space, addr) two-level dict with reference-store semantics.

- 0053 dev-topology-builder-algorithms: the 6-stage compile pipeline,
  cube_mesh.yaml's source_hash cache and its 5 input fields, the
  cube NoC auto-layout algorithm (row/col placement, HBM exclusion
  zone, PE/M_CPU/SRAM attachment via nearest-router, UCIe N/S/E/W
  distribution), the node naming convention (single-owner with
  router.py), the edge-kind catalog, the 4 view projections, and a
  table of spec-field changes vs mesh regeneration.

Bilingual pair verifier passes for all four EN/KO pairs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 10:52:42 -07:00
parent 9a02955770
commit bd49c93703
8 changed files with 2566 additions and 0 deletions
@@ -0,0 +1,352 @@
# ADR-0052: OpLog + MemoryStore Schemas — sim_engine internals
## Status
Accepted (2026-05-22).
`sim_engine/op_log.py``OpRecord` 스키마와 `OpLogger` 의 record_start /
record_end / record_copy 동작, 그리고 `sim_engine/memory_store.py`
`MemoryStore` 가 사용하는 (space, addr) 주소공간 namespace 와 read/write
의미를 명시한다. ADR-0020 (2-pass data execution) 가 두 인프라의 존재를
선언하나, **레코드의 정확한 필드와 의미** 는 ADR-level 에서 정리되지
않았고 ADR-0046 D3.2 (`tl.store` visibility), ADR-0023 D9 (IPCQ copy
record) 등 여러 ADR 이 이들의 동작에 의존하고 있다.
## First action (제일 처음에 하는 일)
### `OpLogger(memory_store=None)`
생성 즉시 다음 3 가지 필드 초기화:
1. `self._records: list[OpRecord] = []` — 누적된 op record.
2. `self._pending: dict[int, dict] = {}``id(msg)` 키로 partial record
(record_start 시점에 만들어졌고 record_end 가 아직 안 온 것).
3. `self._memory_store = memory_store` — 옵션 MemoryStore reference.
math op 의 input 스냅샷 + dma_write 의 HBM source 스냅샷 캡처에 사용.
생성 시점에는 records / pending 모두 비어 있으며, `record_*` 호출이
순차적으로 데이터를 누적한다.
### `MemoryStore()`
생성 즉시 `self._storage: dict[str, dict[int, np.ndarray]] = {}` 단 하나
의 필드 초기화. 두 단계 dict (`space → addr → ndarray`) 이며 lazy 하게
필요한 space 가 생길 때마다 inner dict 가 채워진다.
즉, **두 인프라의 첫 일은 "비어 있는 누적 buffer + space-별 sparse dict
를 만들어 두는 것"** 이다. 첫 record / write 가 실제로 도착하면 그때
필드가 채워지기 시작한다.
## Context
ADR-0020 (2-pass data execution) 의 D2/D5/D7 가 다음을 선언:
- Phase 1 (timing) 동안 `ComponentBase._on_process_start/end` hook 이
`OpLogger.record_start/end` 를 호출하여 모든 data op 의 시간 + 메타
데이터를 기록.
- Phase 2 (data) 가 op_log 를 t_start 순으로 재생하여 실 데이터 결과를
계산.
- 데이터 페이로드 자체는 `MemoryStore` 에 (space, addr) 키로 보관.
ADR-0023 D9 (IPCQ atomic write), ADR-0027 (Megatron TP scratch
overwrite 회피), ADR-0046 D3.2 (`tl.store` visibility) 등 후속 ADR 들이
op_log 와 MemoryStore 의 동작에 의존하지만, **정확한 record 필드 / space
이름 / 스냅샷 시점** 은 코드 grep 으로만 확인 가능하다. 본 ADR 이 이를
정리한다.
## Decision
### D1. `OpRecord` 스키마 — 7 개 필드
```python
@dataclass
class OpRecord:
t_start: float
t_end: float
component_id: str
op_kind: str # "memory" | "gemm" | "math" | "unknown"
op_name: str # e.g. "dma_read", "gemm_f16", "exp",
# "TileToken/DMA_READ", "composite_gemm",
# "ipcq_copy"
params: dict[str, Any]
dependency_ids: list[int] = field(default_factory=list)
```
- **`t_start` / `t_end`**: SimPy 시간 (float ns). `t_start` 는 component
가 op 를 시작한 시점, `t_end` 는 완료 시점. duration = `t_end - t_start`.
- **`component_id`**: op 가 발생한 node id (예:
`"sip0.cube0.pe0.pe_dma"`).
- **`op_kind`**: 4 가지 중 하나. Phase 2 DataExecutor 가 이 값으로 분기.
- **`op_name`**: 디버깅 / 분석용 사람-친화 이름. TileToken 일 경우
`"TileToken/{stage_type}"` (예: `"TileToken/DMA_READ"`) 로 stage 를
구분.
- **`params`**: op-종속 메타데이터 dict (D3 참고).
- **`dependency_ids`**: 현재 사용되지 않음 (default `[]`). 향후 cross-op
dependency 추적이 필요해질 때를 위한 자리.
### D2. `OpLogger.records` — t_start 정렬 보장
```python
@property
def records(self) -> list[OpRecord]:
self._records.sort(key=lambda r: r.t_start)
return self._records
```
매 접근 시 `t_start` 로 stable sort. 즉 같은 t_start 인 record 들은 insertion
순서를 유지. ADR-0020 D5 의 "t_start stable ordering" 요구와 정합.
Phase 2 DataExecutor 는 항상 `records` property 를 통해 접근하므로,
record_end 호출이 t_start 와 다른 순서로 도착해도 (예: 짧은 op 가 긴
op 보다 늦게 시작했으나 먼저 끝남) 재정렬되어 일관된 시퀀스를 받는다.
### D3. op_name 별 `params` 스키마 (`_extract_op_info` 매핑)
#### D3.1. `op_kind="memory", op_name="dma_read"` (DmaReadCmd)
```python
{"src_addr": int, "nbytes": int, "handle_id": str}
```
#### D3.2. `op_kind="memory", op_name="dma_write"` (DmaWriteCmd)
```python
{
"src_space": str, # handle.space ("tcm"|"hbm"|"sram"), default "tcm"
"src_addr": int, # handle.addr
"shape": tuple, "dtype": str,
"dst_space": "hbm", # DmaWrite 는 항상 HBM 으로
"dst_addr": int,
"nbytes": int,
"handle_id": str,
# record_end 시점에 src_space == "hbm" 이면 snapshot 추가 (D4)
"snapshot": np.ndarray | None,
}
```
#### D3.3. `op_kind="gemm", op_name=f"gemm_{dtype_a}"` (GemmCmd)
```python
{
"src_a_addr": int, "src_b_addr": int, "dst_addr": int,
"shape_a": tuple, "shape_b": tuple, "shape_out": tuple,
"dtype_in": str, "dtype_out": str,
"m": int, "k": int, "n": int,
# ADR-0027: per-operand + output spaces 보존
"src_a_space": str, "src_b_space": str, "dst_space": str,
}
```
#### D3.4. `op_kind="math", op_name=msg.op` (MathCmd; op = "exp", "sum", "add", "where" 등)
```python
{
"input_addrs": list[int], # 입력 핸들들의 addr
"input_shapes": list[tuple],
"input_spaces": list[str],
"input_dtypes": list[str],
"dst_addr": int, "dst_space": str,
"shape_out": tuple, "dtype": str,
"axis": int | None, # reduction 인 경우만 의미 있음
# record_end 시점에 모든 input 의 스냅샷이 채워짐 (D4)
"input_snapshots": list[np.ndarray | None],
}
```
#### D3.5. `op_kind="gemm" or "math", op_name=f"composite_{op}"` (CompositeCmd)
```python
{
"op": str, # "gemm" | "math"
"out_addr": int, "out_nbytes": int,
# op == "gemm" 인 경우 GemmCmd 와 같은 필드 추가:
"src_a_addr": int, "src_b_addr": int,
"shape_a": tuple, "shape_b": tuple,
"dtype_in": str, "dtype_out": str,
"src_a_space": str, "src_b_space": str,
"dst_space": "hbm", "dst_addr": int, # = out_addr
}
```
`op == "gemm"` 이면 `op_kind = "gemm"`, 아니면 `"math"`. Phase 2 측에서
GemmCmd 와 동일 path 로 재생되도록 alias.
#### D3.6. `op_kind="memory", op_name="ipcq_copy"` (record_copy 전용 경로)
```python
{
"src_space": str, "src_addr": int,
"dst_space": str, "dst_addr": int,
"shape": tuple, "dtype": str, "nbytes": int,
"snapshot": np.ndarray | None, # 호출자가 전달, 없으면 record_copy 가 fresh read
}
```
`PE_DMA._handle_ipcq_inbound` (ADR-0023 D9) 가 이 record 를 발사하여 IPCQ
slot 의 inbound copy 를 Phase 2 가 재생 가능하게 한다. 이 record 는
`record_start` / `record_end` 를 거치지 않고 직접 `record_copy()` 로 push.
#### D3.7. `op_kind="unknown", op_name=type(msg).__name__`
`_extract_op_info` 가 인식 못 한 message 의 fallback. params = `{}`.
DataExecutor 가 이 op_kind 를 만나면 skip — Phase 2 replay 에 영향 없음.
### D4. snapshot 캡처 시점
`OpLogger._memory_store` 가 set 되어 있을 때 record_end 가 다음을 수행:
- **math op**: 모든 input addr/shape/space/dtype 으로
`self._memory_store.read(...)` 를 호출하여 `params["input_snapshots"]`
ndarray copy 첨부. read 실패 시 None.
- **dma_write op**: `src_space == "hbm"` 인 경우에만 source HBM 의
스냅샷을 `params["snapshot"]` 에 첨부. TCM source 는 **명시적으로
스킵** — TCM (PE scratch) 은 Phase 2 math/gemm 재생이 다시 채우므로,
Phase-1-time snapshot 을 잡으면 이전 kernel 의 stale 데이터를 잡을 위험
(ADR-0027 postmortem: TP gemm → all_reduce race).
- **ipcq_copy**: `record_copy` 호출자가 `snapshot=token.data` 같이 in-flight
스냅샷을 전달. 없으면 record_copy 가 fresh read 로 대체 시도.
스냅샷은 `.copy()` 가 호출되어 (`ndarray.copy()` 가 fresh allocation) 이후
storage mutation 으로부터 안전. ADR-0027 의 "cross-PE Phase 2 ordering"
race 회피의 근간.
`memory_store` 가 None 인 경우 (Phase 1 timing-only 모드) 스냅샷 단계는
전부 skip. record 의 timing 정보만 보존되며 데이터 replay 는 불가능.
### D5. TileToken 처리 — record_start 가 stage 정보를 캡처
ADR-0014 D6 의 self-routing tile token (pipeline 모드) 은 stage_idx 가
record_end 시점에 이미 advance 되어 있을 수 있다 (TileToken 이 다음
component 로 이동하면서 next stage 의 params 를 캐시). 따라서:
`record_start` 가 다음을 `pending[id(msg)]["snap"]` 에 미리 저장:
```python
snap["stage_type"] = stage.stage_type.name # "DMA_READ", "GEMM", 등
snap["stage_params"] = dict(stage.params) # 시점의 params 복사본
```
`record_end` 에서 이 snap 을 꺼내 params 에 merge:
- `params["stage_type"]` 가 final params 에 추가.
- `stage_params` 의 key 들이 (이미 있으면 보존) merge.
- `op_name == "TileToken"` 이면 `op_name = f"TileToken/{stage_type}"`
rewrite (예: `"TileToken/DMA_READ"`) — 같은 component 에서 발생한 서로
다른 stage 의 record 를 disambiguate.
이 메커니즘 덕분에 DMA_READ vs DMA_WRITE, FETCH vs STORE 가 같은 component
(예: pe_dma) 에서 발생하더라도 reporting 측에서 구분 가능.
### D6. `MemoryStore` — (space, addr) 두 단계 dict
```python
class MemoryStore:
def __init__(self) -> None:
self._storage: dict[str, dict[int, np.ndarray]] = {}
def write(self, space, addr, data): self._storage[space][addr] = data
def read(self, space, addr, shape=None, dtype=None) -> np.ndarray: ...
def has(self, space, addr) -> bool: ...
def snapshot(self) -> MemoryStore: ...
```
#### D6.1. space namespace
문자열 키. 표준 값:
- `"hbm"`: HBM 데이터 (deploy_tensor + Phase 2 dma_write 결과).
- `"tcm"`: PE-로컬 TCM (Phase 2 math/gemm 결과).
- `"sram"`: cube-level SRAM (ADR-0023 D9.7 IPCQ slot tier).
다른 space (예: `"reg"`) 도 자유롭게 허용 — `_storage` 가 lazy dict 라
새 space 가 write 호출과 함께 자동 생성.
#### D6.2. address keying
`addr` 는 정수. **physical address (PA) 또는 virtual address (VA)** 일 수
있다 — MemoryStore 자체는 address space 의 의미를 모르고 그저 키로 쓴다.
Phase 1 의 `MemoryWriteMsg` 는 PA + VA 둘 다 write (`_create_tensor` 에서
PA 로 zero-init, VA base 로도 zero-init), Phase 2 는 op_log 가 captured
한 address 로 read/write.
`addr` 의 의미는 호출자가 결정한다 — `MemoryStore` 는 lookup 만 제공.
#### D6.3. read/write 의미 — reference store (no copy)
`write(space, addr, data)`: `data` ndarray 의 reference 를 저장. **copy
하지 않음**. 호출자가 같은 ndarray 를 이후 mutate 하면 stored value 도
변경된다.
`read(space, addr, shape=None, dtype=None)`: 저장된 ndarray 의 reference
반환. `shape` 또는 `dtype` 이 제공되면:
- `dtype != stored.dtype`: `arr.view(np_dtype)` 로 reinterpret cast (no
copy).
- `shape != stored.shape`: `nbytes` 가 일치하면 `arr.reshape(shape)` (view).
- `nbytes` 불일치: `ValueError`.
데이터를 안전하게 분리하려면 호출자가 `arr.copy()` 호출. ADR-0027 의
race 회피가 op_log snapshot 단계에서 명시적 copy 를 강제하는 이유.
#### D6.4. `has(space, addr) -> bool`
해당 키의 존재 여부만 확인. 데이터 인스턴스화는 안 함.
#### D6.5. `snapshot() -> MemoryStore`
shallow copy. inner dict 의 새 인스턴스를 만들되 ndarray reference 는
공유. Phase 2 초기화 시점에 Phase 1 의 store 를 fork 하여 Phase 2 의
mutation 이 Phase 1 의 다른 사용처에 영향을 주지 않게 분리하는 데 사용.
### D7. op_log 가 SimPy 단일-스레드를 가정한다
`OpLogger``_records`, `_pending` 은 lock 없이 사용. SimPy 가 single-
threaded 라 `record_start``record_end` 사이에 다른 thread 가 끼어들
수 없다는 가정.
향후 multi-process kernbench (ADR-0047 D6) 가 도입되면 OpLogger 도 process
별로 분리되어야 함이 명시. 단일 OpLogger 인스턴스가 multiple process 의
record 를 받지 못한다.
## Alternatives Considered
### A1. op_log 를 SQLite / parquet 같은 외부 store 로
기각 (현재). in-memory list 가 Phase 1 → Phase 2 의 핸드오프 latency 를
최소화한다. 외부화는 long-running batch run 에서 의미가 있겠으나, 현재
single-run 워크로드 에서는 overhead 만 추가.
### A2. snapshot 을 record_start 시점에 캡처
기각. record_start 시점은 input 이 아직 채워지지 않은 상황 (예: math
op 의 input 이 직전 op 의 output 일 때) 이 흔하다. record_end 가 정확한
시점.
### A3. MemoryStore 를 component-별 store 로 분리
기각. (space, addr) 키가 이미 충분히 disambiguation 을 제공하며, component
별 분리는 cross-PE IPCQ copy (ADR-0023 D9) 가 source/destination 양쪽
store 를 접근해야 하는 케이스를 복잡하게 만든다.
### A4. op_log 에 cross-op dependency edge 명시
부분 채택. `dependency_ids` 필드가 OpRecord 에 자리 잡고 있지만 현재
사용되지 않음 (D1). Phase 2 DataExecutor 가 t_start 정렬 + secondary sort
(memory ops before math at same t_start) 로 ordering 을 결정하며, 명시적
dependency graph 가 필요해지면 이 필드가 채워질 자리. 현재는 ordering rule
이 충분하므로 미사용.
## Consequences
- ADR-0020 의 op_log / MemoryStore 선언이 D1D6 의 구체 schema 로 확장
되어, Phase 2 DataExecutor 작성/수정 시 정확한 필드 의미를 grep 없이
ADR 에서 확인 가능.
- D3 의 op_name 별 params 스키마가 명시되어, 새 op (예: 새 reduction
type) 추가 시 `_extract_op_info` 분기 어디에 끼울지 명확.
- D4 의 snapshot 시점 차이 (math = input snapshot, dma_write = HBM-only
snapshot) 가 ADR 에 굳어져, ADR-0027 의 cross-PE race 회피 결정이 향후
refactor 에서 silently 깨지지 않음.
- D6.3 의 reference-store 의미가 명시되어, 호출자가 mutation safety 책임
을 인지. ADR-0027 의 explicit `.copy()` 패턴이 정당화됨.
- D7 의 single-thread 가정이 명시되어, multi-process kernbench (ADR-0047
D6 supersession 후보) 도입 시 OpLogger 분리가 필요함이 분명.