# 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 분리가 필요함이 분명.