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