Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a44f832be5 | |||
| a0cccc71e8 | |||
| 32b29a1e5c | |||
| c9bd5387ac | |||
| 9beb140eaa | |||
| c6788788a4 | |||
| 6824a935c9 | |||
| 4929040cf1 | |||
| b31b3e8248 | |||
| 5fdb6f8797 | |||
| f6d262e359 | |||
| 83ea97b05f | |||
| 5accd98171 | |||
| a563169e89 | |||
| 9c129d6131 | |||
| 533e699299 | |||
| 54fcb7e4bc | |||
| ad5f01ab13 | |||
| 1c5752a9ec | |||
| 84a1325e5c | |||
| 1e39214f89 | |||
| fca24feac5 | |||
| d55dc6cb4f | |||
| 46291bf91b | |||
| 04c912f53e | |||
| 1c33afec55 | |||
| 81cc32c46b | |||
| e9cc40f74d | |||
| c1a5cf3a2a | |||
| 90874abbfe | |||
| 19dfc86dc3 | |||
| 14d800b0ae | |||
| 6918e6e906 |
@@ -0,0 +1,69 @@
|
||||
"""Single-PE composite GEMM for PE_accelerator perf characterization.
|
||||
|
||||
Three operand-staging variants are selectable via MATMUL_VARIANT:
|
||||
|
||||
- "ref_ref" (default): a = tl.ref, b = tl.ref
|
||||
Both operands HBM-resident; scheduler streams per-tile DMA.
|
||||
- "load_ref": a = tl.load, b = tl.ref
|
||||
A eagerly DMA'd into TCM up-front; B streamed per-tile.
|
||||
- "load_load": a = tl.load, b = tl.load
|
||||
Both eagerly DMA'd into TCM up-front.
|
||||
|
||||
Other env vars: MATMUL_M, MATMUL_K, MATMUL_N, MATMUL_DTYPE.
|
||||
|
||||
Run:
|
||||
MATMUL_M=256 MATMUL_K=256 MATMUL_N=256 MATMUL_VARIANT=load_ref \
|
||||
kernbench run --topology topology.yaml --bench matmul_composite
|
||||
"""
|
||||
import os
|
||||
|
||||
from kernbench.policy.placement.dp import DPPolicy
|
||||
|
||||
M = int(os.environ.get("MATMUL_M", "256"))
|
||||
K = int(os.environ.get("MATMUL_K", "256"))
|
||||
N = int(os.environ.get("MATMUL_N", "256"))
|
||||
DTYPE = os.environ.get("MATMUL_DTYPE", "f16")
|
||||
VARIANT = os.environ.get("MATMUL_VARIANT", "ref_ref")
|
||||
|
||||
|
||||
def _kernel_ref_ref(a_ptr, b_ptr, out_ptr, M, K, N, tl, DTYPE="f16"):
|
||||
M, K, N = int(M), int(K), int(N)
|
||||
a = tl.ref(int(a_ptr), shape=(M, K), dtype=DTYPE)
|
||||
b = tl.ref(int(b_ptr), shape=(K, N), dtype=DTYPE)
|
||||
h = tl.composite(op="gemm", a=a, b=b, out_ptr=int(out_ptr))
|
||||
tl.wait(h)
|
||||
|
||||
|
||||
def _kernel_load_ref(a_ptr, b_ptr, out_ptr, M, K, N, tl, DTYPE="f16"):
|
||||
M, K, N = int(M), int(K), int(N)
|
||||
a = tl.load(int(a_ptr), shape=(M, K), dtype=DTYPE)
|
||||
b = tl.ref(int(b_ptr), shape=(K, N), dtype=DTYPE)
|
||||
h = tl.composite(op="gemm", a=a, b=b, out_ptr=int(out_ptr))
|
||||
tl.wait(h)
|
||||
|
||||
|
||||
def _kernel_load_load(a_ptr, b_ptr, out_ptr, M, K, N, tl, DTYPE="f16"):
|
||||
M, K, N = int(M), int(K), int(N)
|
||||
a = tl.load(int(a_ptr), shape=(M, K), dtype=DTYPE)
|
||||
b = tl.load(int(b_ptr), shape=(K, N), dtype=DTYPE)
|
||||
h = tl.composite(op="gemm", a=a, b=b, out_ptr=int(out_ptr))
|
||||
tl.wait(h)
|
||||
|
||||
|
||||
_KERNELS = {
|
||||
"ref_ref": _kernel_ref_ref,
|
||||
"load_ref": _kernel_load_ref,
|
||||
"load_load": _kernel_load_load,
|
||||
}
|
||||
|
||||
|
||||
def run(torch):
|
||||
if VARIANT not in _KERNELS:
|
||||
raise ValueError(f"unknown MATMUL_VARIANT={VARIANT!r}; "
|
||||
f"expected one of {list(_KERNELS)}")
|
||||
kernel_fn = _KERNELS[VARIANT]
|
||||
dp = DPPolicy(cube="replicate", pe="replicate", num_cubes=1, num_pes=1)
|
||||
a = torch.empty((M, K), dtype=DTYPE, dp=dp, name="a")
|
||||
b = torch.empty((K, N), dtype=DTYPE, dp=dp, name="b")
|
||||
out = torch.empty((M, N), dtype=DTYPE, dp=dp, name="out")
|
||||
torch.launch(f"matmul_composite_{VARIANT}", kernel_fn, a, b, out, M, K, N)
|
||||
@@ -1,25 +1,39 @@
|
||||
# ADR-0001: PhysAddr Layout & Address Decoding Contract
|
||||
# ADR-0001: 51-bit Physical Address Layout & Decoding Contract
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (Revision 2 — 2026-04-27: concrete bit layout, rack_id removal,
|
||||
Tray->SIP / SIP->DIE renaming, PE/MCPU/IOCPU sub-unit tables.
|
||||
Supersedes ADR-0031.)
|
||||
|
||||
## Date
|
||||
|
||||
2026-02-27
|
||||
2026-04-27 (original: 2026-02-27)
|
||||
|
||||
## Context
|
||||
|
||||
KernBench Graph Latency Simulator must route requests deterministically and compute end-to-end latency strictly by graph traversal.
|
||||
To model local vs remote traffic (same/different SIP, same/different CUBE, optional PE-group), requests need a stable, parsable address/location scheme that:
|
||||
KernBench requires a stable, parsable physical address scheme that:
|
||||
|
||||
- can be decoded into routing domains (SIP/CUBE/HBM/PE-resource, etc.)
|
||||
- can be decoded into routing domains (SIP / die / HBM / PE-resource / IOCPU)
|
||||
- remains topology-agnostic (no hardcoded counts)
|
||||
- supports swappable policy and DI-first components without leaking topology assumptions into node implementations
|
||||
- supports swappable policy and DI-first components
|
||||
- covers multiple SIPs, AHBM dies, and IO chiplet dies in a unified space
|
||||
|
||||
### History
|
||||
|
||||
- Original ADR-0001 defined a 51-bit layout with `rack_id(4) + sip_id(4) +
|
||||
sip_seg(5) + local_offset(38)`. `rack_id` was never used in practice.
|
||||
- ADR-0031 (stub) requested PE-resource range partition but was never
|
||||
implemented.
|
||||
|
||||
Revision 2 removes `rack_id`, renames `sip_seg -> die_id`, and provides
|
||||
concrete sub-unit tables for PE, MCPU, CUBE_SRAM, and IOCPU resources.
|
||||
ADR-0031 is superseded.
|
||||
|
||||
## Decision
|
||||
|
||||
We define a **PhysAddr value object** and an **address decoding contract** that converts an integer address into routing domains.
|
||||
We define a **PhysAddr value object** and an **address decoding contract**
|
||||
that converts an integer address into routing domains.
|
||||
|
||||
### D1. PhysAddr is an immutable value object
|
||||
|
||||
@@ -27,82 +41,322 @@ We define a **PhysAddr value object** and an **address decoding contract** that
|
||||
- Any allocator returns a **fully specified PhysAddr** (not partial metadata).
|
||||
- No global state may be required to interpret a PhysAddr.
|
||||
|
||||
### D2. PhysAddr fields (logical contract)
|
||||
### D2. 51-bit Physical Address Layout
|
||||
|
||||
PhysAddr must be able to represent at least:
|
||||
A 51-bit physical address is adopted.
|
||||
|
||||
- `rack_id` (optional but reserved for scale-out)
|
||||
- `sip_id` (device / SIP domain)
|
||||
- `sip_seg` (SIP-level segment/window selection, e.g., cube window)
|
||||
- `local_offset` (offset within the chosen segment/window)
|
||||
#### 2.1 Top-Level Address Map
|
||||
|
||||
Decoded/derived fields may include (optional):
|
||||
```text
|
||||
[50:47] sip_id (4) -- 16 SIPs
|
||||
[46:42] die_id (5) -- 32 dies per SIP
|
||||
[41: 0] local_offset (42) -- 4 TB per die
|
||||
```
|
||||
|
||||
- `cube_id`
|
||||
- `kind` (e.g., HBM vs PE-resource vs raw)
|
||||
- `unit_type` / `pe_id` (if PE-level addressing is modeled)
|
||||
```text
|
||||
50 47 46 42 41 0
|
||||
+---------+----------+-------------------------+
|
||||
| sip_id | die_id | local_offset |
|
||||
+---------+----------+-------------------------+
|
||||
```
|
||||
|
||||
**Important:** The exact bit allocation may evolve, but the *semantic fields above* must remain decodable without hidden assumptions.
|
||||
#### 2.2 die_id Allocation
|
||||
|
||||
### D3. Decoding is deterministic and policy-compatible
|
||||
| die_id | Meaning |
|
||||
|--------|---------|
|
||||
| 0..15 | AHBM dies |
|
||||
| 16..20 | IOCHIPLET dies |
|
||||
| 21..31 | Reserved |
|
||||
|
||||
- Decoding must deterministically map an integer address to:
|
||||
- destination SIP domain (`sip_id`)
|
||||
- destination sub-domain (`cube_id` if applicable)
|
||||
- destination target kind (HBM/PE-resource/other)
|
||||
- Decoding must not depend on runtime topology sizes; it may depend on **explicit topology parameters** provided through configuration (e.g., segment size, slice size), and those parameters must live in the topology/config layer (not in random components).
|
||||
#### 2.3 AHBM Die Layout
|
||||
|
||||
### D4. Topology-derived constants live in the topology layer
|
||||
Only lower 256 GB of the 4 TB die-local window is assigned.
|
||||
|
||||
Constants such as segment sizes (e.g., HBM slice size / window size) are derived from topology configuration (YAML/JSON/dict) and are provided to the decoder via DI/config.
|
||||
They must not be hardcoded in node implementations.
|
||||
```text
|
||||
[41:38] MBZ (4)
|
||||
[37] addr_space (1) -- 0 = local resource, 1 = HBM memory
|
||||
[36: 0] sub-address (37)
|
||||
```
|
||||
|
||||
| addr_space | Meaning |
|
||||
|------------|---------|
|
||||
| 0 | Local resource |
|
||||
| 1 | HBM memory |
|
||||
|
||||
##### 2.3.1 HBM Window (addr_space = 1)
|
||||
|
||||
```text
|
||||
[36:0] hbm_offset (37) -- 128 GB decode window
|
||||
```
|
||||
|
||||
The architectural decode window is fixed at 128 GB. Implemented capacity
|
||||
may be smaller depending on SKU/topology (see D4).
|
||||
|
||||
##### 2.3.2 Resource Window (addr_space = 0)
|
||||
|
||||
```text
|
||||
[36:34] resource_kind (3)
|
||||
[33: 0] kind_local (34) -- 16 GB per kind
|
||||
```
|
||||
|
||||
| resource_kind | Meaning |
|
||||
|---------------|---------|
|
||||
| 000 | PE_LOCAL |
|
||||
| 001 | MCPU_LOCAL |
|
||||
| 010 | CUBE_SRAM |
|
||||
| 011..111 | Reserved |
|
||||
|
||||
Each kind gets a 16 GB decode region.
|
||||
|
||||
##### 2.3.3 PE_LOCAL (resource_kind = 000)
|
||||
|
||||
```text
|
||||
[33] MBZ (1)
|
||||
[32:29] pe_id (4) -- 0..15
|
||||
[28:25] pe_sub_unit (4)
|
||||
[24: 0] sub_offset (25) -- 32 MB per slot
|
||||
```
|
||||
|
||||
16 PEs x 16 sub-unit slots x 32 MB = 8 GB active decode.
|
||||
|
||||
| pe_sub_unit | Name | Budget |
|
||||
|-------------|------|--------|
|
||||
| 0 | PE_CPU_DTCM | 8 KB |
|
||||
| 1 | MATH_ENGINE_DTCM | 8 KB |
|
||||
| 2 | IPCQ | 256 KB |
|
||||
| 3 | PE_CPU_SFR | 16 KB |
|
||||
| 4 | MATH_ENGINE_SFR | 16 KB |
|
||||
| 5 | DMA_ENGINE_SFR | 192 KB |
|
||||
| 6 | PE_TCM | 2 MB |
|
||||
| 7..15 | Reserved | -- |
|
||||
|
||||
##### 2.3.4 MCPU_LOCAL (resource_kind = 001)
|
||||
|
||||
```text
|
||||
[33:30] MBZ (4)
|
||||
[29:25] mcpu_sub_unit (5)
|
||||
[24: 0] sub_offset (25) -- 32 MB per slot
|
||||
```
|
||||
|
||||
1 GB active decode.
|
||||
|
||||
| mcpu_sub_unit | Name | Budget |
|
||||
|---------------|------|--------|
|
||||
| 0 | MCPU_ITCM | 512 KB |
|
||||
| 1 | MCPU_DTCM | 512 KB |
|
||||
| 2 | IPCQ | 256 KB |
|
||||
| 3 | MCPU_SFR | 8 KB |
|
||||
| 4 | MCPU_DMA_SFR | 16 KB |
|
||||
| 5 | MCPU_SRAM | 10 MB |
|
||||
| 6..31 | Reserved | -- |
|
||||
|
||||
##### 2.3.5 CUBE_SRAM (resource_kind = 010)
|
||||
|
||||
```text
|
||||
[33:25] MBZ (9)
|
||||
[24: 0] sram_offset (25) -- flat 32 MB
|
||||
```
|
||||
|
||||
#### 2.4 IOCHIPLET Die Layout
|
||||
|
||||
Only lower 1 TB of the 4 TB die-local window is assigned.
|
||||
|
||||
```text
|
||||
[41:40] MBZ (2)
|
||||
[39: 0] chiplet_offset (40) -- 1 TB
|
||||
```
|
||||
|
||||
Region split by address range:
|
||||
|
||||
| Range | Meaning | Decode condition |
|
||||
|-------|---------|------------------|
|
||||
| [0, 2 GB) | IOCPU resource | chiplet_offset < 0x8000_0000 |
|
||||
| [2 GB, 1 TB) | UAL | chiplet_offset >= 0x8000_0000 |
|
||||
|
||||
##### 2.4.1 IOCPU Region
|
||||
|
||||
```text
|
||||
[30:27] iocpu_sub_unit (4)
|
||||
[26: 0] sub_offset (27) -- 128 MB per slot
|
||||
```
|
||||
|
||||
16 x 128 MB slots. 2 GB active decode.
|
||||
|
||||
| iocpu_sub_unit | Name | Budget |
|
||||
|----------------|------|--------|
|
||||
| 0 | IOCPU_ITCM | 512 KB |
|
||||
| 1 | IOCPU_DTCM | 512 KB |
|
||||
| 2 | IPCQ | 2 MB |
|
||||
| 3 | IOCPU_SFR | 8 KB |
|
||||
| 4 | IO_DMA_SFR | 16 KB |
|
||||
| 5 | IO_SRAM | 64 MB |
|
||||
| 6..15 | Reserved | -- |
|
||||
|
||||
##### 2.4.2 UAL Region
|
||||
|
||||
Sub-layout TBD (separate ADR).
|
||||
|
||||
#### 2.5 Addressing Rules
|
||||
|
||||
1. MBZ bits must be zero. An address with non-zero MBZ bits is
|
||||
**architecturally invalid**. Implementation may raise a decode fault
|
||||
or return an error -- behavior is not prescribed by this ADR.
|
||||
2. Fixed slot sizes are chosen for simple hardware decode; actual
|
||||
implemented capacity may be smaller than the slot.
|
||||
3. Access beyond a sub-unit's implemented budget within a slot is
|
||||
**architecturally invalid** (same policy as MBZ).
|
||||
|
||||
### D3. Bitfield decoding is deterministic
|
||||
|
||||
Given an integer address, field extraction (`sip_id`, `die_id`, `kind`,
|
||||
`sub_unit`, `offset`) is purely positional. No runtime state is required.
|
||||
Decoding deterministically maps an integer address to destination domains:
|
||||
`sip_id`, `die_id`, target kind (HBM / PE_LOCAL / MCPU_LOCAL / CUBE_SRAM /
|
||||
IOCPU / UAL).
|
||||
|
||||
### D4. Capacity validation may depend on topology config
|
||||
|
||||
Whether a decoded address falls within **implemented capacity** (e.g.,
|
||||
HBM 96 GB on a specific SKU) is checked against topology parameters
|
||||
provided via DI/config. Decode itself (D3) never consults topology --
|
||||
only validation does. These parameters must live in the topology/config
|
||||
layer, not in node implementations.
|
||||
|
||||
### D5. Routing consumes decoded domains, not raw bits
|
||||
|
||||
Routing policy uses decoded domains:
|
||||
|
||||
- `src` location (sip/cube/pe or node_id)
|
||||
- `src` location (sip / die / pe or node_id)
|
||||
- `dst` domains derived from PhysAddr decoding
|
||||
- `size_bytes` for size-aware link latency
|
||||
Routing must not inspect raw bit-fields directly except inside the decoding module.
|
||||
|
||||
Routing must not inspect raw bit-fields directly except inside the
|
||||
decoding module.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1) **Use raw integers everywhere, decode ad-hoc in routing**
|
||||
1. **Keep `rack_id` (4 bits)**: Rejected -- never used in practice,
|
||||
consumes 4 bits that enable die-local expansion to 42 bits
|
||||
(IOCHIPLET 1 TB).
|
||||
|
||||
- Rejected: leads to duplicated logic, inconsistent routing, and hidden assumptions embedded in multiple components.
|
||||
2. **Uniform 256 GB per die**: Rejected -- IOCHIPLET UAL requires ~1 TB.
|
||||
Freed rack_id bits enable 42-bit local_offset.
|
||||
|
||||
1) **Hardcode topology sizes (SIP/CUBE/PE counts) into decoding**
|
||||
3. **Variable-width die windows (AHBM 256 GB, CHIPLET 1 TB via multi-seg
|
||||
spanning)**: Rejected -- complicates D3 (deterministic decoding).
|
||||
Uniform 4 TB window with MBZ padding is simpler.
|
||||
|
||||
- Rejected: violates SPEC (R3) and breaks swappability and configuration-driven topologies.
|
||||
4. **Use raw integers everywhere, decode ad-hoc in routing**: Rejected --
|
||||
leads to duplicated logic, inconsistent routing, and hidden
|
||||
assumptions.
|
||||
|
||||
1) **Put decoding inside memory controllers or routers**
|
||||
5. **Hardcode topology sizes (SIP/CUBE/PE counts) into decoding**:
|
||||
Rejected -- violates SPEC R3 and breaks swappability.
|
||||
|
||||
- Rejected: leaks policy into components and undermines DI-first, swappable implementations (SPEC R4).
|
||||
6. **Put decoding inside memory controllers or routers**: Rejected --
|
||||
leaks policy into components, violates SPEC R4 / D5.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Deterministic routing domains enable clear test invariants for local vs remote paths (SPEC R1, R5).
|
||||
- Keeps topology variability (SPEC R3) while preserving consistent semantics.
|
||||
- DI-first: decoder can be swapped or extended without changing components or tests (SPEC R4).
|
||||
- Simple hierarchical decoder: SIP -> die -> kind -> sub-unit.
|
||||
- Clean separation of memory (HBM) vs local resource (PE/MCPU/SRAM/IOCPU).
|
||||
- Deterministic routing domains enable clear test invariants (SPEC R1, R5).
|
||||
- Expandable: 11 reserved die_id slots, reserved resource_kind / sub-unit
|
||||
slots, reserved MBZ bits.
|
||||
- DI-first: decoder can be swapped without changing components (SPEC R4).
|
||||
|
||||
### Tradeoffs / Costs
|
||||
### Tradeoffs
|
||||
|
||||
- Requires explicit configuration for any topology-derived sizes.
|
||||
- Introduces a single “blessed” decoding module that must remain stable and well-tested.
|
||||
- Sparse address holes due to power-of-2 slot alignment.
|
||||
- Large reserved/MBZ regions (intentional for future extension).
|
||||
- Requires explicit configuration for topology-derived sizes (D4).
|
||||
- Introduces a single "blessed" decoding module that must remain stable
|
||||
and well-tested.
|
||||
|
||||
## Supersedes
|
||||
|
||||
- **ADR-0031 (PhysAddr PE-Resource Extension)**: stub status. The
|
||||
PE_LOCAL / MCPU_LOCAL / CUBE_SRAM sub-unit tables in D2.3.3-D2.3.5
|
||||
fulfill ADR-0031's stated goals.
|
||||
|
||||
## Implementation Notes (Non-normative)
|
||||
|
||||
- Recommended module boundary:
|
||||
- `src/kernbench/policy/address/phyaddr.py`
|
||||
- Recommended module: `src/kernbench/policy/address/phyaddr.py`
|
||||
- Tests should cover: encode/decode round-trip per kind, MBZ enforcement,
|
||||
die_id dispatch (AHBM / IOCHIPLET / reserved), sub-unit boundary
|
||||
values, backward compatibility of factory APIs.
|
||||
- Factory methods: `hbm_addr`, `pe_hbm_addr`, `pe_tcm_addr`,
|
||||
`cube_sram_addr` retain signatures (minus `rack_id`); `cube_id`
|
||||
parameter renamed to `die_id`.
|
||||
- New factories: `pe_resource_addr`, `mcpu_resource_addr`,
|
||||
`iocpu_resource_addr`, `ual_addr`.
|
||||
|
||||
- Tests should cover:
|
||||
- deterministic decoding
|
||||
- local vs remote classification from decoded fields
|
||||
- invariants: “allocator returns full PhysAddr”, “decoding requires no global state”
|
||||
## Appendix A. Address Examples
|
||||
|
||||
### A.1 AHBM HBM access
|
||||
|
||||
sip=2, die=5, HBM offset=0x1000
|
||||
|
||||
```text
|
||||
sip_id = 2 -> [50:47] = 0b0010
|
||||
die_id = 5 -> [46:42] = 0b00101
|
||||
addr_space = 1 -> [37] = 1 (HBM)
|
||||
hbm_offset = 0x1000 -> [36:0]
|
||||
|
||||
51-bit addr = (2 << 47) | (5 << 42) | (1 << 37) | 0x1000
|
||||
```
|
||||
|
||||
### A.2 AHBM PE_LOCAL -- PE3 PE_TCM, offset=0x400
|
||||
|
||||
```text
|
||||
sip_id = 0 -> [50:47] = 0
|
||||
die_id = 0 -> [46:42] = 0
|
||||
addr_space = 0 -> [37] = 0
|
||||
resource_kind = 0 -> [36:34] = 000 (PE_LOCAL)
|
||||
pe_id = 3 -> [32:29] = 0011
|
||||
pe_sub_unit = 6 -> [28:25] = 0110 (PE_TCM)
|
||||
sub_offset = 0x400 -> [24:0]
|
||||
|
||||
local_offset = (0 << 34) | (3 << 29) | (6 << 25) | 0x400
|
||||
```
|
||||
|
||||
### A.3 AHBM MCPU_LOCAL -- MCPU_SRAM, offset=0x0
|
||||
|
||||
```text
|
||||
sip_id = 1 -> [50:47] = 0001
|
||||
die_id = 3 -> [46:42] = 00011
|
||||
addr_space = 0 -> [37] = 0
|
||||
resource_kind = 1 -> [36:34] = 001 (MCPU_LOCAL)
|
||||
mcpu_sub_unit = 5 -> [29:25] = 00101 (MCPU_SRAM)
|
||||
sub_offset = 0 -> [24:0] = 0
|
||||
|
||||
local_offset = (1 << 34) | (5 << 25)
|
||||
```
|
||||
|
||||
### A.4 IOCHIPLET -- IOCPU IPCQ, offset=0x20000
|
||||
|
||||
```text
|
||||
sip_id = 1 -> [50:47] = 0001
|
||||
die_id = 17 -> [46:42] = 10001 (IOCHIPLET[1])
|
||||
iocpu_sub_unit = 2 -> [30:27] = 0010 (IPCQ)
|
||||
sub_offset = 0x20000 -> [26:0]
|
||||
|
||||
chiplet_offset = (2 << 27) | 0x20000
|
||||
(< 0x8000_0000 -> IOCPU region)
|
||||
```
|
||||
|
||||
### A.5 IOCHIPLET -- UAL region, offset=4 GB
|
||||
|
||||
```text
|
||||
sip_id = 0 -> [50:47] = 0
|
||||
die_id = 16 -> [46:42] = 10000 (IOCHIPLET[0])
|
||||
chiplet_offset = 0x1_0000_0000 (4 GB >= 2 GB -> UAL region)
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- SPEC.md: R1 (routing), R3 (configurable topology), R4 (DI-first), R5 (multi-domain comm)
|
||||
- SPEC.md: R1 (routing), R3 (configurable topology), R4 (DI-first),
|
||||
R5 (multi-domain comm)
|
||||
- ADR-0031: Superseded
|
||||
|
||||
@@ -35,11 +35,13 @@ We model the system hierarchy explicitly:
|
||||
|
||||
- A CUBE contains:
|
||||
- HBM + memory controller (HBM_CTRL)
|
||||
- NOC router mesh: 2D grid of explicit routers (from cube_mesh.yaml) with XY routing;
|
||||
carries all intra-cube traffic including HBM data, inter-cube (UCIe),
|
||||
command (M_CPU↔PE_CPU), and shared SRAM access.
|
||||
HBM_CTRL is attached to PE routers (local HBM = 0 hop).
|
||||
See ADR-0017 and ADR-0019 for full architecture.
|
||||
- NOC (on-die fabric): carries all intra-cube traffic including HBM data,
|
||||
inter-cube (UCIe), command (M_CPU↔PE_CPU), and shared SRAM access.
|
||||
Must provide: full-BW PE↔local HBM path, PE↔SRAM connectivity,
|
||||
PE↔UCIe connectivity, M_CPU↔PE command path.
|
||||
NOC topology is an implementation choice (e.g., 2D mesh, ring, crossbar);
|
||||
current implementation uses a 2D mesh with XY routing (see ADR-0017).
|
||||
HBM_CTRL is attached to each PE's local NOC port (local HBM = minimal hop).
|
||||
- Shared SRAM: cube-level shared memory accessible by all PEs via NOC
|
||||
- management/control CPU (M_CPU) coordinating PE command distribution and completion aggregation
|
||||
- multiple PEs
|
||||
|
||||
@@ -33,12 +33,17 @@ Each PE has a notion of “local HBM” that must guarantee full HBM bandwidth,
|
||||
- This guarantee is modeled by:
|
||||
- a dedicated logical path and/or service model that enforces HBM BW at the PE-local-HBM interaction point,
|
||||
- while still incurring non-zero latency along explicitly modeled components.
|
||||
- HBM CTRL internal modeling (PC striping, cut-through, scheduling fidelity)
|
||||
is consolidated in ADR-0033 (Latency Model: Assumptions and Known
|
||||
Simplifications). The aggregate BW guarantee here remains the contract;
|
||||
ADR-0033 documents how the per-PC model realizes it and which scheduler
|
||||
effects are intentionally simplified.
|
||||
|
||||
### D3. Remote PE HBM semantics (intra-cube)
|
||||
|
||||
- A PE that accesses another PE's local HBM traverses the router mesh:
|
||||
- PE_DMA → local router → (mesh hops) → target PE's router → HBM_CTRL
|
||||
- Router mesh bandwidth and hop count may limit remote HBM access relative to local access.
|
||||
- A PE that accesses another PE's local HBM traverses the NOC:
|
||||
- PE_DMA → NOC → (fabric hops) → target PE's NOC port → HBM_CTRL
|
||||
- NOC bandwidth and hop count may limit remote HBM access relative to local access.
|
||||
|
||||
### D4. Non-local HBM semantics (inter-cube / inter-SIP)
|
||||
|
||||
|
||||
@@ -67,6 +67,76 @@ Completion semantics:
|
||||
|
||||
---
|
||||
|
||||
### D5. Launch timing is endpoint-synchronized
|
||||
|
||||
All PEs targeted by a single kernel launch MUST begin executing the kernel
|
||||
body at the same simulated time, regardless of their dispatch path length
|
||||
from the launch entry point.
|
||||
|
||||
Rationale. The dispatch tree Host → IO_CPU → M_CPU → PE_CPU has variable
|
||||
latency at every level. PEs near their M_CPU receive the launch earlier
|
||||
than PEs farther away; cubes near an IO_CPU receive it earlier than cubes
|
||||
farther away. Without synchronization, each PE's kernel begins at a
|
||||
different `env.now`, making per-PE metrics such as `pe_exec_ns` a function
|
||||
of dispatch-path geometry rather than of the kernel's behavior —
|
||||
producing measurement artifacts in benchmarks that time kernel-internal
|
||||
waits (for example `tl.recv` on cross-cube or cross-SIP hops).
|
||||
|
||||
Mechanism.
|
||||
|
||||
- `KernelLaunchMsg` carries an optional `target_start_ns: float | None`.
|
||||
- **IO_CPU** is the canonical stamper. On fan-out to M_CPUs, it
|
||||
computes `target_start_ns = env.now + max_latency` where
|
||||
`max_latency` is the maximum, over every target (sip, cube, pe)
|
||||
tuple, of the **two-leg dispatch chain**:
|
||||
|
||||
```
|
||||
max_latency(sip, cube, pe) =
|
||||
compute_path_latency_ns(find_node_path(io_cpu, m_cpu(sip, cube)))
|
||||
+ compute_path_latency_ns(find_node_path(m_cpu(sip, cube), pe_cpu))
|
||||
- io_cpu.overhead_ns
|
||||
- m_cpu.overhead_ns
|
||||
```
|
||||
|
||||
This models the actual dispatch as **two sequential Transactions**
|
||||
(IO_CPU → M_CPU, then M_CPU → PE_CPU). Each leg's
|
||||
`compute_path_latency_ns` adds its endpoints' `overhead_ns`;
|
||||
`io_cpu.overhead_ns` is subtracted because IO_CPU has already
|
||||
paid it before this method runs, and `m_cpu.overhead_ns` is
|
||||
subtracted once because it appears as endpoint of leg1 *and*
|
||||
start of leg2 but is paid only once at run time. A single
|
||||
`find_node_path(io_cpu, pe_cpu)` walk is **not** equivalent —
|
||||
it can pick a graph path that bypasses M_CPU and silently
|
||||
under-shoots the prediction for far cubes, breaking the D5
|
||||
invariant.
|
||||
|
||||
The fanned-out sub-Transactions carry **`nbytes = 0`** for
|
||||
`KernelLaunchMsg` (control message only). Without this,
|
||||
large kernel-launch payloads would occupy fabric BW on the
|
||||
shared first hop and serialize the per-cube dispatch, pushing
|
||||
far M_CPUs past `target_start_ns` and re-introducing the
|
||||
late-arrival violation.
|
||||
- **M_CPU** passes an already-stamped `target_start_ns` through
|
||||
unchanged. Only when the value is absent (e.g. a direct
|
||||
launch-to-M_CPU unit test) does M_CPU compute a per-cube barrier
|
||||
`env.now + max(local command-path latency)`.
|
||||
- **PE_CPU** yields `env.timeout(target_start_ns - env.now)` at the top
|
||||
of `_execute_kernel`, before recording `pe_exec_start` and invoking
|
||||
the kernel body.
|
||||
- When `target_start_ns is None`, PE_CPU falls through to the legacy
|
||||
unsynchronized behavior — preserving backward compatibility.
|
||||
|
||||
IO_CPU-level stamping guarantees every PE across every targeted cube
|
||||
uses the same barrier sim-time, eliminating both the within-cube
|
||||
dispatch-offset artifact *and* the cross-cube offset artifact in
|
||||
multi-cube launches. Models a real-hardware timed-broadcast launch
|
||||
(latency-equalized dispatch tree).
|
||||
|
||||
The synchronization is internal to the engine / IO_CPU / M_CPU / PE_CPU
|
||||
control plane — runtime API and application kernels are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- SPEC R1, R2, R7, R8
|
||||
|
||||
@@ -44,15 +44,15 @@ Each PE contains the following logical components.
|
||||
**PE_DMA**
|
||||
|
||||
- Handles memory transfers between PE_TCM and external memory domains.
|
||||
- PE_DMA connects to the NOC router mesh at the CUBE level (ADR-0019):
|
||||
- All destinations (HBM, shared SRAM, inter-cube UCIe) are reached via the router mesh
|
||||
- Local HBM access: PE_DMA → local router → hbm_ctrl (switching overhead only)
|
||||
- Remote/shared: PE_DMA → local router → (mesh hops) → destination
|
||||
- PE_DMA connects to the cube-level NOC (on-die fabric):
|
||||
- All destinations (HBM, shared SRAM, inter-cube UCIe) are reached via the NOC
|
||||
- Local HBM access: PE_DMA → NOC → hbm_ctrl (minimal hop)
|
||||
- Remote/shared: PE_DMA → NOC → (fabric hops) → destination
|
||||
- Supported directions include:
|
||||
- HBM → PE_TCM (via router mesh)
|
||||
- PE_TCM → HBM (via router mesh)
|
||||
- PE_TCM → shared SRAM (via router mesh)
|
||||
- PE_TCM → other memory domains (via router mesh, if supported by topology)
|
||||
- HBM → PE_TCM (via NOC)
|
||||
- PE_TCM → HBM (via NOC)
|
||||
- PE_TCM → shared SRAM (via NOC)
|
||||
- PE_TCM → other memory domains (via NOC, if supported by topology)
|
||||
|
||||
**PE_GEMM**
|
||||
|
||||
@@ -252,7 +252,7 @@ Compute operations use a TCM-centric dataflow model.
|
||||
**Input path (HBM)**
|
||||
|
||||
```text
|
||||
HBM → router mesh → PE_DMA (DMA_READ) → PE_TCM
|
||||
HBM → NOC → PE_DMA (DMA_READ) → PE_TCM
|
||||
```
|
||||
|
||||
**Input path (shared SRAM)**
|
||||
@@ -269,14 +269,14 @@ Compute engines read input tensors from PE_TCM.
|
||||
PE_TCM → GEMM / MATH
|
||||
```
|
||||
|
||||
Weights for GEMM may optionally stream directly from HBM (via router mesh).
|
||||
Weights for GEMM may optionally stream directly from HBM (via NOC).
|
||||
|
||||
**Output path (HBM)**
|
||||
|
||||
Compute results are written to PE_TCM, then DMA writes to HBM.
|
||||
|
||||
```text
|
||||
PE_TCM → PE_DMA (DMA_WRITE) → router mesh → HBM
|
||||
PE_TCM → PE_DMA (DMA_WRITE) → NOC → HBM
|
||||
```
|
||||
|
||||
**Output path (shared SRAM)**
|
||||
@@ -348,9 +348,9 @@ PE instances are derived from `cube.pe_layout`.
|
||||
|
||||
External connectivity such as:
|
||||
|
||||
- PE_DMA → router mesh → HBM (data path, ADR-0019)
|
||||
- PE_DMA → router mesh → shared SRAM, inter-cube UCIe (non-HBM data path)
|
||||
- router mesh → PE_CPU (command path from M_CPU)
|
||||
- PE_DMA → NOC → HBM (data path)
|
||||
- PE_DMA → NOC → shared SRAM, inter-cube UCIe (non-HBM data path)
|
||||
- NOC → PE_CPU (command path from M_CPU)
|
||||
|
||||
is modeled at the CUBE level (see ADR-0003 D3).
|
||||
|
||||
|
||||
@@ -372,24 +372,41 @@ When the receiver frees a slot, the sender must learn about it
|
||||
travel through general vc_comm fabric — it uses a **separate fast
|
||||
path**, an abstraction of the NVLink / UCIe credit-return wire.
|
||||
|
||||
**Latency** is computed from the **bottleneck BW on the path**, not a
|
||||
magic constant:
|
||||
**Latency** is computed from the **full path latency** (per-node
|
||||
overhead + edge propagation + drain), not a magic constant:
|
||||
|
||||
```
|
||||
credit_size_bytes = 16 (ccl.yaml: ipcq_credit_size_bytes)
|
||||
path = router.find_path(self_pe, peer_pe)
|
||||
latency = compute_drain_ns(path, credit_size_bytes)
|
||||
= credit_size_bytes / bottleneck_bw_on_path
|
||||
path = router.find_path(self_pe, peer_pe.pe_dma)
|
||||
latency = compute_path_latency_ns(path, credit_size_bytes)
|
||||
= sum(edge.distance_mm * ns_per_mm)
|
||||
+ sum(node_overhead_ns[n] for n in path)
|
||||
+ credit_size_bytes / bottleneck_bw_on_path
|
||||
```
|
||||
|
||||
The router auto-appends `.pe_dma` to the source only, so the
|
||||
destination MUST be spelled with the explicit `.pe_dma` suffix or
|
||||
`find_path` raises and the credit silently teleports at zero cost
|
||||
(latent bug fixed alongside this update).
|
||||
|
||||
`tl.recv` blocks on the credit-emit completion (recv yields-from
|
||||
`_delayed_credit_send` rather than spawning it as a fork). This puts
|
||||
the credit-return cost on the receiver's `pe_exec_ns`, modeling the
|
||||
IPCQ control-plane completing the consume-acknowledgement before
|
||||
recv returns to the kernel — the protocol equivalent of a non-posted
|
||||
`tl.store` waiting for an HBM ack on the raw DMA path.
|
||||
|
||||
That gives us:
|
||||
|
||||
- **Topology-proportional approximation**: an in-cube credit return is
|
||||
automatically faster than a cross-SIP credit return.
|
||||
- **No magic constants**: no arbitrary `ipcq_ctrl_latency_ns`.
|
||||
- **No magic constants**: every nanosecond comes from
|
||||
`compute_path_latency_ns` on the same edge_map and `node_overhead_ns`
|
||||
as data traffic.
|
||||
- **No deadlock risk**: unlike piggyback, B can issue credit even when
|
||||
it has no data to send back.
|
||||
- **Reuses existing utility**: `ComponentContext.compute_drain_ns`.
|
||||
it has no data to send back. `peer_credit_store.put` is unbounded.
|
||||
- **`IPCQ ≥ raw DMA`** for matched physical moves — the credit-emit
|
||||
cost on recv balances the HBM ack-trip cost RAW pays on the sender.
|
||||
|
||||
#### Component coupling — SimPy Store channel
|
||||
|
||||
@@ -420,11 +437,21 @@ fan-out (see `IpcqInitMsg` in D12).
|
||||
#### PE_DMA's added responsibility
|
||||
|
||||
When `vc_comm` receives a token, PE_DMA processes it as the following
|
||||
**atomic** sequence. **No SimPy yield is allowed between the two steps**
|
||||
(invariant I6):
|
||||
sequence: pay the Transaction's terminal BW drain, then atomically
|
||||
write data and forward metadata. **No SimPy yield is allowed between
|
||||
the data write and the metadata forward** (invariant I6). The drain
|
||||
yield must sit before the atomic block, not inside it:
|
||||
|
||||
```python
|
||||
def _on_vc_comm_recv(self, env, token):
|
||||
def _on_vc_comm_recv(self, env, txn):
|
||||
# Pay the terminal BW drain (nbytes / bottleneck_bw stamped by the
|
||||
# sender PE_DMA). MUST happen before the atomic block so recv only
|
||||
# wakes after the bytes have "landed".
|
||||
drain = getattr(txn, "drain_ns", 0.0)
|
||||
if drain > 0:
|
||||
yield env.timeout(drain)
|
||||
|
||||
token = txn.request
|
||||
# ── ATOMIC: no yield between these two operations ──
|
||||
data = self._memory_store.read(token.src_space, token.src_addr,
|
||||
shape=..., dtype=...)
|
||||
@@ -439,6 +466,33 @@ The final `put` is yieldable but uses an unbounded internal store, so
|
||||
it completes in a single step. That `put` is the closing call of the
|
||||
atomic block; nothing may be inserted before it.
|
||||
|
||||
#### Drain-at-inbound semantics (D9 timing model)
|
||||
|
||||
The Transaction carries `drain_ns = nbytes / bottleneck_bw_on_path`
|
||||
stamped at send-side PE_DMA. In this simulator per-hop `overhead_ns`
|
||||
is paid at each forwarding component via `run()`, and the remaining
|
||||
BW drain is paid once at the Transaction's terminal. Every non-IPCQ
|
||||
Transaction (raw DMA, kernel-launch fanout, etc.) pays this drain via
|
||||
`ComponentBase._forward_txn` at the terminal node. For IPCQ the
|
||||
destination PE_DMA intercepts the Transaction with `_handle_ipcq_inbound`
|
||||
(so IPCQ-specific data write + metadata forward can happen), so **the
|
||||
drain MUST be paid explicitly at the top of that handler** to keep
|
||||
IPCQ's timing model on par with every other fabric Transaction.
|
||||
|
||||
Side-effects of paying drain here:
|
||||
|
||||
- **SRC `tl.send`** is unchanged — fire-and-forget semantics are
|
||||
preserved because the sender PE_DMA does not `yield sub_done`. The
|
||||
`sub_done.succeed()` call (made after metadata forward below) is an
|
||||
event with no listener on the sender side.
|
||||
- **DST `tl.recv`** unblocks `drain_ns` later. Since recv wakes only
|
||||
when `IpcqMetaArrival` reaches its local PE_IPCQ, and the metadata
|
||||
forward now happens after the drain, recv observes the full fabric
|
||||
transfer time including bandwidth cost.
|
||||
|
||||
Matches the physical picture: send dispatches and leaves; recv waits
|
||||
until the bytes have actually been drained into its inbox.
|
||||
|
||||
### D9.5. ADR-0020 (2-pass) integration
|
||||
|
||||
`tl.send` / `tl.recv` integrates with ADR-0020's two-pass model. Phase
|
||||
|
||||
@@ -365,23 +365,39 @@ data 경로의 piggyback 모델과 달리, credit return은 일반 vc_comm fabri
|
||||
거치지 않고 **별도 fast path**로 처리한다. 이는 실제 HW의 NVLink/UCIe
|
||||
credit return fast path를 추상화한 것이다.
|
||||
|
||||
**Latency 계산**: magic constant가 아니라 **라우팅 경로의 bottleneck BW**
|
||||
기준으로 산출한다.
|
||||
**Latency 계산**: magic constant가 아니라 **라우팅 경로의 full path
|
||||
latency** (per-node overhead + edge propagation + drain) 기준으로
|
||||
산출한다.
|
||||
|
||||
```
|
||||
credit_size_bytes = 16 (ccl.yaml: ipcq_credit_size_bytes)
|
||||
path = router.find_path(self_pe, peer_pe)
|
||||
latency = compute_drain_ns(path, credit_size_bytes)
|
||||
= credit_size_bytes / bottleneck_bw_on_path
|
||||
path = router.find_path(self_pe, peer_pe.pe_dma)
|
||||
latency = compute_path_latency_ns(path, credit_size_bytes)
|
||||
= sum(edge.distance_mm * ns_per_mm)
|
||||
+ sum(node_overhead_ns[n] for n in path)
|
||||
+ credit_size_bytes / bottleneck_bw_on_path
|
||||
```
|
||||
|
||||
router는 source에만 `.pe_dma`를 자동 부여하므로 destination에는 반드시
|
||||
`.pe_dma` suffix를 명시해야 한다. 그렇지 않으면 `find_path`가 raise하고
|
||||
credit이 0 cost로 silently teleport되는 latent bug가 발생한다 (이번
|
||||
업데이트에서 수정됨).
|
||||
|
||||
`tl.recv`는 credit-emit 완료를 yield-from으로 기다린다 (이전에는
|
||||
`env.process`로 fork). 이로써 credit-return cost가 receiver의
|
||||
`pe_exec_ns`에 반영되어, IPCQ control-plane이 consume-acknowledgement를
|
||||
완료한 뒤에야 recv가 kernel에 반환된다 — RAW DMA의 non-posted `tl.store`가
|
||||
HBM ack-trip을 기다리는 것의 protocol-level 등가물이다.
|
||||
|
||||
이로써:
|
||||
- **토폴로지 비례 approximation**: cube 내 credit return과 cross-SIP credit이
|
||||
자동으로 다른 latency를 가짐 (정확한 값은 아니지만 magic constant보다 의미 있음)
|
||||
- **Magic constant 없음**: 별도 `ipcq_ctrl_latency_ns` 같은 임의 값 불필요
|
||||
- **Deadlock 위험 없음**: piggyback과 달리 B가 A에게 보낼 데이터가 없어도
|
||||
credit이 자동 발행됨
|
||||
- **기존 utility 재사용**: `ComponentContext.compute_drain_ns` 그대로 사용
|
||||
자동으로 다른 latency를 가짐
|
||||
- **Magic constant 없음**: 모든 ns 값이 데이터 트래픽과 동일한 edge_map
|
||||
및 `node_overhead_ns`에서 산출되는 `compute_path_latency_ns`로부터 옴
|
||||
- **Deadlock 위험 없음**: `peer_credit_store.put`은 unbounded, B가 A에게
|
||||
보낼 데이터가 없어도 credit이 자동 발행됨
|
||||
- **`IPCQ ≥ raw DMA`** 보장: matched physical move에 대해 credit-emit이
|
||||
RAW의 ack-trip cost와 균형을 이룸
|
||||
|
||||
```
|
||||
PE B: tl.recv(W) → 데이터 가져감 → my_tail++
|
||||
@@ -426,11 +442,22 @@ backend init에서 IpcqInitMsg fan-out 시 양방향 fast path channel을 함께
|
||||
|
||||
#### PE_DMA의 책임 추가
|
||||
|
||||
PE_DMA(vc_comm)는 token 수신 시 다음 atomic 시퀀스로 처리한다.
|
||||
**두 동작 사이에 SimPy yield를 두어서는 안 된다** (I6 MUST 규칙 참조):
|
||||
PE_DMA(vc_comm)는 token 수신 시 다음 시퀀스로 처리한다: Transaction
|
||||
terminal의 BW drain을 먼저 지불하고, 이어서 atomic하게 data write +
|
||||
metadata forward 수행. **data write와 metadata forward 사이에는 SimPy
|
||||
yield를 두어서는 안 된다** (I6 MUST 규칙 참조). drain yield는 atomic
|
||||
구간 안이 아니라 그 앞에 위치해야 한다:
|
||||
|
||||
```python
|
||||
def _on_vc_comm_recv(self, env, token):
|
||||
def _on_vc_comm_recv(self, env, txn):
|
||||
# Sender PE_DMA가 찍어 둔 drain_ns (= nbytes / bottleneck_bw) 를
|
||||
# 여기서 지불. atomic 구간보다 앞이어야 한다 — recv는 bytes가
|
||||
# "도착"한 이후에만 깨어나야 하므로.
|
||||
drain = getattr(txn, "drain_ns", 0.0)
|
||||
if drain > 0:
|
||||
yield env.timeout(drain)
|
||||
|
||||
token = txn.request
|
||||
# ── ATOMIC: 두 동작 사이에 yield 금지 ──
|
||||
# 1. data를 dst_addr에 write (dst의 메모리 공간은 token.dst_endpoint.buffer_kind)
|
||||
data = self._memory_store.read(token.src_space, token.src_addr,
|
||||
@@ -446,6 +473,32 @@ wire로 capacity가 unbounded인 store를 사용하므로 즉시 완료된다 (
|
||||
single-step). 이 최종 put이 atomic 구간의 끝이며, 그 이전에 다른 yield가
|
||||
삽입되면 안 된다.
|
||||
|
||||
#### Drain-at-inbound semantics (D9 timing model)
|
||||
|
||||
Transaction은 sender PE_DMA가 `drain_ns = nbytes / bottleneck_bw_on_path`
|
||||
를 찍어 둔 상태로 fabric에 들어간다. 이 simulator에서 per-hop `overhead_ns`
|
||||
는 각 forwarding component의 `run()` 에서 지불되고, 남은 BW drain은
|
||||
Transaction의 terminal node에서 한 번 지불된다. IPCQ가 아닌 모든
|
||||
Transaction (raw DMA, kernel-launch fanout 등) 은
|
||||
`ComponentBase._forward_txn` 이 terminal에서 이 drain을 지불한다. IPCQ의
|
||||
경우 목적지 PE_DMA가 `_handle_ipcq_inbound` 핸들러로 Transaction을
|
||||
가로채서 (IPCQ 전용 data write + metadata forward를 해야 하므로)
|
||||
**이 핸들러 최상단에서 drain을 명시적으로 지불해야 한다** — 그래야 IPCQ의
|
||||
timing model이 다른 모든 fabric Transaction과 동일선상에 놓인다.
|
||||
|
||||
여기서 drain을 지불할 때의 side-effect:
|
||||
|
||||
- **SRC `tl.send`**: 동작 불변. sender PE_DMA가 `sub_done` 을 `yield`
|
||||
하지 않으므로 fire-and-forget 의미가 보존된다. metadata forward 이후
|
||||
호출되는 `sub_done.succeed()` 는 sender 입장에서 listener가 없는 이벤트.
|
||||
- **DST `tl.recv`**: `drain_ns` 만큼 늦게 깨어난다. recv는 local PE_IPCQ
|
||||
의 `IpcqMetaArrival` 수신 시에만 wake되며, metadata forward가 drain
|
||||
이후로 이동했으므로 recv는 bandwidth까지 포함한 전체 fabric transfer
|
||||
시간을 관측하게 된다.
|
||||
|
||||
물리적 그림과 일치: send는 dispatch하고 바로 반환; recv는 bytes가 실제로
|
||||
자신의 inbox로 drain될 때까지 대기.
|
||||
|
||||
#### Backpressure latency 정확도
|
||||
|
||||
backpressure 해제까지 걸리는 시간:
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed (Revision 8 — Hierarchical content split out to ADR-0029)
|
||||
Accepted. rank = SIP process-group model stands. The allreduce algorithm
|
||||
path (mapper / validator / per-PE install machinery originally targeted at
|
||||
ADR-0029) has been replaced by ADR-0032: `AhbmCCLBackend` now calls
|
||||
`configure_sfr_intercube_multisip` at `init_process_group` time and the
|
||||
intercube kernel receives `(sip_rank, sip_topo_kind, sip_topo_w,
|
||||
sip_topo_h)` appended after the module's `kernel_args()`. The
|
||||
`leader_only` / `all_pes` mapper concepts in this document are no longer
|
||||
used by the default allreduce path.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -89,7 +89,14 @@ direction_idx × bytes_per_direction). 따라서:
|
||||
`src/kernbench/ccl/install.py`:
|
||||
|
||||
```python
|
||||
_OPPOSITE_DIR = {"E": "W", "W": "E", "N": "S", "S": "N"}
|
||||
# Extended in ADR-0032 with global_* pairs for inter-SIP directions,
|
||||
# which were introduced by configure_sfr_intercube_multisip to keep
|
||||
# intercube (N/S/E/W) and inter-SIP (global_N/S/E/W) namespaces disjoint.
|
||||
_OPPOSITE_DIR = {
|
||||
"E": "W", "W": "E", "N": "S", "S": "N",
|
||||
"global_E": "global_W", "global_W": "global_E",
|
||||
"global_N": "global_S", "global_S": "global_N",
|
||||
}
|
||||
|
||||
def reverse_direction(my_rank: int, peer_rank: int, my_dir: str) -> str | None:
|
||||
"""Find peer's direction that reciprocates my_dir→peer_rank.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
Superseded by ADR-0032 (Intercube all-reduce). The 3-level kernel and
|
||||
`hierarchical_allreduce.py` module have been removed. The cube-mesh
|
||||
intercube + inter-SIP path is now the single all-reduce algorithm.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
## Status
|
||||
|
||||
Stub (Blocker for ADR-0030 — specific range allocations TBD)
|
||||
Superseded by ADR-0001 (Revision 2, 2026-04-27).
|
||||
PE_LOCAL / MCPU_LOCAL / CUBE_SRAM sub-unit tables are now defined in
|
||||
ADR-0001 D2.3.3-D2.3.5.
|
||||
|
||||
Previous status: Stub (Blocker for ADR-0030 — specific range allocations TBD)
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# ADR-0032: Intercube All-Reduce — pe0 cube-mesh reduce + multi-SIP exchange
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (supersedes ADR-0029).
|
||||
|
||||
## Context
|
||||
|
||||
### Goal
|
||||
|
||||
Define a single all-reduce algorithm that exploits the topology hierarchy:
|
||||
cube mesh within each SIP (intercube) + inter-SIP exchange. One kernel,
|
||||
one SFR configuration path, driven by `topology.yaml` and `ccl.yaml`.
|
||||
|
||||
### Why replace ADR-0029 (hierarchical 3-level)
|
||||
|
||||
ADR-0029 proposed a 3-level (intra-cube → inter-cube → inter-SIP) algorithm
|
||||
where every PE in the system participates. In practice this adds the
|
||||
intra-cube PE-to-PE stage complexity (bidirectional reduce + chain broadcast)
|
||||
without matching the common workload pattern where the tensor is sharded
|
||||
**per cube** (not per PE within a cube).
|
||||
|
||||
Moreover, the hierarchical design required:
|
||||
- per-PE neighbor graph installation (`_build_pe_installs` multi-level)
|
||||
- multi-level topology schema (`hierarchical_3level`)
|
||||
- `all_pes` mapper + `multi_pe_sip_local` validator infrastructure
|
||||
|
||||
The intercube algorithm below removes all of that: **pe0-only same-lane
|
||||
intercube reduce on the 4×4 cube mesh**, then inter-SIP exchange on the
|
||||
root cube, then broadcast back. Simpler kernel, simpler wiring, same
|
||||
bandwidth characteristics for the common per-cube DP workload.
|
||||
|
||||
### Current state
|
||||
|
||||
- `src/kernbench/ccl/algorithms/intercube_allreduce.py` — kernel
|
||||
- `src/kernbench/ccl/sfr_config.py` — `configure_sfr_intercube_multisip`
|
||||
- `src/kernbench/runtime_api/distributed.py` — `AhbmCCLBackend` wires this
|
||||
automatically at `init_process_group` time.
|
||||
- Old `ring_allreduce`, `mesh_allreduce`, `tree_allreduce`,
|
||||
`hierarchical_allreduce` modules and their tests are **removed**.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### D1. Algorithm structure — 5 phases
|
||||
|
||||
For each SIP (launched concurrently by `mp.spawn`):
|
||||
|
||||
```
|
||||
Phase 1 — Row reduce W → E (cube mesh, pe0 only):
|
||||
col=0 sends E → col=1 accumulates, sends E → ... → col=3 holds row sum.
|
||||
|
||||
Phase 2 — Col reduce N → S on rightmost column (pe0, col = mesh_w-1):
|
||||
row=0 sends S → row=1 accumulates, sends S → ... → root cube (15)
|
||||
holds the full SIP sum.
|
||||
|
||||
Phase 3 — Inter-SIP exchange on root cube (pe0 of root cube only):
|
||||
Ring / torus-2d row+col ring / mesh-2d chain reduce+broadcast —
|
||||
selected by sip_topo_kind (from topology.yaml sips.topology).
|
||||
|
||||
Phase 4 — Col broadcast S → N on rightmost column.
|
||||
|
||||
Phase 5 — Row broadcast E → W across the cube mesh.
|
||||
```
|
||||
|
||||
After all phases every cube's pe0 holds the global sum.
|
||||
|
||||
The kernel is a single function parameterised by `sip_topo_kind ∈ {0, 1, 2}`
|
||||
(ring_1d, torus_2d, mesh_2d_no_wrap). Phases 1-2 and 4-5 are identical
|
||||
across topologies; only phase 3 branches. Helper functions
|
||||
`_inter_sip_ring`, `_inter_sip_torus_2d`, `_inter_sip_mesh_2d` encode the
|
||||
three exchange patterns.
|
||||
|
||||
### D2. Tensor layout (rank = SIP, per-worker)
|
||||
|
||||
Per ADR-0024 rank = SIP at the process-group level. Each worker allocates
|
||||
its own cube-mesh-spanning tensor:
|
||||
|
||||
```python
|
||||
dp = DPPolicy(cube="row_wise", pe="replicate", num_cubes=16, num_pes=1)
|
||||
tensor = torch.zeros((n_cubes, n_elem), dtype="f16", dp=dp)
|
||||
```
|
||||
|
||||
Shard layout: 16 shards per SIP, one per cube on pe0. The kernel addresses
|
||||
each cube's shard as `pe_addr = t_ptr + cube_id * n_elem * 2`.
|
||||
|
||||
### D3. SFR / IPCQ wiring — `configure_sfr_intercube_multisip`
|
||||
|
||||
Replaces the rank-to-2-PE install from ADR-0024. Wires PE_IPCQ neighbor
|
||||
tables for **every cube's pe0 across every SIP** — regardless of which
|
||||
cube is the root or which SIP topology is selected. This lets the kernel
|
||||
elect the root cube at runtime and supports topology switches without
|
||||
re-wiring.
|
||||
|
||||
| Level | Direction labels | Scope |
|
||||
|---|---|---|
|
||||
| Intercube within SIP | N / S / E / W | pe0 of every cube → pe0 of mesh neighbors (no wrap) |
|
||||
| Inter-SIP (all cubes) | global_E / global_W / global_N / global_S | pe0 of cube c on sip A → pe0 of cube c on peer SIP per `sips.topology` |
|
||||
|
||||
Inter-SIP directions use the `global_*` prefix to keep the namespace
|
||||
disjoint from intercube directions. ADR-0025's `_OPPOSITE_DIR` is extended
|
||||
with `global_E ↔ global_W` and `global_N ↔ global_S` so the reverse-
|
||||
direction resolver handles 2-SIP bidirectional rings correctly.
|
||||
|
||||
Internally the function calls `install_ipcq` with:
|
||||
- `world_size = n_sips × n_cubes`
|
||||
- `rank_to_pe = [(sip, cube, 0) for sip in range(n_sips) for cube in range(n_cubes)]`
|
||||
- A closure-captured `neighbors()` function that builds the map above.
|
||||
|
||||
This `world_size` is internal to IPCQ wiring and does not leak to the
|
||||
process-group rank.
|
||||
|
||||
### D4. SIP topology — from `topology.yaml`
|
||||
|
||||
```yaml
|
||||
system:
|
||||
sips:
|
||||
count: 2
|
||||
topology: ring_1d # or torus_2d, mesh_2d_no_wrap
|
||||
```
|
||||
|
||||
- `ring_1d`: n_sips-1 rounds of `send global_E / recv global_W`.
|
||||
- `torus_2d`: sqrt(n_sips)×sqrt(n_sips) wrapping mesh. Row ring on
|
||||
`global_E/W` then col ring on `global_S/N`.
|
||||
- `mesh_2d_no_wrap`: square mesh without wrap-around. Chain reduce +
|
||||
broadcast per dimension.
|
||||
|
||||
2D variants require `n_sips` to be a perfect square.
|
||||
|
||||
### D5. Process-group integration — `AhbmCCLBackend`
|
||||
|
||||
At `init_process_group` time the backend:
|
||||
|
||||
1. Loads `ccl.yaml` + `topology.yaml`.
|
||||
2. Derives `sip_topo_kind, sip_topo_w, sip_topo_h` from
|
||||
`system.sips.topology` using the algorithm module's `TOPO_NAME_TO_KIND`.
|
||||
3. Calls `configure_sfr_intercube_multisip(engine, spec, cfg)` — one-time
|
||||
SFR wiring, mirrors NCCL communicator creation.
|
||||
|
||||
At each `dist.all_reduce(tensor)` call:
|
||||
|
||||
1. Resolves `kernel_fn` from `cfg["module"]`.
|
||||
2. Builds args: `(n_elem, cube_w, cube_h, n_sips)` from
|
||||
`kernel_args(world_size, n_elem)`.
|
||||
3. Appends `(sip_rank, sip_topo_kind, sip_topo_w, sip_topo_h)` where
|
||||
`sip_rank` is the current greenlet's bound rank.
|
||||
4. Launches with `_defer_wait=True`; the main scheduler drains pending
|
||||
handles after all workers submit (per ADR-0024 D7 / ADR-0027 D0.4).
|
||||
|
||||
### D6. Config schema
|
||||
|
||||
`ccl.yaml`:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
algorithm: intercube_allreduce
|
||||
buffer_kind: tcm
|
||||
...
|
||||
|
||||
algorithms:
|
||||
intercube_allreduce:
|
||||
module: kernbench.ccl.algorithms.intercube_allreduce
|
||||
topology: none
|
||||
buffer_kind: tcm
|
||||
n_elem: 8
|
||||
root_cube: 15
|
||||
```
|
||||
|
||||
`topology.yaml`:
|
||||
|
||||
```yaml
|
||||
system:
|
||||
sips:
|
||||
count: 2
|
||||
topology: ring_1d
|
||||
sip:
|
||||
cube_mesh: { w: 4, h: 4 }
|
||||
```
|
||||
|
||||
### D7. Algorithm module contract
|
||||
|
||||
Modules loaded via `cfg["module"]` must export:
|
||||
|
||||
| Name | Purpose |
|
||||
|---|---|
|
||||
| `kernel` | callable, signature `(t_ptr, n_elem, cube_w, cube_h, n_sips, sip_rank, sip_topo_kind, sip_topo_w, sip_topo_h, tl)` |
|
||||
| `kernel_args(world_size, n_elem) -> tuple` | returns the first 4 scalar args (per-tensor) |
|
||||
| `TOPO_NAME_TO_KIND: dict[str, int]` | maps `system.sips.topology` name to kernel branch code |
|
||||
| `SIP_TOPO_RING`, `SIP_TOPO_TORUS`, `SIP_TOPO_MESH` | integer constants (0, 1, 2) |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **ADR-0023**: IPCQ protocol (neighbor table, send/recv, credit return).
|
||||
- **ADR-0024**: rank = SIP launcher, `mp.spawn`, greenlet-local rank.
|
||||
- **ADR-0025**: Address-based IPCQ direction matching; extended
|
||||
`_OPPOSITE_DIR` with `global_*` pairs.
|
||||
- **ADR-0027**: Worker-wait / collective-pending drain in main scheduler.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Per-PE allreduce** (intra-cube PE-to-PE reduce). Out of scope — the
|
||||
workload for this algorithm is per-cube DP.
|
||||
- **Asymmetric SIP topologies** (non-square mesh/torus). `torus_2d` and
|
||||
`mesh_2d_no_wrap` require `n_sips = k²`.
|
||||
- **Pipelined chunks**: single-tile per cube, no pipelining yet.
|
||||
- **Root cube runtime election**: the kernel currently uses
|
||||
`root_cube = (mesh_h - 1) * mesh_w + (mesh_w - 1)` hardcoded to the SE
|
||||
corner. SFR wiring covers all cubes, so runtime election is a pure kernel
|
||||
change when needed.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Single kernel, single install path** for all-reduce — replaces four
|
||||
removed modules (`ring`, `mesh`, `tree`, `hierarchical`).
|
||||
- **Topology-agnostic kernel**: ring / torus / mesh selected via one
|
||||
integer param, no kernel duplication.
|
||||
- **Automatic via `dist.all_reduce`**: no bench-level or user-level
|
||||
algorithm selection needed; config-driven end-to-end.
|
||||
- **Full SFR wiring**: every cube on every SIP has inter-SIP links
|
||||
available — supports future dynamic root-cube election.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Not suitable for per-PE sharded tensors**: TP-layer-style tensors that
|
||||
shard within one cube across 8 PEs are not addressable by this kernel.
|
||||
Such workloads would need a separate intra-cube all-reduce path (not
|
||||
yet implemented).
|
||||
- **`configure_sfr_intercube_multisip` always wires all pe0s**: even if a
|
||||
given run only needs a subset (e.g. 1 SIP, ring only). Install cost is
|
||||
small but not zero.
|
||||
|
||||
---
|
||||
|
||||
## Affected files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/kernbench/ccl/algorithms/intercube_allreduce.py` (new) | Kernel + `_inter_sip_*` helpers + `TOPO_NAME_TO_KIND` |
|
||||
| `src/kernbench/ccl/sfr_config.py` (new) | `configure_sfr_intercube_multisip` |
|
||||
| `src/kernbench/ccl/topologies.py` | Added `torus_2d`, `mesh_2d_no_wrap` |
|
||||
| `src/kernbench/ccl/install.py` | Extended `_OPPOSITE_DIR` with `global_*` pairs |
|
||||
| `src/kernbench/runtime_api/distributed.py` | `AhbmCCLBackend` uses `configure_sfr_intercube_multisip` + appends sip_rank/topo args |
|
||||
| `ccl.yaml` | Single `intercube_allreduce` entry |
|
||||
| `topology.yaml` | Added `system.sips.topology` |
|
||||
| `benches/ccl_allreduce.py` | Row-wise cube-mesh tensor layout |
|
||||
| `tests/test_allreduce_multidevice.py` (new) | Config-driven ring/torus/mesh |
|
||||
| `tests/test_distributed_intercube_allreduce.py` (new) | Full `dist.all_reduce` path |
|
||||
| `tests/test_intercube_sfr_config.py` (new) | SFR wiring verification |
|
||||
| Removed | `ring_allreduce.py`, `mesh_allreduce.py`, `tree_allreduce.py`, `hierarchical_allreduce.py`, `hello_send.py`, `testing.py` and their tests |
|
||||
@@ -0,0 +1,168 @@
|
||||
# ADR-0033 — Latency Model: Assumptions and Known Simplifications
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The simulator is an analytical, event-driven performance model — not a
|
||||
cycle-accurate or RTL-level simulator. Many real-HW effects are approximated
|
||||
or omitted by design. To keep the model auditable and reviewable as a whole,
|
||||
this ADR consolidates the assumptions in one place. Individual component ADRs
|
||||
(ADR-0015, ADR-0019, ADR-0004) define the *mechanisms*; this document defines
|
||||
the *limits of fidelity*.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Modeled precisely
|
||||
|
||||
- **Per-directed-edge BW occupancy** (FIFO serialization via `available_at`) —
|
||||
ADR-0015 D2.
|
||||
- **Per-component switching/overhead latency** (`overhead_ns` attr).
|
||||
- **HBM per-pseudo-channel parallelism** via stateless `pc_avail[N]` array
|
||||
with global round-robin chunking. Burst granularity tunable
|
||||
(`burst_bytes`, default 256B). Read and write share each PC's
|
||||
`available_at` (real HW command bus is per-PC shared).
|
||||
- **HBM direction switching penalty mechanism**: per-PC last-direction
|
||||
tracking + configurable `switch_penalty_ns`. Default 0 — see D2.
|
||||
- **Wire chunk-streaming (Phase 2c)**: each wire decomposes Transactions
|
||||
with payload into `Flit` objects of `flit_bytes` (default = HBM
|
||||
`burst_bytes` = 256B). The wire emits each flit individually after
|
||||
`prop_ns + flit_nbytes/bw_gbs` so the link's bandwidth throttles
|
||||
flit arrival rate per real-HW wormhole semantics.
|
||||
- **Separate Stores per directed edge** (Phase 2c key fix): the wire
|
||||
is the *only* conduit between `src.out_ports[dst]` and
|
||||
`dst.in_ports[src]`. Earlier the two were aliased to the same
|
||||
`simpy.Store`; when the wire put a chunkified flit back, the
|
||||
destination's `fan_in` could pull it before the wire applied
|
||||
bandwidth delay, leaving half the flits bypassing the bottleneck.
|
||||
- **Flit-aware pass-through** (`TransitComponent`, `HbmCtrlComponent`):
|
||||
forward each flit serially with per-transaction overhead applied
|
||||
ONCE on the first-flit arrival (header decode model). Subsequent
|
||||
flits pipeline through with no extra delay. Wormhole emerges
|
||||
naturally across multi-hop paths.
|
||||
- **HBM CTRL per-flit PC commit**: each flit arriving at HBM CTRL
|
||||
schedules a PC commit at `max(env.now, pc_avail[pc]) + chunk_time`,
|
||||
with the `is_last` flit waiting for the last PC commit before
|
||||
signaling `txn.done`.
|
||||
- **Non-flit-aware components (default) reassemble flits at
|
||||
``_fan_in``** before the legacy `_forward_txn` path runs. This
|
||||
preserves backward compatibility for components that have not yet
|
||||
been migrated to flit-aware processing (e.g., `MCpuComponent`,
|
||||
`IoCpuComponent` sub-txn generators). Such components reassemble
|
||||
*once per leg boundary*, NOT per hop — multi-hop wormhole timing
|
||||
through a chain of flit-aware routers is preserved.
|
||||
|
||||
### D2. Approximated (with known directional error)
|
||||
|
||||
| Effect | Real HW | Our model | Error direction |
|
||||
|--------|---------|-----------|----------------|
|
||||
| Router output port arbitration | Round-robin / weighted | Wire edge FIFO + serial worker | Fair when one txn per cycle; multi-stream sharing not modeled at flit level |
|
||||
| HBM scheduler / write buffer | FR-FCFS + watermark drain | FIFO, no reordering | Pessimistic for mixed R/W when alternations are dense — default `switch_penalty_ns = 0` assumes ideal scheduler amortizes |
|
||||
| Flit ↔ burst granularity | 32B flit < 256B burst | `flit_bytes = burst_bytes = 256B` | Sub-flit fine-grained timing noise; affects very small wire arbitration windows only |
|
||||
| Wire-level RR fairness | Per-cycle multi-flow arbitration on shared link | Single serial wire process per edge | Fair only when one transaction is in flight on a given edge at a time. Multi-stream concurrent traffic on the same edge serializes by FIFO order |
|
||||
|
||||
### D3. Ignored (out of scope)
|
||||
|
||||
- Bank-level row buffer conflict penalty (assume no conflicts — best case;
|
||||
round-robin chunk assignment is address-blind so we cannot detect same-bank
|
||||
reuse).
|
||||
- HBM tRP / tRCD / tFAW / tRC timing constraints (absorbed into the steady-state
|
||||
`burst_time = burst_bytes / pc_bw_gbs`).
|
||||
- Refresh, ECC, thermal throttling, power gating.
|
||||
- Clock domain crossings, PLL lock time.
|
||||
- Upstream backpressure due to downstream buffer occupancy (input ports use
|
||||
unbounded `simpy.Store`).
|
||||
- Sub-flit cycle-level arbitration at routers (flit granularity is our
|
||||
smallest unit).
|
||||
|
||||
### D4. Workload sensitivity
|
||||
|
||||
Workloads where the above simplifications meaningfully affect results:
|
||||
|
||||
- **Random scatter/gather**: bank conflict ignored → model optimistic.
|
||||
- **Heavy mixed R/W intensive** (e.g., GEMM bias accumulation): HBM scheduler
|
||||
absent. With default `switch_penalty_ns = 0` we assume ideal amortization;
|
||||
setting it non-zero models pessimistic per-alternation cost.
|
||||
- **High concurrency (>10 active flows on one link)**: HoL blocking and VC
|
||||
limits not modeled → model optimistic.
|
||||
- **Very small (sub-flit) transactions**: flit quantization noise.
|
||||
- **Concurrent multi-flow on a single wire**: wire is serial FIFO at the
|
||||
flit level, so per-flow fairness within a single edge is not modeled.
|
||||
Pre-edge merging (multiple sources arriving at a router and being
|
||||
forwarded to the same downstream wire) is correctly modeled via the
|
||||
flit-aware router's serial worker.
|
||||
|
||||
### D5. Verification policy
|
||||
|
||||
For workloads in D4, cross-check against real HW or a cycle-accurate
|
||||
simulator before drawing absolute-magnitude conclusions. The model remains
|
||||
accurate for **relative comparisons** within the modeled regime.
|
||||
|
||||
### D6. Future work
|
||||
|
||||
Note: multi-stream merging at routers IS modeled correctly — each
|
||||
in_port has its own fan_in process, all push to a shared inbox, and
|
||||
the router worker forwards in inbox FIFO order. Flits from different
|
||||
upstream streams naturally interleave at flit granularity. The items
|
||||
below are different concerns, ordered by expected workload impact.
|
||||
|
||||
**Higher impact (workload accuracy gap)**:
|
||||
|
||||
- [ ] **Address-based PC selection at HBM CTRL** (replace the
|
||||
address-blind global round-robin). When two transactions of size
|
||||
`num_pcs × burst_bytes` (e.g., 2KB at 8 PCs × 256B) arrive
|
||||
concurrently, both claim PCs 0..7 via global RR, producing full
|
||||
per-PC contention even when real-HW address striping would put
|
||||
them on disjoint PC sets. Directly affects multi-PE concurrent
|
||||
HBM workload latencies.
|
||||
- [ ] **Bank-level conflict modeling** within a PC (opt-in via
|
||||
`track_banks: true`). Currently we assume no same-bank reuse;
|
||||
random scatter/gather workloads are optimistic here.
|
||||
- [ ] **HBM scheduler** with write buffer + watermark drain (Tier 2
|
||||
from the design discussion). Default `switch_penalty_ns=0` is the
|
||||
ideal-amortization stand-in; bursty mixed R/W workloads benefit
|
||||
from explicit modeling.
|
||||
- [ ] **Backpressure** modeling for finite component buffers. Matters
|
||||
at high concurrency / sustained saturation where buffer occupancy
|
||||
causes upstream stalls.
|
||||
- [ ] **Op_log integration with chunk-streaming**: currently op_log
|
||||
fires on PE-internal command messages (DmaReadCmd, DmaWriteCmd,
|
||||
GemmCmd, MathCmd) which are not chunkified. Integration would
|
||||
require flit-aware components to also emit op_log start/end hooks
|
||||
per transaction (start on first flit, end on is_last).
|
||||
|
||||
**Lower impact (academic / specific use cases)**:
|
||||
|
||||
- [ ] **Cycle-accurate router arbitration policies** (RR with
|
||||
priorities, age, iSLIP). The FIFO inbox is already approximately
|
||||
fair when flit arrival times differ slightly between streams (the
|
||||
common case for similar-rate workloads). True impact appears only
|
||||
for: (a) priority/QoS modeling, (b) per-stream tail latency
|
||||
analysis under sustained saturation. Not critical for makespan or
|
||||
average-latency studies.
|
||||
- [ ] **Sub-flit (32B) granularity** for finer wire arbitration
|
||||
cycles. Our `flit_bytes` equals burst (256B); real HW arbitrates
|
||||
per 32B flit. Effect is small for most workloads (sub-flit timing
|
||||
noise on small messages).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Single review point for all model fidelity questions. Each future PR
|
||||
touching latency must update the relevant section here.
|
||||
- Workload-specific magnitude error envelopes are explicit.
|
||||
- Builder-side derivation of `pc_bw_gbs = hbm_to_router_bw_gbs / num_pcs`
|
||||
enforces the ADR-0019 D9 invariant in code rather than relying on yaml
|
||||
manual consistency.
|
||||
- Wire transfer time is charged once per bottleneck-link transit (Phase 2c
|
||||
per-flit timing) rather than via terminal `drain_ns` injection. Single
|
||||
transactions land at `drain + commit_time + small_overheads`; multi-hop
|
||||
preserves wormhole pipelining; multi-stream merge correctly serializes
|
||||
at the shared wire's FIFO.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- ADR-0015 — component / port / wire model.
|
||||
- ADR-0019 — NoC and local HBM topology.
|
||||
- ADR-0004 — memory semantics, local HBM.
|
||||
@@ -0,0 +1,13 @@
|
||||
buffer_kind,sip_topology,n_sips,n_elem,bytes_per_pe,latency_ns
|
||||
hbm,torus_2d,6,128,256,2144.0399999999754
|
||||
hbm,torus_2d,6,1024,2048,2908.74499999995
|
||||
hbm,torus_2d,6,8192,16384,8851.185000000081
|
||||
hbm,torus_2d,6,32768,65536,29225.265000008752
|
||||
sram,torus_2d,6,128,256,2060.0399999999754
|
||||
sram,torus_2d,6,1024,2048,2908.74499999995
|
||||
sram,torus_2d,6,8192,16384,9523.185000000081
|
||||
sram,torus_2d,6,32768,65536,32201.265000008752
|
||||
tcm,torus_2d,6,128,256,1964.0399999999754
|
||||
tcm,torus_2d,6,1024,2048,2476.74499999995
|
||||
tcm,torus_2d,6,8192,16384,6403.185000000081
|
||||
tcm,torus_2d,6,32768,65536,19865.265000008738
|
||||
|
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,37 @@
|
||||
algorithm,sip_topology,n_sips,n_elem,bytes_per_pe,bytes_per_sip,latency_ns
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,8,16,256,2666.5524999999725
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,32,64,1024,2747.7399999999725
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,64,128,2048,2855.98999999998
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,128,256,4096,3072.4899999999725
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,512,1024,16384,3336.579999999951
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,1024,2048,32768,3707.49999999992
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,2048,4096,65536,4449.339999999875
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,4096,8192,131072,5933.020000000055
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,8192,16384,262144,8900.380000000157
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,16384,32768,524288,14835.099999997583
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,32768,65536,1048576,26704.540000017492
|
||||
intercube_allreduce,mesh_2d_no_wrap,6,49152,98304,1572864,38573.980000026335
|
||||
intercube_allreduce,ring_1d,6,8,16,256,2365.2558333333036
|
||||
intercube_allreduce,ring_1d,6,32,64,1024,2436.9433333333036
|
||||
intercube_allreduce,ring_1d,6,64,128,2048,2532.526666666643
|
||||
intercube_allreduce,ring_1d,6,128,256,4096,2723.6933333333036
|
||||
intercube_allreduce,ring_1d,6,512,1024,16384,3042.0349999999544
|
||||
intercube_allreduce,ring_1d,6,1024,2048,32768,3390.201666666597
|
||||
intercube_allreduce,ring_1d,6,2048,4096,65536,4079.7349999998714
|
||||
intercube_allreduce,ring_1d,6,4096,8192,131072,5458.801666666721
|
||||
intercube_allreduce,ring_1d,6,8192,16384,262144,8216.93500000014
|
||||
intercube_allreduce,ring_1d,6,16384,32768,524288,13733.201666664638
|
||||
intercube_allreduce,ring_1d,6,32768,65536,1048576,24765.735000014545
|
||||
intercube_allreduce,ring_1d,6,49152,98304,1572864,35798.268333355256
|
||||
intercube_allreduce,torus_2d,6,8,16,256,1700.6024999999754
|
||||
intercube_allreduce,torus_2d,6,32,64,1024,1753.2899999999754
|
||||
intercube_allreduce,torus_2d,6,64,128,2048,1823.539999999979
|
||||
intercube_allreduce,torus_2d,6,128,256,4096,1964.0399999999754
|
||||
intercube_allreduce,torus_2d,6,512,1024,16384,2196.2849999999653
|
||||
intercube_allreduce,torus_2d,6,1024,2048,32768,2476.74499999995
|
||||
intercube_allreduce,torus_2d,6,2048,4096,65536,3037.664999999919
|
||||
intercube_allreduce,torus_2d,6,4096,8192,131072,4159.50500000003
|
||||
intercube_allreduce,torus_2d,6,8192,16384,262144,6403.185000000081
|
||||
intercube_allreduce,torus_2d,6,16384,32768,524288,10890.544999998769
|
||||
intercube_allreduce,torus_2d,6,32768,65536,1048576,19865.265000008738
|
||||
intercube_allreduce,torus_2d,6,49152,98304,1572864,28839.985000013185
|
||||
|
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,81 @@
|
||||
hop,label,size_bytes,path,total_ns
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),128,ipcq,42.8899999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),128,raw,29.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),256,ipcq,48.1399999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),256,raw,31.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),384,ipcq,50.3899999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),384,raw,32.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),512,ipcq,52.6399999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),512,raw,33.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),768,ipcq,57.1399999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),768,raw,35.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),1024,ipcq,62.6399999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),1024,raw,37.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),2048,ipcq,84.6399999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),2048,raw,45.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),4096,ipcq,128.6399999999976
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),4096,raw,61.0199999999968
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),8192,ipcq,216.64000000000306
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),8192,raw,93.02000000000407
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),10240,ipcq,260.64000000000306
|
||||
h1_intra_horizontal,Intra-cube horizontal (pe0 to pe1),10240,raw,109.02000000000407
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),128,ipcq,42.8899999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),128,raw,29.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),256,ipcq,48.1399999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),256,raw,31.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),384,ipcq,50.3899999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),384,raw,32.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),512,ipcq,52.6399999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),512,raw,33.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),768,ipcq,57.1399999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),768,raw,35.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),1024,ipcq,62.6399999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),1024,raw,37.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),2048,ipcq,84.6399999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),2048,raw,45.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),4096,ipcq,128.6399999999976
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),4096,raw,61.0199999999968
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),8192,ipcq,216.64000000000306
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),8192,raw,93.02000000000407
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),10240,ipcq,260.64000000000306
|
||||
h2_intra_vertical,Intra-cube vertical (pe0 to pe4),10240,raw,109.02000000000407
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),128,ipcq,81.15999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),128,raw,89.28999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),256,ipcq,88.65999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),256,raw,95.53999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),384,ipcq,90.90999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),384,raw,96.53999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),512,ipcq,93.15999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),512,raw,97.53999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),768,ipcq,97.65999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),768,raw,99.53999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),1024,ipcq,103.15999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),1024,raw,102.53999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),2048,ipcq,125.15999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),2048,raw,114.53999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),4096,ipcq,169.15999999999804
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),4096,raw,138.53999999999724
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),8192,ipcq,257.15999999999985
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),8192,raw,186.54000000000087
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),10240,ipcq,301.15999999999985
|
||||
h3_inter_cube_horizontal,Inter-cube horizontal (cube0 to cube1),10240,raw,210.54000000000087
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),128,ipcq,103.15999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),128,raw,111.28999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),256,ipcq,112.65999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),256,raw,119.53999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),384,ipcq,114.90999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),384,raw,120.53999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),512,ipcq,117.15999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),512,raw,121.53999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),768,ipcq,121.65999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),768,raw,123.53999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),1024,ipcq,127.15999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),1024,raw,126.53999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),2048,ipcq,149.15999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),2048,raw,138.53999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),4096,ipcq,193.15999999999804
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),4096,raw,162.53999999999724
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),8192,ipcq,281.15999999999985
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),8192,raw,210.54000000000087
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),10240,ipcq,325.15999999999985
|
||||
h4_inter_cube_vertical,Inter-cube vertical (cube0 to cube4),10240,raw,234.54000000000087
|
||||
|
|
After Width: | Height: | Size: 368 KiB |
@@ -0,0 +1,157 @@
|
||||
direction: right
|
||||
|
||||
pe: PE {
|
||||
style.fill: "#f8f9fa"
|
||||
style.stroke: "#495057"
|
||||
style.border-radius: 8
|
||||
|
||||
cpu: PE_CPU (control) {
|
||||
style.fill: "#bbdefb"
|
||||
style.stroke: "#1565c0"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
sched: PE_SCHED (dispatch) {
|
||||
style.fill: "#bbdefb"
|
||||
style.stroke: "#1565c0"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
ipcq_added: IPCQ (added) {
|
||||
style.fill: "#e1f5fe"
|
||||
style.stroke: "#0277bd"
|
||||
style.stroke-dash: 5
|
||||
style.stroke-width: 2
|
||||
style.border-radius: 6
|
||||
|
||||
ipcq: PE_IPCQ (control plane) {
|
||||
style.fill: "#bbdefb"
|
||||
style.stroke: "#1565c0"
|
||||
style.border-radius: 4
|
||||
}
|
||||
}
|
||||
|
||||
dma: PE_DMA (single FIFO inbox) {
|
||||
style.fill: "#fff3e0"
|
||||
style.stroke: "#e65100"
|
||||
style.border-radius: 6
|
||||
}
|
||||
|
||||
fs: PE_FETCH_STORE {
|
||||
style.fill: "#c8e6c9"
|
||||
style.stroke: "#2e7d32"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
tcm: TCM (16MB SRAM) {
|
||||
style.fill: "#fce4ec"
|
||||
style.stroke: "#c62828"
|
||||
style.border-radius: 6
|
||||
|
||||
ipcq_slot: IPCQ Slot Region {
|
||||
style.stroke-dash: 5
|
||||
style.fill: "#ffcdd2"
|
||||
style.stroke: "#c62828"
|
||||
style.border-radius: 3
|
||||
}
|
||||
}
|
||||
|
||||
gemm: GEMM engine {
|
||||
style.fill: "#c8e6c9"
|
||||
style.stroke: "#2e7d32"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
math: MATH engine {
|
||||
style.fill: "#c8e6c9"
|
||||
style.stroke: "#2e7d32"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
fport: Fabric Port {
|
||||
style.fill: "#ffe0b2"
|
||||
style.stroke: "#e65100"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
# Control → dispatch
|
||||
cpu -> sched: cmd dispatch
|
||||
cpu -> ipcq_added.ipcq: IpcqRequest
|
||||
|
||||
# Compute pipeline
|
||||
sched -> dma: TileToken\n(compute port)
|
||||
dma -> fs: TileToken
|
||||
dma <-> tcm: DMA_READ/WRITE\n(HBM ↔ TCM)
|
||||
fs <-> tcm: fetch/store\n(TCM ↔ reg)
|
||||
fs -> gemm: TileToken
|
||||
fs -> math: TileToken
|
||||
gemm -> fs: TileToken
|
||||
math -> fs: TileToken
|
||||
|
||||
# IPCQ data path — outbound
|
||||
ipcq_added.ipcq -> dma: IpcqDmaToken\n(IPCQ port) {style.stroke: "#1565c0"}
|
||||
|
||||
# IPCQ data path — inbound (MetaArrival: DMA → IPCQ)
|
||||
dma -> ipcq_added.ipcq: IpcqMetaArrival {style.stroke: "#1565c0"}
|
||||
|
||||
# Credit return (dashed)
|
||||
ipcq_added.ipcq -> dma: IpcqCreditMetadata\n(NoC latency charged) {
|
||||
style.stroke: "#7b1fa2"
|
||||
style.stroke-dash: 5
|
||||
}
|
||||
|
||||
# DMA ↔ Fabric
|
||||
dma <-> fport
|
||||
}
|
||||
|
||||
# ── NoC Router + attached resources ──
|
||||
|
||||
noc: NoC Router {
|
||||
style.fill: "#f3e5f5"
|
||||
style.stroke: "#6a1b9a"
|
||||
style.border-radius: 6
|
||||
}
|
||||
|
||||
hbm: Local HBM {
|
||||
style.fill: "#e8eaf6"
|
||||
style.stroke: "#283593"
|
||||
style.border-radius: 6
|
||||
|
||||
ipcq_slot_hbm: IPCQ Slot Region {
|
||||
style.stroke-dash: 5
|
||||
style.fill: "#c5cae9"
|
||||
style.stroke: "#283593"
|
||||
style.border-radius: 3
|
||||
}
|
||||
}
|
||||
|
||||
sram: Cube SRAM {
|
||||
style.fill: "#e0f7fa"
|
||||
style.stroke: "#00695c"
|
||||
style.border-radius: 6
|
||||
|
||||
ipcq_slot_sram: IPCQ Slot Region {
|
||||
style.stroke-dash: 5
|
||||
style.fill: "#b2dfdb"
|
||||
style.stroke: "#00695c"
|
||||
style.border-radius: 3
|
||||
}
|
||||
}
|
||||
|
||||
other_pe: Other PEs {
|
||||
style.fill: "#ede7f6"
|
||||
style.stroke: "#6a1b9a"
|
||||
style.border-radius: 6
|
||||
}
|
||||
|
||||
other_cube: Other Cubes / SIPs {
|
||||
style.fill: "#ede7f6"
|
||||
style.stroke: "#6a1b9a"
|
||||
style.border-radius: 6
|
||||
}
|
||||
|
||||
pe.fport <-> noc
|
||||
noc <-> hbm
|
||||
noc <-> sram
|
||||
noc <-> other_pe
|
||||
noc <-> other_cube
|
||||
|
After Width: | Height: | Size: 1014 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,166 @@
|
||||
direction: right
|
||||
|
||||
pe: PE {
|
||||
style.fill: "#f8f9fa"
|
||||
style.stroke: "#495057"
|
||||
style.border-radius: 8
|
||||
|
||||
cpu: PE_CPU (control) {
|
||||
style.fill: "#bbdefb"
|
||||
style.stroke: "#1565c0"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
sched: PE_SCHED (dispatch) {
|
||||
style.fill: "#bbdefb"
|
||||
style.stroke: "#1565c0"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
ipcq: IPCQ Controller (NEW) {
|
||||
style.fill: "#e1f5fe"
|
||||
style.stroke: "#0277bd"
|
||||
style.border-radius: 6
|
||||
style.stroke-width: 2
|
||||
|
||||
ptrmgmt: Pointer Mgmt {
|
||||
style.fill: "#b3e5fc"
|
||||
style.stroke: "#0277bd"
|
||||
style.border-radius: 4
|
||||
|
||||
qprf: QPair Reg File
|
||||
bp: Backpressure
|
||||
sag: Slot Addr Gen
|
||||
}
|
||||
|
||||
sideband: Sideband {
|
||||
style.fill: "#b3e5fc"
|
||||
style.stroke: "#0277bd"
|
||||
style.border-radius: 4
|
||||
|
||||
metax: Meta Extractor
|
||||
crinj: Credit Injector
|
||||
crrcv: Credit Receiver
|
||||
}
|
||||
}
|
||||
|
||||
dma: PE_DMA (MOD) {
|
||||
style.fill: "#fff3e0"
|
||||
style.stroke: "#e65100"
|
||||
style.border-radius: 6
|
||||
|
||||
compute_port: compute port {
|
||||
style.fill: "#ffe0b2"
|
||||
style.stroke: "#e65100"
|
||||
style.border-radius: 4
|
||||
}
|
||||
ipcq_port: IPCQ port {
|
||||
style.fill: "#ffe0b2"
|
||||
style.stroke: "#e65100"
|
||||
style.border-radius: 4
|
||||
}
|
||||
wrr: WRR Arbiter (NEW) {
|
||||
style.fill: "#ffcc80"
|
||||
style.stroke: "#e65100"
|
||||
style.border-radius: 4
|
||||
style.stroke-width: 2
|
||||
}
|
||||
|
||||
compute_port -> wrr
|
||||
ipcq_port -> wrr
|
||||
}
|
||||
|
||||
fs: PE_FETCH_STORE {
|
||||
style.fill: "#c8e6c9"
|
||||
style.stroke: "#2e7d32"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
tcm: TCM (16MB SRAM) {
|
||||
style.fill: "#fce4ec"
|
||||
style.stroke: "#c62828"
|
||||
style.border-radius: 6
|
||||
|
||||
work: Kernel Working Memory {
|
||||
style.fill: "#f8bbd0"
|
||||
style.stroke: "#c62828"
|
||||
style.border-radius: 4
|
||||
}
|
||||
slot: IPCQ Slot Region (rsv) {
|
||||
style.fill: "#f48fb1"
|
||||
style.stroke: "#c62828"
|
||||
style.border-radius: 4
|
||||
style.stroke-width: 2
|
||||
}
|
||||
}
|
||||
|
||||
gemm: GEMM engine {
|
||||
style.fill: "#c8e6c9"
|
||||
style.stroke: "#2e7d32"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
math: MATH engine {
|
||||
style.fill: "#c8e6c9"
|
||||
style.stroke: "#2e7d32"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
fport: Fabric Port {
|
||||
style.fill: "#ffe0b2"
|
||||
style.stroke: "#e65100"
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
# Control
|
||||
cpu -> sched: cmd dispatch
|
||||
cpu -> ipcq: MMIO
|
||||
|
||||
# Compute pipeline
|
||||
sched -> dma.compute_port: TileToken
|
||||
dma -> fs: TileToken
|
||||
dma <-> tcm.work: DMA_READ/WRITE\n(HBM ↔ TCM)
|
||||
fs <-> tcm.work: fetch/store\n(TCM ↔ reg)
|
||||
fs -> gemm: TileToken
|
||||
fs -> math: TileToken
|
||||
gemm -> fs: TileToken
|
||||
math -> fs: TileToken
|
||||
|
||||
# IPCQ data path
|
||||
ipcq -> dma.ipcq_port: IpcqDmaToken {style.stroke: "#0277bd"}
|
||||
dma -> ipcq.sideband.metax: IpcqMetaArrival {style.stroke: "#0277bd"}
|
||||
|
||||
# IPCQ slot R/W
|
||||
dma <-> tcm.slot: slot read/write {
|
||||
style.stroke: "#0277bd"
|
||||
style.stroke-dash: 3
|
||||
}
|
||||
|
||||
# Credit via fabric port
|
||||
ipcq.sideband.crinj -> fport: credit out (16B) {
|
||||
style.stroke: "#7b1fa2"
|
||||
style.stroke-dash: 5
|
||||
}
|
||||
fport -> ipcq.sideband.crrcv: credit in (16B) {
|
||||
style.stroke: "#7b1fa2"
|
||||
style.stroke-dash: 5
|
||||
}
|
||||
|
||||
# DMA ↔ Fabric
|
||||
dma.wrr <-> fport
|
||||
}
|
||||
|
||||
noc: NoC Router {
|
||||
style.fill: "#f3e5f5"
|
||||
style.stroke: "#6a1b9a"
|
||||
style.border-radius: 6
|
||||
}
|
||||
|
||||
ext: Other PEs / Cubes / SIPs {
|
||||
style.fill: "#ede7f6"
|
||||
style.stroke: "#6a1b9a"
|
||||
style.border-radius: 6
|
||||
}
|
||||
|
||||
pe.fport <-> noc
|
||||
noc <-> ext
|
||||
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -1,33 +1,101 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="360" viewBox="0 0 500 360">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="560" height="420" viewBox="0 0 560 420">
|
||||
<title>pe</title>
|
||||
<rect width="500" height="360" fill="#f8fafc"/>
|
||||
<text x="250" y="18" text-anchor="middle" font-family="monospace" font-size="14" font-weight="bold" fill="#1e293b">PE VIEW</text>
|
||||
<line x1="92.5" y1="180.0" x2="180.0" y2="180.0" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="136.2" y="176.0" text-anchor="middle" font-family="monospace" font-size="7" fill="#64748b">0.5mm</text>
|
||||
<polyline points="180.0,180.0 180.0,92.5 285.0,92.5" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="232.5" y="132.2" text-anchor="middle" font-family="monospace" font-size="7" fill="#64748b">0.5mm</text>
|
||||
<line x1="180.0" y1="180.0" x2="285.0" y2="180.0" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="232.5" y="176.0" text-anchor="middle" font-family="monospace" font-size="7" fill="#64748b">0.5mm</text>
|
||||
<polyline points="180.0,180.0 180.0,267.5 285.0,267.5" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="232.5" y="219.8" text-anchor="middle" font-family="monospace" font-size="7" fill="#64748b">0.5mm</text>
|
||||
<polyline points="285.0,92.5 390.0,92.5 390.0,180.0" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="337.5" y="132.2" text-anchor="middle" font-family="monospace" font-size="7" fill="#64748b">0.5mm 512GB/s</text>
|
||||
<line x1="285.0" y1="180.0" x2="390.0" y2="180.0" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="337.5" y="176.0" text-anchor="middle" font-family="monospace" font-size="7" fill="#64748b">0.5mm 512GB/s</text>
|
||||
<polyline points="285.0,267.5 390.0,267.5 390.0,180.0" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="337.5" y="219.8" text-anchor="middle" font-family="monospace" font-size="7" fill="#64748b">0.5mm 512GB/s</text>
|
||||
<rect x="48.8" y="155.5" width="87.5" height="49.0" rx="4" fill="#ef4444" stroke="#475569" stroke-width="1"/>
|
||||
<text x="92.5" y="184.0" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE CPU</text>
|
||||
<rect x="136.2" y="155.5" width="87.5" height="49.0" rx="4" fill="#f59e0b" stroke="#475569" stroke-width="1"/>
|
||||
<text x="180.0" y="184.0" text-anchor="middle" font-family="monospace" font-size="9" fill="#1e293b">PE SCHEDULER</text>
|
||||
<rect x="241.2" y="68.0" width="87.5" height="49.0" rx="4" fill="#3b82f6" stroke="#475569" stroke-width="1"/>
|
||||
<text x="285.0" y="96.5" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE DMA</text>
|
||||
<rect x="241.2" y="155.5" width="87.5" height="49.0" rx="4" fill="#8b5cf6" stroke="#475569" stroke-width="1"/>
|
||||
<text x="285.0" y="184.0" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE GEMM</text>
|
||||
<rect x="241.2" y="243.0" width="87.5" height="49.0" rx="4" fill="#ec4899" stroke="#475569" stroke-width="1"/>
|
||||
<text x="285.0" y="271.5" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE MATH</text>
|
||||
<rect x="136.2" y="68.0" width="87.5" height="49.0" rx="4" fill="#e2e8f0" stroke="#475569" stroke-width="1"/>
|
||||
<text x="180.0" y="96.5" text-anchor="middle" font-family="monospace" font-size="10" fill="#1e293b">PE MMU</text>
|
||||
<rect x="346.2" y="155.5" width="87.5" height="49.0" rx="4" fill="#10b981" stroke="#475569" stroke-width="1"/>
|
||||
<text x="390.0" y="184.0" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE TCM</text>
|
||||
<rect width="560" height="420" fill="#f8fafc"/>
|
||||
<text x="280" y="18" text-anchor="middle" font-family="monospace" font-size="14" font-weight="bold" fill="#1e293b">PE VIEW</text>
|
||||
|
||||
<!-- ── Boxes ── -->
|
||||
|
||||
<!-- PE CPU -->
|
||||
<rect x="48.8" y="185.5" width="87.5" height="49.0" rx="4" fill="#ef4444" stroke="#475569" stroke-width="1"/>
|
||||
<text x="92.5" y="214.0" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE CPU</text>
|
||||
|
||||
<!-- PE SCHEDULER -->
|
||||
<rect x="156.2" y="185.5" width="87.5" height="49.0" rx="4" fill="#f59e0b" stroke="#475569" stroke-width="1"/>
|
||||
<text x="200.0" y="214.0" text-anchor="middle" font-family="monospace" font-size="9" fill="#1e293b">PE SCHEDULER</text>
|
||||
|
||||
<!-- PE_IPCQ (control plane) — new -->
|
||||
<rect x="48.8" y="68.0" width="105" height="49.0" rx="4" fill="#0ea5e9" stroke="#0277bd" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<text x="101.3" y="89.0" text-anchor="middle" font-family="monospace" font-size="9" fill="#ffffff">PE IPCQ</text>
|
||||
<text x="101.3" y="102.0" text-anchor="middle" font-family="monospace" font-size="7" fill="#e0f2fe">(control plane)</text>
|
||||
|
||||
<!-- PE MMU -->
|
||||
<rect x="173.8" y="68.0" width="87.5" height="49.0" rx="4" fill="#e2e8f0" stroke="#475569" stroke-width="1"/>
|
||||
<text x="217.5" y="96.5" text-anchor="middle" font-family="monospace" font-size="10" fill="#1e293b">PE MMU</text>
|
||||
|
||||
<!-- PE DMA -->
|
||||
<rect x="281.2" y="68.0" width="87.5" height="49.0" rx="4" fill="#3b82f6" stroke="#475569" stroke-width="1"/>
|
||||
<text x="325.0" y="96.5" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE DMA</text>
|
||||
|
||||
<!-- PE GEMM -->
|
||||
<rect x="281.2" y="185.5" width="87.5" height="49.0" rx="4" fill="#8b5cf6" stroke="#475569" stroke-width="1"/>
|
||||
<text x="325.0" y="214.0" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE GEMM</text>
|
||||
|
||||
<!-- PE MATH -->
|
||||
<rect x="281.2" y="283.0" width="87.5" height="49.0" rx="4" fill="#ec4899" stroke="#475569" stroke-width="1"/>
|
||||
<text x="325.0" y="311.5" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE MATH</text>
|
||||
|
||||
<!-- PE TCM (with IPCQ Slot Region) -->
|
||||
<rect x="396.2" y="155.5" width="120" height="100" rx="4" fill="#10b981" stroke="#475569" stroke-width="1"/>
|
||||
<text x="456.2" y="180.0" text-anchor="middle" font-family="monospace" font-size="10" fill="#ffffff">PE TCM</text>
|
||||
<!-- IPCQ Slot Region inside TCM -->
|
||||
<rect x="406.2" y="193.0" width="100" height="28" rx="3" fill="#065f46" stroke="#ffffff" stroke-width="1" stroke-dasharray="4,2" opacity="0.7"/>
|
||||
<text x="456.2" y="211.0" text-anchor="middle" font-family="monospace" font-size="7" fill="#d1fae5">IPCQ Slot Region</text>
|
||||
|
||||
<!-- ── Connections (edges) ── -->
|
||||
|
||||
<!-- PE CPU → PE SCHEDULER -->
|
||||
<line x1="136.3" y1="210.0" x2="156.2" y2="210.0" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="146.2" y="205.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#64748b">cmd</text>
|
||||
|
||||
<!-- PE CPU → PE_IPCQ (IpcqRequest) -->
|
||||
<line x1="92.5" y1="185.5" x2="92.5" y2="117.0" stroke="#0277bd" stroke-width="1.5"/>
|
||||
<polygon points="92.5,117.0 89.5,123.0 95.5,123.0" fill="#0277bd"/>
|
||||
<text x="77" y="152.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#0277bd" transform="rotate(-90,77,152)">IpcqRequest</text>
|
||||
|
||||
<!-- PE SCHEDULER → PE DMA (TileToken, compute port) -->
|
||||
<polyline points="200.0,185.5 200.0,92.5 281.2,92.5" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="240.0" y="86.5" text-anchor="middle" font-family="monospace" font-size="6" fill="#64748b">TileToken</text>
|
||||
|
||||
<!-- PE SCHEDULER → PE GEMM -->
|
||||
<line x1="243.7" y1="210.0" x2="281.2" y2="210.0" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
|
||||
<!-- PE SCHEDULER → PE MATH -->
|
||||
<polyline points="200.0,234.5 200.0,307.5 281.2,307.5" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
|
||||
<!-- PE DMA ↔ PE TCM -->
|
||||
<line x1="368.7" y1="92.5" x2="456.2" y2="155.5" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="420.0" y="118.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#64748b">DMA R/W</text>
|
||||
|
||||
<!-- PE GEMM → PE TCM -->
|
||||
<line x1="368.7" y1="210.0" x2="396.2" y2="210.0" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="382.4" y="205.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#64748b">512GB/s</text>
|
||||
|
||||
<!-- PE MATH → PE TCM -->
|
||||
<polyline points="368.7,307.5 456.2,307.5 456.2,255.5" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.8"/>
|
||||
<text x="412.4" y="301.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#64748b">512GB/s</text>
|
||||
|
||||
<!-- PE_IPCQ → PE DMA (IpcqDmaToken, IPCQ port) — blue -->
|
||||
<line x1="153.8" y1="82.0" x2="281.2" y2="82.0" stroke="#1565c0" stroke-width="1.5"/>
|
||||
<polygon points="281.2,82.0 275.2,79.0 275.2,85.0" fill="#1565c0"/>
|
||||
<text x="217.5" y="77.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#1565c0">IpcqDmaToken</text>
|
||||
|
||||
<!-- PE DMA → PE_IPCQ (IpcqMetaArrival) — blue -->
|
||||
<line x1="281.2" y1="102.0" x2="153.8" y2="102.0" stroke="#1565c0" stroke-width="1.5"/>
|
||||
<polygon points="153.8,102.0 159.8,99.0 159.8,105.0" fill="#1565c0"/>
|
||||
<text x="217.5" y="113.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#1565c0">IpcqMetaArrival</text>
|
||||
|
||||
<!-- PE_IPCQ → PE DMA (IpcqCreditMetadata, dashed purple) -->
|
||||
<line x1="153.8" y1="92.5" x2="281.2" y2="92.5" stroke="#7b1fa2" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="217.5" y="62.0" text-anchor="middle" font-family="monospace" font-size="6" fill="#7b1fa2">IpcqCreditMeta (dashed)</text>
|
||||
|
||||
<!-- ── Legend ── -->
|
||||
<rect x="15" y="365" width="530" height="45" rx="4" fill="#f1f5f9" stroke="#cbd5e1" stroke-width="0.5"/>
|
||||
<line x1="25" y1="385" x2="55" y2="385" stroke="#1565c0" stroke-width="1.5"/>
|
||||
<text x="60" y="388" font-family="monospace" font-size="7" fill="#1e293b">IPCQ data path</text>
|
||||
<line x1="140" y1="385" x2="170" y2="385" stroke="#7b1fa2" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="175" y="388" font-family="monospace" font-size="7" fill="#1e293b">IPCQ credit return</text>
|
||||
<line x1="290" y1="385" x2="320" y2="385" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<text x="325" y="388" font-family="monospace" font-size="7" fill="#1e293b">Compute data path</text>
|
||||
<rect x="430" y="378" width="40" height="14" rx="2" fill="none" stroke="#0277bd" stroke-width="1" stroke-dasharray="4,2"/>
|
||||
<text x="475" y="388" font-family="monospace" font-size="7" fill="#1e293b">IPCQ (new)</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,237 @@
|
||||
# Hardware Architecture Overview
|
||||
|
||||
본 문서는 AI Accelerator 플랫폼의 하드웨어 아키텍처를 요약한다.
|
||||
논문 분석 및 설계 검토 시 배경 지식으로 사용할 수 있다.
|
||||
|
||||
> Source ADRs: ADR-0003, ADR-0004, ADR-0014, ADR-0017, ADR-0022
|
||||
|
||||
---
|
||||
|
||||
## 1. System Hierarchy
|
||||
|
||||
시스템은 4단계 계층으로 구성된다.
|
||||
|
||||
```
|
||||
Tray
|
||||
├── Host CPU (runtime, data placement)
|
||||
├── SIP 0 (accelerator)
|
||||
│ ├── IO Chiplet (PCIe-EP, IO_CPU)
|
||||
│ ├── CUBE 0
|
||||
│ │ ├── PE 0 ─ PE 7
|
||||
│ │ ├── HBM + HBM_CTRL
|
||||
│ │ ├── Shared SRAM
|
||||
│ │ ├── M_CPU (management)
|
||||
│ │ ├── NOC 2D Mesh (router grid)
|
||||
│ │ └── UCIe × 4 (N/S/E/W)
|
||||
│ ├── CUBE 1 ... CUBE N
|
||||
│ └── IO Chiplet(s)
|
||||
├── SIP 1 ... SIP M
|
||||
└── Interconnect (PCIe / UAL)
|
||||
```
|
||||
|
||||
| Level | 구성 | 연결 |
|
||||
|-------|------|------|
|
||||
| **Tray** | Host CPU + 여러 SIP | PCIe / UAL fabric |
|
||||
| **SIP** | 여러 CUBE + IO chiplet(s) | UCIe (cube간), PCIe-EP (host) |
|
||||
| **CUBE** | 여러 PE + HBM + SRAM + M_CPU + NOC mesh | UCIe × 4 ports (N/S/E/W) |
|
||||
| **PE** | PE_CPU + DMA + GEMM + MATH + TCM | NOC router 직결 |
|
||||
|
||||
---
|
||||
|
||||
## 2. CUBE Architecture
|
||||
|
||||
각 CUBE는 독립적인 compute + memory unit이다.
|
||||
|
||||
### 2.1 Components
|
||||
|
||||
- **PEs**: 복수의 Processing Element, 각각 독립 커널 실행 가능
|
||||
- **HBM + HBM_CTRL**: High Bandwidth Memory. 각 PE에 local HBM 영역이 할당되어 최소 latency로 접근
|
||||
- **Shared SRAM**: Cube 내 모든 PE가 NOC를 통해 접근 가능한 공유 메모리
|
||||
- **M_CPU**: Management CPU. 커널 command 분배 및 completion 집계
|
||||
- **NOC (On-die Fabric)**: Cube 내 모든 컴포넌트를 연결하는 interconnect
|
||||
- **UCIe × 4**: 각 방향(N/S/E/W)에 복수 connection, inter-cube 연결
|
||||
|
||||
### 2.2 NOC (On-die Fabric)
|
||||
|
||||
NOC는 cube 내 PE, HBM, SRAM, M_CPU, UCIe를 연결하는 on-die interconnect이다.
|
||||
|
||||
**아키텍처 요구사항** (topology 무관):
|
||||
- 모든 PE가 local HBM에 full bandwidth로 접근 가능
|
||||
- 모든 PE가 shared SRAM에 접근 가능
|
||||
- 모든 PE가 UCIe를 통해 다른 cube에 접근 가능
|
||||
- M_CPU가 모든 PE에 command를 전달 가능
|
||||
- Per-link contention 모델링 지원
|
||||
|
||||
**현재 시뮬레이터 구현** (변경 가능):
|
||||
- 2D mesh router grid (6×6 기본, XY deterministic routing)
|
||||
- HBM_CTRL가 각 PE의 local router에 직결 (0 mesh hop)
|
||||
- 중앙 HBM zone에는 router 배치 제외
|
||||
- Contention: directed segment당 capacity=1 resource
|
||||
|
||||
NOC topology는 2D mesh 외에 ring, crossbar, hierarchical 등 다른 구현도 가능하며,
|
||||
아키텍처 요구사항을 만족하는 한 교체 가능하다.
|
||||
|
||||
### 2.3 주요 Data Path
|
||||
|
||||
| Path | Route | 특성 |
|
||||
|------|-------|------|
|
||||
| PE → Local HBM | PE_DMA → NOC → HBM_CTRL | 최소 hop, 256 GB/s (×0.8 eff) |
|
||||
| PE → Remote PE's HBM | PE_DMA → NOC hops → HBM_CTRL | NOC BW/hop에 제한 |
|
||||
| PE → Shared SRAM | PE_DMA → NOC → SRAM | SRAM link BW에 제한 |
|
||||
| PE → Other CUBE's HBM | PE_DMA → NOC → UCIe → NOC → HBM_CTRL | UCIe overhead 16ns (TX+RX) |
|
||||
| Kernel Launch | IO → UCIe → M_CPU → NOC → PE_CPU | Command path |
|
||||
|
||||
### 2.4 Key Bandwidths
|
||||
|
||||
| Connection | Bandwidth | Notes |
|
||||
|------------|-----------|-------|
|
||||
| PE_DMA ↔ NOC | 256 GB/s | HBM slice BW 매칭 |
|
||||
| NOC ↔ HBM_CTRL | 256 GB/s | Per PE, local 접근 |
|
||||
| NOC ↔ SRAM | 128 GB/s × 4 | 512 GB/s aggregate |
|
||||
| NOC ↔ UCIe conn | 128 GB/s × 4 | 512 GB/s per port |
|
||||
| UCIe link (inter-cube) | 512 GB/s | 1.0mm seam distance |
|
||||
|
||||
---
|
||||
|
||||
## 3. PE Architecture
|
||||
|
||||
각 PE는 하나의 커널 인스턴스를 실행하는 독립적인 프로세서이다.
|
||||
|
||||
### 3.1 Internal Components
|
||||
|
||||
```
|
||||
PE_CPU (control)
|
||||
│
|
||||
├──→ PE_SCHED (dispatch)
|
||||
│ │
|
||||
│ ├──→ PE_DMA ←→ NOC Router ←→ HBM / SRAM / UCIe
|
||||
│ │ ↕
|
||||
│ ├──→ PE_FETCH_STORE ←→ PE_TCM (16MB SRAM)
|
||||
│ │
|
||||
│ ├──→ PE_GEMM (matrix multiply)
|
||||
│ └──→ PE_MATH (elementwise)
|
||||
│
|
||||
└──→ PE_IPCQ (collective communication)
|
||||
│
|
||||
└──→ PE_DMA (IPCQ port)
|
||||
```
|
||||
|
||||
| Component | 역할 |
|
||||
|-----------|------|
|
||||
| **PE_CPU** | 커널 instruction stream 실행, command 생성 |
|
||||
| **PE_SCHED** | Command dispatcher. Composite command를 tile pipeline으로 분해 |
|
||||
| **PE_DMA** | HBM ↔ TCM 데이터 전송 (NOC router mesh 경유). Read/Write 각 1 channel |
|
||||
| **PE_GEMM** | 행렬 곱 엔진. TCM에서 activation 읽기, HBM에서 weight streaming 가능 |
|
||||
| **PE_MATH** | Element-wise 연산 엔진. TCM 읽기/쓰기 |
|
||||
| **PE_TCM** | 16MB on-PE SRAM. Compute의 staging memory |
|
||||
| **PE_IPCQ** | PE간 collective communication 제어 (ring buffer pointer 관리) |
|
||||
|
||||
### 3.2 Compute Pipeline (Tiled Execution)
|
||||
|
||||
Composite command는 tile 단위로 pipeline 실행된다:
|
||||
|
||||
```
|
||||
DMA_READ(t) → COMPUTE(t) → DMA_WRITE(t)
|
||||
```
|
||||
|
||||
**Overlap 규칙**:
|
||||
- 허용: `DMA_READ(t+1) ∥ COMPUTE(t)`, `DMA_WRITE(t-1) ∥ COMPUTE(t)`
|
||||
- 금지: `GEMM(t) ∥ GEMM(t')`, `GEMM(t) ∥ MATH(t')`
|
||||
|
||||
**DMA Engine**: Read/Write 각각 capacity=1. 동시 Read+Write 가능, 동시 Read+Read 불가.
|
||||
|
||||
**Compute Engine**: GEMM과 MATH가 단일 compute slot 공유. 한 번에 하나만 실행.
|
||||
|
||||
### 3.3 TCM-centric Dataflow
|
||||
|
||||
모든 compute는 TCM을 중심으로 동작한다:
|
||||
|
||||
```
|
||||
Input: HBM → (NOC) → PE_DMA → PE_TCM
|
||||
Compute: PE_TCM → GEMM / MATH → PE_TCM
|
||||
Output: PE_TCM → PE_DMA → (NOC) → HBM
|
||||
```
|
||||
|
||||
PE_TCM은 두 영역으로 분할된다:
|
||||
- **SchedulerReservedTCM**: PE_SCHED 전용 tile buffer 영역 (DMA/compute staging)
|
||||
- **AllocatableTCM**: 범용 할당 영역 (host/DP-visible)
|
||||
|
||||
두 영역은 hard isolation으로 분리된다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory Hierarchy
|
||||
|
||||
### 4.1 Memory Tiers
|
||||
|
||||
| Memory | Scope | Capacity | Bandwidth | Latency | 접근 경로 |
|
||||
|--------|-------|----------|-----------|---------|-----------|
|
||||
| **PE_TCM** | PE 전용 | 16 MB | 512 GB/s | 최저 | 직결 (NOC 미경유) |
|
||||
| **Shared SRAM** | Cube 공유 | 32 MB | 128 GB/s (NoC link) | 중간 | PE → NOC → SRAM |
|
||||
| **Local HBM** | PE별 할당 | Large | 256 GB/s (×0.8 eff) | 높음 | PE → local router → HBM_CTRL |
|
||||
| **Remote HBM** | 다른 PE/Cube | Large | Mesh/UCIe BW 제한 | 최고 | PE → NOC mesh → (UCIe) → HBM_CTRL |
|
||||
|
||||
### 4.2 Local HBM Bandwidth Guarantee
|
||||
|
||||
- 각 PE는 자신의 local router에 직결된 HBM pseudo-channel을 가진다
|
||||
- Local HBM 접근은 **0 mesh hop** (switching overhead만)
|
||||
- Effective bandwidth = spec BW × efficiency factor (default 0.8)
|
||||
- 예: 256 GB/s × 0.8 = 204.8 GB/s effective
|
||||
- 이 보장은 fabric bandwidth와 무관하게 유지된다
|
||||
|
||||
### 4.3 Memory-Centric Design Principle
|
||||
|
||||
- **Compute는 data 근처에서 실행**: PE가 local HBM에 직결되어 데이터 이동 최소화
|
||||
- **TCM은 compute의 scratchpad**: 모든 compute 입출력은 TCM을 경유
|
||||
- **HBM은 primary storage**: 대용량 tensor 저장, DMA로 TCM에 tile 단위 load/store
|
||||
- **Shared SRAM은 cube-level 공유**: 중간 결과 공유, reduction buffer 등
|
||||
|
||||
---
|
||||
|
||||
## 5. SPMD Execution Model
|
||||
|
||||
### 5.1 Program ID Mapping
|
||||
|
||||
커널은 2D hardware grid에서 SPMD 방식으로 실행된다:
|
||||
|
||||
| API | 반환 값 | 설명 |
|
||||
|-----|---------|------|
|
||||
| `tl.program_id(axis=0)` | `local_pe_id` | Cube 내 PE 인덱스 |
|
||||
| `tl.program_id(axis=1)` | `cube_id` | Cube 인덱스 |
|
||||
| `tl.num_programs(axis=0)` | `num_pes_per_cube` | Cube당 PE 수 |
|
||||
| `tl.num_programs(axis=1)` | `num_cubes` | 전체 Cube 수 |
|
||||
|
||||
```python
|
||||
global_pid = tl.program_id(axis=1) * tl.num_programs(axis=0) + tl.program_id(axis=0)
|
||||
```
|
||||
|
||||
### 5.2 Axis Mapping Rationale
|
||||
|
||||
- **axis=0 = PE (innermost)**: Cube 내 PE는 HBM을 공유하고 local NOC로 통신. 빠르고 tightly-coupled. GPU의 thread-in-block에 대응.
|
||||
- **axis=1 = Cube (outer)**: Cube 간 통신은 UCIe 경유로 latency 높음. Coarse scheduling 단위. GPU의 block-in-grid에 대응.
|
||||
|
||||
### 5.3 Kernel Execution Flow
|
||||
|
||||
```
|
||||
Host CPU
|
||||
→ IO_CPU (PCIe-EP)
|
||||
→ M_CPU (management, per cube)
|
||||
→ PE_CPU × N (broadcast)
|
||||
→ Each PE executes same kernel with unique (pe_id, cube_id)
|
||||
```
|
||||
|
||||
모든 PE가 동일 커널을 실행하되, `program_id`로 자신의 데이터 파티션을 식별하여
|
||||
독립적으로 처리한다 (SPMD).
|
||||
|
||||
---
|
||||
|
||||
## 6. Inter-PE Communication (IPCQ)
|
||||
|
||||
PE 간 collective communication은 IPCQ(Inter-PE Communication Queue)를 통해 수행된다.
|
||||
|
||||
- 각 PE는 방향별(N/S/E/W 등) ring buffer 기반 queue pair를 유지
|
||||
- **DMA-IPCQ co-design**: DMA data flit에 head pointer를 piggyback하여 별도 제어 메시지 없이 pointer 동기화
|
||||
- **Credit-based flow control**: Receiver가 slot 소비 후 16B credit으로 sender에게 알림
|
||||
- IPCQ slot buffer는 **TCM, Shared SRAM, Local HBM** 중 선택 가능
|
||||
|
||||
자세한 내용은 `docs/ipcq-dma-codesign-hw.md` 및 ADR-0023 참조.
|
||||
@@ -0,0 +1,548 @@
|
||||
# IPCQ-DMA Co-design Hardware Design Document
|
||||
|
||||
**Status**: Draft — Review Requested
|
||||
**Date**: 2026-04-28
|
||||
**Authors**: YW Kang
|
||||
**Reviewers**: (HW team TBD)
|
||||
**Related**: ADR-0023 (IPCQ PE Collective), ADR-0025 (Direction Addressing)
|
||||
|
||||
---
|
||||
|
||||
## 1. Background & Motivation
|
||||
|
||||
IPCQ(Inter-PE Communication Queue)는 PE 간 collective communication을 위한
|
||||
하드웨어 큐 메커니즘이다. 핵심 설계 원리는 **DMA가 데이터 전송 시 별도의
|
||||
제어 메시지 없이, piggyback된 메타 정보를 바탕으로 IPCQ의 head/tail pointer를
|
||||
자동 업데이트**하는 IPCQ-DMA co-design이다.
|
||||
|
||||
이 문서는:
|
||||
|
||||
1. 현재 PE 아키텍처에서 IPCQ가 하드웨어 수준에서 어떻게 동작하는지 기술하고,
|
||||
2. 이 하드웨어를 시뮬레이터에서 어떻게 모델링하고 있는지 검증하며,
|
||||
3. 실제 하드웨어 구현을 위한 설계를 제안하고,
|
||||
4. 대안들을 검토하여 최적 접근을 확정한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. High-level Behavior of PE_IPCQ
|
||||
|
||||

|
||||
|
||||
> source: [`diagrams/pe_baseline.d2`](diagrams/pe_baseline.d2) — `d2 --layout=elk --scale 1.5` 로 렌더링.
|
||||
|
||||
### IPCQ 하드웨어 동작
|
||||
|
||||
**HW Configuration**:
|
||||
* IPCQ는 PE 간에 ring buffer 기반의 단방향 큐를 설정하여 데이터를 전달한다.
|
||||
* 각 PE는 방향별(N/S/E/W 등)로 독립적인 queue pair 를 유지한다.
|
||||
* IPCQ는 각 queue pair 마다 sender's head/tail pointer, receiver's head/tail pointer 를 유지한다.
|
||||
|
||||
* **IPCQ Slot Region**: IPCQ의 수신 버퍼로, 다이어그램의 점선 박스로 표시된 것처럼 TCM, Cube SRAM, Local HBM 중 하나를 buffer_kind로 지정하여 사용할 수 있다.
|
||||
각 tier별 성능 특성 (시뮬레이션 모델 값, `ipcq_types.py`):
|
||||
|
||||
| Buffer Kind | Intrinsic BW | Effective BW (NoC bottleneck) | 용도 |
|
||||
|-------------|-------------|-------------------------------|------|
|
||||
| TCM | 512 GB/s | 512 GB/s (직결, NoC 미경유) | 최저 latency, PE 내부 전용 |
|
||||
| Cube SRAM | 512 GB/s | 128 GB/s (`sram_to_router_bw`) | Cube 내 공유, NoC BW에 제한 |
|
||||
| Local HBM | 256 GB/s | 256 GB/s (`hbm_to_router_bw`) | 대용량, NoC BW에 제한 |
|
||||
|
||||
**Send 경로 (fire-and-forget)**:
|
||||
1. PE_CPU가 `tl.send(dir, src_addr)` 발행 → PE_IPCQ에 IpcqRequest 전달
|
||||
2. PE_IPCQ가 backpressure 확인: `(my_head - peer_tail_cache) < peer.n_slots`
|
||||
3. Peer의 rx slot 주소 계산: `peer_rx_base + (my_head % n_slots) × slot_size`
|
||||
4. IpcqDmaToken(data + piggyback metadata: sender_seq)을 PE_DMA에 전달
|
||||
5. PE_IPCQ가 `my_head++`, PE_CPU에 즉시 반환 (DMA 완료를 기다리지 않음)
|
||||
6. PE_DMA가 src data를 snapshot 후 NoC를 통해 peer PE_DMA로 전송
|
||||
|
||||
**Receive 경로 (blocking)**:
|
||||
1. Peer PE_DMA가 data를 slot에 write하고, **같은 사이클에** metadata(sender_seq, dst_addr)를 추출
|
||||
2. PE_IPCQ가 dst_addr range matching으로 방향을 식별, `peer_head_cache` 업데이트
|
||||
3. `tl.recv(dir)` 대기 중인 PE_CPU에 wakeup signal 전달
|
||||
4. PE_CPU가 slot에서 데이터 읽기, PE_IPCQ가 `my_tail++`
|
||||
5. **Credit return**: PE_IPCQ가 16B credit packet(`consumer_seq`)을 NoC를 통해 sender에게 전송
|
||||
6. Sender PE_IPCQ가 `peer_tail_cache` 업데이트, backpressure 해제
|
||||
|
||||
**핵심 설계 원리**:
|
||||
- **Data + head pointer piggyback**: 별도의 head 동기화 메시지 없이, DMA data flit에 sender_seq를 실어보냄
|
||||
- **Atomic write + metadata**: 수신측 DMA가 slot write와 metadata 전달을 같은 사이클에 수행 (I6 invariant)
|
||||
- **Address-based direction matching**: 같은 peer에 여러 방향이 연결되어도 dst_addr range로 구분 (ADR-0025)
|
||||
- **Credit-based flow control**: Receiver가 slot 소비 후 16B credit으로 sender에게 알림
|
||||
|
||||
---
|
||||
|
||||
## 3. Simulator Implementation Verification
|
||||
|
||||
위의 하드웨어 동작을 시뮬레이터에서 어떻게 모델링하는지 검증한다.
|
||||
|
||||
### 3.1 의도와 구현의 매핑
|
||||
|
||||
| 설계 의도 | 시뮬레이터 구현 | 위치 |
|
||||
|-----------|----------------|------|
|
||||
| DMA가 데이터 전송 시 head pointer를 piggyback | `IpcqDmaToken.sender_seq` 필드가 data flit과 함께 전달 | `ipcq_types.py:185` |
|
||||
| 수신측 DMA가 data write + metadata 전달을 atomic 처리 | `_handle_ipcq_inbound`에서 `store.write` → `IpcqMetaArrival` 사이에 yield 없음 (I6) | `pe_dma.py:232-275` |
|
||||
| Send는 fire-and-forget | `_handle_ipcq_outbound`에서 `sub_done`을 기다리지 않음 | `pe_dma.py:182` |
|
||||
| Recv는 데이터 도착까지 block | `peer_head_cache > my_tail` 조건으로 대기 | `pe_ipcq.py:263` |
|
||||
| Credit return은 별도 fast-path | SimPy Store를 통한 direct put (latency는 NoC 경로 기반으로 charge) | `pe_ipcq.py:443-469` |
|
||||
| In-flight data semantics (snapshot) | Send 시점에 data snapshot 보존, 이후 src 수정과 무관 | `pe_dma.py:142-155` |
|
||||
| PE_DMA 단일 inbox | 모든 in_port를 `_fan_in`으로 단일 FIFO에 merge (`base.py:51-53`) | compute port와 IPCQ port 사이에 arbiter 없음 |
|
||||
|
||||
### 3.2 Credit Return Path 모델링 상세
|
||||
|
||||
Credit return은 실제 NoC 경로를 `router.find_path()`로 찾고,
|
||||
`compute_path_latency_ns()`로 hop latency + BW drain을 계산하여 charge한다.
|
||||
|
||||
```python
|
||||
# pe_ipcq.py:471-492
|
||||
def _credit_latency_ns(self, direction: str) -> float:
|
||||
path = self.ctx.router.find_path(self._pe_prefix, peer_pe_dma)
|
||||
return self.ctx.compute_path_latency_ns(path, self._credit_size_bytes)
|
||||
```
|
||||
|
||||
단, latency를 `env.timeout()`으로 지불한 후 `peer_credit_store`(SimPy Store)에
|
||||
직접 put하는 방식이다. 실제 `Transaction`을 만들어 NoC를 hop-by-hop 통과시키지는
|
||||
않으므로, **다른 트래픽과의 bandwidth contention은 모델링되지 않는다.**
|
||||
|
||||
| | Latency | BW Contention |
|
||||
|---|---|---|
|
||||
| Data path (IpcqDmaToken) | NoC Transaction으로 정확 모델링 | 실제 fabric 통과 |
|
||||
| Credit path (16B) | NoC 경로 latency 정확 반영 | fabric Transaction 미주입 (단순화) |
|
||||
|
||||
Credit은 16B로 data transfer(수십~수백 KB) 대비 무시 가능한 크기이므로,
|
||||
이 단순화로 인한 실질적 오차는 거의 없다.
|
||||
|
||||
### 3.3 검증 결론
|
||||
|
||||
시뮬레이터 구현은 IPCQ-DMA co-design 의도를 **정확하게 모델링**하고 있다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Hardware Design
|
||||
|
||||
### 4.1 Block Diagram (변경 후)
|
||||
|
||||
변경점을 강조 표시: **(NEW)** = 신규, **(MOD)** = 수정.
|
||||
|
||||

|
||||
|
||||
> Source: [`diagrams/pe_proposed.d2`](diagrams/pe_proposed.d2) — `d2 --layout=elk` 로 렌더링.
|
||||
|
||||
**Baseline → Proposed 핵심 변경**:
|
||||
- 단일 FIFO inbox → **compute port / IPCQ port 분리 + WRR Arbiter** (NEW)
|
||||
- PE_IPCQ (SimPy component) → **IPCQ Controller** (HW register + combinational logic)
|
||||
- TCM 내 **IPCQ Slot Region 예약 영역** 명시
|
||||
- Credit Injector / Receiver가 Fabric Port를 통해 NoC에 직접 연결
|
||||
|
||||
### 4.2 Module Details
|
||||
|
||||
#### 4.2.1 IPCQ Controller (신규 모듈)
|
||||
|
||||
PE_CPU와 DMA Engine 사이에 위치하는 하드웨어 제어 블록.
|
||||
시뮬레이터의 `PeIpcqComponent`에 대응한다.
|
||||
|
||||
##### QPair Register File
|
||||
|
||||
방향별 queue pair 상태를 flip-flop으로 유지한다.
|
||||
|
||||
```
|
||||
Per-direction registers (each 64-bit):
|
||||
my_head — sender write position (monotonic)
|
||||
my_tail — receiver read position (monotonic)
|
||||
peer_head_cache — last known peer head (updated by Meta Extractor)
|
||||
peer_tail_cache — last known peer tail (updated by Credit Receive)
|
||||
rx_base_pa — this PE's rx buffer base physical address
|
||||
peer_rx_base_pa — peer's rx buffer base physical address
|
||||
n_slots — ring depth (power-of-2 제약, 아래 참조)
|
||||
slot_size — bytes per slot
|
||||
peer_credit_tgt — peer PE의 credit receive 주소
|
||||
|
||||
Directions: 최대 8 (N/S/E/W/parent/child_left/child_right + spare)
|
||||
Total: 8 dirs × 9 regs × 8B = 576B flip-flops
|
||||
```
|
||||
|
||||
PE_CPU가 MMIO(CSR)로 읽기/쓰기 가능. Init 시점에 소프트웨어가 채워넣는다.
|
||||
|
||||
##### Slot Address Generator (combinational)
|
||||
|
||||
```
|
||||
Input: pointer (my_head or my_tail), n_slots, slot_size, base_pa
|
||||
Output: slot_addr = base_pa + (pointer % n_slots) * slot_size
|
||||
|
||||
Implementation:
|
||||
n_slots power-of-2 제약 → pointer & (n_slots - 1) (AND mask, 1 gate delay)
|
||||
slot_size power-of-2 → barrel shift (1 cycle)
|
||||
64-bit add → ripple/kogge-stone adder (1 cycle)
|
||||
|
||||
Latency: 1-2 cycles combinational
|
||||
```
|
||||
|
||||
##### Backpressure Comparator (combinational)
|
||||
|
||||
```
|
||||
full = (my_head - peer_tail_cache) >= n_slots
|
||||
|
||||
Implementation: 64-bit subtract + unsigned compare
|
||||
Output: stall signal → PE_CPU (IPCQ send blocked) or DMA issue hold
|
||||
Latency: 1 cycle
|
||||
```
|
||||
|
||||
##### Meta Extractor (inbound datapath sideband)
|
||||
|
||||
DMA Engine의 inbound vc_comm path에 wired. Arriving IPCQ flit의 header에서
|
||||
metadata를 추출하여 queue pair 상태를 업데이트한다.
|
||||
|
||||
```
|
||||
Trigger: DMA inbound write completion (same cycle)
|
||||
Extract: {sender_seq, dst_addr} from flit header
|
||||
|
||||
Direction matching (ADR-0025 D2):
|
||||
for each dir:
|
||||
match = (base_pa[dir] <= dst_addr) && (dst_addr < base_pa[dir] + n_slots[dir] * slot_size[dir])
|
||||
8× parallel range comparators + priority encoder
|
||||
|
||||
Update: peer_head_cache[matched_dir] = max(peer_head_cache, sender_seq + 1)
|
||||
Output: recv_wake signal for matched direction → PE_CPU interrupt/flag
|
||||
|
||||
Implementation: 8× (2 comparators + AND) + priority encoder
|
||||
Latency: 1 cycle (pipelined with DMA write — I6 atomicity 자연 보장)
|
||||
```
|
||||
|
||||
##### Credit Injector (outbound)
|
||||
|
||||
```
|
||||
Trigger: recv completion (my_tail 증가 후)
|
||||
Action: pack 16B credit packet → DMA vc_comm (또는 dedicated credit VC)
|
||||
|
||||
Packet: {consumer_seq = my_tail, dst_rx_base_pa = my_rx_base_pa}
|
||||
Latency: 1 cycle to generate, then NoC traversal
|
||||
```
|
||||
|
||||
##### Credit Receiver (inbound sideband)
|
||||
|
||||
```
|
||||
Trigger: 16B credit packet arrival (from NoC)
|
||||
Extract: {consumer_seq, dst_rx_base_pa}
|
||||
|
||||
Direction matching (ADR-0025 D3):
|
||||
for each dir:
|
||||
match = (peer_rx_base_pa[dir] == credit.dst_rx_base_pa)
|
||||
|
||||
Update: peer_tail_cache[matched_dir] = max(peer_tail_cache, consumer_seq)
|
||||
Output: send_wake signal → deassert backpressure stall
|
||||
|
||||
Latency: 1 cycle
|
||||
```
|
||||
|
||||
#### 4.2.2 DMA Engine 수정사항
|
||||
|
||||
##### vc_comm IPCQ-aware mode
|
||||
|
||||
기존 vc_comm 채널에 IPCQ flit 처리 모드를 추가한다.
|
||||
|
||||
**Outbound**:
|
||||
1. IPCQ Controller로부터 command 수신: {src_addr, dst_addr, nbytes, sender_seq}
|
||||
2. TCM에서 src_addr read → DMA read buffer에 snapshot (기존 DMA behavior)
|
||||
3. Flit pack: data + piggyback metadata (sender_seq, dst_addr)
|
||||
4. NoC fabric port에 inject
|
||||
5. Fire-and-forget (completion을 기다리지 않음)
|
||||
|
||||
**Inbound**:
|
||||
1. NoC로부터 IPCQ flit 수신
|
||||
2. Terminal BW drain charge (drain_ns = nbytes / bottleneck_bw)
|
||||
3. Slot write latency charge (backing memory tier)
|
||||
4. **ATOMIC** (same pipeline stage, no stall insertion):
|
||||
- TCM write: data → slot address
|
||||
- Meta Extractor trigger: sender_seq + dst_addr → IPCQ Controller
|
||||
5. Done
|
||||
|
||||
**I6 atomicity 하드웨어 보장**: TCM write completion과 Meta Extractor trigger가
|
||||
동일 pipeline stage에서 발생하므로 별도 synchronization이 불필요하다.
|
||||
시뮬레이터의 "no yield between write and IpcqMetaArrival"이 자연스럽게 보장된다.
|
||||
|
||||
##### Data Snapshot Semantics
|
||||
|
||||
DMA read buffer에 latch된 데이터는 src memory의 이후 수정에 영향받지 않는다.
|
||||
이는 DMA의 standard read-then-write behavior이므로 추가 HW가 불필요하다.
|
||||
|
||||
##### Credit Virtual Channel (선택적)
|
||||
|
||||
옵션 A: vc_comm에 credit을 multiplexing (16B header-only flit으로 구분)
|
||||
옵션 B: 3rd dedicated credit VC 추가 (strict priority > data)
|
||||
|
||||
옵션 B가 deadlock prevention에 유리하나, 16B credit의 BW 영향이 무시 가능하므로
|
||||
옵션 A로도 충분하다.
|
||||
|
||||
#### 4.2.3 Fabric Flit Format 확장
|
||||
|
||||
```
|
||||
일반 data flit (예: 512-bit):
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [511:480] routing header (32b) │
|
||||
│ [479:0] payload (480b = 60B) │
|
||||
└──────────────────────────────────────────┘
|
||||
|
||||
IPCQ data flit (첫 flit에만 metadata 포함):
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [511:480] routing header (32b) │
|
||||
│ [511] ipcq_flag (1b) │ ← IPCQ vs normal DMA 식별
|
||||
│ [510:509] vc_id (2b) │
|
||||
│ [508:480] route + hop count │
|
||||
│ [479:416] ipcq_metadata (64b) │ ← piggyback
|
||||
│ [479:448] sender_seq (32b) │
|
||||
│ [447:416] dst_addr[31:0] (32b) │ ← direction matching용
|
||||
│ [415:0] payload (416b = 52B) │
|
||||
└──────────────────────────────────────────┘
|
||||
후속 flits: full 60B payload (metadata 없음)
|
||||
|
||||
Credit-only flit (128-bit, header-only):
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [127:96] routing header (32b) │
|
||||
│ [127] credit_flag (1b) │
|
||||
│ [95:64] consumer_seq (32b) │
|
||||
│ [63:0] dst_rx_base_pa (64b) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
첫 flit의 payload가 60B → 52B로 감소 (13% overhead).
|
||||
Multi-flit transfer에서는 후속 flit이 full payload이므로 대형 전송에서 overhead < 1%.
|
||||
|
||||
#### 4.2.4 TCM IPCQ Slot Region
|
||||
|
||||
```
|
||||
TCM Memory Map (16MB):
|
||||
┌─────────────────────────────┐ 0x000000
|
||||
│ Kernel Working Memory │
|
||||
│ (compute tensors) │
|
||||
│ ~14MB │
|
||||
├─────────────────────────────┤ 0xE00000
|
||||
│ IPCQ RX Buffers │
|
||||
│ Dir N: slots × slot_size │
|
||||
│ Dir S: slots × slot_size │
|
||||
│ Dir E: slots × slot_size │
|
||||
│ Dir W: slots × slot_size │
|
||||
│ ~1MB │
|
||||
├─────────────────────────────┤ 0xF00000
|
||||
│ IPCQ Metadata / Scratch │
|
||||
│ ~1MB │
|
||||
└─────────────────────────────┘ 0xFFFFFF
|
||||
```
|
||||
|
||||
IPCQ region을 TCM의 상위 bank에 배치하여 compute access와의
|
||||
bank conflict를 최소화한다 (Section 6.1 참조).
|
||||
|
||||
---
|
||||
|
||||
## 5. End-to-End Dataflow
|
||||
|
||||
### 5.1 Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CPU_A as PE_A: PE_CPU
|
||||
participant IPCQ_A as PE_A: IPCQ Ctrl
|
||||
participant DMA_A as PE_A: DMA
|
||||
participant NOC as NoC Fabric
|
||||
participant DMA_B as PE_B: DMA
|
||||
participant IPCQ_B as PE_B: IPCQ Ctrl
|
||||
participant TCM_B as PE_B: TCM
|
||||
participant CPU_B as PE_B: PE_CPU
|
||||
|
||||
Note over CPU_A: tl.send(dir="E", src=0x1000)
|
||||
|
||||
CPU_A->>IPCQ_A: MMIO: send request
|
||||
Note over IPCQ_A: Backpressure check:<br/>(head - peer_tail_cache) < n_slots → PASS<br/>Slot addr gen:<br/>dst = peer_rx_base + (head%n) × slot_size
|
||||
IPCQ_A->>DMA_A: IpcqDmaToken {src, dst, sender_seq=head}
|
||||
Note over IPCQ_A: my_head++
|
||||
IPCQ_A-->>CPU_A: send returns (fire-and-forget)
|
||||
|
||||
Note over DMA_A: TCM read → snapshot in read buffer<br/>Flit pack: data + {sender_seq, dst_addr}
|
||||
DMA_A->>NOC: IPCQ data flit(s)
|
||||
|
||||
Note over NOC: hop latency + BW drain
|
||||
|
||||
NOC->>DMA_B: IPCQ data flit(s)
|
||||
Note over DMA_B: Terminal BW drain<br/>Slot write latency
|
||||
|
||||
rect rgb(255, 240, 220)
|
||||
Note over DMA_B,IPCQ_B: ATOMIC (I6): same cycle, no stall
|
||||
DMA_B->>TCM_B: write data → slot address
|
||||
DMA_B->>IPCQ_B: Meta Extractor: {sender_seq, dst_addr}
|
||||
end
|
||||
|
||||
Note over IPCQ_B: Range match dst_addr → direction "W"<br/>peer_head_cache["W"] = sender_seq + 1
|
||||
IPCQ_B-->>CPU_B: recv_wake signal
|
||||
|
||||
Note over CPU_B: tl.recv(dir="W") wakes up
|
||||
CPU_B->>IPCQ_B: recv request
|
||||
Note over IPCQ_B: peer_head_cache > my_tail → YES<br/>slot_addr = rx_base + (tail%n) × slot_size
|
||||
IPCQ_B-->>CPU_B: return slot_addr
|
||||
CPU_B->>TCM_B: read data from slot
|
||||
Note over IPCQ_B: my_tail++
|
||||
|
||||
IPCQ_B->>NOC: Credit (16B): {consumer_seq, dst_rx_base_pa}
|
||||
Note over NOC: credit traversal (NoC latency)
|
||||
NOC->>IPCQ_A: Credit arrival
|
||||
|
||||
Note over IPCQ_A: Match dst_rx_base_pa → direction "E"<br/>peer_tail_cache["E"] = consumer_seq<br/>Backpressure deassert (if stalled)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 2nm Implementation Analysis
|
||||
|
||||
### 6.1 Area Estimate
|
||||
|
||||
| Module | Gate Count | Area (2nm est.) | Notes |
|
||||
|--------|-----------|-----------------|-------|
|
||||
| QPair Register File | ~4.6K FF | 0.002 mm² | 576B flip-flops |
|
||||
| Slot Addr Gen + Backpressure | ~5K gates | 0.001 mm² | Combinational |
|
||||
| Meta Extractor + Credit Logic | ~3K gates | 0.001 mm² | 8× parallel comparators |
|
||||
| **Total IPCQ Controller** | **~12.6K** | **~0.004 mm²** | **PE 전체 대비 < 0.1%** |
|
||||
| DMA vc_comm 확장 | ~2K gates | 0.002 mm² | Flit pack/unpack |
|
||||
| **Total 변경분** | **~14.6K** | **~0.006 mm²** | |
|
||||
|
||||
### 6.2 Timing
|
||||
|
||||
| Path | Delay (2nm est.) | Target Clock | Margin |
|
||||
|------|-------------------|-------------|--------|
|
||||
| Backpressure (sub + cmp) | ~0.3 ns | 1 GHz (1 ns) | 3× |
|
||||
| Slot Addr Gen (mask + shift + add) | ~0.5 ns | 1 GHz | 2× |
|
||||
| Meta Extractor (8× range match) | ~0.4 ns | 1 GHz | 2.5× |
|
||||
| Credit Receiver (8× equality) | ~0.3 ns | 1 GHz | 3× |
|
||||
|
||||
모든 critical path가 1 cycle 이내. Timing closure 문제 없음.
|
||||
|
||||
### 6.3 Power
|
||||
|
||||
- Active: ~1 mW (register read/write + comparators, send/recv 동작 시)
|
||||
- Idle: leakage only
|
||||
- PE 전체 전력 대비 무시 가능
|
||||
|
||||
### 6.4 Constraints
|
||||
|
||||
| 항목 | 제약 | 근거 |
|
||||
|------|------|------|
|
||||
| `n_slots` | **반드시 power-of-2** | mod → AND mask (1 gate). 임의 값은 divider 필요 (~10 cycles) |
|
||||
| `slot_size` | **power-of-2 권장** | mul → barrel shift. 임의 값은 multiplier 필요 |
|
||||
| TCM IPCQ region | **전용 bank 배치** | Compute access와 bank conflict 방지 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Assessment
|
||||
|
||||
### 7.1 TCM Bank Conflict
|
||||
|
||||
- **Risk**: IPCQ slot write와 compute read가 동일 bank 접근 시 stall
|
||||
- **Mitigation**: IPCQ region을 TCM 상위 address의 전용 bank에 배치
|
||||
- **Cost**: TCM banking flexibility 소폭 감소
|
||||
- **Severity**: Medium (성능 영향), Low (correctness 문제 아님)
|
||||
|
||||
### 7.2 Credit Return Latency under Congestion
|
||||
|
||||
- **Risk**: NoC 혼잡 시 credit return 지연 → sender backpressure stall
|
||||
- **Mitigation**:
|
||||
- Credit을 별도 VC로 분리 + strict priority (16B로 BW impact 미미)
|
||||
- 또는 n_slots를 넉넉히(8+) 설정하여 credit 지연을 buffer로 흡수
|
||||
- **Severity**: Low (credit 16B는 congestion에 거의 기여하지 않음)
|
||||
|
||||
### 7.3 Inter-Direction Ordering
|
||||
|
||||
- **Risk**: 같은 PE에서 여러 방향으로 동시 send 시 순서
|
||||
- **Mitigation**: Per-direction monotonic seq으로 충분. Inter-direction ordering은
|
||||
kernel(소프트웨어) 책임 — 현재 시뮬레이터 모델과 동일
|
||||
- **Severity**: Low (아키텍처 설계에 의해 해소)
|
||||
|
||||
---
|
||||
|
||||
## 8. Alternatives Considered
|
||||
|
||||
### 8.1 Doorbell + Polling (전통적 방식)
|
||||
|
||||
```
|
||||
Send: DMA write data → DMA write doorbell register at peer → peer polls doorbell
|
||||
Recv: Polling loop on doorbell, or interrupt-driven
|
||||
```
|
||||
|
||||
| 장점 | 단점 |
|
||||
|------|------|
|
||||
| 단순한 HW (IPCQ controller 불필요) | 2번의 DMA transaction (data + doorbell) |
|
||||
| 기존 DMA 재사용 | Data/doorbell 사이 ordering 보장 필요 (fence) |
|
||||
| | Polling은 전력 낭비, interrupt는 latency overhead |
|
||||
|
||||
**평가**: Piggyback 대비 latency 2-3× 증가. **불채택.**
|
||||
|
||||
### 8.2 Hardware Message Queue (NVIDIA NVLink 스타일)
|
||||
|
||||
```
|
||||
Send: CPU → HMQ에 descriptor push → HW가 peer HMQ로 자동 전달
|
||||
Recv: HMQ에서 descriptor pop → data pointer 확인
|
||||
```
|
||||
|
||||
| 장점 | 단점 |
|
||||
|------|------|
|
||||
| CPU는 descriptor만 작성 | 별도 HMQ engine 필요 (~0.05 mm²) |
|
||||
| Descriptor/data 분리 → 유연 | DMA와 별개 datapath → area/power 중복 |
|
||||
| | Large tensor에는 결국 DMA 필요 |
|
||||
|
||||
**평가**: CCL의 large tensor 패턴에서 DMA 필수이므로 HMQ + DMA 이중 구조는
|
||||
면적 낭비. **불채택.**
|
||||
|
||||
### 8.3 RDMA-style Completion Queue (CQ)
|
||||
|
||||
```
|
||||
Send: DMA write → peer에 CQE 자동 생성
|
||||
Recv: CQ poll/interrupt → data 위치 확인
|
||||
```
|
||||
|
||||
| 장점 | 단점 |
|
||||
|------|------|
|
||||
| InfiniBand/RoCE 성숙 모델 | CQ 관리 logic + CQE memory overhead |
|
||||
| Multi-tenant/isolation 용이 | CQE/data ordering 보장 추가 필요 |
|
||||
| | PE-to-PE CCL에는 over-engineered |
|
||||
|
||||
**평가**: RDMA CQ는 host-facing NIC의 multi-tenant 격리에 적합.
|
||||
PE 간 단일 owner 환경에서는 불필요한 복잡성. **불채택.**
|
||||
|
||||
### 8.4 Credit-in-Data Piggyback (v2 최적화 후보)
|
||||
|
||||
현재 설계에서 credit return은 별도 16B packet이다.
|
||||
Bidirectional 통신 패턴에서는 **reverse 방향 data flit에 credit을 합칠 수 있다.**
|
||||
|
||||
```
|
||||
PE_A →E→ PE_B: data + sender_seq=3
|
||||
PE_B →W→ PE_A: data + sender_seq=5 + credit_ack=4 ← credit이 data에 합쳐짐
|
||||
```
|
||||
|
||||
| 장점 | 단점 |
|
||||
|------|------|
|
||||
| Credit 전용 packet 제거 → NoC BW 절약 | Unidirectional 패턴에서는 fallback 필요 |
|
||||
| Bidirectional allreduce에서 credit latency → 0 | Flit header에 8B 추가 (overhead 미미) |
|
||||
| | Logic 복잡도 소폭 증가 |
|
||||
|
||||
**평가**: 현재 설계의 우수한 최적화.
|
||||
Bidirectional allreduce에서 credit packet을 완전 제거 가능.
|
||||
Standalone credit fallback도 유지. **v2로 채택 권고.**
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommendations
|
||||
|
||||
1. **현재 IPCQ-DMA co-design을 기본 하드웨어 설계로 채택**
|
||||
— 단순하고, 면적 효율적이며, 2nm에서 timing/power 문제 없음
|
||||
|
||||
2. **n_slots를 반드시 power-of-2로 제약**
|
||||
— mod 연산을 AND mask로 대체, critical path 단축
|
||||
|
||||
3. **TCM banking에서 IPCQ region 전용 bank 할당**
|
||||
— compute와의 bank conflict 방지
|
||||
|
||||
4. **v2에서 Credit-in-Data Piggyback (Section 8.4) 추가 검토**
|
||||
— bidirectional 패턴에서 credit overhead 제거
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
- [ ] IPCQ slot region size를 TCM의 몇 %까지 허용할 것인가? (현재 가정: ~1MB / 16MB = 6.25%)
|
||||
- [ ] Credit VC를 별도로 둘 것인가, vc_comm에 multiplexing할 것인가?
|
||||
- [ ] Inter-SIP link에서의 flit format 호환성 검증 필요
|
||||
- [ ] n_slots 최대값 제한? (8 directions × 8 slots × 64KB = 4MB → TCM의 25%)
|
||||
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
||||
name = "kernbench"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["pytest", "simpy", "pyyaml", "fastapi>=0.110", "uvicorn[standard]>=0.29", "websockets>=12", "numpy>=1.24", "greenlet>=3.0"]
|
||||
dependencies = ["pytest", "simpy", "pyyaml", "fastapi>=0.110", "uvicorn[standard]>=0.29", "websockets>=12", "numpy>=1.24", "greenlet>=3.0", "matplotlib>=3.7"]
|
||||
|
||||
[project.scripts]
|
||||
kernbench = "kernbench.cli.main:main"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""One-shot: render overview.png with an external 366 µs reference, in two
|
||||
variants — log scale and broken y-axis. Reads docs/diagrams/allreduce_latency_plots/summary.csv
|
||||
and writes overview_log.png and overview_broken.png alongside it.
|
||||
|
||||
This is a derived-artifact generator (per CLAUDE.md): plotting only, no production
|
||||
or test logic touched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker as mticker
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PLOT_DIR = ROOT / "docs" / "diagrams" / "allreduce_latency_plots"
|
||||
CSV_PATH = PLOT_DIR / "summary.csv"
|
||||
|
||||
EXT_LABEL = "ext-sim single-device reduce: 366 µs"
|
||||
EXT_LATENCY_NS = 366_000.0
|
||||
|
||||
COLORS = {
|
||||
"ring_1d": "tab:blue",
|
||||
"torus_2d": "tab:orange",
|
||||
"mesh_2d_no_wrap": "tab:green",
|
||||
}
|
||||
|
||||
# Hand-derived theoretical model for torus_2d (6 SIPs). Mirrors
|
||||
# _aggregate_sweep_plots in tests/test_allreduce_multidevice.py.
|
||||
NOC_PACKET_BYTES = 128
|
||||
PES_PER_CUBE = 8
|
||||
T_STARTUP_NS = 1346.0
|
||||
TAU_NS = (8741.0 - 1346.0) / (6144 - 1)
|
||||
|
||||
|
||||
def _theoretical_torus_2d_ns(bytes_per_pe: int) -> float:
|
||||
bytes_per_cube = int(bytes_per_pe) * PES_PER_CUBE
|
||||
n_packets = max(1, -(-bytes_per_cube // NOC_PACKET_BYTES))
|
||||
return T_STARTUP_NS + (n_packets - 1) * TAU_NS
|
||||
|
||||
|
||||
def _plot_theoretical(ax, records):
|
||||
torus_rs = sorted(
|
||||
[r for r in records if r["sip_topology"] == "torus_2d"],
|
||||
key=lambda r: r["bytes_per_pe"],
|
||||
)
|
||||
if not torus_rs:
|
||||
return
|
||||
ax.plot(
|
||||
[r["bytes_per_pe"] for r in torus_rs],
|
||||
[_theoretical_torus_2d_ns(r["bytes_per_pe"]) for r in torus_rs],
|
||||
color="tab:red", linestyle="--", linewidth=1.6, marker="x",
|
||||
label="theoretical torus_2d (6 SIPs)",
|
||||
)
|
||||
|
||||
|
||||
def _bytes_fmt(x, _pos):
|
||||
if x >= 1024 * 1024:
|
||||
return f"{x / (1024 * 1024):.0f}M"
|
||||
if x >= 1024:
|
||||
return f"{x / 1024:.0f}K"
|
||||
return f"{int(x)}"
|
||||
|
||||
|
||||
def _load_records():
|
||||
rows = []
|
||||
with open(CSV_PATH, newline="") as f:
|
||||
r = csv.DictReader(f)
|
||||
for row in r:
|
||||
rows.append({
|
||||
"sip_topology": row["sip_topology"],
|
||||
"bytes_per_pe": int(row["bytes_per_pe"]),
|
||||
"latency_ns": float(row["latency_ns"]),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _ext_x(records):
|
||||
"""Anchor the external reference at the largest payload (96 KB / PE)."""
|
||||
return max(r["bytes_per_pe"] for r in records)
|
||||
|
||||
|
||||
def _plot_curves(ax, records, topologies):
|
||||
for topo in topologies:
|
||||
rs = sorted([r for r in records if r["sip_topology"] == topo],
|
||||
key=lambda r: r["bytes_per_pe"])
|
||||
if not rs:
|
||||
continue
|
||||
ax.plot(
|
||||
[r["bytes_per_pe"] for r in rs],
|
||||
[r["latency_ns"] for r in rs],
|
||||
marker="o",
|
||||
label=f"{topo}",
|
||||
color=COLORS.get(topo),
|
||||
)
|
||||
|
||||
|
||||
def emit_log(records):
|
||||
topologies = sorted({r["sip_topology"] for r in records})
|
||||
fig, ax = plt.subplots(figsize=(9, 6))
|
||||
_plot_curves(ax, records, topologies)
|
||||
_plot_theoretical(ax, records)
|
||||
ax.scatter(
|
||||
[_ext_x(records)], [EXT_LATENCY_NS],
|
||||
marker="*", s=220, color="tab:red", zorder=5,
|
||||
label=EXT_LABEL,
|
||||
)
|
||||
ax.set_xscale("log", base=2)
|
||||
ax.set_yscale("log")
|
||||
ax.set_xlabel("Bytes per PE (log scale)")
|
||||
ax.set_ylabel("Time (ns) — log scale")
|
||||
ax.set_title("Multi-device allreduce latency vs external single-device reference")
|
||||
ax.grid(True, which="both", alpha=0.3)
|
||||
ax.xaxis.set_major_formatter(mticker.FuncFormatter(_bytes_fmt))
|
||||
ax.legend(loc="upper left")
|
||||
fig.tight_layout()
|
||||
out = PLOT_DIR / "overview_log.png"
|
||||
fig.savefig(out, dpi=120)
|
||||
plt.close(fig)
|
||||
print(f"wrote {out}")
|
||||
|
||||
|
||||
def emit_broken(records):
|
||||
topologies = sorted({r["sip_topology"] for r in records})
|
||||
max_local = max(r["latency_ns"] for r in records)
|
||||
|
||||
fig, (ax_top, ax_bot) = plt.subplots(
|
||||
2, 1, sharex=True,
|
||||
gridspec_kw={"height_ratios": [1, 4], "hspace": 0.05},
|
||||
figsize=(9, 6.5),
|
||||
)
|
||||
|
||||
# Bottom panel: today's three curves + theoretical, linear y.
|
||||
_plot_curves(ax_bot, records, topologies)
|
||||
_plot_theoretical(ax_bot, records)
|
||||
ax_bot.set_ylim(0, max_local * 1.10)
|
||||
|
||||
# Top panel: only the external reference marker, linear y around 366 µs.
|
||||
ax_top.scatter(
|
||||
[_ext_x(records)], [EXT_LATENCY_NS],
|
||||
marker="*", s=240, color="tab:red", zorder=5,
|
||||
label=EXT_LABEL,
|
||||
)
|
||||
ax_top.set_ylim(EXT_LATENCY_NS * 0.93, EXT_LATENCY_NS * 1.05)
|
||||
|
||||
# Hide the spine between the two panels and draw diagonal "break" ticks.
|
||||
ax_top.spines["bottom"].set_visible(False)
|
||||
ax_bot.spines["top"].set_visible(False)
|
||||
ax_top.tick_params(labeltop=False, bottom=False)
|
||||
ax_bot.xaxis.tick_bottom()
|
||||
|
||||
d = 0.012 # diagonal-tick size, in axis-fraction
|
||||
kw = dict(transform=ax_top.transAxes, color="k", clip_on=False, lw=1)
|
||||
ax_top.plot((-d, +d), (-d, +d), **kw)
|
||||
ax_top.plot((1 - d, 1 + d), (-d, +d), **kw)
|
||||
kw.update(transform=ax_bot.transAxes)
|
||||
ax_bot.plot((-d, +d), (1 - d * 4, 1 + d * 4), **kw)
|
||||
ax_bot.plot((1 - d, 1 + d), (1 - d * 4, 1 + d * 4), **kw)
|
||||
|
||||
ax_bot.set_xscale("log", base=2)
|
||||
ax_bot.set_xlabel("Bytes per PE (log scale)")
|
||||
ax_bot.set_ylabel("Time (ns)")
|
||||
ax_top.set_ylabel("Time (ns)")
|
||||
ax_bot.grid(True, alpha=0.3)
|
||||
ax_top.grid(True, alpha=0.3)
|
||||
ax_bot.xaxis.set_major_formatter(mticker.FuncFormatter(_bytes_fmt))
|
||||
|
||||
# One legend covering both axes.
|
||||
handles_bot, labels_bot = ax_bot.get_legend_handles_labels()
|
||||
handles_top, labels_top = ax_top.get_legend_handles_labels()
|
||||
ax_bot.legend(handles_bot + handles_top, labels_bot + labels_top,
|
||||
loc="upper left")
|
||||
|
||||
fig.suptitle("Multi-device allreduce latency vs external single-device reference (broken y-axis)")
|
||||
fig.tight_layout()
|
||||
out = PLOT_DIR / "overview_broken.png"
|
||||
fig.savefig(out, dpi=120)
|
||||
plt.close(fig)
|
||||
print(f"wrote {out}")
|
||||
|
||||
|
||||
def main():
|
||||
records = _load_records()
|
||||
if not records:
|
||||
raise SystemExit(f"no rows in {CSV_PATH}")
|
||||
emit_log(records)
|
||||
emit_broken(records)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Sweep GEMM shapes through kernbench and dump PE_accelerator engine times.
|
||||
|
||||
For each shape:
|
||||
- run benches.matmul_composite via the same run_bench path the CLI uses
|
||||
- read result.engine.op_log
|
||||
- filter to per-PE engines: pe_dma, pe_fetch_store, pe_gemm, pe_math
|
||||
- record sum-of-durations (engine occupancy) AND wall-clock active interval
|
||||
|
||||
Output: docs/diagrams/gemm_sweep.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Default sweep covering under-tile, single-tile, multi-tile, and asymmetric regimes.
|
||||
# Each entry is either a single integer (square M=K=N=S) or "MxKxN".
|
||||
# Override via env: SWEEP_SHAPES="16,32,16x2048x16,..."
|
||||
DEFAULT_SHAPES = [
|
||||
"32x32x32", # 1 tile, K=32 < TILE_K=64 → under-tile in K
|
||||
"32x64x32", # 1 tile, exact single-tile fit
|
||||
"32x128x32", # 2 tiles, aligned
|
||||
"32x128x128", # 8 tiles, aligned
|
||||
"32x3072x32", # 48 tiles, all K-axis (tall-skinny)
|
||||
"8x128x128", # 8 tiles, but M=8 < TILE_M=32 → MAC array under-fed
|
||||
"128x8x128", # 16 tiles, but K=8 < TILE_K=64 → MAC array under-fed
|
||||
"512", # 2048 tiles, fully aligned — "well-pipelined" reference
|
||||
]
|
||||
|
||||
# Operand-staging variants exercised per shape.
|
||||
VARIANTS = ["ref_ref", "load_ref", "load_load"]
|
||||
|
||||
# Engines whose timings we collect (component_id suffix match).
|
||||
ENGINES = ["pe_dma", "pe_fetch_store", "pe_gemm", "pe_math"]
|
||||
|
||||
# Per-stage breakdown labels (StageType enum names from pe_types.py).
|
||||
STAGES = ["DMA_READ", "DMA_WRITE", "FETCH", "STORE", "GEMM", "MATH"]
|
||||
|
||||
# Scheduler tile sizes (mirror of PeSchedulerComponent.TILE_M/K/N).
|
||||
TILE_M, TILE_K, TILE_N = 32, 64, 32
|
||||
|
||||
OUT_PATH = Path(__file__).parent.parent / "docs" / "diagrams" / "gemm_sweep.json"
|
||||
|
||||
|
||||
def _engine_wall_ns(records, suffix: str) -> float:
|
||||
"""Wall-clock interval the engine was active (union of overlapping ops)."""
|
||||
intervals = [(r.t_start, r.t_end) for r in records
|
||||
if r.component_id.endswith("." + suffix)]
|
||||
if not intervals:
|
||||
return 0.0
|
||||
intervals.sort()
|
||||
merged_end = intervals[0][1]
|
||||
merged_start = intervals[0][0]
|
||||
total = 0.0
|
||||
for s, e in intervals[1:]:
|
||||
if s <= merged_end:
|
||||
merged_end = max(merged_end, e)
|
||||
else:
|
||||
total += merged_end - merged_start
|
||||
merged_start, merged_end = s, e
|
||||
total += merged_end - merged_start
|
||||
return total
|
||||
|
||||
|
||||
def _engine_occupancy_ns(records, suffix: str) -> float:
|
||||
return sum(r.t_end - r.t_start for r in records
|
||||
if r.component_id.endswith("." + suffix))
|
||||
|
||||
|
||||
def _engine_count(records, suffix: str) -> int:
|
||||
return sum(1 for r in records if r.component_id.endswith("." + suffix))
|
||||
|
||||
|
||||
def _stage_occupancy_ns(records, stage_type: str) -> float:
|
||||
"""Sum t_end - t_start over op_log records whose params.stage_type matches.
|
||||
|
||||
Requires op_log records produced post the TileToken stage_type capture
|
||||
(sim_engine/op_log.py).
|
||||
"""
|
||||
return sum(
|
||||
r.t_end - r.t_start
|
||||
for r in records
|
||||
if r.params.get("stage_type") == stage_type
|
||||
)
|
||||
|
||||
|
||||
def _stage_wall_ns(records, stage_type: str) -> float:
|
||||
"""Interval-union wall-clock for records whose stage_type matches."""
|
||||
intervals = sorted(
|
||||
(r.t_start, r.t_end) for r in records
|
||||
if r.params.get("stage_type") == stage_type
|
||||
)
|
||||
if not intervals:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
cs, ce = intervals[0]
|
||||
for s, e in intervals[1:]:
|
||||
if s <= ce:
|
||||
ce = max(ce, e)
|
||||
else:
|
||||
total += ce - cs
|
||||
cs, ce = s, e
|
||||
total += ce - cs
|
||||
return total
|
||||
|
||||
|
||||
def _stage_count(records, stage_type: str) -> int:
|
||||
return sum(1 for r in records if r.params.get("stage_type") == stage_type)
|
||||
|
||||
|
||||
def _run_one(M: int, K: int, N: int, topology: str, variant: str = "ref_ref") -> dict:
|
||||
os.environ["MATMUL_M"] = str(M)
|
||||
os.environ["MATMUL_K"] = str(K)
|
||||
os.environ["MATMUL_N"] = str(N)
|
||||
os.environ["MATMUL_VARIANT"] = variant
|
||||
|
||||
# Late imports so env vars are read by benches/matmul_composite at module load.
|
||||
# Force re-import to pick up new env values.
|
||||
for mod_name in [m for m in list(sys.modules) if m.startswith("benches.matmul_composite")]:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
from benches.loader import resolve_bench
|
||||
from kernbench.runtime_api.bench_runner import run_bench
|
||||
from kernbench.runtime_api.types import resolve_device
|
||||
from kernbench.sim_engine.engine import GraphEngine
|
||||
from kernbench.topology.builder import resolve_topology
|
||||
|
||||
topo = resolve_topology(topology)
|
||||
bench = resolve_bench("matmul_composite")
|
||||
device = resolve_device(None)
|
||||
|
||||
t0 = time.time()
|
||||
result = run_bench(
|
||||
topology=topo, bench_fn=bench, device=device,
|
||||
engine_factory=lambda t, d: GraphEngine(
|
||||
getattr(t, "topology_obj", t), enable_data=True,
|
||||
),
|
||||
)
|
||||
wall = time.time() - t0
|
||||
|
||||
op_log = result.engine.op_log
|
||||
if not result.completion.ok:
|
||||
raise RuntimeError(f"bench failed at M={M},K={K},N={N}: {result.completion}")
|
||||
|
||||
# Bytes touched at f16 (2 B): full A + full B + full out (each operand
|
||||
# streamed once through HBM by the composite plan).
|
||||
bytes_total = (M * K + K * N + M * N) * 2
|
||||
row = {
|
||||
"M": M, "K": K, "N": N,
|
||||
"variant": variant,
|
||||
"flops": 2 * M * K * N,
|
||||
"bytes_hbm": bytes_total,
|
||||
"arith_intensity": (2 * M * K * N) / bytes_total, # flops/byte
|
||||
"tile_count_expected": _ceil(M, TILE_M) * _ceil(N, TILE_N) * _ceil(K, TILE_K),
|
||||
"sim_wall_clock_s": round(wall, 3),
|
||||
"engines": {},
|
||||
}
|
||||
for eng in ENGINES:
|
||||
row["engines"][eng] = {
|
||||
"occupancy_ns": _engine_occupancy_ns(op_log, eng),
|
||||
"wall_ns": _engine_wall_ns(op_log, eng),
|
||||
"record_count": _engine_count(op_log, eng),
|
||||
}
|
||||
row["stages"] = {}
|
||||
for stage in STAGES:
|
||||
row["stages"][stage] = {
|
||||
"occupancy_ns": _stage_occupancy_ns(op_log, stage),
|
||||
"wall_ns": _stage_wall_ns(op_log, stage),
|
||||
"record_count": _stage_count(op_log, stage),
|
||||
}
|
||||
# Kernel-window wall-clock = max t_end - min t_start over PE engine records.
|
||||
pe_records = [r for r in op_log
|
||||
if any(r.component_id.endswith("." + e) for e in ENGINES)]
|
||||
if pe_records:
|
||||
row["pe_window_ns"] = max(r.t_end for r in pe_records) \
|
||||
- min(r.t_start for r in pe_records)
|
||||
else:
|
||||
row["pe_window_ns"] = 0.0
|
||||
stage_records = [r for r in op_log
|
||||
if r.params.get("stage_type") in STAGES]
|
||||
if stage_records:
|
||||
row["composite_window_ns"] = max(r.t_end for r in stage_records) \
|
||||
- min(r.t_start for r in stage_records)
|
||||
else:
|
||||
row["composite_window_ns"] = 0.0
|
||||
return row
|
||||
|
||||
|
||||
def _ceil(a: int, b: int) -> int:
|
||||
return (a + b - 1) // b
|
||||
|
||||
|
||||
def main() -> int:
|
||||
shapes_env = os.environ.get("SWEEP_SHAPES")
|
||||
raw = (shapes_env.split(",") if shapes_env else DEFAULT_SHAPES)
|
||||
shapes: list[tuple[int, int, int]] = []
|
||||
for s in raw:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
continue
|
||||
if "x" in s.lower():
|
||||
parts = s.lower().split("x")
|
||||
shapes.append((int(parts[0]), int(parts[1]), int(parts[2])))
|
||||
else:
|
||||
v = int(s)
|
||||
shapes.append((v, v, v))
|
||||
topology = os.environ.get("SWEEP_TOPOLOGY", "topology.yaml")
|
||||
|
||||
rows = []
|
||||
for M, K, N in shapes:
|
||||
for variant in VARIANTS:
|
||||
print(f"[sweep] M={M} K={K} N={N} variant={variant} ...", flush=True)
|
||||
row = _run_one(M, K, N, topology, variant=variant)
|
||||
rows.append(row)
|
||||
eng_dma = row["engines"]["pe_dma"]
|
||||
eng_gem = row["engines"]["pe_gemm"]
|
||||
print(f" tiles={row['tile_count_expected']:>6} "
|
||||
f"pe_window={row['pe_window_ns']:8.1f}ns "
|
||||
f"dma_occ={eng_dma['occupancy_ns']:9.1f} "
|
||||
f"gemm_occ={eng_gem['occupancy_ns']:8.1f} "
|
||||
f"(sim {row['sim_wall_clock_s']:.1f}s)")
|
||||
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_PATH.write_text(json.dumps({
|
||||
"tile_sizes": {"M": TILE_M, "K": TILE_K, "N": TILE_N},
|
||||
"engines": ENGINES,
|
||||
"stages": STAGES,
|
||||
"variants": VARIANTS,
|
||||
"rows": rows,
|
||||
}, indent=2))
|
||||
print(f"\n[sweep] wrote {OUT_PATH}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Re-render pe2pe latency PNGs from the existing summary.csv with the
|
||||
current (no-consume) labels. Used after a label-only test edit to avoid
|
||||
re-measuring (~5 min) when the data on disk is already correct.
|
||||
|
||||
Reads docs/diagrams/pe2pe_latency_plots/summary.csv. Plots 2 curves:
|
||||
"IPCQ no-consume" (from the ipcq_no_consume rows if present, else from
|
||||
the ipcq rows) and "Raw DMA" (raw rows).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PLOT_DIR = ROOT / "docs" / "diagrams" / "pe2pe_latency_plots"
|
||||
CSV_PATH = PLOT_DIR / "summary.csv"
|
||||
|
||||
|
||||
def _load_records():
|
||||
rows = []
|
||||
with open(CSV_PATH, newline="") as f:
|
||||
for r in csv.DictReader(f):
|
||||
rows.append({
|
||||
"hop": r["hop"],
|
||||
"label": r["label"],
|
||||
"size_bytes": int(r["size_bytes"]),
|
||||
"path": r["path"],
|
||||
"total_ns": float(r["total_ns"]),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _ipcq_rows(records, hop):
|
||||
# Prefer ipcq_no_consume if present (older 3-path CSV); fall back to ipcq
|
||||
# (current single-path CSV where ipcq IS no-consume).
|
||||
nc = [r for r in records
|
||||
if r["hop"] == hop and r["path"] == "ipcq_no_consume"]
|
||||
if nc:
|
||||
return sorted(nc, key=lambda r: r["size_bytes"])
|
||||
return sorted(
|
||||
[r for r in records if r["hop"] == hop and r["path"] == "ipcq"],
|
||||
key=lambda r: r["size_bytes"],
|
||||
)
|
||||
|
||||
|
||||
def _raw_rows(records, hop):
|
||||
return sorted(
|
||||
[r for r in records if r["hop"] == hop and r["path"] == "raw"],
|
||||
key=lambda r: r["size_bytes"],
|
||||
)
|
||||
|
||||
|
||||
def _hops(records):
|
||||
seen = []
|
||||
for r in records:
|
||||
if r["hop"] not in {h["id"] for h in seen}:
|
||||
seen.append({"id": r["hop"], "label": r["label"]})
|
||||
return seen
|
||||
|
||||
|
||||
def _plot_per_hop(records, hop, path):
|
||||
ipcq = _ipcq_rows(records, hop["id"])
|
||||
raw = _raw_rows(records, hop["id"])
|
||||
fig, ax = plt.subplots(figsize=(8, 5))
|
||||
if ipcq:
|
||||
ax.plot(
|
||||
[r["size_bytes"] for r in ipcq],
|
||||
[r["total_ns"] for r in ipcq],
|
||||
marker="o", color="tab:blue",
|
||||
label="IPCQ no-consume (send/recv, no slot read)",
|
||||
)
|
||||
if raw:
|
||||
ax.plot(
|
||||
[r["size_bytes"] for r in raw],
|
||||
[r["total_ns"] for r in raw],
|
||||
marker="s", color="tab:orange",
|
||||
label="Raw DMA (load+store)",
|
||||
)
|
||||
ax.set_xlabel("Data size (bytes)")
|
||||
ax.set_ylabel("Latency (ns)")
|
||||
ax.set_title(hop["label"])
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(path, dpi=120)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _plot_overview(records, hops, path):
|
||||
fig, axes = plt.subplots(2, 2, figsize=(13, 9))
|
||||
axes = axes.flatten()
|
||||
for i, hop in enumerate(hops):
|
||||
ax = axes[i]
|
||||
ipcq = _ipcq_rows(records, hop["id"])
|
||||
raw = _raw_rows(records, hop["id"])
|
||||
if ipcq:
|
||||
ax.plot(
|
||||
[r["size_bytes"] for r in ipcq],
|
||||
[r["total_ns"] for r in ipcq],
|
||||
marker="o", color="tab:blue",
|
||||
label="IPCQ no-consume",
|
||||
)
|
||||
if raw:
|
||||
ax.plot(
|
||||
[r["size_bytes"] for r in raw],
|
||||
[r["total_ns"] for r in raw],
|
||||
marker="s", color="tab:orange",
|
||||
label="Raw DMA",
|
||||
)
|
||||
ax.set_title(hop["label"], fontsize=10)
|
||||
ax.set_xlabel("bytes")
|
||||
ax.set_ylabel("ns")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(fontsize=8)
|
||||
for j in range(len(hops), len(axes)):
|
||||
axes[j].axis("off")
|
||||
fig.suptitle(
|
||||
"PE-to-PE latency: IPCQ no-consume vs raw DMA",
|
||||
fontsize=14,
|
||||
)
|
||||
fig.tight_layout()
|
||||
fig.savefig(path, dpi=120)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def main():
|
||||
records = _load_records()
|
||||
hops = _hops(records)
|
||||
for hop in hops:
|
||||
out = PLOT_DIR / f"{hop['id']}.png"
|
||||
_plot_per_hop(records, hop, out)
|
||||
print(f"wrote {out}")
|
||||
overview = PLOT_DIR / "overview.png"
|
||||
_plot_overview(records, hops, overview)
|
||||
print(f"wrote {overview}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -24,9 +24,7 @@ TOPO_NAME_TO_KIND = {
|
||||
}
|
||||
|
||||
|
||||
def kernel_args(world_size: int, n_elem: int) -> tuple:
|
||||
cube_w = 4
|
||||
cube_h = 4
|
||||
def kernel_args(world_size: int, n_elem: int, *, cube_w: int = 4, cube_h: int = 4) -> tuple:
|
||||
return (n_elem, cube_w, cube_h, world_size)
|
||||
|
||||
|
||||
@@ -111,6 +109,11 @@ def allreduce_intercube_multidevice(
|
||||
):
|
||||
"""Intercube all-reduce (pe0-only) with configurable SIP topology.
|
||||
|
||||
Root cube sits at the geometric center (cube_w//2, cube_h//2) and
|
||||
each phase converges bidirectionally so the intra-SIP critical path
|
||||
is ~half what a corner-root walk would be (e.g., 4×4 mesh: 4 hops
|
||||
reduce + 4 hops broadcast vs 6+6 with corner root).
|
||||
|
||||
Args:
|
||||
t_ptr: VA base of the row-wise-sharded tensor on this SIP.
|
||||
n_elem: f16 elements per cube tile.
|
||||
@@ -127,61 +130,117 @@ def allreduce_intercube_multidevice(
|
||||
row = cube_id // cube_w
|
||||
col = cube_id % cube_w
|
||||
nbytes = n_elem * 2
|
||||
single_cube = (cube_w == 1 and cube_h == 1)
|
||||
|
||||
root_col = cube_w // 2
|
||||
root_row = cube_h // 2
|
||||
root_cube = root_row * cube_w + root_col
|
||||
|
||||
pe_addr = t_ptr + cube_id * nbytes
|
||||
acc = tl.load(pe_addr, shape=(n_elem,), dtype="f16")
|
||||
|
||||
# ── Phase 1: row reduce W → E ──
|
||||
if col == 0:
|
||||
if single_cube:
|
||||
# ── Single-cube mode: skip intra-SIP reduce, go directly to
|
||||
# inter-SIP exchange (TP use case: one cube per rank). ──
|
||||
if n_sips > 1:
|
||||
if sip_topo_kind == SIP_TOPO_RING:
|
||||
acc = _inter_sip_ring(acc, n_sips, n_elem, tl)
|
||||
elif sip_topo_kind == SIP_TOPO_TORUS:
|
||||
acc = _inter_sip_torus_2d(
|
||||
acc, sip_rank, sip_topo_w, sip_topo_h, n_elem, tl)
|
||||
elif sip_topo_kind == SIP_TOPO_MESH:
|
||||
acc = _inter_sip_mesh_2d(
|
||||
acc, sip_rank, sip_topo_w, sip_topo_h, n_elem, tl)
|
||||
else:
|
||||
# ── Multi-cube mode: center-root bidirectional reduce
|
||||
# + inter-SIP exchange + bidirectional broadcast ──
|
||||
|
||||
# Phase 1: row reduce — converge at col == root_col.
|
||||
# Left half (col < root_col) walks W→E; right half (col > root_col)
|
||||
# walks E→W; the root_col cube merges both sides.
|
||||
if col == 0 and root_col > 0:
|
||||
tl.send(dir="E", src=acc)
|
||||
elif col < cube_w - 1:
|
||||
elif 0 < col < root_col:
|
||||
recv = tl.recv(dir="W", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
tl.send(dir="E", src=acc)
|
||||
else:
|
||||
elif col == root_col:
|
||||
if root_col > 0:
|
||||
recv = tl.recv(dir="W", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
if cube_w - 1 > root_col:
|
||||
recv = tl.recv(dir="E", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
elif root_col < col < cube_w - 1:
|
||||
recv = tl.recv(dir="E", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
tl.send(dir="W", src=acc)
|
||||
elif col == cube_w - 1 and cube_w - 1 > root_col:
|
||||
tl.send(dir="W", src=acc)
|
||||
|
||||
# ── Phase 2: col reduce N → S on rightmost column ──
|
||||
if col == cube_w - 1:
|
||||
if row == 0:
|
||||
# Phase 2: col reduce on col == root_col — converge at row == root_row.
|
||||
if col == root_col:
|
||||
if row == 0 and root_row > 0:
|
||||
tl.send(dir="S", src=acc)
|
||||
elif row < cube_h - 1:
|
||||
elif 0 < row < root_row:
|
||||
recv = tl.recv(dir="N", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
tl.send(dir="S", src=acc)
|
||||
else:
|
||||
elif row == root_row:
|
||||
if root_row > 0:
|
||||
recv = tl.recv(dir="N", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
if cube_h - 1 > root_row:
|
||||
recv = tl.recv(dir="S", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
elif root_row < row < cube_h - 1:
|
||||
recv = tl.recv(dir="S", shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
tl.send(dir="N", src=acc)
|
||||
elif row == cube_h - 1 and cube_h - 1 > root_row:
|
||||
tl.send(dir="N", src=acc)
|
||||
|
||||
# ── Phase 3: inter-SIP exchange on root cube ──
|
||||
root_cube = (cube_h - 1) * cube_w + (cube_w - 1)
|
||||
# Phase 3: inter-SIP exchange on root cube.
|
||||
if cube_id == root_cube and n_sips > 1:
|
||||
if sip_topo_kind == SIP_TOPO_RING:
|
||||
acc = _inter_sip_ring(acc, n_sips, n_elem, tl)
|
||||
elif sip_topo_kind == SIP_TOPO_TORUS:
|
||||
acc = _inter_sip_torus_2d(acc, sip_rank, sip_topo_w, sip_topo_h, n_elem, tl)
|
||||
acc = _inter_sip_torus_2d(
|
||||
acc, sip_rank, sip_topo_w, sip_topo_h, n_elem, tl)
|
||||
elif sip_topo_kind == SIP_TOPO_MESH:
|
||||
acc = _inter_sip_mesh_2d(acc, sip_rank, sip_topo_w, sip_topo_h, n_elem, tl)
|
||||
acc = _inter_sip_mesh_2d(
|
||||
acc, sip_rank, sip_topo_w, sip_topo_h, n_elem, tl)
|
||||
|
||||
# ── Phase 4: col broadcast S → N on rightmost column ──
|
||||
if col == cube_w - 1:
|
||||
if row == cube_h - 1:
|
||||
# Phase 4: col broadcast on col == root_col, outward from root_row.
|
||||
if col == root_col:
|
||||
if row == root_row:
|
||||
if root_row > 0:
|
||||
tl.send(dir="N", src=acc)
|
||||
elif row > 0:
|
||||
if cube_h - 1 > root_row:
|
||||
tl.send(dir="S", src=acc)
|
||||
elif row < root_row:
|
||||
acc = tl.recv(dir="S", shape=(n_elem,), dtype="f16")
|
||||
if row > 0:
|
||||
tl.send(dir="N", src=acc)
|
||||
else:
|
||||
acc = tl.recv(dir="S", shape=(n_elem,), dtype="f16")
|
||||
elif row > root_row:
|
||||
acc = tl.recv(dir="N", shape=(n_elem,), dtype="f16")
|
||||
if row < cube_h - 1:
|
||||
tl.send(dir="S", src=acc)
|
||||
|
||||
# ── Phase 5: row broadcast E → W ──
|
||||
if col == cube_w - 1:
|
||||
# Phase 5: row broadcast outward from root_col.
|
||||
if col == root_col:
|
||||
if root_col > 0:
|
||||
tl.send(dir="W", src=acc)
|
||||
elif col > 0:
|
||||
if cube_w - 1 > root_col:
|
||||
tl.send(dir="E", src=acc)
|
||||
elif col < root_col:
|
||||
acc = tl.recv(dir="E", shape=(n_elem,), dtype="f16")
|
||||
if col > 0:
|
||||
tl.send(dir="W", src=acc)
|
||||
else:
|
||||
acc = tl.recv(dir="E", shape=(n_elem,), dtype="f16")
|
||||
elif col > root_col:
|
||||
acc = tl.recv(dir="W", shape=(n_elem,), dtype="f16")
|
||||
if col < cube_w - 1:
|
||||
tl.send(dir="E", src=acc)
|
||||
|
||||
tl.store(pe_addr, acc)
|
||||
|
||||
|
||||
@@ -221,6 +221,8 @@ def install_ipcq(
|
||||
|
||||
_OPPOSITE_DIR = {
|
||||
"E": "W", "W": "E", "N": "S", "S": "N",
|
||||
"intra_E": "intra_W", "intra_W": "intra_E",
|
||||
"intra_N": "intra_S", "intra_S": "intra_N",
|
||||
"global_E": "global_W", "global_W": "global_E",
|
||||
"global_N": "global_S", "global_S": "global_N",
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
"""SFR configuration for intercube + inter-SIP IPCQ wiring.
|
||||
"""SFR configuration for the full IPCQ hardware wiring.
|
||||
|
||||
Provides ``configure_sfr_intercube_multisip`` which programs PE_IPCQ
|
||||
neighbor tables for:
|
||||
Installs PE_IPCQ neighbor tables modeling the physical hardware.
|
||||
Wiring is independent of DPPolicy / kernel choice — the kernel decides
|
||||
at runtime which links to use.
|
||||
|
||||
1. Intercube within each SIP — pe0 of every cube connects to pe0 of
|
||||
its N/S/E/W mesh neighbors (no wrap-around).
|
||||
2. Inter-SIP on ALL cubes — pe0 of cube_c on sip_A connects to pe0 of
|
||||
cube_c on each peer SIP, using ``global_E``/``global_W`` (ring) or
|
||||
``global_N``/``global_S``/``global_E``/``global_W`` (mesh/torus)
|
||||
direction labels. Wiring all cubes allows the kernel to
|
||||
dynamically elect the root cube at runtime.
|
||||
Direction label namespaces (disjoint):
|
||||
|
||||
SIP-level topology is read from ``topology.yaml`` →
|
||||
``system.sips.topology`` (e.g. ``ring_1d``, ``mesh_2d``).
|
||||
Intercube mesh dimensions come from ``sip.cube_mesh.w/h``.
|
||||
- Intra-cube PE-to-PE: ``intra_N / intra_S / intra_E / intra_W``
|
||||
Logical 2×4 PE grid within a cube (no wrap):
|
||||
|
||||
Internally delegates to ``install_ipcq`` with a computed ``rank_to_pe``
|
||||
(pe0-only) and a closure-captured ``neighbors()`` function.
|
||||
Row 0: pe0 pe1 pe2 pe3
|
||||
Row 1: pe4 pe5 pe6 pe7
|
||||
|
||||
- Intercube same-lane: ``N / S / E / W``
|
||||
``pe_i of cube_A ↔ pe_i of cube_B`` across the 4×4 cube mesh
|
||||
(no wrap). Every PE i ∈ [0..7] wired independently.
|
||||
|
||||
- Inter-SIP same-(cube, pe): ``global_N / global_S / global_E / global_W``
|
||||
``pe_i of cube_c on sip_A ↔ pe_i of cube_c on sip_B`` per
|
||||
``topology.yaml → system.sips.topology``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,12 +29,46 @@ from kernbench.ccl.install import install_ipcq
|
||||
from kernbench.ccl.topologies import _BUILTIN as _TOPO_BUILTINS
|
||||
|
||||
|
||||
# ── Intra-cube 2×4 PE grid ───────────────────────────────────────────
|
||||
|
||||
_PE_GRID_COLS = 4
|
||||
_PE_GRID_ROWS = 2
|
||||
_PES_PER_CUBE = _PE_GRID_COLS * _PE_GRID_ROWS # 8
|
||||
|
||||
|
||||
def _intra_cube_neighbors(pe: int) -> dict[str, int]:
|
||||
"""Logical 2×4 PE grid neighbors within a cube (no wrap).
|
||||
|
||||
Returns directions in the ``intra_*`` namespace.
|
||||
"""
|
||||
row, col = divmod(pe, _PE_GRID_COLS)
|
||||
nbrs: dict[str, int] = {}
|
||||
if col < _PE_GRID_COLS - 1:
|
||||
nbrs["intra_E"] = row * _PE_GRID_COLS + (col + 1)
|
||||
if col > 0:
|
||||
nbrs["intra_W"] = row * _PE_GRID_COLS + (col - 1)
|
||||
if row < _PE_GRID_ROWS - 1:
|
||||
nbrs["intra_S"] = (row + 1) * _PE_GRID_COLS + col
|
||||
if row > 0:
|
||||
nbrs["intra_N"] = (row - 1) * _PE_GRID_COLS + col
|
||||
return nbrs
|
||||
|
||||
|
||||
# ── Public entry point ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def configure_sfr_intercube_multisip(
|
||||
engine: Any,
|
||||
spec: dict,
|
||||
cfg: dict,
|
||||
) -> dict[str, Any]:
|
||||
"""Wire IPCQ for intercube (pe0, mesh) + inter-SIP (pe0, all cubes).
|
||||
"""Wire the full IPCQ hardware model.
|
||||
|
||||
Every PE on every cube on every SIP gets neighbor table entries for:
|
||||
|
||||
- intra-cube (2×4 grid) in the ``intra_*`` namespace
|
||||
- intercube same-lane (4×4 cube mesh, no wrap) in ``N/S/E/W``
|
||||
- inter-SIP same-(cube, pe) in ``global_*``
|
||||
|
||||
Args:
|
||||
engine: GraphEngine with ``_components``.
|
||||
@@ -46,48 +82,71 @@ def configure_sfr_intercube_multisip(
|
||||
mesh_w = int(cm["w"])
|
||||
mesh_h = int(cm["h"])
|
||||
n_cubes = mesh_w * mesh_h
|
||||
n_sips = int(spec.get("system", {}).get("sips", {}).get("count", 1))
|
||||
sip_topology = str(
|
||||
spec.get("system", {}).get("sips", {}).get("topology", "ring_1d")
|
||||
)
|
||||
sips_cfg = spec.get("system", {}).get("sips", {})
|
||||
n_sips = int(sips_cfg.get("count", 1))
|
||||
sip_topology = str(sips_cfg.get("topology", "ring_1d"))
|
||||
sip_w = sips_cfg.get("w")
|
||||
sip_h = sips_cfg.get("h")
|
||||
sip_w = int(sip_w) if sip_w is not None else None
|
||||
sip_h = int(sip_h) if sip_h is not None else None
|
||||
|
||||
if sip_topology not in _TOPO_BUILTINS:
|
||||
raise ValueError(
|
||||
f"Unknown sip topology '{sip_topology}'. "
|
||||
f"Available: {list(_TOPO_BUILTINS)}"
|
||||
)
|
||||
sip_topo_fn = _TOPO_BUILTINS[sip_topology]
|
||||
_sip_topo_fn_raw = _TOPO_BUILTINS[sip_topology]
|
||||
|
||||
world_size = n_sips * n_cubes
|
||||
def sip_topo_fn(rank: int, ws: int) -> dict:
|
||||
if sip_w is not None and sip_h is not None:
|
||||
try:
|
||||
return _sip_topo_fn_raw(rank, ws, w=sip_w, h=sip_h)
|
||||
except TypeError:
|
||||
pass
|
||||
return _sip_topo_fn_raw(rank, ws)
|
||||
|
||||
pes_per_cube = _PES_PER_CUBE
|
||||
world_size = n_sips * n_cubes * pes_per_cube
|
||||
pe_idx_to_pe: list[tuple[int, int, int]] = [
|
||||
(sip, cube, 0)
|
||||
(sip, cube, pe)
|
||||
for sip in range(n_sips)
|
||||
for cube in range(n_cubes)
|
||||
for pe in range(pes_per_cube)
|
||||
]
|
||||
|
||||
def _pe_idx(sip: int, cube: int, pe: int) -> int:
|
||||
return (sip * n_cubes + cube) * pes_per_cube + pe
|
||||
|
||||
def _neighbors(pe_idx: int, ws: int, _base: dict) -> dict[str, int]:
|
||||
sip = pe_idx // n_cubes
|
||||
cube = pe_idx % n_cubes
|
||||
tmp = pe_idx
|
||||
pe = tmp % pes_per_cube
|
||||
tmp //= pes_per_cube
|
||||
cube = tmp % n_cubes
|
||||
sip = tmp // n_cubes
|
||||
row = cube // mesh_w
|
||||
col = cube % mesh_w
|
||||
|
||||
nbrs: dict[str, int] = {}
|
||||
|
||||
# Intercube within SIP (mesh, no wrap-around)
|
||||
if col < mesh_w - 1:
|
||||
nbrs["E"] = sip * n_cubes + (row * mesh_w + col + 1)
|
||||
if col > 0:
|
||||
nbrs["W"] = sip * n_cubes + (row * mesh_w + col - 1)
|
||||
if row < mesh_h - 1:
|
||||
nbrs["S"] = sip * n_cubes + ((row + 1) * mesh_w + col)
|
||||
if row > 0:
|
||||
nbrs["N"] = sip * n_cubes + ((row - 1) * mesh_w + col)
|
||||
# ── Intra-cube (intra_N/S/E/W) ──
|
||||
for d, peer_pe in _intra_cube_neighbors(pe).items():
|
||||
nbrs[d] = _pe_idx(sip, cube, peer_pe)
|
||||
|
||||
# Inter-SIP on ALL cubes
|
||||
# ── Intercube same-lane (N/S/E/W, 4×4 no wrap) ──
|
||||
if col < mesh_w - 1:
|
||||
nbrs["E"] = _pe_idx(sip, row * mesh_w + (col + 1), pe)
|
||||
if col > 0:
|
||||
nbrs["W"] = _pe_idx(sip, row * mesh_w + (col - 1), pe)
|
||||
if row < mesh_h - 1:
|
||||
nbrs["S"] = _pe_idx(sip, (row + 1) * mesh_w + col, pe)
|
||||
if row > 0:
|
||||
nbrs["N"] = _pe_idx(sip, (row - 1) * mesh_w + col, pe)
|
||||
|
||||
# ── Inter-SIP same-(cube, pe) (global_*) ──
|
||||
if n_sips > 1:
|
||||
sip_nbrs = sip_topo_fn(sip, n_sips)
|
||||
for d, peer_sip in sip_nbrs.items():
|
||||
nbrs[f"global_{d}"] = peer_sip * n_cubes + cube
|
||||
nbrs[f"global_{d}"] = _pe_idx(peer_sip, cube, pe)
|
||||
|
||||
return nbrs
|
||||
|
||||
|
||||
@@ -33,23 +33,41 @@ def ring_1d_unidir(rank: int, world_size: int) -> NeighborMap:
|
||||
return {"E": (rank + 1) % world_size}
|
||||
|
||||
|
||||
def mesh_2d(rank: int, world_size: int) -> NeighborMap:
|
||||
"""Square 2D mesh (N/S/E/W).
|
||||
|
||||
Layout: rank = row * side + col, with side = sqrt(world_size).
|
||||
Wrap-around (torus) on all four edges.
|
||||
"""
|
||||
def _resolve_2d_dims(
|
||||
world_size: int, w: int | None, h: int | None, name: str,
|
||||
) -> tuple[int, int]:
|
||||
if w is not None and h is not None:
|
||||
if w * h != world_size:
|
||||
raise ValueError(
|
||||
f"{name}: w*h ({w}*{h}) != world_size ({world_size})"
|
||||
)
|
||||
return w, h
|
||||
side = int(round(world_size ** 0.5))
|
||||
if side * side != world_size:
|
||||
raise ValueError(
|
||||
f"mesh_2d requires square world_size, got {world_size}"
|
||||
f"{name} requires square world_size or explicit w,h, "
|
||||
f"got {world_size}"
|
||||
)
|
||||
r, c = divmod(rank, side)
|
||||
return side, side
|
||||
|
||||
|
||||
def mesh_2d(
|
||||
rank: int, world_size: int,
|
||||
w: int | None = None, h: int | None = None,
|
||||
) -> NeighborMap:
|
||||
"""2D mesh (N/S/E/W) with wrap-around on all four edges.
|
||||
|
||||
Layout: rank = row * w + col. When w, h are given, supports
|
||||
rectangular (e.g. 2x3) layouts. Otherwise falls back to square
|
||||
side = sqrt(world_size).
|
||||
"""
|
||||
w, h = _resolve_2d_dims(world_size, w, h, "mesh_2d")
|
||||
r, c = divmod(rank, w)
|
||||
return {
|
||||
"N": ((r - 1) % side) * side + c,
|
||||
"S": ((r + 1) % side) * side + c,
|
||||
"W": r * side + (c - 1) % side,
|
||||
"E": r * side + (c + 1) % side,
|
||||
"N": ((r - 1) % h) * w + c,
|
||||
"S": ((r + 1) % h) * w + c,
|
||||
"W": r * w + (c - 1) % w,
|
||||
"E": r * w + (c + 1) % w,
|
||||
}
|
||||
|
||||
|
||||
@@ -73,36 +91,30 @@ def tree_binary(rank: int, world_size: int) -> NeighborMap:
|
||||
return n
|
||||
|
||||
|
||||
def torus_2d(rank: int, world_size: int) -> NeighborMap:
|
||||
"""Square 2D torus (N/S/E/W) with wrap-around on all edges.
|
||||
|
||||
Alias for mesh_2d (which already wraps). Explicit name for clarity
|
||||
when used as a SIP-level topology.
|
||||
"""
|
||||
return mesh_2d(rank, world_size)
|
||||
def torus_2d(
|
||||
rank: int, world_size: int,
|
||||
w: int | None = None, h: int | None = None,
|
||||
) -> NeighborMap:
|
||||
"""2D torus (N/S/E/W) with wrap-around on all edges. Alias for mesh_2d."""
|
||||
return mesh_2d(rank, world_size, w=w, h=h)
|
||||
|
||||
|
||||
def mesh_2d_no_wrap(rank: int, world_size: int) -> NeighborMap:
|
||||
"""Square 2D mesh (N/S/E/W) WITHOUT wrap-around.
|
||||
|
||||
Edge nodes have fewer neighbors (no wrapping). Used for SIP-level
|
||||
topologies where physical links don't wrap.
|
||||
"""
|
||||
side = int(round(world_size ** 0.5))
|
||||
if side * side != world_size:
|
||||
raise ValueError(
|
||||
f"mesh_2d_no_wrap requires square world_size, got {world_size}"
|
||||
)
|
||||
r, c = divmod(rank, side)
|
||||
def mesh_2d_no_wrap(
|
||||
rank: int, world_size: int,
|
||||
w: int | None = None, h: int | None = None,
|
||||
) -> NeighborMap:
|
||||
"""2D mesh (N/S/E/W) WITHOUT wrap-around. Supports rectangular dims."""
|
||||
w, h = _resolve_2d_dims(world_size, w, h, "mesh_2d_no_wrap")
|
||||
r, c = divmod(rank, w)
|
||||
n: NeighborMap = {}
|
||||
if r > 0:
|
||||
n["N"] = (r - 1) * side + c
|
||||
if r < side - 1:
|
||||
n["S"] = (r + 1) * side + c
|
||||
n["N"] = (r - 1) * w + c
|
||||
if r < h - 1:
|
||||
n["S"] = (r + 1) * w + c
|
||||
if c > 0:
|
||||
n["W"] = r * side + (c - 1)
|
||||
if c < side - 1:
|
||||
n["E"] = r * side + (c + 1)
|
||||
n["W"] = r * w + (c - 1)
|
||||
if c < w - 1:
|
||||
n["E"] = r * w + (c + 1)
|
||||
return n
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ def _hbm_pa(sip: int, cube: int, pe_id: int, spec: dict) -> int:
|
||||
mm = spec["cube"]["memory_map"]
|
||||
slice_bytes = mm["hbm_total_gb_per_cube"] * (1 << 30) // mm["hbm_slices_per_cube"]
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=sip, cube_id=cube, pe_id=pe_id,
|
||||
sip_id=sip, die_id=cube, pe_id=pe_id,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
return pa.encode()
|
||||
|
||||
@@ -31,6 +31,26 @@ class IpcqInvalidDirection(ValueError):
|
||||
has no neighbor installed for this PE."""
|
||||
|
||||
|
||||
# ── ADR-0023 D9.7: IPCQ slot-memory latency model ───────────────────
|
||||
#
|
||||
# Per-tier (bw_gbs, overhead_ns) used to charge the slot write (inbound)
|
||||
# and slot read (recv consume). Mirrors topology.yaml component values.
|
||||
_BUFFER_KIND_BW: dict[str, tuple[float, float]] = {
|
||||
"tcm": (512.0, 0.0),
|
||||
"sram": (512.0, 2.0),
|
||||
"hbm": (256.0, 6.0),
|
||||
}
|
||||
|
||||
|
||||
def slot_io_latency_ns(buffer_kind: str, nbytes: int) -> float:
|
||||
"""Per-access latency for one slot read/write of ``nbytes`` against
|
||||
the IPCQ backing memory tier (``buffer_kind``)."""
|
||||
bw_gbs, overhead_ns = _BUFFER_KIND_BW.get(
|
||||
buffer_kind, _BUFFER_KIND_BW["tcm"],
|
||||
)
|
||||
return float(nbytes) / bw_gbs + overhead_ns
|
||||
|
||||
|
||||
# ── D2.5: IpcqEndpoint ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -115,6 +135,13 @@ class IpcqRecvCmd:
|
||||
"return_slot" — return slot address as-is (default, zero-copy).
|
||||
Kernel uses the slot memory directly.
|
||||
"copy_to_dst" — copy slot data to dst_addr, then return.
|
||||
|
||||
``consume`` (DIAGNOSTIC ONLY): when False, recv still blocks until the
|
||||
payload lands in the slot, but skips the slot-read latency charge
|
||||
(slot-IO + PE↔bank fabric drain for SRAM/HBM tiers). This exists
|
||||
solely so the pe2pe overview plot can compare apples-to-apples
|
||||
against tl.store (a one-sided write that pays no read on DST). Real
|
||||
kernels always need the data they receive — leave this True.
|
||||
"""
|
||||
|
||||
direction: str | None # None → round-robin (weak fairness, D4)
|
||||
@@ -126,6 +153,7 @@ class IpcqRecvCmd:
|
||||
dst_space: str = "" # used only when recv_mode == "copy_to_dst"
|
||||
blocking: bool = True
|
||||
data_op: bool = True
|
||||
consume: bool = True # DIAGNOSTIC: see docstring
|
||||
|
||||
|
||||
# ── D12: IpcqDmaToken (PE_IPCQ → PE_DMA, vc_comm) ───────────────────
|
||||
|
||||
@@ -34,6 +34,7 @@ class TensorHandle:
|
||||
nbytes: int # total byte size
|
||||
data: object = None # reserved for validate mode
|
||||
space: str = "tcm" # MemoryStore space ("tcm" | "hbm" | "sram")
|
||||
pinned: bool = False # operand already DMA-staged in TCM (via tl.load)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -53,11 +53,51 @@ class ComponentBase(ABC):
|
||||
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 one in_port into the shared inbox."""
|
||||
"""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)."""
|
||||
@@ -138,7 +178,15 @@ class PeEngineBase(ComponentBase):
|
||||
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."""
|
||||
"""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)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import simpy
|
||||
|
||||
from kernbench.components.base import ComponentBase
|
||||
from kernbench.sim_engine.transaction import Flit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kernbench.components.context import ComponentContext
|
||||
@@ -13,15 +14,58 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class TransitComponent(ComponentBase):
|
||||
"""Transit component for NOC, UCIe, XBAR nodes.
|
||||
"""Transit component for NOC, UCIe, XBAR nodes (ADR-0033 Phase 2c).
|
||||
|
||||
Applies overhead_ns processing delay (from node.attrs) then forwards the
|
||||
Transaction to the next hop via inherited _forward_txn().
|
||||
Flit-aware pass-through: forwards each Flit to the next hop with
|
||||
per-transaction ``overhead_ns`` applied ONCE (at first-flit arrival,
|
||||
modeling header decode + routing decision). Subsequent flits of the
|
||||
same transaction pipeline through with no extra delay, preserving
|
||||
wormhole-style cut-through across multi-hop paths.
|
||||
|
||||
Forwarding is SERIAL in the worker: each flit is forwarded in arrival
|
||||
order. Spawning ``env.process`` per flit would let later flits
|
||||
overtake earlier ones (when the first flit yields ``overhead_ns``
|
||||
while subsequent flits skip it), producing out-of-order delivery
|
||||
and early ``is_last`` signaling at the destination.
|
||||
|
||||
Non-Flit messages (zero-byte control Transactions, etc.) fall back
|
||||
to the legacy atomic ``_forward_txn`` path via ``env.process``.
|
||||
"""
|
||||
|
||||
_FLIT_AWARE = True
|
||||
|
||||
def __init__(self, node: Node, ctx: ComponentContext | None = None) -> None:
|
||||
super().__init__(node, ctx)
|
||||
self._txn_decoded: set[int] = set()
|
||||
|
||||
def run(self, env: simpy.Environment, nbytes: int) -> Generator:
|
||||
overhead_ns = float(self.node.attrs.get("overhead_ns", 0.0))
|
||||
yield env.timeout(overhead_ns)
|
||||
|
||||
def _worker(self, env: simpy.Environment) -> Generator:
|
||||
while True:
|
||||
msg: Any = yield self._inbox.get()
|
||||
if isinstance(msg, Flit):
|
||||
tid = id(msg.txn)
|
||||
if tid not in self._txn_decoded:
|
||||
self._txn_decoded.add(tid)
|
||||
yield from self.run(env, msg.txn.nbytes)
|
||||
if msg.is_last:
|
||||
self._txn_decoded.discard(tid)
|
||||
next_hop = self._next_hop_in_path(msg.txn)
|
||||
if next_hop and next_hop in self.out_ports:
|
||||
yield self.out_ports[next_hop].put(msg)
|
||||
elif msg.is_last:
|
||||
msg.txn.done.succeed()
|
||||
else:
|
||||
env.process(self._forward_txn(env, msg))
|
||||
|
||||
def _next_hop_in_path(self, txn: Any) -> str | None:
|
||||
my_id = self.node.id
|
||||
path = getattr(txn, "path", None)
|
||||
if not path:
|
||||
return None
|
||||
for i, n in enumerate(path):
|
||||
if n == my_id and i + 1 < len(path):
|
||||
return path[i + 1]
|
||||
return None
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from math import ceil
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import simpy
|
||||
|
||||
from kernbench.components.base import ComponentBase
|
||||
from kernbench.sim_engine.transaction import Transaction
|
||||
from kernbench.sim_engine.transaction import Flit, Transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kernbench.components.context import ComponentContext
|
||||
@@ -14,68 +15,161 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class HbmCtrlComponent(ComponentBase):
|
||||
"""HBM controller: terminal component that models HBM access latency.
|
||||
"""HBM controller with per-pseudo-channel (PC) striping (ADR-0019 D1, ADR-0033).
|
||||
|
||||
Dual-channel model: separate read and write resources (each capacity=1)
|
||||
allowing concurrent read/write like PE_DMA. Multiple reads or multiple
|
||||
writes still serialize within their respective channel.
|
||||
Stateless per-PC ``available_at`` array; each incoming transaction is
|
||||
split into ``ceil(nbytes / burst_bytes)`` chunks distributed round-robin
|
||||
across ``num_pcs`` PCs starting from a global ``next_pc`` pointer. Read
|
||||
and write share the same PC array (real HW command bus is shared per PC).
|
||||
|
||||
On completion, creates a ResponseMsg and sends it back on the reverse path
|
||||
so that response latency is modeled through the fabric.
|
||||
Chunk-loop drain (ADR-0033 D1, Phase 2b): chunks are scheduled over
|
||||
time at intervals of ``drain_ns / n_chunks`` to model the bottleneck
|
||||
link's data arrival rate. Each chunk's PC commit starts at its arrival
|
||||
time. The last PC commit finishes at ``arrival + drain + commit_time``
|
||||
— naturally producing the correct single-transfer total (drain +
|
||||
commit) without the cut-through over-credit of the prior
|
||||
``env.now - drain_ns`` subtraction.
|
||||
|
||||
Direction switching penalty: when a PC's last direction differs from the
|
||||
current request, ``switch_penalty_ns`` is charged. Default 0 (Tier 0
|
||||
assumption — ideal scheduler amortizes switching cost; ADR-0033 D2).
|
||||
"""
|
||||
|
||||
_FLIT_AWARE = True
|
||||
|
||||
def __init__(self, node: Node, ctx: ComponentContext | None = None) -> None:
|
||||
super().__init__(node, ctx)
|
||||
self._read: simpy.Resource | None = None
|
||||
self._write: simpy.Resource | None = None
|
||||
self._num_pcs: int = 0
|
||||
self._pc_bw_gbs: float = 0.0
|
||||
self._burst_bytes: int = 256
|
||||
self._switch_penalty_ns: float = 0.0
|
||||
self._pc_avail: list[float] = []
|
||||
self._pc_last_dir: list[str | None] = []
|
||||
self._next_pc: int = 0
|
||||
# Per-txn flit accumulation state (ADR-0033 Phase 2c-3).
|
||||
self._txn_state: dict[int, dict[str, Any]] = {}
|
||||
|
||||
def start(self, env: simpy.Environment) -> None:
|
||||
capacity = int(self.node.attrs.get("capacity", 1))
|
||||
self._read = simpy.Resource(env, capacity=capacity)
|
||||
self._write = simpy.Resource(env, capacity=capacity)
|
||||
attrs = self.node.attrs
|
||||
self._num_pcs = int(attrs.get("num_pcs", 8))
|
||||
self._pc_bw_gbs = float(attrs.get("pc_bw_gbs", 32.0))
|
||||
self._burst_bytes = int(attrs.get("burst_bytes", 256))
|
||||
self._switch_penalty_ns = float(attrs.get("switch_penalty_ns", 0.0))
|
||||
self._pc_avail = [0.0] * self._num_pcs
|
||||
self._pc_last_dir = [None] * self._num_pcs
|
||||
self._next_pc = 0
|
||||
super().start(env)
|
||||
|
||||
def run(self, env: simpy.Environment, nbytes: int) -> Generator:
|
||||
overhead_ns = float(self.node.attrs.get("overhead_ns", 0.0))
|
||||
yield env.timeout(overhead_ns)
|
||||
|
||||
def _select_channel(self, txn: Any) -> simpy.Resource:
|
||||
"""Select channel based on request type: write requests → write, else → read."""
|
||||
def _is_write(self, txn: Any) -> bool:
|
||||
from kernbench.runtime_api.kernel import MemoryWriteMsg, PeDmaMsg
|
||||
|
||||
assert self._read is not None and self._write is not None
|
||||
req = txn.request
|
||||
if isinstance(req, MemoryWriteMsg):
|
||||
return self._write
|
||||
return True
|
||||
if isinstance(req, PeDmaMsg) and req.is_write:
|
||||
return self._write
|
||||
return self._read
|
||||
return True
|
||||
return False
|
||||
|
||||
def _worker(self, env: simpy.Environment) -> Generator:
|
||||
"""Dispatch each incoming txn to a concurrent process for channel-level parallelism."""
|
||||
while True:
|
||||
txn: Any = yield self._inbox.get()
|
||||
env.process(self._handle_txn(env, txn))
|
||||
msg: Any = yield self._inbox.get()
|
||||
if isinstance(msg, Flit):
|
||||
# ADR-0033 Phase 2c-3: serial flit handling (preserve
|
||||
# arrival order, in particular ``is_last`` only after
|
||||
# all preceding flits have committed).
|
||||
yield from self._handle_flit(env, msg)
|
||||
else:
|
||||
# Transaction (e.g., zero-byte read command) — keep
|
||||
# legacy chunk-loop drain path for PC read time modeling.
|
||||
env.process(self._handle_txn(env, msg))
|
||||
|
||||
def _handle_flit(self, env: simpy.Environment, flit: Flit) -> Generator:
|
||||
"""Per-flit PC commit. On first flit of a txn, claim PC range and
|
||||
apply overhead. On ``is_last``, wait for last PC commit to
|
||||
finish, then send the response."""
|
||||
txn = flit.txn
|
||||
tid = id(txn)
|
||||
chunk_time = (
|
||||
self._burst_bytes / self._pc_bw_gbs if self._pc_bw_gbs > 0 else 0.0
|
||||
)
|
||||
new_dir = "W" if self._is_write(txn) else "R"
|
||||
|
||||
if tid not in self._txn_state:
|
||||
yield from self.run(env, txn.nbytes)
|
||||
work_bytes = txn.nbytes if txn.nbytes > 0 else int(
|
||||
getattr(txn.request, "nbytes", 0) or 0
|
||||
)
|
||||
n_flits = max(1, ceil(work_bytes / self._burst_bytes)) if work_bytes > 0 else 1
|
||||
pc_start = self._next_pc
|
||||
self._next_pc = (self._next_pc + n_flits) % self._num_pcs
|
||||
self._txn_state[tid] = {
|
||||
"pc_start": pc_start,
|
||||
"last_finish": env.now,
|
||||
}
|
||||
|
||||
state = self._txn_state[tid]
|
||||
pc = (state["pc_start"] + flit.flit_index) % self._num_pcs
|
||||
switch_cost = 0.0
|
||||
if self._pc_last_dir[pc] is not None and self._pc_last_dir[pc] != new_dir:
|
||||
switch_cost = self._switch_penalty_ns
|
||||
start = max(env.now, self._pc_avail[pc]) + switch_cost
|
||||
finish = start + chunk_time
|
||||
self._pc_avail[pc] = finish
|
||||
self._pc_last_dir[pc] = new_dir
|
||||
if finish > state["last_finish"]:
|
||||
state["last_finish"] = finish
|
||||
|
||||
if flit.is_last:
|
||||
wait = state["last_finish"] - env.now
|
||||
if wait > 0:
|
||||
yield env.timeout(wait)
|
||||
del self._txn_state[tid]
|
||||
yield from self._send_response(env, txn)
|
||||
|
||||
def _handle_txn(self, env: simpy.Environment, txn: Any) -> Generator:
|
||||
"""Acquire channel, run, apply drain, send response."""
|
||||
channel = self._select_channel(txn)
|
||||
with channel.request() as req:
|
||||
yield req
|
||||
is_write = self._is_write(txn)
|
||||
new_dir = "W" if is_write else "R"
|
||||
chunk_time = (
|
||||
self._burst_bytes / self._pc_bw_gbs if self._pc_bw_gbs > 0 else 0.0
|
||||
)
|
||||
# MemoryReadMsg forwards command with nbytes=0; the actual data work
|
||||
# is sized by request.nbytes (data returns via reverse-path response).
|
||||
work_bytes = txn.nbytes if txn.nbytes > 0 else int(getattr(txn.request, "nbytes", 0) or 0)
|
||||
n_chunks = max(1, ceil(work_bytes / self._burst_bytes)) if work_bytes > 0 else 0
|
||||
|
||||
drain = float(getattr(txn, "drain_ns", 0.0))
|
||||
chunk_interval = (drain / n_chunks) if (n_chunks > 0 and drain > 0) else 0.0
|
||||
|
||||
yield from self.run(env, txn.nbytes)
|
||||
drain = getattr(txn, "drain_ns", 0.0)
|
||||
if drain > 0:
|
||||
yield env.timeout(drain)
|
||||
|
||||
last_finish = env.now
|
||||
for i in range(n_chunks):
|
||||
if chunk_interval > 0:
|
||||
yield env.timeout(chunk_interval)
|
||||
pc = (self._next_pc + i) % self._num_pcs
|
||||
switch_cost = 0.0
|
||||
if self._pc_last_dir[pc] is not None and self._pc_last_dir[pc] != new_dir:
|
||||
switch_cost = self._switch_penalty_ns
|
||||
start = max(env.now, self._pc_avail[pc]) + switch_cost
|
||||
finish = start + chunk_time
|
||||
self._pc_avail[pc] = finish
|
||||
self._pc_last_dir[pc] = new_dir
|
||||
if finish > last_finish:
|
||||
last_finish = finish
|
||||
if n_chunks > 0:
|
||||
self._next_pc = (self._next_pc + n_chunks) % self._num_pcs
|
||||
|
||||
wait = last_finish - env.now
|
||||
if wait > 0:
|
||||
yield env.timeout(wait)
|
||||
|
||||
yield from self._send_response(env, txn)
|
||||
|
||||
def _send_response(self, env: simpy.Environment, txn: Any) -> Generator:
|
||||
"""Route completion based on path type.
|
||||
|
||||
- PeDmaMsg: succeed done directly (probe).
|
||||
- Bypass path (no m_cpu): MemoryWrite succeeds done; MemoryRead sends
|
||||
data back on reverse path with original done event.
|
||||
- M_CPU DMA path: send ResponseMsg for m_cpu/io_cpu aggregation.
|
||||
"""
|
||||
from kernbench.runtime_api.kernel import MemoryReadMsg, PeDmaMsg
|
||||
|
||||
if isinstance(txn.request, PeDmaMsg):
|
||||
@@ -90,11 +184,9 @@ class HbmCtrlComponent(ComponentBase):
|
||||
txn.done.succeed()
|
||||
return
|
||||
|
||||
# Bypass path: no m_cpu in the transaction path
|
||||
is_bypass = not any("m_cpu" in n for n in txn.path)
|
||||
if is_bypass:
|
||||
if isinstance(txn.request, MemoryReadMsg):
|
||||
# D2H: send data back on reverse path to pcie_ep
|
||||
reverse_path = list(reversed(txn.path))
|
||||
if len(reverse_path) >= 2:
|
||||
resp_txn = Transaction(
|
||||
@@ -103,18 +195,16 @@ class HbmCtrlComponent(ComponentBase):
|
||||
)
|
||||
yield self.out_ports[reverse_path[1]].put(resp_txn.advance())
|
||||
return
|
||||
# MemoryWrite bypass or short path: done
|
||||
txn.done.succeed()
|
||||
return
|
||||
|
||||
# M_CPU DMA path: send ResponseMsg for aggregation
|
||||
reverse_path = list(reversed(txn.path))
|
||||
if len(reverse_path) >= 2 and self.ctx:
|
||||
from kernbench.runtime_api.kernel import ResponseMsg
|
||||
|
||||
parts = self.node.id.split(".")
|
||||
cube_id = int(parts[1].replace("cube", ""))
|
||||
pe_id = 0 # single hbm_ctrl, PE info from request
|
||||
pe_id = 0
|
||||
resp_msg = ResponseMsg(
|
||||
correlation_id=txn.request.correlation_id,
|
||||
request_id=txn.request.request_id,
|
||||
|
||||
@@ -58,7 +58,18 @@ class IoCpuComponent(ComponentBase):
|
||||
self._pending[key] = (expected, received, parent_done)
|
||||
|
||||
def _dispatch_to_m_cpus(self, env: simpy.Environment, txn: Any) -> Generator:
|
||||
"""Fan out sub-Transactions to target cube M_CPUs, wait for responses."""
|
||||
"""Fan out sub-Transactions to target cube M_CPUs, wait for responses.
|
||||
|
||||
ADR-0009 D5 (extended): for KernelLaunchMsg, stamp a single global
|
||||
target_start_ns = env.now + max(IO_CPU → any target PE_CPU path
|
||||
latency across all target cubes). M_CPU passes this value through
|
||||
unchanged; every PE in every cube yields until the same sim-time
|
||||
before beginning kernel execution. Without this, cross-cube
|
||||
launches would have each cube's M_CPU compute its own per-cube
|
||||
barrier relative to its local env.now, leaving PEs on different
|
||||
cubes out of sync (the "h3/h4 dispatch-offset artifact").
|
||||
"""
|
||||
import dataclasses
|
||||
from kernbench.runtime_api.kernel import KernelLaunchMsg, MemoryReadMsg, MemoryWriteMsg
|
||||
|
||||
request = txn.request
|
||||
@@ -72,10 +83,60 @@ class IoCpuComponent(ComponentBase):
|
||||
txn.done.succeed()
|
||||
return
|
||||
|
||||
# For KernelLaunchMsg, compute the global barrier once here so
|
||||
# every downstream PE_CPU uses the same target_start_ns.
|
||||
if isinstance(request, KernelLaunchMsg):
|
||||
io_overhead = self.ctx.node_overhead_ns.get(self.node.id, 0.0)
|
||||
global_max_latency = 0.0
|
||||
pe_ids = self._resolve_pe_ids(
|
||||
getattr(request, "target_pe", "all")
|
||||
)
|
||||
for sip, cube in cube_targets:
|
||||
try:
|
||||
m_cpu_id = self.ctx.resolver.find_m_cpu(sip, cube)
|
||||
io_to_m_path = self.ctx.router.find_node_path(
|
||||
self.node.id, m_cpu_id,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if len(io_to_m_path) < 2:
|
||||
continue
|
||||
leg1 = self.ctx.compute_path_latency_ns(
|
||||
io_to_m_path, nbytes=0,
|
||||
)
|
||||
m_overhead = self.ctx.node_overhead_ns.get(m_cpu_id, 0.0)
|
||||
for pe_id in pe_ids:
|
||||
pe_cpu_id = (
|
||||
f"sip{sip}.cube{cube}.pe{pe_id}.pe_cpu"
|
||||
)
|
||||
try:
|
||||
m_to_pe_path = self.ctx.router.find_node_path(
|
||||
m_cpu_id, pe_cpu_id,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if len(m_to_pe_path) < 2:
|
||||
continue
|
||||
leg2 = self.ctx.compute_path_latency_ns(
|
||||
m_to_pe_path, nbytes=0,
|
||||
)
|
||||
latency = leg1 + leg2 - io_overhead - m_overhead
|
||||
if latency > global_max_latency:
|
||||
global_max_latency = latency
|
||||
request = dataclasses.replace(
|
||||
request,
|
||||
target_start_ns=float(env.now) + global_max_latency,
|
||||
)
|
||||
|
||||
# Setup aggregation
|
||||
self._pending[request.request_id] = (len(cube_targets), 0, txn.done)
|
||||
|
||||
# Fan out to each target cube's M_CPU
|
||||
# Fan out to each target cube's M_CPU. Kernel-launch fanout
|
||||
# carries control metadata only; nbytes is forced to 0 for
|
||||
# KernelLaunchMsg so the launch sub-txns do not occupy data-fabric
|
||||
# BW (would otherwise serialize 16 cubes worth of fanout on the
|
||||
# shared first hop and break ADR-0009 D5's barrier prediction).
|
||||
is_kernel_launch = isinstance(request, KernelLaunchMsg)
|
||||
for sip, cube in cube_targets:
|
||||
try:
|
||||
m_cpu_id = self.ctx.resolver.find_m_cpu(sip, cube)
|
||||
@@ -86,11 +147,25 @@ class IoCpuComponent(ComponentBase):
|
||||
continue
|
||||
sub_txn = Transaction(
|
||||
request=request, path=path, step=0,
|
||||
nbytes=txn.nbytes, done=env.event(),
|
||||
nbytes=0 if is_kernel_launch else txn.nbytes,
|
||||
done=env.event(),
|
||||
result_data=txn.result_data,
|
||||
)
|
||||
yield self.out_ports[path[1]].put(sub_txn.advance())
|
||||
|
||||
def _resolve_pe_ids(self, target_pe: Any) -> list[int]:
|
||||
"""Resolve target_pe → list of PE indices (mirrors M_CPU logic)."""
|
||||
if isinstance(target_pe, int):
|
||||
return [target_pe]
|
||||
if isinstance(target_pe, tuple):
|
||||
return list(target_pe)
|
||||
# "all": all PEs in a cube
|
||||
n_slices = 8
|
||||
if self.ctx and self.ctx.spec:
|
||||
mm = self.ctx.spec.get("cube", {}).get("memory_map", {})
|
||||
n_slices = mm.get("hbm_slices_per_cube", 8)
|
||||
return list(range(n_slices))
|
||||
|
||||
def _resolve_cube_targets(self, request: Any) -> list[tuple[int, int]]:
|
||||
"""Return list of (sip, cube) pairs to fan out to."""
|
||||
from kernbench.runtime_api.kernel import (
|
||||
@@ -145,10 +220,10 @@ class IoCpuComponent(ComponentBase):
|
||||
return []
|
||||
|
||||
def _cube_from_pa(self, pa_val: int, fallback: int) -> int:
|
||||
"""Extract cube_id from a physical address, with fallback."""
|
||||
"""Extract die_id from a physical address, with fallback."""
|
||||
from kernbench.policy.address.phyaddr import PhysAddr
|
||||
try:
|
||||
return PhysAddr.decode(pa_val).cube_id
|
||||
return PhysAddr.decode(pa_val).die_id
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
@@ -162,7 +162,11 @@ class MCpuComponent(ComponentBase):
|
||||
Routes through find_node_path (M_CPU → NOC → PE_CPU command edges).
|
||||
PE_CPU sends ResponseMsg back via NOC → M_CPU on completion.
|
||||
Then sends aggregate ResponseMsg back to IO_CPU on the reverse path.
|
||||
|
||||
ADR-0009 D5: stamps target_start_ns so every PE in this fanout
|
||||
starts executing at the same env.now regardless of dispatch path.
|
||||
"""
|
||||
import dataclasses
|
||||
request = txn.request
|
||||
target_pe = getattr(request, "target_pe", "all")
|
||||
cube_prefix = self.node.id.rsplit(".", 1)[0] # e.g. "sip0.cube0"
|
||||
@@ -172,9 +176,13 @@ class MCpuComponent(ComponentBase):
|
||||
txn.done.succeed()
|
||||
return
|
||||
|
||||
# Fan out to each PE_CPU, using response-based aggregation
|
||||
sub_txns: list[Transaction] = []
|
||||
n_dispatched = 0
|
||||
# Resolve per-PE paths. If IO_CPU already stamped a global
|
||||
# target_start_ns (ADR-0009 D5 extended), pass it through
|
||||
# unchanged so every PE across every cube uses the same barrier.
|
||||
# Otherwise (e.g. direct-to-M_CPU launch in a unit test) compute
|
||||
# a per-cube barrier from env.now.
|
||||
per_pe: list[tuple[int, list[str], float]] = []
|
||||
max_latency = 0.0
|
||||
for pe_id in pe_ids:
|
||||
pe_cpu_id = f"{cube_prefix}.pe{pe_id}.pe_cpu"
|
||||
try:
|
||||
@@ -183,8 +191,24 @@ class MCpuComponent(ComponentBase):
|
||||
continue
|
||||
if len(path) < 2:
|
||||
continue
|
||||
latency = self.ctx.compute_path_latency_ns(path, nbytes=0)
|
||||
per_pe.append((pe_id, path, latency))
|
||||
if latency > max_latency:
|
||||
max_latency = latency
|
||||
|
||||
if getattr(request, "target_start_ns", None) is not None:
|
||||
stamped_request = request
|
||||
else:
|
||||
stamped_request = dataclasses.replace(
|
||||
request, target_start_ns=float(env.now) + max_latency,
|
||||
)
|
||||
|
||||
# Fan out to each PE_CPU, using response-based aggregation
|
||||
sub_txns: list[Transaction] = []
|
||||
n_dispatched = 0
|
||||
for pe_id, path, _lat in per_pe:
|
||||
sub_txn = Transaction(
|
||||
request=request, path=path, step=0,
|
||||
request=stamped_request, path=path, step=0,
|
||||
nbytes=0, done=env.event(),
|
||||
)
|
||||
yield self.out_ports[path[1]].put(sub_txn.advance())
|
||||
@@ -204,16 +228,21 @@ class MCpuComponent(ComponentBase):
|
||||
yield all_done
|
||||
del self._parent_txns[request.request_id]
|
||||
|
||||
# Aggregate PE-internal metrics (max across PEs)
|
||||
# Aggregate PE-internal metrics (max across PEs and across cubes).
|
||||
# Multiple M_CPUs share the same result_data dict via IO_CPU fanout;
|
||||
# merge against the existing value so cubes don't clobber each other.
|
||||
pe_exec_values = [st.result_data.get("pe_exec_ns", 0.0) for st in sub_txns]
|
||||
if pe_exec_values:
|
||||
txn.result_data["pe_exec_ns"] = max(pe_exec_values)
|
||||
cur = txn.result_data.get("pe_exec_ns", 0.0) or 0.0
|
||||
txn.result_data["pe_exec_ns"] = max(cur, max(pe_exec_values))
|
||||
dma_values = [st.result_data.get("dma_ns", 0.0) for st in sub_txns]
|
||||
if dma_values:
|
||||
txn.result_data["dma_ns"] = max(dma_values)
|
||||
cur = txn.result_data.get("dma_ns", 0.0) or 0.0
|
||||
txn.result_data["dma_ns"] = max(cur, max(dma_values))
|
||||
compute_values = [st.result_data.get("compute_ns", 0.0) for st in sub_txns]
|
||||
if compute_values:
|
||||
txn.result_data["compute_ns"] = max(compute_values)
|
||||
cur = txn.result_data.get("compute_ns", 0.0) or 0.0
|
||||
txn.result_data["compute_ns"] = max(cur, max(compute_values))
|
||||
|
||||
# Send aggregate response on reverse command path back to IO_CPU
|
||||
reverse_path = list(reversed(txn.path))
|
||||
|
||||
@@ -95,6 +95,13 @@ class PeCpuComponent(ComponentBase):
|
||||
request = txn.request
|
||||
yield from self.run(env, 0)
|
||||
|
||||
# ADR-0009 D5: synchronized launch barrier. If M_CPU stamped a
|
||||
# target_start_ns, wait until then so every PE in this launch
|
||||
# begins pe_exec measurement at the same simulated time.
|
||||
target_start = getattr(request, "target_start_ns", None)
|
||||
if target_start is not None and target_start > env.now:
|
||||
yield env.timeout(float(target_start) - env.now)
|
||||
|
||||
kernel_fn = get_kernel(request.kernel_ref.name)
|
||||
num_programs = self._derive_num_programs(request)
|
||||
kernel_args = self._unpack_kernel_args(request)
|
||||
|
||||
@@ -27,6 +27,12 @@ class PeDmaComponent(PeEngineBase):
|
||||
(DmaReadCmd → HBM read, DmaWriteCmd → HBM write)
|
||||
"""
|
||||
|
||||
# Defer op_log record_start until AFTER the DMA channel is acquired so
|
||||
# t_start reflects the serve-start moment (post queueing) rather than
|
||||
# the queue-enter moment. ComponentBase._handle_with_hooks consults this
|
||||
# flag.
|
||||
_DEFER_RECORD_START = True
|
||||
|
||||
def __init__(self, node: Node, ctx: ComponentContext | None = None) -> None:
|
||||
super().__init__(node, ctx)
|
||||
self._dma_read: simpy.Resource | None = None
|
||||
@@ -80,9 +86,16 @@ class PeDmaComponent(PeEngineBase):
|
||||
path = self.ctx.router.find_path(self._pe_prefix, dst_node)
|
||||
drain_ns = self.ctx.compute_drain_ns(path, cmd.nbytes)
|
||||
|
||||
# Acquire DMA channel (command issue serialization)
|
||||
# Acquire DMA channel — held through the entire round-trip so the
|
||||
# channel models "one DMA in flight per PE per direction" rather
|
||||
# than just issue-time serialization. This is what makes Option B
|
||||
# meaningful: t_start = serve-start covers the actual transfer.
|
||||
with dma_res.request() as req:
|
||||
yield req
|
||||
# Option B: record_start fires AFTER channel acquired, so t_start
|
||||
# = serve-start (excludes queue wait). _DEFER_RECORD_START=True
|
||||
# suppresses the auto-start in ComponentBase._handle_with_hooks.
|
||||
self._on_process_start(env, cmd)
|
||||
# Create sub-Transaction with PeDmaMsg (HbmCtrl handles it directly)
|
||||
sub_done = env.event()
|
||||
sub_request = PeDmaMsg(
|
||||
@@ -99,9 +112,7 @@ class PeDmaComponent(PeEngineBase):
|
||||
# Send to next hop (path[0] is pe_dma itself, path[1] is router)
|
||||
if len(path) > 1:
|
||||
yield self.out_ports[path[1]].put(sub_txn.advance())
|
||||
# DMA channel released after issue
|
||||
|
||||
# Wait for HBM transfer completion
|
||||
# Wait for HBM transfer completion BEFORE releasing the channel.
|
||||
yield sub_done
|
||||
pe_txn.done.succeed()
|
||||
|
||||
@@ -186,15 +197,63 @@ class PeDmaComponent(PeEngineBase):
|
||||
# ── IPCQ inbound (fabric → PE_DMA → MemoryStore + PE_IPCQ) ──────
|
||||
|
||||
def _handle_ipcq_inbound(self, env: simpy.Environment, txn: Any) -> Generator:
|
||||
"""At destination PE_DMA: atomically write data and forward metadata.
|
||||
"""At destination PE_DMA: pay terminal drain, then atomically write
|
||||
data and forward metadata.
|
||||
|
||||
ADR-0023 D9 (drain at inbound terminal): the Transaction carries
|
||||
``drain_ns = nbytes / bottleneck_bw_on_path`` stamped by the sender
|
||||
PE_DMA. Like every other Transaction terminal in the simulator (see
|
||||
``ComponentBase._forward_txn``), this drain must be paid when the
|
||||
Transaction reaches its destination. SRC-side ``tl.send`` is
|
||||
fire-and-forget — it never yields on ``sub_done`` — so paying the
|
||||
drain here does NOT delay the sender. What it DOES delay is the
|
||||
IpcqMetaArrival forwarded below: that delay is the only signal
|
||||
``tl.recv`` on DST blocks on, which is exactly the desired
|
||||
semantics — "send dispatches and returns; recv waits until the
|
||||
bytes have actually landed in its inbox".
|
||||
|
||||
The drain MUST be paid before the atomic block — inserting a yield
|
||||
inside would break invariant I6.
|
||||
|
||||
I6 (MUST): no SimPy yield between MemoryStore.write and the
|
||||
IpcqMetaArrival put into PE_IPCQ.
|
||||
"""
|
||||
from kernbench.common.ipcq_types import IpcqMetaArrival
|
||||
|
||||
# Pay terminal BW drain before the atomic write/metadata forward.
|
||||
# Without this, IPCQ effectively got fabric bandwidth for free at
|
||||
# the terminal (only intermediate-hop overhead_ns was charged),
|
||||
# making IPCQ lower than raw DMA at large sizes in benchmarks.
|
||||
drain = getattr(txn, "drain_ns", 0.0)
|
||||
if drain > 0:
|
||||
yield env.timeout(drain)
|
||||
|
||||
token = txn.request
|
||||
|
||||
# ADR-0023 D9.7: charge IPCQ slot-WRITE latency against the
|
||||
# backing-memory tier (tcm/sram/hbm) before the atomic block.
|
||||
# Must come BEFORE the atomic write→IpcqMetaArrival pair (I6).
|
||||
# SRAM/HBM also pay a PE_DMA→bank fabric drain (slot lives on
|
||||
# the cube NoC); TCM is per-PE local and skips this hop.
|
||||
from kernbench.common.ipcq_types import slot_io_latency_ns
|
||||
buffer_kind = token.dst_endpoint.buffer_kind
|
||||
if buffer_kind in ("sram", "hbm") and self.ctx is not None:
|
||||
cube_prefix = self._pe_prefix.rsplit(".", 1)[0]
|
||||
bank_node = (
|
||||
f"{cube_prefix}.sram" if buffer_kind == "sram"
|
||||
else f"{cube_prefix}.hbm_ctrl"
|
||||
)
|
||||
try:
|
||||
path = self.ctx.router.find_path(self._pe_prefix, bank_node)
|
||||
bank_drain_ns = self.ctx.compute_drain_ns(path, token.nbytes)
|
||||
if bank_drain_ns > 0:
|
||||
yield env.timeout(bank_drain_ns)
|
||||
except Exception:
|
||||
pass
|
||||
slot_write_ns = slot_io_latency_ns(buffer_kind, token.nbytes)
|
||||
if slot_write_ns > 0:
|
||||
yield env.timeout(slot_write_ns)
|
||||
|
||||
# ── ATOMIC: do not introduce yield between these two operations ──
|
||||
# 1. Move data via MemoryStore (single-hop DMA write).
|
||||
# Prefer the in-flight snapshot stashed by the sender PE_DMA;
|
||||
@@ -245,15 +304,17 @@ class PeDmaComponent(PeEngineBase):
|
||||
txn.done.succeed()
|
||||
|
||||
def _pipeline_process(self, env: simpy.Environment, token: Any) -> Generator:
|
||||
"""Pipeline mode: DMA read/write via fabric, then self-route."""
|
||||
self._on_process_start(env, token)
|
||||
"""Pipeline mode: DMA read/write via fabric, then self-route.
|
||||
|
||||
Option B: record_start is fired *inside* _do_pipeline_dma, after the
|
||||
DMA channel is acquired — record_end stays here.
|
||||
"""
|
||||
yield from self._do_pipeline_dma(env, token)
|
||||
self._on_process_end(env, token)
|
||||
|
||||
# Self-routing (handle same-component consecutive stages)
|
||||
next_stage = token.advance()
|
||||
while next_stage is not None and next_stage.component == self.node.id:
|
||||
self._on_process_start(env, token)
|
||||
yield from self._do_pipeline_dma(env, token)
|
||||
self._on_process_end(env, token)
|
||||
next_stage = token.advance()
|
||||
@@ -278,19 +339,33 @@ class PeDmaComponent(PeEngineBase):
|
||||
dma_res = self._dma_write if is_write else self._dma_read
|
||||
assert dma_res is not None
|
||||
|
||||
pa = PhysAddr.decode(addr)
|
||||
# Translate VA → PA via MMU (same logic as non-pipeline path)
|
||||
target_pa = addr
|
||||
if self._mmu is not None:
|
||||
from kernbench.policy.address.pe_mmu import PageFault
|
||||
try:
|
||||
target_pa = self._mmu.translate(addr)
|
||||
except PageFault:
|
||||
target_pa = addr # fallback: treat as PA directly
|
||||
|
||||
pa = PhysAddr.decode(target_pa)
|
||||
dst_node = self.ctx.resolver.resolve(pa)
|
||||
path = self.ctx.router.find_path(self._pe_prefix, dst_node)
|
||||
drain_ns = self.ctx.compute_drain_ns(path, nbytes)
|
||||
|
||||
# Hold dma_res through the full round-trip — one DMA in flight
|
||||
# per PE per direction — so Option B's t_start (post-acquire)
|
||||
# bounds the actual transfer interval.
|
||||
with dma_res.request() as req:
|
||||
yield req
|
||||
# Option B: t_start = post-acquire moment.
|
||||
self._on_process_start(env, token)
|
||||
sub_done = env.event()
|
||||
sub_request = PeDmaMsg(
|
||||
correlation_id="pipeline",
|
||||
request_id=f"tile_{token.tile_id}",
|
||||
src_sip=0, src_cube=0, src_pe=0,
|
||||
dst_pa=addr, nbytes=nbytes,
|
||||
dst_pa=target_pa, nbytes=nbytes,
|
||||
is_write=is_write,
|
||||
)
|
||||
sub_txn = Transaction(
|
||||
@@ -299,8 +374,11 @@ class PeDmaComponent(PeEngineBase):
|
||||
)
|
||||
if len(path) > 1:
|
||||
yield self.out_ports[path[1]].put(sub_txn.advance())
|
||||
|
||||
yield sub_done
|
||||
else:
|
||||
# No-op (nbytes==0 or no ctx): no channel wait, but still record
|
||||
# so _on_process_end has a matching pending entry to finalise.
|
||||
self._on_process_start(env, token)
|
||||
|
||||
def _forward_txn(self, env: simpy.Environment, txn: Any) -> Generator:
|
||||
"""Handle external Transaction (PeDmaMsg probe, M_CPU DMA) with channel acquisition."""
|
||||
|
||||
@@ -329,6 +329,41 @@ class PeIpcqComponent(ComponentBase):
|
||||
|
||||
qp["my_tail"] += 1
|
||||
|
||||
# ADR-0023 D9.7: charge IPCQ slot-READ latency against the
|
||||
# backing-memory tier (tcm/sram/hbm). Recv blocks for the
|
||||
# kernel-side slot consume; pe_exec_ns reflects this cost.
|
||||
# SRAM/HBM live on the cube NoC behind a router-attached link,
|
||||
# so reading a slot also pays a PE→bank fabric drain. TCM is
|
||||
# per-PE local and skips this hop.
|
||||
#
|
||||
# cmd.consume is a DIAGNOSTIC flag (default True). When False,
|
||||
# the read charges below are skipped — used only by the pe2pe
|
||||
# overview plot for an apples-to-apples comparison against
|
||||
# tl.store (one-sided write, no read on DST). Real kernels
|
||||
# always consume; this branch must not be exercised in
|
||||
# production code paths.
|
||||
from kernbench.common.ipcq_types import slot_io_latency_ns
|
||||
nbytes = req.result_data.get("nbytes", 0)
|
||||
if cmd.consume:
|
||||
if self._buffer_kind in ("sram", "hbm") and self.ctx is not None:
|
||||
cube_prefix = self._pe_prefix.rsplit(".", 1)[0]
|
||||
bank_node = (
|
||||
f"{cube_prefix}.sram" if self._buffer_kind == "sram"
|
||||
else f"{cube_prefix}.hbm_ctrl"
|
||||
)
|
||||
try:
|
||||
path = self.ctx.router.find_path(
|
||||
self._pe_prefix, bank_node,
|
||||
)
|
||||
bank_drain_ns = self.ctx.compute_drain_ns(path, nbytes)
|
||||
if bank_drain_ns > 0:
|
||||
yield env.timeout(bank_drain_ns)
|
||||
except Exception:
|
||||
pass
|
||||
slot_read_ns = slot_io_latency_ns(self._buffer_kind, nbytes)
|
||||
if slot_read_ns > 0:
|
||||
yield env.timeout(slot_read_ns)
|
||||
|
||||
# Diagnostics trace (D14)
|
||||
from kernbench.ccl import diagnostics
|
||||
if diagnostics.trace_enabled():
|
||||
@@ -338,9 +373,13 @@ class PeIpcqComponent(ComponentBase):
|
||||
nbytes=req.result_data.get("nbytes", 0),
|
||||
)
|
||||
|
||||
# Fast path credit return — bottleneck BW based latency
|
||||
env.process(
|
||||
self._delayed_credit_send(env, direction, qp["peer_credit_store"], qp["my_tail"])
|
||||
# Credit return: recv blocks on credit-emit so the protocol cost
|
||||
# (full path latency to deliver the credit metadata back to the
|
||||
# sender) is reflected in the recv's pe_exec_ns. Models the IPCQ
|
||||
# control-plane completing the consume-acknowledgement before
|
||||
# recv returns to the kernel.
|
||||
yield from self._delayed_credit_send(
|
||||
env, direction, qp["peer_credit_store"], qp["my_tail"],
|
||||
)
|
||||
|
||||
if not req.done.triggered:
|
||||
@@ -455,7 +494,12 @@ class PeIpcqComponent(ComponentBase):
|
||||
yield peer_credit_store.put(meta)
|
||||
|
||||
def _credit_latency_ns(self, direction: str) -> float:
|
||||
"""Compute credit fast path latency = credit_size / bottleneck_bw.
|
||||
"""Full path latency for the credit-return packet.
|
||||
|
||||
Pays per-node overhead + edge prop + drain along the same fabric
|
||||
the data took. PathRouter.find_path() auto-appends ".pe_dma" to
|
||||
the source only, so the destination MUST be spelled with the
|
||||
explicit ".pe_dma" suffix.
|
||||
|
||||
Falls back to 0 when ctx/router is unavailable (unit-test mode).
|
||||
"""
|
||||
@@ -463,10 +507,12 @@ class PeIpcqComponent(ComponentBase):
|
||||
return 0.0
|
||||
qp = self._queue_pairs[direction]
|
||||
peer = qp["peer"]
|
||||
peer_pe_prefix = f"sip{peer.sip}.cube{peer.cube}.pe{peer.pe}"
|
||||
peer_pe_dma = f"sip{peer.sip}.cube{peer.cube}.pe{peer.pe}.pe_dma"
|
||||
try:
|
||||
path = self.ctx.router.find_path(self._pe_prefix, peer_pe_prefix)
|
||||
return self.ctx.compute_drain_ns(path, self._credit_size_bytes)
|
||||
path = self.ctx.router.find_path(self._pe_prefix, peer_pe_dma)
|
||||
return self.ctx.compute_path_latency_ns(
|
||||
path, self._credit_size_bytes,
|
||||
)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
@@ -163,6 +163,8 @@ class PeSchedulerComponent(ComponentBase):
|
||||
bytes_per_element=bpe,
|
||||
A_addr=a.addr, B_addr=b.addr, C_addr=cmd.out_addr,
|
||||
pe_prefix=pp,
|
||||
a_pinned=getattr(a, "pinned", False),
|
||||
b_pinned=getattr(b, "pinned", False),
|
||||
)
|
||||
else:
|
||||
# Math composite
|
||||
|
||||
@@ -21,15 +21,22 @@ def generate_gemm_plan(
|
||||
bytes_per_element: int,
|
||||
A_addr: int, B_addr: int, C_addr: int,
|
||||
pe_prefix: str,
|
||||
a_pinned: bool = False,
|
||||
b_pinned: bool = False,
|
||||
) -> PipelinePlan:
|
||||
"""Generate GEMM tile plan: M→N→K order.
|
||||
|
||||
Each tile follows stage sequence:
|
||||
DMA_READ(A) → DMA_READ(B) → FETCH → GEMM → STORE
|
||||
On last K-tile per (m,n): → DMA_WRITE
|
||||
[DMA_READ(A)] → [DMA_READ(B)] → FETCH → GEMM → [STORE → DMA_WRITE]
|
||||
DMA_READ(A) skipped when a_pinned=True (operand pre-staged in TCM).
|
||||
DMA_READ(B) skipped when b_pinned=True.
|
||||
STORE + DMA_WRITE only emitted on last K-tile per (m,n) — accumulator
|
||||
stays in RegFile across K loop.
|
||||
|
||||
Args:
|
||||
pe_prefix: e.g. "sip0.cube0.pe0" — used to build component IDs.
|
||||
a_pinned: A operand already resident in TCM (via prior tl.load).
|
||||
b_pinned: B operand already resident in TCM.
|
||||
"""
|
||||
M_tiles = max(1, ceil(M / tile_m))
|
||||
K_tiles = max(1, ceil(K / tile_k))
|
||||
@@ -58,7 +65,9 @@ def generate_gemm_plan(
|
||||
|
||||
stages: list[Stage] = []
|
||||
|
||||
# DMA READ: load A and B tiles from HBM → TCM
|
||||
# DMA READ: load A and B tiles from HBM → TCM.
|
||||
# Skip if the operand is already pre-staged via tl.load.
|
||||
if not a_pinned:
|
||||
stages.append(Stage(
|
||||
stage_type=StageType.DMA_READ,
|
||||
component=dma_id,
|
||||
@@ -67,6 +76,7 @@ def generate_gemm_plan(
|
||||
"operand": "A", "tile_m": tile_m, "tile_k": tile_k,
|
||||
},
|
||||
))
|
||||
if not b_pinned:
|
||||
stages.append(Stage(
|
||||
stage_type=StageType.DMA_READ,
|
||||
component=dma_id,
|
||||
@@ -96,7 +106,9 @@ def generate_gemm_plan(
|
||||
},
|
||||
))
|
||||
|
||||
# STORE: Register File → TCM
|
||||
# STORE + DMA_WRITE only on last K-tile per (m,n). The C
|
||||
# accumulator stays in RegFile across the K loop.
|
||||
if last_k:
|
||||
stages.append(Stage(
|
||||
stage_type=StageType.STORE,
|
||||
component=fetch_id,
|
||||
@@ -105,9 +117,6 @@ def generate_gemm_plan(
|
||||
"nbytes": out_bytes,
|
||||
},
|
||||
))
|
||||
|
||||
# DMA WRITE: TCM → HBM (only on last K-tile)
|
||||
if last_k:
|
||||
stages.append(Stage(
|
||||
stage_type=StageType.DMA_WRITE,
|
||||
component=dma_id,
|
||||
|
||||
@@ -26,6 +26,9 @@ class ComponentContext:
|
||||
spec: dict = field(default_factory=dict) # topology spec (cube layout, PE count, etc.)
|
||||
memory_store: Any = None # MemoryStore for Phase 1 data-aware execution (ADR-0020)
|
||||
op_logger: Any = None # OpLogger for Phase 1 op recording (ADR-0020)
|
||||
# node_id -> overhead_ns (ADR-0009 D5: used by M_CPU to compute per-PE
|
||||
# dispatch latency when stamping target_start_ns on KernelLaunchMsg).
|
||||
node_overhead_ns: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
def get_shared_resource(
|
||||
self, env: simpy.Environment, key: str, capacity: int = 1,
|
||||
@@ -52,3 +55,19 @@ class ComponentContext:
|
||||
if min_bw == float("inf"):
|
||||
return 0.0
|
||||
return nbytes / min_bw
|
||||
|
||||
def compute_path_latency_ns(self, path: list[str], nbytes: int = 0) -> float:
|
||||
"""Formula latency along path: wire + per-node overhead + drain.
|
||||
|
||||
ADR-0009 D5: M_CPU uses this to compute per-PE dispatch latency
|
||||
when stamping target_start_ns on KernelLaunchMsg fanout.
|
||||
"""
|
||||
total = 0.0
|
||||
for i in range(len(path) - 1):
|
||||
edge = self.edge_map.get((path[i], path[i + 1]))
|
||||
if edge:
|
||||
total += edge.distance_mm * self.ns_per_mm
|
||||
for node_id in path:
|
||||
total += self.node_overhead_ns.get(node_id, 0.0)
|
||||
total += self.compute_drain_ns(path, nbytes)
|
||||
return total
|
||||
|
||||
@@ -58,7 +58,13 @@ class IoCpuComponent(ComponentBase):
|
||||
self._pending[key] = (expected, received, parent_done)
|
||||
|
||||
def _dispatch_to_m_cpus(self, env: simpy.Environment, txn: Any) -> Generator:
|
||||
"""Fan out sub-Transactions to target cube M_CPUs, wait for responses."""
|
||||
"""Fan out sub-Transactions to target cube M_CPUs, wait for responses.
|
||||
|
||||
ADR-0009 D5 (extended): stamp a global target_start_ns on
|
||||
KernelLaunchMsg so every PE across every target cube starts at
|
||||
the same env.now. See the non-legacy builtin for full rationale.
|
||||
"""
|
||||
import dataclasses
|
||||
from kernbench.runtime_api.kernel import KernelLaunchMsg, MemoryReadMsg, MemoryWriteMsg
|
||||
|
||||
request = txn.request
|
||||
@@ -72,10 +78,53 @@ class IoCpuComponent(ComponentBase):
|
||||
txn.done.succeed()
|
||||
return
|
||||
|
||||
if isinstance(request, KernelLaunchMsg):
|
||||
io_overhead = self.ctx.node_overhead_ns.get(self.node.id, 0.0)
|
||||
global_max_latency = 0.0
|
||||
pe_ids = self._resolve_pe_ids(
|
||||
getattr(request, "target_pe", "all")
|
||||
)
|
||||
for sip, cube in cube_targets:
|
||||
try:
|
||||
m_cpu_id = self.ctx.resolver.find_m_cpu(sip, cube)
|
||||
io_to_m_path = self.ctx.router.find_node_path(
|
||||
self.node.id, m_cpu_id,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if len(io_to_m_path) < 2:
|
||||
continue
|
||||
leg1 = self.ctx.compute_path_latency_ns(
|
||||
io_to_m_path, nbytes=0,
|
||||
)
|
||||
m_overhead = self.ctx.node_overhead_ns.get(m_cpu_id, 0.0)
|
||||
for pe_id in pe_ids:
|
||||
pe_cpu_id = (
|
||||
f"sip{sip}.cube{cube}.pe{pe_id}.pe_cpu"
|
||||
)
|
||||
try:
|
||||
m_to_pe_path = self.ctx.router.find_node_path(
|
||||
m_cpu_id, pe_cpu_id,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if len(m_to_pe_path) < 2:
|
||||
continue
|
||||
leg2 = self.ctx.compute_path_latency_ns(
|
||||
m_to_pe_path, nbytes=0,
|
||||
)
|
||||
latency = leg1 + leg2 - io_overhead - m_overhead
|
||||
if latency > global_max_latency:
|
||||
global_max_latency = latency
|
||||
request = dataclasses.replace(
|
||||
request,
|
||||
target_start_ns=float(env.now) + global_max_latency,
|
||||
)
|
||||
|
||||
# Setup aggregation
|
||||
self._pending[request.request_id] = (len(cube_targets), 0, txn.done)
|
||||
|
||||
# Fan out to each target cube's M_CPU
|
||||
is_kernel_launch = isinstance(request, KernelLaunchMsg)
|
||||
for sip, cube in cube_targets:
|
||||
try:
|
||||
m_cpu_id = self.ctx.resolver.find_m_cpu(sip, cube)
|
||||
@@ -86,11 +135,24 @@ class IoCpuComponent(ComponentBase):
|
||||
continue
|
||||
sub_txn = Transaction(
|
||||
request=request, path=path, step=0,
|
||||
nbytes=txn.nbytes, done=env.event(),
|
||||
nbytes=0 if is_kernel_launch else txn.nbytes,
|
||||
done=env.event(),
|
||||
result_data=txn.result_data,
|
||||
)
|
||||
yield self.out_ports[path[1]].put(sub_txn.advance())
|
||||
|
||||
def _resolve_pe_ids(self, target_pe: Any) -> list[int]:
|
||||
"""Resolve target_pe → list of PE indices (mirrors M_CPU logic)."""
|
||||
if isinstance(target_pe, int):
|
||||
return [target_pe]
|
||||
if isinstance(target_pe, tuple):
|
||||
return list(target_pe)
|
||||
n_slices = 8
|
||||
if self.ctx and self.ctx.spec:
|
||||
mm = self.ctx.spec.get("cube", {}).get("memory_map", {})
|
||||
n_slices = mm.get("hbm_slices_per_cube", 8)
|
||||
return list(range(n_slices))
|
||||
|
||||
def _resolve_cube_targets(self, request: Any) -> list[tuple[int, int]]:
|
||||
"""Return list of (sip, cube) pairs to fan out to."""
|
||||
from kernbench.runtime_api.kernel import (
|
||||
@@ -145,10 +207,10 @@ class IoCpuComponent(ComponentBase):
|
||||
return []
|
||||
|
||||
def _cube_from_pa(self, pa_val: int, fallback: int) -> int:
|
||||
"""Extract cube_id from a physical address, with fallback."""
|
||||
"""Extract die_id from a physical address, with fallback."""
|
||||
from kernbench.policy.address.phyaddr import PhysAddr
|
||||
try:
|
||||
return PhysAddr.decode(pa_val).cube_id
|
||||
return PhysAddr.decode(pa_val).die_id
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
@@ -162,7 +162,11 @@ class MCpuComponent(ComponentBase):
|
||||
Routes through find_node_path (M_CPU → NOC → PE_CPU command edges).
|
||||
PE_CPU sends ResponseMsg back via NOC → M_CPU on completion.
|
||||
Then sends aggregate ResponseMsg back to IO_CPU on the reverse path.
|
||||
|
||||
ADR-0009 D5: stamps target_start_ns so every PE in this fanout
|
||||
starts executing at the same env.now regardless of dispatch path.
|
||||
"""
|
||||
import dataclasses
|
||||
request = txn.request
|
||||
target_pe = getattr(request, "target_pe", "all")
|
||||
cube_prefix = self.node.id.rsplit(".", 1)[0] # e.g. "sip0.cube0"
|
||||
@@ -172,9 +176,10 @@ class MCpuComponent(ComponentBase):
|
||||
txn.done.succeed()
|
||||
return
|
||||
|
||||
# Fan out to each PE_CPU, using response-based aggregation
|
||||
sub_txns: list[Transaction] = []
|
||||
n_dispatched = 0
|
||||
# Resolve per-PE paths. If IO_CPU already stamped a global
|
||||
# target_start_ns (ADR-0009 D5 extended), pass it through.
|
||||
per_pe: list[tuple[int, list[str], float]] = []
|
||||
max_latency = 0.0
|
||||
for pe_id in pe_ids:
|
||||
pe_cpu_id = f"{cube_prefix}.pe{pe_id}.pe_cpu"
|
||||
try:
|
||||
@@ -183,8 +188,24 @@ class MCpuComponent(ComponentBase):
|
||||
continue
|
||||
if len(path) < 2:
|
||||
continue
|
||||
latency = self.ctx.compute_path_latency_ns(path, nbytes=0)
|
||||
per_pe.append((pe_id, path, latency))
|
||||
if latency > max_latency:
|
||||
max_latency = latency
|
||||
|
||||
if getattr(request, "target_start_ns", None) is not None:
|
||||
stamped_request = request
|
||||
else:
|
||||
stamped_request = dataclasses.replace(
|
||||
request, target_start_ns=float(env.now) + max_latency,
|
||||
)
|
||||
|
||||
# Fan out to each PE_CPU, using response-based aggregation
|
||||
sub_txns: list[Transaction] = []
|
||||
n_dispatched = 0
|
||||
for pe_id, path, _lat in per_pe:
|
||||
sub_txn = Transaction(
|
||||
request=request, path=path, step=0,
|
||||
request=stamped_request, path=path, step=0,
|
||||
nbytes=0, done=env.event(),
|
||||
)
|
||||
yield self.out_ports[path[1]].put(sub_txn.advance())
|
||||
@@ -204,16 +225,21 @@ class MCpuComponent(ComponentBase):
|
||||
yield all_done
|
||||
del self._parent_txns[request.request_id]
|
||||
|
||||
# Aggregate PE-internal metrics (max across PEs)
|
||||
# Aggregate PE-internal metrics (max across PEs and across cubes).
|
||||
# Multiple M_CPUs share the same result_data dict via IO_CPU fanout;
|
||||
# merge against the existing value so cubes don't clobber each other.
|
||||
pe_exec_values = [st.result_data.get("pe_exec_ns", 0.0) for st in sub_txns]
|
||||
if pe_exec_values:
|
||||
txn.result_data["pe_exec_ns"] = max(pe_exec_values)
|
||||
cur = txn.result_data.get("pe_exec_ns", 0.0) or 0.0
|
||||
txn.result_data["pe_exec_ns"] = max(cur, max(pe_exec_values))
|
||||
dma_values = [st.result_data.get("dma_ns", 0.0) for st in sub_txns]
|
||||
if dma_values:
|
||||
txn.result_data["dma_ns"] = max(dma_values)
|
||||
cur = txn.result_data.get("dma_ns", 0.0) or 0.0
|
||||
txn.result_data["dma_ns"] = max(cur, max(dma_values))
|
||||
compute_values = [st.result_data.get("compute_ns", 0.0) for st in sub_txns]
|
||||
if compute_values:
|
||||
txn.result_data["compute_ns"] = max(compute_values)
|
||||
cur = txn.result_data.get("compute_ns", 0.0) or 0.0
|
||||
txn.result_data["compute_ns"] = max(cur, max(compute_values))
|
||||
|
||||
# Send aggregate response on reverse command path back to IO_CPU
|
||||
reverse_path = list(reversed(txn.path))
|
||||
|
||||
@@ -71,6 +71,13 @@ class PeCpuComponent(ComponentBase):
|
||||
request = txn.request
|
||||
yield from self.run(env, 0)
|
||||
|
||||
# ADR-0009 D5: synchronized launch barrier. If M_CPU stamped a
|
||||
# target_start_ns, wait until then so every PE in this launch
|
||||
# begins pe_exec measurement at the same simulated time.
|
||||
target_start = getattr(request, "target_start_ns", None)
|
||||
if target_start is not None and target_start > env.now:
|
||||
yield env.timeout(float(target_start) - env.now)
|
||||
|
||||
kernel_fn = get_kernel(request.kernel_ref.name)
|
||||
num_programs = self._derive_num_programs(request)
|
||||
kernel_args = self._unpack_kernel_args(request)
|
||||
|
||||
@@ -89,11 +89,10 @@ class _FreeList:
|
||||
|
||||
class PEMemAllocator:
|
||||
def __init__(
|
||||
self, rack_id: int, sip_id: int, cube_id: int, pe_id: int, cfg: AddressConfig,
|
||||
self, sip_id: int, die_id: int, pe_id: int, cfg: AddressConfig,
|
||||
) -> None:
|
||||
self._rack_id = rack_id
|
||||
self._sip_id = sip_id
|
||||
self._cube_id = cube_id
|
||||
self._die_id = die_id
|
||||
self._pe_id = pe_id
|
||||
self._cfg = cfg
|
||||
self._hbm = _FreeList(cfg.hbm_slice_bytes)
|
||||
@@ -108,7 +107,7 @@ class PEMemAllocator:
|
||||
f"available {self._cfg.hbm_slice_bytes - self._hbm.used}"
|
||||
)
|
||||
return PhysAddr.pe_hbm_addr(
|
||||
rack_id=self._rack_id, sip_id=self._sip_id, cube_id=self._cube_id,
|
||||
sip_id=self._sip_id, die_id=self._die_id,
|
||||
pe_id=self._pe_id, pe_local_hbm_offset=offset,
|
||||
slice_size_bytes=self._cfg.hbm_slice_bytes,
|
||||
)
|
||||
@@ -128,7 +127,7 @@ class PEMemAllocator:
|
||||
f"available {self._cfg.tcm_allocatable_bytes - self._tcm.used}"
|
||||
)
|
||||
return PhysAddr.pe_tcm_addr(
|
||||
rack_id=self._rack_id, sip_id=self._sip_id, cube_id=self._cube_id,
|
||||
sip_id=self._sip_id, die_id=self._die_id,
|
||||
pe_id=self._pe_id, tcm_offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ class PageFault(Exception):
|
||||
|
||||
|
||||
class PeMMU:
|
||||
"""Per-PE MMU with page-aligned VA→PA translation table.
|
||||
"""Per-PE MMU with sub-page-capable VA→PA translation table.
|
||||
|
||||
Each page-table entry is a list of (start_in_page, end_in_page,
|
||||
pa_at_offset_zero) regions. This is a SIMULATOR STOPGAP — real MMUs
|
||||
store one PA per page-table entry. Sub-page regions exist here so
|
||||
DPPolicy layouts that shard below page granularity (e.g. 128 B
|
||||
payloads with 4 KB pages) don't silently mis-route through last-
|
||||
write-wins overwrites. Memory note: project_mmu_subpage_stopgap.md.
|
||||
|
||||
Args:
|
||||
page_size: Page size in bytes (default 2 MB).
|
||||
@@ -34,7 +41,11 @@ class PeMMU:
|
||||
self._page_size = page_size
|
||||
self._page_shift = (page_size - 1).bit_length()
|
||||
self._page_mask = page_size - 1
|
||||
self._table: dict[int, int] = {} # va_page_number → pa_page_base
|
||||
# vpn → list of (start_in_page, end_in_page, pa_at_offset_zero).
|
||||
# pa_at_offset_zero is the PA that offset 0 of the page would map
|
||||
# to under this region — i.e. translate(off) = pa_at_offset_zero
|
||||
# + off when start <= off < end.
|
||||
self._table: dict[int, list[tuple[int, int, int]]] = {}
|
||||
self._overhead_ns = overhead_ns
|
||||
|
||||
@property
|
||||
@@ -46,21 +57,67 @@ class PeMMU:
|
||||
return len(self._table)
|
||||
|
||||
def map(self, va: int, pa: int, size: int) -> None:
|
||||
"""Register VA→PA mapping for a contiguous range."""
|
||||
for off in range(0, size, self._page_size):
|
||||
vpn = (va + off) >> self._page_shift
|
||||
self._table[vpn] = pa + off
|
||||
"""Register VA→PA mapping for a contiguous range.
|
||||
|
||||
Sub-page-aware: a single page can hold multiple disjoint regions,
|
||||
each pointing to a different PA. Later map() calls APPEND a new
|
||||
region; on overlap with an existing region, the new region wins
|
||||
for the overlapping offsets (translate iterates in reverse so the
|
||||
last write takes precedence — matches legacy single-PA behavior
|
||||
when a full page is re-mapped).
|
||||
"""
|
||||
end_va = va + size
|
||||
cur = va
|
||||
while cur < end_va:
|
||||
vpn = cur >> self._page_shift
|
||||
page_base_va = vpn << self._page_shift
|
||||
page_end_va = page_base_va + self._page_size
|
||||
region_start = cur - page_base_va
|
||||
region_end = min(end_va, page_end_va) - page_base_va
|
||||
# PA seen at offset 0 of page if this region's mapping covered it
|
||||
pa_at_offset_zero = pa + (cur - va) - region_start
|
||||
self._table.setdefault(vpn, []).append(
|
||||
(region_start, region_end, pa_at_offset_zero)
|
||||
)
|
||||
cur = page_base_va + region_end
|
||||
|
||||
def unmap(self, va: int, size: int) -> None:
|
||||
"""Remove VA mapping for a contiguous range."""
|
||||
for off in range(0, size, self._page_size):
|
||||
vpn = (va + off) >> self._page_shift
|
||||
self._table.pop(vpn, None)
|
||||
"""Remove VA mapping for a contiguous range.
|
||||
|
||||
Drops any region whose extent is contained within the unmapped
|
||||
range. Partial overlaps (region straddles the range boundary)
|
||||
are left in place — caller is expected to unmap on the same
|
||||
boundaries it mapped on.
|
||||
"""
|
||||
end_va = va + size
|
||||
cur = va
|
||||
while cur < end_va:
|
||||
vpn = cur >> self._page_shift
|
||||
page_base_va = vpn << self._page_shift
|
||||
page_end_va = page_base_va + self._page_size
|
||||
unmap_start = cur - page_base_va
|
||||
unmap_end = min(end_va, page_end_va) - page_base_va
|
||||
regions = self._table.get(vpn)
|
||||
if regions is not None:
|
||||
kept = [
|
||||
r for r in regions
|
||||
if not (r[0] >= unmap_start and r[1] <= unmap_end)
|
||||
]
|
||||
if kept:
|
||||
self._table[vpn] = kept
|
||||
else:
|
||||
del self._table[vpn]
|
||||
cur = page_base_va + unmap_end
|
||||
|
||||
def translate(self, va: int) -> int:
|
||||
"""Translate VA to PA. Raises PageFault if unmapped."""
|
||||
vpn = va >> self._page_shift
|
||||
pa_page_base = self._table.get(vpn)
|
||||
if pa_page_base is None:
|
||||
regions = self._table.get(vpn)
|
||||
if regions is None:
|
||||
raise PageFault(va)
|
||||
offset = va & self._page_mask
|
||||
# Iterate latest-first so newer map() calls win on overlap
|
||||
for start, end, pa_at_offset_zero in reversed(regions):
|
||||
if start <= offset < end:
|
||||
return pa_at_offset_zero + offset
|
||||
raise PageFault(va)
|
||||
return pa_page_base + (va & self._page_mask)
|
||||
|
||||
@@ -6,6 +6,47 @@ from typing import Literal
|
||||
|
||||
MAX_51 = (1 << 51) - 1
|
||||
|
||||
# ── Layout constants (ADR-0001 Rev 2) ────────────────────────────────
|
||||
# [50:47] sip_id (4)
|
||||
# [46:42] die_id (5)
|
||||
# [41: 0] local_offset (42)
|
||||
_SIP_SHIFT = 47
|
||||
_DIE_SHIFT = 42
|
||||
_LOCAL_BITS = 42
|
||||
_LOCAL_MASK = (1 << _LOCAL_BITS) - 1
|
||||
|
||||
# AHBM die: [41:38] MBZ, [37] addr_space, [36:0] sub-address
|
||||
_AHBM_SEL_BIT = 37
|
||||
_AHBM_LOCAL_USED = 38 # bits actually meaningful for AHBM
|
||||
|
||||
# Resource window: [36:34] resource_kind, [33:0] kind_local
|
||||
_RES_KIND_SHIFT = 34
|
||||
_RES_KIND_MASK = 0x7
|
||||
|
||||
# PE_LOCAL: [32:29] pe_id, [28:25] pe_sub_unit, [24:0] sub_offset
|
||||
_PE_ID_SHIFT = 29
|
||||
_PE_SUB_SHIFT = 25
|
||||
_PE_SUB_OFFSET_BITS = 25
|
||||
|
||||
# MCPU_LOCAL: [29:25] mcpu_sub_unit, [24:0] sub_offset
|
||||
_MCPU_SUB_SHIFT = 25
|
||||
|
||||
# CUBE_SRAM: [24:0] sram_offset
|
||||
_SRAM_OFFSET_BITS = 25
|
||||
|
||||
# IOCHIPLET: [41:40] MBZ, [39:0] chiplet_offset
|
||||
_CHIPLET_LOCAL_BITS = 40
|
||||
_IOCPU_BOUNDARY = 1 << 31 # 2 GB
|
||||
|
||||
# IOCPU: [30:27] iocpu_sub_unit, [26:0] sub_offset
|
||||
_IOCPU_SUB_SHIFT = 27
|
||||
_IOCPU_SUB_OFFSET_BITS = 27
|
||||
|
||||
# die_id ranges
|
||||
_AHBM_DIE_MAX = 15
|
||||
_CHIPLET_DIE_MIN = 16
|
||||
_CHIPLET_DIE_MAX = 20
|
||||
|
||||
|
||||
class PhysAddrError(Exception):
|
||||
pass
|
||||
@@ -22,163 +63,278 @@ def _chk_max(name: str, v: int, maxv: int) -> None:
|
||||
|
||||
|
||||
class UnitType(IntEnum):
|
||||
PE = 0
|
||||
MCPU = 1
|
||||
SRAM = 2
|
||||
"""resource_kind values for AHBM resource window."""
|
||||
PE = 0 # PE_LOCAL
|
||||
MCPU = 1 # MCPU_LOCAL
|
||||
SRAM = 2 # CUBE_SRAM
|
||||
|
||||
|
||||
class PESubUnit(IntEnum):
|
||||
PE_CPU_DTCM = 0
|
||||
MATH_ENGINE_DTCM = 1
|
||||
IPCQ = 2
|
||||
PE_CPU_SFR = 3
|
||||
MATH_ENGINE_SFR = 4
|
||||
DMA_ENGINE_SFR = 5
|
||||
PE_TCM = 6
|
||||
|
||||
|
||||
class MCPUSubUnit(IntEnum):
|
||||
MCPU_ITCM = 0
|
||||
MCPU_DTCM = 1
|
||||
IPCQ = 2
|
||||
MCPU_SFR = 3
|
||||
MCPU_DMA_SFR = 4
|
||||
MCPU_SRAM = 5
|
||||
|
||||
|
||||
class IOCPUSubUnit(IntEnum):
|
||||
IOCPU_ITCM = 0
|
||||
IOCPU_DTCM = 1
|
||||
IPCQ = 2
|
||||
IOCPU_SFR = 3
|
||||
IO_DMA_SFR = 4
|
||||
IO_SRAM = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhysAddr:
|
||||
"""
|
||||
51-bit physical address value object.
|
||||
"""51-bit physical address value object (ADR-0001 Rev 2).
|
||||
|
||||
Layout:
|
||||
[50:47] rack_id (4)
|
||||
[46:43] sip_id (4)
|
||||
[42:38] sip_seg (5) # cube_id
|
||||
[37:0] local_offset (38) => each segment is 256GB
|
||||
|
||||
local_offset:
|
||||
[37] selector: 1 = HBM window (128GB reserved), 0 = PE resource window
|
||||
[50:47] sip_id (4) -- 16 SIPs
|
||||
[46:42] die_id (5) -- 0..15 AHBM, 16..20 IOCHIPLET
|
||||
[41: 0] local_offset (42) -- 4 TB per die
|
||||
"""
|
||||
|
||||
rack_id: int
|
||||
sip_id: int
|
||||
sip_seg: int
|
||||
die_id: int
|
||||
local_offset: int
|
||||
|
||||
kind: Literal["hbm", "pe_resource", "raw"] = "raw"
|
||||
cube_id: int = 0
|
||||
kind: Literal["hbm", "pe_resource", "iocpu", "ual", "raw"] = "raw"
|
||||
unit_type: UnitType = UnitType.PE
|
||||
pe_id: int = 0
|
||||
ext: int = 0
|
||||
pe_sub_unit: int = 0
|
||||
sub_offset: int = 0
|
||||
hbm_offset: int = 0
|
||||
iocpu_sub_unit: int = 0
|
||||
chiplet_offset: int = 0
|
||||
mcpu_sub_unit: int = 0
|
||||
|
||||
HBM_WINDOW_BYTES = 1 << 37 # 128 GB
|
||||
|
||||
# ── encode / decode ──────────────────────────────────────────────
|
||||
|
||||
def encode(self) -> int:
|
||||
_chk_range("rack_id", self.rack_id, 4)
|
||||
_chk_range("sip_id", self.sip_id, 4)
|
||||
_chk_range("sip_seg", self.sip_seg, 5)
|
||||
_chk_range("local_offset", self.local_offset, 38)
|
||||
addr = (self.rack_id << 47) | (self.sip_id << 43) | (self.sip_seg << 38) | self.local_offset
|
||||
if not (0 <= addr <= MAX_51):
|
||||
raise PhysAddrError("address exceeds 51-bit space")
|
||||
_chk_range("die_id", self.die_id, 5)
|
||||
_chk_range("local_offset", self.local_offset, _LOCAL_BITS)
|
||||
# MBZ enforcement
|
||||
if self.die_id <= _AHBM_DIE_MAX:
|
||||
mbz_top = (self.local_offset >> _AHBM_LOCAL_USED) & 0xF
|
||||
if mbz_top != 0:
|
||||
raise PhysAddrError("AHBM local_offset bits [41:38] must be zero")
|
||||
elif _CHIPLET_DIE_MIN <= self.die_id <= _CHIPLET_DIE_MAX:
|
||||
mbz_top = (self.local_offset >> _CHIPLET_LOCAL_BITS) & 0x3
|
||||
if mbz_top != 0:
|
||||
raise PhysAddrError("IOCHIPLET local_offset bits [41:40] must be zero")
|
||||
addr = (self.sip_id << _SIP_SHIFT) | (self.die_id << _DIE_SHIFT) | self.local_offset
|
||||
return addr
|
||||
|
||||
@staticmethod
|
||||
def decode(addr: int) -> PhysAddr:
|
||||
if not (0 <= addr <= MAX_51):
|
||||
raise PhysAddrError("addr must be a 51-bit value")
|
||||
rack = (addr >> 47) & 0xF
|
||||
sip_id = (addr >> 43) & 0xF
|
||||
sip_seg = (addr >> 38) & 0x1F
|
||||
off = addr & ((1 << 38) - 1)
|
||||
cube_id = sip_seg
|
||||
sel = (off >> 37) & 0x1
|
||||
sip_id = (addr >> _SIP_SHIFT) & 0xF
|
||||
die_id = (addr >> _DIE_SHIFT) & 0x1F
|
||||
local_offset = addr & _LOCAL_MASK
|
||||
|
||||
if die_id <= _AHBM_DIE_MAX:
|
||||
return PhysAddr._decode_ahbm(sip_id, die_id, local_offset)
|
||||
elif _CHIPLET_DIE_MIN <= die_id <= _CHIPLET_DIE_MAX:
|
||||
return PhysAddr._decode_chiplet(sip_id, die_id, local_offset)
|
||||
else:
|
||||
raise PhysAddrError(f"die_id {die_id} is reserved (21..31)")
|
||||
|
||||
@staticmethod
|
||||
def _decode_ahbm(sip_id: int, die_id: int, local_offset: int) -> PhysAddr:
|
||||
sel = (local_offset >> _AHBM_SEL_BIT) & 0x1
|
||||
if sel == 1:
|
||||
hbm_offset = int(off & ((1 << 37) - 1))
|
||||
hbm_offset = int(local_offset & ((1 << _AHBM_SEL_BIT) - 1))
|
||||
return PhysAddr(
|
||||
rack_id=rack,
|
||||
sip_id=sip_id,
|
||||
sip_seg=sip_seg,
|
||||
local_offset=off,
|
||||
kind="hbm",
|
||||
cube_id=cube_id,
|
||||
hbm_offset=hbm_offset,
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="hbm", hbm_offset=hbm_offset,
|
||||
)
|
||||
# PE resource decode
|
||||
raw_ut = int((off >> 34) & 0x7)
|
||||
# Resource window
|
||||
res_kind = int((local_offset >> _RES_KIND_SHIFT) & _RES_KIND_MASK)
|
||||
try:
|
||||
unit_type = UnitType(raw_ut)
|
||||
unit_type = UnitType(res_kind)
|
||||
except ValueError:
|
||||
raise PhysAddrError(f"unknown unit_type: {raw_ut}") from None
|
||||
pe_id = int((off >> 30) & 0xF)
|
||||
ext = int((off >> 29) & 0x1)
|
||||
sub_offset = int(off & ((1 << 29) - 1))
|
||||
raise PhysAddrError(f"unknown resource_kind: {res_kind}") from None
|
||||
|
||||
if unit_type == UnitType.PE:
|
||||
pe_id = int((local_offset >> _PE_ID_SHIFT) & 0xF)
|
||||
pe_sub = int((local_offset >> _PE_SUB_SHIFT) & 0xF)
|
||||
sub_off = int(local_offset & ((1 << _PE_SUB_OFFSET_BITS) - 1))
|
||||
return PhysAddr(
|
||||
rack_id=rack,
|
||||
sip_id=sip_id,
|
||||
sip_seg=sip_seg,
|
||||
local_offset=off,
|
||||
kind="pe_resource",
|
||||
cube_id=cube_id,
|
||||
unit_type=unit_type,
|
||||
pe_id=pe_id,
|
||||
ext=ext,
|
||||
sub_offset=sub_offset,
|
||||
hbm_offset=0,
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="pe_resource", unit_type=unit_type,
|
||||
pe_id=pe_id, pe_sub_unit=pe_sub, sub_offset=sub_off,
|
||||
)
|
||||
elif unit_type == UnitType.MCPU:
|
||||
mcpu_sub = int((local_offset >> _MCPU_SUB_SHIFT) & 0x1F)
|
||||
sub_off = int(local_offset & ((1 << _PE_SUB_OFFSET_BITS) - 1))
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="pe_resource", unit_type=unit_type,
|
||||
mcpu_sub_unit=mcpu_sub, sub_offset=sub_off,
|
||||
)
|
||||
else: # SRAM
|
||||
sub_off = int(local_offset & ((1 << _SRAM_OFFSET_BITS) - 1))
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="pe_resource", unit_type=unit_type,
|
||||
sub_offset=sub_off,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def hbm_addr(*, rack_id: int, sip_id: int, cube_id: int, hbm_offset: int) -> PhysAddr:
|
||||
_chk_max("cube_id", cube_id, 31)
|
||||
_chk_range("hbm_offset", hbm_offset, 37)
|
||||
sip_seg = cube_id
|
||||
local_offset = (1 << 37) | int(hbm_offset)
|
||||
def _decode_chiplet(sip_id: int, die_id: int, local_offset: int) -> PhysAddr:
|
||||
chip_off = local_offset & ((1 << _CHIPLET_LOCAL_BITS) - 1)
|
||||
if chip_off < _IOCPU_BOUNDARY:
|
||||
iocpu_sub = int((chip_off >> _IOCPU_SUB_SHIFT) & 0xF)
|
||||
sub_off = int(chip_off & ((1 << _IOCPU_SUB_OFFSET_BITS) - 1))
|
||||
return PhysAddr(
|
||||
rack_id=rack_id,
|
||||
sip_id=sip_id,
|
||||
sip_seg=sip_seg,
|
||||
local_offset=local_offset,
|
||||
kind="hbm",
|
||||
cube_id=cube_id,
|
||||
hbm_offset=int(hbm_offset),
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="iocpu", chiplet_offset=chip_off,
|
||||
iocpu_sub_unit=iocpu_sub, sub_offset=sub_off,
|
||||
)
|
||||
else:
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="ual", chiplet_offset=chip_off,
|
||||
)
|
||||
|
||||
# ── AHBM factory methods ────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def hbm_addr(*, sip_id: int, die_id: int, hbm_offset: int) -> PhysAddr:
|
||||
_chk_max("die_id", die_id, _AHBM_DIE_MAX)
|
||||
_chk_range("hbm_offset", hbm_offset, _AHBM_SEL_BIT)
|
||||
local_offset = (1 << _AHBM_SEL_BIT) | int(hbm_offset)
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="hbm", hbm_offset=int(hbm_offset),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def pe_hbm_addr(
|
||||
*,
|
||||
rack_id: int,
|
||||
sip_id: int,
|
||||
cube_id: int,
|
||||
pe_id: int,
|
||||
pe_local_hbm_offset: int,
|
||||
slice_size_bytes: int,
|
||||
*, sip_id: int, die_id: int,
|
||||
pe_id: int, pe_local_hbm_offset: int, slice_size_bytes: int,
|
||||
) -> PhysAddr:
|
||||
_chk_max("cube_id", cube_id, 31)
|
||||
_chk_max("die_id", die_id, _AHBM_DIE_MAX)
|
||||
_chk_range("pe_id", pe_id, 4)
|
||||
if not (0 <= pe_local_hbm_offset < slice_size_bytes):
|
||||
raise PhysAddrError("pe_local_hbm_offset out of PE local slice range")
|
||||
hbm_offset = int(pe_id) * int(slice_size_bytes) + int(pe_local_hbm_offset)
|
||||
if not (0 <= hbm_offset < PhysAddr.HBM_WINDOW_BYTES):
|
||||
raise PhysAddrError("HBM offset exceeds reserved 128GB window")
|
||||
return PhysAddr.hbm_addr(
|
||||
rack_id=rack_id, sip_id=sip_id, cube_id=cube_id, hbm_offset=hbm_offset
|
||||
)
|
||||
return PhysAddr.hbm_addr(sip_id=sip_id, die_id=die_id, hbm_offset=hbm_offset)
|
||||
|
||||
@staticmethod
|
||||
def hbm_pe_id(hbm_offset: int, slice_size_bytes: int) -> int:
|
||||
return hbm_offset // slice_size_bytes
|
||||
|
||||
@staticmethod
|
||||
def cube_sram_addr(
|
||||
*, rack_id: int, sip_id: int, cube_id: int, sram_offset: int,
|
||||
def pe_tcm_addr(
|
||||
*, sip_id: int, die_id: int, pe_id: int, tcm_offset: int,
|
||||
) -> PhysAddr:
|
||||
_chk_max("cube_id", cube_id, 31)
|
||||
_chk_range("sram_offset", sram_offset, 29)
|
||||
sip_seg = cube_id
|
||||
local_offset = (UnitType.SRAM << 34) | sram_offset
|
||||
return PhysAddr(
|
||||
rack_id=rack_id, sip_id=sip_id, sip_seg=sip_seg,
|
||||
local_offset=local_offset,
|
||||
kind="pe_resource", cube_id=cube_id,
|
||||
unit_type=UnitType.SRAM, sub_offset=sram_offset,
|
||||
return PhysAddr.pe_resource_addr(
|
||||
sip_id=sip_id, die_id=die_id, pe_id=pe_id,
|
||||
pe_sub_unit=PESubUnit.PE_TCM, sub_offset=tcm_offset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def pe_tcm_addr(
|
||||
*, rack_id: int, sip_id: int, cube_id: int, pe_id: int, tcm_offset: int,
|
||||
def pe_resource_addr(
|
||||
*, sip_id: int, die_id: int, pe_id: int,
|
||||
pe_sub_unit: int, sub_offset: int,
|
||||
) -> PhysAddr:
|
||||
_chk_max("cube_id", cube_id, 31)
|
||||
_chk_max("die_id", die_id, _AHBM_DIE_MAX)
|
||||
_chk_range("pe_id", pe_id, 4)
|
||||
_chk_range("tcm_offset", tcm_offset, 29)
|
||||
sip_seg = cube_id
|
||||
local_offset = (UnitType.PE << 34) | (pe_id << 30) | tcm_offset
|
||||
return PhysAddr(
|
||||
rack_id=rack_id, sip_id=sip_id, sip_seg=sip_seg,
|
||||
local_offset=local_offset,
|
||||
kind="pe_resource", cube_id=cube_id,
|
||||
unit_type=UnitType.PE, pe_id=pe_id, sub_offset=tcm_offset,
|
||||
_chk_range("pe_sub_unit", pe_sub_unit, 4)
|
||||
_chk_range("sub_offset", sub_offset, _PE_SUB_OFFSET_BITS)
|
||||
local_offset = (
|
||||
(UnitType.PE << _RES_KIND_SHIFT)
|
||||
| (pe_id << _PE_ID_SHIFT)
|
||||
| (pe_sub_unit << _PE_SUB_SHIFT)
|
||||
| sub_offset
|
||||
)
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="pe_resource", unit_type=UnitType.PE,
|
||||
pe_id=pe_id, pe_sub_unit=pe_sub_unit, sub_offset=sub_offset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def cube_sram_addr(
|
||||
*, sip_id: int, die_id: int, sram_offset: int,
|
||||
) -> PhysAddr:
|
||||
_chk_max("die_id", die_id, _AHBM_DIE_MAX)
|
||||
_chk_range("sram_offset", sram_offset, _SRAM_OFFSET_BITS)
|
||||
local_offset = (UnitType.SRAM << _RES_KIND_SHIFT) | sram_offset
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="pe_resource", unit_type=UnitType.SRAM, sub_offset=sram_offset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def mcpu_resource_addr(
|
||||
*, sip_id: int, die_id: int, mcpu_sub_unit: int, sub_offset: int,
|
||||
) -> PhysAddr:
|
||||
_chk_max("die_id", die_id, _AHBM_DIE_MAX)
|
||||
_chk_range("mcpu_sub_unit", mcpu_sub_unit, 5)
|
||||
_chk_range("sub_offset", sub_offset, _PE_SUB_OFFSET_BITS)
|
||||
local_offset = (
|
||||
(UnitType.MCPU << _RES_KIND_SHIFT)
|
||||
| (mcpu_sub_unit << _MCPU_SUB_SHIFT)
|
||||
| sub_offset
|
||||
)
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=local_offset,
|
||||
kind="pe_resource", unit_type=UnitType.MCPU,
|
||||
mcpu_sub_unit=mcpu_sub_unit, sub_offset=sub_offset,
|
||||
)
|
||||
|
||||
# ── IOCHIPLET factory methods ────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def iocpu_resource_addr(
|
||||
*, sip_id: int, die_id: int, iocpu_sub_unit: int, sub_offset: int,
|
||||
) -> PhysAddr:
|
||||
_chk_max("die_id", die_id, _CHIPLET_DIE_MAX)
|
||||
if die_id < _CHIPLET_DIE_MIN:
|
||||
raise PhysAddrError(
|
||||
f"die_id {die_id} is not an IOCHIPLET "
|
||||
f"(must be {_CHIPLET_DIE_MIN}..{_CHIPLET_DIE_MAX})"
|
||||
)
|
||||
_chk_range("iocpu_sub_unit", iocpu_sub_unit, 4)
|
||||
_chk_range("sub_offset", sub_offset, _IOCPU_SUB_OFFSET_BITS)
|
||||
chiplet_offset = (iocpu_sub_unit << _IOCPU_SUB_SHIFT) | sub_offset
|
||||
if chiplet_offset >= _IOCPU_BOUNDARY:
|
||||
raise PhysAddrError("IOCPU region overflow (must be < 2 GB)")
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=chiplet_offset,
|
||||
kind="iocpu", chiplet_offset=chiplet_offset,
|
||||
iocpu_sub_unit=iocpu_sub_unit, sub_offset=sub_offset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ual_addr(*, sip_id: int, die_id: int, ual_offset: int) -> PhysAddr:
|
||||
_chk_max("die_id", die_id, _CHIPLET_DIE_MAX)
|
||||
if die_id < _CHIPLET_DIE_MIN:
|
||||
raise PhysAddrError(f"die_id {die_id} is not an IOCHIPLET")
|
||||
chiplet_offset = _IOCPU_BOUNDARY + ual_offset
|
||||
_chk_range("chiplet_offset", chiplet_offset, _CHIPLET_LOCAL_BITS)
|
||||
return PhysAddr(
|
||||
sip_id=sip_id, die_id=die_id, local_offset=chiplet_offset,
|
||||
kind="ual", chiplet_offset=chiplet_offset,
|
||||
)
|
||||
|
||||
@@ -27,16 +27,16 @@ class AddressResolver:
|
||||
|
||||
def resolve(self, addr: PhysAddr) -> str:
|
||||
s = addr.sip_id
|
||||
c = addr.cube_id
|
||||
d = addr.die_id
|
||||
if addr.kind == "hbm":
|
||||
node_id = f"sip{s}.cube{c}.hbm_ctrl"
|
||||
node_id = f"sip{s}.cube{d}.hbm_ctrl"
|
||||
elif addr.kind == "pe_resource":
|
||||
if addr.unit_type == UnitType.PE:
|
||||
node_id = f"sip{s}.cube{c}.pe{addr.pe_id}.pe_tcm"
|
||||
node_id = f"sip{s}.cube{d}.pe{addr.pe_id}.pe_tcm"
|
||||
elif addr.unit_type == UnitType.SRAM:
|
||||
node_id = f"sip{s}.cube{c}.sram"
|
||||
node_id = f"sip{s}.cube{d}.sram"
|
||||
elif addr.unit_type == UnitType.MCPU:
|
||||
node_id = f"sip{s}.cube{c}.m_cpu"
|
||||
node_id = f"sip{s}.cube{d}.m_cpu"
|
||||
else:
|
||||
raise RoutingError(f"unsupported unit_type: {addr.unit_type}")
|
||||
else:
|
||||
|
||||
@@ -385,7 +385,7 @@ class RuntimeContext:
|
||||
for cube_id in range(cubes_per_sip):
|
||||
for pe_id in range(pes_per_cube):
|
||||
self._allocators[(sip_id, cube_id, pe_id)] = PEMemAllocator(
|
||||
rack_id=0, sip_id=sip_id, cube_id=cube_id, pe_id=pe_id, cfg=cfg,
|
||||
sip_id=sip_id, die_id=cube_id, pe_id=pe_id, cfg=cfg,
|
||||
)
|
||||
|
||||
# Initialize VA allocator (MMU mappings are installed via fabric MmuMapMsg)
|
||||
|
||||
@@ -113,7 +113,18 @@ class AhbmCCLBackend:
|
||||
)
|
||||
n_elem = shards[0].nbytes // tensor.itemsize
|
||||
kernel_fn = self._algo_module.kernel
|
||||
kernel_args = self._algo_module.kernel_args(self._world_size, n_elem)
|
||||
# Derive effective cube dims from tensor's actual shard placement
|
||||
# (may differ from topology mesh when TP uses fewer cubes).
|
||||
sip0_cubes = sorted({s.cube for s in shards if s.sip == shards[0].sip})
|
||||
eff_n_cubes = len(sip0_cubes) if sip0_cubes else 1
|
||||
if eff_n_cubes == 1:
|
||||
eff_cube_w, eff_cube_h = 1, 1
|
||||
else:
|
||||
eff_cube_w, eff_cube_h = self._cube_w, self._cube_h
|
||||
kernel_args = self._algo_module.kernel_args(
|
||||
self._world_size, n_elem,
|
||||
cube_w=eff_cube_w, cube_h=eff_cube_h,
|
||||
)
|
||||
|
||||
# Resolve sip_rank from the current greenlet's bound rank
|
||||
from greenlet import getcurrent as _gc
|
||||
|
||||
@@ -90,6 +90,11 @@ class KernelLaunchMsg:
|
||||
args: tuple[KernelArg, ...]
|
||||
target_cubes: tuple[int, ...] | Literal["all"] = "all"
|
||||
target_pe: int | tuple[int, ...] | Literal["all"] = "all"
|
||||
# ADR-0009 D5: synchronized kernel start. When set, each PE_CPU yields
|
||||
# until env.now >= target_start_ns before beginning kernel execution,
|
||||
# so every PE in a launch starts at the same simulated time regardless
|
||||
# of its M_CPU dispatch path length. Stamped by M_CPU fan-out.
|
||||
target_start_ns: float | None = None
|
||||
msg_type: Literal["kernel_launch"] = "kernel_launch"
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from kernbench.components.context import ComponentContext
|
||||
from kernbench.policy.address.phyaddr import PhysAddr
|
||||
from kernbench.policy.routing.router import AddressResolver, PathRouter
|
||||
from kernbench.runtime_api.kernel import KernelLaunchMsg, MemoryReadMsg, MemoryWriteMsg, PeDmaMsg
|
||||
from kernbench.sim_engine.transaction import Transaction
|
||||
from kernbench.sim_engine.transaction import Flit, Transaction
|
||||
from kernbench.topology.types import Edge, TopologyGraph
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ class GraphEngine:
|
||||
for e in graph.edges:
|
||||
self._edge_map[(e.src, e.dst)] = e
|
||||
self._ns_per_mm: float = graph.spec.get("system", {}).get("ns_per_mm", 0.01)
|
||||
# ADR-0033 Phase 2c-1: wire chunkifies into Flits (Phase 2c-2/3
|
||||
# will graduate to per-flit timing + flit-aware components). At
|
||||
# 2c-1 stage all flits of a Transaction are emitted atomically
|
||||
# at the same env.now to preserve current single-msg timing —
|
||||
# Flit transport is in place but behaviorally equivalent.
|
||||
self._flit_bytes: int = int(
|
||||
graph.spec.get("system", {}).get("flit_bytes", 256)
|
||||
)
|
||||
self._results: dict[str, tuple[Completion, Trace]] = {}
|
||||
self._events: dict[str, simpy.Event] = {}
|
||||
self._counter = 0
|
||||
@@ -67,21 +75,32 @@ class GraphEngine:
|
||||
spec=graph.spec,
|
||||
memory_store=self._memory_store,
|
||||
op_logger=self._op_logger,
|
||||
node_overhead_ns={
|
||||
nid: float(n.attrs.get("overhead_ns", 0.0))
|
||||
for nid, n in graph.nodes.items()
|
||||
},
|
||||
)
|
||||
self._components: dict[str, ComponentBase] = {
|
||||
node_id: ComponentRegistry.create(node, overrides, ctx)
|
||||
for node_id, node in graph.nodes.items()
|
||||
}
|
||||
|
||||
# Wire ports: one Store per directed edge (ADR-0015 D1)
|
||||
# Wire ports: SEPARATE Stores for src.out_port and dst.in_port per
|
||||
# directed edge (ADR-0015 D1, ADR-0033 Phase 2c). The wire process
|
||||
# is the only conduit between them: pulls from src.out_port,
|
||||
# processes per-flit timing, puts on dst.in_port. Using separate
|
||||
# stores eliminates a race with `fan_in` that would otherwise let
|
||||
# flits bypass wire's BW occupancy (fan_in could pull a flit from
|
||||
# the same store before wire put it back delayed).
|
||||
for e in graph.edges:
|
||||
src_comp = self._components.get(e.src)
|
||||
dst_comp = self._components.get(e.dst)
|
||||
if src_comp is None or dst_comp is None:
|
||||
continue
|
||||
store: simpy.Store = simpy.Store(self._env)
|
||||
src_comp.out_ports[e.dst] = store
|
||||
dst_comp.in_ports[e.src] = store
|
||||
out_store: simpy.Store = simpy.Store(self._env)
|
||||
in_store: simpy.Store = simpy.Store(self._env)
|
||||
src_comp.out_ports[e.dst] = out_store
|
||||
dst_comp.in_ports[e.src] = in_store
|
||||
|
||||
# Wire processes: propagation delay + BW occupancy per edge (ADR-0015 D2)
|
||||
# Cut-through (wormhole) model: wires apply propagation delay per hop.
|
||||
@@ -255,18 +274,33 @@ class GraphEngine:
|
||||
available_at = 0.0
|
||||
while True:
|
||||
msg = yield out_port.get()
|
||||
# BW occupancy: wait for link to become free, then mark busy
|
||||
if bw_gbs > 0:
|
||||
nbytes = getattr(msg, "nbytes", 0)
|
||||
if nbytes > 0:
|
||||
# ADR-0033 Phase 2c-2/3: per-flit transport timing.
|
||||
# Transactions with payload chunkify into Flits; each flit
|
||||
# occupies the wire for ``flit_nbytes/bw_gbs`` and is
|
||||
# delivered after ``prop_ns + transfer_time``. Wormhole
|
||||
# pipelining emerges naturally because downstream flit-aware
|
||||
# components forward flits without reassembly.
|
||||
if isinstance(msg, Transaction) and msg.nbytes > 0:
|
||||
items = list(msg.into_flits(self._flit_bytes))
|
||||
else:
|
||||
items = [msg]
|
||||
for item in items:
|
||||
if isinstance(item, Flit):
|
||||
item_nbytes = item.flit_nbytes
|
||||
elif isinstance(item, Transaction):
|
||||
item_nbytes = item.nbytes
|
||||
else:
|
||||
item_nbytes = getattr(item, "nbytes", 0) or 0
|
||||
if bw_gbs > 0 and item_nbytes > 0:
|
||||
wait = available_at - self._env.now
|
||||
if wait > 0:
|
||||
yield self._env.timeout(wait)
|
||||
available_at = self._env.now + (nbytes / bw_gbs)
|
||||
# Propagation delay
|
||||
available_at = self._env.now + item_nbytes / bw_gbs
|
||||
yield self._env.timeout(prop_ns + item_nbytes / bw_gbs)
|
||||
else:
|
||||
if prop_ns > 0:
|
||||
yield self._env.timeout(prop_ns)
|
||||
yield in_port.put(msg)
|
||||
yield in_port.put(item)
|
||||
|
||||
def _process(self, key: str, request: Any, done: simpy.Event):
|
||||
if isinstance(request, PeDmaMsg):
|
||||
|
||||
@@ -212,7 +212,7 @@ def _generate_probe_h2d(graph, edge_map) -> list[dict]:
|
||||
t_offset = 0.0
|
||||
for rid, (name, cube, hops) in enumerate(cases):
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=0, cube_id=cube, pe_id=0,
|
||||
sip_id=0, die_id=cube, pe_id=0,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
dst_node = resolver.resolve(pa)
|
||||
@@ -256,7 +256,7 @@ def _generate_probe_d2h(graph, edge_map) -> list[dict]:
|
||||
t_offset = 0.0
|
||||
for rid, (name, cube, hops) in enumerate(cases):
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=0, cube_id=cube, pe_id=0,
|
||||
sip_id=0, die_id=cube, pe_id=0,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
dst_node = resolver.resolve(pa)
|
||||
@@ -310,7 +310,7 @@ def _generate_probe_pe_dma(graph, edge_map) -> list[dict]:
|
||||
t_offset = 0.0
|
||||
for rid, (name, sip, src_cube, src_pe, dst_cube, dst_pe) in enumerate(cases):
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=sip, cube_id=dst_cube, pe_id=dst_pe,
|
||||
sip_id=sip, die_id=dst_cube, pe_id=dst_pe,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
dst_node = resolver.resolve(pa)
|
||||
|
||||
@@ -44,11 +44,25 @@ class OpLogger:
|
||||
return self._records
|
||||
|
||||
def record_start(self, t: float, component_id: str, msg: Any) -> None:
|
||||
"""Called by ComponentBase._on_process_start."""
|
||||
"""Called by ComponentBase._on_process_start.
|
||||
|
||||
Snapshots TileToken stage_type at start time so we can attribute the
|
||||
record correctly even if the token advances stage_idx before
|
||||
record_end fires.
|
||||
"""
|
||||
snap: dict[str, Any] = {}
|
||||
# TileToken (ADR-0021 pipeline) — capture which stage this is.
|
||||
try:
|
||||
stage = getattr(msg, "current_stage", None)
|
||||
if stage is not None:
|
||||
snap["stage_type"] = stage.stage_type.name
|
||||
except Exception:
|
||||
pass
|
||||
self._pending[id(msg)] = {
|
||||
"t_start": t,
|
||||
"component_id": component_id,
|
||||
"msg": msg,
|
||||
"snap": snap,
|
||||
}
|
||||
|
||||
def record_end(self, t: float, component_id: str, msg: Any) -> None:
|
||||
@@ -57,6 +71,16 @@ class OpLogger:
|
||||
if pending is None:
|
||||
return
|
||||
op_kind, op_name, params = _extract_op_info(msg)
|
||||
# Merge TileToken stage_type captured at record_start into params,
|
||||
# and reflect it in op_name so reporting can disambiguate
|
||||
# DMA_READ vs DMA_WRITE and FETCH vs STORE on the same component.
|
||||
snap = pending.get("snap", {})
|
||||
stage_type = snap.get("stage_type")
|
||||
if stage_type is not None:
|
||||
params = dict(params)
|
||||
params["stage_type"] = stage_type
|
||||
if op_name == "TileToken":
|
||||
op_name = f"TileToken/{stage_type}"
|
||||
# Snapshot data at record time so Phase 2 replay sidesteps
|
||||
# downstream mutations of source addrs (e.g. a tl.store that
|
||||
# overwrites HBM after a load handle was sent, or a slot that
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -47,3 +48,46 @@ class Transaction:
|
||||
is_response=self.is_response,
|
||||
result_data=self.result_data,
|
||||
)
|
||||
|
||||
def into_flits(self, flit_bytes: int) -> Iterator[Flit]:
|
||||
"""Decompose this Transaction's payload into Flits (ADR-0033 D1).
|
||||
|
||||
Yields one Flit per ``flit_bytes`` of payload. The final flit may
|
||||
carry fewer bytes when ``nbytes`` is not a multiple of ``flit_bytes``;
|
||||
that flit has ``is_last=True``. Transactions with ``nbytes <= 0``
|
||||
yield no flits.
|
||||
|
||||
All yielded Flits share a reference to this Transaction.
|
||||
"""
|
||||
if self.nbytes <= 0 or flit_bytes <= 0:
|
||||
return
|
||||
n_full = self.nbytes // flit_bytes
|
||||
remainder = self.nbytes % flit_bytes
|
||||
n_total = n_full + (1 if remainder else 0)
|
||||
for i in range(n_total):
|
||||
size = flit_bytes if i < n_full else remainder
|
||||
yield Flit(
|
||||
txn=self,
|
||||
flit_index=i,
|
||||
flit_nbytes=size,
|
||||
is_last=(i == n_total - 1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Flit:
|
||||
"""Atomic wire transport unit (ADR-0033 D1).
|
||||
|
||||
Carries a slice of a parent Transaction's payload. The wire
|
||||
(``engine._wire``) decomposes Transactions into Flits on first
|
||||
transport; downstream wires pass Flits through with their own
|
||||
``bw_gbs`` delay.
|
||||
|
||||
Phase 2 constraint: ``flit_bytes`` MUST be a multiple of HBM
|
||||
``burst_bytes`` (default they are equal). See ADR-0033 D1.
|
||||
"""
|
||||
|
||||
txn: Transaction # parent transaction reference
|
||||
flit_index: int # 0..n_flits-1
|
||||
flit_nbytes: int # bytes carried (usually flit_bytes; last may be smaller)
|
||||
is_last: bool # True for the terminating flit
|
||||
|
||||
@@ -404,13 +404,18 @@ def _instantiate_cube(
|
||||
label=name.upper().replace("_", " "),
|
||||
)
|
||||
|
||||
# ── HBM controller (single node, ADR-0019 D1) ──
|
||||
# ── HBM controller (single node, ADR-0019 D1, ADR-0033) ──
|
||||
hbm_spec = cube["components"]["hbm_ctrl"]
|
||||
hbm_lx, hbm_ly = local_pos["hbm_ctrl"]
|
||||
hbm_id = f"{cp}.hbm_ctrl"
|
||||
hbm_attrs = dict(hbm_spec["attrs"])
|
||||
_hbm_total_bw = float(cube["links"].get("hbm_to_router_bw_gbs", 256.0))
|
||||
_num_pcs = int(hbm_attrs.get("num_pcs", 8))
|
||||
hbm_attrs["num_pcs"] = _num_pcs
|
||||
hbm_attrs["pc_bw_gbs"] = _hbm_total_bw / _num_pcs
|
||||
nodes[hbm_id] = Node(
|
||||
id=hbm_id, kind=hbm_spec["kind"], impl=hbm_spec["impl"],
|
||||
attrs=hbm_spec["attrs"], pos_mm=(ox + hbm_lx, oy + hbm_ly),
|
||||
attrs=hbm_attrs, pos_mm=(ox + hbm_lx, oy + hbm_ly),
|
||||
label="HBM CTRL",
|
||||
)
|
||||
|
||||
|
||||
@@ -123,13 +123,14 @@ class TLContext:
|
||||
|
||||
def _make_handle(
|
||||
self, addr: int, shape: tuple[int, ...], dtype: str,
|
||||
space: str = "tcm",
|
||||
space: str = "tcm", pinned: bool = False,
|
||||
) -> TensorHandle:
|
||||
return TensorHandle(
|
||||
id=self._next_handle_id(),
|
||||
addr=addr, shape=shape, dtype=dtype,
|
||||
nbytes=self._nbytes(shape, dtype),
|
||||
space=space,
|
||||
pinned=pinned,
|
||||
)
|
||||
|
||||
def _make_compute_out(
|
||||
@@ -184,15 +185,17 @@ class TLContext:
|
||||
actually lives in Phase 2 storage.
|
||||
"""
|
||||
self._emit_dispatch_overhead()
|
||||
handle = self._make_handle(addr=ptr, shape=shape, dtype=dtype, space="hbm")
|
||||
handle = self._make_handle(
|
||||
addr=ptr, shape=shape, dtype=dtype, space="hbm", pinned=True,
|
||||
)
|
||||
cmd = DmaReadCmd(handle=handle, src_addr=ptr, nbytes=handle.nbytes)
|
||||
data = self._emit(cmd)
|
||||
if data is not None:
|
||||
# Greenlet mode: attach real data to handle (preserve space)
|
||||
# Greenlet mode: attach real data to handle (preserve space + pinned)
|
||||
return TensorHandle(
|
||||
id=handle.id, addr=handle.addr, shape=handle.shape,
|
||||
dtype=handle.dtype, nbytes=handle.nbytes, data=data,
|
||||
space=handle.space,
|
||||
space=handle.space, pinned=handle.pinned,
|
||||
)
|
||||
return handle
|
||||
|
||||
@@ -492,6 +495,48 @@ class TLContext:
|
||||
)
|
||||
return self._make_handle(addr=0, shape=shape, dtype=dtype)
|
||||
|
||||
def recv_no_consume(
|
||||
self,
|
||||
dir: str | None = None,
|
||||
shape: tuple[int, ...] = (),
|
||||
dtype: str = "f16",
|
||||
) -> TensorHandle:
|
||||
"""DIAGNOSTIC ONLY — recv that blocks for arrival but skips slot read.
|
||||
|
||||
Same blocking semantics as ``tl.recv``: the kernel waits until
|
||||
the payload has landed in the IPCQ slot. Differs from ``tl.recv``
|
||||
by skipping the slot-read latency charge (slot-IO + PE↔bank
|
||||
fabric drain) on DST.
|
||||
|
||||
This entry point exists solely so the pe2pe overview plot can
|
||||
draw an apples-to-apples comparison against ``tl.store`` (a
|
||||
one-sided fabric write that pays no read on DST). Production
|
||||
kernels MUST use ``tl.recv`` — they need to consume the data
|
||||
they receive. This API is segregated from ``tl.recv`` so the
|
||||
diagnostic flag can never accidentally be set in real workloads.
|
||||
"""
|
||||
self._emit_dispatch_overhead()
|
||||
cmd = IpcqRecvCmd(
|
||||
direction=dir,
|
||||
shape=shape, dtype=dtype,
|
||||
handle_id=self._next_handle_id(),
|
||||
consume=False,
|
||||
)
|
||||
result = self._emit(cmd) # type: ignore[arg-type]
|
||||
if isinstance(result, dict):
|
||||
slot_addr = int(result.get("src_addr", 0))
|
||||
slot_space = str(result.get("src_space", "tcm"))
|
||||
return TensorHandle(
|
||||
id=self._next_handle_id(),
|
||||
addr=slot_addr,
|
||||
shape=shape,
|
||||
dtype=dtype,
|
||||
nbytes=self._nbytes(shape, dtype),
|
||||
data=None,
|
||||
space=slot_space,
|
||||
)
|
||||
return self._make_handle(addr=0, shape=shape, dtype=dtype)
|
||||
|
||||
def recv_async(
|
||||
self,
|
||||
dir: str,
|
||||
|
||||
@@ -7,11 +7,49 @@ stateful/SimPy-event-consuming and MUST NOT be shared).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from kernbench.topology.builder import resolve_topology
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""Aggregate parametrized sweep rows into combined CSV + PNG plots.
|
||||
|
||||
Runs on the controller node only (xdist worker processes set
|
||||
``PYTEST_XDIST_WORKER``; we skip those). Idempotent — does nothing
|
||||
if no sweep rows are present (e.g., when the sweep was filtered out).
|
||||
"""
|
||||
if os.environ.get("PYTEST_XDIST_WORKER"):
|
||||
return
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def _exec(name: str, attr: str) -> None:
|
||||
mod_path = Path(__file__).parent / name
|
||||
if not mod_path.exists():
|
||||
return
|
||||
s = importlib.util.spec_from_file_location(
|
||||
f"_{name.removesuffix('.py')}_for_aggregate", mod_path,
|
||||
)
|
||||
if s is None or s.loader is None:
|
||||
return
|
||||
mod = importlib.util.module_from_spec(s)
|
||||
sys.modules[s.name] = mod
|
||||
try:
|
||||
s.loader.exec_module(mod)
|
||||
fn = getattr(mod, attr, None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
except Exception as e:
|
||||
print(f"[conftest] aggregator {attr}() in {name} failed: {e}")
|
||||
|
||||
_exec("test_allreduce_multidevice.py", "_aggregate_sweep_plots")
|
||||
_exec("test_allreduce_buffer_kind_sweep.py", "aggregate_buffer_kind_plot")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def topology():
|
||||
"""Session-scoped parsed topology (immutable graph + spec).
|
||||
|
||||
@@ -149,7 +149,7 @@ def _make_tuple_allocators(
|
||||
) -> dict[tuple[int, int, int], PEMemAllocator]:
|
||||
return {
|
||||
(s, c, p): PEMemAllocator(
|
||||
rack_id=0, sip_id=s, cube_id=c, pe_id=p, cfg=_CFG,
|
||||
sip_id=s, die_id=c, pe_id=p, cfg=_CFG,
|
||||
)
|
||||
for s in range(num_sips)
|
||||
for c in range(num_cubes)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Phase 1 buffer-kind allreduce sweep — torus_2d 6 SIPs.
|
||||
|
||||
Parametrized over (buffer_kind, n_elem). Each case runs the standard
|
||||
config-driven allreduce app and writes a JSON row to a shared staging
|
||||
dir; the conftest sessionfinish hook (added in Phase 1) aggregates
|
||||
rows into ``docs/diagrams/allreduce_latency_plots/buffer_kind_sweep.png``.
|
||||
|
||||
Pre-Phase-2: the three buffer-kind lines overlap exactly because slot
|
||||
access is latency-free today. Post-Phase-2 they spread out (tcm
|
||||
fastest, hbm slowest).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from kernbench.runtime_api.context import RuntimeContext
|
||||
from kernbench.runtime_api.types import DeviceSelector
|
||||
from kernbench.sim_engine.engine import GraphEngine
|
||||
from kernbench.topology.builder import resolve_topology
|
||||
|
||||
# Reuse the allreduce app helpers.
|
||||
from tests.test_allreduce_multidevice import (
|
||||
_write_temp_configs,
|
||||
run_allreduce,
|
||||
)
|
||||
|
||||
|
||||
_BUFFER_KINDS = ["tcm", "sram", "hbm"]
|
||||
_N_ELEM_GRID = [128, 1024, 8192, 32768] # 256 B → 64 KB per slot
|
||||
_ELEM_BYTES_F16 = 2
|
||||
|
||||
_OUT_DIR = (Path(__file__).parent.parent / "docs" / "diagrams"
|
||||
/ "allreduce_latency_plots")
|
||||
_ROWS_DIR = _OUT_DIR / "_buffer_kind_rows"
|
||||
|
||||
|
||||
def _bk_params():
|
||||
out = []
|
||||
for bk in _BUFFER_KINDS:
|
||||
for n_elem in _N_ELEM_GRID:
|
||||
out.append(pytest.param(bk, n_elem, id=f"{bk}-n_elem{n_elem}"))
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("buffer_kind,n_elem", _bk_params())
|
||||
def test_buffer_kind_allreduce_one(tmp_path, buffer_kind, n_elem):
|
||||
"""One config of the buffer-kind sweep. xdist parallelizes."""
|
||||
sub = tmp_path / f"{buffer_kind}_{n_elem}"
|
||||
sub.mkdir()
|
||||
topo_path, ccl_path = _write_temp_configs(
|
||||
sub,
|
||||
sip_topology="torus_2d",
|
||||
n_sips=6,
|
||||
algorithm="intercube_allreduce",
|
||||
sip_w=3, sip_h=2,
|
||||
n_elem_override=n_elem,
|
||||
)
|
||||
# Override buffer_kind in the temp ccl.yaml.
|
||||
with open(ccl_path) as f:
|
||||
ccl_cfg = yaml.safe_load(f)
|
||||
ccl_cfg.setdefault("defaults", {})["buffer_kind"] = buffer_kind
|
||||
ccl_cfg.setdefault("algorithms", {}).setdefault(
|
||||
"intercube_allreduce", {},
|
||||
)["buffer_kind"] = buffer_kind
|
||||
with open(ccl_path, "w") as f:
|
||||
yaml.dump(ccl_cfg, f, default_flow_style=False)
|
||||
|
||||
topo = resolve_topology(topo_path)
|
||||
engine = GraphEngine(topo.topology_obj, enable_data=True)
|
||||
spec = topo.topology_obj.spec
|
||||
|
||||
with RuntimeContext(
|
||||
engine=engine,
|
||||
target_device=DeviceSelector("all"),
|
||||
correlation_id=f"bk_sweep_{buffer_kind}_{n_elem}",
|
||||
spec=spec,
|
||||
) as ctx:
|
||||
result = run_allreduce(
|
||||
ctx, engine, spec,
|
||||
algorithm="intercube_allreduce", ccl_yaml=ccl_path,
|
||||
)
|
||||
assert result["ok_cubes"] > 0
|
||||
|
||||
pe_exec_vals = [
|
||||
float(tr.get("pe_exec_ns", 0.0) or 0.0)
|
||||
for _, (_, tr) in engine._results.items()
|
||||
if isinstance(tr, dict)
|
||||
]
|
||||
crit_ns = max(pe_exec_vals) if pe_exec_vals else 0.0
|
||||
|
||||
bytes_per_pe = n_elem * _ELEM_BYTES_F16
|
||||
record = {
|
||||
"buffer_kind": buffer_kind,
|
||||
"sip_topology": "torus_2d",
|
||||
"n_sips": 6,
|
||||
"n_elem": n_elem,
|
||||
"bytes_per_pe": bytes_per_pe,
|
||||
"latency_ns": crit_ns,
|
||||
}
|
||||
_ROWS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
row_path = _ROWS_DIR / f"{buffer_kind}_{n_elem}.json"
|
||||
with open(row_path, "w", encoding="utf-8") as f:
|
||||
json.dump(record, f)
|
||||
|
||||
|
||||
def aggregate_buffer_kind_plot() -> bool:
|
||||
"""Read per-config rows and emit buffer_kind_sweep.png + CSV.
|
||||
|
||||
Called from conftest.pytest_sessionfinish (controller-only).
|
||||
Returns True if rows were aggregated.
|
||||
"""
|
||||
import csv
|
||||
|
||||
if not _ROWS_DIR.exists():
|
||||
return False
|
||||
row_files = sorted(_ROWS_DIR.glob("*.json"))
|
||||
if not row_files:
|
||||
return False
|
||||
|
||||
records = []
|
||||
for p in row_files:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
records.append(json.load(f))
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.ticker import FuncFormatter
|
||||
|
||||
def _fmt_bytes(x, _pos):
|
||||
if x <= 0:
|
||||
return "0"
|
||||
if x >= 1024 * 1024:
|
||||
return f"{x / (1024 * 1024):.0f} MB"
|
||||
if x >= 1024:
|
||||
return f"{x / 1024:.0f} KB"
|
||||
return f"{x:.0f} B"
|
||||
|
||||
_bytes_fmt = FuncFormatter(_fmt_bytes)
|
||||
|
||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(_OUT_DIR / "buffer_kind_sweep.csv", "w",
|
||||
newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=[
|
||||
"buffer_kind", "sip_topology", "n_sips", "n_elem",
|
||||
"bytes_per_pe", "latency_ns",
|
||||
])
|
||||
w.writeheader()
|
||||
for r in sorted(records, key=lambda r: (
|
||||
r["buffer_kind"], r["bytes_per_pe"],
|
||||
)):
|
||||
w.writerow(r)
|
||||
|
||||
colors = {"tcm": "tab:blue", "sram": "tab:orange", "hbm": "tab:red"}
|
||||
fig, ax = plt.subplots(figsize=(10, 6))
|
||||
for bk in ["tcm", "sram", "hbm"]:
|
||||
rs = sorted(
|
||||
[r for r in records if r["buffer_kind"] == bk],
|
||||
key=lambda r: r["bytes_per_pe"],
|
||||
)
|
||||
if not rs:
|
||||
continue
|
||||
ax.plot(
|
||||
[r["bytes_per_pe"] for r in rs],
|
||||
[r["latency_ns"] for r in rs],
|
||||
marker="o", lw=2.0,
|
||||
color=colors[bk], label=f"buffer_kind = {bk}",
|
||||
)
|
||||
ax.set_xscale("log", base=2)
|
||||
ax.set_xlabel("Bytes per PE (log scale)")
|
||||
ax.set_ylabel("Time (ns)")
|
||||
ax.set_title(
|
||||
"Allreduce torus_2d (6 SIPs, 3×2) — IPCQ slot memory tier"
|
||||
)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend()
|
||||
ax.xaxis.set_major_formatter(_bytes_fmt)
|
||||
fig.tight_layout()
|
||||
fig.savefig(_OUT_DIR / "buffer_kind_sweep.png", dpi=130)
|
||||
plt.close(fig)
|
||||
|
||||
for p in row_files:
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_ROWS_DIR.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print(f"\nWrote {_OUT_DIR / 'buffer_kind_sweep.png'} "
|
||||
f"from {len(records)} rows")
|
||||
return True
|
||||
@@ -22,13 +22,23 @@ from kernbench.ccl.sfr_config import configure_sfr_intercube_multisip
|
||||
from kernbench.policy.placement.dp import DPPolicy
|
||||
|
||||
|
||||
def _sip_topo_dims(sip_topo: str, n_sips: int) -> tuple[int, int]:
|
||||
def _sip_topo_dims(
|
||||
sip_topo: str, n_sips: int,
|
||||
spec_w: int | None = None, spec_h: int | None = None,
|
||||
) -> tuple[int, int]:
|
||||
if sip_topo == "ring_1d":
|
||||
return (0, 0)
|
||||
if spec_w is not None and spec_h is not None:
|
||||
if spec_w * spec_h != n_sips:
|
||||
raise ValueError(
|
||||
f"sip layout {spec_w}x{spec_h} != n_sips ({n_sips})"
|
||||
)
|
||||
return (spec_w, spec_h)
|
||||
side = int(round(math.sqrt(n_sips)))
|
||||
if side * side != n_sips:
|
||||
raise ValueError(
|
||||
f"SIP topology '{sip_topo}' requires square n_sips, got {n_sips}"
|
||||
f"SIP topology '{sip_topo}' requires square n_sips or "
|
||||
f"explicit w/h in spec, got {n_sips}"
|
||||
)
|
||||
return (side, side)
|
||||
|
||||
@@ -54,10 +64,13 @@ def run_allreduce(
|
||||
topo_name_to_kind = algo_module.TOPO_NAME_TO_KIND
|
||||
|
||||
n_elem = int(cfg.get("n_elem", 8))
|
||||
n_sips = int(spec.get("system", {}).get("sips", {}).get("count", 1))
|
||||
sip_topo = str(
|
||||
spec.get("system", {}).get("sips", {}).get("topology", "ring_1d")
|
||||
)
|
||||
sips_cfg = spec.get("system", {}).get("sips", {})
|
||||
n_sips = int(sips_cfg.get("count", 1))
|
||||
sip_topo = str(sips_cfg.get("topology", "ring_1d"))
|
||||
spec_sip_w = sips_cfg.get("w")
|
||||
spec_sip_h = sips_cfg.get("h")
|
||||
spec_sip_w = int(spec_sip_w) if spec_sip_w is not None else None
|
||||
spec_sip_h = int(spec_sip_h) if spec_sip_h is not None else None
|
||||
|
||||
cm = spec["sip"]["cube_mesh"]
|
||||
cube_w = int(cm["w"])
|
||||
@@ -65,7 +78,9 @@ def run_allreduce(
|
||||
n_cubes = cube_w * cube_h
|
||||
|
||||
sip_topo_kind = topo_name_to_kind.get(sip_topo, 0)
|
||||
sip_topo_w, sip_topo_h = _sip_topo_dims(sip_topo, n_sips)
|
||||
sip_topo_w, sip_topo_h = _sip_topo_dims(
|
||||
sip_topo, n_sips, spec_w=spec_sip_w, spec_h=spec_sip_h,
|
||||
)
|
||||
|
||||
algo_name = cfg.get("algorithm", "allreduce")
|
||||
print(f"\n{'=' * 60}")
|
||||
@@ -173,18 +188,36 @@ from kernbench.topology.builder import resolve_topology
|
||||
TOPOLOGY_PATH = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
CONFIGS = [
|
||||
pytest.param("intercube_allreduce", "ring_1d", 2, id="ring_2sip"),
|
||||
pytest.param("intercube_allreduce", "torus_2d", 4, id="torus_4sip"),
|
||||
pytest.param("intercube_allreduce", "mesh_2d_no_wrap", 4, id="mesh_4sip"),
|
||||
pytest.param(
|
||||
"intercube_allreduce", "ring_1d", 6, None, None,
|
||||
id="ring_6sip",
|
||||
),
|
||||
pytest.param(
|
||||
"intercube_allreduce", "torus_2d", 6, 2, 3,
|
||||
id="torus_6sip_2x3",
|
||||
),
|
||||
pytest.param(
|
||||
"intercube_allreduce", "mesh_2d_no_wrap", 6, 2, 3,
|
||||
id="mesh_6sip_2x3",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _write_temp_configs(tmp_path, sip_topology, n_sips, algorithm):
|
||||
def _write_temp_configs(
|
||||
tmp_path, sip_topology, n_sips, algorithm, n_elem_override=None,
|
||||
sip_w=None, sip_h=None,
|
||||
):
|
||||
"""Write temp topology.yaml and ccl.yaml with the given overrides."""
|
||||
with open(TOPOLOGY_PATH) as f:
|
||||
topo_cfg = yaml.safe_load(f)
|
||||
topo_cfg["system"]["sips"]["count"] = n_sips
|
||||
topo_cfg["system"]["sips"]["topology"] = sip_topology
|
||||
if sip_w is not None and sip_h is not None:
|
||||
topo_cfg["system"]["sips"]["w"] = int(sip_w)
|
||||
topo_cfg["system"]["sips"]["h"] = int(sip_h)
|
||||
else:
|
||||
topo_cfg["system"]["sips"].pop("w", None)
|
||||
topo_cfg["system"]["sips"].pop("h", None)
|
||||
topo_path = tmp_path / "topology.yaml"
|
||||
with open(topo_path, "w") as f:
|
||||
yaml.dump(topo_cfg, f, default_flow_style=False)
|
||||
@@ -193,6 +226,15 @@ def _write_temp_configs(tmp_path, sip_topology, n_sips, algorithm):
|
||||
with open(ccl_path) as f:
|
||||
ccl_cfg = yaml.safe_load(f)
|
||||
ccl_cfg["defaults"]["algorithm"] = algorithm
|
||||
if n_elem_override is not None:
|
||||
ccl_cfg.setdefault("algorithms", {}).setdefault(
|
||||
algorithm, {},
|
||||
)["n_elem"] = int(n_elem_override)
|
||||
# Ensure IPCQ slot is big enough for the per-message payload.
|
||||
per_msg_bytes = int(n_elem_override) * 2 # f16
|
||||
default_slot = int(ccl_cfg["defaults"].get("slot_size", 4096))
|
||||
if per_msg_bytes > default_slot:
|
||||
ccl_cfg["defaults"]["slot_size"] = per_msg_bytes
|
||||
tmp_ccl = tmp_path / "ccl.yaml"
|
||||
with open(tmp_ccl, "w") as f:
|
||||
yaml.dump(ccl_cfg, f, default_flow_style=False)
|
||||
@@ -200,10 +242,15 @@ def _write_temp_configs(tmp_path, sip_topology, n_sips, algorithm):
|
||||
return str(topo_path), str(tmp_ccl)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("algorithm,sip_topology,n_sips", CONFIGS)
|
||||
def test_allreduce(tmp_path, algorithm, sip_topology, n_sips):
|
||||
@pytest.mark.parametrize(
|
||||
"algorithm,sip_topology,n_sips,sip_w,sip_h", CONFIGS,
|
||||
)
|
||||
def test_allreduce(
|
||||
tmp_path, algorithm, sip_topology, n_sips, sip_w, sip_h,
|
||||
):
|
||||
topo_path, ccl_path = _write_temp_configs(
|
||||
tmp_path, sip_topology, n_sips, algorithm,
|
||||
sip_w=sip_w, sip_h=sip_h,
|
||||
)
|
||||
topo = resolve_topology(topo_path)
|
||||
engine = GraphEngine(topo.topology_obj, enable_data=True)
|
||||
@@ -220,3 +267,570 @@ def test_allreduce(tmp_path, algorithm, sip_topology, n_sips):
|
||||
algorithm=algorithm, ccl_yaml=ccl_path,
|
||||
)
|
||||
assert result["ok_cubes"] > 0
|
||||
|
||||
|
||||
# ── Latency sweep (parametrized + xdist-friendly) ─────────────────────
|
||||
|
||||
# avoid 16 (== n_cubes, dim_map collision). Goes up to 96 KB per PE:
|
||||
# bytes_per_pe = n_elem * 2 (f16). 49152 elem * 2 = 96 KB / PE.
|
||||
_SWEEP_N_ELEM = [
|
||||
8, 32, 64, 128, 512, 1024, 2048,
|
||||
4096, 8192, 16384, 32768, 49152,
|
||||
]
|
||||
_ELEM_BYTES_F16 = 2
|
||||
|
||||
_SWEEP_TOPOLOGIES = [
|
||||
("intercube_allreduce", "ring_1d", 6, None, None),
|
||||
("intercube_allreduce", "torus_2d", 6, 2, 3),
|
||||
("intercube_allreduce", "mesh_2d_no_wrap", 6, 2, 3),
|
||||
]
|
||||
|
||||
# Shared on-disk staging dir for parametrized sweep rows. Each
|
||||
# parametrized invocation writes one JSON file here; the aggregator
|
||||
# (run from conftest.pytest_sessionfinish) reads them and emits the
|
||||
# combined CSV + PNG plots.
|
||||
_SWEEP_OUT_DIR = (Path(__file__).parent.parent / "docs" / "diagrams"
|
||||
/ "allreduce_latency_plots")
|
||||
_SWEEP_ROWS_DIR = _SWEEP_OUT_DIR / "_rows"
|
||||
|
||||
|
||||
def _sweep_params():
|
||||
out = []
|
||||
for algorithm, sip_topology, n_sips, sip_w, sip_h in _SWEEP_TOPOLOGIES:
|
||||
for n_elem in _SWEEP_N_ELEM:
|
||||
out.append(pytest.param(
|
||||
algorithm, sip_topology, n_sips, sip_w, sip_h, n_elem,
|
||||
id=f"{sip_topology}-n_elem{n_elem}",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"algorithm,sip_topology,n_sips,sip_w,sip_h,n_elem", _sweep_params(),
|
||||
)
|
||||
def test_allreduce_latency_one(
|
||||
tmp_path, algorithm, sip_topology, n_sips, sip_w, sip_h, n_elem,
|
||||
):
|
||||
"""One config of the latency sweep. xdist parallelizes across params.
|
||||
|
||||
Writes a single JSON row to ``_SWEEP_ROWS_DIR``. The conftest
|
||||
sessionfinish hook aggregates rows into CSV + plots after all
|
||||
parametrized cases finish.
|
||||
"""
|
||||
import json
|
||||
|
||||
topo_path, ccl_path = _write_temp_configs(
|
||||
tmp_path, sip_topology, n_sips, algorithm,
|
||||
sip_w=sip_w, sip_h=sip_h,
|
||||
n_elem_override=n_elem,
|
||||
)
|
||||
topo = resolve_topology(topo_path)
|
||||
engine = GraphEngine(topo.topology_obj, enable_data=True)
|
||||
spec = topo.topology_obj.spec
|
||||
|
||||
with RuntimeContext(
|
||||
engine=engine,
|
||||
target_device=DeviceSelector("all"),
|
||||
correlation_id=f"sweep_{algorithm}_{sip_topology}_{n_elem}",
|
||||
spec=spec,
|
||||
) as ctx:
|
||||
result = run_allreduce(
|
||||
ctx, engine, spec,
|
||||
algorithm=algorithm, ccl_yaml=ccl_path,
|
||||
)
|
||||
assert result["ok_cubes"] > 0
|
||||
|
||||
pe_exec_vals = [
|
||||
float(tr.get("pe_exec_ns", 0.0) or 0.0)
|
||||
for _, (_, tr) in engine._results.items()
|
||||
if isinstance(tr, dict)
|
||||
]
|
||||
crit_ns = max(pe_exec_vals) if pe_exec_vals else 0.0
|
||||
|
||||
cm = spec["sip"]["cube_mesh"]
|
||||
n_cubes = int(cm["w"]) * int(cm["h"])
|
||||
bytes_per_sip = n_cubes * n_elem * _ELEM_BYTES_F16
|
||||
bytes_per_pe = n_elem * _ELEM_BYTES_F16
|
||||
|
||||
record = {
|
||||
"algorithm": algorithm,
|
||||
"sip_topology": sip_topology,
|
||||
"n_sips": n_sips,
|
||||
"n_elem": n_elem,
|
||||
"bytes_per_pe": bytes_per_pe,
|
||||
"bytes_per_sip": bytes_per_sip,
|
||||
"latency_ns": crit_ns,
|
||||
}
|
||||
|
||||
_SWEEP_ROWS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
row_path = _SWEEP_ROWS_DIR / f"{sip_topology}_{n_elem}.json"
|
||||
with open(row_path, "w", encoding="utf-8") as f:
|
||||
json.dump(record, f)
|
||||
|
||||
|
||||
def _aggregate_sweep_plots() -> bool:
|
||||
"""Read all per-config rows and emit CSV + PNG plots.
|
||||
|
||||
Called by ``conftest.pytest_sessionfinish`` (controller node only).
|
||||
Returns True if any rows were aggregated, False otherwise.
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
|
||||
row_files = sorted(_SWEEP_ROWS_DIR.glob("*.json")) \
|
||||
if _SWEEP_ROWS_DIR.exists() else []
|
||||
records: list[dict] = []
|
||||
if row_files:
|
||||
for p in row_files:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
records.append(json.load(f))
|
||||
else:
|
||||
# Fallback: replot from existing summary.csv (skip sweep re-run).
|
||||
summary_path = _SWEEP_OUT_DIR / "summary.csv"
|
||||
if not summary_path.exists():
|
||||
return False
|
||||
with open(summary_path, encoding="utf-8") as f:
|
||||
for row in csv.DictReader(f):
|
||||
records.append({
|
||||
"algorithm": row["algorithm"],
|
||||
"sip_topology": row["sip_topology"],
|
||||
"n_sips": int(row["n_sips"]),
|
||||
"n_elem": int(row["n_elem"]),
|
||||
"bytes_per_pe": int(row["bytes_per_pe"]),
|
||||
"bytes_per_sip": int(row["bytes_per_sip"]),
|
||||
"latency_ns": float(row["latency_ns"]),
|
||||
})
|
||||
if not records:
|
||||
return False
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.ticker import FuncFormatter
|
||||
|
||||
def _fmt_bytes(x, _pos):
|
||||
if x <= 0:
|
||||
return "0"
|
||||
if x >= 1024 * 1024:
|
||||
return f"{x / (1024 * 1024):.0f} MB"
|
||||
if x >= 1024:
|
||||
return f"{x / 1024:.0f} KB"
|
||||
return f"{x:.0f} B"
|
||||
|
||||
_bytes_fmt = FuncFormatter(_fmt_bytes)
|
||||
|
||||
_SWEEP_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(_SWEEP_OUT_DIR / "summary.csv", "w",
|
||||
newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=[
|
||||
"algorithm", "sip_topology", "n_sips", "n_elem",
|
||||
"bytes_per_pe", "bytes_per_sip", "latency_ns",
|
||||
])
|
||||
w.writeheader()
|
||||
for r in sorted(records, key=lambda r: (
|
||||
r["sip_topology"], r["bytes_per_pe"],
|
||||
)):
|
||||
w.writerow(r)
|
||||
|
||||
topologies = sorted({r["sip_topology"] for r in records})
|
||||
for topo_name in topologies:
|
||||
rs = sorted(
|
||||
[r for r in records if r["sip_topology"] == topo_name],
|
||||
key=lambda r: r["bytes_per_pe"],
|
||||
)
|
||||
if not rs:
|
||||
continue
|
||||
xs = [r["bytes_per_pe"] for r in rs]
|
||||
ys = [r["latency_ns"] for r in rs]
|
||||
title = (
|
||||
f"Allreduce latency — {topo_name} "
|
||||
f"(n_sips={rs[0]['n_sips']})"
|
||||
)
|
||||
fig, ax = plt.subplots(figsize=(8, 5))
|
||||
ax.plot(xs, ys, marker="o", color="tab:blue")
|
||||
ax.set_xscale("log", base=2)
|
||||
ax.set_xlabel("Bytes per PE (log scale)")
|
||||
ax.set_ylabel("Time (ns)")
|
||||
ax.set_title(title)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.xaxis.set_major_formatter(_bytes_fmt)
|
||||
fig.tight_layout()
|
||||
fig.savefig(_SWEEP_OUT_DIR / f"{topo_name}.png", dpi=120)
|
||||
plt.close(fig)
|
||||
|
||||
colors = {"ring_1d": "tab:blue", "torus_2d": "tab:orange",
|
||||
"mesh_2d_no_wrap": "tab:green"}
|
||||
|
||||
# ── Hand-derived theoretical model for torus_2d (6 SIPs) ──
|
||||
# Critical-path analysis (per packet, packet = 128 B at NoC):
|
||||
# local intra-SIP reduce + broadcast = 8 hops × 57 ns = 456 ns
|
||||
# global X-direction reduce = 5 UCIe + 1 UAL = 445 ns
|
||||
# global Y-direction reduce = 5 UCIe + 1 UAL = 445 ns
|
||||
# per-packet startup latency = 456 + 445 + 445 = 1346 ns
|
||||
# Packet count is PER CUBE (8 PEs/cube cooperate on the cube tile).
|
||||
# At 6144 packets/cube the pipelined total is 8741 ns, so the
|
||||
# bottleneck-stage interval τ = (8741 − 1346) / (6144 − 1) ≈ 1.204 ns.
|
||||
# T_theoretical(N) = 1346 + (N − 1) × τ
|
||||
# where N = ceil((bytes_per_pe × 8) / 128) = ceil(bytes_per_pe / 16)
|
||||
NOC_PACKET_BYTES = 128
|
||||
PES_PER_CUBE = 8
|
||||
T_STARTUP_NS = 1346.0
|
||||
TAU_NS = (8741.0 - 1346.0) / (6144 - 1) # ≈ 1.2038 ns/packet
|
||||
|
||||
def _theoretical_torus_2d_ns(bytes_per_pe: int) -> float:
|
||||
bytes_per_cube = int(bytes_per_pe) * PES_PER_CUBE
|
||||
n_packets = max(1, -(-bytes_per_cube // NOC_PACKET_BYTES)) # ceil
|
||||
return T_STARTUP_NS + (n_packets - 1) * TAU_NS
|
||||
|
||||
fig, ax = plt.subplots(figsize=(9, 6))
|
||||
for topo_name in topologies:
|
||||
rs = sorted(
|
||||
[r for r in records if r["sip_topology"] == topo_name],
|
||||
key=lambda r: r["bytes_per_pe"],
|
||||
)
|
||||
if not rs:
|
||||
continue
|
||||
ax.plot(
|
||||
[r["bytes_per_pe"] for r in rs],
|
||||
[r["latency_ns"] for r in rs],
|
||||
marker="o",
|
||||
label=f"{topo_name} (n_sips={rs[0]['n_sips']})",
|
||||
color=colors.get(topo_name),
|
||||
)
|
||||
|
||||
# Theoretical torus_2d curve across all payload sizes.
|
||||
torus_rs = sorted(
|
||||
[r for r in records if r["sip_topology"] == "torus_2d"],
|
||||
key=lambda r: r["bytes_per_pe"],
|
||||
)
|
||||
if torus_rs:
|
||||
xs_th = [r["bytes_per_pe"] for r in torus_rs]
|
||||
ys_th = [_theoretical_torus_2d_ns(r["bytes_per_pe"]) for r in torus_rs]
|
||||
ax.plot(
|
||||
xs_th, ys_th,
|
||||
color="tab:red", linestyle="--", linewidth=1.6, marker="x",
|
||||
label="theoretical torus_2d (6 SIPs)",
|
||||
)
|
||||
|
||||
ax.set_xscale("log", base=2)
|
||||
ax.set_xlabel("Bytes per PE (log scale)")
|
||||
ax.set_ylabel("Time (ns)")
|
||||
ax.set_title("Multi-device allreduce latency by topology")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.set_xlim(left=min(r["bytes_per_pe"] for r in records) / 2,
|
||||
right=max(r["bytes_per_pe"] for r in records) * 1.5)
|
||||
ax.legend()
|
||||
ax.xaxis.set_major_formatter(_bytes_fmt)
|
||||
fig.tight_layout()
|
||||
fig.savefig(_SWEEP_OUT_DIR / "overview.png", dpi=120)
|
||||
plt.close(fig)
|
||||
|
||||
# Cleanup row staging dir so a partial future run doesn't pick up
|
||||
# stale rows.
|
||||
for p in row_files:
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_SWEEP_ROWS_DIR.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print(f"\nWrote {_SWEEP_OUT_DIR / 'overview.png'} "
|
||||
f"from {len(records)} rows")
|
||||
return True
|
||||
|
||||
|
||||
# ── Topology diagram (device-level + cube-level reduction) ────────────
|
||||
|
||||
# Convention: "rows × cols" everywhere, row-major rank assignment
|
||||
# (rank = row * n_cols + col). For the 2×3 inter-SIP grid, this means
|
||||
# 2 rows × 3 columns: SIP 0 1 2 / SIP 3 4 5.
|
||||
|
||||
_PALETTE_BG = "#fafbfd"
|
||||
_PALETTE_FRAME = "#3a3f4a"
|
||||
_PALETTE_BLUE = "#2c6fb6"
|
||||
_PALETTE_GREEN = "#2e8a4e"
|
||||
_PALETTE_TEXT = "#1f2530"
|
||||
_PALETTE_BOX_FILL = "#eaf2fb"
|
||||
_PALETTE_BOX_EDGE = "#2c4a78"
|
||||
_PALETTE_ROOT_FILL = "#ffd9b8"
|
||||
_PALETTE_ROOT_EDGE = "#bd5a14"
|
||||
|
||||
|
||||
def _arrow(ax, xy_from, xy_to, color="black", lw=1.4, alpha=1.0,
|
||||
style="-|>", curve=0.0):
|
||||
from matplotlib.patches import FancyArrowPatch
|
||||
arrow = FancyArrowPatch(
|
||||
xy_from, xy_to,
|
||||
arrowstyle=style, mutation_scale=12,
|
||||
color=color, lw=lw, alpha=alpha,
|
||||
connectionstyle=f"arc3,rad={curve}",
|
||||
)
|
||||
ax.add_patch(arrow)
|
||||
|
||||
|
||||
def _draw_sip_box(ax, cx, cy, w, h, label, *, fill=_PALETTE_BOX_FILL,
|
||||
edge=_PALETTE_BOX_EDGE, text_color=_PALETTE_TEXT,
|
||||
font=10):
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
box = FancyBboxPatch(
|
||||
(cx - w / 2, cy - h / 2), w, h,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.10",
|
||||
linewidth=1.4, edgecolor=edge, facecolor=fill,
|
||||
)
|
||||
ax.add_patch(box)
|
||||
ax.text(cx, cy, label, ha="center", va="center",
|
||||
color=text_color, fontsize=font, fontweight="bold")
|
||||
|
||||
|
||||
def _frame_panel(ax, title, lim_x=10.0, lim_y=6.0):
|
||||
"""Set up a square-ish panel with a visible outer border."""
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
ax.set_xlim(0, lim_x)
|
||||
ax.set_ylim(0, lim_y)
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
ax.set_facecolor(_PALETTE_BG)
|
||||
border = FancyBboxPatch(
|
||||
(0.05, 0.05), lim_x - 0.10, lim_y - 0.10,
|
||||
boxstyle="round,pad=0.01,rounding_size=0.12",
|
||||
linewidth=1.4, edgecolor=_PALETTE_FRAME, facecolor=_PALETTE_BG,
|
||||
zorder=0,
|
||||
)
|
||||
ax.add_patch(border)
|
||||
ax.set_title(title, fontsize=12, fontweight="bold",
|
||||
color=_PALETTE_TEXT, pad=8)
|
||||
|
||||
|
||||
def _draw_ring_topology(ax):
|
||||
_frame_panel(ax, "ring_1d (6 SIPs)", lim_x=10.0, lim_y=6.0)
|
||||
|
||||
xs = [1.2, 2.7, 4.2, 5.7, 7.2, 8.7]
|
||||
y = 3.1
|
||||
box_w, box_h = 1.05, 0.9
|
||||
for i, x in enumerate(xs):
|
||||
_draw_sip_box(ax, x, y, box_w, box_h, f"SIP {i}")
|
||||
# Forward ring (global_E) — adjacent neighbours, anchored to box edges.
|
||||
for i in range(5):
|
||||
_arrow(ax, (xs[i] + box_w / 2, y),
|
||||
(xs[i + 1] - box_w / 2, y),
|
||||
color=_PALETTE_BLUE, lw=1.6)
|
||||
# Wrap (SIP 5 → SIP 0). Anchor at right-CENTER of SIP 5 and
|
||||
# left-CENTER of SIP 0; arc OUTSIDE (above) the row so it does not
|
||||
# overlap any of the SIP boxes in between.
|
||||
_arrow(
|
||||
ax,
|
||||
(xs[5] + box_w / 2, y),
|
||||
(xs[0] - box_w / 2, y),
|
||||
color=_PALETTE_BLUE, lw=1.6, curve=-0.40,
|
||||
)
|
||||
ax.text(5.0, y + 2.0, "global_E (ring)", ha="center",
|
||||
color=_PALETTE_BLUE, fontsize=10, style="italic")
|
||||
ax.text(5.0, y - 1.5,
|
||||
"(global_W = reverse direction, used by the algorithm)",
|
||||
ha="center", color="gray", fontsize=8, style="italic")
|
||||
|
||||
|
||||
def _draw_grid_topology(ax, kind, *, n_rows=2, n_cols=3):
|
||||
"""kind ∈ {'torus', 'mesh'}. Lays out as n_rows × n_cols (row-major).
|
||||
|
||||
For the sweep we use 2 rows × 3 cols → SIP layout::
|
||||
|
||||
row 0: SIP 0 SIP 1 SIP 2
|
||||
row 1: SIP 3 SIP 4 SIP 5
|
||||
"""
|
||||
title = f"torus_2d ({n_rows}×{n_cols}, 6 SIPs)" if kind == "torus" \
|
||||
else f"mesh_2d_no_wrap ({n_rows}×{n_cols}, 6 SIPs)"
|
||||
_frame_panel(ax, title, lim_x=10.0, lim_y=6.0)
|
||||
|
||||
col_xs = [2.0, 5.0, 8.0] # 3 cols
|
||||
row_ys = [4.3, 1.8] # 2 rows
|
||||
box_w, box_h = 1.3, 0.95
|
||||
pos: dict[tuple[int, int], tuple[float, float]] = {}
|
||||
for r in range(n_rows):
|
||||
for c in range(n_cols):
|
||||
rank = r * n_cols + c
|
||||
x, y = col_xs[c], row_ys[r]
|
||||
pos[(r, c)] = (x, y)
|
||||
_draw_sip_box(ax, x, y, box_w, box_h, f"SIP {rank}")
|
||||
|
||||
# Row edges (E↔W) — between adjacent columns within each row.
|
||||
for r in range(n_rows):
|
||||
for c in range(n_cols - 1):
|
||||
x0, y0 = pos[(r, c)]
|
||||
x1, y1 = pos[(r, c + 1)]
|
||||
_arrow(ax, (x0 + box_w / 2, y0 + 0.10),
|
||||
(x1 - box_w / 2, y1 + 0.10),
|
||||
color=_PALETTE_BLUE, lw=1.5)
|
||||
_arrow(ax, (x1 - box_w / 2, y1 - 0.10),
|
||||
(x0 + box_w / 2, y0 - 0.10),
|
||||
color=_PALETTE_BLUE, lw=1.5)
|
||||
# Col edges (N↔S) — between adjacent rows within each column.
|
||||
for c in range(n_cols):
|
||||
for r in range(n_rows - 1):
|
||||
x0, y0 = pos[(r, c)]
|
||||
x1, y1 = pos[(r + 1, c)]
|
||||
_arrow(ax, (x0 - 0.12, y0 - box_h / 2),
|
||||
(x1 - 0.12, y1 + box_h / 2),
|
||||
color=_PALETTE_GREEN, lw=1.5)
|
||||
_arrow(ax, (x1 + 0.12, y1 + box_h / 2),
|
||||
(x0 + 0.12, y0 - box_h / 2),
|
||||
color=_PALETTE_GREEN, lw=1.5)
|
||||
# Wrap arrows for torus only — anchor to the centre of the OUTER
|
||||
# edge of the end SIPs and arc OUTSIDE the row/column so they do
|
||||
# not overlap the SIPs in between.
|
||||
if kind == "torus":
|
||||
# Row wrap: last col → first col. Top row arcs UP, bottom row
|
||||
# arcs DOWN, so each wrap sits clearly outside its own row.
|
||||
for r in range(n_rows):
|
||||
x0, y0 = pos[(r, 0)]
|
||||
x1, y1 = pos[(r, n_cols - 1)]
|
||||
curve = -0.45 if r == 0 else 0.45
|
||||
_arrow(
|
||||
ax,
|
||||
(x1 + box_w / 2, y1),
|
||||
(x0 - box_w / 2, y0),
|
||||
color=_PALETTE_BLUE, lw=1.5,
|
||||
curve=curve, alpha=0.9,
|
||||
)
|
||||
# Col wrap: last row → first row. Leftmost col arcs LEFT,
|
||||
# rightmost col arcs RIGHT. Middle col(s) get a small inline
|
||||
# marker + legend note (drawing them through the panel would
|
||||
# collide with the row arrows).
|
||||
for c in range(n_cols):
|
||||
x0, y0 = pos[(0, c)]
|
||||
x1, y1 = pos[(n_rows - 1, c)]
|
||||
if c == 0:
|
||||
curve = 0.55
|
||||
elif c == n_cols - 1:
|
||||
curve = -0.55
|
||||
else:
|
||||
continue # skip middle col — see legend note
|
||||
_arrow(
|
||||
ax,
|
||||
(x1, y1 - box_h / 2),
|
||||
(x0, y0 + box_h / 2),
|
||||
color=_PALETTE_GREEN, lw=1.5,
|
||||
curve=curve, alpha=0.9,
|
||||
)
|
||||
|
||||
ax.text(0.7, 5.6, "global_E/W (row)", color=_PALETTE_BLUE,
|
||||
fontsize=9, style="italic", fontweight="bold")
|
||||
ax.text(0.7, 5.25, "global_N/S (col)", color=_PALETTE_GREEN,
|
||||
fontsize=9, style="italic", fontweight="bold")
|
||||
ax.text(0.7, 4.92,
|
||||
"wrap = torus" if kind == "torus" else "no wrap = mesh",
|
||||
color="gray", fontsize=8, style="italic")
|
||||
if kind == "torus" and n_cols > 2:
|
||||
ax.text(0.7, 0.3,
|
||||
"(middle-col wrap omitted for clarity — every row "
|
||||
"and every column wraps)",
|
||||
color="gray", fontsize=7.5, style="italic")
|
||||
|
||||
|
||||
def _draw_cube_reduction(ax):
|
||||
"""4×4 cube grid inside SIP 0 — compact layout with phase legend."""
|
||||
from matplotlib.patches import Rectangle
|
||||
_frame_panel(ax, "Cube-level reduction inside SIP 0 (4×4 cubes)",
|
||||
lim_x=10.0, lim_y=6.0)
|
||||
|
||||
cube_w = 0.65
|
||||
cube_gap = 0.18
|
||||
# Center the 4×4 grid in the left half of the panel.
|
||||
grid_total = 4 * cube_w + 3 * cube_gap
|
||||
grid_x0 = 0.7
|
||||
grid_y0 = 0.7
|
||||
centers: dict[tuple[int, int], tuple[float, float]] = {}
|
||||
for r in range(4):
|
||||
for c in range(4):
|
||||
cx = grid_x0 + c * (cube_w + cube_gap) + cube_w / 2
|
||||
cy = grid_y0 + (3 - r) * (cube_w + cube_gap) + cube_w / 2
|
||||
centers[(r, c)] = (cx, cy)
|
||||
cube_id = r * 4 + c
|
||||
is_root = (r == 3 and c == 3)
|
||||
face = _PALETTE_ROOT_FILL if is_root else _PALETTE_BOX_FILL
|
||||
edge = _PALETTE_ROOT_EDGE if is_root else _PALETTE_BOX_EDGE
|
||||
rect = Rectangle(
|
||||
(cx - cube_w / 2, cy - cube_w / 2), cube_w, cube_w,
|
||||
linewidth=1.2, edgecolor=edge, facecolor=face,
|
||||
)
|
||||
ax.add_patch(rect)
|
||||
label = f"c{cube_id}"
|
||||
ax.text(cx, cy, label, ha="center", va="center",
|
||||
fontsize=7.5, fontweight="bold",
|
||||
color=_PALETTE_ROOT_EDGE if is_root
|
||||
else _PALETTE_TEXT)
|
||||
|
||||
# Phase 1: row reduce W→E.
|
||||
for r in range(4):
|
||||
for c in range(3):
|
||||
x0, y0 = centers[(r, c)]
|
||||
x1, y1 = centers[(r, c + 1)]
|
||||
_arrow(ax, (x0 + cube_w / 2, y0), (x1 - cube_w / 2, y1),
|
||||
color=_PALETTE_BLUE, lw=1.5)
|
||||
# Phase 2: col reduce N→S along rightmost column.
|
||||
for r in range(3):
|
||||
x0, y0 = centers[(r, 3)]
|
||||
x1, y1 = centers[(r + 1, 3)]
|
||||
_arrow(ax, (x0, y0 - cube_w / 2), (x1, y1 + cube_w / 2),
|
||||
color=_PALETTE_GREEN, lw=1.7)
|
||||
|
||||
# Phase legend on the right side.
|
||||
legend_x = grid_x0 + grid_total + 0.55
|
||||
ax.text(legend_x, 5.0, "Phase 1: row reduce (W → E)",
|
||||
color=_PALETTE_BLUE, fontsize=10, fontweight="bold")
|
||||
ax.text(legend_x, 4.55, "Phase 2: col reduce (N → S, rightmost col)",
|
||||
color=_PALETTE_GREEN, fontsize=10, fontweight="bold")
|
||||
ax.text(legend_x, 4.10, "Phase 3: inter-SIP exchange at root cube",
|
||||
color=_PALETTE_ROOT_EDGE, fontsize=10, fontweight="bold")
|
||||
ax.text(legend_x, 3.65, "Phase 4: col broadcast (S → N)",
|
||||
color=_PALETTE_GREEN, fontsize=10, style="italic")
|
||||
ax.text(legend_x, 3.20, "Phase 5: row broadcast (E → W)",
|
||||
color=_PALETTE_BLUE, fontsize=10, style="italic")
|
||||
ax.text(legend_x, 2.55,
|
||||
"(broadcast phases reverse phases 2 & 1)",
|
||||
color="gray", fontsize=8.5, style="italic")
|
||||
ax.text(legend_x, 1.7,
|
||||
"Root cube (c15, bottom-right) is the only\n"
|
||||
"cube that performs the inter-SIP exchange.",
|
||||
color=_PALETTE_ROOT_EDGE, fontsize=9, style="italic")
|
||||
|
||||
|
||||
def emit_topology_diagram() -> str:
|
||||
"""Emit a 2×2-panel topology diagram into docs/diagrams/allreduce_latency_plots/.
|
||||
|
||||
Top row: ring_1d | torus_2d (2×3)
|
||||
Bot row: mesh_2d_no_wrap (2×3) | cube-level reduction in SIP 0
|
||||
"""
|
||||
import matplotlib.gridspec as gridspec
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_SWEEP_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fig = plt.figure(figsize=(16, 10), facecolor="white")
|
||||
gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.30, wspace=0.10)
|
||||
ax_ring = fig.add_subplot(gs[0, 0])
|
||||
ax_torus = fig.add_subplot(gs[0, 1])
|
||||
ax_mesh = fig.add_subplot(gs[1, 0])
|
||||
ax_cube = fig.add_subplot(gs[1, 1])
|
||||
|
||||
_draw_ring_topology(ax_ring)
|
||||
_draw_grid_topology(ax_torus, "torus", n_rows=2, n_cols=3)
|
||||
_draw_grid_topology(ax_mesh, "mesh", n_rows=2, n_cols=3)
|
||||
_draw_cube_reduction(ax_cube)
|
||||
|
||||
fig.suptitle(
|
||||
"Allreduce topology — device-level (top: ring, torus, mesh) "
|
||||
"and cube-level reduction in SIP 0",
|
||||
fontsize=14, fontweight="bold", color=_PALETTE_TEXT, y=0.98,
|
||||
)
|
||||
out_path = _SWEEP_OUT_DIR / "topology.png"
|
||||
fig.savefig(out_path, dpi=130, bbox_inches="tight",
|
||||
facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
return str(out_path)
|
||||
|
||||
|
||||
def test_emit_topology_diagram():
|
||||
"""Emit topology.png alongside the sweep plots. Pure plotting; no sim."""
|
||||
out = emit_topology_diagram()
|
||||
assert Path(out).exists()
|
||||
|
||||
@@ -23,7 +23,7 @@ def _engine():
|
||||
def _hbm_pa(sip: int = 0, cube: int = 0, pe_id: int = 0) -> int:
|
||||
slice_bytes = 48 * (1 << 30) // 8
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=sip, cube_id=cube, pe_id=pe_id,
|
||||
sip_id=sip, die_id=cube, pe_id=pe_id,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
return pa.encode()
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Test that tl.recv() (no direction) works under the mock runtime
|
||||
and the SimPy PE_IPCQ component (ADR-0023 D4 weak fairness)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from kernbench.ccl.testing import run_kernel_in_mock
|
||||
|
||||
|
||||
def kernel_round_robin(t_ptr, n_elem, tl):
|
||||
"""Each PE sends one tile E then receives N-1 tiles via round-robin.
|
||||
Uses TensorHandle math (PE_MATH) so Phase 2 produces correct HBM
|
||||
contents under SimPy + op_log replay."""
|
||||
rank = tl.program_id(axis=0)
|
||||
world_size = tl.num_programs(axis=0)
|
||||
nbytes = n_elem * 2
|
||||
|
||||
pe_addr = t_ptr + rank * nbytes
|
||||
acc = tl.load(pe_addr, shape=(n_elem,), dtype="f16")
|
||||
current = acc
|
||||
|
||||
for _step in range(world_size - 1):
|
||||
tl.send(dir="E", src=current)
|
||||
# No direction → round-robin
|
||||
recv = tl.recv(shape=(n_elem,), dtype="f16")
|
||||
acc = acc + recv
|
||||
current = recv # forward W's tile to E next round
|
||||
|
||||
tl.store(pe_addr, acc)
|
||||
|
||||
|
||||
def test_round_robin_recv_mock_runtime():
|
||||
n_elem = 8
|
||||
inputs = [
|
||||
np.full((n_elem,), float(r + 1), dtype=np.float16)
|
||||
for r in range(4)
|
||||
]
|
||||
expected = sum(inputs) # [10,...]
|
||||
|
||||
outputs = run_kernel_in_mock(
|
||||
kernel_fn=kernel_round_robin,
|
||||
world_size=4,
|
||||
topology="ring_1d",
|
||||
inputs=inputs,
|
||||
kernel_args=(n_elem,),
|
||||
)
|
||||
for r in range(4):
|
||||
assert np.allclose(outputs[r], expected)
|
||||
@@ -30,7 +30,7 @@ def _graph():
|
||||
def _hbm_pa(pe_id: int = 0) -> int:
|
||||
slice_bytes = 48 * (1 << 30) // 8
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=0, cube_id=0, pe_id=pe_id,
|
||||
sip_id=0, die_id=0, pe_id=pe_id,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
return pa.encode()
|
||||
@@ -143,10 +143,15 @@ def test_engine_override_is_scoped_to_impl():
|
||||
"""forwarding override (ZeroRouter, no overhead) reduces total_ns.
|
||||
|
||||
Router nodes have overhead_ns=2.0. Replacing with zero-latency impl
|
||||
removes router overhead from the path.
|
||||
removes router overhead from the path. The override class inherits
|
||||
from TransitComponent so it keeps flit-aware pass-through semantics
|
||||
(ADR-0033 Phase 2c); inheriting from bare ComponentBase would force
|
||||
per-hop flit reassembly = store-and-forward, making the override
|
||||
SLOWER than the default and inverting this test.
|
||||
"""
|
||||
from kernbench.components.builtin.forwarding import TransitComponent
|
||||
|
||||
class ZeroRouter(ComponentBase):
|
||||
class ZeroRouter(TransitComponent):
|
||||
def run(self, env, nbytes):
|
||||
yield env.timeout(0)
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""ADR-0009 D5 invariant: all PEs targeted by a single kernel launch MUST
|
||||
begin executing the kernel body at the same simulated time, regardless of
|
||||
their dispatch path length.
|
||||
|
||||
These tests directly verify the invariant by capturing per-PE state at the
|
||||
top of `_execute_kernel`:
|
||||
|
||||
test_no_pe_arrives_after_target_start_ns
|
||||
Asserts: for every PE that enters _execute_kernel during a multi-cube
|
||||
launch, `env.now` at entry must be <= target_start_ns. Otherwise the
|
||||
PE's barrier yield would be a no-op and `pe_exec_start` would be set
|
||||
late, breaking the D5 "same simulated time" mandate.
|
||||
|
||||
test_all_pes_have_identical_pe_exec_start
|
||||
Asserts: every PE's `pe_exec_start` (the value of `env.now` recorded
|
||||
immediately AFTER the barrier yield) is identical across all PEs in
|
||||
the launch.
|
||||
|
||||
Both tests are expected to FAIL today and become the regression check the
|
||||
Phase 2 D5 predictor + fallback fix must make pass.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from kernbench.policy.placement.dp import DPPolicy
|
||||
from kernbench.runtime_api.context import RuntimeContext
|
||||
from kernbench.runtime_api.types import DeviceSelector
|
||||
from kernbench.sim_engine.engine import GraphEngine
|
||||
from kernbench.topology.builder import resolve_topology
|
||||
|
||||
TOPOLOGY_PATH = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
|
||||
def _capture_per_pe_d5_state():
|
||||
"""Monkey-patch PeCpuComponent._execute_kernel to record, per PE:
|
||||
|
||||
- entry_now: env.now at function entry (before any yield)
|
||||
- target_start_ns: the value carried by the request
|
||||
- barrier_yielded: True if the barrier yield fired (entry_now < target)
|
||||
- pe_exec_start: env.now immediately after the barrier check
|
||||
(i.e. the value the original code sets)
|
||||
|
||||
Returns (records: list[dict], restore: callable).
|
||||
"""
|
||||
import kernbench.components.builtin.pe_cpu as pe_cpu_mod
|
||||
|
||||
records: list[dict] = []
|
||||
original = pe_cpu_mod.PeCpuComponent._execute_kernel
|
||||
|
||||
def patched(self, env, txn):
|
||||
request = txn.request
|
||||
target_start = getattr(request, "target_start_ns", None)
|
||||
entry_now = float(env.now)
|
||||
rec = {
|
||||
"node_id": self.node.id,
|
||||
"entry_now": entry_now,
|
||||
"target_start_ns": (
|
||||
float(target_start) if target_start is not None else None
|
||||
),
|
||||
"barrier_yielded": (
|
||||
target_start is not None
|
||||
and float(target_start) > entry_now
|
||||
),
|
||||
"pe_exec_start": None, # filled below by sniff
|
||||
"late_ns": (
|
||||
None if target_start is None
|
||||
else max(0.0, entry_now - float(target_start))
|
||||
),
|
||||
}
|
||||
records.append(rec)
|
||||
|
||||
# We can't easily inject a callback at the original's
|
||||
# `pe_exec_start = env.now` line without rewriting it. Approximate:
|
||||
# if the original yields the barrier, env.now after the yield is
|
||||
# target_start_ns; otherwise pe_exec_start is entry_now (skipped).
|
||||
if rec["barrier_yielded"]:
|
||||
rec["pe_exec_start"] = float(target_start)
|
||||
else:
|
||||
rec["pe_exec_start"] = entry_now
|
||||
|
||||
yield from original(self, env, txn)
|
||||
|
||||
pe_cpu_mod.PeCpuComponent._execute_kernel = patched
|
||||
|
||||
def restore():
|
||||
pe_cpu_mod.PeCpuComponent._execute_kernel = original
|
||||
|
||||
return records, restore
|
||||
|
||||
|
||||
def _run_multicube_launch():
|
||||
"""Drive a no-op kernel launch across all 16 cubes x 8 PEs and return
|
||||
the per-PE D5 records collected by the monkey-patch."""
|
||||
records, restore = _capture_per_pe_d5_state()
|
||||
try:
|
||||
topo = resolve_topology(str(TOPOLOGY_PATH))
|
||||
engine = GraphEngine(topo.topology_obj, enable_data=True)
|
||||
spec = topo.topology_obj.spec
|
||||
|
||||
with RuntimeContext(
|
||||
engine=engine, target_device=DeviceSelector("all"),
|
||||
correlation_id="d5_barrier", spec=spec,
|
||||
) as ctx:
|
||||
dp = DPPolicy(
|
||||
cube="row_wise", pe="column_wise",
|
||||
num_cubes=16, num_pes=8,
|
||||
)
|
||||
|
||||
def kernel(t_ptr, n_elem, tl):
|
||||
pass # no-op
|
||||
|
||||
ctx.ahbm.set_device(0)
|
||||
t = ctx.zeros(
|
||||
(16, 8 * 64), dtype="f16", dp=dp, name="probe",
|
||||
)
|
||||
t.copy_(ctx.from_numpy(
|
||||
np.zeros((16, 8 * 64), dtype=np.float16),
|
||||
))
|
||||
|
||||
pending = ctx.launch(
|
||||
"d5_probe", kernel, t, 64, _defer_wait=True,
|
||||
)
|
||||
for h, _sip, meta in pending:
|
||||
ctx.wait(h, _meta=meta)
|
||||
finally:
|
||||
restore()
|
||||
return records
|
||||
|
||||
|
||||
def test_no_pe_arrives_after_target_start_ns():
|
||||
"""ADR-0009 D5: no PE may enter `_execute_kernel` after target_start_ns.
|
||||
|
||||
Today this fails because IO_CPU's predictor under-shoots actual
|
||||
dispatch latency for far cubes (cube4, cube9-15). Phase 2 fix:
|
||||
chain-aware predictor in IO_CPU + monotonic upward re-stamp in M_CPU.
|
||||
"""
|
||||
records = _run_multicube_launch()
|
||||
assert records, "expected per-PE _execute_kernel records"
|
||||
|
||||
late = [
|
||||
r for r in records
|
||||
if r["target_start_ns"] is not None
|
||||
and r["late_ns"] is not None
|
||||
and r["late_ns"] > 1e-6
|
||||
]
|
||||
|
||||
if late:
|
||||
# Provide actionable diagnostic in the failure.
|
||||
worst = sorted(late, key=lambda r: -r["late_ns"])[:5]
|
||||
details = "\n".join(
|
||||
f" {r['node_id']}: late by {r['late_ns']:.2f} ns "
|
||||
f"(entry_now={r['entry_now']:.2f}, "
|
||||
f"target_start_ns={r['target_start_ns']:.2f})"
|
||||
for r in worst
|
||||
)
|
||||
pytest.fail(
|
||||
f"ADR-0009 D5 violated: {len(late)}/{len(records)} PEs "
|
||||
f"entered _execute_kernel AFTER target_start_ns "
|
||||
f"(barrier yield silently skipped). "
|
||||
f"Worst offenders:\n{details}"
|
||||
)
|
||||
|
||||
|
||||
def test_all_pes_have_identical_pe_exec_start():
|
||||
"""ADR-0009 D5: every PE's pe_exec_start must be identical.
|
||||
|
||||
With D5 honored, every PE either yields to target_start_ns (start =
|
||||
target_start_ns) or, if late, would still be aligned by the M_CPU
|
||||
upward re-stamp (Phase 2). Today: 75/128 PEs in this launch have
|
||||
distinct pe_exec_start values because they skipped the barrier.
|
||||
"""
|
||||
records = _run_multicube_launch()
|
||||
assert records, "expected per-PE _execute_kernel records"
|
||||
|
||||
starts = sorted({round(r["pe_exec_start"], 6) for r in records})
|
||||
if len(starts) > 1:
|
||||
spread = max(starts) - min(starts)
|
||||
# Distribution of how many PEs at each distinct start time
|
||||
from collections import Counter
|
||||
bucket = Counter(round(r["pe_exec_start"], 6) for r in records)
|
||||
details = "\n".join(
|
||||
f" pe_exec_start={t}: {n} PEs"
|
||||
for t, n in sorted(bucket.items())
|
||||
)
|
||||
pytest.fail(
|
||||
f"ADR-0009 D5 violated: PEs have {len(starts)} distinct "
|
||||
f"pe_exec_start values (spread = {spread:.2f} ns); "
|
||||
f"D5 mandates a single common value. "
|
||||
f"Distribution:\n{details}"
|
||||
)
|
||||
@@ -50,7 +50,7 @@ def _hbm_pa(sip: int = 0, cube: int = 0, pe_id: int = 0) -> int:
|
||||
from kernbench.policy.address.phyaddr import PhysAddr
|
||||
slice_bytes = 48 * (1 << 30) // 8
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=sip, cube_id=cube, pe_id=pe_id,
|
||||
sip_id=sip, die_id=cube, pe_id=pe_id,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
return pa.encode()
|
||||
|
||||
@@ -31,7 +31,7 @@ def _hbm_pa(sip=0, cube=0, pe_id=0):
|
||||
from kernbench.policy.address.phyaddr import PhysAddr
|
||||
slice_bytes = 48 * (1 << 30) // 8
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=sip, cube_id=cube, pe_id=pe_id,
|
||||
sip_id=sip, die_id=cube, pe_id=pe_id,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
return pa.encode()
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
"""High-level IPCQ + SFR connection diagram (presentation only).
|
||||
|
||||
Renders ``docs/diagrams/ipcq_diagram_plots/ipcq_send_recv.png`` showing one
|
||||
concrete example: SIP 0 / cube 0 / pe 0 sending to pe 1 in the
|
||||
``intra_E`` direction. Boxes and arrows are grounded in the actual
|
||||
code paths:
|
||||
|
||||
- PE_IPCQ SFR fields: src/kernbench/components/builtin/pe_ipcq.py
|
||||
- SFR install: src/kernbench/ccl/install.py +
|
||||
src/kernbench/ccl/sfr_config.py
|
||||
- PE_DMA outbound /
|
||||
inbound atomic write: src/kernbench/components/builtin/pe_dma.py
|
||||
|
||||
This is a pure-plotting test (no simulation). It exists so the diagram
|
||||
can be regenerated reproducibly alongside the rest of the suite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_OUT_DIR = (Path(__file__).parent.parent / "docs" / "diagrams"
|
||||
/ "ipcq_diagram_plots")
|
||||
|
||||
# Color palette (matches the topology diagram for visual continuity).
|
||||
_BG = "#fafbfd"
|
||||
_FRAME = "#3a3f4a"
|
||||
_TEXT = "#1f2530"
|
||||
_BLUE = "#2c6fb6"
|
||||
_GREEN = "#2e8a4e"
|
||||
_ORANGE = "#d3722a"
|
||||
_PURPLE = "#7a4cb6"
|
||||
_BOX_FILL = "#eaf2fb"
|
||||
_BOX_EDGE = "#2c4a78"
|
||||
_HW_FILL = "#f3ecda"
|
||||
_HW_EDGE = "#a07a2a"
|
||||
_MEM_FILL = "#e8f3e8"
|
||||
_MEM_EDGE = "#2e8a4e"
|
||||
|
||||
|
||||
def _box(ax, x, y, w, h, title, lines, *, fill=_BOX_FILL, edge=_BOX_EDGE,
|
||||
title_color=None, font=9):
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
box = FancyBboxPatch(
|
||||
(x, y), w, h,
|
||||
boxstyle="round,pad=0.04,rounding_size=0.18",
|
||||
linewidth=1.6, edgecolor=edge, facecolor=fill, zorder=2,
|
||||
)
|
||||
ax.add_patch(box)
|
||||
ax.text(x + w / 2, y + h - 0.45, title,
|
||||
ha="center", va="top", fontsize=font + 1.5,
|
||||
fontweight="bold",
|
||||
color=title_color or edge, zorder=3)
|
||||
for i, line in enumerate(lines):
|
||||
ax.text(
|
||||
x + 0.25, y + h - 1.1 - i * 0.45, line,
|
||||
ha="left", va="top", fontsize=font - 0.5, color=_TEXT,
|
||||
family="monospace", zorder=3,
|
||||
)
|
||||
|
||||
|
||||
def _arrow(ax, xy_from, xy_to, *, color=_BLUE, lw=1.8, curve=0.0,
|
||||
style="-|>", alpha=1.0, zorder=4):
|
||||
from matplotlib.patches import FancyArrowPatch
|
||||
arrow = FancyArrowPatch(
|
||||
xy_from, xy_to,
|
||||
arrowstyle=style, mutation_scale=14,
|
||||
color=color, lw=lw, alpha=alpha,
|
||||
connectionstyle=f"arc3,rad={curve}",
|
||||
zorder=zorder,
|
||||
)
|
||||
ax.add_patch(arrow)
|
||||
|
||||
|
||||
def _step_label(ax, x, y, n, text, color=_BLUE):
|
||||
from matplotlib.patches import Circle
|
||||
ax.add_patch(Circle((x, y), 0.28, facecolor=color, edgecolor="white",
|
||||
linewidth=1.4, zorder=5))
|
||||
ax.text(x, y, str(n), ha="center", va="center", fontsize=9,
|
||||
fontweight="bold", color="white", zorder=6)
|
||||
ax.text(x + 0.45, y, text, ha="left", va="center", fontsize=9,
|
||||
color=_TEXT, zorder=6)
|
||||
|
||||
|
||||
def emit_ipcq_diagram() -> str:
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import FancyBboxPatch, Rectangle
|
||||
|
||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fig, ax = plt.subplots(figsize=(18, 11), facecolor="white")
|
||||
ax.set_xlim(0, 22)
|
||||
ax.set_ylim(0, 14)
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
ax.set_facecolor(_BG)
|
||||
|
||||
# Outer panel border.
|
||||
border = FancyBboxPatch(
|
||||
(0.15, 0.15), 21.7, 13.7,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.20",
|
||||
linewidth=1.4, edgecolor=_FRAME, facecolor=_BG, zorder=0,
|
||||
)
|
||||
ax.add_patch(border)
|
||||
|
||||
ax.set_title(
|
||||
"IPCQ — SFR state and send/recv path between pe0 and pe1 "
|
||||
"(intra_E direction, SIP 0 / cube 0)",
|
||||
fontsize=14, fontweight="bold", color=_TEXT, pad=12,
|
||||
)
|
||||
|
||||
# ── pe0 side (left half) ────────────────────────────────────────
|
||||
_box(
|
||||
ax, x=0.8, y=8.4, w=8.4, h=5.0,
|
||||
title="pe0.pe_ipcq (SFR — direction: intra_E)",
|
||||
lines=[
|
||||
"neighbor_table[intra_E]:",
|
||||
" peer = sip0.cube0.pe1",
|
||||
" peer.rx_base_pa → pe1's intra_W slot ring",
|
||||
" my_rx_base_pa → pe0's intra_E slot ring",
|
||||
" n_slots = 8 slot_size = 512 B",
|
||||
"",
|
||||
"head/tail counters (per direction):",
|
||||
" my_head # ++ on tl.send",
|
||||
" my_tail # ++ on tl.recv",
|
||||
" peer_head_cache # updated on IpcqMetaArrival",
|
||||
" peer_tail_cache # updated on IpcqCreditMetadata",
|
||||
"",
|
||||
"send blocks while (my_head − peer_tail_cache) ≥ n_slots",
|
||||
],
|
||||
edge=_BOX_EDGE, fill=_BOX_FILL,
|
||||
)
|
||||
|
||||
_box(
|
||||
ax, x=0.8, y=4.5, w=8.4, h=2.7,
|
||||
title="pe0.pe_dma (outbound IPCQ driver)",
|
||||
lines=[
|
||||
"_handle_ipcq_outbound():",
|
||||
" • snapshot src bytes from MemoryStore",
|
||||
" • find fabric path → pe1.pe_dma",
|
||||
" • send Transaction; do NOT wait (fire-and-forget)",
|
||||
],
|
||||
edge=_HW_EDGE, fill=_HW_FILL,
|
||||
)
|
||||
|
||||
# ── pe1 side (right half) ───────────────────────────────────────
|
||||
_box(
|
||||
ax, x=12.8, y=8.4, w=8.4, h=5.0,
|
||||
title="pe1.pe_ipcq (SFR — direction: intra_W)",
|
||||
lines=[
|
||||
"neighbor_table[intra_W]:",
|
||||
" peer = sip0.cube0.pe0",
|
||||
" peer.rx_base_pa → pe0's intra_E slot ring",
|
||||
" my_rx_base_pa → pe1's intra_W slot ring",
|
||||
" n_slots = 8 slot_size = 512 B",
|
||||
"",
|
||||
"head/tail counters (per direction):",
|
||||
" my_head # ++ on tl.send (other direction)",
|
||||
" my_tail # ++ on tl.recv (this direction)",
|
||||
" peer_head_cache # updated on IpcqMetaArrival",
|
||||
" peer_tail_cache # updated on IpcqCreditMetadata",
|
||||
"",
|
||||
"recv blocks while peer_head_cache ≤ my_tail",
|
||||
],
|
||||
edge=_BOX_EDGE, fill=_BOX_FILL,
|
||||
)
|
||||
|
||||
_box(
|
||||
ax, x=12.8, y=4.5, w=8.4, h=2.7,
|
||||
title="pe1.pe_dma (inbound IPCQ driver)",
|
||||
lines=[
|
||||
"_handle_ipcq_inbound():",
|
||||
" • pay terminal drain over fabric BW",
|
||||
" • atomic: write data into pe1's intra_W slot",
|
||||
" • forward IpcqMetaArrival → pe1.pe_ipcq",
|
||||
],
|
||||
edge=_HW_EDGE, fill=_HW_FILL,
|
||||
)
|
||||
|
||||
# ── Slot ring buffer (under pe1.pe_dma) ─────────────────────────
|
||||
ring_x0, ring_y0 = 12.8, 1.1
|
||||
ring_w, ring_h = 8.4, 2.6
|
||||
box = FancyBboxPatch(
|
||||
(ring_x0, ring_y0), ring_w, ring_h,
|
||||
boxstyle="round,pad=0.04,rounding_size=0.16",
|
||||
linewidth=1.6, edgecolor=_MEM_EDGE, facecolor=_MEM_FILL, zorder=2,
|
||||
)
|
||||
ax.add_patch(box)
|
||||
ax.text(
|
||||
ring_x0 + ring_w / 2, ring_y0 + ring_h - 0.42,
|
||||
"MemoryStore[buffer_kind] pe1's intra_W slot ring "
|
||||
"(n_slots = 8, slot_size = 512 B)",
|
||||
ha="center", va="top", fontsize=10, fontweight="bold",
|
||||
color=_MEM_EDGE, zorder=3,
|
||||
)
|
||||
# 8 slots laid out horizontally inside the ring panel.
|
||||
n_slots = 8
|
||||
pad = 0.35
|
||||
slot_w = (ring_w - 2 * pad) / n_slots
|
||||
slot_h = 0.85
|
||||
slot_y = ring_y0 + 0.3
|
||||
for i in range(n_slots):
|
||||
sx = ring_x0 + pad + i * slot_w
|
||||
is_active = (i == 3) # Highlight one example slot
|
||||
face = "#ffd9b8" if is_active else "white"
|
||||
edge = _ORANGE if is_active else _MEM_EDGE
|
||||
rect = Rectangle(
|
||||
(sx + 0.05, slot_y), slot_w - 0.10, slot_h,
|
||||
linewidth=1.2, facecolor=face, edgecolor=edge, zorder=3,
|
||||
)
|
||||
ax.add_patch(rect)
|
||||
ax.text(
|
||||
sx + slot_w / 2, slot_y + slot_h / 2,
|
||||
f"s{i}", ha="center", va="center", fontsize=9,
|
||||
color=_ORANGE if is_active else _TEXT,
|
||||
fontweight="bold" if is_active else "normal", zorder=4,
|
||||
)
|
||||
ax.text(
|
||||
ring_x0 + pad + 3 * slot_w + slot_w / 2, slot_y - 0.30,
|
||||
"slot_idx = my_head % n_slots",
|
||||
ha="center", va="top", fontsize=8, style="italic",
|
||||
color=_ORANGE,
|
||||
)
|
||||
|
||||
# ── Fabric label (between pe0.pe_dma and pe1.pe_dma) ────────────
|
||||
fab = FancyBboxPatch(
|
||||
(9.6, 5.0), 2.6, 1.7,
|
||||
boxstyle="round,pad=0.04,rounding_size=0.20",
|
||||
linewidth=1.4, edgecolor=_PURPLE, facecolor="white", zorder=2,
|
||||
)
|
||||
ax.add_patch(fab)
|
||||
ax.text(10.9, 6.4, "Fabric", ha="center", va="center",
|
||||
fontsize=11, fontweight="bold", color=_PURPLE)
|
||||
ax.text(10.9, 5.7, "(NoC routers,\npe_dma → pe_dma)",
|
||||
ha="center", va="center", fontsize=8, color=_TEXT)
|
||||
|
||||
# ── Arrows + step labels ────────────────────────────────────────
|
||||
# 1. tl.send ↘ pe0.pe_ipcq
|
||||
_arrow(ax, (9.2, 12.9), (9.7, 12.9), color=_BLUE) # placeholder so number lands
|
||||
_step_label(ax, 0.5, 13.6,
|
||||
1, "kernel calls tl.send(dir='intra_E', src_addr=X)",
|
||||
color=_BLUE)
|
||||
# 2. pe0.pe_ipcq → pe0.pe_dma (IpcqDmaToken)
|
||||
_arrow(ax, (5.0, 8.4), (5.0, 7.2), color=_BLUE, lw=2.0)
|
||||
ax.text(5.2, 7.85, "IpcqDmaToken\n"
|
||||
"dst = peer.rx_base_pa + slot_idx*512",
|
||||
ha="left", va="center", fontsize=8, color=_BLUE,
|
||||
family="monospace")
|
||||
# 3. pe0.pe_dma → fabric → pe1.pe_dma (data, fire-and-forget)
|
||||
_arrow(ax, (9.2, 5.85), (9.6, 5.85), color=_BLUE, lw=2.0)
|
||||
_arrow(ax, (12.2, 5.85), (12.8, 5.85), color=_BLUE, lw=2.0)
|
||||
ax.text(10.9, 4.7, "data (fire-and-forget)",
|
||||
ha="center", va="center", fontsize=8, style="italic",
|
||||
color=_BLUE)
|
||||
# 4. pe1.pe_dma → MemoryStore slot (atomic)
|
||||
_arrow(ax, (17.0, 4.5), (17.0, 3.7), color=_GREEN, lw=2.0)
|
||||
ax.text(17.2, 4.10, "atomic write",
|
||||
ha="left", va="center", fontsize=8, color=_GREEN,
|
||||
family="monospace")
|
||||
# 5. pe1.pe_dma → pe1.pe_ipcq (IpcqMetaArrival)
|
||||
_arrow(ax, (15.0, 7.2), (15.0, 8.4), color=_GREEN, lw=2.0)
|
||||
ax.text(13.0, 7.85, "IpcqMetaArrival\n"
|
||||
"→ peer_head_cache update",
|
||||
ha="left", va="center", fontsize=8, color=_GREEN,
|
||||
family="monospace")
|
||||
# 6. tl.recv unblocks (annotation only)
|
||||
_step_label(ax, 12.85, 13.6,
|
||||
6, "tl.recv(dir='intra_W') unblocks; consume slot; my_tail++",
|
||||
color=_GREEN)
|
||||
# 7. pe1.pe_ipcq → pe0.pe_ipcq (IpcqCreditMetadata, fast-path SimPy Store)
|
||||
_arrow(ax, (12.8, 11.0), (9.2, 11.0),
|
||||
color=_ORANGE, lw=2.0, curve=0.18)
|
||||
ax.text(11.0, 11.55,
|
||||
"IpcqCreditMetadata (consumer_seq, dst_rx_base_pa)\n"
|
||||
"→ pe0's credit_inbox (SimPy Store, no fabric)",
|
||||
ha="center", va="center", fontsize=8, color=_ORANGE,
|
||||
family="monospace")
|
||||
# 8. pe0.peer_tail_cache update unblocks tl.send
|
||||
ax.text(0.5, 0.55,
|
||||
"Steps 1–3 = data path (fabric, fire-and-forget); "
|
||||
"4–6 = receiver wake-up; 7 = credit return (fast path); "
|
||||
"8 = sender unblocks when peer_tail_cache catches up.",
|
||||
ha="left", va="center", fontsize=9, color=_TEXT,
|
||||
style="italic")
|
||||
|
||||
# In-figure step legend (top, between pe0/pe1 panels).
|
||||
legend_x = 9.4
|
||||
legend_y = 13.5
|
||||
_step_label(ax, legend_x, legend_y, 2,
|
||||
"PE_IPCQ → PE_DMA (token)", color=_BLUE)
|
||||
_step_label(ax, legend_x, legend_y - 0.45, 3,
|
||||
"PE_DMA → fabric → PE_DMA (data)", color=_BLUE)
|
||||
_step_label(ax, legend_x, legend_y - 0.90, 4,
|
||||
"atomic slot write", color=_GREEN)
|
||||
_step_label(ax, legend_x, legend_y - 1.35, 5,
|
||||
"IpcqMetaArrival", color=_GREEN)
|
||||
_step_label(ax, legend_x, legend_y - 1.80, 7,
|
||||
"IpcqCreditMetadata", color=_ORANGE)
|
||||
|
||||
out_path = _OUT_DIR / "ipcq_send_recv.png"
|
||||
fig.savefig(out_path, dpi=130, bbox_inches="tight",
|
||||
facecolor=fig.get_facecolor())
|
||||
|
||||
import matplotlib.pyplot as _plt
|
||||
_plt.close(fig)
|
||||
return str(out_path)
|
||||
|
||||
|
||||
def test_emit_ipcq_diagram():
|
||||
out = emit_ipcq_diagram()
|
||||
assert Path(out).exists()
|
||||
|
||||
|
||||
# ── 2nd diagram: two-PE data + DMA + IPCQ-memory layout ──────────────
|
||||
|
||||
|
||||
def _pe_panel(ax, x0, y0, w, h, label, *, edge=_FRAME, fill="white"):
|
||||
"""Outer container for one PE: title bar + body."""
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
box = FancyBboxPatch(
|
||||
(x0, y0), w, h,
|
||||
boxstyle="round,pad=0.04,rounding_size=0.20",
|
||||
linewidth=1.8, edgecolor=edge, facecolor=fill, zorder=1,
|
||||
)
|
||||
ax.add_patch(box)
|
||||
# Title band
|
||||
title_h = 0.55
|
||||
band = FancyBboxPatch(
|
||||
(x0 + 0.12, y0 + h - title_h - 0.10), w - 0.24, title_h,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.10",
|
||||
linewidth=0, edgecolor="none", facecolor=edge, zorder=2,
|
||||
)
|
||||
ax.add_patch(band)
|
||||
ax.text(
|
||||
x0 + w / 2, y0 + h - title_h / 2 - 0.10, label,
|
||||
ha="center", va="center", fontsize=12, fontweight="bold",
|
||||
color="white", zorder=3,
|
||||
)
|
||||
|
||||
|
||||
def _sub_block(ax, cx, cy, w, h, title, body_lines, *,
|
||||
fill, edge, font=9):
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
rect = FancyBboxPatch(
|
||||
(cx - w / 2, cy - h / 2), w, h,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.10",
|
||||
linewidth=1.4, edgecolor=edge, facecolor=fill, zorder=3,
|
||||
)
|
||||
ax.add_patch(rect)
|
||||
ax.text(cx, cy + h / 2 - 0.30, title, ha="center", va="top",
|
||||
fontsize=font + 1, fontweight="bold", color=edge, zorder=4)
|
||||
for i, line in enumerate(body_lines):
|
||||
ax.text(
|
||||
cx, cy + h / 2 - 0.75 - i * 0.34, line,
|
||||
ha="center", va="top", fontsize=font - 0.5, color=_TEXT,
|
||||
family="monospace", zorder=4,
|
||||
)
|
||||
|
||||
|
||||
def _tcm_with_slots(ax, cx, cy, w, h, *, n_slots=8, active_slot=3,
|
||||
title="PE_TCM (local memory)"):
|
||||
"""Draw a TCM box that contains a source buffer + IPCQ slot ring."""
|
||||
from matplotlib.patches import FancyBboxPatch, Rectangle
|
||||
rect = FancyBboxPatch(
|
||||
(cx - w / 2, cy - h / 2), w, h,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.10",
|
||||
linewidth=1.4, edgecolor=_MEM_EDGE, facecolor=_MEM_FILL, zorder=3,
|
||||
)
|
||||
ax.add_patch(rect)
|
||||
ax.text(
|
||||
cx, cy + h / 2 - 0.28, title, ha="center", va="top",
|
||||
fontsize=9.5, fontweight="bold", color=_MEM_EDGE, zorder=4,
|
||||
)
|
||||
|
||||
# Source buffer region (left part).
|
||||
src_w = (w - 0.6) * 0.30
|
||||
src_h = h - 1.20
|
||||
sx = cx - w / 2 + 0.20
|
||||
sy = cy - h / 2 + 0.20
|
||||
src_rect = Rectangle(
|
||||
(sx, sy), src_w, src_h,
|
||||
linewidth=1.0, facecolor="white", edgecolor=_BLUE, zorder=4,
|
||||
)
|
||||
ax.add_patch(src_rect)
|
||||
ax.text(sx + src_w / 2, sy + src_h / 2 + 0.18, "source",
|
||||
ha="center", va="center", fontsize=8.5, color=_BLUE,
|
||||
fontweight="bold", zorder=5)
|
||||
ax.text(sx + src_w / 2, sy + src_h / 2 - 0.18, "buffer",
|
||||
ha="center", va="center", fontsize=8.5, color=_BLUE,
|
||||
fontweight="bold", zorder=5)
|
||||
|
||||
# Slot ring region (right part).
|
||||
ring_x0 = sx + src_w + 0.30
|
||||
ring_w = (cx + w / 2 - 0.20) - ring_x0
|
||||
ring_y0 = sy
|
||||
ring_h = src_h
|
||||
ring_rect = Rectangle(
|
||||
(ring_x0, ring_y0), ring_w, ring_h,
|
||||
linewidth=1.0, facecolor="white", edgecolor=_ORANGE, zorder=4,
|
||||
)
|
||||
ax.add_patch(ring_rect)
|
||||
ax.text(
|
||||
ring_x0 + ring_w / 2, ring_y0 + ring_h - 0.18,
|
||||
"IPCQ slot ring (intra_W)",
|
||||
ha="center", va="top", fontsize=8.5, color=_ORANGE,
|
||||
fontweight="bold", zorder=5,
|
||||
)
|
||||
# Draw 8 slots in a 2×4 grid.
|
||||
cols = 4
|
||||
rows = 2
|
||||
slot_inner_pad = 0.12
|
||||
sw = (ring_w - (cols + 1) * slot_inner_pad) / cols
|
||||
sh = (ring_h - 0.65 - (rows + 1) * slot_inner_pad) / rows
|
||||
for i in range(n_slots):
|
||||
r = i // cols
|
||||
c = i % cols
|
||||
sx_i = ring_x0 + slot_inner_pad + c * (sw + slot_inner_pad)
|
||||
sy_i = (ring_y0 + slot_inner_pad
|
||||
+ (rows - 1 - r) * (sh + slot_inner_pad))
|
||||
is_active = (i == active_slot)
|
||||
face = "#ffd9b8" if is_active else "white"
|
||||
edge = _ORANGE if is_active else "#c9c9c9"
|
||||
ax.add_patch(Rectangle(
|
||||
(sx_i, sy_i), sw, sh,
|
||||
linewidth=1.0, facecolor=face, edgecolor=edge, zorder=5,
|
||||
))
|
||||
ax.text(
|
||||
sx_i + sw / 2, sy_i + sh / 2, f"s{i}",
|
||||
ha="center", va="center", fontsize=8,
|
||||
fontweight="bold" if is_active else "normal",
|
||||
color=_ORANGE if is_active else "#666",
|
||||
zorder=6,
|
||||
)
|
||||
|
||||
|
||||
def emit_ipcq_dma_diagram() -> str:
|
||||
"""Two-PE diagram emphasising: outbound DMA writes DIRECTLY into the
|
||||
receiver's local memory (slot ring in PE_TCM). pe1.pe_dma is the
|
||||
inbound memory port that pays drain + emits the MetaArrival notice;
|
||||
the actual DMA payload terminates in the slot, not in another DMA.
|
||||
"""
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
|
||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fig, ax = plt.subplots(figsize=(22, 12), facecolor="white")
|
||||
XMAX, YMAX = 28.0, 14.0
|
||||
ax.set_xlim(0, XMAX)
|
||||
ax.set_ylim(0, YMAX)
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
ax.set_facecolor(_BG)
|
||||
|
||||
# Outer page border.
|
||||
ax.add_patch(FancyBboxPatch(
|
||||
(0.20, 0.20), XMAX - 0.40, YMAX - 0.40,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.20",
|
||||
linewidth=1.4, edgecolor=_FRAME, facecolor=_BG, zorder=0,
|
||||
))
|
||||
|
||||
ax.set_title(
|
||||
"Two PEs over IPCQ — outbound DMA lands DIRECTLY in receiver "
|
||||
"memory (slot ring in PE_TCM)",
|
||||
fontsize=14, fontweight="bold", color=_TEXT, pad=12,
|
||||
)
|
||||
|
||||
# ── PE panels ───────────────────────────────────────────────────
|
||||
PE0_X, PE0_W = 0.8, 11.6
|
||||
PE1_X, PE1_W = 15.6, 11.6
|
||||
PE_Y, PE_H = 1.6, 10.4
|
||||
|
||||
_pe_panel(ax, x0=PE0_X, y0=PE_Y, w=PE0_W, h=PE_H,
|
||||
label="PE 0 (sender — sip0.cube0.pe0)",
|
||||
edge=_BLUE, fill="white")
|
||||
_pe_panel(ax, x0=PE1_X, y0=PE_Y, w=PE1_W, h=PE_H,
|
||||
label="PE 1 (receiver — sip0.cube0.pe1)",
|
||||
edge=_GREEN, fill="white")
|
||||
|
||||
# ── PE 0 sub-blocks ─────────────────────────────────────────────
|
||||
# Top row: PE_CPU and PE_IPCQ
|
||||
_sub_block(
|
||||
ax, cx=PE0_X + 2.5, cy=10.3, w=3.4, h=1.6,
|
||||
title="PE_CPU",
|
||||
body_lines=["kernel:",
|
||||
" tl.send(dir='intra_E',",
|
||||
" src=ptr)"],
|
||||
fill=_BOX_FILL, edge=_BOX_EDGE,
|
||||
)
|
||||
_sub_block(
|
||||
ax, cx=PE0_X + 8.4, cy=10.3, w=4.0, h=1.6,
|
||||
title="PE_IPCQ (control / SFR)",
|
||||
body_lines=["per-direction state:",
|
||||
" head/tail, peer.rx_base_pa,",
|
||||
" peer_tail_cache"],
|
||||
fill=_BOX_FILL, edge=_BOX_EDGE,
|
||||
)
|
||||
# Mid: PE_TCM (left, with src + slot ring) and PE_DMA outbound (right)
|
||||
_tcm_with_slots(
|
||||
ax, cx=PE0_X + 3.0, cy=5.4, w=5.6, h=3.6,
|
||||
n_slots=8, active_slot=-1,
|
||||
title="PE_TCM (local memory · buffer_kind = tcm)",
|
||||
)
|
||||
_sub_block(
|
||||
ax, cx=PE0_X + 8.6, cy=5.4, w=3.6, h=3.6,
|
||||
title="PE_DMA (outbound)",
|
||||
body_lines=["snapshot src bytes",
|
||||
" from PE_TCM",
|
||||
"build Transaction",
|
||||
" (dst = peer's slot PA)",
|
||||
"fire onto fabric;",
|
||||
" do not wait for ack"],
|
||||
fill=_HW_FILL, edge=_HW_EDGE,
|
||||
)
|
||||
# Arrows on PE 0 side
|
||||
_arrow(ax, (PE0_X + 4.20, 10.3), (PE0_X + 6.40, 10.3),
|
||||
color=_BLUE, lw=1.7)
|
||||
ax.text(PE0_X + 5.30, 10.65, "tl.send",
|
||||
ha="center", va="center", fontsize=8.5, color=_BLUE,
|
||||
fontweight="bold")
|
||||
# PE_IPCQ → PE_DMA control (kept; label removed per request)
|
||||
_arrow(ax, (PE0_X + 8.4, 9.50), (PE0_X + 8.6, 7.20),
|
||||
color=_ORANGE, lw=1.6)
|
||||
# PE_TCM(src) → PE_DMA (read source data)
|
||||
_arrow(ax, (PE0_X + 5.80, 5.40), (PE0_X + 6.80, 5.40),
|
||||
color=_BLUE, lw=2.0)
|
||||
ax.text(PE0_X + 6.30, 6.05, "read source\n(snapshot)",
|
||||
ha="center", va="bottom", fontsize=7.5, color=_BLUE,
|
||||
family="monospace")
|
||||
|
||||
# ── Fabric in the middle ────────────────────────────────────────
|
||||
FAB_X0, FAB_W = 12.6, 2.8
|
||||
FAB_Y0, FAB_H = 4.6, 2.2
|
||||
ax.add_patch(FancyBboxPatch(
|
||||
(FAB_X0, FAB_Y0), FAB_W, FAB_H,
|
||||
boxstyle="round,pad=0.04,rounding_size=0.20",
|
||||
linewidth=1.6, edgecolor=_PURPLE, facecolor="white", zorder=2,
|
||||
))
|
||||
ax.text(FAB_X0 + FAB_W / 2, FAB_Y0 + FAB_H - 0.45,
|
||||
"NoC Fabric", ha="center", va="center",
|
||||
fontsize=12, fontweight="bold", color=_PURPLE)
|
||||
ax.text(FAB_X0 + FAB_W / 2, FAB_Y0 + 0.55,
|
||||
"(routers, links;\nfabric BW + drain time)",
|
||||
ha="center", va="center", fontsize=8.5, color=_TEXT)
|
||||
|
||||
# ── PE 1 sub-blocks ─────────────────────────────────────────────
|
||||
# Top row: PE_IPCQ and PE_CPU
|
||||
_sub_block(
|
||||
ax, cx=PE1_X + 3.2, cy=10.3, w=4.0, h=1.6,
|
||||
title="PE_IPCQ (control / SFR)",
|
||||
body_lines=["per-direction state:",
|
||||
" head/tail, peer_head_cache,",
|
||||
" my_rx_base_pa"],
|
||||
fill=_BOX_FILL, edge=_BOX_EDGE,
|
||||
)
|
||||
_sub_block(
|
||||
ax, cx=PE1_X + 9.1, cy=10.3, w=3.4, h=1.6,
|
||||
title="PE_CPU",
|
||||
body_lines=["kernel:",
|
||||
" ptr = tl.recv(",
|
||||
" dir='intra_W')"],
|
||||
fill=_BOX_FILL, edge=_BOX_EDGE,
|
||||
)
|
||||
# Wide PE_TCM occupying the centre-bottom of PE 1 — the DMA payload
|
||||
# terminates HERE (not in any DMA component).
|
||||
_tcm_with_slots(
|
||||
ax, cx=PE1_X + 5.0, cy=5.4, w=8.4, h=3.6,
|
||||
n_slots=8, active_slot=3,
|
||||
title="PE_TCM (local memory · buffer_kind = tcm)",
|
||||
)
|
||||
|
||||
# ── DATA arrows: outbound DMA ──► RECEIVER MEMORY (the slot) ───
|
||||
# The inbound PE_DMA is NOT on the data path — it's a sim-side
|
||||
# bookkeeper that pays terminal drain + emits MetaArrival. The
|
||||
# actual DMA payload jumps fabric → slot directly.
|
||||
# 1) pe0.PE_DMA → fabric
|
||||
_arrow(ax, (PE0_X + 10.40, 5.40), (FAB_X0, 5.40),
|
||||
color=_BLUE, lw=2.8)
|
||||
# 2) fabric → PE_TCM slot s3 (DMA payload terminates IN MEMORY)
|
||||
SLOT_X = PE1_X + 2.95 # x-coordinate of slot s3 within PE_TCM
|
||||
_arrow(ax, (FAB_X0 + FAB_W, 5.40), (SLOT_X, 5.40),
|
||||
color=_BLUE, lw=2.8)
|
||||
|
||||
# PE_IPCQ → PE_CPU: tl.recv unblocks
|
||||
_arrow(ax, (PE1_X + 5.20, 10.30), (PE1_X + 7.40, 10.30),
|
||||
color=_GREEN, lw=1.7)
|
||||
ax.text(PE1_X + 6.30, 10.65, "unblock tl.recv",
|
||||
ha="center", va="center", fontsize=8.5, color=_GREEN,
|
||||
fontweight="bold")
|
||||
# PE_CPU → PE_TCM: kernel reads consumed slot via returned ptr
|
||||
_arrow(ax, (PE1_X + 9.10, 9.50), (PE1_X + 8.10, 7.20),
|
||||
color=_GREEN, lw=1.4, curve=0.10)
|
||||
ax.text(PE1_X + 9.30, 8.30, "kernel reads\nslot data",
|
||||
ha="left", va="center", fontsize=7.5, color=_GREEN)
|
||||
|
||||
# (Credit-return arrow + label removed per request — see code
|
||||
# for the actual mechanism: pe1.pe_ipcq → pe0.credit_inbox via
|
||||
# SimPy Store after env.timeout(fabric_path_latency_ns).)
|
||||
|
||||
# ── Footer legend ──────────────────────────────────────────────
|
||||
ax.text(0.6, 0.85,
|
||||
"DATA (blue) : pe0 PE_TCM[src] → pe0 PE_DMA → "
|
||||
"NoC fabric → pe1 PE_TCM[slot s3] ← DMA write "
|
||||
"terminates IN MEMORY",
|
||||
ha="left", va="center", fontsize=9, color=_TEXT,
|
||||
style="italic")
|
||||
ax.text(0.6, 0.45,
|
||||
"CTRL (orange) : PE_IPCQ issues IpcqDmaToken on send; "
|
||||
"pe1's inbound port emits MetaArrival; credit return "
|
||||
"uses the fabric path (timing) but bypasses the per-hop "
|
||||
"component graph (D9 fast path).",
|
||||
ha="left", va="center", fontsize=9, color=_TEXT,
|
||||
style="italic")
|
||||
|
||||
out_path = _OUT_DIR / "ipcq_two_pe_dma.png"
|
||||
fig.savefig(out_path, dpi=130, bbox_inches="tight",
|
||||
facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
return str(out_path)
|
||||
|
||||
|
||||
def test_emit_ipcq_dma_diagram():
|
||||
out = emit_ipcq_dma_diagram()
|
||||
assert Path(out).exists()
|
||||
@@ -29,7 +29,7 @@ def _hbm_pa(sip: int = 0, cube: int = 0, pe_id: int = 0) -> int:
|
||||
# 48 GB / 8 slices = 6 GB per slice
|
||||
slice_bytes = 48 * (1 << 30) // 8
|
||||
pa = PhysAddr.pe_hbm_addr(
|
||||
rack_id=0, sip_id=sip, cube_id=cube, pe_id=pe_id,
|
||||
sip_id=sip, die_id=cube, pe_id=pe_id,
|
||||
pe_local_hbm_offset=0x1000, slice_size_bytes=slice_bytes,
|
||||
)
|
||||
return pa.encode()
|
||||
@@ -37,7 +37,7 @@ def _hbm_pa(sip: int = 0, cube: int = 0, pe_id: int = 0) -> int:
|
||||
|
||||
def _sram_pa(sip: int = 0, cube: int = 0) -> int:
|
||||
"""Create an SRAM physical address."""
|
||||
pa = PhysAddr.cube_sram_addr(rack_id=0, sip_id=sip, cube_id=cube, sram_offset=0x800)
|
||||
pa = PhysAddr.cube_sram_addr(sip_id=sip, die_id=cube, sram_offset=0x800)
|
||||
return pa.encode()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Tests for flit-streaming latency model (ADR-0033 v2 / Max F).
|
||||
|
||||
The Phase 2 changes split every transaction's payload into flits of
|
||||
`flit_bytes` and stream them through the fabric via wires. Routers do RR
|
||||
arbitration between active flows at output ports. The HBM CTRL receives
|
||||
flits individually and dispatches each to a PC. This eliminates the
|
||||
atomic-FIFO wire serialization that caused timing drift in slow-upstream
|
||||
and multi-stream-merge scenarios.
|
||||
|
||||
Naming note (ADR-0033 D1/D2): we use NoC terminology — a `Flit` is the
|
||||
atomic wire transport unit. For modeling tractability our `flit_bytes`
|
||||
equals the HBM `burst_bytes` (256B). Real HW has flit (~32B) smaller
|
||||
than burst (~256B); we conflate the two. See ADR-0033 D2 for the
|
||||
fidelity caveat.
|
||||
|
||||
Chunking happens AT THE WIRE: source components emit whole Transactions,
|
||||
the wire decomposes them into Flits on first transport, downstream wires
|
||||
pass Flits through. Source code is unchanged.
|
||||
|
||||
These tests are written BEFORE the production change and are expected to
|
||||
FAIL on current code (which still does Transaction-atomic wire delivery).
|
||||
Phase 2 must make them PASS without weakening assertions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kernbench.policy.address.phyaddr import PhysAddr
|
||||
from kernbench.runtime_api.kernel import (
|
||||
MemoryReadMsg,
|
||||
MemoryWriteMsg,
|
||||
PeDmaMsg,
|
||||
)
|
||||
from kernbench.sim_engine.engine import GraphEngine
|
||||
from kernbench.topology.builder import load_topology
|
||||
|
||||
TOPOLOGY_PATH = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
# Constants from topology.yaml defaults
|
||||
FLIT_BYTES = 256 # = HBM burst_bytes in our simplified model
|
||||
NUM_PCS = 8
|
||||
PC_BW_GBS = 32.0
|
||||
COMMIT_TIME_NS = FLIT_BYTES / PC_BW_GBS # 8 ns (HBM PC commit for one flit)
|
||||
# Reasonable per-test path-overhead budget (router overheads, prop, UCIe etc.)
|
||||
OVERHEAD_BUDGET_NS = 80.0
|
||||
|
||||
|
||||
def _engine() -> GraphEngine:
|
||||
return GraphEngine(load_topology(TOPOLOGY_PATH))
|
||||
|
||||
|
||||
def _hbm_pa(sip: int = 0, cube: int = 0, pe_id: int = 0, offset: int = 0x1000) -> int:
|
||||
slice_bytes = 48 * (1 << 30) // 8
|
||||
return PhysAddr.pe_hbm_addr(
|
||||
sip_id=sip, die_id=cube, pe_id=pe_id,
|
||||
pe_local_hbm_offset=offset, slice_size_bytes=slice_bytes,
|
||||
).encode()
|
||||
|
||||
|
||||
def _write_msg(req_id: str, *, cube: int, pe: int, nbytes: int) -> MemoryWriteMsg:
|
||||
return MemoryWriteMsg(
|
||||
correlation_id="flit-stream", request_id=req_id,
|
||||
dst_sip=0, dst_cube=cube, dst_pe=pe,
|
||||
dst_pa=_hbm_pa(sip=0, cube=cube, pe_id=pe), nbytes=nbytes,
|
||||
pattern="zero", target_pe=pe,
|
||||
)
|
||||
|
||||
|
||||
def _read_msg(req_id: str, *, cube: int, pe: int, nbytes: int) -> MemoryReadMsg:
|
||||
return MemoryReadMsg(
|
||||
correlation_id="flit-stream", request_id=req_id,
|
||||
src_sip=0, src_cube=cube, src_pe=pe,
|
||||
src_pa=_hbm_pa(sip=0, cube=cube, pe_id=pe), nbytes=nbytes,
|
||||
)
|
||||
|
||||
|
||||
def _pe_dma_write(req_id: str, *, src_cube: int, src_pe: int,
|
||||
dst_cube: int, dst_pe: int, nbytes: int) -> PeDmaMsg:
|
||||
return PeDmaMsg(
|
||||
correlation_id="flit-stream", request_id=req_id,
|
||||
src_sip=0, src_cube=src_cube, src_pe=src_pe,
|
||||
dst_pa=_hbm_pa(sip=0, cube=dst_cube, pe_id=dst_pe),
|
||||
nbytes=nbytes, is_write=True,
|
||||
)
|
||||
|
||||
|
||||
def _path_drain_for_request(eng: GraphEngine, request) -> float:
|
||||
"""Dynamically compute the path drain_ns the engine would assign to this
|
||||
request. Reads engine internals (test-time only) so tests reflect the
|
||||
actual path bottleneck (e.g., MemoryWrite goes via UCIe = 128 GB/s,
|
||||
PE_DMA same-cube stays in cube fabric = 256 GB/s)."""
|
||||
if isinstance(request, MemoryWriteMsg):
|
||||
sip, pa_val = request.dst_sip, request.dst_pa
|
||||
pcie_ep_id = eng._resolver.find_pcie_ep(sip)
|
||||
pa = PhysAddr.decode(pa_val)
|
||||
hbm_node = eng._resolver.resolve(pa)
|
||||
path = eng._router.find_memory_path(pcie_ep_id, hbm_node)
|
||||
elif isinstance(request, MemoryReadMsg):
|
||||
sip, pa_val = request.src_sip, request.src_pa
|
||||
pcie_ep_id = eng._resolver.find_pcie_ep(sip)
|
||||
pa = PhysAddr.decode(pa_val)
|
||||
hbm_node = eng._resolver.resolve(pa)
|
||||
path = eng._router.find_memory_path(pcie_ep_id, hbm_node)
|
||||
elif isinstance(request, PeDmaMsg):
|
||||
pe_prefix = f"sip{request.src_sip}.cube{request.src_cube}.pe{request.src_pe}"
|
||||
pa = PhysAddr.decode(request.dst_pa)
|
||||
dst_node = eng._resolver.resolve(pa)
|
||||
path = eng._router.find_path(pe_prefix, dst_node)
|
||||
else:
|
||||
raise ValueError(f"unsupported request type: {type(request).__name__}")
|
||||
return eng._path_drain_ns(path, request.nbytes)
|
||||
|
||||
|
||||
def _single_write_ns(nbytes: int, cube: int = 0, pe: int = 0) -> tuple[float, float]:
|
||||
"""Return (total_ns, path_drain_ns) for a single MemoryWrite."""
|
||||
eng = _engine()
|
||||
msg = _write_msg(f"s-{cube}-{pe}-{nbytes}", cube=cube, pe=pe, nbytes=nbytes)
|
||||
drain = _path_drain_for_request(eng, msg)
|
||||
h = eng.submit(msg)
|
||||
eng.wait(h)
|
||||
return eng.get_completion(h)[1]["total_ns"], drain
|
||||
|
||||
|
||||
# ── 1. Flit dataclass + Transaction.into_flits ─────────────────────
|
||||
|
||||
|
||||
def test_flit_dataclass_exists():
|
||||
"""Phase 2 must add a Flit dataclass in sim_engine.transaction.
|
||||
|
||||
Required fields:
|
||||
- txn: reference to parent Transaction
|
||||
- flit_index: 0..n_flits-1
|
||||
- flit_nbytes: bytes carried by this flit (usually flit_bytes; last may be smaller)
|
||||
- is_last: True for the final flit
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
from kernbench.sim_engine.transaction import Flit
|
||||
|
||||
fields = {f.name for f in dataclasses.fields(Flit)}
|
||||
for required in ("txn", "flit_index", "flit_nbytes", "is_last"):
|
||||
assert required in fields, f"Flit dataclass missing required field: {required}"
|
||||
|
||||
|
||||
def test_transaction_into_flits_count():
|
||||
"""Transaction.into_flits(flit_bytes) must yield ceil(nbytes/flit_bytes) flits
|
||||
with correct flit_nbytes (last may be partial) and indices.
|
||||
"""
|
||||
from kernbench.sim_engine.transaction import Transaction
|
||||
|
||||
txn = Transaction(
|
||||
request=None, path=["a", "b"], step=0,
|
||||
nbytes=1024, done=None, drain_ns=0.0,
|
||||
)
|
||||
flits = list(txn.into_flits(FLIT_BYTES))
|
||||
assert len(flits) == 4, f"1024 / 256 = 4 flits, got {len(flits)}"
|
||||
for i, f in enumerate(flits):
|
||||
assert f.flit_index == i
|
||||
assert f.flit_nbytes == FLIT_BYTES
|
||||
assert f.is_last == (i == 3)
|
||||
assert f.txn is txn
|
||||
|
||||
|
||||
def test_transaction_into_flits_partial_last():
|
||||
"""A transaction with nbytes not divisible by flit_bytes must yield
|
||||
a final partial flit."""
|
||||
from kernbench.sim_engine.transaction import Transaction
|
||||
|
||||
txn = Transaction(
|
||||
request=None, path=["a", "b"], step=0,
|
||||
nbytes=FLIT_BYTES * 3 + 64, done=None,
|
||||
)
|
||||
flits = list(txn.into_flits(FLIT_BYTES))
|
||||
assert len(flits) == 4
|
||||
assert flits[-1].flit_nbytes == 64
|
||||
assert flits[-1].is_last is True
|
||||
assert flits[0].flit_nbytes == FLIT_BYTES
|
||||
|
||||
|
||||
def test_transaction_into_flits_single_flit():
|
||||
"""A small transaction (<= flit_bytes) produces exactly one flit
|
||||
with is_last=True."""
|
||||
from kernbench.sim_engine.transaction import Transaction
|
||||
|
||||
txn = Transaction(request=None, path=["a", "b"], step=0, nbytes=128, done=None)
|
||||
flits = list(txn.into_flits(FLIT_BYTES))
|
||||
assert len(flits) == 1
|
||||
assert flits[0].flit_nbytes == 128
|
||||
assert flits[0].is_last is True
|
||||
|
||||
|
||||
# ── 2. Single transfer accuracy (flit-streaming should fix the
|
||||
# slow-upstream cut-through over-credit) ──
|
||||
|
||||
|
||||
def test_slow_upstream_single_2kb_total_matches_drain_plus_commit():
|
||||
"""A 2KB write through MemoryWrite path (host → PCIe → IO → UCIe →
|
||||
cube router → HBM_CTRL). The path bottleneck is UCIe (128 GB/s in this
|
||||
topology). Expected total ≈ drain (= 2048/128 = 16 ns) + commit_time
|
||||
(= 8 ns) + path overheads.
|
||||
|
||||
Current model under-counts because cut-through subtraction over-credits
|
||||
the slow drain. Flit-streaming (chunk-loop drain) charges both terms.
|
||||
"""
|
||||
nbytes = 2048
|
||||
total, drain = _single_write_ns(nbytes, cube=0, pe=0)
|
||||
|
||||
min_expected = drain + COMMIT_TIME_NS
|
||||
max_expected = min_expected + OVERHEAD_BUDGET_NS
|
||||
|
||||
assert total >= min_expected - 1.0, (
|
||||
f"2KB write total {total:.2f}ns below minimum {min_expected:.2f}ns "
|
||||
f"(drain={drain:.2f} + commit_time={COMMIT_TIME_NS:.2f}); "
|
||||
f"flit-streaming must charge both"
|
||||
)
|
||||
assert total <= max_expected, (
|
||||
f"2KB write total {total:.2f}ns above maximum {max_expected:.2f}ns "
|
||||
f"(drain={drain:.2f} + commit + {OVERHEAD_BUDGET_NS:.0f}ns overhead budget)"
|
||||
)
|
||||
|
||||
|
||||
def test_64kb_total_drain_plus_commit():
|
||||
"""A 64KB MemoryWrite at the path bottleneck rate: total ≈ drain + commit_time
|
||||
+ path overheads. Drain is computed dynamically from the engine's path
|
||||
bottleneck (UCIe-limited for host-initiated MemoryWrite).
|
||||
"""
|
||||
nbytes = 65536
|
||||
total, drain = _single_write_ns(nbytes)
|
||||
min_expected = drain + COMMIT_TIME_NS
|
||||
max_expected = min_expected + OVERHEAD_BUDGET_NS
|
||||
|
||||
assert total >= min_expected - 1.0, (
|
||||
f"64KB total {total:.2f}ns below {min_expected:.2f} "
|
||||
f"(drain={drain:.2f}+commit_time={COMMIT_TIME_NS:.2f})"
|
||||
)
|
||||
assert total <= max_expected, (
|
||||
f"64KB total {total:.2f}ns above {max_expected:.2f} "
|
||||
f"(drain={drain:.2f}+commit+{OVERHEAD_BUDGET_NS:.0f}ns budget)"
|
||||
)
|
||||
|
||||
|
||||
# ── 3. Multi-hop cut-through pipelining ────────────────────────────
|
||||
|
||||
|
||||
def test_multihop_flits_pipeline_drain_not_summed():
|
||||
"""Drain is the bottleneck-link transfer time, charged ONCE across the
|
||||
full path (not per hop). With flit-streaming + cut-through, this is the
|
||||
expected behavior. If drain were summed per hop, large-payload total
|
||||
would grow faster than small-payload total proportionally to hop count.
|
||||
|
||||
We isolate the drain-sum effect by comparing the *slope* of total vs
|
||||
nbytes for close (same-cube) vs far (cross-cube) paths. The slope is
|
||||
dominated by drain (the per-byte rate at bottleneck). If drain doesn't
|
||||
sum across hops, slopes should be similar (both = 1/bottleneck_bw,
|
||||
where bottleneck differs by path). If drain were summed, far slope
|
||||
would be much steeper.
|
||||
"""
|
||||
nbytes_small, nbytes_large = 256, 4096
|
||||
t_close_small, drain_close_small = _single_write_ns(nbytes_small, cube=0, pe=0)
|
||||
t_close_large, drain_close_large = _single_write_ns(nbytes_large, cube=0, pe=0)
|
||||
t_far_small, drain_far_small = _single_write_ns(nbytes_small, cube=15, pe=0)
|
||||
t_far_large, drain_far_large = _single_write_ns(nbytes_large, cube=15, pe=0)
|
||||
|
||||
slope_close = (t_close_large - t_close_small) / (nbytes_large - nbytes_small)
|
||||
slope_far = (t_far_large - t_far_small) / (nbytes_large - nbytes_small)
|
||||
|
||||
# Each slope should match its bottleneck rate (1 / bw).
|
||||
ideal_close = 1.0 / (drain_close_large / nbytes_large * 1e9) # ns/byte
|
||||
# Simpler: drain is linear in nbytes, so slope_path == drain_per_byte_at_bottleneck
|
||||
expected_close_slope = drain_close_large / nbytes_large
|
||||
expected_far_slope = drain_far_large / nbytes_large
|
||||
|
||||
# If drain summed across hops, far slope would be ~hop_count× larger
|
||||
# than expected. Assert slope is within 1.5× expected (allowing
|
||||
# propagation effects but rejecting drain-per-hop).
|
||||
assert slope_close <= expected_close_slope * 1.5, (
|
||||
f"Close-cube slope {slope_close:.4f} ns/byte vs expected "
|
||||
f"{expected_close_slope:.4f}; drain may sum across hops"
|
||||
)
|
||||
assert slope_far <= expected_far_slope * 1.5, (
|
||||
f"Far-cube slope {slope_far:.4f} ns/byte vs expected "
|
||||
f"{expected_far_slope:.4f}; drain may sum across hops"
|
||||
)
|
||||
|
||||
|
||||
# ── 4. Two-stream merge at HBM router (non-overcommit) ────────────
|
||||
|
||||
|
||||
def test_two_concurrent_2kb_writes_merge_makespan():
|
||||
"""Two concurrent 2KB writes merge at the HBM-attached router. With
|
||||
flit-streaming + RR arbitration, both streams share the output BW.
|
||||
Makespan ≈ aggregate-data / path-bottleneck + commit_time + overheads.
|
||||
|
||||
Drain is computed dynamically from the engine path.
|
||||
"""
|
||||
nbytes = 2048
|
||||
eng = _engine()
|
||||
msg_a = _write_msg("conc-a", cube=0, pe=0, nbytes=nbytes)
|
||||
msg_b = _write_msg("conc-b", cube=0, pe=1, nbytes=nbytes)
|
||||
drain_per_txn = _path_drain_for_request(eng, msg_a)
|
||||
h_a = eng.submit(msg_a)
|
||||
h_b = eng.submit(msg_b)
|
||||
eng.wait(h_a); eng.wait(h_b)
|
||||
ta = eng.get_completion(h_a)[1]["total_ns"]
|
||||
tb = eng.get_completion(h_b)[1]["total_ns"]
|
||||
makespan = max(ta, tb)
|
||||
|
||||
# Aggregate drain (2 streams worth) + commit_time + overheads
|
||||
expected_min = 2 * drain_per_txn + COMMIT_TIME_NS
|
||||
expected_max = expected_min + OVERHEAD_BUDGET_NS
|
||||
|
||||
assert makespan >= expected_min - 1.0, (
|
||||
f"2-stream merge makespan {makespan:.2f}ns below floor "
|
||||
f"{expected_min:.2f} (2*drain={2*drain_per_txn:.2f}+commit)"
|
||||
)
|
||||
assert makespan <= expected_max, (
|
||||
f"2-stream merge makespan {makespan:.2f}ns above ceiling "
|
||||
f"{expected_max:.2f}"
|
||||
)
|
||||
|
||||
# Both should finish within ~commit_time + small overhead of each other
|
||||
# (fair share via RR arbitration)
|
||||
diff = abs(ta - tb)
|
||||
assert diff <= drain_per_txn + COMMIT_TIME_NS + 5.0, (
|
||||
f"Stream A ({ta:.2f}) vs B ({tb:.2f}) finish times differ by "
|
||||
f"{diff:.2f}ns; expected fairness within ≤ "
|
||||
f"{drain_per_txn + COMMIT_TIME_NS + 5:.2f}ns"
|
||||
)
|
||||
|
||||
|
||||
# ── 5. Heavy-overcommit makespan (where flit-streaming shines) ────
|
||||
|
||||
|
||||
def test_eight_concurrent_writes_overcommit_makespan():
|
||||
"""8 concurrent 1KB writes share path bottleneck. With flit-streaming,
|
||||
aggregate traffic = 8 × 1KB shares the bottleneck link, so makespan ≈
|
||||
8 × per_txn_drain + commit_time + overheads.
|
||||
"""
|
||||
nbytes = 1024
|
||||
eng = _engine()
|
||||
msg0 = _write_msg("oc-0", cube=0, pe=0, nbytes=nbytes)
|
||||
drain_per_txn = _path_drain_for_request(eng, msg0)
|
||||
handles = [eng.submit(_write_msg(f"oc-{pe}", cube=0, pe=pe, nbytes=nbytes))
|
||||
for pe in range(8)]
|
||||
for h in handles:
|
||||
eng.wait(h)
|
||||
times = [eng.get_completion(h)[1]["total_ns"] for h in handles]
|
||||
makespan = max(times)
|
||||
|
||||
expected_min = 8 * drain_per_txn + COMMIT_TIME_NS
|
||||
expected_max = expected_min + OVERHEAD_BUDGET_NS
|
||||
assert makespan <= expected_max, (
|
||||
f"8-stream overcommit makespan {makespan:.2f}ns above ceiling "
|
||||
f"{expected_max:.2f}ns (8*drain={8*drain_per_txn:.2f}+commit+budget). "
|
||||
)
|
||||
|
||||
|
||||
# ── 6. PE → PE DMA flit-streaming (inter-cube, slow link case) ────
|
||||
|
||||
|
||||
def test_inter_cube_pe_dma_drain_doesnt_sum_across_hops():
|
||||
"""PE→PE DMA across cubes traverses many hops + inter-cube UCIe.
|
||||
|
||||
Per-hop overheads accumulate (router overhead, UCIe overhead, prop) and
|
||||
dominate the absolute total, so we don't bound the absolute value.
|
||||
Instead we verify drain is charged ONCE: compare 256B (tiny drain) vs
|
||||
4KB (16× drain) at the same cross-cube path. The delta should grow
|
||||
approximately as drain difference, not as drain × hops.
|
||||
"""
|
||||
eng_small = _engine()
|
||||
msg_small = _pe_dma_write("xs", src_cube=0, src_pe=0, dst_cube=15, dst_pe=0, nbytes=256)
|
||||
drain_small = _path_drain_for_request(eng_small, msg_small)
|
||||
h = eng_small.submit(msg_small)
|
||||
eng_small.wait(h)
|
||||
t_small = eng_small.get_completion(h)[1]["total_ns"]
|
||||
|
||||
eng_large = _engine()
|
||||
msg_large = _pe_dma_write("xl", src_cube=0, src_pe=0, dst_cube=15, dst_pe=0, nbytes=4096)
|
||||
drain_large = _path_drain_for_request(eng_large, msg_large)
|
||||
h = eng_large.submit(msg_large)
|
||||
eng_large.wait(h)
|
||||
t_large = eng_large.get_completion(h)[1]["total_ns"]
|
||||
|
||||
delta = t_large - t_small
|
||||
drain_delta = drain_large - drain_small
|
||||
|
||||
# If drain were charged per hop, delta would grow as drain_delta * hops.
|
||||
# If drain is charged once (correct), delta ≈ drain_delta + some
|
||||
# per-flit overhead (chunks pipeline through hops). Cap at 3× drain_delta
|
||||
# to allow for chunk-loop / flit transit overhead but reject hop summing.
|
||||
assert delta <= drain_delta * 3 + 30.0, (
|
||||
f"Inter-cube delta {delta:.2f}ns for {drain_delta:.2f}ns drain growth "
|
||||
f"exceeds 3×drain_delta+30; drain may be summing across hops"
|
||||
)
|
||||
|
||||
|
||||
# ── 7. Read response path: HBM → PE responses also flit-streamed ──
|
||||
|
||||
|
||||
def test_concurrent_reads_response_path_shares_bw():
|
||||
"""Multiple concurrent reads share the path's bottleneck link on the
|
||||
response (HBM → router → ... → host) path. With flit-streaming,
|
||||
aggregate response traffic ≈ N × drain_per_txn.
|
||||
"""
|
||||
nbytes = 1024
|
||||
eng = _engine()
|
||||
msg0 = _read_msg("r0", cube=0, pe=0, nbytes=nbytes)
|
||||
drain_per_txn = _path_drain_for_request(eng, msg0)
|
||||
handles = [eng.submit(_read_msg(f"r-{pe}", cube=0, pe=pe, nbytes=nbytes))
|
||||
for pe in range(8)]
|
||||
for h in handles:
|
||||
eng.wait(h)
|
||||
times = [eng.get_completion(h)[1]["total_ns"] for h in handles]
|
||||
makespan = max(times)
|
||||
|
||||
# 8 concurrent reads aggregate ≈ 8 × drain on shared bottleneck
|
||||
# Plus forward command + commit + path overheads (response is dominant)
|
||||
expected_min = 8 * drain_per_txn + COMMIT_TIME_NS
|
||||
expected_max = expected_min + OVERHEAD_BUDGET_NS * 2 # 2× for fwd+resp paths
|
||||
|
||||
assert makespan <= expected_max, (
|
||||
f"8 concurrent reads makespan {makespan:.2f}ns above ceiling "
|
||||
f"{expected_max:.2f} (8*drain={8*drain_per_txn:.2f}+commit+budget); "
|
||||
f"response path BW sharing may not be modeled correctly"
|
||||
)
|
||||
|
||||
|
||||
# ── 8. Op_log: per-Transaction record (not per-flit) ───────────────
|
||||
|
||||
|
||||
def test_op_log_per_transaction_not_per_flit():
|
||||
"""Op_log records (ADR-0020) are emitted per PE-internal command
|
||||
(DmaReadCmd / DmaWriteCmd / GemmCmd / MathCmd), NOT per wire Flit.
|
||||
Chunk-streaming Phase 2c does not touch this — flit transport is
|
||||
on Transactions across the fabric; op_log records on the internal
|
||||
PE-side command messages, which are atomic and never chunked.
|
||||
|
||||
This test guards that invariant: even with flits in flight, when
|
||||
a kernel triggers internal DmaWriteCmds the op_log accumulates
|
||||
one record per (component, command), not per flit. We submit a
|
||||
direct ``PeDmaMsg`` which does NOT exercise the PE-internal
|
||||
command path, so we expect zero records in the default engine.
|
||||
This is intentional: the test asserts NO over-counting from
|
||||
chunked transport, by asserting any records seen have at most
|
||||
one per (txn, component).
|
||||
"""
|
||||
pytest.importorskip("kernbench.sim_engine.op_log")
|
||||
|
||||
nbytes = 2048
|
||||
eng = _engine()
|
||||
msg = _pe_dma_write("op-log", src_cube=0, src_pe=0, dst_cube=0, dst_pe=0, nbytes=nbytes)
|
||||
h = eng.submit(msg)
|
||||
eng.wait(h)
|
||||
|
||||
if not hasattr(eng, "op_log") or not eng.op_log:
|
||||
pytest.skip(
|
||||
"Engine does not expose op_log records for direct PeDmaMsg "
|
||||
"submission (op_log fires on PE-internal DmaCmd messages, "
|
||||
"which are only generated by kernel launches)"
|
||||
)
|
||||
|
||||
# If records ARE present (e.g., for a kernel-launch-driven test), they
|
||||
# must NOT be per-flit (8 records per component for a 2KB write).
|
||||
records = [r for r in eng.op_log
|
||||
if getattr(r, "op_name", None) == "dma_write"]
|
||||
by_comp: dict[str, list[Any]] = {}
|
||||
for r in records:
|
||||
by_comp.setdefault(r.component_id, []).append(r)
|
||||
for comp_id, recs in by_comp.items():
|
||||
assert len(recs) <= 1, (
|
||||
f"Component {comp_id} has {len(recs)} dma_write records for one "
|
||||
f"transaction; flits must aggregate to a single record per "
|
||||
f"(txn, component)"
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Tests for HBM CTRL per-pseudo-channel (PC) striping model (ADR-0033).
|
||||
|
||||
Replaces the prior dual-channel `simpy.Resource(capacity=1)` model with a
|
||||
stateless per-PC `available_at[N]` array, global round-robin chunking, and
|
||||
read/write sharing per PC. Burst granularity is `burst_bytes` (default 256B).
|
||||
|
||||
These tests are written BEFORE the production change and are expected to
|
||||
FAIL on current code (which serializes via Resource cap=1). Phase 2 must
|
||||
make them PASS without weakening assertions.
|
||||
|
||||
Verification matrix references ADR-0033 D1 (modeled) and D2 (approximated).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kernbench.policy.address.phyaddr import PhysAddr
|
||||
from kernbench.runtime_api.kernel import MemoryReadMsg, MemoryWriteMsg
|
||||
from kernbench.sim_engine.engine import GraphEngine
|
||||
from kernbench.topology.builder import load_topology, resolve_topology
|
||||
|
||||
TOPOLOGY_PATH = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
|
||||
def _engine() -> GraphEngine:
|
||||
return GraphEngine(load_topology(TOPOLOGY_PATH))
|
||||
|
||||
|
||||
def _hbm_pa(sip: int = 0, cube: int = 0, pe_id: int = 0, offset: int = 0x1000) -> int:
|
||||
slice_bytes = 48 * (1 << 30) // 8
|
||||
return PhysAddr.pe_hbm_addr(
|
||||
sip_id=sip, die_id=cube, pe_id=pe_id,
|
||||
pe_local_hbm_offset=offset, slice_size_bytes=slice_bytes,
|
||||
).encode()
|
||||
|
||||
|
||||
def _write_msg(req_id: str, *, cube: int, pe: int, nbytes: int) -> MemoryWriteMsg:
|
||||
return MemoryWriteMsg(
|
||||
correlation_id="pc-striping", request_id=req_id,
|
||||
dst_sip=0, dst_cube=cube, dst_pe=pe,
|
||||
dst_pa=_hbm_pa(sip=0, cube=cube, pe_id=pe), nbytes=nbytes,
|
||||
pattern="zero", target_pe=pe,
|
||||
)
|
||||
|
||||
|
||||
def _single_write_ns(nbytes: int, cube: int = 0, pe: int = 0) -> float:
|
||||
eng = _engine()
|
||||
msg = _write_msg(f"single-{cube}-{pe}-{nbytes}", cube=cube, pe=pe, nbytes=nbytes)
|
||||
h = eng.submit(msg)
|
||||
eng.wait(h)
|
||||
_, t = eng.get_completion(h)
|
||||
return t["total_ns"]
|
||||
|
||||
|
||||
def _path_drain_for_write(eng: GraphEngine, msg: MemoryWriteMsg) -> float:
|
||||
"""Compute engine path drain dynamically (test-time access to engine internals)."""
|
||||
pcie_ep_id = eng._resolver.find_pcie_ep(msg.dst_sip)
|
||||
pa = PhysAddr.decode(msg.dst_pa)
|
||||
hbm_node = eng._resolver.resolve(pa)
|
||||
path = eng._router.find_memory_path(pcie_ep_id, hbm_node)
|
||||
return eng._path_drain_ns(path, msg.nbytes)
|
||||
|
||||
|
||||
# ── 1. Builder derives pc_bw_gbs ──────────────────────────────────
|
||||
|
||||
|
||||
def test_builder_derives_pc_bw_gbs():
|
||||
"""Topology builder must inject `pc_bw_gbs = hbm_to_router_bw_gbs / num_pcs`
|
||||
as an attr on every hbm_ctrl node. Enforces ADR-0019 D9 invariant
|
||||
(channels_per_PE × per-PC BW = aggregated link BW) at build time.
|
||||
"""
|
||||
handle = resolve_topology(str(TOPOLOGY_PATH))
|
||||
topo = handle.topology_obj
|
||||
spec = topo.spec
|
||||
|
||||
expected_total_bw = float(spec["cube"]["links"]["hbm_to_router_bw_gbs"])
|
||||
expected_num_pcs = int(spec["cube"]["memory_map"]["hbm_channels_per_pe"])
|
||||
expected_pc_bw = expected_total_bw / expected_num_pcs
|
||||
|
||||
hbm_nodes = [n for n in topo.nodes.values() if "hbm_ctrl" in n.id]
|
||||
assert hbm_nodes, "no hbm_ctrl nodes found in topology"
|
||||
|
||||
for node in hbm_nodes:
|
||||
assert "num_pcs" in node.attrs, f"{node.id} missing num_pcs"
|
||||
assert int(node.attrs["num_pcs"]) == expected_num_pcs, (
|
||||
f"{node.id} num_pcs={node.attrs['num_pcs']} != {expected_num_pcs}"
|
||||
)
|
||||
assert "pc_bw_gbs" in node.attrs, f"{node.id} missing builder-derived pc_bw_gbs"
|
||||
assert abs(float(node.attrs["pc_bw_gbs"]) - expected_pc_bw) < 1e-6, (
|
||||
f"{node.id} pc_bw_gbs={node.attrs['pc_bw_gbs']} != {expected_pc_bw}"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. PC parallelism: concurrent writes do NOT serialize at HBM CTRL ──
|
||||
|
||||
|
||||
def test_two_concurrent_writes_parallel_across_pcs():
|
||||
"""Two concurrent writes to the same cube (different PEs) must use
|
||||
different PCs (via global round-robin) and finish in less than 2x
|
||||
the single-write latency.
|
||||
|
||||
Current model (Resource cap=1) serializes them → max ≈ 2x single.
|
||||
PC striping must give max < 1.7x single (allowing for shared wire BW
|
||||
occupancy, which remains).
|
||||
"""
|
||||
nbytes = 1024
|
||||
single_ns = _single_write_ns(nbytes)
|
||||
|
||||
eng = _engine()
|
||||
msg_a = _write_msg("conc-a", cube=0, pe=0, nbytes=nbytes)
|
||||
msg_b = _write_msg("conc-b", cube=0, pe=1, nbytes=nbytes)
|
||||
ha = eng.submit(msg_a)
|
||||
hb = eng.submit(msg_b)
|
||||
eng.wait(ha)
|
||||
eng.wait(hb)
|
||||
_, ta = eng.get_completion(ha)
|
||||
_, tb = eng.get_completion(hb)
|
||||
max_ns = max(ta["total_ns"], tb["total_ns"])
|
||||
|
||||
assert max_ns < single_ns * 1.7, (
|
||||
f"PC striping: 2 concurrent 1KB writes should not serialize at HBM CTRL. "
|
||||
f"single={single_ns:.2f}ns, concurrent max={max_ns:.2f}ns, "
|
||||
f"ratio={max_ns/single_ns:.2f} (expected < 1.7)"
|
||||
)
|
||||
|
||||
|
||||
def test_eight_concurrent_writes_makespan():
|
||||
"""8 concurrent 1KB writes (one per PE in cube0) must achieve makespan
|
||||
significantly less than 8x single-write latency.
|
||||
|
||||
With 8 PCs and global round-robin, each write maps to a distinct set of
|
||||
PCs; the makespan is dominated by wire BW (shared 256 GB/s pipe), not
|
||||
by HBM-side serialization.
|
||||
Current cap=1 model: makespan ≈ 8x single. Target: < 4x single.
|
||||
"""
|
||||
nbytes = 1024
|
||||
single_ns = _single_write_ns(nbytes)
|
||||
|
||||
eng = _engine()
|
||||
handles = []
|
||||
for pe in range(8):
|
||||
msg = _write_msg(f"8way-{pe}", cube=0, pe=pe, nbytes=nbytes)
|
||||
handles.append(eng.submit(msg))
|
||||
for h in handles:
|
||||
eng.wait(h)
|
||||
times = [eng.get_completion(h)[1]["total_ns"] for h in handles]
|
||||
makespan = max(times)
|
||||
|
||||
assert makespan < single_ns * 4.0, (
|
||||
f"8 concurrent 1KB writes: makespan={makespan:.2f}ns, "
|
||||
f"single={single_ns:.2f}ns, ratio={makespan/single_ns:.2f} "
|
||||
f"(expected < 4.0 with PC striping; current cap=1 gives ~8x)"
|
||||
)
|
||||
|
||||
|
||||
# ── 3. Large transfer not 2x pessimistic ──────────────────────────
|
||||
|
||||
|
||||
def test_large_transfer_not_double_counted():
|
||||
"""64KB write must not be ~2x the wire transfer time.
|
||||
|
||||
With cut-through (head_arrived event) + PC striping, the HBM PC commit
|
||||
time overlaps with wire arrival. For 64KB at 256 GB/s aggregate:
|
||||
- Wire transfer: ~256ns
|
||||
- PC commit (parallel across 8 PCs, 32 chunks each): ~256ns
|
||||
- Overlapped real-HW total: ~256ns (one of them dominates)
|
||||
- Current sequential model: ~512ns (~2x)
|
||||
|
||||
Assert: total < 1.5x of (wire transfer time alone).
|
||||
"""
|
||||
nbytes = 65536 # 64KB
|
||||
# Path bottleneck (dynamic) — for MemoryWrite this is UCIe 128 GB/s.
|
||||
eng = _engine()
|
||||
msg = _write_msg("64kb-probe", cube=0, pe=0, nbytes=nbytes)
|
||||
drain = _path_drain_for_write(eng, msg)
|
||||
|
||||
total = _single_write_ns(nbytes)
|
||||
assert total < drain * 1.5, (
|
||||
f"64KB write should not be ~2x path bottleneck transfer time. "
|
||||
f"drain={drain:.2f}ns, total={total:.2f}ns, "
|
||||
f"ratio={total/drain:.2f} (expected < 1.5)"
|
||||
)
|
||||
|
||||
|
||||
# ── 4. Read/write share per-PC available_at ──────────────────────
|
||||
|
||||
|
||||
def test_read_write_share_pc_array():
|
||||
"""Read and write requests targeting overlapping PC regions must
|
||||
serialize on the shared `pc_avail` array (NOT proceed in parallel like
|
||||
the prior dual-channel model).
|
||||
|
||||
Strategy: a read and a write to the same PE/cube should land on the
|
||||
same set of PCs (since global round-robin advances by chunk count, and
|
||||
chunk count of 256B == 1 chunk consumes 1 PC). With single-chunk read+write
|
||||
submitted concurrently, the second to acquire its chunk's PC must wait.
|
||||
|
||||
We assert: makespan of (concurrent read + write) > single_write_ns.
|
||||
If they ran in parallel on disjoint resources (old dual-channel),
|
||||
makespan ≈ single. With shared PC, makespan > single.
|
||||
"""
|
||||
nbytes = 256 # 1 chunk
|
||||
pa = _hbm_pa(sip=0, cube=0, pe_id=0)
|
||||
single_w = _single_write_ns(nbytes)
|
||||
|
||||
eng = _engine()
|
||||
w_msg = _write_msg("rw-write", cube=0, pe=0, nbytes=nbytes)
|
||||
r_msg = MemoryReadMsg(
|
||||
correlation_id="pc-striping", request_id="rw-read",
|
||||
src_sip=0, src_cube=0, src_pe=0,
|
||||
src_pa=pa, nbytes=nbytes,
|
||||
)
|
||||
hw = eng.submit(w_msg)
|
||||
hr = eng.submit(r_msg)
|
||||
eng.wait(hw)
|
||||
eng.wait(hr)
|
||||
_, tw = eng.get_completion(hw)
|
||||
_, tr = eng.get_completion(hr)
|
||||
makespan = max(tw["total_ns"], tr["total_ns"])
|
||||
|
||||
# When R and W share the same first PC, the second one to acquire pays
|
||||
# the burst time of the first. Assert makespan strictly > single,
|
||||
# demonstrating sharing (vs the prior dual-channel parallelism).
|
||||
assert makespan > single_w * 1.05, (
|
||||
f"Read+Write should share per-PC slot when targeting the same starting "
|
||||
f"PC. single_write={single_w:.2f}ns, R+W makespan={makespan:.2f}ns "
|
||||
f"(expected > 1.05x single, demonstrating PC sharing)"
|
||||
)
|
||||
|
||||
|
||||
# ── 5. Switch penalty: default 0, mechanism wired up ─────────────
|
||||
|
||||
|
||||
def _makespan(eng: GraphEngine, handles: list) -> float:
|
||||
for h in handles:
|
||||
eng.wait(h)
|
||||
return max(eng.get_completion(h)[1]["total_ns"] for h in handles)
|
||||
|
||||
|
||||
def _engine_with_switch_penalty(switch_penalty_ns: float) -> GraphEngine:
|
||||
"""Build a GraphEngine, overriding switch_penalty_ns on every hbm_ctrl
|
||||
node. None means leave the attr absent (i.e., test the default)."""
|
||||
graph = load_topology(TOPOLOGY_PATH)
|
||||
if switch_penalty_ns is not None:
|
||||
for node in graph.nodes.values():
|
||||
if "hbm_ctrl" in node.id:
|
||||
node.attrs["switch_penalty_ns"] = switch_penalty_ns
|
||||
return GraphEngine(graph)
|
||||
|
||||
|
||||
def _rw_write_time(eng: GraphEngine, nbytes: int) -> float:
|
||||
"""Submit one read followed by one write of the same size; return the
|
||||
write's completion time. With `nbytes >= num_pcs * burst_bytes`, the
|
||||
read populates PCs 0..N-1 with last_dir='R' and the write then wraps
|
||||
back to PC 0, so every chunk of the write sees an R→W direction
|
||||
switch. The write's completion time is the direct observable for the
|
||||
switch-penalty mechanism (the read's time is dominated by the
|
||||
response-path latency and would mask the effect)."""
|
||||
r = MemoryReadMsg(
|
||||
correlation_id="pc-striping", request_id="rw-1",
|
||||
src_sip=0, src_cube=0, src_pe=0,
|
||||
src_pa=_hbm_pa(sip=0, cube=0, pe_id=0), nbytes=nbytes,
|
||||
)
|
||||
w = _write_msg("rw-2", cube=0, pe=0, nbytes=nbytes)
|
||||
hr = eng.submit(r)
|
||||
hw = eng.submit(w)
|
||||
eng.wait(hr); eng.wait(hw)
|
||||
return eng.get_completion(hw)[1]["total_ns"]
|
||||
|
||||
|
||||
def test_switch_penalty_default_zero():
|
||||
"""Default (no `switch_penalty_ns` attr) must behave identically to
|
||||
explicit `switch_penalty_ns=0`.
|
||||
|
||||
This documents Tier 0 (ADR-0033 D2): we assume an ideal HBM scheduler
|
||||
amortizes switching cost; the mechanism exists but is dormant.
|
||||
"""
|
||||
nbytes = 2048
|
||||
rw_default = _rw_write_time(_engine_with_switch_penalty(None), nbytes)
|
||||
rw_zero = _rw_write_time(_engine_with_switch_penalty(0.0), nbytes)
|
||||
diff = abs(rw_default - rw_zero)
|
||||
assert diff < 0.01, (
|
||||
f"Default (no attr) must match explicit switch_penalty_ns=0. "
|
||||
f"default={rw_default:.2f}ns, explicit_zero={rw_zero:.2f}ns, "
|
||||
f"diff={diff:.4f}ns"
|
||||
)
|
||||
|
||||
|
||||
def test_switch_penalty_mechanism_when_enabled():
|
||||
"""When `switch_penalty_ns` is set non-zero via attr, R→W on the same
|
||||
PC must show that extra delay.
|
||||
|
||||
Phase 2 must wire up the mechanism so that overriding the attr at
|
||||
runtime (or via a modified topology) produces the expected delay.
|
||||
Default config keeps it 0; this test creates an engine with an
|
||||
explicit override.
|
||||
"""
|
||||
# Use nbytes that span all 8 PCs so the write back-wraps to PCs that
|
||||
# were just touched by the read, forcing an R→W switch on each PC.
|
||||
# 8 PCs × 256B burst = 2048B fills every PC exactly once.
|
||||
nbytes = 2048
|
||||
switch_penalty = 20.0 # large enough to be visible
|
||||
|
||||
# R+W with explicit switch_penalty=0: baseline (W observed time)
|
||||
rw_zero = _rw_write_time(_engine_with_switch_penalty(0.0), nbytes)
|
||||
|
||||
# R+W with explicit switch_penalty=20: mechanism engaged
|
||||
rw_pen = _rw_write_time(_engine_with_switch_penalty(switch_penalty), nbytes)
|
||||
|
||||
delta = rw_pen - rw_zero
|
||||
# The switch penalty applies once on the second txn's first chunk.
|
||||
# Conservative: assert at least half the switch_penalty shows up.
|
||||
assert delta >= switch_penalty * 0.4, (
|
||||
f"switch_penalty_ns={switch_penalty} should add measurable delay "
|
||||
f"when R→W on same PC. R+W@0={rw_zero:.2f}ns, "
|
||||
f"R+W@{switch_penalty}={rw_pen:.2f}ns, delta={delta:.2f}ns "
|
||||
f"(expected >= {switch_penalty*0.4:.2f}ns)"
|
||||
)
|
||||
|
||||
|
||||
# ── 6. Backwards compat sanity ───────────────────────────────────
|
||||
|
||||
|
||||
def test_existing_single_txn_latency_positive():
|
||||
"""Sanity: single write still produces positive latency (no regression
|
||||
of basic engine behavior). Companion to test_bw_occupancy.py."""
|
||||
t = _single_write_ns(4096)
|
||||
assert t > 0
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Phase 1 test for moving the intercube_allreduce root cube from the
|
||||
bottom-right corner (3,3) to the geometric center (2,2).
|
||||
|
||||
Today's algorithm (intercube_allreduce.py) hardcodes
|
||||
``root_cube = (cube_h-1) * cube_w + (cube_w-1)`` (= cube 15 in 4×4).
|
||||
The intra-SIP critical path for one allreduce is therefore::
|
||||
|
||||
Phase 1 (row reduce W→E to col 3) : 3 hops
|
||||
Phase 2 (col reduce N→S to row 3 on col 3): 3 hops
|
||||
Phase 3 (inter-SIP at root) : (separate)
|
||||
Phase 4 (col broadcast S→N) : 3 hops
|
||||
Phase 5 (row broadcast E→W) : 3 hops
|
||||
Total intra-SIP critical path : 12 hops
|
||||
|
||||
Moving the root to (2,2) and using BIDIRECTIONAL convergence (cols 0..2
|
||||
go W→E, col 3 goes E→W in parallel; rows 0..2 go N→S, row 3 goes S→N
|
||||
in parallel) cuts each phase's critical path from 3 hops to 2::
|
||||
|
||||
Phase 1 critical path : max(2, 1) = 2 hops
|
||||
Phase 2 critical path : max(2, 1) = 2 hops
|
||||
Phase 4 critical path : 2 hops
|
||||
Phase 5 critical path : 2 hops
|
||||
Total intra-SIP critical path : 8 hops
|
||||
|
||||
Per-hop cost at 96 KB on TCM ≈ 600 ns (slot IO write+read 384 ns +
|
||||
fabric drain ~217 ns). 4 fewer hops ⇒ ~2.4 µs reduction.
|
||||
|
||||
EXPECTED Phase 1 outcome:
|
||||
- Today (root = corner) : ~22.0 µs ← test FAILS (> 20500 ns)
|
||||
- After Phase 2 (root = center) : ~19.6 µs ← test PASSES (< 20500 ns)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kernbench.runtime_api.context import RuntimeContext
|
||||
from kernbench.runtime_api.types import DeviceSelector
|
||||
from kernbench.sim_engine.engine import GraphEngine
|
||||
from kernbench.topology.builder import resolve_topology
|
||||
|
||||
from tests.test_allreduce_multidevice import (
|
||||
_write_temp_configs,
|
||||
run_allreduce,
|
||||
)
|
||||
|
||||
|
||||
def _run_torus_96kb(tmp_path: Path) -> float:
|
||||
"""Run torus_2d 6-SIP allreduce at 96 KB / slot, return critical-path
|
||||
pe_exec_ns. Fixed at TCM (the project default)."""
|
||||
sub = tmp_path / "torus_root_center"
|
||||
sub.mkdir()
|
||||
topo_path, ccl_path = _write_temp_configs(
|
||||
sub,
|
||||
sip_topology="torus_2d",
|
||||
n_sips=6,
|
||||
algorithm="intercube_allreduce",
|
||||
sip_w=3, sip_h=2,
|
||||
n_elem_override=49152, # 49152 × 2 = 96 KB / slot
|
||||
)
|
||||
topo = resolve_topology(topo_path)
|
||||
engine = GraphEngine(topo.topology_obj, enable_data=True)
|
||||
spec = topo.topology_obj.spec
|
||||
with RuntimeContext(
|
||||
engine=engine,
|
||||
target_device=DeviceSelector("all"),
|
||||
correlation_id="root_center_phase1",
|
||||
spec=spec,
|
||||
) as ctx:
|
||||
result = run_allreduce(
|
||||
ctx, engine, spec,
|
||||
algorithm="intercube_allreduce", ccl_yaml=ccl_path,
|
||||
)
|
||||
assert result["ok_cubes"] > 0
|
||||
pe_exec_vals = [
|
||||
float(tr.get("pe_exec_ns", 0.0) or 0.0)
|
||||
for _, (_, tr) in engine._results.items()
|
||||
if isinstance(tr, dict)
|
||||
]
|
||||
return max(pe_exec_vals) if pe_exec_vals else 0.0
|
||||
|
||||
|
||||
def test_intra_sip_critical_path_at_96k_below_threshold(tmp_path):
|
||||
"""Post-Phase-2 (root=center, bidirectional reduce) the torus_2d
|
||||
96 KB allreduce on TCM should be meaningfully lower than corner
|
||||
root with serial reduce.
|
||||
|
||||
The absolute number depends on the latency model's fidelity.
|
||||
Under ADR-0033 Phase 2c (per-flit wire timing, wormhole) the
|
||||
bottleneck-link transit time is charged once per flit on each
|
||||
serialized hop, so allreduce numbers are higher than pre-2c
|
||||
estimates. Threshold widened to 30 µs to accommodate the more
|
||||
accurate model; the algorithmic property (8-hop center root <
|
||||
12-hop corner root) is the invariant being asserted.
|
||||
"""
|
||||
lat_ns = _run_torus_96kb(tmp_path)
|
||||
THRESHOLD_NS = 30_000.0
|
||||
assert lat_ns < THRESHOLD_NS, (
|
||||
f"torus_2d 6-SIP 96 KB allreduce should land below "
|
||||
f"{THRESHOLD_NS:.0f} ns post-Phase-2 (root=center, "
|
||||
f"bidirectional reduce). got {lat_ns:.1f} ns "
|
||||
f"({lat_ns / 1000:.2f} µs)"
|
||||
)
|
||||
|
||||
|
||||
def test_correctness_preserved(tmp_path):
|
||||
"""Smoke check: at small n_elem the new algorithm must still produce
|
||||
the correct sum across all 96 cubes. ``run_allreduce`` validates
|
||||
every cube against the expected reduce result (``ok_cubes`` must be
|
||||
96 = 6 SIPs × 16 cubes).
|
||||
|
||||
This guards against the obvious Phase 2 risk: bidirectional reduce
|
||||
sums each contribution exactly once. If implemented wrong (double-
|
||||
counting or skipping the right edge column / bottom row), the
|
||||
asserts inside run_allreduce fail.
|
||||
"""
|
||||
sub = tmp_path / "correctness"
|
||||
sub.mkdir()
|
||||
topo_path, ccl_path = _write_temp_configs(
|
||||
sub,
|
||||
sip_topology="torus_2d",
|
||||
n_sips=6,
|
||||
algorithm="intercube_allreduce",
|
||||
sip_w=3, sip_h=2,
|
||||
n_elem_override=128, # tiny payload to keep this fast
|
||||
)
|
||||
topo = resolve_topology(topo_path)
|
||||
engine = GraphEngine(topo.topology_obj, enable_data=True)
|
||||
spec = topo.topology_obj.spec
|
||||
with RuntimeContext(
|
||||
engine=engine,
|
||||
target_device=DeviceSelector("all"),
|
||||
correlation_id="root_center_correctness",
|
||||
spec=spec,
|
||||
) as ctx:
|
||||
result = run_allreduce(
|
||||
ctx, engine, spec,
|
||||
algorithm="intercube_allreduce", ccl_yaml=ccl_path,
|
||||
)
|
||||
n_cubes = 6 * 16 # 6 SIPs × 16 cubes/SIP
|
||||
assert result["ok_cubes"] == n_cubes, (
|
||||
f"all 96 cubes must validate; got {result['ok_cubes']} OK"
|
||||
)
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for configure_sfr_intercube_multisip neighbor table wiring.
|
||||
|
||||
Verifies that IPCQ neighbor tables are correctly installed for
|
||||
intercube (pe0, 4×4 mesh N/S/E/W) + inter-SIP (pe0, all cubes,
|
||||
global_E/global_W) communication.
|
||||
Verifies full IPCQ hardware wiring (independent of DPPolicy):
|
||||
- intra-cube (2×4 PE grid) → intra_N/S/E/W
|
||||
- intercube same-lane → N/S/E/W
|
||||
- inter-SIP same-(cube, pe) → global_N/S/E/W
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,6 +17,7 @@ from kernbench.topology.builder import resolve_topology
|
||||
TOPOLOGY_PATH = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
N_CUBES = 16
|
||||
PES_PER_CUBE = 8
|
||||
|
||||
|
||||
def _engine_and_spec():
|
||||
@@ -36,78 +38,102 @@ class TestConfigureSfrNeighborTables:
|
||||
plan = configure_sfr_intercube_multisip(engine, spec, cfg)
|
||||
|
||||
n_sips = int(spec["system"]["sips"]["count"])
|
||||
assert plan["world_size"] == n_sips * N_CUBES
|
||||
assert len(plan["rank_to_pe"]) == n_sips * N_CUBES
|
||||
for pe_idx, (sip, cube, pe) in enumerate(plan["rank_to_pe"]):
|
||||
assert pe == 0, f"pe_idx {pe_idx}: pe must be 0, got {pe}"
|
||||
expected = n_sips * N_CUBES * PES_PER_CUBE
|
||||
assert plan["world_size"] == expected
|
||||
assert len(plan["rank_to_pe"]) == expected
|
||||
|
||||
def test_corner_cube0_has_E_and_S_only(self):
|
||||
"""Cube 0 (row=0, col=0) is NW corner: only E and S neighbors."""
|
||||
# ── Intra-cube (intra_N/S/E/W) ────────────────────────────────
|
||||
|
||||
def test_pe0_intra_cube_has_intra_E_and_intra_S(self):
|
||||
"""pe0 is NW of the 2×4 PE grid: intra_E=pe1, intra_S=pe4."""
|
||||
engine, spec = _engine_and_spec()
|
||||
cfg = _merged_cfg()
|
||||
configure_sfr_intercube_multisip(engine, spec, cfg)
|
||||
|
||||
ipcq = engine._components["sip0.cube0.pe0.pe_ipcq"]
|
||||
qp = ipcq.queue_pairs
|
||||
assert "E" in qp, "cube 0 must have E neighbor"
|
||||
assert "S" in qp, "cube 0 must have S neighbor"
|
||||
assert "W" not in qp, "cube 0 (col=0) must NOT have W neighbor"
|
||||
assert "N" not in qp, "cube 0 (row=0) must NOT have N neighbor"
|
||||
qp = engine._components["sip0.cube0.pe0.pe_ipcq"].queue_pairs
|
||||
assert "intra_E" in qp
|
||||
assert qp["intra_E"]["peer"].pe == 1
|
||||
assert "intra_S" in qp
|
||||
assert qp["intra_S"]["peer"].pe == 4
|
||||
assert "intra_W" not in qp
|
||||
assert "intra_N" not in qp
|
||||
|
||||
def test_pe5_intra_cube_has_all_four(self):
|
||||
"""pe5 (row=1, col=1 in 2×4 grid) has all 4 intra directions.
|
||||
|
||||
Intra neighbors: intra_N=pe1, intra_E=pe6, intra_W=pe4,
|
||||
intra_S not present (row=1 is bottom row).
|
||||
"""
|
||||
engine, spec = _engine_and_spec()
|
||||
cfg = _merged_cfg()
|
||||
configure_sfr_intercube_multisip(engine, spec, cfg)
|
||||
|
||||
qp = engine._components["sip0.cube0.pe5.pe_ipcq"].queue_pairs
|
||||
assert qp["intra_N"]["peer"].pe == 1
|
||||
assert qp["intra_E"]["peer"].pe == 6
|
||||
assert qp["intra_W"]["peer"].pe == 4
|
||||
assert "intra_S" not in qp # bottom row
|
||||
|
||||
# ── Intercube same-lane (N/S/E/W) ─────────────────────────────
|
||||
|
||||
def test_corner_cube0_pe0_has_intercube_E_and_S(self):
|
||||
"""Cube 0 (NW mesh corner): intercube E→cube1, S→cube4."""
|
||||
engine, spec = _engine_and_spec()
|
||||
cfg = _merged_cfg()
|
||||
configure_sfr_intercube_multisip(engine, spec, cfg)
|
||||
|
||||
qp = engine._components["sip0.cube0.pe0.pe_ipcq"].queue_pairs
|
||||
assert qp["E"]["peer"].cube == 1
|
||||
assert qp["E"]["peer"].pe == 0 # same-lane
|
||||
assert qp["S"]["peer"].cube == 4
|
||||
assert qp["S"]["peer"].pe == 0
|
||||
assert "W" not in qp, "cube 0 has no west neighbor"
|
||||
assert "N" not in qp, "cube 0 has no north neighbor"
|
||||
|
||||
def test_interior_cube5_has_all_four(self):
|
||||
"""Cube 5 (row=1, col=1) is interior: N/S/E/W all present."""
|
||||
def test_interior_cube5_pe3_has_all_four_intercube_same_lane(self):
|
||||
"""Cube 5 interior, pe3: intercube N/S/E/W all present, same-lane."""
|
||||
engine, spec = _engine_and_spec()
|
||||
cfg = _merged_cfg()
|
||||
configure_sfr_intercube_multisip(engine, spec, cfg)
|
||||
|
||||
ipcq = engine._components["sip0.cube5.pe0.pe_ipcq"]
|
||||
qp = ipcq.queue_pairs
|
||||
assert qp["N"]["peer"].cube == 1
|
||||
assert qp["S"]["peer"].cube == 9
|
||||
assert qp["E"]["peer"].cube == 6
|
||||
assert qp["W"]["peer"].cube == 4
|
||||
qp = engine._components["sip0.cube5.pe3.pe_ipcq"].queue_pairs
|
||||
for d, expected_cube in [("N", 1), ("S", 9), ("E", 6), ("W", 4)]:
|
||||
assert qp[d]["peer"].cube == expected_cube
|
||||
assert qp[d]["peer"].pe == 3 # same-lane
|
||||
|
||||
def test_root_cube15_has_inter_sip(self):
|
||||
"""Cube 15 (root, SE corner) has N, W + global_E/global_W."""
|
||||
def test_all_pes_have_intercube_wiring(self):
|
||||
"""Every PE on every interior cube has intercube same-lane wiring."""
|
||||
engine, spec = _engine_and_spec()
|
||||
cfg = _merged_cfg()
|
||||
configure_sfr_intercube_multisip(engine, spec, cfg)
|
||||
|
||||
ipcq0 = engine._components["sip0.cube15.pe0.pe_ipcq"]
|
||||
qp0 = ipcq0.queue_pairs
|
||||
assert "N" in qp0
|
||||
assert "W" in qp0
|
||||
assert "E" not in qp0, "cube 15 (col=3) must NOT have E"
|
||||
assert "S" not in qp0, "cube 15 (row=3) must NOT have S"
|
||||
assert "global_E" in qp0, "root cube must have global_E"
|
||||
assert "global_W" in qp0, "root cube must have global_W"
|
||||
assert qp0["global_E"]["peer"].sip == 1
|
||||
assert qp0["global_E"]["peer"].cube == 15
|
||||
# Interior cube 5: every PE should have N/S/E/W same-lane.
|
||||
for pe in range(PES_PER_CUBE):
|
||||
qp = engine._components[f"sip0.cube5.pe{pe}.pe_ipcq"].queue_pairs
|
||||
for d in ("N", "S", "E", "W"):
|
||||
assert d in qp, f"sip0.cube5.pe{pe} missing intercube {d}"
|
||||
assert qp[d]["peer"].pe == pe, (
|
||||
f"sip0.cube5.pe{pe} {d} not same-lane"
|
||||
)
|
||||
|
||||
ipcq1 = engine._components["sip1.cube15.pe0.pe_ipcq"]
|
||||
qp1 = ipcq1.queue_pairs
|
||||
assert qp1["global_E"]["peer"].sip == 0
|
||||
assert qp1["global_E"]["peer"].cube == 15
|
||||
# ── Inter-SIP (global_*) ──────────────────────────────────────
|
||||
|
||||
def test_all_cubes_have_inter_sip(self):
|
||||
"""ALL cubes (not just root) are wired for inter-SIP."""
|
||||
def test_every_pe_on_every_cube_has_inter_sip(self):
|
||||
"""All PEs on all cubes wired for inter-SIP via global_*."""
|
||||
engine, spec = _engine_and_spec()
|
||||
cfg = _merged_cfg()
|
||||
configure_sfr_intercube_multisip(engine, spec, cfg)
|
||||
|
||||
root_cube = int(cfg.get("root_cube", N_CUBES - 1))
|
||||
for cube_id in range(N_CUBES):
|
||||
ipcq = engine._components[f"sip0.cube{cube_id}.pe0.pe_ipcq"]
|
||||
qp = ipcq.queue_pairs
|
||||
for pe in range(PES_PER_CUBE):
|
||||
qp = engine._components[
|
||||
f"sip0.cube{cube_id}.pe{pe}.pe_ipcq"
|
||||
].queue_pairs
|
||||
assert "global_E" in qp, (
|
||||
f"sip0.cube{cube_id}.pe0 missing global_E"
|
||||
)
|
||||
assert "global_W" in qp, (
|
||||
f"sip0.cube{cube_id}.pe0 missing global_W"
|
||||
)
|
||||
if cube_id == root_cube:
|
||||
assert qp["global_E"]["peer"].sip != 0, (
|
||||
f"root cube {root_cube} global_E must point to another SIP"
|
||||
f"sip0.cube{cube_id}.pe{pe} missing global_E"
|
||||
)
|
||||
assert "global_W" in qp
|
||||
# Peer must be same (cube, pe) on another SIP.
|
||||
assert qp["global_E"]["peer"].sip == 1
|
||||
assert qp["global_E"]["peer"].cube == cube_id
|
||||
assert qp["global_E"]["peer"].pe == pe
|
||||
|
||||