bd49c93703
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>
353 lines
14 KiB
Markdown
353 lines
14 KiB
Markdown
# 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 선언이 D1–D6 의 구체 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 분리가 필요함이 분명.
|