Files
kernbench2/docs/adr-ko/ADR-0052-dev-oplog-memory-store-schemas.md
ywkang bd49c93703 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>
2026-05-22 10:52:42 -07:00

14 KiB
Raw Permalink Blame History

ADR-0052: OpLog + MemoryStore Schemas — sim_engine internals

Status

Accepted (2026-05-22).

sim_engine/op_log.pyOpRecord 스키마와 OpLogger 의 record_start / record_end / record_copy 동작, 그리고 sim_engine/memory_store.pyMemoryStore 가 사용하는 (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 개 필드

@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 정렬 보장

@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)

{"src_addr": int, "nbytes": int, "handle_id": str}

D3.2. op_kind="memory", op_name="dma_write" (DmaWriteCmd)

{
    "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)

{
    "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" 등)

{
    "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)

{
    "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 전용 경로)

{
    "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"] 에 미리 저장:

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

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