diff --git a/conftest.py b/conftest.py index bdefdc4188..31f44ca419 100644 --- a/conftest.py +++ b/conftest.py @@ -163,6 +163,12 @@ def pytest_addoption(parser): help="Enable PMU collection. Bare flag = PIPE_UTILIZATION(2). " "Pass event type to override (e.g. --enable-pmu 4)", ) + parser.addoption( + "--enable-scope-stats", + action="store_true", + default=False, + help="Enable per-scope peak collection and emit /scope_stats.jsonl (per-scope ring-fill peaks).", + ) parser.addoption("--build", action="store_true", default=False, help="Compile runtime from source") parser.addoption( "--pto-isa-commit", diff --git a/docs/dfx/scope-stats.md b/docs/dfx/scope-stats.md new file mode 100644 index 0000000000..0785ba380c --- /dev/null +++ b/docs/dfx/scope-stats.md @@ -0,0 +1,269 @@ +# Scope Stats — Per-scope Resource Usage Peaks + +Scope stats records the peak resource usage — task-window slots, heap +bytes, tensormap entries — for every `PTO2_SCOPE` region in an +orchestration, so you can see *which scope* drove each resource to its +high-water mark. When a model runs out of task windows, heap, or +tensormap entries, the failure tells you *which* resource is exhausted +but not *where*; scope stats gives you the where. + +It is a diagnostic-only, opt-in feature for the +**tensormap_and_ringbuffer (T&R)** runtime. When disabled (the default) +it costs a single bool load per probe. + +## 1. Quick Start + +The full workflow is three steps: enable the flag, run, then turn the +resulting `scope_stats.jsonl` into an HTML report. + +### Step 1 — Run with `--enable-scope-stats` + +Pass the flag to any T&R example or scene test: + +```bash +python tests/st//test_.py -p a2a3 -d 0 --enable-scope-stats +``` + +The flag is bit 4 of `enable_profiling_flag`; on a T&R run it turns on +per-scope peak tracking. On other runtimes the flag is accepted but +produces no records. + +### Step 2 — Locate the output + +The run writes `scope_stats.jsonl` under the per-task output prefix +(the same directory other profiling artifacts land in for that run). +It is NDJSON: line 1 is run metadata, every subsequent line is one +scope sample. See [§3](#3-output-scope_statsjsonl) for the schema. + +### Step 3 — Visualize with `scope_stats_plot.py` + +```bash +python tools/scope_stats_plot.py /scope_stats.jsonl +# writes /scope_stats.html + +# or send the report elsewhere: +python tools/scope_stats_plot.py path/to/scope_stats.jsonl --out-dir /tmp/report +``` + +| Argument | Required | Meaning | +| -------- | -------- | ------- | +| `jsonl` | yes | Path to a `scope_stats.jsonl` produced by Step 1 | +| `--out-dir DIR` | no | Where to write `scope_stats.html` (default: next to the input) | + +The output is a single self-contained `scope_stats.html` — the SVG +charts are inlined (no matplotlib, no external JS/CDN), so it opens +offline and is trivial to share. Open it in any browser. + +### Reading the report + +The report groups charts by **ring**, and within each ring draws one +line chart per resource (task_window, heap, tensormap). The x-axis is +scopes in begin/end order; hover a point to see its source site. Each +ring header lists the per-resource max capacity, and three metrics are +plotted per ring/resource: + +| Metric | Formula | What it tells you | +| ------ | ------- | ----------------- | +| `scope_high_water` | `end.head − begin.tail` | Highest occupancy the scope held — residual not yet released on entry, plus what this scope added. The true peak. | +| `real_occupancy` | `end.head − end.tail` | What is still occupied at scope exit (the live level on leaving). | +| `scope_alloc` | `end.head − begin.head` | How far the allocation frontier advanced — this scope's own net (unreleased) allocation. | + +`head` is the ring's allocation frontier (`heap_top`); `tail` is its +released boundary (`heap_tail`). Ring-buffer occupancy is non-negative, +so a negative delta is folded back by one buffer length +(`(head + cap − tail) % cap`) — a wrap past the buffer end. `tensormap` +is reported as a single in-use value (no head/tail), so it shows only +the `real_occupancy` curve. + +## 2. What gets captured + +- **The whole orchestration, automatically.** You do not mark a region + to profile — instrumentation lives inside the `PTO2_SCOPE` macro + itself, so *every* scope in the orchestration is recorded once the + flag is on. The executor wraps the orchestration entry in a root + `PTO2_SCOPE` (`depth` 0), and every user-written `PTO2_SCOPE` nests + under it, so the report covers the entire orch scope tree end to end. +- **One sample per scope boundary.** Each `PTO2_SCOPE` emits a `begin` + record on entry and an `end` record on exit. The plot tool pairs them + by site to compute the metrics above. +- **Per-ring.** Each ring's task allocator heap / task-window is tracked + independently; a scope only ever touches its own ring + (`ring = min(depth, MAX_RING_DEPTH−1)`). +- **Capacity denominators.** The `*_max` capacities in the metadata line + are snapshots of what the runtime actually configured for that run, so + `used/cap` ratios are exact. +- **Disk-bounded, no wrap.** Full buffers stream off the device during + the run, so the record count is bounded only by disk — there is no + fixed-ring overwrite. + +## 3. Output: `scope_stats.jsonl` + +NDJSON. Line 1 is run metadata; each subsequent line is one scope +sample (`begin` or `end`). Schema version 4: + +```json +{"version": 4, "fatal": false, "dropped": 0, "total": 4, "task_window_max": [8, 4], "heap_max": [268435456, 268435456], "tensormap_max": 65536} +{"site": "example_orchestration.cpp:77", "phase": "begin", "depth": 1, "ring": 1, "task_window_start": 0, "task_window_end": 0, "heap_start": 0, "heap_end": 0, "tensormap": 0} +{"site": "example_orchestration.cpp:77", "phase": "end", "depth": 1, "ring": 1, "task_window_start": 0, "task_window_end": 4, "heap_start": 0, "heap_end": 8192, "tensormap": 5} +``` + +Metadata line (line 1): + +| Field | Type | Meaning | +| ----- | ---- | ------- | +| `version` | int | Schema version (`4`) | +| `fatal` | bool | `true` iff a fatal was latched during the run; records past it are diagnostic-only | +| `dropped` | uint | Records dropped on device (free_queue empty / ready_queue full); `0` on a healthy run | +| `total` | uint | Total records the device attempted (collected + dropped) | +| `task_window_max` | int[] | Per-ring task-window capacity (indexed by `ring`) | +| `heap_max` | int[] | Per-ring heap-byte capacity (indexed by `ring`) | +| `tensormap_max` | int | Tensormap entry capacity (scalar) | + +Per-sample lines, oldest-first: + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `site` | `"basename:line"` | Source location of the `PTO2_SCOPE()` call | +| `phase` | `"begin"`/`"end"` | Scope entry or exit sample | +| `depth` | int | Nesting depth (0 = root scope inside the executor) | +| `ring` | int | Ring this scope used; indexes `task_window_max` / `heap_max` | +| `task_window_start` | int | Task-window ring tail at this boundary | +| `task_window_end` | int | Task-window ring head at this boundary | +| `heap_start` | uint | Heap ring tail (released boundary) in bytes | +| `heap_end` | uint | Heap ring head (allocation frontier) in bytes | +| `tensormap` | int | Tensormap entries in use | + +`start`/`end` are the ring's tail/head pointers at that boundary — see +the metric formulas in [§1](#reading-the-report) for how a scope's peak +and occupancy are derived from a paired begin/end. + +--- + +## 4. Internals + +This section is for maintainers wiring scope stats into a runtime; users +do not need it. There is **no public C/C++ API** — the only external +interfaces are the `--enable-scope-stats` flag and the plot tool above. + +### 4.1 Layering + +Scope stats uses a platform-provides / runtime-calls pattern: + +```text +platform/include/aicpu/scope_stats_collector_aicpu.h + Pure-value API declarations. No runtime types cross this boundary. + +platform/src/aicpu/scope_stats_collector_aicpu.cpp + Owns all collector state (depth stack, peak arrays, shared buffer). + Scope lifecycle, peak comparison, capacity registration, record writes. + +platform/src/host/scope_stats_collector.cpp + Host side: allocates the shared header/buffer pool, streams full + buffers off the device, reconciles counters, writes scope_stats.jsonl. + +runtime (pto_orchestrator.cpp, pto_scheduler.h) + Calls platform APIs at instrumentation points, passing extracted + values (ring_id, heap_bytes, ...) as plain integers. No scope_stats + source files live in the runtime directory. +``` + +### 4.2 AICPU platform API + +Header: +[`src/a2a3/platform/include/aicpu/scope_stats_collector_aicpu.h`](../../src/a2a3/platform/include/aicpu/scope_stats_collector_aicpu.h) + +All entry points are `extern "C"` and take primitive types only, so the +same collector links into any runtime that wires it up. Symbol +resolution is unconditional (see §4.4), so call sites need no guards. + +Single-producer contract: all `*_peaks` updates use non-atomic +read-max-write and assume the orchestrator thread is the only writer. +Concurrent callers may lose peaks silently — acceptable for diagnostic +data, and it saves an atomic on the hot path. + +```cpp +// Host → AICPU init (called from kernel.cpp at kernel entry) +void set_scope_stats_enabled(bool enable); +void set_platform_scope_stats_base(uint64_t scope_stats_data_base); + +// Runtime → AICPU init (once per ring at orchestrator init / scheduler attach) +void scope_stats_set_ring_capacity(int ring_id, int32_t window_cap, uint64_t heap_cap); +void scope_stats_set_tensormap_capacity(int32_t cap); + +// Runtime → AICPU per-scope +void scope_stats_set_pending_site(const char *file, int line); +void scope_stats_begin(int ring_id, uint64_t heap_bytes, int32_t tasks_in_flight, int32_t tensormap_used); +void scope_stats_end(int ring_id, uint64_t heap_bytes, int32_t tasks_in_flight, int32_t tensormap_used); +void scope_stats_on_fatal(); +``` + +`enable` mirrors the host's `--enable-scope-stats` flag; +`scope_stats_data_base` is the device-visible address of the shared +header the host allocates. `set_platform_scope_stats_base` doubles as +device-side init (maps the header + per-instance buffer state, resets +collector-local state). When `enable=false` every probe early-returns +after one bool load. + +A scope costs exactly two collector calls — `begin` and `end` — each +carrying that boundary's sample for the scope's own ring. The runtime +gates both on the local weak `is_scope_stats_enabled()` stub first, so a +disabled run pays neither the cross-`.so` calls nor the cross-agent +`active_count()` read (same idiom as `is_dep_gen_enabled`). +`PTO2_SCOPE()` expansion calls `set_pending_site(__FILE__, __LINE__)` +before `begin`; `copy_basename` keeps the JSON readable without forcing +the host to chase a device pointer into the orchestration `.so`'s string +table. `on_fatal` sets `header.fatal_latched`, surfacing as +`"fatal": true`; the host still emits whatever records made it. + +`ring_id` outside `[0, PTO2_SCOPE_STATS_MAX_RING_DEPTH)` is silently +dropped. Caps are copied verbatim into the buffer header so the host can +render `used/cap` without a second device→host query. + +### 4.3 Comparison with other profiling subsystems + +| Feature | Layer | Runtime scope | Why | +| ------- | ----- | ------------- | --- | +| PMU | platform only | all runtimes | reads hardware registers | +| L2 swimlane | platform only | all runtimes | reads AICore ring buffers | +| dep_gen | platform only | all runtimes | traces `submit_task` | +| tensor dump | platform only | all runtimes | dumps tensor data | +| **scope stats** | **platform API + runtime call sites** | **T&R only** | runtime extracts values, platform tracks peaks | + +### 4.4 Symbol resolution + +`kernel.cpp` (platform, shared by all runtimes) always calls +`set_scope_stats_enabled` / `set_platform_scope_stats_base`, so the +collector symbols resolve into every AICPU `.so`. Only the T&R runtime +adds the `begin`/`end`/capacity call sites, so only it produces records; +host_build_graph links the collector but never invokes it. + +### 4.5 Data flow + +```text +Host AICPU (T&R runtime) +───── ───────────────────── +ScopeStatsCollector platform scope_stats_collector_aicpu.cpp + allocate header + buffer pool set_platform_scope_stats_base(addr) + pre-fill free_queue set_scope_stats_enabled(true) + set kernel_args fields runtime: scope_stats_set_ring_capacity() + launch kernel runtime: scope_stats_set_tensormap_capacity() + │ │ + poll thread: on PTO2_SCOPE begin/end: + append records to memory ◀──┐ runtime: scope_stats_begin()/end() + │ │ └─ emit record, append to buffer; + stop() (drain + join) └──────────── push full buffer to ready_queue + reconcile_counters() orch exit: flush remaining buffers + write_jsonl() +``` + +A worked example is in +[`tests/st/a2a3/tensormap_and_ringbuffer/dfx/scope_stats/test_scope_stats.py`](../../tests/st/a2a3/tensormap_and_ringbuffer/dfx/scope_stats/test_scope_stats.py) +— it runs the `vector_example` orchestration with `--enable-scope-stats` +and asserts the resulting NDJSON. + +### 4.6 Future: cross-runtime support + +If host_build_graph adds scope-like concepts, extending scope_stats only +requires adding the same platform call sites in HBG — no platform +changes. The collector is already runtime-agnostic: it accepts plain +values and has no knowledge of T&R types. diff --git a/python/bindings/task_interface.cpp b/python/bindings/task_interface.cpp index 7aa251db25..360c519896 100644 --- a/python/bindings/task_interface.cpp +++ b/python/bindings/task_interface.cpp @@ -618,6 +618,15 @@ NB_MODULE(_task_interface, m) { c.enable_dep_gen = v ? 1 : 0; } ) + .def_prop_rw( + "enable_scope_stats", + [](const CallConfig &c) { + return static_cast(c.enable_scope_stats); + }, + [](CallConfig &c, bool v) { + c.enable_scope_stats = v ? 1 : 0; + } + ) .def_prop_rw( "output_prefix", [](const CallConfig &c) -> std::string { @@ -639,7 +648,8 @@ NB_MODULE(_task_interface, m) { os << "CallConfig(block_dim=" << self.block_dim << ", aicpu_thread_num=" << self.aicpu_thread_num << ", enable_l2_swimlane=" << self.enable_l2_swimlane << ", enable_dump_tensor=" << (self.enable_dump_tensor ? "True" : "False") - << ", enable_pmu=" << self.enable_pmu << ", enable_dep_gen=" << (self.enable_dep_gen ? "True" : "False"); + << ", enable_pmu=" << self.enable_pmu << ", enable_dep_gen=" << (self.enable_dep_gen ? "True" : "False") + << ", enable_scope_stats=" << (self.enable_scope_stats ? "True" : "False"); if (self.output_prefix_set()) { os << ", output_prefix='" << self.output_prefix << "'"; } diff --git a/python/simpler/worker.py b/python/simpler/worker.py index e4956e708e..64274d35d8 100644 --- a/python/simpler/worker.py +++ b/python/simpler/worker.py @@ -112,11 +112,11 @@ def my_l4_orch(orch, args, config): _OFF_CALLABLE = 8 _OFF_CONFIG = 16 # Packed CallConfig wire layout — must match call_config.h byte for byte: -# 6 int32 (block_dim, aicpu_thread_num, enable_l2_swimlane, enable_dump_tensor, -# enable_pmu, enable_dep_gen) + 1024-byte NUL-terminated output_prefix. Log -# config travels separately via ChipWorker.init(log_level, log_info_v) — not -# on per-task wire. -_CFG_FMT = struct.Struct("=iiiiii1024s") +# 7 int32 (block_dim, aicpu_thread_num, enable_l2_swimlane, enable_dump_tensor, +# enable_pmu, enable_dep_gen, enable_scope_stats) + 1024-byte NUL-terminated +# output_prefix. Log config travels separately via ChipWorker.init(log_level, +# log_info_v) — not on per-task wire. +_CFG_FMT = struct.Struct("=iiiiiii1024s") # Args region starts after CONFIG, rounded up to 8 bytes so the first # ContinuousTensor.data (uint64_t at OFF_ARGS+8) is 8-byte aligned, avoiding # SIGBUS on strict-alignment platforms (aarch64 atomics, some ARM cores). @@ -718,7 +718,7 @@ def _chip_process_loop( def _read_config_from_mailbox(buf: memoryview) -> "CallConfig": """Reconstruct a CallConfig from the unified mailbox layout.""" - block_dim, aicpu_tn, swl, dt, pmu, dep_gen, prefix_bytes = _CFG_FMT.unpack_from(buf, _OFF_CONFIG) + block_dim, aicpu_tn, swl, dt, pmu, dep_gen, scope_stats, prefix_bytes = _CFG_FMT.unpack_from(buf, _OFF_CONFIG) cfg = CallConfig() cfg.block_dim = block_dim cfg.aicpu_thread_num = aicpu_tn @@ -726,6 +726,7 @@ def _read_config_from_mailbox(buf: memoryview) -> "CallConfig": cfg.enable_dump_tensor = bool(dt) cfg.enable_pmu = pmu cfg.enable_dep_gen = bool(dep_gen) + cfg.enable_scope_stats = bool(scope_stats) # NUL-terminated C string in a 1024-byte field. cfg.output_prefix = prefix_bytes.split(b"\x00", 1)[0].decode("utf-8") return cfg diff --git a/simpler_setup/scene_test.py b/simpler_setup/scene_test.py index f179b5cbaa..6c6fc358f8 100644 --- a/simpler_setup/scene_test.py +++ b/simpler_setup/scene_test.py @@ -639,6 +639,31 @@ def _convert_case_swimlane( _run_swimlane_converter(input_path=perf_file, func_names_path=func_names_path) +def _plot_case_scope_stats(case_label: str, output_prefix: Path) -> None: + """Post-case: turn ``/scope_stats.jsonl`` into per-ring + heap-delta line charts. Path is known a priori from CallConfig.output_prefix. + """ + import logging # noqa: PLC0415 + + logger = logging.getLogger(__name__) + jsonl_file = output_prefix / "scope_stats" / "scope_stats.jsonl" + if not jsonl_file.exists(): + logger.warning(f"[{case_label}] {jsonl_file} not produced; skipping scope_stats plot") + return + + import sys # noqa: PLC0415 + from pathlib import Path as _Path # noqa: PLC0415 + + tools_dir = _Path(__file__).resolve().parents[1] / "tools" + sys.path.insert(0, str(tools_dir)) + try: + import scope_stats_plot # noqa: PLC0415 + + scope_stats_plot.process(jsonl_file) + finally: + sys.path.remove(str(tools_dir)) + + def run_class_cases( # noqa: PLR0913 -- shared layer-5 entry; kwargs mirror CLI surface worker, cls_inst, @@ -652,6 +677,7 @@ def run_class_cases( # noqa: PLR0913 -- shared layer-5 entry; kwargs mirror CLI enable_dump_tensor, enable_pmu, enable_dep_gen, + enable_scope_stats, ): """Execute a pre-filtered list of cases for one class (layers 5-6). @@ -661,11 +687,14 @@ def run_class_cases( # noqa: PLR0913 -- shared layer-5 entry; kwargs mirror CLI """ cls_name = type(cls_inst).__name__ callable_spec = getattr(type(cls_inst), "CALLABLE", None) - diagnostics_on = enable_l2_swimlane or enable_dump_tensor or enable_pmu or enable_dep_gen + diagnostics_on = enable_l2_swimlane or enable_dump_tensor or enable_pmu or enable_dep_gen or enable_scope_stats for case in cases: case_label = f"{cls_name}_{case['name']}" # Per-case directory the runtime writes into. Required (non-empty) when # any diagnostic flag is on; CallConfig::validate() throws otherwise. + # scope_stats now writes /scope_stats/scope_stats.jsonl (sibling of + # l2_perf_records.json / deps.json), so it pulls output_prefix the + # same way the other DFX flags do. prefix = _build_output_prefix(case_label) if diagnostics_on else Path("") try: cls_inst._run_and_validate( @@ -679,11 +708,14 @@ def run_class_cases( # noqa: PLR0913 -- shared layer-5 entry; kwargs mirror CLI enable_dump_tensor=enable_dump_tensor, enable_pmu=enable_pmu, enable_dep_gen=enable_dep_gen, + enable_scope_stats=enable_scope_stats, output_prefix=str(prefix) if diagnostics_on else "", ) finally: if enable_l2_swimlane: _convert_case_swimlane(case_label, prefix, callable_spec=callable_spec) + if enable_scope_stats: + _plot_case_scope_stats(case_label, prefix) def _compare_outputs(test_args, golden_args, output_names, rtol, atol): @@ -851,6 +883,7 @@ def _build_config( enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, *, output_prefix="", ): @@ -867,6 +900,7 @@ def _build_config( config.enable_dump_tensor = enable_dump_tensor config.enable_pmu = enable_pmu # 0=disabled, >0=enabled with event type config.enable_dep_gen = enable_dep_gen + config.enable_scope_stats = enable_scope_stats # `output_prefix` is required by CallConfig::validate() whenever any # diagnostic flag is enabled. Caller threads it down from the per-case # directory built by _build_output_prefix(). @@ -903,6 +937,7 @@ def _run_and_validate( # noqa: PLR0913 -- threads CLI diagnostic flags + case c enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, output_prefix="", ): if self._st_level == 2: @@ -916,6 +951,7 @@ def _run_and_validate( # noqa: PLR0913 -- threads CLI diagnostic flags + case c enable_dump_tensor=enable_dump_tensor, enable_pmu=enable_pmu, enable_dep_gen=enable_dep_gen, + enable_scope_stats=enable_scope_stats, output_prefix=output_prefix, ) elif self._st_level == 3: @@ -930,10 +966,11 @@ def _run_and_validate( # noqa: PLR0913 -- threads CLI diagnostic flags + case c enable_dump_tensor=enable_dump_tensor, enable_pmu=enable_pmu, enable_dep_gen=enable_dep_gen, + enable_scope_stats=enable_scope_stats, output_prefix=output_prefix, ) - def _run_and_validate_l2( + def _run_and_validate_l2( # noqa: PLR0913 -- threads CLI diagnostic flags + case context self, worker, callable_obj, @@ -944,6 +981,7 @@ def _run_and_validate_l2( enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, output_prefix="", ): params = case.get("params", {}) @@ -993,6 +1031,7 @@ def _run_and_validate_l2( enable_dump_tensor=enable_dump_tensor, enable_pmu=enable_pmu, enable_dep_gen=enable_dep_gen, + enable_scope_stats=enable_scope_stats, output_prefix=output_prefix, ) @@ -1019,6 +1058,7 @@ def _run_and_validate_l3( # noqa: PLR0913 -- threads CLI diagnostic flags + L3 enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, output_prefix="", ): # Defensive belt-and-braces: the pytest dispatcher and run_module both @@ -1073,6 +1113,7 @@ def _run_and_validate_l3( # noqa: PLR0913 -- threads CLI diagnostic flags + L3 enable_dump_tensor=enable_dump_tensor, enable_pmu=enable_pmu, enable_dep_gen=enable_dep_gen, + enable_scope_stats=enable_scope_stats, output_prefix=output_prefix, ) @@ -1126,6 +1167,7 @@ def test_run(self, st_platform, st_worker, request): enable_dump_tensor = request.config.getoption("--dump-tensor", default=False) enable_pmu = request.config.getoption("--enable-pmu", default=0) enable_dep_gen = self._effective_enable_dep_gen(request, warn=True) + enable_scope_stats = request.config.getoption("--enable-scope-stats", default=False) if rounds > 1: if enable_l2_swimlane: logger.warning("Profiling disabled: --rounds > 1") @@ -1136,6 +1178,9 @@ def test_run(self, st_platform, st_worker, request): if enable_pmu: logger.warning("PMU disabled: --rounds > 1") enable_pmu = 0 + if enable_scope_stats: + logger.warning("scope_stats disabled: --rounds > 1") + enable_scope_stats = False cls_name = type(self).__name__ callable_obj = self.build_callable(st_platform) @@ -1176,6 +1221,7 @@ def test_run(self, st_platform, st_worker, request): enable_dump_tensor=enable_dump_tensor, enable_pmu=enable_pmu, enable_dep_gen=enable_dep_gen, + enable_scope_stats=enable_scope_stats, ) # ------------------------------------------------------------------ @@ -1243,6 +1289,13 @@ def run_module(module_name): # noqa: PLR0912, PLR0915 -- CLI parsing + dispatch help="Enable PMU collection. Bare flag = PIPE_UTILIZATION(2). " "Pass event type to override (e.g. --enable-pmu 4)", ) + parser.add_argument( + "--enable-scope-stats", + action="store_true", + default=False, + help="Enable per-scope peak collection and emit /scope_stats/scope_stats.jsonl " + "(per-scope ring-fill peaks).", + ) parser.add_argument("--build", action="store_true", help="Compile runtime from source") parser.add_argument( "--runtime", @@ -1304,6 +1357,9 @@ def run_module(module_name): # noqa: PLR0912, PLR0915 -- CLI parsing + dispatch if args.rounds > 1 and args.enable_dep_gen: logger.warning("dep_gen disabled: --rounds > 1") args.enable_dep_gen = False + if args.rounds > 1 and args.enable_scope_stats: + logger.warning("scope_stats disabled: --rounds > 1") + args.enable_scope_stats = False from .parallel_scheduler import default_max_parallel, device_range_to_list # noqa: PLC0415 @@ -1433,6 +1489,7 @@ def run_module(module_name): # noqa: PLR0912, PLR0915 -- CLI parsing + dispatch enable_dump_tensor=args.dump_tensor, enable_pmu=args.enable_pmu, enable_dep_gen=args.enable_dep_gen, + enable_scope_stats=args.enable_scope_stats, ) print("PASSED") except Exception as e: # noqa: BLE001 @@ -1472,6 +1529,8 @@ def _dispatch_test_phases_standalone(module_name, selected_by_cls, args): # noq common.append("--dump-tensor") if args.enable_dep_gen: common.append("--enable-dep-gen") + if args.enable_scope_stats: + common.append("--enable-scope-stats") if args.build: common.append("--build") diff --git a/src/a2a3/platform/include/aicpu/scope_stats_collector_aicpu.h b/src/a2a3/platform/include/aicpu/scope_stats_collector_aicpu.h new file mode 100644 index 0000000000..e979cd4650 --- /dev/null +++ b/src/a2a3/platform/include/aicpu/scope_stats_collector_aicpu.h @@ -0,0 +1,90 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +#pragma once + +#include + +#include "common/scope_stats.h" + +// Scope-stats collector — platform-owned, runtime-agnostic. +// +// Platform owns all collector state. Runtime calls pure-value APIs to report +// the task/heap ring start/end and tensormap usage at scope boundaries; no +// runtime types cross the boundary. +// +// Streaming transport (mirrors dep_gen): one ScopeStatsRecord is appended per +// scope boundary (begin and end) into a pooled ScopeStatsBuffer popped from the +// host-fed free_queue; full buffers are pushed onto the orchestrator thread's +// ready_queue. The host +// collector drains them in real time, so the effective record capacity is +// disk-bounded rather than the old fixed 16 384-entry ring. +// +// Setter symbols (set_scope_stats_enabled, set_platform_scope_stats_base) +// are exported unconditionally so the host-side sim DeviceRunner's dlsym +// always resolves. + +extern "C" { + +// --- Scope lifecycle probes (called by orchestrator begin_scope/end_scope) --- +// +// Each emits one record for the scope's current ring, carrying the task ring's +// and heap ring's start/end plus the tensormap pool usage, sampled at that +// boundary: begin tags it SCOPE_STATS_PHASE_BEGIN, end tags it +// SCOPE_STATS_PHASE_END. task_start/end = task ring tail/head, heap_start/end = +// heap reclaim/allocation pointers. + +void scope_stats_begin( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +); +void scope_stats_end( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +); +void scope_stats_on_fatal(); + +// --- Site tracking --- + +void scope_stats_set_pending_site(const char *file, int line); + +// --- Setter symbols (always exported) --- + +void set_scope_stats_enabled(bool enable); + +// Enabled-state predicate, queried by the orchestrator as its begin/end gate +// (same idiom as is_dep_gen_enabled). The collector owns the strong +// definition; the orchestrator declares a weak `false` fallback for host +// builds. +bool is_scope_stats_enabled(); + +// Map the shared region and reset collector-local state. Doubles as the init +// entry point — no separate init symbol; the current buffer is popped lazily +// on the first scope_end append. Host owns the shared header (free_queue, +// num_instances, capacities), so this no longer zeroes shared memory. +void set_platform_scope_stats_base(uint64_t scope_stats_data_base); + +// --- Streaming transport plumbing (orchestrator thread) --- + +// Record which AICPU thread runs the orchestrator (selects the per-thread +// ready_queue when buffers fill / on flush). Mirrors +// dep_gen_aicpu_set_orch_thread_idx. +void scope_stats_aicpu_set_orch_thread_idx(int thread_idx); + +// Push the current (partially-filled) buffer onto the ready_queue so the host +// gets the final records. Idempotent: a no-op when there is no current buffer. +// Called from the orchestrator-thread exit path and from scope_stats_on_fatal. +void scope_stats_aicpu_flush_buffers(); + +// --- Capacity registration (called by runtime at init) --- + +void scope_stats_set_ring_capacity(int ring_id, int32_t window_cap, uint64_t heap_cap); +void scope_stats_set_tensormap_capacity(int32_t cap); + +} // extern "C" diff --git a/src/a2a3/platform/include/common/kernel_args.h b/src/a2a3/platform/include/common/kernel_args.h index 60c9499579..71859a8c43 100644 --- a/src/a2a3/platform/include/common/kernel_args.h +++ b/src/a2a3/platform/include/common/kernel_args.h @@ -88,12 +88,15 @@ struct KernelArgs { uint64_t pmu_data_base{0}; // PMU shared memory base address; use explicit flags to detect enablement uint64_t pmu_reg_addrs{0}; // Per-core PMU MMIO register base address array (onboard only; 0 on sim) uint64_t dep_gen_data_base{0}; // dep_gen shared memory base address; use explicit flags to detect enablement + uint64_t scope_stats_data_base{0}; // ScopeStatsBuffer shared memory base; 0 when scope_stats is off. + // Allocated by host's ScopeStatsCollector, read+written by AICPU's + // scope_stats_collector via set_platform_scope_stats_base. uint64_t aicore_ring_addr{0}; // Device ptr to a uint64_t[num_aicore] table holding each core's // L2PerfAicoreRing address. AICore kernel entry indexes by block_idx // and forwards into platform set/get state. 0 when L2 swimlane is off. uint32_t log_level{1}; // Severity floor: 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NUL uint32_t log_info_v{5}; // INFO verbosity threshold (0..9); default V5 - uint32_t enable_profiling_flag{0}; // Profiling umbrella bitmask; bit0=dump_tensor, bit1=l2_swimlane, bit2=pmu + uint32_t enable_profiling_flag{0}; // Profiling umbrella bitmask; dump_tensor|l2_swimlane|pmu|dep_gen|scope_stats uint32_t _pad{0}; // Alignment padding // Device pointer to an 8-byte buffer that the platform AICPU entry writes diff --git a/src/a2a3/platform/include/common/platform_config.h b/src/a2a3/platform/include/common/platform_config.h index 25d37301ad..b392b585f7 100644 --- a/src/a2a3/platform/include/common/platform_config.h +++ b/src/a2a3/platform/include/common/platform_config.h @@ -169,6 +169,7 @@ inline double cycles_to_us(uint64_t cycles) { #define PROFILING_FLAG_L2_SWIMLANE (1u << 1) #define PROFILING_FLAG_PMU (1u << 2) #define PROFILING_FLAG_DEP_GEN (1u << 3) +#define PROFILING_FLAG_SCOPE_STATS (1u << 4) #define GET_PROFILING_FLAG(flags, bit) ((((uint32_t)(flags)) & ((uint32_t)(bit))) != 0u) #define SET_PROFILING_FLAG(flags, bit) ((flags) |= (uint32_t)(bit)) #define CLEAR_PROFILING_FLAG(flags, bit) ((flags) &= ~((uint32_t)(bit))) @@ -282,6 +283,39 @@ constexpr int PLATFORM_DEP_GEN_READYQUEUE_SIZE = PLATFORM_DEP_GEN_BUFFERS_PER_IN */ constexpr int PLATFORM_DEP_GEN_TIMEOUT_SECONDS = 30; +// ============================================================================= +// scope_stats Configuration +// ============================================================================= + +/** + * Number of ScopeStatsRecord entries per ScopeStatsBuffer. + * Each record is 52 B. Larger buffers = fewer device-side switch_buffer + * hand-offs (barriers, helps Orch) and fewer/larger device→host drain memcpys. + * 512 records ≈ 27 KB/buffer (×8 buffers ≈ 213 KB). + */ +constexpr int PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER = 512; + +/** + * SPSC free_queue slot count for scope_stats buffers (Host→Device hand-off depth). + */ +constexpr int PLATFORM_SCOPE_STATS_SLOT_COUNT = 8; + +/** + * Pre-allocated ScopeStatsBuffer count per orchestrator instance. + */ +constexpr int PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE = 8; + +/** + * Ready queue capacity for scope_stats (per AICPU thread). scope_stats is + * single-instance so headroom over BUFFERS_PER_INSTANCE is small. + */ +constexpr int PLATFORM_SCOPE_STATS_READYQUEUE_SIZE = PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE * 2; + +/** + * Idle timeout duration for scope_stats collection (seconds). + */ +constexpr int PLATFORM_SCOPE_STATS_TIMEOUT_SECONDS = 30; + // ============================================================================= // Register Communication Configuration // ============================================================================= diff --git a/src/a2a3/platform/include/common/scope_stats.h b/src/a2a3/platform/include/common/scope_stats.h new file mode 100644 index 0000000000..88efa72dde --- /dev/null +++ b/src/a2a3/platform/include/common/scope_stats.h @@ -0,0 +1,188 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +/** + * @file scope_stats.h + * @brief scope_stats streaming shared-memory data structures. + * + * Two ScopeStatsRecords are produced per scope — one at scope_begin and one at + * scope_end — each carrying the task/heap ring start/end and the tensormap + * live-entry count sampled at that boundary, tagged with a phase flag. Records + * stream off the device in + * fixed-capacity buffers, mirroring PMU / dep_gen / tensor_dump / l2_perf (the + * single source of mgmt-loop truth is + * src/a2a3/platform/include/host/profiling_common/profiler_base.h): + * + * ScopeStatsFreeQueue — SPSC: Host pushes free buffers, AICPU pops them. + * ScopeStatsBufferState — Per-instance state: free_queue + current buffer ptr + * + drop/total counters. + * ScopeStatsDataHeader — Fixed header: per-thread ready queues + static + * capacity metadata + fatal latch. + * ScopeStatsBuffer — Fixed-capacity ScopeStatsRecord buffer. + * + * Single-instance: the orchestrator is one AICPU thread, so the BufferState + * array has length 1 — kept array-shaped for symmetry with the other + * collectors and to match ProfilerBase::for_each_instance. + * + * Concurrency contract (SPSC, both directions single-side on each end — the + * orchestrator thread is the only AICPU writer): + * free_queue — Host producer / AICPU consumer. + * ready_queue — AICPU producer / Host consumer. + * The host collector thread DOES read buffers concurrently with AICPU writing + * other buffers; ownership of any single buffer is handed off atomically via + * the queues. + */ + +#ifndef PLATFORM_COMMON_SCOPE_STATS_H_ +#define PLATFORM_COMMON_SCOPE_STATS_H_ + +#include +#include + +#include "common/platform_config.h" + +#define PTO2_SCOPE_STATS_MAX_RING_DEPTH 4 +#define PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH 64 + +#ifdef __cplusplus +extern "C" { +#endif + +// Phase tag: which scope boundary a record was sampled at. +#define SCOPE_STATS_PHASE_BEGIN 0 +#define SCOPE_STATS_PHASE_END 1 + +// One record per scope boundary (begin or end). Layout MUST stay in sync with +// the device-side writer in platform/src/aicpu/scope_stats_collector_aicpu.cpp +// and the host reader in platform/src/host/scope_stats_collector.cpp. +// +// Each boundary writes one record into host-visible (uncached) device memory, +// so record width is directly on the orchestrator hot path. A scope only ever +// touches its own ring (ring_id = min(scope_depth, PTO2_MAX_RING_DEPTH-1)), so +// a single ring's start/end is stored rather than a per-ring array. +// +// For both rings, end-start is the live span: task_end-task_start is in-flight +// tasks, heap_end-heap_start is bytes in use. tensormap is a free-list pool +// (not a ring), so its current live-entry count is stored directly. +// +// Field order keeps the uint64 heap pointers 8-byte aligned with no internal +// padding. +struct ScopeStatsRecord { + char site_file_basename[32]; // NUL-terminated basename of the PTO2_SCOPE site, + // captured at append time so the host JSON has a + // human-readable path without dereferencing a + // device pointer (the string table lives in the + // orchestration .so, not in shared memory). + uint64_t heap_start; // Heap ring reclaim pointer (heap_tail_). + uint64_t heap_end; // Heap ring allocation pointer (heap_top_). + int32_t site_line; + int32_t task_start; // Task ring tail (last_task_alive). + int32_t task_end; // Task ring head (next task id). + int32_t tensormap_used; // tensormap pool live-entry count (not a ring). + int16_t depth; + int16_t ring_id; // Ring whose start/end this record carries. + int16_t phase; // SCOPE_STATS_PHASE_BEGIN / _END. + int16_t _pad; +}; + +// Fixed-capacity ScopeStatsRecord buffer. Allocated by Host, pushed into the +// orchestrator instance's free_queue. The orchestrator thread is the single +// producer; it commits directly into records[count]. +struct ScopeStatsBuffer { + // Header (first 64 bytes) — host copies this alone first to learn count. + volatile uint32_t count; // Number of valid records committed + uint32_t _pad0[15]; // Pad count to 64 B; isolates count's cache line. + + ScopeStatsRecord records[PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER]; +} __attribute__((aligned(64))); + +static_assert(offsetof(ScopeStatsBuffer, records) == 64, "ScopeStatsBuffer header must be exactly 64 bytes"); + +// SPSC free queue: Host (producer) pushes recycled/new buffers, Device (AICPU +// consumer) pops them when switching the current buffer. +struct ScopeStatsFreeQueue { + volatile uint64_t buffer_ptrs[PLATFORM_SCOPE_STATS_SLOT_COUNT]; + volatile uint32_t head; // Consumer read position (Device increments) + volatile uint32_t tail; // Producer write position (Host increments) + uint32_t _pad[14]; +} __attribute__((aligned(64))); + +// Per-instance buffer state. current_buf_ptr / current_buf_seq / the counters +// are all Device-written, Host-read (except free_queue.tail which Host writes). +struct ScopeStatsBufferState { + ScopeStatsFreeQueue free_queue; + volatile uint64_t current_buf_ptr; // Device's in-progress buffer (0 = none) + volatile uint32_t current_buf_seq; // Device monotonic counter + volatile uint32_t dropped_record_count; // Device: records dropped (free_queue empty / ready_queue full) + volatile uint32_t total_record_count; // Device: monotonic count of every record appended (success + dropped) + uint32_t _pad[11]; +} __attribute__((aligned(64))); + +// Ready queue entry — when a ScopeStatsBuffer fills, AICPU pushes one of these +// onto the per-thread ready queue for host pickup. +struct ScopeStatsReadyQueueEntry { + uint32_t instance_index; // Always 0 (single instance) + uint32_t _pad0; + uint64_t buffer_ptr; // Device pointer to the full ScopeStatsBuffer + uint32_t buffer_seq; + uint32_t _pad1; +} __attribute__((aligned(32))); + +// scope_stats data fixed header, located at the start of the shared region. +// Per-thread ready queues match the ProfilerBase contract (poll over +// header->queues[q] for q in [0, num_threads)); the orchestrator writes into +// queue[orch_thread_idx]. +// +// Static capacity metadata + fatal latch live here (not in the streamed +// records) because they are run-constants written once by the AICPU capacity +// setters at orchestrator init and read by the host at finalize. +struct ScopeStatsDataHeader { + ScopeStatsReadyQueueEntry queues[PLATFORM_MAX_AICPU_THREADS][PLATFORM_SCOPE_STATS_READYQUEUE_SIZE]; + volatile uint32_t queue_heads[PLATFORM_MAX_AICPU_THREADS]; // Host reads (consumer) + volatile uint32_t queue_tails[PLATFORM_MAX_AICPU_THREADS]; // AICPU writes (producer) + uint32_t num_instances; // Always 1 for now + + // Per-ring static capacities — written once by AICPU at orchestrator init + // (scope_stats_set_ring_capacity / scope_stats_set_tensormap_capacity). + // Host needs them to render the "used/cap" ratio without a separate query. + int32_t task_window_cap[PTO2_SCOPE_STATS_MAX_RING_DEPTH]; + uint64_t heap_cap[PTO2_SCOPE_STATS_MAX_RING_DEPTH]; + int32_t tensormap_cap; + volatile uint32_t fatal_latched; // AICPU sets to 1 on first fatal. + uint32_t _pad[2]; +} __attribute__((aligned(64))); + +// ============================================================================= +// Memory layout helpers +// ============================================================================= + +// Total bytes for the scope_stats shared-mem region (header + buffer states). +// Actual ScopeStatsBuffers are dynamically allocated and tracked by the host. +inline size_t calc_scope_stats_shm_size(int num_instances) { + return sizeof(ScopeStatsDataHeader) + static_cast(num_instances) * sizeof(ScopeStatsBufferState); +} + +inline ScopeStatsDataHeader *get_scope_stats_header(void *base_ptr) { + return reinterpret_cast(base_ptr); +} + +inline ScopeStatsBufferState *get_scope_stats_buffer_state(void *base_ptr, int instance_index) { + return reinterpret_cast( + reinterpret_cast(base_ptr) + sizeof(ScopeStatsDataHeader) + ) + + instance_index; +} + +#ifdef __cplusplus +} +#endif + +#endif // PLATFORM_COMMON_SCOPE_STATS_H_ diff --git a/src/a2a3/platform/include/host/scope_stats_collector.h b/src/a2a3/platform/include/host/scope_stats_collector.h new file mode 100644 index 0000000000..1ebdfd5274 --- /dev/null +++ b/src/a2a3/platform/include/host/scope_stats_collector.h @@ -0,0 +1,174 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +/** + * @file scope_stats_collector.h + * @brief Host-side scope_stats streaming collector + NDJSON export. + * + * Architecture mirrors DepGenCollector: BufferPoolManager + * runs the mgmt thread (polls the per-thread ready queue, recycles buffers, + * refills the single instance's free_queue); ScopeStatsCollector's poll thread + * appends each full buffer's ScopeStatsRecords to an in-memory vector. After + * stop(), write_jsonl() renders them to /scope_stats.jsonl. + * + * Lifecycle: + * init() — Allocate header + 1 BufferState + N ScopeStatsBuffers + * (pre-fills free_queue; surplus → recycled pool). + * start(tf) — Inherited: launches mgmt + poll threads. + * [device execution] + * stop() — Inherited: drain queues, join threads. + * reconcile_counters() — current_buf_ptr cleared by flush + collected == + * total - dropped cross-check. + * write_jsonl() — Emit scope_stats.jsonl (meta line + one record/line). + * finalize() — Free all device memory, unregister. + * + * Output (scope_stats.jsonl), NDJSON: + * line 1: {"version":3,"fatal":bool,"dropped":uint,"total":uint} + * line k: {"site":"file:line","depth":int, + * "task_window":["u/c",...],"heap":["u/c",...],"tensormap":"u/c"} + */ + +#ifndef SRC_A2A3_PLATFORM_INCLUDE_HOST_SCOPE_STATS_COLLECTOR_H_ +#define SRC_A2A3_PLATFORM_INCLUDE_HOST_SCOPE_STATS_COLLECTOR_H_ + +#include +#include +#include +#include +#include +#include + +#include "common/platform_config.h" +#include "common/scope_stats.h" +#include "common/unified_log.h" +#include "host/profiling_common/profiler_base.h" + +// --------------------------------------------------------------------------- +// scope_stats Module (drives BufferPoolManager) +// --------------------------------------------------------------------------- + +struct ScopeStatsReadyBufferInfo { + uint32_t instance_index; // Always 0 (single instance) + uint32_t thread_index; // AICPU thread queue index this entry came from + void *dev_buffer_ptr; + void *host_buffer_ptr; + uint32_t buffer_seq; +}; + +struct ScopeStatsModule { + using DataHeader = ScopeStatsDataHeader; + using ReadyEntry = ScopeStatsReadyQueueEntry; + using ReadyBufferInfo = ::ScopeStatsReadyBufferInfo; + using FreeQueue = ScopeStatsFreeQueue; + + static constexpr int kBufferKinds = 1; + static constexpr uint32_t kReadyQueueSize = PLATFORM_SCOPE_STATS_READYQUEUE_SIZE; + static constexpr uint32_t kSlotCount = PLATFORM_SCOPE_STATS_SLOT_COUNT; + static constexpr const char *kSubsystemName = "ScopeStatsModule"; + + static constexpr int batch_size(int /*kind*/) { + constexpr int kBatch = PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE - PLATFORM_SCOPE_STATS_SLOT_COUNT; + return kBatch < 1 ? 1 : kBatch; + } + + static DataHeader *header_from_shm(void *shm) { return get_scope_stats_header(shm); } + + static std::optional> + resolve_entry(void *shm, DataHeader * /*header*/, int q, const ReadyEntry &entry) { + ScopeStatsBufferState *state = get_scope_stats_buffer_state(shm, static_cast(entry.instance_index)); + profiling_common::EntrySite site; + site.kind = 0; + site.free_queue = &state->free_queue; + site.buffer_size = sizeof(ScopeStatsBuffer); + site.info.instance_index = entry.instance_index; + site.info.thread_index = static_cast(q); + site.info.dev_buffer_ptr = reinterpret_cast(entry.buffer_ptr); + site.info.host_buffer_ptr = nullptr; // filled by ProfilerAlgorithms + site.info.buffer_seq = entry.buffer_seq; + return site; + } + + template + static void for_each_instance(void *shm, DataHeader *header, Cb &&cb) { + const int n = static_cast(header->num_instances); + for (int i = 0; i < n; i++) { + ScopeStatsBufferState *state = get_scope_stats_buffer_state(shm, i); + cb(/*kind=*/0, &state->free_queue, sizeof(ScopeStatsBuffer)); + } + } +}; + +using ScopeStatsAllocCallback = profiling_common::ProfAllocCallback; +using ScopeStatsRegisterCallback = profiling_common::ProfRegisterCallback; +using ScopeStatsUnregisterCallback = profiling_common::ProfUnregisterCallback; +using ScopeStatsFreeCallback = profiling_common::ProfFreeCallback; + +// --------------------------------------------------------------------------- +// ScopeStatsCollector +// --------------------------------------------------------------------------- + +class ScopeStatsCollector : public profiling_common::ProfilerBase { +public: + ScopeStatsCollector() = default; + ~ScopeStatsCollector(); + + ScopeStatsCollector(const ScopeStatsCollector &) = delete; + ScopeStatsCollector &operator=(const ScopeStatsCollector &) = delete; + + static constexpr int kIdleTimeoutSec = PLATFORM_SCOPE_STATS_TIMEOUT_SECONDS; + static constexpr const char *kSubsystemName = "ScopeStats"; + + int init( + int num_threads, const ScopeStatsAllocCallback &alloc_cb, ScopeStatsRegisterCallback register_cb, + const ScopeStatsFreeCallback &free_cb, int device_id + ); + + // Device pointer to the ScopeStatsDataHeader. Set + // kernel_args.scope_stats_data_base to this after init(). + void *get_scope_stats_shm_device_ptr() const { return shm_dev_; } + + // Poll-thread hook: append the buffer's records to the in-memory vector. + void on_buffer_collected(const ScopeStatsReadyBufferInfo &info); + + // After stop(): warn on drops / un-flushed buffer and cross-check + // collected == total - dropped. Returns true iff the run is clean. + bool reconcile_counters(); + + // Render the collected records to /scope_stats.jsonl. Reads the + // static capacity metadata + fatal latch from the shared header (constant + // after orchestrator init). Must be called after stop(). + int write_jsonl(const std::string &output_dir); + + void finalize(ScopeStatsUnregisterCallback unregister_cb, const ScopeStatsFreeCallback &free_cb); + + bool is_initialized() const { return initialized_; } + uint64_t total_collected() const { return total_collected_; } + +private: + bool initialized_ = false; + int num_threads_ = 0; + + void *shm_dev_ = nullptr; + bool shm_registered_ = false; + size_t shm_size_ = 0; + bool buffers_registered_ = false; + + std::vector records_; + std::mutex records_mutex_; + uint64_t total_collected_ = 0; + + ScopeStatsDataHeader *scope_stats_header() const { return get_scope_stats_header(shm_host_); } + ScopeStatsBufferState *scope_stats_state(int idx = 0) const { return get_scope_stats_buffer_state(shm_host_, idx); } + + void append_buffer_records(const void *buf_host_ptr); +}; + +#endif // SRC_A2A3_PLATFORM_INCLUDE_HOST_SCOPE_STATS_COLLECTOR_H_ diff --git a/src/a2a3/platform/onboard/aicpu/kernel.cpp b/src/a2a3/platform/onboard/aicpu/kernel.cpp index 3998d3a0d6..291cafc884 100644 --- a/src/a2a3/platform/onboard/aicpu/kernel.cpp +++ b/src/a2a3/platform/onboard/aicpu/kernel.cpp @@ -20,6 +20,7 @@ #include "aicpu/pmu_collector_aicpu.h" #include "aicpu/platform_regs.h" #include "aicpu/platform_aicpu_affinity.h" +#include "aicpu/scope_stats_collector_aicpu.h" #include "aicpu/tensor_dump_aicpu.h" #include "runtime.h" @@ -115,6 +116,8 @@ extern "C" __attribute__((visibility("default"))) int simpler_aicpu_exec(void *a set_pmu_enabled(GET_PROFILING_FLAG(k_args->enable_profiling_flag, PROFILING_FLAG_PMU)); set_platform_dep_gen_base(k_args->dep_gen_data_base); set_dep_gen_enabled(GET_PROFILING_FLAG(k_args->enable_profiling_flag, PROFILING_FLAG_DEP_GEN)); + set_scope_stats_enabled(GET_PROFILING_FLAG(k_args->enable_profiling_flag, PROFILING_FLAG_SCOPE_STATS)); + set_platform_scope_stats_base(k_args->scope_stats_data_base); // Affinity gate: drop excess threads before entering runtime if (!platform_aicpu_affinity_gate(runtime->aicpu_thread_num, PLATFORM_MAX_AICPU_THREADS_JUST_FOR_LAUNCH)) { diff --git a/src/a2a3/platform/onboard/host/CMakeLists.txt b/src/a2a3/platform/onboard/host/CMakeLists.txt index 4750fe6d99..16abd2e685 100644 --- a/src/a2a3/platform/onboard/host/CMakeLists.txt +++ b/src/a2a3/platform/onboard/host/CMakeLists.txt @@ -61,6 +61,7 @@ list(APPEND HOST_RUNTIME_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/comm_hccl.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/pmu_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/dep_gen_collector.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/scope_stats_collector.cpp" ) # Add common/host sources (LoadAicpuOp) list(APPEND HOST_RUNTIME_SOURCES diff --git a/src/a2a3/platform/onboard/host/device_runner.cpp b/src/a2a3/platform/onboard/host/device_runner.cpp index fdef36cbc0..74993a79b1 100644 --- a/src/a2a3/platform/onboard/host/device_runner.cpp +++ b/src/a2a3/platform/onboard/host/device_runner.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -573,6 +574,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_dep_gen_) { SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_DEP_GEN); } + if (enable_scope_stats_) { + SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_SCOPE_STATS); + } kernel_args_.args.enable_profiling_flag = enable_profiling_flag; for (int i = 0; i < num_aicore; i++) { @@ -637,6 +641,14 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { } } + if (enable_scope_stats_) { + rc = init_scope_stats(launch_aicpu_num, device_id_); + if (rc != 0) { + LOG_ERROR("init_scope_stats failed: %d", rc); + return rc; + } + } + // On any exit from run() — success or early error — release the diagnostics // collectors' shared memory. They are only re-initialized per run(), so a // Worker reused across runs (e.g. a pytest session-scoped worker pool) would @@ -701,6 +713,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_dep_gen_) { dep_gen_collector_.start(thread_factory); } + if (enable_scope_stats_) { + scope_stats_collector_.start(thread_factory); + } LOG_INFO_V0("=== launch_aicpu_kernel %s ===", host::KernelNames::InitName); rc = launch_aicpu_kernel(stream_aicpu_, &kernel_args_.args, host::KernelNames::InitName, 1); @@ -805,6 +820,12 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { } } + if (enable_scope_stats_) { + scope_stats_collector_.stop(); + scope_stats_collector_.reconcile_counters(); + scope_stats_collector_.write_jsonl(output_prefix_); + } + // Print handshake results (reads from device memory, must be before free) print_handshake_results(); @@ -1415,6 +1436,38 @@ int DeviceRunner::init_dep_gen(int num_threads, int device_id) { return 0; } +int DeviceRunner::init_scope_stats(int num_threads, int device_id) { + auto alloc_cb = [this](size_t size) -> void * { + return mem_alloc_.alloc(size); + }; + + auto register_cb = +[](void *dev_ptr, size_t size, int device_id, void **host_ptr) -> int { + if (load_hal_if_needed() != 0) { + LOG_ERROR("Failed to load ascend_hal for scope_stats: %s", dlerror()); + return -1; + } + HalHostRegisterFn fn = get_halHostRegister(); + if (fn == nullptr) { + LOG_ERROR("halHostRegister symbol not found: %s", dlerror()); + return -1; + } + return fn(dev_ptr, size, DEV_SVM_MAP_HOST, device_id, host_ptr); + }; + + auto free_cb = [this](void *dev_ptr) -> int { + return mem_alloc_.free(dev_ptr); + }; + + int rc = scope_stats_collector_.init(num_threads, alloc_cb, register_cb, free_cb, device_id); + if (rc != 0) { + return rc; + } + + kernel_args_.args.scope_stats_data_base = + reinterpret_cast(scope_stats_collector_.get_scope_stats_shm_device_ptr()); + return 0; +} + void DeviceRunner::finalize_collectors() { auto unregister_cb = [](void *dev_ptr, int device_id) -> int { HalHostUnregisterFn fn = get_halHostUnregister(); @@ -1439,4 +1492,8 @@ void DeviceRunner::finalize_collectors() { if (dep_gen_collector_.is_initialized()) { dep_gen_collector_.finalize(unregister_cb, free_cb); } + if (scope_stats_collector_.is_initialized()) { + scope_stats_collector_.finalize(unregister_cb, free_cb); + kernel_args_.args.scope_stats_data_base = 0; + } } diff --git a/src/a2a3/platform/onboard/host/device_runner.h b/src/a2a3/platform/onboard/host/device_runner.h index bac1ff9a26..b7934686c9 100644 --- a/src/a2a3/platform/onboard/host/device_runner.h +++ b/src/a2a3/platform/onboard/host/device_runner.h @@ -52,6 +52,7 @@ #include "host/pmu_collector.h" #include "host/dep_gen_collector.h" #include "load_aicpu_op.h" +#include "host/scope_stats_collector.h" #include "runtime.h" /** @@ -219,6 +220,7 @@ class DeviceRunner { pmu_event_type_ = resolve_pmu_event_type(enable_pmu); } void set_dep_gen_enabled(bool enable) { enable_dep_gen_ = enable; } + void set_scope_stats_enabled(bool enable) { enable_scope_stats_ = enable; } // Directory under which all diagnostic artifacts (l2_perf_records.json / // tensor_dump/ / pmu.csv) land. Required (non-empty) when any diagnostic // is enabled; CallConfig::validate() enforces this contract upstream. @@ -734,6 +736,7 @@ class DeviceRunner { * @return 0 on success, error code on failure */ int init_dep_gen(int num_threads, int device_id); + int init_scope_stats(int num_threads, int device_id); /** * Finalize whichever diagnostics collectors are currently initialized, @@ -754,6 +757,8 @@ class DeviceRunner { bool enable_dump_tensor_{false}; bool enable_pmu_{false}; bool enable_dep_gen_{false}; + bool enable_scope_stats_{false}; + ScopeStatsCollector scope_stats_collector_; L2PerfLevel l2_perf_level_{L2PerfLevel::DISABLED}; // resolved from set_l2_swimlane_enabled() PmuEventType pmu_event_type_{PmuEventType::PIPE_UTILIZATION}; // resolved from set_pmu_enabled() std::string output_prefix_{}; // diagnostic artifact root directory diff --git a/src/a2a3/platform/onboard/host/pto_runtime_c_api.cpp b/src/a2a3/platform/onboard/host/pto_runtime_c_api.cpp index deb8f6a0d5..c23d9cb7b6 100644 --- a/src/a2a3/platform/onboard/host/pto_runtime_c_api.cpp +++ b/src/a2a3/platform/onboard/host/pto_runtime_c_api.cpp @@ -351,7 +351,7 @@ int prepare_callable(DeviceContextHandle ctx, int32_t callable_id, const void *c int run_prepared( DeviceContextHandle ctx, RuntimeHandle runtime, int32_t callable_id, const void *args, int block_dim, int aicpu_thread_num, int enable_l2_swimlane, int enable_dump_tensor, int enable_pmu, int enable_dep_gen, - const char *output_prefix, PtoRunTiming *out_timing + int enable_scope_stats, const char *output_prefix, PtoRunTiming *out_timing ) { if (out_timing != NULL) { out_timing->host_wall_ns = 0; @@ -416,6 +416,7 @@ int run_prepared( runner->set_dump_tensor_enabled(enable_dump_tensor != 0); runner->set_pmu_enabled(enable_pmu); runner->set_dep_gen_enabled(enable_dep_gen != 0); + runner->set_scope_stats_enabled(enable_scope_stats != 0); runner->set_output_prefix(output_prefix); rc = runner->run(*r, block_dim, aicpu_thread_num); diff --git a/src/a2a3/platform/sim/host/CMakeLists.txt b/src/a2a3/platform/sim/host/CMakeLists.txt index 49d0fe62a0..95ed539b39 100644 --- a/src/a2a3/platform/sim/host/CMakeLists.txt +++ b/src/a2a3/platform/sim/host/CMakeLists.txt @@ -47,6 +47,7 @@ list(APPEND HOST_RUNTIME_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/tensor_dump_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/pmu_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/dep_gen_collector.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/scope_stats_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../aicpu/platform_aicpu_affinity.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../../../common/platform_comm/comm_sim.cpp" ) diff --git a/src/a2a3/platform/sim/host/device_runner.cpp b/src/a2a3/platform/sim/host/device_runner.cpp index 9a9cbbabfe..5c4f3eac8f 100644 --- a/src/a2a3/platform/sim/host/device_runner.cpp +++ b/src/a2a3/platform/sim/host/device_runner.cpp @@ -313,6 +313,20 @@ int DeviceRunner::ensure_binaries_loaded() { return -1; } + set_scope_stats_enabled_func_ = + reinterpret_cast(dlsym(aicpu_so_handle_, "set_scope_stats_enabled")); + if (set_scope_stats_enabled_func_ == nullptr) { + LOG_ERROR("dlsym failed for set_scope_stats_enabled: %s", dlerror()); + return -1; + } + + set_platform_scope_stats_base_func_ = + reinterpret_cast(dlsym(aicpu_so_handle_, "set_platform_scope_stats_base")); + if (set_platform_scope_stats_base_func_ == nullptr) { + LOG_ERROR("dlsym failed for set_platform_scope_stats_base: %s", dlerror()); + return -1; + } + // Log config travels via the RTLD_GLOBAL HostLogger singleton in // libsimpler_log.so — already seeded by simpler_log_init() before the // AICPU sim SO was dlopen'd, so no per-SO setter forwarding is needed. @@ -464,6 +478,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_dep_gen_) { SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_DEP_GEN); } + if (enable_scope_stats_) { + SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_SCOPE_STATS); + } kernel_args_.enable_profiling_flag = enable_profiling_flag; for (int i = 0; i < num_aicore; i++) { @@ -531,6 +548,14 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { } } + if (enable_scope_stats_) { + rc = init_scope_stats(launch_aicpu_num); + if (rc != 0) { + LOG_ERROR("init_scope_stats failed: %d", rc); + return rc; + } + } + // On any exit from run() — success or early error — release the diagnostics // collectors' shared memory. They are only re-initialized per run(), so a // Worker reused across runs (e.g. a pytest session-scoped worker pool) would @@ -617,7 +642,8 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { set_platform_dump_base_func_ == nullptr || set_dump_tensor_enabled_func_ == nullptr || set_platform_pmu_base_func_ == nullptr || set_platform_pmu_reg_addrs_func_ == nullptr || set_pmu_enabled_func_ == nullptr || set_platform_dep_gen_base_func_ == nullptr || - set_dep_gen_enabled_func_ == nullptr) { + set_dep_gen_enabled_func_ == nullptr || set_scope_stats_enabled_func_ == nullptr || + set_platform_scope_stats_base_func_ == nullptr) { LOG_ERROR("Executor functions not loaded. Call ensure_binaries_loaded first."); return -1; } @@ -632,6 +658,8 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { set_pmu_enabled_func_(enable_pmu_); set_platform_dep_gen_base_func_(kernel_args_.dep_gen_data_base); set_dep_gen_enabled_func_(enable_dep_gen_); + set_scope_stats_enabled_func_(enable_scope_stats_); + set_platform_scope_stats_base_func_(kernel_args_.scope_stats_data_base); // No per-SO log-config push: HostLogger lives in libsimpler_log.so // (RTLD_GLOBAL singleton) and the AICPU sim SO reads it directly via the @@ -655,6 +683,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_dep_gen_) { dep_gen_collector_.start(thread_factory); } + if (enable_scope_stats_) { + scope_stats_collector_.start(thread_factory); + } // Launch AICPU threads (over-launch for affinity gate) constexpr int over_launch = PLATFORM_MAX_AICPU_THREADS_JUST_FOR_LAUNCH; @@ -764,6 +795,12 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { } } + if (enable_scope_stats_) { + scope_stats_collector_.stop(); + scope_stats_collector_.reconcile_counters(); + scope_stats_collector_.write_jsonl(output_prefix_); + } + // Print handshake results at end of run print_handshake_results(); @@ -813,6 +850,8 @@ void DeviceRunner::unload_executor_binaries() { set_pmu_enabled_func_ = nullptr; set_platform_dep_gen_base_func_ = nullptr; set_dep_gen_enabled_func_ = nullptr; + set_scope_stats_enabled_func_ = nullptr; + set_platform_scope_stats_base_func_ = nullptr; aicpu_so_loaded_ = false; } if (!aicpu_so_path_.empty()) { @@ -1263,6 +1302,26 @@ int DeviceRunner::init_dep_gen(int num_threads, int /*device_id*/) { return 0; } +int DeviceRunner::init_scope_stats(int num_threads) { + auto alloc_cb = [this](size_t size) -> void * { + return mem_alloc_.alloc(size); + }; + auto free_cb = [this](void *dev_ptr) -> int { + return mem_alloc_.free(dev_ptr); + }; + + // Sim shares an address space with the AICPU thread, so register_cb is + // not needed (mirrors PMU / dep_gen's nullptr in sim). + int rc = scope_stats_collector_.init(num_threads, alloc_cb, /*register_cb=*/nullptr, free_cb, /*device_id=*/-1); + if (rc != 0) { + return rc; + } + + kernel_args_.scope_stats_data_base = + reinterpret_cast(scope_stats_collector_.get_scope_stats_shm_device_ptr()); + return 0; +} + void DeviceRunner::finalize_collectors() { // Free through MemoryAllocator so finalize() can audit. Sim shares an // address space with the AICPU thread, so no host unregister is needed. @@ -1282,4 +1341,8 @@ void DeviceRunner::finalize_collectors() { if (dep_gen_collector_.is_initialized()) { dep_gen_collector_.finalize(nullptr, free_cb); } + if (scope_stats_collector_.is_initialized()) { + scope_stats_collector_.finalize(nullptr, free_cb); + kernel_args_.scope_stats_data_base = 0; + } } diff --git a/src/a2a3/platform/sim/host/device_runner.h b/src/a2a3/platform/sim/host/device_runner.h index 46ee459138..d5134dcce1 100644 --- a/src/a2a3/platform/sim/host/device_runner.h +++ b/src/a2a3/platform/sim/host/device_runner.h @@ -58,6 +58,7 @@ #include "host/tensor_dump_collector.h" #include "host/pmu_collector.h" #include "host/dep_gen_collector.h" +#include "host/scope_stats_collector.h" #include "runtime.h" /** @@ -195,6 +196,7 @@ class DeviceRunner { pmu_event_type_ = resolve_pmu_event_type(enable_pmu); } void set_dep_gen_enabled(bool enable) { enable_dep_gen_ = enable; } + void set_scope_stats_enabled(bool enable) { enable_scope_stats_ = enable; } // Directory under which all diagnostic artifacts (l2_perf_records.json / // tensor_dump/ / pmu.csv) land. Required (non-empty) when any diagnostic // is enabled; CallConfig::validate() enforces this contract upstream. @@ -386,6 +388,8 @@ class DeviceRunner { void (*set_pmu_enabled_func_)(bool){nullptr}; void (*set_platform_dep_gen_base_func_)(uint64_t){nullptr}; void (*set_dep_gen_enabled_func_)(bool){nullptr}; + void (*set_scope_stats_enabled_func_)(bool){nullptr}; + void (*set_platform_scope_stats_base_func_)(uint64_t){nullptr}; std::string aicpu_so_path_; std::string aicore_so_path_; @@ -398,6 +402,7 @@ class DeviceRunner { PmuCollector pmu_collector_; // dep_gen collector — captures orchestrator submit_task inputs for offline replay DepGenCollector dep_gen_collector_; + ScopeStatsCollector scope_stats_collector_; // Private helper methods — read aicpu_so_binary_ / aicore_kernel_binary_ // off the runner (populated by set_executors during simpler_init). @@ -430,6 +435,7 @@ class DeviceRunner { int init_pmu(int num_cores, int num_threads, const std::string &csv_path, PmuEventType event_type, int device_id); int init_dep_gen(int num_threads, int device_id); + int init_scope_stats(int num_threads); /** * Finalize whichever diagnostics collectors are currently initialized, @@ -448,6 +454,7 @@ class DeviceRunner { bool enable_dump_tensor_{false}; bool enable_pmu_{false}; bool enable_dep_gen_{false}; + bool enable_scope_stats_{false}; L2PerfLevel l2_perf_level_{L2PerfLevel::DISABLED}; // resolved from set_l2_swimlane_enabled() PmuEventType pmu_event_type_{PmuEventType::PIPE_UTILIZATION}; // resolved from set_pmu_enabled() std::string output_prefix_{}; // diagnostic artifact root directory diff --git a/src/a2a3/platform/sim/host/pto_runtime_c_api.cpp b/src/a2a3/platform/sim/host/pto_runtime_c_api.cpp index fca663610c..9b5d1b4523 100644 --- a/src/a2a3/platform/sim/host/pto_runtime_c_api.cpp +++ b/src/a2a3/platform/sim/host/pto_runtime_c_api.cpp @@ -313,7 +313,7 @@ int prepare_callable(DeviceContextHandle ctx, int32_t callable_id, const void *c int run_prepared( DeviceContextHandle ctx, RuntimeHandle runtime, int32_t callable_id, const void *args, int block_dim, int aicpu_thread_num, int enable_l2_swimlane, int enable_dump_tensor, int enable_pmu, int enable_dep_gen, - const char *output_prefix, PtoRunTiming *out_timing + int enable_scope_stats, const char *output_prefix, PtoRunTiming *out_timing ) { if (out_timing != NULL) { out_timing->host_wall_ns = 0; @@ -368,6 +368,7 @@ int run_prepared( runner->set_dump_tensor_enabled(enable_dump_tensor != 0); runner->set_pmu_enabled(enable_pmu); runner->set_dep_gen_enabled(enable_dep_gen != 0); + runner->set_scope_stats_enabled(enable_scope_stats != 0); runner->set_output_prefix(output_prefix); rc = runner->run(*r, block_dim, aicpu_thread_num); diff --git a/src/a2a3/platform/src/aicpu/scope_stats_collector_aicpu.cpp b/src/a2a3/platform/src/aicpu/scope_stats_collector_aicpu.cpp new file mode 100644 index 0000000000..8784bf50df --- /dev/null +++ b/src/a2a3/platform/src/aicpu/scope_stats_collector_aicpu.cpp @@ -0,0 +1,331 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +// Platform-layer scope_stats collector (streaming). +// +// Owns the scope depth/site stack and exposes pure-value APIs for runtime to +// report the task/heap ring start/end and tensormap usage at scope boundaries. +// One ScopeStatsRecord is appended per boundary (begin and end) into a pooled +// ScopeStatsBuffer; full buffers stream to the host via the per-thread +// ready_queue (SPSC, mirrors dep_gen). No runtime-specific types cross the +// boundary. + +#include "aicpu/scope_stats_collector_aicpu.h" + +#include + +#include "common/memory_barrier.h" +#include "common/platform_config.h" +#include "common/scope_stats.h" +#include "common/unified_log.h" + +// --------------------------------------------------------------------------- +// Collector state +// --------------------------------------------------------------------------- + +int32_t scope_stats_depth = -1; +static bool scope_stats_enabled = false; + +// Streaming transport state — single dep_gen-style instance (the orchestrator). +static ScopeStatsDataHeader *s_scope_stats_header = nullptr; +static ScopeStatsBufferState *s_scope_stats_state = nullptr; +static int s_orch_thread_idx = -1; // set via scope_stats_aicpu_set_orch_thread_idx + +namespace { + +const char *s_pending_site_file = nullptr; +int32_t s_pending_site_line = 0; + +const char *s_scope_site_file[PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH] = {}; +int32_t s_scope_site_line[PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH] = {}; + +inline const char *basename_of(const char *path) { + if (!path) return "(unknown)"; + const char *base = path; + for (const char *p = path; *p; ++p) { + if (*p == '/' || *p == '\\') base = p + 1; + } + return base; +} + +inline void copy_basename(char (&dst)[32], const char *src) { + // site_file is a compile-time-constant string pointer; the same scope source + // line (e.g. a begin_scope inside an unroll loop) reuses the same pointer on + // every iteration. Memoize the basename scan keyed by that pointer so a + // repeated site becomes a single 32B copy instead of a full-path rescan. + static const char *s_cached_src = nullptr; + static char s_cached[32]; + if (src != nullptr && src == s_cached_src) { + memcpy(dst, s_cached, sizeof(dst)); + return; + } + const char *base = basename_of(src); + size_t i = 0; + for (; i + 1 < sizeof(dst) && base[i]; i++) + dst[i] = base[i]; + dst[i] = '\0'; + if (src != nullptr) { + s_cached_src = src; + memcpy(s_cached, dst, sizeof(dst)); + } +} + +// Enqueue a full buffer onto the orchestrator thread's ready_queue. Returns 0 +// on success, -1 if the queue is full or the orch thread index is unset. +int enqueue_ready_buffer(uint64_t buffer_ptr, uint32_t buffer_seq) { + if (s_orch_thread_idx < 0 || s_orch_thread_idx >= PLATFORM_MAX_AICPU_THREADS) { + return -1; + } + int q = s_orch_thread_idx; + uint32_t capacity = PLATFORM_SCOPE_STATS_READYQUEUE_SIZE; + uint32_t current_tail = s_scope_stats_header->queue_tails[q]; + uint32_t current_head = s_scope_stats_header->queue_heads[q]; + + uint32_t next_tail = (current_tail + 1) % capacity; + if (next_tail == current_head) { + return -1; // Queue full + } + + s_scope_stats_header->queues[q][current_tail].instance_index = 0; + s_scope_stats_header->queues[q][current_tail].buffer_ptr = buffer_ptr; + s_scope_stats_header->queues[q][current_tail].buffer_seq = buffer_seq; + // Single hand-off barrier: drain every record written into the buffer plus + // the ready-entry fields above before the host can observe the advanced + // tail. Replaces the former per-record wmb() in scope_stats_end(). + wmb(); + s_scope_stats_header->queue_tails[q] = next_tail; + return 0; +} + +// Pop a free buffer into current_buf_ptr. Returns true if one was available. +bool pop_free_buffer() { + rmb(); + uint32_t head = s_scope_stats_state->free_queue.head; + uint32_t tail = s_scope_stats_state->free_queue.tail; + if (head == tail) { + return false; + } + uint64_t buf_ptr = s_scope_stats_state->free_queue.buffer_ptrs[head % PLATFORM_SCOPE_STATS_SLOT_COUNT]; + rmb(); + s_scope_stats_state->free_queue.head = head + 1; + s_scope_stats_state->current_buf_ptr = buf_ptr; + reinterpret_cast(buf_ptr)->count = 0; + wmb(); + return true; +} + +// Commit the full current buffer to the ready_queue and pop a replacement. On +// no free buffer / ready_queue full, drop the buffer's records and reuse it. +void switch_buffer() { + if (s_scope_stats_state == nullptr) { + return; + } + ScopeStatsBuffer *full_buf = reinterpret_cast(s_scope_stats_state->current_buf_ptr); + if (full_buf == nullptr) { + return; + } + + rmb(); + uint32_t head = s_scope_stats_state->free_queue.head; + uint32_t tail = s_scope_stats_state->free_queue.tail; + if (head == tail) { + // Host can't recycle buffers fast enough: drop silently (count only, no + // per-drop log). Logging here would make a slow host pay device-side + // hot-path cost — the device must not be coupled to host throughput. The + // total is surfaced via dropped_record_count in the finalize summary. + s_scope_stats_state->dropped_record_count += full_buf->count; + full_buf->count = 0; + wmb(); + return; + } + + uint32_t seq = s_scope_stats_state->current_buf_seq; + int rc = enqueue_ready_buffer(s_scope_stats_state->current_buf_ptr, seq); + if (rc != 0) { + s_scope_stats_state->dropped_record_count += full_buf->count; + full_buf->count = 0; + wmb(); + return; + } + + uint64_t new_buf_ptr = s_scope_stats_state->free_queue.buffer_ptrs[head % PLATFORM_SCOPE_STATS_SLOT_COUNT]; + rmb(); + s_scope_stats_state->free_queue.head = head + 1; + s_scope_stats_state->current_buf_ptr = new_buf_ptr; + s_scope_stats_state->current_buf_seq = seq + 1; + reinterpret_cast(new_buf_ptr)->count = 0; + wmb(); +} + +// Append one record carrying the task/heap ring start/end and tensormap usage, +// tagged with the current depth/site and the boundary phase. Called once per +// scope boundary. +void append_record_snapshot( + int ring_id, int16_t phase, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, + int32_t tensormap_used +) { + if (s_scope_stats_state == nullptr) return; + // Single volatile read of current_buf_ptr (re-read only after a pop/switch). + uint64_t cur = s_scope_stats_state->current_buf_ptr; + if (cur == 0) { + pop_free_buffer(); + cur = s_scope_stats_state->current_buf_ptr; + } + ScopeStatsBuffer *buf = reinterpret_cast(cur); + // Single volatile read of buf->count (reused as the write index below). + uint32_t idx = (buf != nullptr) ? buf->count : 0; + if (buf != nullptr && idx >= static_cast(PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER)) { + switch_buffer(); + cur = s_scope_stats_state->current_buf_ptr; + buf = reinterpret_cast(cur); + idx = (buf != nullptr) ? buf->count : 0; + } + s_scope_stats_state->total_record_count += 1; + if (buf == nullptr) { + s_scope_stats_state->dropped_record_count += 1; + return; + } + ScopeStatsRecord &rec = buf->records[idx]; + copy_basename(rec.site_file_basename, s_scope_site_file[scope_stats_depth]); + rec.site_line = s_scope_site_line[scope_stats_depth]; + rec.depth = static_cast(scope_stats_depth); + rec.ring_id = static_cast(ring_id); + rec.phase = phase; + rec._pad = 0; + rec.task_start = task_start; + rec.task_end = task_end; + rec.tensormap_used = tensormap_used; + rec.heap_start = heap_start; + rec.heap_end = heap_end; + buf->count = idx + 1; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Setter symbols — always exported, unconditionally compiled +// --------------------------------------------------------------------------- + +extern "C" void set_scope_stats_enabled(bool enable) { scope_stats_enabled = enable; } + +extern "C" bool is_scope_stats_enabled() { return scope_stats_enabled; } + +extern "C" void set_platform_scope_stats_base(uint64_t scope_stats_data_base) { + void *base = reinterpret_cast(scope_stats_data_base); + if (base != nullptr) { + s_scope_stats_header = get_scope_stats_header(base); + s_scope_stats_state = get_scope_stats_buffer_state(base, /*instance_index=*/0); + } else { + s_scope_stats_header = nullptr; + s_scope_stats_state = nullptr; + } + // Reset collector-local statics so a prior run that crashed mid-scope (or + // reused the same AICPU .so process) can't leak stale depth/peak data into + // the new run's records. The shared header/free_queue is host-owned — the + // host already zeroed it at init, so this no longer touches shared memory. + scope_stats_depth = -1; + s_pending_site_file = nullptr; + s_pending_site_line = 0; + memset(s_scope_site_file, 0, sizeof(s_scope_site_file)); + memset(s_scope_site_line, 0, sizeof(s_scope_site_line)); +} + +void scope_stats_aicpu_set_orch_thread_idx(int thread_idx) { s_orch_thread_idx = thread_idx; } + +void scope_stats_aicpu_flush_buffers() { + if (s_scope_stats_state == nullptr) { + return; + } + rmb(); + uint64_t buf_ptr = s_scope_stats_state->current_buf_ptr; + if (buf_ptr == 0) { + return; // Idempotent: nothing in flight. + } + ScopeStatsBuffer *buf = reinterpret_cast(buf_ptr); + if (buf->count == 0) { + return; + } + + uint32_t seq = s_scope_stats_state->current_buf_seq; + int rc = enqueue_ready_buffer(buf_ptr, seq); + if (rc == 0) { + LOG_INFO_V0("scope_stats: flushed buffer with %u records", buf->count); + } else { + LOG_ERROR("scope_stats: flush failed (ready_queue full), %u records dropped", buf->count); + s_scope_stats_state->dropped_record_count += buf->count; + buf->count = 0; + } + s_scope_stats_state->current_buf_ptr = 0; + wmb(); +} + +// --------------------------------------------------------------------------- +// Scope lifecycle probes +// --------------------------------------------------------------------------- + +extern "C" void scope_stats_set_pending_site(const char *file, int line) { + s_pending_site_file = file; + s_pending_site_line = line; +} + +// Push depth/site and emit the begin-boundary record (ring start/end + usage). +extern "C" void scope_stats_begin( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +) { + if (!scope_stats_enabled) return; + if (scope_stats_depth + 1 >= PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH) return; + int32_t d = ++scope_stats_depth; + s_scope_site_file[d] = s_pending_site_file; + s_scope_site_line[d] = s_pending_site_line; + s_pending_site_file = nullptr; + s_pending_site_line = 0; + append_record_snapshot( + ring_id, SCOPE_STATS_PHASE_BEGIN, task_start, task_end, heap_start, heap_end, tensormap_used + ); +} + +// Emit the end-boundary record, then tear down depth/site. +extern "C" void scope_stats_end( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +) { + if (!scope_stats_enabled) return; + if (scope_stats_depth < 0) return; + int32_t d = scope_stats_depth; + append_record_snapshot(ring_id, SCOPE_STATS_PHASE_END, task_start, task_end, heap_start, heap_end, tensormap_used); + s_scope_site_file[d] = nullptr; + s_scope_site_line[d] = 0; + --scope_stats_depth; +} + +extern "C" void scope_stats_on_fatal() { + if (!scope_stats_enabled) return; + if (s_scope_stats_header == nullptr) return; + s_scope_stats_header->fatal_latched = 1; + wmb(); + // Deliver the partial buffer to the host before the abort unwinds. + scope_stats_aicpu_flush_buffers(); +} + +// --------------------------------------------------------------------------- +// Capacity registration — called by runtime at init +// --------------------------------------------------------------------------- + +extern "C" void scope_stats_set_ring_capacity(int ring_id, int32_t window_cap, uint64_t heap_cap) { + if (!s_scope_stats_header) return; + if (ring_id < 0 || ring_id >= PTO2_SCOPE_STATS_MAX_RING_DEPTH) return; + s_scope_stats_header->task_window_cap[ring_id] = window_cap; + s_scope_stats_header->heap_cap[ring_id] = heap_cap; +} + +extern "C" void scope_stats_set_tensormap_capacity(int32_t cap) { + if (!s_scope_stats_header) return; + s_scope_stats_header->tensormap_cap = cap; +} diff --git a/src/a2a3/platform/src/host/scope_stats_collector.cpp b/src/a2a3/platform/src/host/scope_stats_collector.cpp new file mode 100644 index 0000000000..04bd85c627 --- /dev/null +++ b/src/a2a3/platform/src/host/scope_stats_collector.cpp @@ -0,0 +1,332 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +/** + * @file scope_stats_collector.cpp + * @brief Host-side scope_stats collector. The mgmt-thread + buffer-pool + * machinery lives in profiling_common::BufferPoolManager parameterized + * by ScopeStatsModule (host/scope_stats_collector.h); this file owns the + * per-buffer on_buffer_collected callback (in-memory append), the + * device-side cross-check, and the NDJSON export. + */ + +#include "host/scope_stats_collector.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "common/memory_barrier.h" +#include "common/unified_log.h" + +ScopeStatsCollector::~ScopeStatsCollector() { stop(); } + +// --------------------------------------------------------------------------- +// init +// --------------------------------------------------------------------------- + +int ScopeStatsCollector::init( + int num_threads, const ScopeStatsAllocCallback &alloc_cb, ScopeStatsRegisterCallback register_cb, + const ScopeStatsFreeCallback &free_cb, int device_id +) { + if (num_threads <= 0 || alloc_cb == nullptr || free_cb == nullptr) { + LOG_ERROR("ScopeStatsCollector::init: invalid arguments"); + return -1; + } + + num_threads_ = num_threads; + buffers_registered_ = (register_cb != nullptr); + total_collected_ = 0; + records_.clear(); + execution_complete_.store(false, std::memory_order_release); + + const int num_instances = 1; + shm_size_ = calc_scope_stats_shm_size(num_instances); + shm_dev_ = alloc_cb(shm_size_); + if (shm_dev_ == nullptr) { + LOG_ERROR("ScopeStatsCollector: failed to allocate scope_stats shared memory (%zu bytes)", shm_size_); + return -1; + } + + if (register_cb != nullptr) { + int rc = register_cb(shm_dev_, shm_size_, device_id, &shm_host_); + if (rc != 0) { + LOG_ERROR("ScopeStatsCollector: halHostRegister for scope_stats SHM failed: %d", rc); + free_cb(shm_dev_); + shm_dev_ = nullptr; + return rc; + } + shm_registered_ = true; + } else { + shm_host_ = shm_dev_; + } + std::memset(shm_host_, 0, shm_size_); + + ScopeStatsDataHeader *hdr = get_scope_stats_header(shm_host_); + hdr->num_instances = static_cast(num_instances); + + const size_t buf_size = sizeof(ScopeStatsBuffer); + ScopeStatsBufferState *state = scope_stats_state(0); + + for (int b = 0; b < PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE; b++) { + void *dev_ptr = alloc_cb(buf_size); + if (dev_ptr == nullptr) { + LOG_ERROR("ScopeStatsCollector: failed to allocate ScopeStatsBuffer b=%d", b); + return -1; + } + + void *host_ptr = dev_ptr; + if (register_cb != nullptr) { + int rc = register_cb(dev_ptr, buf_size, device_id, &host_ptr); + if (rc != 0) { + LOG_ERROR("ScopeStatsCollector: halHostRegister for ScopeStatsBuffer b=%d failed: %d", b, rc); + free_cb(dev_ptr); + return rc; + } + } + std::memset(host_ptr, 0, buf_size); + + manager_.register_mapping(dev_ptr, host_ptr); + + if (b < PLATFORM_SCOPE_STATS_SLOT_COUNT) { + uint32_t tail = state->free_queue.tail; + assert(tail - state->free_queue.head < PLATFORM_SCOPE_STATS_SLOT_COUNT && "free_queue overflow on init"); + state->free_queue.buffer_ptrs[tail % PLATFORM_SCOPE_STATS_SLOT_COUNT] = reinterpret_cast(dev_ptr); + wmb(); + state->free_queue.tail = tail + 1; + wmb(); + } else { + manager_.push_recycled(0, dev_ptr); + } + } + + initialized_ = true; + set_memory_context(alloc_cb, register_cb, free_cb, shm_host_, device_id); + + LOG_INFO_V0( + "ScopeStats collector initialized: %d threads, SHM=0x%lx", num_threads, + reinterpret_cast(shm_dev_) + ); + return 0; +} + +// --------------------------------------------------------------------------- +// Record accumulation (in-memory) +// --------------------------------------------------------------------------- + +void ScopeStatsCollector::append_buffer_records(const void *buf_host_ptr) { + const ScopeStatsBuffer *buf = reinterpret_cast(buf_host_ptr); + uint32_t n = buf->count; + if (n > static_cast(PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER)) { + n = static_cast(PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER); + } + if (n == 0) return; + + std::scoped_lock lock(records_mutex_); + records_.insert(records_.end(), buf->records, buf->records + n); + total_collected_ += n; +} + +void ScopeStatsCollector::on_buffer_collected(const ScopeStatsReadyBufferInfo &info) { + append_buffer_records(info.host_buffer_ptr); +} + +// --------------------------------------------------------------------------- +// reconcile_counters +// --------------------------------------------------------------------------- + +bool ScopeStatsCollector::reconcile_counters() { + if (shm_host_ == nullptr) return false; + + rmb(); + bool clean = true; + + ScopeStatsBufferState *state = scope_stats_state(0); + uint64_t buf_dev = state->current_buf_ptr; + if (buf_dev != 0) { + void *host_ptr = manager_.resolve_host_ptr(reinterpret_cast(buf_dev)); + if (host_ptr != nullptr) { + uint32_t count = reinterpret_cast(host_ptr)->count; + if (count != 0) { + LOG_ERROR( + "scope_stats reconcile: un-flushed buffer (current_buf_ptr=0x%lx, count=%u) — device flush failed", + static_cast(buf_dev), count + ); + clean = false; + } + } + } + + uint64_t total_device = state->total_record_count; + uint64_t dropped_device = state->dropped_record_count; + + if (dropped_device > 0) { + LOG_WARN( + "scope_stats reconcile: %lu records dropped on device side (free_queue empty or ready_queue full). " + "Increase PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE / PLATFORM_SCOPE_STATS_READYQUEUE_SIZE if frequent.", + static_cast(dropped_device) + ); + clean = false; + } + if (total_collected_ + dropped_device != total_device) { + LOG_WARN( + "scope_stats reconcile: record count mismatch (collected=%lu + dropped=%lu != device_total=%lu)", + static_cast(total_collected_), static_cast(dropped_device), + static_cast(total_device) + ); + clean = false; + } else { + LOG_INFO_V0( + "scope_stats reconcile: counts match (collected=%lu, dropped=%lu, device_total=%lu)", + static_cast(total_collected_), static_cast(dropped_device), + static_cast(total_device) + ); + } + + return clean; +} + +// --------------------------------------------------------------------------- +// NDJSON export +// --------------------------------------------------------------------------- + +int ScopeStatsCollector::write_jsonl(const std::string &output_dir) { + if (!initialized_ || shm_host_ == nullptr) return 0; + + std::filesystem::path dir = std::filesystem::path(output_dir) / "scope_stats"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + LOG_WARN("scope_stats: failed to create output dir %s: %s", dir.c_str(), ec.message().c_str()); + } + const std::string path = (dir / "scope_stats.jsonl").string(); + + std::FILE *fp = std::fopen(path.c_str(), "w"); + if (fp == nullptr) { + LOG_ERROR("scope_stats: failed to open %s", path.c_str()); + return -1; + } + + const ScopeStatsDataHeader *hdr = scope_stats_header(); + const ScopeStatsBufferState *state = scope_stats_state(0); + + // Line 1: run metadata. The per-ring maxima (task window / heap capacities) + // and the tensormap capacity are run-constants, so they live here once + // rather than on every record. + std::string task_window_max; + std::string heap_max; + for (int r = 0; r < PTO2_SCOPE_STATS_MAX_RING_DEPTH; r++) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%s%d", r == 0 ? "" : ", ", hdr->task_window_cap[r]); + task_window_max += buf; + std::snprintf(buf, sizeof(buf), "%s%" PRIu64, r == 0 ? "" : ", ", hdr->heap_cap[r]); + heap_max += buf; + } + std::fprintf( + fp, + "{\"version\": 4, \"fatal\": %s, \"dropped\": %u, \"total\": %u, " + "\"task_window_max\": [%s], \"heap_max\": [%s], \"tensormap_max\": %d}\n", + hdr->fatal_latched ? "true" : "false", state->dropped_record_count, state->total_record_count, + task_window_max.c_str(), heap_max.c_str(), hdr->tensormap_cap + ); + + // Serialize every record into one in-memory buffer, then a single fwrite. + // The hot loop is one snprintf per record (not 6 fprintf): stdio format + // parsing + per-call FILE locking on ~6×N calls was the dominant host cost. + std::scoped_lock lock(records_mutex_); + std::string out; + out.reserve(records_.size() * 128); + char line[320]; + for (const ScopeStatsRecord &rec : records_) { + const int site_len = static_cast(strnlen(rec.site_file_basename, sizeof(rec.site_file_basename))); + const char *phase = (rec.phase == SCOPE_STATS_PHASE_BEGIN) ? "begin" : "end"; + int n = std::snprintf( + line, sizeof(line), + "{\"site\": \"%.*s:%d\", \"phase\": \"%s\", \"depth\": %d, \"ring\": %d, " + "\"task_window_start\": %d, \"task_window_end\": %d, " + "\"heap_start\": %" PRIu64 ", \"heap_end\": %" PRIu64 ", " + "\"tensormap\": %d}\n", + site_len, rec.site_file_basename, rec.site_line, phase, rec.depth, rec.ring_id, rec.task_start, + rec.task_end, rec.heap_start, rec.heap_end, rec.tensormap_used + ); + if (n > 0) out.append(line, static_cast(n < static_cast(sizeof(line)) ? n : sizeof(line) - 1)); + } + std::fwrite(out.data(), 1, out.size(), fp); + std::fclose(fp); + + LOG_INFO_V1( + "scope_stats: wrote %lu records (dropped=%u) to %s", static_cast(records_.size()), + state->dropped_record_count, path.c_str() + ); + return 0; +} + +// --------------------------------------------------------------------------- +// finalize +// --------------------------------------------------------------------------- + +void ScopeStatsCollector::finalize(ScopeStatsUnregisterCallback unregister_cb, const ScopeStatsFreeCallback &free_cb) { + if (!initialized_) return; + + stop(); + + { + std::scoped_lock lock(records_mutex_); + records_.clear(); + records_.shrink_to_fit(); + } + + auto release_buf = [&](void *p) { + release_one_buffer(p, buffers_registered_ ? unregister_cb : nullptr, free_cb); + }; + manager_.release_owned_buffers(release_buf); + + if (shm_host_ != nullptr) { + std::unordered_set already_freed; + auto release_unique = [&](void *p) { + if (p == nullptr || !already_freed.insert(p).second) return; + release_buf(p); + }; + ScopeStatsBufferState *state = scope_stats_state(0); + release_unique(reinterpret_cast(state->current_buf_ptr)); + state->current_buf_ptr = 0; + rmb(); + uint32_t head = state->free_queue.head; + uint32_t tail = state->free_queue.tail; + uint32_t queued = tail - head; + if (queued > PLATFORM_SCOPE_STATS_SLOT_COUNT) queued = PLATFORM_SCOPE_STATS_SLOT_COUNT; + for (uint32_t i = 0; i < queued; i++) { + uint32_t slot = (head + i) % PLATFORM_SCOPE_STATS_SLOT_COUNT; + release_unique(reinterpret_cast(state->free_queue.buffer_ptrs[slot])); + state->free_queue.buffer_ptrs[slot] = 0; + } + state->free_queue.head = tail; + } + manager_.clear_mappings(); + + if (shm_dev_ != nullptr) { + release_one_buffer(shm_dev_, shm_registered_ ? unregister_cb : nullptr, free_cb); + shm_dev_ = nullptr; + shm_host_ = nullptr; + } + + initialized_ = false; + buffers_registered_ = false; + shm_registered_ = false; + shm_size_ = 0; + total_collected_ = 0; + clear_memory_context(); + LOG_INFO_V0("ScopeStats collector finalized"); +} diff --git a/src/a2a3/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp b/src/a2a3/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp index 5477c29a14..f468ead395 100644 --- a/src/a2a3/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp +++ b/src/a2a3/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp @@ -36,6 +36,7 @@ // Performance profiling headers #include "aicpu/l2_perf_collector_aicpu.h" +#include "aicpu/scope_stats_collector_aicpu.h" #include "aicpu/tensor_dump_aicpu.h" #include "aicpu/dep_gen_collector_aicpu.h" #include "common/l2_perf_profiling.h" @@ -521,6 +522,14 @@ int32_t AicpuExecutor::run(Runtime *runtime) { runtime_finalize_after_wire(rt, sched_ctx_.aic_count(), sched_ctx_.aiv_count()); #if PTO2_PROFILING rt->orchestrator.l2_perf_level = get_l2_perf_level(); + { + auto &orch = rt->orchestrator; + for (int r = 0; r < PTO2_MAX_RING_DEPTH; r++) { + auto &alloc = orch.rings[r].task_allocator; + scope_stats_set_ring_capacity(r, alloc.window_size(), alloc.heap_capacity()); + } + scope_stats_set_tensormap_capacity(orch.tensor_map.pool_capacity()); + } #endif // With multi-ring, slot_states are per-ring inside the scheduler. @@ -551,6 +560,14 @@ int32_t AicpuExecutor::run(Runtime *runtime) { dep_gen_aicpu_init(); } +#if PTO2_PROFILING + // scope_stats streams scope_end records off the orchestrator thread: + // record the per-thread ready_queue index. No-op (writer shared + // state null) when scope_stats is disabled; the current buffer is + // popped lazily on the first scope_end append. + scope_stats_aicpu_set_orch_thread_idx(thread_idx); +#endif + #if PTO2_PROFILING orch_cycle_start = get_sys_cnt_aicpu(); #endif @@ -567,6 +584,11 @@ int32_t AicpuExecutor::run(Runtime *runtime) { if (is_dep_gen_enabled()) { dep_gen_aicpu_flush(); } +#if PTO2_PROFILING + // Push the partially-filled scope_stats buffer so the host gets the + // final scope_end records. Idempotent / no-op when disabled. + scope_stats_aicpu_flush_buffers(); +#endif #if PTO2_PROFILING uint64_t orch_cycle_end = get_sys_cnt_aicpu(); (void)orch_cycle_end; diff --git a/src/a2a3/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h b/src/a2a3/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h index eabe3ec3fb..0bd3431e65 100644 --- a/src/a2a3/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h +++ b/src/a2a3/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h @@ -123,6 +123,11 @@ typedef struct PTO2RuntimeOps { ); TaskOutputTensors (*alloc_tensors)(PTO2Runtime *rt, const Arg &args); TaskOutputTensors (*submit_dummy_task)(PTO2Runtime *rt, const Arg &args); + + // Stash the call-site of the next PTO2ScopeGuard so the [ScopeStats] + // collector can log it. Always present to keep ops-table layout stable + // across PTO2_PROFILING settings; set to nullptr at PTO2_PROFILING=0. + void (*scope_set_site)(const char *file, int line); } PTO2RuntimeOps; /** @@ -361,10 +366,13 @@ static inline void set_tensor_data(const Tensor &tensor, uint32_t ndims, const u */ class PTO2ScopeGuard { public: - explicit PTO2ScopeGuard(PTO2ScopeMode mode = PTO2ScopeMode::AUTO) : + explicit PTO2ScopeGuard( + PTO2ScopeMode mode = PTO2ScopeMode::AUTO, const char *file = __builtin_FILE(), int line = __builtin_LINE() + ) : rt_(current_runtime()) { if (!rt_->ops->is_fatal(rt_)) { rt_->pending_scope_mode = mode; + if (rt_->ops->scope_set_site) rt_->ops->scope_set_site(file, line); rt_->ops->scope_begin(rt_); } } diff --git a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp index f80c7a6557..6ac98b8db3 100644 --- a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp +++ b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp @@ -39,6 +39,10 @@ extern "C" void set_dump_tensor_selective_mode(bool enable); extern "C" void set_dump_tensor_task_mask(uint64_t task_id, uint64_t mask); +#if PTO2_PROFILING +#include "aicpu/scope_stats_collector_aicpu.h" +#endif + // Verify the captured Tensor blob size in DepGenRecord matches the runtime // Tensor layout. The platform header defines DEP_GEN_TENSOR_SIZE without // including runtime/tensor.h, so this check lives at the orch callsite. @@ -58,6 +62,12 @@ extern "C" __attribute__((weak, visibility("hidden"))) bool is_dep_gen_enabled() __attribute__((weak, visibility("hidden"))) void dep_gen_aicpu_record_submit(uint64_t, bool, int, const void *const *, const uint8_t *, int, const uint64_t *) {} +// Scope_stats enable gate, queried via the same predicate idiom as +// is_dep_gen_enabled above. The AICPU collector links the strong definition; +// host builds fall back to this weak `false`. Gating here still skips the +// cross-agent occupancy reads that feed the sample when scope_stats is disabled. +extern "C" __attribute__((weak, visibility("hidden"))) bool is_scope_stats_enabled() { return false; } + // ============================================================================= // Orchestrator Profiling (compile-time toggle) // ============================================================================= @@ -173,6 +183,14 @@ static void orch_report_fatal_v(PTO2OrchestratorState *orch, int32_t error_code, const char *func, const char *fmt, va_list args) { int32_t latched_code = orch_mark_fatal(orch, error_code); +#if PTO2_PROFILING + // Flush the current scope's peaks BEFORE the FATAL log line, so the + // diagnostic context (which pool/window filled up) appears right next to + // the failure reason. on_fatal is latched, so duplicate fatals from + // different layers don't print multiple stats lines. + scope_stats_on_fatal(); +#endif + if (fmt == nullptr || fmt[0] == '\0') { if (latched_code != PTO2_ERROR_NONE && latched_code != error_code) { unified_log_error(func, "FATAL(code=%d, latched=%d)", error_code, latched_code); @@ -421,6 +439,18 @@ void PTO2OrchestratorState::begin_scope(PTO2ScopeMode mode) { if (mode == PTO2ScopeMode::MANUAL && !already_in_manual_scope) { orch->manual_begin_depth = orch->scope_stack_top; } +#if PTO2_PROFILING + // Gate via is_scope_stats_enabled() (weak-false in host builds) BEFORE the + // collector call: when disabled we pay nothing. Sample the current ring's + // task/heap start-end and tensormap usage at the scope boundary. + if (is_scope_stats_enabled()) { + auto &alloc = orch->rings[orch->current_ring_id()].task_allocator; + scope_stats_begin( + orch->current_ring_id(), alloc.task_tail(), alloc.task_head(), alloc.heap_tail(), alloc.heap_top(), + orch->tensor_map.current_used() + ); + } +#endif } void PTO2OrchestratorState::end_scope() { @@ -430,6 +460,21 @@ void PTO2OrchestratorState::end_scope() { } assert(orch->scope_stack_top >= 0 && "Scope stack underflow"); + // Snapshot the ring start/end BEFORE the orchestrator drains pending tasks + // via scheduler->on_scope_end, so the end record reflects the scope's + // occupancy at close, not the residual after teardown. +#if PTO2_PROFILING + // Gate via is_scope_stats_enabled() (see begin_scope). One collector call + // emits the end-boundary record and tears down bookkeeping. + if (is_scope_stats_enabled()) { + auto &alloc = orch->rings[orch->current_ring_id()].task_allocator; + scope_stats_end( + orch->current_ring_id(), alloc.task_tail(), alloc.task_head(), alloc.heap_tail(), alloc.heap_top(), + orch->tensor_map.current_used() + ); + } +#endif + #if PTO2_ORCH_PROFILING uint64_t _se0 = get_sys_cnt_aicpu(); #endif diff --git a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h index abd2a75105..0cd37b8d1b 100644 --- a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h +++ b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h @@ -178,6 +178,11 @@ class PTO2TaskAllocator { return local_task_id_ - last_alive; } + // Task ring start/end: tail = oldest live task (last_task_alive), head = + // next task id to allocate. head - tail == active_count(). + int32_t task_tail() const { return last_alive_ptr_->load(std::memory_order_acquire); } + int32_t task_head() const { return local_task_id_; } + int32_t window_size() const { return window_size_; } uint64_t heap_available() const { @@ -191,7 +196,14 @@ class PTO2TaskAllocator { } uint64_t heap_top() const { return heap_top_; } + // Heap ring start: reclaim pointer (oldest byte still live). heap_top() is + // the end (next allocation). heap_top - heap_tail == heap_used_bytes(). + uint64_t heap_tail() const { return heap_tail_; } uint64_t heap_capacity() const { return heap_size_; } + uint64_t heap_used_bytes() const { + if (heap_size_ == 0) return 0; + return (heap_top_ + heap_size_ - heap_tail_) % heap_size_; + } private: // --- Task Ring --- diff --git a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp index f39bac3650..8aee802b1c 100644 --- a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp +++ b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp @@ -28,6 +28,9 @@ #include "aicpu/device_time.h" #include "common/unified_log.h" +#if PTO2_PROFILING +#include "aicpu/scope_stats_collector_aicpu.h" +#endif // Weak fallback for HOST .so builds (never called, but satisfies linker). // The AICPU build links the strong symbol from platform/.../device_time.cpp. @@ -231,6 +234,14 @@ void set_tensor_data(PTO2Runtime *rt, const Tensor &tensor, uint32_t ndims, cons memcpy(ptr, &value, elem_size); } +// Ops-table entry that hands the call-site captured by PTO2ScopeGuard to the +// [ScopeStats] collector. The slot is always present in the struct to keep +// the layout stable; at PTO2_PROFILING=0 we fill nullptr so the orchestration +// .so's null-check skips it. +#if PTO2_PROFILING +static void scope_set_site_impl(const char *file, int line) { scope_stats_set_pending_site(file, line); } +#endif + static const PTO2RuntimeOps s_runtime_ops = { .submit_task = submit_task_impl, .scope_begin = rt_scope_begin, @@ -246,6 +257,11 @@ static const PTO2RuntimeOps s_runtime_ops = { .set_tensor_data = set_tensor_data, .alloc_tensors = alloc_tensors_impl, .submit_dummy_task = submit_dummy_task_impl, +#if PTO2_PROFILING + .scope_set_site = scope_set_site_impl, +#else + .scope_set_site = nullptr, +#endif }; // ============================================================================= diff --git a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h index 460624e69e..2ff08b05b1 100644 --- a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h +++ b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h @@ -89,6 +89,10 @@ struct PTO2RuntimeOps { ); TaskOutputTensors (*alloc_tensors)(PTO2Runtime *rt, const Arg &args); TaskOutputTensors (*submit_dummy_task)(PTO2Runtime *rt, const Arg &args); + // Stash the call-site captured by PTO2ScopeGuard into the [ScopeStats] + // collector. Always present in the struct to keep ops-table layout stable + // across PTO2_PROFILING settings; set to nullptr at PTO2_PROFILING=0. + void (*scope_set_site)(const char *file, int line); }; /** diff --git a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h index b63f20676c..684708e2d6 100644 --- a/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h +++ b/src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h @@ -371,6 +371,13 @@ struct PTO2TensorMap { return task_local_id & (task_window_sizes[ring_id] - 1); } + // Accessors read by scope_stats_collector. Declared unconditionally so the + // collector .cpp compiles at PTO2_PROFILING=0 (collector is unconditional — + // setter symbols must export for host dlsym; the probe call sites that use + // these accessors stay gated by PTO2_PROFILING). + int32_t current_used() const { return next_entry_idx - free_num; } + int32_t pool_capacity() const { return pool_size; } + // new_entry only allocates memory, does not assign attributes PTO2TensorMapEntry *new_entry() { if (free_num > 0) { diff --git a/src/a5/platform/include/aicpu/scope_stats_collector_aicpu.h b/src/a5/platform/include/aicpu/scope_stats_collector_aicpu.h new file mode 100644 index 0000000000..e979cd4650 --- /dev/null +++ b/src/a5/platform/include/aicpu/scope_stats_collector_aicpu.h @@ -0,0 +1,90 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +#pragma once + +#include + +#include "common/scope_stats.h" + +// Scope-stats collector — platform-owned, runtime-agnostic. +// +// Platform owns all collector state. Runtime calls pure-value APIs to report +// the task/heap ring start/end and tensormap usage at scope boundaries; no +// runtime types cross the boundary. +// +// Streaming transport (mirrors dep_gen): one ScopeStatsRecord is appended per +// scope boundary (begin and end) into a pooled ScopeStatsBuffer popped from the +// host-fed free_queue; full buffers are pushed onto the orchestrator thread's +// ready_queue. The host +// collector drains them in real time, so the effective record capacity is +// disk-bounded rather than the old fixed 16 384-entry ring. +// +// Setter symbols (set_scope_stats_enabled, set_platform_scope_stats_base) +// are exported unconditionally so the host-side sim DeviceRunner's dlsym +// always resolves. + +extern "C" { + +// --- Scope lifecycle probes (called by orchestrator begin_scope/end_scope) --- +// +// Each emits one record for the scope's current ring, carrying the task ring's +// and heap ring's start/end plus the tensormap pool usage, sampled at that +// boundary: begin tags it SCOPE_STATS_PHASE_BEGIN, end tags it +// SCOPE_STATS_PHASE_END. task_start/end = task ring tail/head, heap_start/end = +// heap reclaim/allocation pointers. + +void scope_stats_begin( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +); +void scope_stats_end( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +); +void scope_stats_on_fatal(); + +// --- Site tracking --- + +void scope_stats_set_pending_site(const char *file, int line); + +// --- Setter symbols (always exported) --- + +void set_scope_stats_enabled(bool enable); + +// Enabled-state predicate, queried by the orchestrator as its begin/end gate +// (same idiom as is_dep_gen_enabled). The collector owns the strong +// definition; the orchestrator declares a weak `false` fallback for host +// builds. +bool is_scope_stats_enabled(); + +// Map the shared region and reset collector-local state. Doubles as the init +// entry point — no separate init symbol; the current buffer is popped lazily +// on the first scope_end append. Host owns the shared header (free_queue, +// num_instances, capacities), so this no longer zeroes shared memory. +void set_platform_scope_stats_base(uint64_t scope_stats_data_base); + +// --- Streaming transport plumbing (orchestrator thread) --- + +// Record which AICPU thread runs the orchestrator (selects the per-thread +// ready_queue when buffers fill / on flush). Mirrors +// dep_gen_aicpu_set_orch_thread_idx. +void scope_stats_aicpu_set_orch_thread_idx(int thread_idx); + +// Push the current (partially-filled) buffer onto the ready_queue so the host +// gets the final records. Idempotent: a no-op when there is no current buffer. +// Called from the orchestrator-thread exit path and from scope_stats_on_fatal. +void scope_stats_aicpu_flush_buffers(); + +// --- Capacity registration (called by runtime at init) --- + +void scope_stats_set_ring_capacity(int ring_id, int32_t window_cap, uint64_t heap_cap); +void scope_stats_set_tensormap_capacity(int32_t cap); + +} // extern "C" diff --git a/src/a5/platform/include/common/kernel_args.h b/src/a5/platform/include/common/kernel_args.h index f138e7af82..67d7a5f1e2 100644 --- a/src/a5/platform/include/common/kernel_args.h +++ b/src/a5/platform/include/common/kernel_args.h @@ -77,10 +77,13 @@ struct KernelArgs { // indexes by block_idx and forwards into per-core platform state. uint64_t aicore_l2_perf_ring_addrs{0}; // L2PerfAicoreRing* per core; 0 when L2 swimlane is off uint64_t aicore_pmu_ring_addrs{0}; // PmuAicoreRing* per core; 0 when PMU is off + uint64_t scope_stats_data_base{0}; // ScopeStatsBuffer device pointer; 0 when scope_stats is off. + // a5 has no halHostRegister — host keeps a separate shadow and + // refreshes it via rtMemcpy DEVICE_TO_HOST at dump time. uint32_t log_level{1}; // Severity floor: 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NUL uint32_t log_info_v{5}; // INFO verbosity threshold (0..9); default V5 - uint32_t enable_profiling_flag{0}; // Profiling umbrella bitmask; bit0=dump_tensor, bit1=l2_swimlane, bit2=pmu - uint32_t _pad{0}; // Alignment padding + uint32_t enable_profiling_flag{0}; // Profiling umbrella bitmask; dump_tensor|l2_swimlane|pmu|dep_gen|scope_stats + uint32_t _pad{0}; // Alignment padding // Device pointer to an 8-byte buffer that the platform AICPU entry writes // the run-wall (ns) into. Allocated once at simpler_init, kept resident. diff --git a/src/a5/platform/include/common/platform_config.h b/src/a5/platform/include/common/platform_config.h index f19241bc57..9d1c0cb575 100644 --- a/src/a5/platform/include/common/platform_config.h +++ b/src/a5/platform/include/common/platform_config.h @@ -169,6 +169,7 @@ inline double cycles_to_us(uint64_t cycles) { #define PROFILING_FLAG_DUMP_TENSOR (1u << 0) #define PROFILING_FLAG_L2_SWIMLANE (1u << 1) #define PROFILING_FLAG_PMU (1u << 2) +#define PROFILING_FLAG_SCOPE_STATS (1u << 4) #define GET_PROFILING_FLAG(flags, bit) ((((uint32_t)(flags)) & ((uint32_t)(bit))) != 0u) #define SET_PROFILING_FLAG(flags, bit) ((flags) |= (uint32_t)(bit)) #define CLEAR_PROFILING_FLAG(flags, bit) ((flags) &= ~((uint32_t)(bit))) @@ -272,6 +273,39 @@ constexpr int PLATFORM_PMU_READYQUEUE_SIZE = PLATFORM_MAX_CORES * PLATFORM_PMU_B */ constexpr int PLATFORM_PMU_TIMEOUT_SECONDS = 30; +// ============================================================================= +// scope_stats Configuration +// ============================================================================= + +/** + * Number of ScopeStatsRecord entries per ScopeStatsBuffer. + * Each record is 52 B. Larger buffers = fewer device-side switch_buffer + * hand-offs (barriers, helps Orch) and fewer/larger device→host drain memcpys. + * 512 records ≈ 27 KB/buffer (×8 buffers ≈ 213 KB). + */ +constexpr int PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER = 512; + +/** + * SPSC free_queue slot count for scope_stats buffers (Host→Device hand-off depth). + */ +constexpr int PLATFORM_SCOPE_STATS_SLOT_COUNT = 8; + +/** + * Pre-allocated ScopeStatsBuffer count per orchestrator instance. + */ +constexpr int PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE = 8; + +/** + * Ready queue capacity for scope_stats (per AICPU thread). scope_stats is + * single-instance so headroom over BUFFERS_PER_INSTANCE is small. + */ +constexpr int PLATFORM_SCOPE_STATS_READYQUEUE_SIZE = PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE * 2; + +/** + * Idle timeout duration for scope_stats collection (seconds). + */ +constexpr int PLATFORM_SCOPE_STATS_TIMEOUT_SECONDS = 30; + // ============================================================================= // Register Communication Configuration // ============================================================================= diff --git a/src/a5/platform/include/common/scope_stats.h b/src/a5/platform/include/common/scope_stats.h new file mode 100644 index 0000000000..88efa72dde --- /dev/null +++ b/src/a5/platform/include/common/scope_stats.h @@ -0,0 +1,188 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +/** + * @file scope_stats.h + * @brief scope_stats streaming shared-memory data structures. + * + * Two ScopeStatsRecords are produced per scope — one at scope_begin and one at + * scope_end — each carrying the task/heap ring start/end and the tensormap + * live-entry count sampled at that boundary, tagged with a phase flag. Records + * stream off the device in + * fixed-capacity buffers, mirroring PMU / dep_gen / tensor_dump / l2_perf (the + * single source of mgmt-loop truth is + * src/a2a3/platform/include/host/profiling_common/profiler_base.h): + * + * ScopeStatsFreeQueue — SPSC: Host pushes free buffers, AICPU pops them. + * ScopeStatsBufferState — Per-instance state: free_queue + current buffer ptr + * + drop/total counters. + * ScopeStatsDataHeader — Fixed header: per-thread ready queues + static + * capacity metadata + fatal latch. + * ScopeStatsBuffer — Fixed-capacity ScopeStatsRecord buffer. + * + * Single-instance: the orchestrator is one AICPU thread, so the BufferState + * array has length 1 — kept array-shaped for symmetry with the other + * collectors and to match ProfilerBase::for_each_instance. + * + * Concurrency contract (SPSC, both directions single-side on each end — the + * orchestrator thread is the only AICPU writer): + * free_queue — Host producer / AICPU consumer. + * ready_queue — AICPU producer / Host consumer. + * The host collector thread DOES read buffers concurrently with AICPU writing + * other buffers; ownership of any single buffer is handed off atomically via + * the queues. + */ + +#ifndef PLATFORM_COMMON_SCOPE_STATS_H_ +#define PLATFORM_COMMON_SCOPE_STATS_H_ + +#include +#include + +#include "common/platform_config.h" + +#define PTO2_SCOPE_STATS_MAX_RING_DEPTH 4 +#define PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH 64 + +#ifdef __cplusplus +extern "C" { +#endif + +// Phase tag: which scope boundary a record was sampled at. +#define SCOPE_STATS_PHASE_BEGIN 0 +#define SCOPE_STATS_PHASE_END 1 + +// One record per scope boundary (begin or end). Layout MUST stay in sync with +// the device-side writer in platform/src/aicpu/scope_stats_collector_aicpu.cpp +// and the host reader in platform/src/host/scope_stats_collector.cpp. +// +// Each boundary writes one record into host-visible (uncached) device memory, +// so record width is directly on the orchestrator hot path. A scope only ever +// touches its own ring (ring_id = min(scope_depth, PTO2_MAX_RING_DEPTH-1)), so +// a single ring's start/end is stored rather than a per-ring array. +// +// For both rings, end-start is the live span: task_end-task_start is in-flight +// tasks, heap_end-heap_start is bytes in use. tensormap is a free-list pool +// (not a ring), so its current live-entry count is stored directly. +// +// Field order keeps the uint64 heap pointers 8-byte aligned with no internal +// padding. +struct ScopeStatsRecord { + char site_file_basename[32]; // NUL-terminated basename of the PTO2_SCOPE site, + // captured at append time so the host JSON has a + // human-readable path without dereferencing a + // device pointer (the string table lives in the + // orchestration .so, not in shared memory). + uint64_t heap_start; // Heap ring reclaim pointer (heap_tail_). + uint64_t heap_end; // Heap ring allocation pointer (heap_top_). + int32_t site_line; + int32_t task_start; // Task ring tail (last_task_alive). + int32_t task_end; // Task ring head (next task id). + int32_t tensormap_used; // tensormap pool live-entry count (not a ring). + int16_t depth; + int16_t ring_id; // Ring whose start/end this record carries. + int16_t phase; // SCOPE_STATS_PHASE_BEGIN / _END. + int16_t _pad; +}; + +// Fixed-capacity ScopeStatsRecord buffer. Allocated by Host, pushed into the +// orchestrator instance's free_queue. The orchestrator thread is the single +// producer; it commits directly into records[count]. +struct ScopeStatsBuffer { + // Header (first 64 bytes) — host copies this alone first to learn count. + volatile uint32_t count; // Number of valid records committed + uint32_t _pad0[15]; // Pad count to 64 B; isolates count's cache line. + + ScopeStatsRecord records[PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER]; +} __attribute__((aligned(64))); + +static_assert(offsetof(ScopeStatsBuffer, records) == 64, "ScopeStatsBuffer header must be exactly 64 bytes"); + +// SPSC free queue: Host (producer) pushes recycled/new buffers, Device (AICPU +// consumer) pops them when switching the current buffer. +struct ScopeStatsFreeQueue { + volatile uint64_t buffer_ptrs[PLATFORM_SCOPE_STATS_SLOT_COUNT]; + volatile uint32_t head; // Consumer read position (Device increments) + volatile uint32_t tail; // Producer write position (Host increments) + uint32_t _pad[14]; +} __attribute__((aligned(64))); + +// Per-instance buffer state. current_buf_ptr / current_buf_seq / the counters +// are all Device-written, Host-read (except free_queue.tail which Host writes). +struct ScopeStatsBufferState { + ScopeStatsFreeQueue free_queue; + volatile uint64_t current_buf_ptr; // Device's in-progress buffer (0 = none) + volatile uint32_t current_buf_seq; // Device monotonic counter + volatile uint32_t dropped_record_count; // Device: records dropped (free_queue empty / ready_queue full) + volatile uint32_t total_record_count; // Device: monotonic count of every record appended (success + dropped) + uint32_t _pad[11]; +} __attribute__((aligned(64))); + +// Ready queue entry — when a ScopeStatsBuffer fills, AICPU pushes one of these +// onto the per-thread ready queue for host pickup. +struct ScopeStatsReadyQueueEntry { + uint32_t instance_index; // Always 0 (single instance) + uint32_t _pad0; + uint64_t buffer_ptr; // Device pointer to the full ScopeStatsBuffer + uint32_t buffer_seq; + uint32_t _pad1; +} __attribute__((aligned(32))); + +// scope_stats data fixed header, located at the start of the shared region. +// Per-thread ready queues match the ProfilerBase contract (poll over +// header->queues[q] for q in [0, num_threads)); the orchestrator writes into +// queue[orch_thread_idx]. +// +// Static capacity metadata + fatal latch live here (not in the streamed +// records) because they are run-constants written once by the AICPU capacity +// setters at orchestrator init and read by the host at finalize. +struct ScopeStatsDataHeader { + ScopeStatsReadyQueueEntry queues[PLATFORM_MAX_AICPU_THREADS][PLATFORM_SCOPE_STATS_READYQUEUE_SIZE]; + volatile uint32_t queue_heads[PLATFORM_MAX_AICPU_THREADS]; // Host reads (consumer) + volatile uint32_t queue_tails[PLATFORM_MAX_AICPU_THREADS]; // AICPU writes (producer) + uint32_t num_instances; // Always 1 for now + + // Per-ring static capacities — written once by AICPU at orchestrator init + // (scope_stats_set_ring_capacity / scope_stats_set_tensormap_capacity). + // Host needs them to render the "used/cap" ratio without a separate query. + int32_t task_window_cap[PTO2_SCOPE_STATS_MAX_RING_DEPTH]; + uint64_t heap_cap[PTO2_SCOPE_STATS_MAX_RING_DEPTH]; + int32_t tensormap_cap; + volatile uint32_t fatal_latched; // AICPU sets to 1 on first fatal. + uint32_t _pad[2]; +} __attribute__((aligned(64))); + +// ============================================================================= +// Memory layout helpers +// ============================================================================= + +// Total bytes for the scope_stats shared-mem region (header + buffer states). +// Actual ScopeStatsBuffers are dynamically allocated and tracked by the host. +inline size_t calc_scope_stats_shm_size(int num_instances) { + return sizeof(ScopeStatsDataHeader) + static_cast(num_instances) * sizeof(ScopeStatsBufferState); +} + +inline ScopeStatsDataHeader *get_scope_stats_header(void *base_ptr) { + return reinterpret_cast(base_ptr); +} + +inline ScopeStatsBufferState *get_scope_stats_buffer_state(void *base_ptr, int instance_index) { + return reinterpret_cast( + reinterpret_cast(base_ptr) + sizeof(ScopeStatsDataHeader) + ) + + instance_index; +} + +#ifdef __cplusplus +} +#endif + +#endif // PLATFORM_COMMON_SCOPE_STATS_H_ diff --git a/src/a5/platform/include/host/scope_stats_collector.h b/src/a5/platform/include/host/scope_stats_collector.h new file mode 100644 index 0000000000..714f1134c9 --- /dev/null +++ b/src/a5/platform/include/host/scope_stats_collector.h @@ -0,0 +1,179 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +/** + * @file scope_stats_collector.h + * @brief Host-side scope_stats streaming collector + NDJSON export. + * + * Architecture mirrors PmuCollector: BufferPoolManager runs + * the mgmt thread (polls the per-thread ready queue, recycles buffers, refills + * the single instance's free_queue); ScopeStatsCollector's poll thread appends + * each full buffer's ScopeStatsRecords to an in-memory vector. After stop(), + * write_jsonl() renders them to /scope_stats.jsonl. + * + * a5 specifics: device↔host transfers go through profiling_copy.h. The + * framework's mgmt loop mirrors the shm region per tick; per-buffer payloads + * (ScopeStatsBuffer) are pulled on demand inside ProfilerAlgorithms. + * + * Lifecycle: + * init() — Allocate header + 1 BufferState + N ScopeStatsBuffers + * (pre-fills free_queue; surplus → recycled pool). + * start(tf) — Inherited: launches mgmt + poll threads. + * [device execution] + * stop() — Inherited: drain queues, join threads. + * reconcile_counters() — current_buf_ptr cleared by flush + collected == + * total - dropped cross-check. + * write_jsonl() — Emit scope_stats.jsonl (meta line + one record/line). + * finalize() — Free all device memory, unregister. + * + * Output (scope_stats.jsonl), NDJSON: + * line 1: {"version":3,"fatal":bool,"dropped":uint,"total":uint} + * line k: {"site":"file:line","depth":int, + * "task_window":"u/c","heap":"u/c","tensormap":"u/c"} + */ + +#ifndef SRC_A5_PLATFORM_INCLUDE_HOST_SCOPE_STATS_COLLECTOR_H_ +#define SRC_A5_PLATFORM_INCLUDE_HOST_SCOPE_STATS_COLLECTOR_H_ + +#include +#include +#include +#include +#include +#include + +#include "common/platform_config.h" +#include "common/scope_stats.h" +#include "common/unified_log.h" +#include "host/profiling_common/profiler_base.h" + +// --------------------------------------------------------------------------- +// scope_stats Module (drives BufferPoolManager) +// --------------------------------------------------------------------------- + +struct ScopeStatsReadyBufferInfo { + uint32_t instance_index; // Always 0 (single instance) + uint32_t thread_index; // AICPU thread queue index this entry came from + void *dev_buffer_ptr; + void *host_buffer_ptr; + uint32_t buffer_seq; +}; + +struct ScopeStatsModule { + using DataHeader = ScopeStatsDataHeader; + using ReadyEntry = ScopeStatsReadyQueueEntry; + using ReadyBufferInfo = ::ScopeStatsReadyBufferInfo; + using FreeQueue = ScopeStatsFreeQueue; + + static constexpr int kBufferKinds = 1; + static constexpr uint32_t kReadyQueueSize = PLATFORM_SCOPE_STATS_READYQUEUE_SIZE; + static constexpr uint32_t kSlotCount = PLATFORM_SCOPE_STATS_SLOT_COUNT; + static constexpr const char *kSubsystemName = "ScopeStatsModule"; + + static constexpr int batch_size(int /*kind*/) { + constexpr int kBatch = PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE - PLATFORM_SCOPE_STATS_SLOT_COUNT; + return kBatch < 1 ? 1 : kBatch; + } + + static DataHeader *header_from_shm(void *shm) { return get_scope_stats_header(shm); } + + static std::optional> + resolve_entry(void *shm, DataHeader * /*header*/, int q, const ReadyEntry &entry) { + ScopeStatsBufferState *state = get_scope_stats_buffer_state(shm, static_cast(entry.instance_index)); + profiling_common::EntrySite site; + site.kind = 0; + site.free_queue = &state->free_queue; + site.buffer_size = sizeof(ScopeStatsBuffer); + site.info.instance_index = entry.instance_index; + site.info.thread_index = static_cast(q); + site.info.dev_buffer_ptr = reinterpret_cast(entry.buffer_ptr); + site.info.host_buffer_ptr = nullptr; // filled by ProfilerAlgorithms + site.info.buffer_seq = entry.buffer_seq; + return site; + } + + template + static void for_each_instance(void *shm, DataHeader *header, Cb &&cb) { + const int n = static_cast(header->num_instances); + for (int i = 0; i < n; i++) { + ScopeStatsBufferState *state = get_scope_stats_buffer_state(shm, i); + cb(/*kind=*/0, &state->free_queue, sizeof(ScopeStatsBuffer)); + } + } +}; + +using ScopeStatsAllocCallback = profiling_common::ProfAllocCallback; +using ScopeStatsRegisterCallback = profiling_common::ProfRegisterCallback; +using ScopeStatsUnregisterCallback = profiling_common::ProfUnregisterCallback; +using ScopeStatsFreeCallback = profiling_common::ProfFreeCallback; + +// --------------------------------------------------------------------------- +// ScopeStatsCollector +// --------------------------------------------------------------------------- + +class ScopeStatsCollector : public profiling_common::ProfilerBase { +public: + ScopeStatsCollector() = default; + ~ScopeStatsCollector(); + + ScopeStatsCollector(const ScopeStatsCollector &) = delete; + ScopeStatsCollector &operator=(const ScopeStatsCollector &) = delete; + + static constexpr int kIdleTimeoutSec = PLATFORM_SCOPE_STATS_TIMEOUT_SECONDS; + static constexpr const char *kSubsystemName = "ScopeStats"; + + int init( + int num_threads, const ScopeStatsAllocCallback &alloc_cb, ScopeStatsRegisterCallback register_cb, + const ScopeStatsFreeCallback &free_cb, int device_id + ); + + // Device pointer to the ScopeStatsDataHeader. Set + // kernel_args.scope_stats_data_base to this after init(). + void *get_scope_stats_shm_device_ptr() const { return shm_dev_; } + + // Poll-thread hook: append the buffer's records to the in-memory vector. + void on_buffer_collected(const ScopeStatsReadyBufferInfo &info); + + // After stop(): warn on drops / un-flushed buffer and cross-check + // collected == total - dropped. Returns true iff the run is clean. + bool reconcile_counters(); + + // Render the collected records to /scope_stats.jsonl. Reads the + // static capacity metadata + fatal latch from the shared header (constant + // after orchestrator init). Must be called after stop(). + int write_jsonl(const std::string &output_dir); + + void finalize(ScopeStatsUnregisterCallback unregister_cb, const ScopeStatsFreeCallback &free_cb); + + bool is_initialized() const { return initialized_; } + uint64_t total_collected() const { return total_collected_; } + +private: + bool initialized_ = false; + int num_threads_ = 0; + + // Shared memory region (ScopeStatsDataHeader + ScopeStatsBufferState). + // shm_host_ / shm_size_ / device_id_ live on ProfilerBase (set via + // set_memory_context in init()). + void *shm_dev_ = nullptr; + + std::vector records_; + std::mutex records_mutex_; + uint64_t total_collected_ = 0; + + ScopeStatsDataHeader *scope_stats_header() const { return get_scope_stats_header(shm_host_); } + ScopeStatsBufferState *scope_stats_state(int idx = 0) const { return get_scope_stats_buffer_state(shm_host_, idx); } + + void *alloc_single_buffer(size_t size, void **host_ptr_out); + void append_buffer_records(const void *buf_host_ptr); +}; + +#endif // SRC_A5_PLATFORM_INCLUDE_HOST_SCOPE_STATS_COLLECTOR_H_ diff --git a/src/a5/platform/onboard/aicpu/kernel.cpp b/src/a5/platform/onboard/aicpu/kernel.cpp index 9bce80d2a8..0f18c99093 100644 --- a/src/a5/platform/onboard/aicpu/kernel.cpp +++ b/src/a5/platform/onboard/aicpu/kernel.cpp @@ -19,6 +19,7 @@ #include "aicpu/platform_regs.h" #include "aicpu/platform_aicpu_affinity.h" #include "aicpu/pmu_collector_aicpu.h" +#include "aicpu/scope_stats_collector_aicpu.h" #include "aicpu/tensor_dump_aicpu.h" #include "runtime.h" @@ -108,6 +109,8 @@ extern "C" __attribute__((visibility("default"))) int simpler_aicpu_exec(void *a set_l2_swimlane_enabled(GET_PROFILING_FLAG(k_args->enable_profiling_flag, PROFILING_FLAG_L2_SWIMLANE)); set_platform_pmu_base(k_args->pmu_data_base); set_pmu_enabled(GET_PROFILING_FLAG(k_args->enable_profiling_flag, PROFILING_FLAG_PMU)); + set_scope_stats_enabled(GET_PROFILING_FLAG(k_args->enable_profiling_flag, PROFILING_FLAG_SCOPE_STATS)); + set_platform_scope_stats_base(k_args->scope_stats_data_base); // Affinity gate: drop excess threads before entering runtime if (!platform_aicpu_affinity_gate(runtime->aicpu_thread_num, PLATFORM_MAX_AICPU_THREADS_JUST_FOR_LAUNCH)) { diff --git a/src/a5/platform/onboard/host/CMakeLists.txt b/src/a5/platform/onboard/host/CMakeLists.txt index b280f5fbb5..eec7e4afd1 100644 --- a/src/a5/platform/onboard/host/CMakeLists.txt +++ b/src/a5/platform/onboard/host/CMakeLists.txt @@ -45,6 +45,7 @@ list(APPEND HOST_RUNTIME_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/host_regs.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/l2_perf_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/pmu_collector.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/scope_stats_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/tensor_dump_collector.cpp" ) # Add common/host sources (LoadAicpuOp) diff --git a/src/a5/platform/onboard/host/device_runner.cpp b/src/a5/platform/onboard/host/device_runner.cpp index 75cb35e2ce..614ca180d6 100644 --- a/src/a5/platform/onboard/host/device_runner.cpp +++ b/src/a5/platform/onboard/host/device_runner.cpp @@ -423,6 +423,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_pmu_) { SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_PMU); } + if (enable_scope_stats_) { + SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_SCOPE_STATS); + } for (int i = 0; i < num_aicore; i++) { runtime.workers[i].aicpu_ready = 0; @@ -487,6 +490,14 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { } } + if (enable_scope_stats_) { + rc = init_scope_stats(launch_aicpu_num, device_id_); + if (rc != 0) { + LOG_ERROR("init_scope_stats failed: %d", rc); + return rc; + } + } + // Cleanup guard for early returns: stops all started collectors so // their mgmt + poll threads exit cleanly. stop() is idempotent and a // no-op on collectors that never started. @@ -530,6 +541,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_pmu_) { pmu_collector_.start(thread_factory); } + if (enable_scope_stats_) { + scope_stats_collector_.start(thread_factory); + } LOG_INFO_V0("=== launch_aicpu_kernel %s ===", host::KernelNames::InitName); rc = launch_aicpu_kernel(stream_aicpu_, &kernel_args_.args, host::KernelNames::InitName, 1); @@ -624,6 +638,12 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { pmu_collector_.reconcile_counters(); } + if (enable_scope_stats_) { + scope_stats_collector_.stop(); + scope_stats_collector_.reconcile_counters(); + scope_stats_collector_.write_jsonl(output_prefix_); + } + // Print handshake results (reads from device memory, must be before free) print_handshake_results(); @@ -930,6 +950,10 @@ int DeviceRunner::finalize() { if (pmu_collector_.is_initialized()) { pmu_collector_.finalize(/*unregister_cb=*/nullptr, prof_free_cb); } + if (scope_stats_collector_.is_initialized()) { + scope_stats_collector_.finalize(/*unregister_cb=*/nullptr, prof_free_cb); + kernel_args_.args.scope_stats_data_base = 0; + } // Release the three per-Worker pooled arenas (GM heap, PTO2 SM, optional // trb prebuilt runtime arena — each its own device_malloc). Must precede @@ -1142,3 +1166,16 @@ int DeviceRunner::init_pmu( } return rc; } + +int DeviceRunner::init_scope_stats(int num_threads, int device_id) { + // a5: register_cb=nullptr, so the collector mallocs a host shadow per + // device buffer + rtMemcpy's the zeroed shadow to device (see + // ScopeStatsCollector::alloc_single_buffer). No halHostRegister on a5. + int rc = scope_stats_collector_.init(num_threads, prof_alloc_cb, /*register_cb=*/nullptr, prof_free_cb, device_id); + if (rc != 0) { + return rc; + } + kernel_args_.args.scope_stats_data_base = + reinterpret_cast(scope_stats_collector_.get_scope_stats_shm_device_ptr()); + return 0; +} diff --git a/src/a5/platform/onboard/host/device_runner.h b/src/a5/platform/onboard/host/device_runner.h index 86a0175f5d..2faa47d6cc 100644 --- a/src/a5/platform/onboard/host/device_runner.h +++ b/src/a5/platform/onboard/host/device_runner.h @@ -49,6 +49,7 @@ #include "host/memory_allocator.h" #include "host/l2_perf_collector.h" #include "host/pmu_collector.h" +#include "host/scope_stats_collector.h" #include "host/tensor_dump_collector.h" #include "load_aicpu_op.h" #include "runtime.h" @@ -210,6 +211,7 @@ class DeviceRunner { enable_pmu_ = (enable_pmu > 0); pmu_event_type_ = resolve_pmu_event_type(enable_pmu); } + void set_scope_stats_enabled(bool enable) { enable_scope_stats_ = enable; } // Directory under which all diagnostic artifacts (l2_perf_records.json / // tensor_dump/ / pmu.csv) land. Required (non-empty) when any diagnostic // is enabled; CallConfig::validate() enforces this contract upstream. @@ -606,11 +608,14 @@ class DeviceRunner { bool enable_l2_swimlane_{false}; bool enable_dump_tensor_{false}; bool enable_pmu_{false}; + bool enable_scope_stats_{false}; + ScopeStatsCollector scope_stats_collector_; L2PerfLevel l2_perf_level_{L2PerfLevel::DISABLED}; // resolved from set_l2_swimlane_enabled() PmuEventType pmu_event_type_{PmuEventType::PIPE_UTILIZATION}; // resolved from set_pmu_enabled() std::string output_prefix_{}; // diagnostic artifact root directory int init_pmu(int num_cores, int num_threads, const std::string &csv_path, PmuEventType event_type, int device_id); + int init_scope_stats(int num_threads, int device_id); // Per-run collector teardown: stops mgmt + poll threads on every collector // whose init succeeded, in the only safe order (stop() joins mgmt before diff --git a/src/a5/platform/onboard/host/pto_runtime_c_api.cpp b/src/a5/platform/onboard/host/pto_runtime_c_api.cpp index 24d3db6eb4..6b743d00cf 100644 --- a/src/a5/platform/onboard/host/pto_runtime_c_api.cpp +++ b/src/a5/platform/onboard/host/pto_runtime_c_api.cpp @@ -408,7 +408,7 @@ int prepare_callable(DeviceContextHandle ctx, int32_t callable_id, const void *c int run_prepared( DeviceContextHandle ctx, RuntimeHandle runtime, int32_t callable_id, const void *args, int block_dim, int aicpu_thread_num, int enable_l2_swimlane, int enable_dump_tensor, int enable_pmu, int /*enable_dep_gen*/, - const char *output_prefix, PtoRunTiming *out_timing + int enable_scope_stats, const char *output_prefix, PtoRunTiming *out_timing ) { if (out_timing != NULL) { out_timing->host_wall_ns = 0; @@ -472,6 +472,7 @@ int run_prepared( runner->set_l2_swimlane_enabled(enable_l2_swimlane); runner->set_dump_tensor_enabled(enable_dump_tensor != 0); runner->set_pmu_enabled(enable_pmu); + runner->set_scope_stats_enabled(enable_scope_stats != 0); runner->set_output_prefix(output_prefix); rc = runner->run(*r, block_dim, aicpu_thread_num); diff --git a/src/a5/platform/sim/host/CMakeLists.txt b/src/a5/platform/sim/host/CMakeLists.txt index c42ed3fa77..221b2d6ed2 100644 --- a/src/a5/platform/sim/host/CMakeLists.txt +++ b/src/a5/platform/sim/host/CMakeLists.txt @@ -46,6 +46,7 @@ list(APPEND HOST_RUNTIME_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/platform_compile_info.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/l2_perf_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/pmu_collector.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/scope_stats_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../../src/host/tensor_dump_collector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../aicpu/platform_aicpu_affinity.cpp" # Shared POSIX-shm sim comm backend (same source as a2a3 sim). diff --git a/src/a5/platform/sim/host/device_runner.cpp b/src/a5/platform/sim/host/device_runner.cpp index b3072919c9..728f20c16a 100644 --- a/src/a5/platform/sim/host/device_runner.cpp +++ b/src/a5/platform/sim/host/device_runner.cpp @@ -280,6 +280,20 @@ int DeviceRunner::ensure_binaries_loaded() { return -1; } + set_scope_stats_enabled_func_ = + reinterpret_cast(dlsym(aicpu_so_handle_, "set_scope_stats_enabled")); + if (set_scope_stats_enabled_func_ == nullptr) { + LOG_ERROR("dlsym failed for set_scope_stats_enabled: %s", dlerror()); + return -1; + } + + set_platform_scope_stats_base_func_ = + reinterpret_cast(dlsym(aicpu_so_handle_, "set_platform_scope_stats_base")); + if (set_platform_scope_stats_base_func_ == nullptr) { + LOG_ERROR("dlsym failed for set_platform_scope_stats_base: %s", dlerror()); + return -1; + } + // Log config travels via the RTLD_GLOBAL HostLogger singleton in // libsimpler_log.so — already seeded by simpler_log_init() before the // AICPU sim SO was dlopen'd, so no per-SO setter forwarding is needed. @@ -425,6 +439,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_pmu_) { SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_PMU); } + if (enable_scope_stats_) { + SET_PROFILING_FLAG(enable_profiling_flag, PROFILING_FLAG_SCOPE_STATS); + } for (int i = 0; i < num_aicore; i++) { runtime.workers[i].aicpu_ready = 0; @@ -486,6 +503,14 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { } } + if (enable_scope_stats_) { + rc = init_scope_stats(launch_aicpu_num); + if (rc != 0) { + LOG_ERROR("init_scope_stats failed: %d", rc); + return rc; + } + } + // Cleanup guard for early returns: stops all started collectors so // their mgmt + poll threads exit cleanly. stop() is idempotent and a // no-op on collectors that never started. @@ -533,7 +558,8 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { // Check if executors are loaded if (aicpu_execute_func_ == nullptr || aicore_execute_func_ == nullptr || set_platform_regs_func_ == nullptr || set_platform_dump_base_func_ == nullptr || set_dump_tensor_enabled_func_ == nullptr || - set_platform_pmu_base_func_ == nullptr || set_pmu_enabled_func_ == nullptr) { + set_platform_pmu_base_func_ == nullptr || set_pmu_enabled_func_ == nullptr || + set_scope_stats_enabled_func_ == nullptr || set_platform_scope_stats_base_func_ == nullptr) { LOG_ERROR("Executor functions not loaded. Call ensure_binaries_loaded first."); return -1; } @@ -545,6 +571,8 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { set_l2_swimlane_enabled_func_(enable_l2_swimlane_); set_platform_pmu_base_func_(kernel_args_.pmu_data_base); set_pmu_enabled_func_(enable_pmu_); + set_scope_stats_enabled_func_(enable_scope_stats_); + set_platform_scope_stats_base_func_(kernel_args_.scope_stats_data_base); // No per-SO log-config push: HostLogger lives in libsimpler_log.so // (RTLD_GLOBAL singleton) and the AICPU sim SO reads it directly via the @@ -566,6 +594,9 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { if (enable_pmu_) { pmu_collector_.start(thread_factory); } + if (enable_scope_stats_) { + scope_stats_collector_.start(thread_factory); + } // Launch AICPU threads (over-launch for affinity gate) constexpr int over_launch = PLATFORM_MAX_AICPU_THREADS_JUST_FOR_LAUNCH; @@ -660,6 +691,12 @@ int DeviceRunner::run(Runtime &runtime, int block_dim, int launch_aicpu_num) { pmu_collector_.reconcile_counters(); } + if (enable_scope_stats_) { + scope_stats_collector_.stop(); + scope_stats_collector_.reconcile_counters(); + scope_stats_collector_.write_jsonl(output_prefix_); + } + // Print handshake results at end of run print_handshake_results(); @@ -706,6 +743,8 @@ void DeviceRunner::unload_executor_binaries() { set_l2_swimlane_enabled_func_ = nullptr; set_platform_pmu_base_func_ = nullptr; set_pmu_enabled_func_ = nullptr; + set_scope_stats_enabled_func_ = nullptr; + set_platform_scope_stats_base_func_ = nullptr; aicpu_so_loaded_ = false; } if (!aicpu_so_path_.empty()) { @@ -916,6 +955,10 @@ int DeviceRunner::finalize() { if (pmu_collector_.is_initialized()) { pmu_collector_.finalize(/*unregister_cb=*/nullptr, prof_free_cb); } + if (scope_stats_collector_.is_initialized()) { + scope_stats_collector_.finalize(/*unregister_cb=*/nullptr, prof_free_cb); + kernel_args_.scope_stats_data_base = 0; + } // Release any chip callable buffers uploaded via upload_chip_callable_buffer. // Pool semantics mirror per-fid binaries: never freed until finalize. @@ -1134,3 +1177,18 @@ int DeviceRunner::init_pmu( } return rc; } + +int DeviceRunner::init_scope_stats(int num_threads) { + // a5 sim: register_cb=nullptr, so the collector mallocs a host shadow per + // device buffer; sim's profiling_copy_* are plain memcpys, so the dev/host + // shadow path collapses to one allocation pair without address-space tricks. + int rc = scope_stats_collector_.init( + num_threads, prof_alloc_cb, /*register_cb=*/nullptr, prof_free_cb, /*device_id=*/-1 + ); + if (rc != 0) { + return rc; + } + kernel_args_.scope_stats_data_base = + reinterpret_cast(scope_stats_collector_.get_scope_stats_shm_device_ptr()); + return 0; +} diff --git a/src/a5/platform/sim/host/device_runner.h b/src/a5/platform/sim/host/device_runner.h index 59b6855720..03ffa37804 100644 --- a/src/a5/platform/sim/host/device_runner.h +++ b/src/a5/platform/sim/host/device_runner.h @@ -54,6 +54,7 @@ #include "host/memory_allocator.h" #include "host/l2_perf_collector.h" #include "host/pmu_collector.h" +#include "host/scope_stats_collector.h" #include "host/tensor_dump_collector.h" #include "runtime.h" @@ -191,6 +192,7 @@ class DeviceRunner { enable_pmu_ = (enable_pmu > 0); pmu_event_type_ = resolve_pmu_event_type(enable_pmu); } + void set_scope_stats_enabled(bool enable) { enable_scope_stats_ = enable; } // Directory under which all diagnostic artifacts (l2_perf_records.json / // tensor_dump/ / pmu.csv) land. Required (non-empty) when any diagnostic // is enabled; CallConfig::validate() enforces this contract upstream. @@ -378,6 +380,8 @@ class DeviceRunner { void (*set_platform_l2_perf_base_func_)(uint64_t){nullptr}; void (*set_l2_swimlane_enabled_func_)(bool){nullptr}; void (*set_pmu_enabled_func_)(bool){nullptr}; + void (*set_scope_stats_enabled_func_)(bool){nullptr}; + void (*set_platform_scope_stats_base_func_)(uint64_t){nullptr}; std::string aicpu_so_path_; std::string aicore_so_path_; @@ -389,6 +393,7 @@ class DeviceRunner { // PMU profiling (per-task AICore hardware counters) PmuCollector pmu_collector_; + ScopeStatsCollector scope_stats_collector_; // Private helper methods — read aicpu_so_binary_ / aicore_kernel_binary_ // off the runner (populated by set_executors during simpler_init). @@ -433,11 +438,13 @@ class DeviceRunner { bool enable_l2_swimlane_{false}; bool enable_dump_tensor_{false}; bool enable_pmu_{false}; + bool enable_scope_stats_{false}; L2PerfLevel l2_perf_level_{L2PerfLevel::DISABLED}; // resolved from set_l2_swimlane_enabled() PmuEventType pmu_event_type_{PmuEventType::PIPE_UTILIZATION}; // resolved from set_pmu_enabled() std::string output_prefix_{}; // diagnostic artifact root directory int init_pmu(int num_cores, int num_threads, const std::string &csv_path, PmuEventType event_type, int device_id); + int init_scope_stats(int num_threads); // Per-run collector teardown: stops mgmt + poll threads on every collector // whose init succeeded. Idempotent. Mirrors the onboard helper. diff --git a/src/a5/platform/sim/host/pto_runtime_c_api.cpp b/src/a5/platform/sim/host/pto_runtime_c_api.cpp index f2dc10b4ee..f82ba95ea2 100644 --- a/src/a5/platform/sim/host/pto_runtime_c_api.cpp +++ b/src/a5/platform/sim/host/pto_runtime_c_api.cpp @@ -308,7 +308,7 @@ int prepare_callable(DeviceContextHandle ctx, int32_t callable_id, const void *c int run_prepared( DeviceContextHandle ctx, RuntimeHandle runtime, int32_t callable_id, const void *args, int block_dim, int aicpu_thread_num, int enable_l2_swimlane, int enable_dump_tensor, int enable_pmu, int /*enable_dep_gen*/, - const char *output_prefix, PtoRunTiming *out_timing + int enable_scope_stats, const char *output_prefix, PtoRunTiming *out_timing ) { if (out_timing != NULL) { out_timing->host_wall_ns = 0; @@ -362,6 +362,7 @@ int run_prepared( runner->set_l2_swimlane_enabled(enable_l2_swimlane); runner->set_dump_tensor_enabled(enable_dump_tensor != 0); runner->set_pmu_enabled(enable_pmu); + runner->set_scope_stats_enabled(enable_scope_stats != 0); runner->set_output_prefix(output_prefix); rc = runner->run(*r, block_dim, aicpu_thread_num); diff --git a/src/a5/platform/src/aicpu/scope_stats_collector_aicpu.cpp b/src/a5/platform/src/aicpu/scope_stats_collector_aicpu.cpp new file mode 100644 index 0000000000..8784bf50df --- /dev/null +++ b/src/a5/platform/src/aicpu/scope_stats_collector_aicpu.cpp @@ -0,0 +1,331 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +// Platform-layer scope_stats collector (streaming). +// +// Owns the scope depth/site stack and exposes pure-value APIs for runtime to +// report the task/heap ring start/end and tensormap usage at scope boundaries. +// One ScopeStatsRecord is appended per boundary (begin and end) into a pooled +// ScopeStatsBuffer; full buffers stream to the host via the per-thread +// ready_queue (SPSC, mirrors dep_gen). No runtime-specific types cross the +// boundary. + +#include "aicpu/scope_stats_collector_aicpu.h" + +#include + +#include "common/memory_barrier.h" +#include "common/platform_config.h" +#include "common/scope_stats.h" +#include "common/unified_log.h" + +// --------------------------------------------------------------------------- +// Collector state +// --------------------------------------------------------------------------- + +int32_t scope_stats_depth = -1; +static bool scope_stats_enabled = false; + +// Streaming transport state — single dep_gen-style instance (the orchestrator). +static ScopeStatsDataHeader *s_scope_stats_header = nullptr; +static ScopeStatsBufferState *s_scope_stats_state = nullptr; +static int s_orch_thread_idx = -1; // set via scope_stats_aicpu_set_orch_thread_idx + +namespace { + +const char *s_pending_site_file = nullptr; +int32_t s_pending_site_line = 0; + +const char *s_scope_site_file[PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH] = {}; +int32_t s_scope_site_line[PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH] = {}; + +inline const char *basename_of(const char *path) { + if (!path) return "(unknown)"; + const char *base = path; + for (const char *p = path; *p; ++p) { + if (*p == '/' || *p == '\\') base = p + 1; + } + return base; +} + +inline void copy_basename(char (&dst)[32], const char *src) { + // site_file is a compile-time-constant string pointer; the same scope source + // line (e.g. a begin_scope inside an unroll loop) reuses the same pointer on + // every iteration. Memoize the basename scan keyed by that pointer so a + // repeated site becomes a single 32B copy instead of a full-path rescan. + static const char *s_cached_src = nullptr; + static char s_cached[32]; + if (src != nullptr && src == s_cached_src) { + memcpy(dst, s_cached, sizeof(dst)); + return; + } + const char *base = basename_of(src); + size_t i = 0; + for (; i + 1 < sizeof(dst) && base[i]; i++) + dst[i] = base[i]; + dst[i] = '\0'; + if (src != nullptr) { + s_cached_src = src; + memcpy(s_cached, dst, sizeof(dst)); + } +} + +// Enqueue a full buffer onto the orchestrator thread's ready_queue. Returns 0 +// on success, -1 if the queue is full or the orch thread index is unset. +int enqueue_ready_buffer(uint64_t buffer_ptr, uint32_t buffer_seq) { + if (s_orch_thread_idx < 0 || s_orch_thread_idx >= PLATFORM_MAX_AICPU_THREADS) { + return -1; + } + int q = s_orch_thread_idx; + uint32_t capacity = PLATFORM_SCOPE_STATS_READYQUEUE_SIZE; + uint32_t current_tail = s_scope_stats_header->queue_tails[q]; + uint32_t current_head = s_scope_stats_header->queue_heads[q]; + + uint32_t next_tail = (current_tail + 1) % capacity; + if (next_tail == current_head) { + return -1; // Queue full + } + + s_scope_stats_header->queues[q][current_tail].instance_index = 0; + s_scope_stats_header->queues[q][current_tail].buffer_ptr = buffer_ptr; + s_scope_stats_header->queues[q][current_tail].buffer_seq = buffer_seq; + // Single hand-off barrier: drain every record written into the buffer plus + // the ready-entry fields above before the host can observe the advanced + // tail. Replaces the former per-record wmb() in scope_stats_end(). + wmb(); + s_scope_stats_header->queue_tails[q] = next_tail; + return 0; +} + +// Pop a free buffer into current_buf_ptr. Returns true if one was available. +bool pop_free_buffer() { + rmb(); + uint32_t head = s_scope_stats_state->free_queue.head; + uint32_t tail = s_scope_stats_state->free_queue.tail; + if (head == tail) { + return false; + } + uint64_t buf_ptr = s_scope_stats_state->free_queue.buffer_ptrs[head % PLATFORM_SCOPE_STATS_SLOT_COUNT]; + rmb(); + s_scope_stats_state->free_queue.head = head + 1; + s_scope_stats_state->current_buf_ptr = buf_ptr; + reinterpret_cast(buf_ptr)->count = 0; + wmb(); + return true; +} + +// Commit the full current buffer to the ready_queue and pop a replacement. On +// no free buffer / ready_queue full, drop the buffer's records and reuse it. +void switch_buffer() { + if (s_scope_stats_state == nullptr) { + return; + } + ScopeStatsBuffer *full_buf = reinterpret_cast(s_scope_stats_state->current_buf_ptr); + if (full_buf == nullptr) { + return; + } + + rmb(); + uint32_t head = s_scope_stats_state->free_queue.head; + uint32_t tail = s_scope_stats_state->free_queue.tail; + if (head == tail) { + // Host can't recycle buffers fast enough: drop silently (count only, no + // per-drop log). Logging here would make a slow host pay device-side + // hot-path cost — the device must not be coupled to host throughput. The + // total is surfaced via dropped_record_count in the finalize summary. + s_scope_stats_state->dropped_record_count += full_buf->count; + full_buf->count = 0; + wmb(); + return; + } + + uint32_t seq = s_scope_stats_state->current_buf_seq; + int rc = enqueue_ready_buffer(s_scope_stats_state->current_buf_ptr, seq); + if (rc != 0) { + s_scope_stats_state->dropped_record_count += full_buf->count; + full_buf->count = 0; + wmb(); + return; + } + + uint64_t new_buf_ptr = s_scope_stats_state->free_queue.buffer_ptrs[head % PLATFORM_SCOPE_STATS_SLOT_COUNT]; + rmb(); + s_scope_stats_state->free_queue.head = head + 1; + s_scope_stats_state->current_buf_ptr = new_buf_ptr; + s_scope_stats_state->current_buf_seq = seq + 1; + reinterpret_cast(new_buf_ptr)->count = 0; + wmb(); +} + +// Append one record carrying the task/heap ring start/end and tensormap usage, +// tagged with the current depth/site and the boundary phase. Called once per +// scope boundary. +void append_record_snapshot( + int ring_id, int16_t phase, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, + int32_t tensormap_used +) { + if (s_scope_stats_state == nullptr) return; + // Single volatile read of current_buf_ptr (re-read only after a pop/switch). + uint64_t cur = s_scope_stats_state->current_buf_ptr; + if (cur == 0) { + pop_free_buffer(); + cur = s_scope_stats_state->current_buf_ptr; + } + ScopeStatsBuffer *buf = reinterpret_cast(cur); + // Single volatile read of buf->count (reused as the write index below). + uint32_t idx = (buf != nullptr) ? buf->count : 0; + if (buf != nullptr && idx >= static_cast(PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER)) { + switch_buffer(); + cur = s_scope_stats_state->current_buf_ptr; + buf = reinterpret_cast(cur); + idx = (buf != nullptr) ? buf->count : 0; + } + s_scope_stats_state->total_record_count += 1; + if (buf == nullptr) { + s_scope_stats_state->dropped_record_count += 1; + return; + } + ScopeStatsRecord &rec = buf->records[idx]; + copy_basename(rec.site_file_basename, s_scope_site_file[scope_stats_depth]); + rec.site_line = s_scope_site_line[scope_stats_depth]; + rec.depth = static_cast(scope_stats_depth); + rec.ring_id = static_cast(ring_id); + rec.phase = phase; + rec._pad = 0; + rec.task_start = task_start; + rec.task_end = task_end; + rec.tensormap_used = tensormap_used; + rec.heap_start = heap_start; + rec.heap_end = heap_end; + buf->count = idx + 1; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Setter symbols — always exported, unconditionally compiled +// --------------------------------------------------------------------------- + +extern "C" void set_scope_stats_enabled(bool enable) { scope_stats_enabled = enable; } + +extern "C" bool is_scope_stats_enabled() { return scope_stats_enabled; } + +extern "C" void set_platform_scope_stats_base(uint64_t scope_stats_data_base) { + void *base = reinterpret_cast(scope_stats_data_base); + if (base != nullptr) { + s_scope_stats_header = get_scope_stats_header(base); + s_scope_stats_state = get_scope_stats_buffer_state(base, /*instance_index=*/0); + } else { + s_scope_stats_header = nullptr; + s_scope_stats_state = nullptr; + } + // Reset collector-local statics so a prior run that crashed mid-scope (or + // reused the same AICPU .so process) can't leak stale depth/peak data into + // the new run's records. The shared header/free_queue is host-owned — the + // host already zeroed it at init, so this no longer touches shared memory. + scope_stats_depth = -1; + s_pending_site_file = nullptr; + s_pending_site_line = 0; + memset(s_scope_site_file, 0, sizeof(s_scope_site_file)); + memset(s_scope_site_line, 0, sizeof(s_scope_site_line)); +} + +void scope_stats_aicpu_set_orch_thread_idx(int thread_idx) { s_orch_thread_idx = thread_idx; } + +void scope_stats_aicpu_flush_buffers() { + if (s_scope_stats_state == nullptr) { + return; + } + rmb(); + uint64_t buf_ptr = s_scope_stats_state->current_buf_ptr; + if (buf_ptr == 0) { + return; // Idempotent: nothing in flight. + } + ScopeStatsBuffer *buf = reinterpret_cast(buf_ptr); + if (buf->count == 0) { + return; + } + + uint32_t seq = s_scope_stats_state->current_buf_seq; + int rc = enqueue_ready_buffer(buf_ptr, seq); + if (rc == 0) { + LOG_INFO_V0("scope_stats: flushed buffer with %u records", buf->count); + } else { + LOG_ERROR("scope_stats: flush failed (ready_queue full), %u records dropped", buf->count); + s_scope_stats_state->dropped_record_count += buf->count; + buf->count = 0; + } + s_scope_stats_state->current_buf_ptr = 0; + wmb(); +} + +// --------------------------------------------------------------------------- +// Scope lifecycle probes +// --------------------------------------------------------------------------- + +extern "C" void scope_stats_set_pending_site(const char *file, int line) { + s_pending_site_file = file; + s_pending_site_line = line; +} + +// Push depth/site and emit the begin-boundary record (ring start/end + usage). +extern "C" void scope_stats_begin( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +) { + if (!scope_stats_enabled) return; + if (scope_stats_depth + 1 >= PTO2_SCOPE_STATS_MAX_SCOPE_DEPTH) return; + int32_t d = ++scope_stats_depth; + s_scope_site_file[d] = s_pending_site_file; + s_scope_site_line[d] = s_pending_site_line; + s_pending_site_file = nullptr; + s_pending_site_line = 0; + append_record_snapshot( + ring_id, SCOPE_STATS_PHASE_BEGIN, task_start, task_end, heap_start, heap_end, tensormap_used + ); +} + +// Emit the end-boundary record, then tear down depth/site. +extern "C" void scope_stats_end( + int ring_id, int32_t task_start, int32_t task_end, uint64_t heap_start, uint64_t heap_end, int32_t tensormap_used +) { + if (!scope_stats_enabled) return; + if (scope_stats_depth < 0) return; + int32_t d = scope_stats_depth; + append_record_snapshot(ring_id, SCOPE_STATS_PHASE_END, task_start, task_end, heap_start, heap_end, tensormap_used); + s_scope_site_file[d] = nullptr; + s_scope_site_line[d] = 0; + --scope_stats_depth; +} + +extern "C" void scope_stats_on_fatal() { + if (!scope_stats_enabled) return; + if (s_scope_stats_header == nullptr) return; + s_scope_stats_header->fatal_latched = 1; + wmb(); + // Deliver the partial buffer to the host before the abort unwinds. + scope_stats_aicpu_flush_buffers(); +} + +// --------------------------------------------------------------------------- +// Capacity registration — called by runtime at init +// --------------------------------------------------------------------------- + +extern "C" void scope_stats_set_ring_capacity(int ring_id, int32_t window_cap, uint64_t heap_cap) { + if (!s_scope_stats_header) return; + if (ring_id < 0 || ring_id >= PTO2_SCOPE_STATS_MAX_RING_DEPTH) return; + s_scope_stats_header->task_window_cap[ring_id] = window_cap; + s_scope_stats_header->heap_cap[ring_id] = heap_cap; +} + +extern "C" void scope_stats_set_tensormap_capacity(int32_t cap) { + if (!s_scope_stats_header) return; + s_scope_stats_header->tensormap_cap = cap; +} diff --git a/src/a5/platform/src/host/scope_stats_collector.cpp b/src/a5/platform/src/host/scope_stats_collector.cpp new file mode 100644 index 0000000000..ee18370c18 --- /dev/null +++ b/src/a5/platform/src/host/scope_stats_collector.cpp @@ -0,0 +1,376 @@ +/* + * Copyright (c) PyPTO Contributors. + * This program is free software, you can redistribute it and/or modify it under the terms and conditions of + * CANN Open Software License Agreement Version 2.0 (the "License"). + * Please refer to the License for details. You may not use this file except in compliance with the License. + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + * See LICENSE in the root of the software repository for the full text of the License. + * ----------------------------------------------------------------------------------------------------------- + */ + +/** + * @file scope_stats_collector.cpp + * @brief Host-side scope_stats collector. The mgmt-thread + buffer-pool + * machinery lives in profiling_common::BufferPoolManager parameterized + * by ScopeStatsModule (host/scope_stats_collector.h); this file owns the + * per-buffer on_buffer_collected callback (in-memory append), the + * device-side cross-check, and the NDJSON export. + * + * a5 specifics: device↔host transfers go through profiling_copy.h. Each + * ScopeStatsBuffer's contents are pulled from device on demand inside + * ProfilerAlgorithms::process_entry, so on_buffer_collected can read `count` + * and `records[]` directly off the host shadow. + */ + +#include "host/scope_stats_collector.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/memory_barrier.h" +#include "common/unified_log.h" +#include "host/profiling_copy.h" + +ScopeStatsCollector::~ScopeStatsCollector() { stop(); } + +void *ScopeStatsCollector::alloc_single_buffer(size_t size, void **host_ptr_out) { + void *dev_ptr = alloc_cb_(size); + if (dev_ptr == nullptr) { + if (host_ptr_out) *host_ptr_out = nullptr; + return nullptr; + } + + void *host_ptr = nullptr; + if (register_cb_ != nullptr) { + int rc = register_cb_(dev_ptr, size, device_id_, &host_ptr); + if (rc != 0 || host_ptr == nullptr) { + LOG_ERROR("ScopeStatsCollector: register failed: %d", rc); + free_cb_(dev_ptr); + if (host_ptr_out) *host_ptr_out = nullptr; + return nullptr; + } + } else { + host_ptr = std::malloc(size); + if (host_ptr == nullptr) { + LOG_ERROR("ScopeStatsCollector: host shadow alloc failed for %zu bytes", size); + free_cb_(dev_ptr); + if (host_ptr_out) *host_ptr_out = nullptr; + return nullptr; + } + std::memset(host_ptr, 0, size); + profiling_copy_to_device(dev_ptr, host_ptr, size); + } + + if (host_ptr_out) *host_ptr_out = host_ptr; + manager_.register_mapping(dev_ptr, host_ptr); + return dev_ptr; +} + +// --------------------------------------------------------------------------- +// init +// --------------------------------------------------------------------------- + +int ScopeStatsCollector::init( + int num_threads, const ScopeStatsAllocCallback &alloc_cb, ScopeStatsRegisterCallback register_cb, + const ScopeStatsFreeCallback &free_cb, int device_id +) { + if (num_threads <= 0 || alloc_cb == nullptr || free_cb == nullptr) { + LOG_ERROR("ScopeStatsCollector::init: invalid arguments"); + return -1; + } + if (initialized_) { + LOG_ERROR("ScopeStatsCollector already initialized"); + return -1; + } + + num_threads_ = num_threads; + total_collected_ = 0; + records_.clear(); + execution_complete_.store(false, std::memory_order_release); + + // Stash callbacks on the base up-front so alloc_single_buffer sees + // consistent values during init. shm_host_ stays nullptr until the shm + // allocation succeeds — start(tf) gates on shm_host_. + set_memory_context( + alloc_cb, register_cb, free_cb, /*shm_dev=*/nullptr, /*shm_host=*/nullptr, /*shm_size=*/0, device_id + ); + + const int num_instances = 1; + size_t shm_size = calc_scope_stats_shm_size(num_instances); + void *shm_host_local = nullptr; + void *shm_dev_local = alloc_single_buffer(shm_size, &shm_host_local); + if (shm_dev_local == nullptr) { + LOG_ERROR("ScopeStatsCollector: failed to allocate scope_stats shared memory (%zu bytes)", shm_size); + return -1; + } + + std::memset(shm_host_local, 0, shm_size); + ScopeStatsDataHeader *hdr = get_scope_stats_header(shm_host_local); + hdr->num_instances = static_cast(num_instances); + + const size_t buf_size = sizeof(ScopeStatsBuffer); + ScopeStatsBufferState *state = get_scope_stats_buffer_state(shm_host_local, 0); + + for (int b = 0; b < PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE; b++) { + void *host_ptr = nullptr; + void *dev_ptr = alloc_single_buffer(buf_size, &host_ptr); + if (dev_ptr == nullptr) { + LOG_ERROR("ScopeStatsCollector: failed to allocate ScopeStatsBuffer b=%d", b); + return -1; + } + + if (b < PLATFORM_SCOPE_STATS_SLOT_COUNT) { + uint32_t tail = state->free_queue.tail; + assert(tail - state->free_queue.head < PLATFORM_SCOPE_STATS_SLOT_COUNT && "free_queue overflow on init"); + state->free_queue.buffer_ptrs[tail % PLATFORM_SCOPE_STATS_SLOT_COUNT] = reinterpret_cast(dev_ptr); + state->free_queue.tail = tail + 1; + } else { + manager_.push_recycled(0, dev_ptr); + } + } + + // Push the entire initialized shm region (header + BufferState + + // free_queue contents) to device. + profiling_copy_to_device(shm_dev_local, shm_host_local, shm_size); + + initialized_ = true; + shm_dev_ = shm_dev_local; + + // Re-set_memory_context now that the shm region is ready. start(tf) gates + // on shm_host_ being non-null, so this is the moment the collector becomes + // startable. + set_memory_context(alloc_cb, register_cb, free_cb, shm_dev_local, shm_host_local, shm_size, device_id); + + LOG_INFO_V0( + "ScopeStats collector initialized: %d threads, SHM=0x%lx", num_threads, + reinterpret_cast(shm_dev_) + ); + return 0; +} + +// --------------------------------------------------------------------------- +// Record accumulation (in-memory) +// --------------------------------------------------------------------------- + +void ScopeStatsCollector::append_buffer_records(const void *buf_host_ptr) { + const ScopeStatsBuffer *buf = reinterpret_cast(buf_host_ptr); + uint32_t n = buf->count; + if (n > static_cast(PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER)) { + n = static_cast(PLATFORM_SCOPE_STATS_RECORDS_PER_BUFFER); + } + if (n == 0) return; + + std::scoped_lock lock(records_mutex_); + records_.insert(records_.end(), buf->records, buf->records + n); + total_collected_ += n; +} + +void ScopeStatsCollector::on_buffer_collected(const ScopeStatsReadyBufferInfo &info) { + append_buffer_records(info.host_buffer_ptr); +} + +// --------------------------------------------------------------------------- +// reconcile_counters +// --------------------------------------------------------------------------- + +bool ScopeStatsCollector::reconcile_counters() { + if (shm_host_ == nullptr) return false; + + // Pull the latest BufferState (current_buf_ptr, total/dropped counters) + // before the cross-check so it sees post-stop() device state. + if (manager_.shared_mem_dev() != nullptr && shm_size_ > 0) { + profiling_copy_from_device(shm_host_, manager_.shared_mem_dev(), shm_size_); + } + rmb(); + bool clean = true; + + ScopeStatsBufferState *state = scope_stats_state(0); + uint64_t buf_dev = state->current_buf_ptr; + if (buf_dev != 0) { + void *host_ptr = manager_.resolve_host_ptr(reinterpret_cast(buf_dev)); + if (host_ptr != nullptr) { + profiling_copy_from_device(host_ptr, reinterpret_cast(buf_dev), sizeof(ScopeStatsBuffer)); + uint32_t count = reinterpret_cast(host_ptr)->count; + if (count != 0) { + LOG_ERROR( + "scope_stats reconcile: un-flushed buffer (current_buf_ptr=0x%lx, count=%u) — device flush failed", + static_cast(buf_dev), count + ); + clean = false; + } + } + } + + uint64_t total_device = state->total_record_count; + uint64_t dropped_device = state->dropped_record_count; + + if (dropped_device > 0) { + LOG_WARN( + "scope_stats reconcile: %lu records dropped on device side (free_queue empty or ready_queue full). " + "Increase PLATFORM_SCOPE_STATS_BUFFERS_PER_INSTANCE / PLATFORM_SCOPE_STATS_READYQUEUE_SIZE if frequent.", + static_cast(dropped_device) + ); + clean = false; + } + if (total_collected_ + dropped_device != total_device) { + LOG_WARN( + "scope_stats reconcile: record count mismatch (collected=%lu + dropped=%lu != device_total=%lu)", + static_cast(total_collected_), static_cast(dropped_device), + static_cast(total_device) + ); + clean = false; + } else { + LOG_INFO_V0( + "scope_stats reconcile: counts match (collected=%lu, dropped=%lu, device_total=%lu)", + static_cast(total_collected_), static_cast(dropped_device), + static_cast(total_device) + ); + } + + return clean; +} + +// --------------------------------------------------------------------------- +// NDJSON export +// --------------------------------------------------------------------------- + +int ScopeStatsCollector::write_jsonl(const std::string &output_dir) { + if (!initialized_ || shm_host_ == nullptr) return 0; + + std::filesystem::path dir = std::filesystem::path(output_dir) / "scope_stats"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + LOG_WARN("scope_stats: failed to create output dir %s: %s", dir.c_str(), ec.message().c_str()); + } + const std::string path = (dir / "scope_stats.jsonl").string(); + + std::FILE *fp = std::fopen(path.c_str(), "w"); + if (fp == nullptr) { + LOG_ERROR("scope_stats: failed to open %s", path.c_str()); + return -1; + } + + const ScopeStatsDataHeader *hdr = scope_stats_header(); + const ScopeStatsBufferState *state = scope_stats_state(0); + + // Line 1: run metadata. The per-ring maxima (task window / heap capacities) + // and the tensormap capacity are run-constants, so they live here once + // rather than on every record. + std::string task_window_max; + std::string heap_max; + for (int r = 0; r < PTO2_SCOPE_STATS_MAX_RING_DEPTH; r++) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%s%d", r == 0 ? "" : ", ", hdr->task_window_cap[r]); + task_window_max += buf; + std::snprintf(buf, sizeof(buf), "%s%" PRIu64, r == 0 ? "" : ", ", hdr->heap_cap[r]); + heap_max += buf; + } + std::fprintf( + fp, + "{\"version\": 4, \"fatal\": %s, \"dropped\": %u, \"total\": %u, " + "\"task_window_max\": [%s], \"heap_max\": [%s], \"tensormap_max\": %d}\n", + hdr->fatal_latched ? "true" : "false", state->dropped_record_count, state->total_record_count, + task_window_max.c_str(), heap_max.c_str(), hdr->tensormap_cap + ); + + // Serialize every record into one in-memory buffer, then a single fwrite. + // The hot loop is one snprintf per record (not 6 fprintf): stdio format + // parsing + per-call FILE locking on ~6×N calls was the dominant host cost. + std::scoped_lock lock(records_mutex_); + std::string out; + out.reserve(records_.size() * 128); + char line[320]; + for (const ScopeStatsRecord &rec : records_) { + const int site_len = static_cast(strnlen(rec.site_file_basename, sizeof(rec.site_file_basename))); + const char *phase = (rec.phase == SCOPE_STATS_PHASE_BEGIN) ? "begin" : "end"; + int n = std::snprintf( + line, sizeof(line), + "{\"site\": \"%.*s:%d\", \"phase\": \"%s\", \"depth\": %d, \"ring\": %d, " + "\"task_window_start\": %d, \"task_window_end\": %d, " + "\"heap_start\": %" PRIu64 ", \"heap_end\": %" PRIu64 ", " + "\"tensormap\": %d}\n", + site_len, rec.site_file_basename, rec.site_line, phase, rec.depth, rec.ring_id, rec.task_start, + rec.task_end, rec.heap_start, rec.heap_end, rec.tensormap_used + ); + if (n > 0) out.append(line, static_cast(n < static_cast(sizeof(line)) ? n : sizeof(line) - 1)); + } + std::fwrite(out.data(), 1, out.size(), fp); + std::fclose(fp); + + LOG_INFO_V1( + "scope_stats: wrote %lu records (dropped=%u) to %s", static_cast(records_.size()), + state->dropped_record_count, path.c_str() + ); + return 0; +} + +// --------------------------------------------------------------------------- +// finalize +// --------------------------------------------------------------------------- + +void ScopeStatsCollector::finalize(ScopeStatsUnregisterCallback unregister_cb, const ScopeStatsFreeCallback &free_cb) { + if (!initialized_) return; + + stop(); + + { + std::scoped_lock lock(records_mutex_); + records_.clear(); + records_.shrink_to_fit(); + } + + auto release_dev = [&](void *p) { + release_one_buffer(p, unregister_cb, free_cb); + }; + + // Free buffers still parked in the free_queue / current_buf_ptr. Release + // the device pointer only — the paired host shadow stays in dev_to_host_ + // and is freed by clear_mappings() below (single source of truth for + // shadow lifetime, no double-free). + if (shm_host_ != nullptr) { + ScopeStatsBufferState *state = scope_stats_state(0); + release_dev(reinterpret_cast(state->current_buf_ptr)); + state->current_buf_ptr = 0; + rmb(); + uint32_t head = state->free_queue.head; + uint32_t tail = state->free_queue.tail; + uint32_t queued = tail - head; + if (queued > PLATFORM_SCOPE_STATS_SLOT_COUNT) queued = PLATFORM_SCOPE_STATS_SLOT_COUNT; + for (uint32_t i = 0; i < queued; i++) { + uint32_t slot = (head + i) % PLATFORM_SCOPE_STATS_SLOT_COUNT; + release_dev(reinterpret_cast(state->free_queue.buffer_ptrs[slot])); + state->free_queue.buffer_ptrs[slot] = 0; + } + state->free_queue.head = tail; + } + + // Release framework-owned buffers (recycled pool, ready_queue, + // done_queue). release_owned_buffers also frees their host shadows. + manager_.release_owned_buffers([&](void *p) { + release_dev(p); + }); + + // Free shared header region (device only — shadow stays in dev_to_host_ + // until clear_mappings). + if (shm_dev_ != nullptr) { + release_dev(shm_dev_); + shm_dev_ = nullptr; + } + + // Free remaining host shadows (per-state buffers + shm region). + manager_.clear_mappings(); + + initialized_ = false; + num_threads_ = 0; + total_collected_ = 0; + clear_memory_context(); + LOG_INFO_V0("ScopeStats collector finalized"); +} diff --git a/src/a5/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp b/src/a5/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp index d7fd61c396..6d0510a77a 100644 --- a/src/a5/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp +++ b/src/a5/runtime/tensormap_and_ringbuffer/aicpu/aicpu_executor.cpp @@ -36,6 +36,7 @@ // Performance profiling headers #include "aicpu/l2_perf_collector_aicpu.h" +#include "aicpu/scope_stats_collector_aicpu.h" #include "aicpu/tensor_dump_aicpu.h" #include "common/l2_perf_profiling.h" #include "common/unified_log.h" @@ -523,6 +524,14 @@ int32_t AicpuExecutor::run(Runtime *runtime) { #if PTO2_PROFILING rt->orchestrator.l2_perf_level = get_l2_perf_level(); + { + auto &orch = rt->orchestrator; + for (int r = 0; r < PTO2_MAX_RING_DEPTH; r++) { + auto &alloc = orch.rings[r].task_allocator; + scope_stats_set_ring_capacity(r, alloc.window_size(), alloc.heap_capacity()); + } + scope_stats_set_tensormap_capacity(orch.tensor_map.pool_capacity()); + } #endif // With multi-ring, slot_states are per-ring inside the scheduler. @@ -543,6 +552,11 @@ int32_t AicpuExecutor::run(Runtime *runtime) { if (get_l2_perf_level() >= L2PerfLevel::ORCH_PHASES) { l2_perf_aicpu_set_orch_thread_idx(thread_idx); } + // scope_stats streams scope_end records off the orchestrator thread: + // record the per-thread ready_queue index. No-op (writer shared + // state null) when scope_stats is disabled; the current buffer is + // popped lazily on the first scope_end append. + scope_stats_aicpu_set_orch_thread_idx(thread_idx); #endif #if PTO2_PROFILING @@ -555,6 +569,11 @@ int32_t AicpuExecutor::run(Runtime *runtime) { rt_scope_begin(rt); (*p_func)(*orch_args_cached_); rt_scope_end(rt); +#if PTO2_PROFILING + // Push the partially-filled scope_stats buffer so the host gets the + // final scope_end records. Idempotent / no-op when disabled. + scope_stats_aicpu_flush_buffers(); +#endif #if PTO2_PROFILING uint64_t orch_cycle_end = get_sys_cnt_aicpu(); (void)orch_cycle_end; diff --git a/src/a5/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h b/src/a5/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h index f717666189..cf448bba18 100644 --- a/src/a5/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h +++ b/src/a5/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h @@ -123,6 +123,11 @@ typedef struct PTO2RuntimeOps { ); TaskOutputTensors (*alloc_tensors)(PTO2Runtime *rt, const Arg &args); TaskOutputTensors (*submit_dummy_task)(PTO2Runtime *rt, const Arg &args); + + // Stash the call-site of the next PTO2ScopeGuard so the [ScopeStats] + // collector can log it. Always present to keep ops-table layout stable + // across PTO2_PROFILING settings; set to nullptr at PTO2_PROFILING=0. + void (*scope_set_site)(const char *file, int line); } PTO2RuntimeOps; /** @@ -361,10 +366,13 @@ static inline void set_tensor_data(const Tensor &tensor, uint32_t ndims, const u */ class PTO2ScopeGuard { public: - explicit PTO2ScopeGuard(PTO2ScopeMode mode = PTO2ScopeMode::AUTO) : + explicit PTO2ScopeGuard( + PTO2ScopeMode mode = PTO2ScopeMode::AUTO, const char *file = __builtin_FILE(), int line = __builtin_LINE() + ) : rt_(current_runtime()) { if (!rt_->ops->is_fatal(rt_)) { rt_->pending_scope_mode = mode; + if (rt_->ops->scope_set_site) rt_->ops->scope_set_site(file, line); rt_->ops->scope_begin(rt_); } } diff --git a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp index c937fd986a..db06248e67 100644 --- a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp +++ b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp @@ -37,6 +37,16 @@ extern "C" void set_dump_tensor_selective_mode(bool enable); extern "C" void set_dump_tensor_task_mask(uint64_t task_id, uint64_t mask); +#if PTO2_PROFILING +#include "aicpu/scope_stats_collector_aicpu.h" + +// Scope_stats enable gate, queried via the same predicate idiom as +// is_dep_gen_enabled. The AICPU collector links the strong definition; host +// builds fall back to this weak `false`. Gating here still skips the +// cross-agent occupancy reads that feed the sample when scope_stats is disabled. +extern "C" __attribute__((weak, visibility("hidden"))) bool is_scope_stats_enabled() { return false; } +#endif + // ============================================================================= // Orchestrator Profiling (compile-time toggle) // ============================================================================= @@ -146,6 +156,12 @@ static int32_t orch_mark_fatal(PTO2OrchestratorState *orch, int32_t error_code) static void orch_report_fatal_v(PTO2OrchestratorState *orch, int32_t error_code, const char *func, const char *fmt, va_list args) { int32_t latched_code = orch_mark_fatal(orch, error_code); +#if PTO2_PROFILING + // Flush the active scope's peaks before the FATAL line so the diagnostic + // context lands adjacent in the log. Latched internally — safe to call + // from every cascaded report_fatal. + scope_stats_on_fatal(); +#endif if (fmt == nullptr || fmt[0] == '\0') { if (latched_code != PTO2_ERROR_NONE && latched_code != error_code) { @@ -396,6 +412,18 @@ void PTO2OrchestratorState::begin_scope(PTO2ScopeMode mode) { if (mode == PTO2ScopeMode::MANUAL && !already_in_manual_scope) { orch->manual_begin_depth = orch->scope_stack_top; } +#if PTO2_PROFILING + // Gate via is_scope_stats_enabled() (weak-false in host builds) BEFORE the + // collector call: when disabled we pay nothing. Sample the current ring's + // task/heap start-end and tensormap usage at the scope boundary. + if (is_scope_stats_enabled()) { + auto &alloc = orch->rings[orch->current_ring_id()].task_allocator; + scope_stats_begin( + orch->current_ring_id(), alloc.task_tail(), alloc.task_head(), alloc.heap_tail(), alloc.heap_top(), + orch->tensor_map.current_used() + ); + } +#endif } void PTO2OrchestratorState::end_scope() { @@ -405,6 +433,21 @@ void PTO2OrchestratorState::end_scope() { } assert(orch->scope_stack_top >= 0 && "Scope stack underflow"); + // Snapshot the ring start/end BEFORE the orchestrator drains pending tasks + // via scheduler->on_scope_end, so the end record reflects the scope's + // occupancy at close, not the residual after teardown. +#if PTO2_PROFILING + // Gate via is_scope_stats_enabled() (see begin_scope). One collector call + // emits the end-boundary record and tears down bookkeeping. + if (is_scope_stats_enabled()) { + auto &alloc = orch->rings[orch->current_ring_id()].task_allocator; + scope_stats_end( + orch->current_ring_id(), alloc.task_tail(), alloc.task_head(), alloc.heap_tail(), alloc.heap_top(), + orch->tensor_map.current_used() + ); + } +#endif + #if PTO2_ORCH_PROFILING uint64_t _se0 = get_sys_cnt_aicpu(); #endif diff --git a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h index abd2a75105..758d594294 100644 --- a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h +++ b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h @@ -178,6 +178,11 @@ class PTO2TaskAllocator { return local_task_id_ - last_alive; } + // Task ring start/end: tail = oldest live task (last_task_alive), head = + // next task id to allocate. head - tail == active_count(). + int32_t task_tail() const { return last_alive_ptr_->load(std::memory_order_acquire); } + int32_t task_head() const { return local_task_id_; } + int32_t window_size() const { return window_size_; } uint64_t heap_available() const { @@ -191,8 +196,16 @@ class PTO2TaskAllocator { } uint64_t heap_top() const { return heap_top_; } + // Heap ring start: reclaim pointer (oldest byte still live). heap_top() is + // the end (next allocation). heap_top - heap_tail == heap_used_bytes(). + uint64_t heap_tail() const { return heap_tail_; } uint64_t heap_capacity() const { return heap_size_; } + uint64_t heap_used_bytes() const { + if (heap_size_ == 0) return 0; + return (heap_top_ + heap_size_ - heap_tail_) % heap_size_; + } + private: // --- Task Ring --- PTO2TaskDescriptor *descriptors_ = nullptr; diff --git a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp index f39bac3650..8aee802b1c 100644 --- a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp +++ b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp @@ -28,6 +28,9 @@ #include "aicpu/device_time.h" #include "common/unified_log.h" +#if PTO2_PROFILING +#include "aicpu/scope_stats_collector_aicpu.h" +#endif // Weak fallback for HOST .so builds (never called, but satisfies linker). // The AICPU build links the strong symbol from platform/.../device_time.cpp. @@ -231,6 +234,14 @@ void set_tensor_data(PTO2Runtime *rt, const Tensor &tensor, uint32_t ndims, cons memcpy(ptr, &value, elem_size); } +// Ops-table entry that hands the call-site captured by PTO2ScopeGuard to the +// [ScopeStats] collector. The slot is always present in the struct to keep +// the layout stable; at PTO2_PROFILING=0 we fill nullptr so the orchestration +// .so's null-check skips it. +#if PTO2_PROFILING +static void scope_set_site_impl(const char *file, int line) { scope_stats_set_pending_site(file, line); } +#endif + static const PTO2RuntimeOps s_runtime_ops = { .submit_task = submit_task_impl, .scope_begin = rt_scope_begin, @@ -246,6 +257,11 @@ static const PTO2RuntimeOps s_runtime_ops = { .set_tensor_data = set_tensor_data, .alloc_tensors = alloc_tensors_impl, .submit_dummy_task = submit_dummy_task_impl, +#if PTO2_PROFILING + .scope_set_site = scope_set_site_impl, +#else + .scope_set_site = nullptr, +#endif }; // ============================================================================= diff --git a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h index 460624e69e..86db7e99a8 100644 --- a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h +++ b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.h @@ -89,6 +89,11 @@ struct PTO2RuntimeOps { ); TaskOutputTensors (*alloc_tensors)(PTO2Runtime *rt, const Arg &args); TaskOutputTensors (*submit_dummy_task)(PTO2Runtime *rt, const Arg &args); + + // Stash the call-site captured by PTO2ScopeGuard into the [ScopeStats] + // collector. Always present to keep ops-table layout stable across + // PTO2_PROFILING settings; set to nullptr at PTO2_PROFILING=0. + void (*scope_set_site)(const char *file, int line); }; /** diff --git a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h index b63f20676c..684708e2d6 100644 --- a/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h +++ b/src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_tensormap.h @@ -371,6 +371,13 @@ struct PTO2TensorMap { return task_local_id & (task_window_sizes[ring_id] - 1); } + // Accessors read by scope_stats_collector. Declared unconditionally so the + // collector .cpp compiles at PTO2_PROFILING=0 (collector is unconditional — + // setter symbols must export for host dlsym; the probe call sites that use + // these accessors stay gated by PTO2_PROFILING). + int32_t current_used() const { return next_entry_idx - free_num; } + int32_t pool_capacity() const { return pool_size; } + // new_entry only allocates memory, does not assign attributes PTO2TensorMapEntry *new_entry() { if (free_num > 0) { diff --git a/src/common/task_interface/call_config.h b/src/common/task_interface/call_config.h index b8afbd32b8..58ca0076b5 100644 --- a/src/common/task_interface/call_config.h +++ b/src/common/task_interface/call_config.h @@ -11,9 +11,13 @@ /** * CallConfig — per-NEXT_LEVEL-task config. Carries execution knobs - * (block_dim, aicpu_thread_num) plus the four parallel diagnostics + * (block_dim, aicpu_thread_num) plus the five parallel diagnostics * sub-features under the profiling umbrella: `enable_l2_swimlane` (swimlane), - * `enable_dump_tensor`, `enable_pmu`, and `enable_dep_gen`. + * `enable_dump_tensor`, `enable_pmu`, `enable_dep_gen`, and + * `enable_scope_stats`. All five require `output_prefix` because they each + * write a sibling artifact into that directory + * (`l2_perf_records.json` / `tensor_dump/` / `pmu.csv` / `deps.json` / + * `scope_stats.json`). * * `block_dim == 0` is a sentinel for "auto" — DeviceRunner resolves it at * run() time to the max block_dim the AICore stream allows @@ -32,9 +36,9 @@ * * `output_prefix` is a NUL-terminated directory path under which all * diagnostic artifacts (l2_perf_records.json / tensor_dump/ / pmu.csv / - * submit_trace.bin) are written. The caller is responsible for filling it - * whenever any diagnostic flag is enabled — `validate()` enforces this - * contract at every submit/run entry point so the runtime never has to + * deps.json / scope_stats.json) are written. The caller is responsible for + * filling it whenever any diagnostic flag is enabled — `validate()` enforces + * this contract at every submit/run entry point so the runtime never has to * invent a path. */ @@ -52,10 +56,12 @@ struct CallConfig { int32_t enable_dump_tensor = 0; int32_t enable_pmu = 0; // 0 = disabled; >0 = enabled, value selects event type int32_t enable_dep_gen = 0; + int32_t enable_scope_stats = 0; // writes /scope_stats.json char output_prefix[1024] = {}; bool diagnostics_any() const noexcept { - return enable_l2_swimlane != 0 || enable_dump_tensor != 0 || enable_pmu != 0 || enable_dep_gen != 0; + return enable_l2_swimlane != 0 || enable_dump_tensor != 0 || enable_pmu != 0 || enable_dep_gen != 0 || + enable_scope_stats != 0; } bool output_prefix_set() const noexcept { return output_prefix[0] != '\0'; } @@ -67,10 +73,11 @@ struct CallConfig { if (diagnostics_any() && !output_prefix_set()) { throw std::invalid_argument( "CallConfig: output_prefix must be set whenever any of " - "enable_l2_swimlane / enable_dump_tensor / enable_pmu / enable_dep_gen is enabled" + "enable_l2_swimlane / enable_dump_tensor / enable_pmu / enable_dep_gen / " + "enable_scope_stats is enabled" ); } } }; #pragma pack(pop) -static_assert(sizeof(CallConfig) == 6 * sizeof(int32_t) + 1024, "CallConfig wire layout drift"); +static_assert(sizeof(CallConfig) == 7 * sizeof(int32_t) + 1024, "CallConfig wire layout drift"); diff --git a/src/common/worker/chip_worker.cpp b/src/common/worker/chip_worker.cpp index 2ee392ab1a..a3dd90eb71 100644 --- a/src/common/worker/chip_worker.cpp +++ b/src/common/worker/chip_worker.cpp @@ -323,7 +323,8 @@ RunTiming ChipWorker::run(int32_t callable_id, const ChipStorageTaskArgs *args, PtoRunTiming timing{0, 0}; int rc = run_prepared_fn_( device_ctx_, rt, callable_id, args, config.block_dim, config.aicpu_thread_num, config.enable_l2_swimlane, - config.enable_dump_tensor, config.enable_pmu, config.enable_dep_gen, config.output_prefix, &timing + config.enable_dump_tensor, config.enable_pmu, config.enable_dep_gen, config.enable_scope_stats, + config.output_prefix, &timing ); if (rc != 0) { throw std::runtime_error("run_prepared failed with code " + std::to_string(rc)); diff --git a/src/common/worker/chip_worker.h b/src/common/worker/chip_worker.h index e1632eb2a8..e4fb958c5a 100644 --- a/src/common/worker/chip_worker.h +++ b/src/common/worker/chip_worker.h @@ -143,7 +143,7 @@ class ChipWorker { int (*)(void *, int, const uint8_t *, size_t, const uint8_t *, size_t, const uint8_t *, size_t); using PrepareCallableFn = int (*)(void *, int32_t, const void *); using RunPreparedFn = - int (*)(void *, void *, int32_t, const void *, int, int, int, int, int, int, const char *, PtoRunTiming *); + int (*)(void *, void *, int32_t, const void *, int, int, int, int, int, int, int, const char *, PtoRunTiming *); using UnregisterCallableFn = int (*)(void *, int32_t); using GetAicpuDlopenCountFn = size_t (*)(void *); using FinalizeDeviceFn = int (*)(void *); diff --git a/src/common/worker/pto_runtime_c_api.h b/src/common/worker/pto_runtime_c_api.h index c4f6b7adf6..e3fa4eb684 100644 --- a/src/common/worker/pto_runtime_c_api.h +++ b/src/common/worker/pto_runtime_c_api.h @@ -194,7 +194,7 @@ int prepare_callable(DeviceContextHandle ctx, int32_t callable_id, const void *c int run_prepared( DeviceContextHandle ctx, RuntimeHandle runtime, int32_t callable_id, const void *args, int block_dim, int aicpu_thread_num, int enable_l2_swimlane, int enable_dump_tensor, int enable_pmu, int enable_dep_gen, - const char *output_prefix, PtoRunTiming *out_timing + int enable_scope_stats, const char *output_prefix, PtoRunTiming *out_timing ); /** diff --git a/tests/st/a2a3/host_build_graph/prepared_callable/test_prepared_callable.py b/tests/st/a2a3/host_build_graph/prepared_callable/test_prepared_callable.py index 9a43d54ec3..39cfd778cc 100644 --- a/tests/st/a2a3/host_build_graph/prepared_callable/test_prepared_callable.py +++ b/tests/st/a2a3/host_build_graph/prepared_callable/test_prepared_callable.py @@ -96,7 +96,7 @@ def compute_golden(self, args, params): a, b = args.a, args.b args.f[:] = (a + b + 1) * (a + b + 2) - def _run_and_validate_l2( + def _run_and_validate_l2( # noqa: PLR0913 self, worker, callable_obj, @@ -107,6 +107,7 @@ def _run_and_validate_l2( enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, output_prefix="", ): params = case.get("params", {}) diff --git a/tests/st/a2a3/tensormap_and_ringbuffer/dfx/scope_stats/test_scope_stats.py b/tests/st/a2a3/tensormap_and_ringbuffer/dfx/scope_stats/test_scope_stats.py new file mode 100644 index 0000000000..f07861aae4 --- /dev/null +++ b/tests/st/a2a3/tensormap_and_ringbuffer/dfx/scope_stats/test_scope_stats.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# Copyright (c) PyPTO Contributors. +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of +# CANN Open Software License Agreement Version 2.0 (the "License"). +# Please refer to the License for details. You may not use this file except in compliance with the License. +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE in the root of the software repository for the full text of the License. +# ----------------------------------------------------------------------------------------------------------- +"""scope_stats smoke — capture pipeline produces a usable ``scope_stats.jsonl``. + +Re-uses ``vector_example`` (outer executor scope + one inner ``PTO2_SCOPE()``). +With ``--enable-scope-stats`` the platform collector +(``scope_stats_collector_aicpu.h``) appends one record per scope boundary +(begin and end) into a pooled buffer that streams to the host, which writes +NDJSON. Enabling the flag is the entire user surface for the new API — the +runtime takes care of the ``set_pending_site`` / ``scope_stats_begin`` / +``scope_stats_end`` calls. Schema lives in ``docs/dfx/scope-stats.md`` §5. + +Output (``scope_stats.jsonl``): line 1 is run metadata +(``{"version":4,"fatal":bool,"dropped":uint,"total":uint}``); each subsequent +line is one scope-boundary record carrying the ring task/heap start-end. +""" + +import json + +import torch +from simpler.task_interface import ArgDirection as D + +from simpler_setup import SceneTestCase, TaskArgsBuilder, Tensor, scene_test +from simpler_setup.scene_test import _outputs_dir, _sanitize_for_filename + +KERNELS_BASE = "../../../../../../examples/a2a3/tensormap_and_ringbuffer/vector_example/kernels" +_REQUIRED_RECORD_FIELDS = { + "site", + "phase", + "depth", + "ring", + "task_window_start", + "task_window_end", + "heap_start", + "heap_end", + "tensormap", +} + + +@scene_test(level=2, runtime="tensormap_and_ringbuffer") +class TestScopeStats(SceneTestCase): + """Vector example with --enable-scope-stats, then assert scope_stats.jsonl.""" + + CALLABLE = { + "orchestration": { + "source": f"{KERNELS_BASE}/orchestration/example_orchestration.cpp", + "function_name": "aicpu_orchestration_entry", + "signature": [D.IN, D.IN, D.OUT], + }, + "incores": [ + { + "func_id": 0, + "source": f"{KERNELS_BASE}/aiv/kernel_add.cpp", + "core_type": "aiv", + "signature": [D.IN, D.IN, D.OUT], + }, + { + "func_id": 1, + "source": f"{KERNELS_BASE}/aiv/kernel_add_scalar.cpp", + "core_type": "aiv", + "signature": [D.IN, D.OUT], + }, + { + "func_id": 2, + "source": f"{KERNELS_BASE}/aiv/kernel_mul.cpp", + "core_type": "aiv", + "signature": [D.IN, D.IN, D.OUT], + }, + ], + } + + CASES = [ + { + "name": "default", + "platforms": ["a2a3sim", "a2a3"], + "config": {"aicpu_thread_num": 4, "block_dim": 3}, + "params": {}, + }, + ] + + def generate_args(self, params): + SIZE = 128 * 128 + return TaskArgsBuilder( + Tensor("a", torch.full((SIZE,), 2.0, dtype=torch.float32)), + Tensor("b", torch.full((SIZE,), 3.0, dtype=torch.float32)), + Tensor("f", torch.zeros(SIZE, dtype=torch.float32)), + ) + + def compute_golden(self, args, params): + args.f[:] = (args.a + args.b + 1) * (args.a + args.b + 2) + (args.a + args.b) + + def test_run(self, st_platform, st_worker, request): + super().test_run(st_platform, st_worker, request) + if not request.config.getoption("--enable-scope-stats", default=False): + return + for case in self.CASES: + if st_platform in case["platforms"]: + self._validate_scope_stats_artifact(case) + + def _validate_scope_stats_artifact(self, case): + safe_label = _sanitize_for_filename(f"TestScopeStats_{case['name']}") + matches = sorted(_outputs_dir().glob(f"{safe_label}_*"), key=lambda p: p.stat().st_mtime) + assert matches, ( + f"no output directory under {_outputs_dir()} matching {safe_label}_* — " + f"--enable-scope-stats was on but the run produced no per-case output dir" + ) + path = matches[-1] / "scope_stats" / "scope_stats.jsonl" + assert path.exists(), f"scope_stats.jsonl missing under {matches[-1]} — collector finalize failed?" + lines = [ln for ln in path.read_text().splitlines() if ln.strip()] + assert lines, f"scope_stats.jsonl empty under {matches[-1]}" + meta = json.loads(lines[0]) + assert meta.get("version") == 4, f"unexpected schema version: {meta!r}" + assert meta.get("fatal") is False, f"run latched fatal: {meta!r}" + assert meta.get("dropped", 0) == 0, f"records dropped on device: {meta!r}" + records = [json.loads(ln) for ln in lines[1:]] + # outer (executor) + inner PTO2_SCOPE, each emitting a begin and an end + # record → ≥4 records. + assert len(records) >= 4, f"expected ≥4 begin/end records, got {records!r}" + for rec in records: + assert _REQUIRED_RECORD_FIELDS <= rec.keys(), f"record missing fields: {rec!r}" + + +if __name__ == "__main__": + SceneTestCase.run_module(__name__) diff --git a/tests/st/a2a3/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py b/tests/st/a2a3/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py index b40ffcde4f..fbd0e6d09d 100644 --- a/tests/st/a2a3/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py +++ b/tests/st/a2a3/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py @@ -97,7 +97,7 @@ def generate_args(self, params): def compute_golden(self, args, params): args.f[:] = (args.a + args.b + 1) * (args.a + args.b + 2) + (args.a + args.b) - def _run_and_validate_l2( + def _run_and_validate_l2( # noqa: PLR0913 self, worker, callable_obj, @@ -108,6 +108,7 @@ def _run_and_validate_l2( enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, output_prefix="", ): params = case.get("params", {}) diff --git a/tests/st/a5/host_build_graph/prepared_callable/test_prepared_callable.py b/tests/st/a5/host_build_graph/prepared_callable/test_prepared_callable.py index 2a0158dc76..1b0810714b 100644 --- a/tests/st/a5/host_build_graph/prepared_callable/test_prepared_callable.py +++ b/tests/st/a5/host_build_graph/prepared_callable/test_prepared_callable.py @@ -85,7 +85,7 @@ def compute_golden(self, args, params): # dump_tensor orchestration computes f = (a + b) + 1 args.f[:] = (args.a + args.b) + 1 - def _run_and_validate_l2( + def _run_and_validate_l2( # noqa: PLR0913 self, worker, callable_obj, @@ -96,6 +96,7 @@ def _run_and_validate_l2( enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, output_prefix="", ): params = case.get("params", {}) diff --git a/tests/st/a5/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py b/tests/st/a5/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py index dbede6ac7e..a2bf0b2a17 100644 --- a/tests/st/a5/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py +++ b/tests/st/a5/tensormap_and_ringbuffer/prepared_callable/test_prepared_callable.py @@ -98,7 +98,7 @@ def generate_args(self, params): def compute_golden(self, args, params): args.f[:] = (args.a + args.b + 1) * (args.a + args.b + 2) + (args.a + args.b) - def _run_and_validate_l2( + def _run_and_validate_l2( # noqa: PLR0913 self, worker, callable_obj, @@ -109,6 +109,7 @@ def _run_and_validate_l2( enable_dump_tensor=False, enable_pmu=0, enable_dep_gen=False, + enable_scope_stats=False, output_prefix="", ): params = case.get("params", {}) diff --git a/tests/ut/cpp/CMakeLists.txt b/tests/ut/cpp/CMakeLists.txt index d638e84730..ab0dbc729c 100644 --- a/tests/ut/cpp/CMakeLists.txt +++ b/tests/ut/cpp/CMakeLists.txt @@ -92,6 +92,7 @@ add_library(a2a3_rt_objs OBJECT ${A2A3_RUNTIME_DIR}/scheduler/pto_scheduler.cpp ${A2A3_RUNTIME_DIR}/shared/pto_tensormap.cpp ${A2A3_RUNTIME_DIR}/shared/pto_runtime2_init.cpp + ${CMAKE_SOURCE_DIR}/../../../src/a2a3/platform/src/aicpu/scope_stats_collector_aicpu.cpp ${CMAKE_SOURCE_DIR}/stubs/test_stubs.cpp ) target_include_directories(a2a3_rt_objs PUBLIC diff --git a/tools/scope_stats_plot.py b/tools/scope_stats_plot.py new file mode 100644 index 0000000000..547df03101 --- /dev/null +++ b/tools/scope_stats_plot.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +# Copyright (c) PyPTO Contributors. +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of +# CANN Open Software License Agreement Version 2.0 (the "License"). +# Please refer to the License for details. You may not use this file except in compliance with the License. +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE in the root of the software repository for the full text of the License. +# ----------------------------------------------------------------------------------------------------------- +"""Post-process a scope_stats.jsonl into a single self-contained HTML report. + +Groups records by ``ring``; within a ring, each ``begin`` is paired with the +first later ``end`` that shares the same ``site`` (cpp file:line). For each +resource (task_window, heap, tensormap) three deltas are computed and drawn as +inline-SVG line charts: + + 1. scope_high_water = end._head - begin._tail + 2. real_occupancy = end._head - end._tail + 3. scope_alloc = end._head - begin._head + +Naming: the ``begin.`` / ``end.`` prefix is the *scope's* entry/exit sample; +``_head`` / ``_tail`` are the *ring's* pointers (head = allocation frontier +heap_top, tail = released boundary heap_tail). The JSON fields are still named +``_start`` (tail) and ``_end`` (head). + +tensormap is reported as a single in-use value (no head/tail), so it +degenerates to a single real_occupancy curve. + +Output: one self-contained ``/scope_stats.html`` with all rings and +resources. The SVG is inlined (no matplotlib, no external JS/CDN) so the file +opens offline and is trivial to share. Per-resource max capacities are listed +once in the report header (no per-chart capacity line); the header also carries +a legend explaining what each metric means. +""" + +from __future__ import annotations + +import argparse +import json +import logging +from collections import defaultdict +from pathlib import Path + +logger = logging.getLogger(__name__) + +# resource name -> (y-axis unit label, display divisor applied after wrap) +RESOURCES = { + "task_window": ("slots", 1), + "heap": ("MiB", 1024 * 1024), + "tensormap": ("entries", 1), +} + + +def _metrics(resource: str): + # tensormap is reported as a single in-use value (no start/end), so the + # three start/end deltas degenerate to one occupancy curve. + if resource == "tensormap": + return [("real_occupancy (end.tensormap)", lambda b, en: en["tensormap"])] + # JSON fields are _start / _end, but they are the ring's tail / + # head pointers — not the scope's begin/end. Labels use head/tail to make + # the "scope.begin vs ring.tail" distinction unambiguous. + s, e = f"{resource}_start", f"{resource}_end" + head, tail = f"{resource}_head", f"{resource}_tail" + return [ + (f"scope_high_water (end.{head} - begin.{tail})", lambda b, en: en[e] - b[s]), + (f"real_occupancy (end.{head} - end.{tail})", lambda b, en: en[e] - en[s]), + (f"scope_alloc (end.{head} - begin.{head})", lambda b, en: en[e] - b[e]), + ] + + +def _load(jsonl_path: Path) -> tuple[dict, list[dict]]: + lines = jsonl_path.read_text().splitlines() + # Line 1 is run metadata; the rest are per-scope records. + meta = json.loads(lines[0]) if lines else {} + records = [json.loads(line) for line in lines[1:] if line.strip()] + return meta, records + + +def _resource_size(meta: dict, resource: str, ring: int) -> int | None: + """Ring capacity used for wrap-around correction. heap/task_window are + per-ring ring buffers (``*_max`` is a list); tensormap is a scalar cap.""" + cap = meta.get(f"{resource}_max") + if isinstance(cap, list): + return cap[ring] if 0 <= ring < len(cap) else None + return cap + + +def _wrap(delta: int, size: int | None) -> int: + """Ring-buffer occupancy is non-negative: a negative delta means the + allocator wrapped past the buffer end (start > end), so fold it back by + one buffer length — mirrors ``(end + size - start) % size``.""" + if size and delta < 0: + return delta + size + return delta + + +def _pair_by_ring(records: list[dict]) -> dict[int, list[tuple[dict, dict]]]: + """Return ring -> [(begin, end), ...]. A begin pairs with the first later + end of the same site within that ring (FIFO per site).""" + pending: dict[int, dict[str, list[dict]]] = defaultdict(lambda: defaultdict(list)) + pairs: dict[int, list[tuple[dict, dict]]] = defaultdict(list) + for rec in records: + ring, site, phase = rec["ring"], rec["site"], rec["phase"] + if phase == "begin": + pending[ring][site].append(rec) + elif phase == "end": + queue = pending[ring].get(site) + if queue: + pairs[ring].append((queue.pop(0), rec)) + else: + logger.warning("ring %s site %s: end without matching begin", ring, site) + return pairs + + +_SVG_W = 720 +_SVG_H = 160 +_PAD_L = 56 +_PAD_R = 16 +_PAD_T = 12 +_PAD_B = 28 + + +def _esc(text: str) -> str: + """Minimal XML escaping for text/attribute content.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +def _svg_chart(label: str, series: list[float], sites: list[str], unit: str, fmt: str) -> str: + """One inline-SVG line chart for a single metric series. The y-range is + derived from the data (0..peak); per-resource max capacities are shown once + in the report header instead of as a per-chart reference line.""" + n = len(series) + peak = max(series) if series else 0.0 + y_max = peak if peak > 0 else 1.0 + plot_w = _SVG_W - _PAD_L - _PAD_R + plot_h = _SVG_H - _PAD_T - _PAD_B + + def sx(i: int) -> float: + return _PAD_L + (plot_w * i / (n - 1) if n > 1 else plot_w / 2) + + def sy(v: float) -> float: + return _PAD_T + plot_h * (1 - v / y_max) + + parts = [f''] + # Axes (left + bottom) and y gridlines at 0 / y_max. + parts.append(f'') + parts.append( + f'' + ) + ymax_label = f"{fmt.format(y_max)} {_esc(unit)}" + parts.append(f'{ymax_label}') + parts.append(f'0') + parts.append(f'scope (begin/end order)') + peak_label = f"peak {fmt.format(peak)} {_esc(unit)}" + parts.append(f'{peak_label}') + + if n > 0: + pts = " ".join(f"{sx(i):.1f},{sy(v):.1f}" for i, v in enumerate(series)) + parts.append(f'') + for i, v in enumerate(series): + title = f"#{i} {fmt.format(v)} {unit} — {sites[i]}" + parts.append( + f'{_esc(title)}' + ) + + parts.append("") + return f'
{_esc(label)}
{"".join(parts)}
' + + +def _ring_section(ring: int, pairs: list[tuple[dict, dict]], resource: str, size: int | None) -> str: + unit, divisor = RESOURCES[resource] + fmt = "{:.1f}" if divisor > 1 else "{:.0f}" + sites = [e.get("site", "?") for _, e in pairs] + charts = [] + for label, fn in _metrics(resource): + series = [_wrap(fn(b, e), size) / divisor for b, e in pairs] + charts.append(_svg_chart(label, series, sites, unit, fmt)) + return ( + f'

ring {ring} — {_esc(resource)} ({len(pairs)} pairs)

{"".join(charts)}
' + ) + + +_STYLE = """ +body{font-family:system-ui,Arial,sans-serif;margin:24px;color:#222} +h1{font-size:20px} h2{font-size:16px;margin-top:28px;border-bottom:1px solid #ddd;padding-bottom:4px} +h3{font-size:14px;color:#555;margin:16px 0 4px} +.metric{margin:0 0 8px;display:inline-block;width:740px;vertical-align:top} +figcaption{font-size:12px;color:#333;margin-bottom:2px} +.chart{width:100%;height:auto;border:1px solid #eee;background:#fff} +.axis{stroke:#999;stroke-width:1} +.line{fill:none;stroke:#2563eb;stroke-width:1.4} +.dot{fill:#2563eb} +.ylab,.alab{font-size:10px;fill:#666} +.intro{background:#f7f7f9;border:1px solid #e2e2e8;border-radius:6px; +padding:12px 16px;margin:8px 0 20px;max-width:760px} +.intro p{margin:4px 0;font-size:13px;line-height:1.5} +.intro code{background:#ececf1;padding:1px 4px;border-radius:3px;font-size:12px} +.intro .name{font-weight:600;color:#1d4ed8} +.intro .caps{margin-top:8px;padding-top:8px;border-top:1px solid #e2e2e8} +.intro .caps b{color:#dc2626} +""" + + +def _capacity_summary(meta: dict) -> str: + """One-line-per-resource max-capacity overview shown in the report header.""" + rows = [] + for resource, (unit, divisor) in RESOURCES.items(): + cap = meta.get(f"{resource}_max") + fmt = "{:.1f}" if divisor > 1 else "{:.0f}" + if isinstance(cap, list): + vals = sorted(set(cap)) + if len(vals) == 1: + txt = f"{fmt.format(vals[0] / divisor)} {unit} (all rings)" + else: + txt = ", ".join(f"ring{i}={fmt.format(v / divisor)}" for i, v in enumerate(cap)) + f" {unit}" + elif cap is not None: + txt = f"{fmt.format(cap / divisor)} {unit}" + else: + txt = "n/a" + rows.append(f"

{_esc(resource)} max capacity: {_esc(txt)}

") + return f"
{''.join(rows)}
" + + +def _intro(meta: dict) -> str: + return f""" +
+

Each scope samples resource usage once on entry (begin) and once +on exit (end). Naming: the begin. / end. +prefix is the scope's entry/exit sample; the _head / +_tail suffix is the ring's pointer +(head = allocation frontier heap_top, tail = released boundary heap_tail). +For ring-buffer resources, occupancy uses the wrap formula +(head + cap - tail) % cap (tail > head means it +wrapped past the end). The y-range is 0..peak; per-resource max capacities are +listed below instead of as a per-chart line.

+

scope_high_water = end.head - begin.tail — +the highest occupancy reached during the scope's lifetime: residual not yet +released on entry + what this scope added; the actual peak it held.

+

real_occupancy = end.head - end.tail — +what is genuinely still occupied at the exit instant (the ring occupancy that +heap_used_bytes() returns); the live level on leaving.

+

scope_alloc = end.head - begin.head — +how far the allocation frontier (head) advanced, i.e. this scope's own net +(unreleased) allocation.

+

tensormap reports a single in-use value (no head/tail), so it shows just one +real_occupancy curve.

+{_capacity_summary(meta)} +
+""" + + +def process(jsonl_path: Path, out_dir: Path | None = None) -> list[Path]: + out_dir = out_dir or jsonl_path.parent + out_dir.mkdir(parents=True, exist_ok=True) + meta, records = _load(jsonl_path) + pairs_by_ring = _pair_by_ring(records) + + body = [f"

scope_stats — {_esc(jsonl_path.name)}

", _intro(meta)] + for resource in RESOURCES: + sections = [] + for ring in sorted(pairs_by_ring): + pairs = pairs_by_ring[ring] + if not pairs: + continue + sections.append(_ring_section(ring, pairs, resource, _resource_size(meta, resource, ring))) + if sections: + body.append(f"

{_esc(resource)}

{''.join(sections)}") + + out_path = out_dir / "scope_stats.html" + html = ( + "" + f"scope_stats" + f"{''.join(body)}" + ) + out_path.write_text(html) + return [out_path] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("jsonl", type=Path, help="path to scope_stats.jsonl") + parser.add_argument("--out-dir", type=Path, default=None, help="output dir for the HTML (default: alongside input)") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(message)s") + + written = process(args.jsonl, args.out_dir) + if written: + for path in written: + logger.info("wrote %s", path) + else: + logger.warning("no begin/end pairs found in %s", args.jsonl) + + +if __name__ == "__main__": + main()