b31b3e8248
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>
285 lines
11 KiB
Python
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()."
|
|
)
|