Files
kernbench2/src/kernbench/components/base.py
T
ywkang b31b3e8248 Phase 2c-1: wire chunkifies into Flits + reassembly compat layer
Wire decomposes Transactions into Flits per `_flit_bytes` but emits all
flits atomically at the same env.now — preserves single-msg timing as
infrastructure for Phase 2c-2 (per-flit timing + flit-aware routers).

Non-flit-aware components reassemble Flits in `_fan_in`; `_update_step`
sets txn.step to current component's path position so legacy
step-based routing continues working when upstream is flit-aware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:03:59 -07:00

285 lines
11 KiB
Python

from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
import simpy
if TYPE_CHECKING:
from kernbench.components.context import ComponentContext
from kernbench.topology.types import Node
class ComponentBase(ABC):
"""Base class for all SimPy component implementations (ADR-0007 D3, ADR-0015).
Each component corresponds to one node in the compiled topology graph.
It models the processing overhead at that node as a SimPy generator,
allowing future implementations to add queueing and contention.
Port model (ADR-0015 D1):
in_ports[src_node_id] — SimPy Store for incoming messages from src
out_ports[dst_node_id] — SimPy Store for outgoing messages to dst
Ports are wired by GraphEngine at initialization; wire processes model
propagation delay between connected ports (ADR-0015 D2).
Context (ADR-0015 D4):
ctx — ComponentContext with router and resolver.
"""
def __init__(self, node: Node, ctx: ComponentContext | None = None) -> None:
self.node = node
self.ctx = ctx
self.in_ports: dict[str, simpy.Store] = {}
self.out_ports: dict[str, simpy.Store] = {}
self._op_logger: Any | None = None # OpLogger, set by GraphEngine if enabled
def start(self, env: simpy.Environment) -> None:
"""Called once after all ports are wired.
Default: starts a fan-in collector and a generic forwarding worker.
The worker calls self.run() for per-component latency, then routes the
Transaction to the next hop or signals done (duck-typed; no direct
Transaction import to avoid circular dependencies).
Override in components that need custom fan-out / aggregation logic
(e.g. MCpuComponent, IoCpuComponent for kernel launch).
"""
if not self.in_ports:
return
self._inbox: simpy.Store = simpy.Store(env)
for port in self.in_ports.values():
env.process(self._fan_in(port))
env.process(self._worker(env))
# ADR-0033 Phase 2c: flit-aware components consume Flits directly;
# non-flit-aware components reassemble Flits into the parent
# Transaction before delivery to _inbox. Default False preserves
# legacy single-msg semantics during incremental rollout.
_FLIT_AWARE: bool = False
def _fan_in(self, port: simpy.Store) -> Generator:
"""Relay messages from in_port to _inbox. For non-flit-aware
components (default), Flits are accumulated by parent Transaction
and only the reassembled Transaction is placed on _inbox once
``is_last`` arrives. Step is updated to this component's path
position for legacy step-based routing."""
from kernbench.sim_engine.transaction import Flit
if self._FLIT_AWARE:
while True:
msg = yield port.get()
yield self._inbox.put(msg)
return
flit_buffers: dict[int, list[Any]] = {}
while True:
msg = yield port.get()
if isinstance(msg, Flit):
tid = id(msg.txn)
flit_buffers.setdefault(tid, []).append(msg)
if msg.is_last:
flit_buffers.pop(tid, None)
self._update_step(msg.txn)
yield self._inbox.put(msg.txn)
else:
yield self._inbox.put(msg)
def _update_step(self, txn: Any) -> None:
"""Set txn.step to this component's index in txn.path (if found).
Allows legacy step-based routing to work even when flit-aware
upstream components don't call txn.advance()."""
my_id = self.node.id
path = getattr(txn, "path", None)
if not path:
return
for i, n in enumerate(path):
if n == my_id:
txn.step = i
return
def _worker(self, env: simpy.Environment) -> Generator:
"""Generic forwarding worker: spawns _forward_txn per message (pipeline)."""
while True:
txn: Any = yield self._inbox.get()
env.process(self._forward_txn(env, txn))
def _on_process_start(self, env: simpy.Environment, msg: Any) -> None:
"""Op log hook: record service start for data_op messages (ADR-0020 D2)."""
if self._op_logger and getattr(msg, "data_op", False):
self._op_logger.record_start(env.now, self.node.id, msg)
def _on_process_end(self, env: simpy.Environment, msg: Any) -> None:
"""Op log hook: record service end for data_op messages (ADR-0020 D2)."""
if self._op_logger and getattr(msg, "data_op", False):
self._op_logger.record_end(env.now, self.node.id, msg)
def _forward_txn(self, env: simpy.Environment, txn: Any) -> Generator:
"""Apply run() latency, then forward to next hop or drain at terminal."""
self._on_process_start(env, txn)
yield from self.run(env, txn.nbytes)
self._on_process_end(env, txn)
next_hop = txn.next_hop # duck-typed: Transaction.next_hop
if next_hop:
yield self.out_ports[next_hop].put(txn.advance())
else:
drain = getattr(txn, "drain_ns", 0.0)
if drain > 0:
yield env.timeout(drain)
txn.done.succeed()
@abstractmethod
def run(self, env: simpy.Environment, nbytes: int) -> Generator:
"""SimPy process: yield one or more events for this node's processing.
Subclasses yield env.timeout(overhead_ns) or compute latency dynamically.
Called by _forward_txn and subclass-specific handlers.
"""
...
class PeEngineBase(ComponentBase):
"""Base class for PE-internal engines (PE_DMA, PE_GEMM, PE_MATH).
Provides:
- ``_pe_prefix``: extracted from node.id (e.g. "sip0.cube0.pe0")
- Dual-message ``_worker``: dispatches PeInternalTxn to
``handle_command()`` and Transaction to inherited ``_forward_txn()``.
- ``init_resources(env)``: hook for subclass resource initialization,
called by ``start()`` before the worker is spawned.
Subclass contract:
1. Override ``handle_command(env, pe_txn)`` — process a PeInternalTxn.
2. Override ``run(env, nbytes)`` — yield component latency.
3. Optionally override ``init_resources(env)`` for DMA channels, etc.
"""
def __init__(self, node: Node, ctx: ComponentContext | None = None) -> None:
super().__init__(node, ctx)
self._pe_prefix: str = node.id.rsplit(".", 1)[0]
def start(self, env: simpy.Environment) -> None:
self.init_resources(env)
super().start(env)
def init_resources(self, env: simpy.Environment) -> None:
"""Hook for subclass resource initialization. Called before worker spawn."""
def _worker(self, env: simpy.Environment) -> Generator:
"""Dual-message dispatch: PeInternalTxn → handle_command, Transaction → _forward_txn."""
from kernbench.common.pe_commands import PeInternalTxn
while True:
msg: Any = yield self._inbox.get()
if isinstance(msg, PeInternalTxn):
env.process(self._handle_with_hooks(env, msg))
else:
env.process(self._forward_txn(env, msg))
def _handle_with_hooks(self, env: simpy.Environment, pe_txn: Any) -> Generator:
"""Wrap handle_command with op log hooks on the inner command.
Subclasses that need to defer record_start until after a resource
wait (e.g. pe_dma's DMA-channel acquire) set
``_DEFER_RECORD_START = True`` and call
``self._on_process_start(env, pe_txn.command)`` themselves at the
post-wait moment. record_end still fires here.
"""
if not getattr(self, "_DEFER_RECORD_START", False):
self._on_process_start(env, pe_txn.command)
yield from self.handle_command(env, pe_txn)
self._on_process_end(env, pe_txn.command)
@abstractmethod
def handle_command(self, env: simpy.Environment, pe_txn: Any) -> Generator:
"""Process a PE-internal command (PeInternalTxn).
Subclass must:
- Perform engine-specific work (acquire resources, compute, etc.)
- Call ``pe_txn.done.succeed()`` on completion.
"""
...
class ComponentRegistry:
"""DI registry: maps node.impl strings to ComponentBase subclasses.
Resolution order for ComponentRegistry.create(node, overrides, ctx):
1. overrides[node.impl] — caller-injected override
2. _registry[node.impl] — globally registered impl (lazy import)
3. Error — no fallback; every node must have an impl
Registry is populated from components.yaml via load_components_yaml().
Manual register() is still supported for tests and overrides.
"""
_registry: dict[str, type[ComponentBase]] = {}
_lazy: dict[str, str] = {} # impl → "module.path:ClassName"
_loaded: bool = False
@classmethod
def register(cls, impl: str, component_cls: type[ComponentBase]) -> None:
cls._registry[impl] = component_cls
@classmethod
def load_components_yaml(cls, path: str | None = None) -> None:
"""Load impl→class mappings from components.yaml. Lazy imports on first use."""
if cls._loaded:
return
import yaml
from pathlib import Path
if path is None:
# Search: project root (cwd), then relative to this file
candidates = [
Path.cwd() / "components.yaml",
Path(__file__).parent.parent.parent.parent / "components.yaml",
]
for p in candidates:
if p.exists():
path = str(p)
break
if path is None:
return
with open(path) as f:
spec = yaml.safe_load(f)
for impl, class_path in (spec.get("components") or {}).items():
cls._lazy[impl] = class_path
cls._loaded = True
@classmethod
def _resolve(cls, impl: str) -> type[ComponentBase] | None:
"""Resolve impl name: check _registry first, then lazy import from _lazy."""
if impl in cls._registry:
return cls._registry[impl]
if not cls._loaded:
cls.load_components_yaml()
class_path = cls._lazy.get(impl)
if class_path is None:
return None
import importlib
module_path, class_name = class_path.rsplit(":", 1)
mod = importlib.import_module(module_path)
component_cls = getattr(mod, class_name)
cls._registry[impl] = component_cls # cache for next lookup
return component_cls
@classmethod
def create(
cls,
node: Node,
overrides: dict[str, type[ComponentBase]] | None = None,
ctx: ComponentContext | None = None,
) -> ComponentBase:
if overrides and node.impl in overrides:
return overrides[node.impl](node, ctx)
component_cls = cls._resolve(node.impl)
if component_cls is not None:
return component_cls(node, ctx)
raise ValueError(
f"No component registered for impl '{node.impl}' (node: {node.id}). "
f"Add it to components.yaml or call ComponentRegistry.register()."
)