diff --git a/iron/common/compilation/__init__.py b/iron/common/compilation/__init__.py index de748ffb1..71b33b500 100644 --- a/iron/common/compilation/__init__.py +++ b/iron/common/compilation/__init__.py @@ -31,4 +31,5 @@ from .sequence import ( SequenceMLIRArtifact, FusePythonGeneratedMLIRCompilationRule, + trace_buffer_layout, ) diff --git a/iron/common/compilation/base.py b/iron/common/compilation/base.py index 5ecc8746a..32d1864f3 100644 --- a/iron/common/compilation/base.py +++ b/iron/common/compilation/base.py @@ -320,11 +320,14 @@ def __init__( mlir_input: CompilationArtifact, dependencies: list[CompilationArtifact], extra_flags: list[str] | None = None, + trace_size: int = 0, ) -> None: if mlir_input not in dependencies: dependencies = dependencies + [mlir_input] super().__init__(filename, dependencies) self.extra_flags = extra_flags if extra_flags is not None else [] + # Bytes of trace buffer per runlist step, 0 for an untraced build. + self.trace_size = trace_size class XclbinArtifact(_MLIRInputMixin, CompilationArtifact): @@ -544,6 +547,10 @@ def compile(self, graph): "--expand-load-pdis", "--get-scratchpad-parameters", ] + artifact.extra_flags + if artifact.trace_size: + # The trace parser reads the lowered module for the buffer + # layout and each design's traced tiles and events. + options.append("--get-input-with-addresses") def _compile( artifact=artifact, diff --git a/iron/common/compilation/sequence.py b/iron/common/compilation/sequence.py index 52382b805..19ac7d75e 100644 --- a/iron/common/compilation/sequence.py +++ b/iron/common/compilation/sequence.py @@ -14,6 +14,7 @@ from aie import ir from aie.dialects import aie, aiex, memref from aie.extras.context import mlir_mod_ctx +from aie.utils.trace import get_trace_slices import ml_dtypes from typing import Any @@ -34,6 +35,22 @@ # ########################################################################## +def trace_buffer_layout(mlir_text: str): + """Regions of the fused trace buffer, one per traced operator. + + `-aie-fuse-trace-buffers` gives the dispatched sequence one trace buffer + covering every design it configures, and records the split on the sequence. + The host reads it for the buffer's size, the parser for which design wrote + which bytes. + + Returns `(total_bytes, slices)`; `(0, [])` for an untraced build. + """ + slices = get_trace_slices(mlir_text) + if not slices: + return 0, [] + return max(s["offset"] + s["size"] for s in slices), slices + + class SequenceMLIRArtifact(MLIRArtifact): def __init__( self, @@ -43,6 +60,7 @@ def __init__( subbuffer_layout: dict[str, tuple[str, int, int]], buffer_sizes: tuple[int, int, int], slice_info: dict[str, tuple[str, int, int]] | None = None, + trace_size: int = 0, ) -> None: dependencies = list(operator_mlir_map.values()) super().__init__(filename, dependencies) @@ -51,6 +69,8 @@ def __init__( self.subbuffer_layout = subbuffer_layout self.buffer_sizes = buffer_sizes self.slice_info = slice_info or {} + # Bytes of trace buffer per runlist step, 0 for an untraced build. + self.trace_size = trace_size # Helper Functions diff --git a/iron/common/sequence.py b/iron/common/sequence.py index 0d33b482c..125ee6001 100644 --- a/iron/common/sequence.py +++ b/iron/common/sequence.py @@ -65,6 +65,12 @@ def resolve(self, device): return SeparateDispatch() +def _trace_tag(seq): + """Tracing adds a runtime-sequence argument, so a traced build cannot reuse an + untraced one's ELF. Empty when untraced, leaving those artifacts named as before.""" + return f"_traced{seq.trace_size}" if seq.trace_size else "" + + class FusedDispatch(SequenceDispatch): """Single-ELF dispatch (NPU2 only): all operators fused into one ELF.""" @@ -81,10 +87,11 @@ def set_up_artifacts(self, seq): mlir_artifact = self.build_fused_mlir(seq) kernel_objects = self._collect_kernel_artifacts(seq) full_elf_artifact = comp.FullElfArtifact( - f"{seq.name}.elf", + f"{seq.name}{_trace_tag(seq)}.elf", mlir_input=mlir_artifact, dependencies=[mlir_artifact] + kernel_objects, extra_flags=seq.extra_flags, + trace_size=seq.trace_size, ) seq.add_artifacts([full_elf_artifact]) @@ -112,12 +119,13 @@ def build_fused_mlir(self, seq): comp_runlist.append((design_names[design_of[id(op)]], *bufs)) return comp.SequenceMLIRArtifact( - seq.name + "_fused.mlir", + f"{seq.name}{_trace_tag(seq)}_fused.mlir", operator_mlir_map=operator_mlir_map, runlist=comp_runlist, subbuffer_layout=seq.subbuffer_layout, buffer_sizes=seq.buffer_sizes, slice_info=seq.slice_info, + trace_size=seq.trace_size, ) def _collect_kernel_artifacts(self, seq): @@ -264,6 +272,7 @@ def __init__( buffer_sizes=None, dispatch="auto", extra_flags=None, + trace_size=0, share_designs=False, *args, **kwargs, @@ -289,6 +298,8 @@ def __init__( ) # Optional dict: buffer_name -> size_in_bytes # Extra aiecc flags forwarded to the full-ELF build. self.extra_flags = extra_flags or [] + # Bytes of hardware trace buffer per runlist step; 0 leaves the design untraced. + self.trace_size = trace_size self.share_designs = share_designs self._dispatch = dispatch @@ -561,6 +572,8 @@ def __init__(self, op, device_name="main", sequence_name="sequence"): self.run_handle.set_arg(0, self.input_buffer.buffer_object()) self.run_handle.set_arg(1, self.output_buffer.buffer_object()) self.run_handle.set_arg(2, self.scratch_buffer.buffer_object()) + if self.trace_buffer is not None: + self.run_handle.set_arg(3, self.trace_buffer.buffer_object()) self._params = None @@ -598,6 +611,24 @@ def _allocate_buffers(self): self.scratch_buffer = XRTTensor( (_n_elements(scratch_sz),), dtype=ml_dtypes.bfloat16 ) + # Trace lowering appends one buffer covering every configured design, + # after the consolidated three. trace_size is per design and says + # nothing about how many channels or sub-designs claim a share, so the + # size comes from the lowered module. + self.trace_buffer = None + self.trace_slices = [] + if self.op.trace_size: + total, self.trace_slices = comp.trace_buffer_layout( + self.lowered_mlir_text() + ) + if total: + self.trace_buffer = XRTTensor((total,), dtype=np.int8) + + def lowered_mlir_text(self) -> str: + """aiecc's post-lowering module, which carries the trace buffer layout.""" + mlir_filename = self.op.artifacts[0].mlir_input.filename + path = comp._aiecc_work_dir(mlir_filename) / "input_with_addresses.mlir" + return path.read_text() def get_buffer(self, buffer_name): if buffer_name in self._buffer_cache: @@ -625,6 +656,9 @@ def _sync_outputs(self): # range "cpu" (otherwise a looped dispatch would read stale output). self.output_buffer.device = "npu" self.output_buffer.to("cpu") + if self.trace_buffer is not None: + self.trace_buffer.device = "npu" + self.trace_buffer.to("cpu") def _run(self): self.run_handle.start() diff --git a/iron/common/tracing_utils.py b/iron/common/tracing_utils.py new file mode 100644 index 000000000..b7368c982 --- /dev/null +++ b/iron/common/tracing_utils.py @@ -0,0 +1,367 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 KU Leuven (MICAS). All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Read a traced run's hardware trace buffer back and write Perfetto JSON. + +Tracing is configured at build time (``IRON_TRACE_SIZE`` / ``IRON_TRACE_NTILES``, +consumed by the operator's design) and the runtime already syncs the resulting +buffer device->host after every dispatch. Nothing reads it, though, so a traced +run leaves its data sitting in host memory. This module is that last step: one call +after ``run()`` turns it into files. + + from iron.common.tracing_utils import dump_traces + + run = operator.get_callable() + run() + dump_traces(run, "my_operator") + +No-op on an untraced build, so the call can stay in a test unconditionally. + +Two files land per traced design: the raw 32-bit words as hex text, and the +parsed JSON for https://ui.perfetto.dev. The raw text is kept because reparsing is +free and re-dispatching is not - see :func:`parse_trace_words` to reparse it with a +different column shift without touching the device. + +``dump_traces`` also prints a per-tile summary, since the Perfetto timeline of a +few hundred short kernel calls is hard to read at a glance and the numbers a +designer wants - how much of the run a core spent computing, and how much waiting - +are a few sums away. :func:`print_trace_summary` does the same for a JSON file +written earlier. + +Environment: + * ``IRON_TRACE_DIR`` where to write (default ``outputs/traces``) + * ``IRON_TRACE_MLIR`` override the MLIR the parser reads (see below) + * ``IRON_TRACE_COLSHIFT`` force the column shift; unset means auto-detect +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import numpy as np + +from aie.utils.trace.parse import parse_trace + +from . import compilation as comp + +__all__ = [ + "dump_traces", + "parse_trace_words", + "lowered_mlir", + "trace_words", + "summarize_trace", + "print_trace_summary", +] + +DEFAULT_TRACE_DIR = "outputs/traces" + +# The kernel brackets: aie_kernels sources wrap their body in event0()/event1(), +# so one pair is one kernel invocation. Everything between two pairs is the core +# waiting - on its input object FIFO, on a lock, on the next descriptor. +KERNEL_START, KERNEL_END = "INSTR_EVENT_0", "INSTR_EVENT_1" + + +def lowered_mlir(run) -> tuple[Path, str]: + """The post-lowering MLIR for a callable, as ``(path, text)``. + + mlir-aie's trace parser reads ``aiex.npu.write32`` ops and matches the + trace-unit config addresses. ``aie-insert-trace-flows`` produces those writes + from the declarative ``aie.trace`` ops inside aiecc, so the module handed to + aiecc carries none of them. A traced build passes + ``--get-input-with-addresses``, which lands the lowered module in the work dir + beside the source (``.mlir.d/``). + """ + override = os.environ.get("IRON_TRACE_MLIR") + if override: + path = Path(override) + return path, path.read_text() + + source = Path(run.op.artifacts[0].mlir_input.filename) + path = comp._aiecc_work_dir(str(source)) / "input_with_addresses.mlir" + if not path.exists(): + raise FileNotFoundError( + f"{path} is missing; a traced build passes --get-input-with-addresses " + "to aiecc. Point IRON_TRACE_MLIR at a lowered module to override." + ) + return path, path.read_text() + + +def trace_words(buf) -> np.ndarray: + """A trace buffer's contents as uint32 words, with the unfilled tail dropped. + + The buffer is allocated at the full trace size and only partly written, so the + trailing zeros are absence of events rather than events. Trimming them keeps the + JSON small and stops the parser inventing a long idle tail. + """ + raw = buf.to_torch().numpy().astype(np.uint8) + raw = raw[: raw.size - raw.size % 4] + words = raw.view(np.uint32) # little-endian on x86, matching the DMA layout + if not words.any(): + return words[:0] + return words[: int(np.nonzero(words)[0][-1]) + 1] + + +def parse_trace_words( + words, mlir_text: str, colshift: int | None = None, device: str | None = None +): + """Trace words plus the lowered MLIR into Trace Event Format events. + + ``colshift`` of None lets the parser align the columns itself, which is what you + want by default: a design configured for one column may be loaded into another. + Override it only when the tiles in the output do not match the placement. + + ``device`` names the ``aie.device`` that wrote these words. A fused sequence + holds one per sub-design, and two often share tile coordinates, so an unset + ``device`` merges their event assignments. + + The parser calls ``sys.exit`` rather than raising on some malformed input, so + SystemExit is caught here - a visualisation failure should never take a test + down with it. + """ + try: + return parse_trace( + np.asarray(words, dtype=np.uint32), mlir_text, colshift, device + ) + except SystemExit as exc: + raise RuntimeError( + "mlir-aie's trace parser exited; the usual cause is an MLIR without the " + "trace register writes, or a column shift that does not match the data. " + "Run with logging at DEBUG to see the tiles it found." + ) from exc + + +def _slug(text: str) -> str: + keep = "-_." + return "".join(c if c.isalnum() or c in keep else "_" for c in text) + + +def _by_tile(events): + """Group Trace Event Format records by pid, resolving each pid's tile name. + + The parser emits one process per traced tile (``process_name`` metadata), and + one thread per monitored event slot. Metadata records carry no timestamp, so + they are separated out here rather than filtered at every use. + """ + names, records = {}, {} + for index, event in enumerate(events): + pid = event.get("pid") + if event.get("ph") == "M": + if event.get("name") == "process_name": + names[pid] = event.get("args", {}).get("name", str(pid)) + continue + if "ts" in event: + records.setdefault(pid, []).append((event["ts"], index, event)) + for pid in records: + records[pid].sort() # index breaks ts ties, keeping emission order + return names, records + + +def _state_cycles(records): + """Cycles each event name was asserted, summed over its begin/end intervals. + + A level event (a stall, vector activity) is emitted as ``B``/``E`` pairs on its + own thread, re-asserted at every trace command, so one logical stall arrives as + many short intervals. Summing them gives the time in that state. These overlap + each other and the kernel brackets - a core stalls *during* a kernel call - so + they are shares of the window, not a partition of it. + """ + open_at, totals = {}, {} + for ts, _, event in records: + key = (event.get("tid"), event.get("name")) + if event.get("ph") == "B": + open_at.setdefault(key, ts) + elif event.get("ph") == "E" and key in open_at: + totals[key[1]] = totals.get(key[1], 0) + ts - open_at.pop(key) + return totals + + +def _invocations(records): + """Kernel invocations as ``(start, end)`` cycle pairs. + + Pairs each ``event0`` with the next ``event1``, ignoring repeats of either - + the same rule mlir-aie's own summary uses, so the call counts agree. + """ + spans, start = [], None + for ts, _, event in records: + if event.get("ph") != "B": + continue + if event.get("name") == KERNEL_START and start is None: + start = ts + elif event.get("name") == KERNEL_END and start is not None: + spans.append((start, ts)) + start = None + return spans + + +def _stats(values): + if not values: + return None + ordered = sorted(values) + return { + "count": len(ordered), + "total": sum(ordered), + "min": ordered[0], + "max": ordered[-1], + "mean": sum(ordered) / len(ordered), + } + + +def summarize_trace(source) -> dict: + """Per-tile cycle accounting for a parsed trace. + + Accepts a JSON path or an already-parsed event list. Returns + ``{tile_name: {...}}`` with, per tile: the traced ``window`` in cycles, the + kernel invocations (``busy``), the gaps between them (``waiting``), and the + cycles spent in each monitored state. + + All figures are AIE core cycles, from the trace unit's own timer. + """ + if isinstance(source, (str, Path)): + source = json.loads(Path(source).read_text()) + + names, per_pid = _by_tile(source) + summary = {} + for pid, records in per_pid.items(): + window = records[-1][0] - records[0][0] + spans = _invocations(records) + busy = _stats([end - start for start, end in spans]) + waiting = _stats([nxt[0] - cur[1] for cur, nxt in zip(spans, spans[1:])]) + summary[names.get(pid, str(pid))] = { + "window": window, + "busy": busy, + "waiting": waiting, + "states": _state_cycles(records), + } + return summary + + +def _pct(part, whole): + return f"{100.0 * part / whole:5.1f}%" if whole else " -" + + +def print_trace_summary(source, title: str | None = None) -> dict: + """Print :func:`summarize_trace` as a short per-tile report, and return it. + + Reads as: how much of the traced window each core spent inside a kernel, how + much it spent between kernels, and what it was stalled on meanwhile. + """ + summary = summarize_trace(source) + if title is None and isinstance(source, (str, Path)): + title = Path(source).name + if title: + print(f"\n[trace] {title}") + + for tile, data in summary.items(): + window = data["window"] + busy, waiting = data["busy"], data["waiting"] + print(f" {tile} - {window} cycles traced") + + if busy: + print( + f" in kernel {busy['count']:>6} calls {busy['total']:>10} cyc " + f"{_pct(busy['total'], window)} " + f"min/mean/max {busy['min']}/{busy['mean']:.1f}/{busy['max']}" + ) + else: + print( + f" in kernel no {KERNEL_START}/{KERNEL_END} pairs - does " + "this kernel call event0()/event1()?" + ) + if waiting: + print( + f" between {waiting['count']:>6} gaps " + f"{waiting['total']:>10} cyc {_pct(waiting['total'], window)} " + f"min/mean/max {waiting['min']}/{waiting['mean']:.1f}/{waiting['max']}" + ) + + # Stalls and vector activity overlap the above, so they are listed apart. + states = { + name: cycles + for name, cycles in data["states"].items() + if name not in (KERNEL_START, KERNEL_END) and cycles + } + for name, cycles in sorted(states.items(), key=lambda s: -s[1]): + print(f" {name.lower():<12} {cycles:>22} cyc {_pct(cycles, window)}") + if summary: + print( + " (stall and vector shares overlap the kernel time above, " + "they are not a partition)" + ) + return summary + + +def dump_traces( + run, + tag: str, + out_dir=None, + colshift: int | None = None, + summary: bool = True, +) -> list[Path]: + """Write a completed run's trace buffer as hex text and Perfetto JSON. + + Call it after ``run()``: the callable syncs its trace buffer device->host as + part of the dispatch, so this only reads host memory. Returns the JSON paths + written, empty on an untraced build. + + ``tag`` distinguishes one dump from another - a test name or parameter id. The + layout the compiler recorded on the dispatched sequence splits the buffer, so a + fused sequence yields one pair of files per configured design. + """ + buffer = getattr(run, "trace_buffer", None) + if buffer is None: + if getattr(getattr(run, "op", None), "trace_size", 0): + raise TypeError( + f"{type(run).__name__} was built with tracing enabled but exposes no " + "trace_buffer; only the full-ELF sequence callable allocates one." + ) + return [] + + out_dir = Path(out_dir or os.environ.get("IRON_TRACE_DIR", DEFAULT_TRACE_DIR)) + out_dir.mkdir(parents=True, exist_ok=True) + + if colshift is None: + env = os.environ.get("IRON_TRACE_COLSHIFT") + colshift = int(env) if env else None + + mlir_path, mlir_text = lowered_mlir(run) + print(f"[trace] parsing against {mlir_path}") + + all_words = buffer.to_torch().numpy().astype(np.uint8).view(np.uint32) + tag = _slug(tag) + written = [] + for index, entry in enumerate(run.trace_slices): + name = f"{index}_{entry['device']}" + start = entry["offset"] // 4 + region = all_words[start : start + entry["size"] // 4] + words = region[: int(np.nonzero(region)[0][-1]) + 1] if region.any() else region + if not words.size: + print(f"[trace] {name}: buffer is all zeros, no trace data captured") + continue + if words.size == region.size: + print( + f"[trace] {name}: slice full ({entry['size']} B), trace is likely " + "truncated - raise IRON_TRACE_SIZE" + ) + + stem = out_dir / f"{tag}_{_slug(name)}" + stem.with_suffix(".txt").write_text("\n".join(f"{w:08x}" for w in words) + "\n") + + try: + events = parse_trace_words(words, mlir_text, colshift, entry["device"]) + except Exception as exc: # never let a visualisation failure fail a run + print(f"[trace] {name}: parse failed ({exc}); raw words kept at {stem}.txt") + continue + + target = stem.with_suffix(".json") + target.write_text(json.dumps(events)) + print(f"[trace] {target} ({len(events)} events)") + written.append(target) + + if summary: + try: + print_trace_summary(events, title=target.name) + except Exception as exc: # a summary is never worth failing a run over + print(f"[trace] {name}: summary failed ({exc})") + return written diff --git a/iron/operators/swiglu_prefill_stream/op.py b/iron/operators/swiglu_prefill_stream/op.py index 4a4098f57..0b0711c86 100644 --- a/iron/operators/swiglu_prefill_stream/op.py +++ b/iron/operators/swiglu_prefill_stream/op.py @@ -148,6 +148,8 @@ class SwiGLUPrefillStream(OperatorSequence): def __init__( self, seq_len, embedding_dim, hidden_dim, k=1, context=None, share_designs=True ): + from iron.operators.swiglu_prefill_stream.stream_design import trace_size + ports, inputs, outputs = _wiring(seq_len, embedding_dim, hidden_dim, k) groups = [ _SwiGLUStreamGroup( @@ -167,6 +169,7 @@ def __init__( ], input_args=inputs, output_args=outputs, + trace_size=trace_size(), share_designs=share_designs, context=context, ) diff --git a/iron/operators/swiglu_prefill_stream/stream_design.py b/iron/operators/swiglu_prefill_stream/stream_design.py index b4edae178..3d48b04cb 100644 --- a/iron/operators/swiglu_prefill_stream/stream_design.py +++ b/iron/operators/swiglu_prefill_stream/stream_design.py @@ -277,12 +277,27 @@ def _experiment_id(seq_len, embedding_dim, hidden_dim, k): grid = array() hardware = os.path.splitext(os.path.basename(ACCELERATOR))[0] suffix = f"_k{k}" if k > 1 else "" + if trace_size(): + suffix += "_traced" return ( f"{hardware}-swiglu{suffix}_{seq_len}_{embedding_dim}_{hidden_dim}" f"-{grid.num_rows}_row_{grid.num_columns}_col" ) +def trace_size(): + """DDR trace buffer in bytes, 0 for an untraced build. + + Opt-in: tracing adds a runtime-sequence argument, so it changes the ABI. + """ + return int(os.environ.get("IRON_TRACE_SIZE", "0")) + + +def trace_tiles(): + """How many tiles to trace. Routing, not the packet id space, is the real limit.""" + return int(os.environ.get("IRON_TRACE_NTILES", "4")) + + def _design_paths(seq_len, embedding_dim, hidden_dim, k): """Where stream-dse writes each group's MLIR. @@ -319,7 +334,8 @@ def _run_codegen(seq_len, embedding_dim, hidden_dim, npu, k): output_path=OUTPUT_ROOT, skip_if_exists=False, enable_codegen=True, - trace_size=0, + trace_size=trace_size(), + trace_max_tiles=trace_tiles(), nb_cols_to_use=grid.num_columns, npu=npu, backend=BACKEND, diff --git a/iron/operators/swiglu_prefill_stream/test.py b/iron/operators/swiglu_prefill_stream/test.py index c0a9deaa8..a6d9032af 100644 --- a/iron/operators/swiglu_prefill_stream/test.py +++ b/iron/operators/swiglu_prefill_stream/test.py @@ -7,6 +7,8 @@ import pytest import torch +from iron.common.tracing_utils import dump_traces + # The design is generated by stream-dse at compile() time. stream-dse is an # optional dependency (see requirements_stream.txt) absent from the default CI # image, so skip this whole module when it is unavailable. @@ -70,6 +72,7 @@ def test_swiglu_prefill_stream(k, aie_context): # up to 25%. Tolerances are local to this test. run = _staged(operator, golden_ref) run() + dump_traces(run, f"swiglu_k{k}") output = run.get_buffer(OUTPUT).to_torch().reshape((SEQ_LEN, EMBEDDING_DIM)) errors = verify_buffer( output, diff --git a/iron/tests/infrastructure/trace_layout.py b/iron/tests/infrastructure/trace_layout.py new file mode 100644 index 000000000..d16b2626c --- /dev/null +++ b/iron/tests/infrastructure/trace_layout.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 KU Leuven (MICAS). All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Reading back the trace buffer layout the compiler recorded on the sequence.""" + +from iron.common.compilation import trace_buffer_layout + +LOWERED = """ +module { + aie.device(npu1_1col) { + aie.runtime_sequence @sequence(%arg0: memref<4xi32>, %arg1: memref<12288xi8>) + attributes {aie.trace_slices = [ + {device = "dev_a", offset = 0 : i64, sequence = "seq", size = 8192 : i64}, + {device = "dev_b", offset = 8192 : i64, sequence = "seq", size = 4096 : i64}]} { + } + } +} +""" + +UNTRACED = """ +module { + aie.device(npu1_1col) { + aie.runtime_sequence @sequence(%arg0: memref<4xi32>) { + } + } +} +""" + + +def test_total_spans_every_slice(): + total, slices = trace_buffer_layout(LOWERED) + assert total == 12288 + assert [s["offset"] for s in slices] == [0, 8192] + assert [s["size"] for s in slices] == [8192, 4096] + + +def test_each_slice_names_the_design_that_wrote_it(): + _, slices = trace_buffer_layout(LOWERED) + assert [s["device"] for s in slices] == ["dev_a", "dev_b"] + + +def test_untraced_build_has_no_trace_buffer(): + assert trace_buffer_layout(UNTRACED) == (0, []) diff --git a/requirements_stream.txt b/requirements_stream.txt index 092dc823c..72799e5cf 100644 --- a/requirements_stream.txt +++ b/requirements_stream.txt @@ -4,9 +4,10 @@ # Optional dependencies for the stream-dse-backed fused SwiGLU-prefill operator # (iron/operators/swiglu_prefill_stream). # -# Not installed by the default CI (requirements.txt); the operator's test skips -# itself (pytest.importorskip) when stream-dse is absent. Install this file to -# build and run the operator and its test: +# Kept out of requirements.txt so an install without stream-dse still works: the +# operator's test skips itself (pytest.importorskip) when it is absent. CI does +# install this file (.github/actions/prereqs), so the operator runs there. To build +# and run the operator and its test: # # pip install -r requirements_stream.txt # stream-setup-aie # REQUIRED: installs stream-dse's pure-Python AIE codegen @@ -19,4 +20,4 @@ # package directory, so that environment must be writable. onnxscript>=0.7 -stream-dse>=1.13.11 +stream-dse>=1.13.14