Skip to content

[Performance] Streaming orchestrator design space (a2a3/a5/Host/HW) #984

Description

@hw-native-sys-bot

Platform

a2a3 (Ascend 910B/C hardware)

Runtime Variant

tensormap_and_ringbuffer

Summary

Tracking issue for the a2a3 tensormap_and_ringbuffer orchestrator. Captures a PTO2_ORCH_PROFILING=1 measurement baseline of the per-task submit hot path, side-by-side numbers for the auto-dependency (paged_attention_unroll) and manual-dependency (paged_attention_unroll_manual_scope) variants on the same workload, and the architectural background needed to interpret them.

The intent of this issue is characterization, not regression reporting. Concrete evolution directions are posted as separate comments.

Related: #545 (overall runtime perf tracking — scheduler / dispatch / runtime-wrapping side), #849, #902.

Git Commit ID

60742ff

CANN Version

9.0.0 (V100R001C10SPC001B250)

Driver Version

26.0.rc1 (ascendhal_version 7.35.23)

Host Platform

Linux (aarch64)

Reproduction

# 1. Enable orchestrator + tensormap profiling
#    (src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2_types.h)
#      #define PTO2_ORCH_PROFILING        1
#      #define PTO2_TENSORMAP_PROFILING   1
# 2. Rebuild the a2a3 onboard runtime
python3 -m venv --system-site-packages .venv && source .venv/bin/activate
pip install --no-build-isolation -e .
python simpler_setup/build_runtimes.py --platforms a2a3

# 3. Run the auto-dep variant on a free a2a3 device
task-submit --device auto --device-num 1 --run "source .venv/bin/activate && \
    python -m pytest \
        tests/st/a2a3/tensormap_and_ringbuffer/paged_attention_unroll/test_paged_attention_unroll.py \
        --platform a2a3 --device \$TASK_DEVICE -v -s --log-level v9 --enable-l2-swimlane 4"
# Inspect device log for the '=== Orchestrator Profiling' block:
ls -lt $HOME/ascend/log/debug/device-<id>/ | head -3

# 4. Repeat with the manual-scope variant
task-submit --device auto --device-num 1 --run "source .venv/bin/activate && \
    python -m pytest \
        examples/a2a3/tensormap_and_ringbuffer/paged_attention_unroll_manual_scope/test_paged_attention_unroll.py \
        --platform a2a3 --device \$TASK_DEVICE -v -s --log-level v9 --enable-l2-swimlane 4"

Both tests use the same Case1 parameters (batch=256, num_heads=16, kv_head_num=1, head_dim=128, block_size=128, context_len=8192, max_model_len=32768, dtype=bfloat16, aicpu_thread_num=4, block_dim=24).

Additional Context

1. What the orchestrator does

Single AICPU thread (Thread 3 of 4 on a2a3). Per runtime/pto_orchestrator.h:11-26: executes the user orchestration function, allocates intermediate buffers from the GM heap (heap ring), submits tasks via PTO2OrchestratorState::submit_task (real work), submit_dummy_task (barrier), or alloc_tensors (inline-complete buffer carve-out), builds the dependency graph through TensorMap or explicit_deps, and manages scopes via PTO2_SCOPE. It is not an executor — it writes records into shared memory and pushes opaque slot-state pointers onto a single SPSC wiring queue; the scheduler threads do all fanout wiring, list maintenance, and watermark advancement.

2. Two components: runtime interface and user code

The runtime is split into a function-pointer ops table and the user .so that calls it through that table. There is no link dependency: AICPU Thread 3 dumps the orchestration .so to a temp file, dlopens it, looks up aicpu_orchestration_config and aicpu_orchestration_entry, the runtime populates rt->ops, and the user code calls back via current_runtime()->ops->submit_task (etc.).

  • Runtime interface: src/a2a3/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h — opaque struct PTO2Runtime { const PTO2RuntimeOps *ops; PTO2ScopeMode pending_scope_mode; }, the PTO2RuntimeOps function table (submit_task, scope_begin/end, alloc_tensors, submit_dummy_task, logging, set_tensor_data/get_tensor_data, fatal handling), inline rt_submit_*_task wrappers, the Arg builder, and PTO2_SCOPE(...) RAII.
  • User code: examples/.../paged_attention_orch.cpp exports exactly aicpu_orchestration_entry (and _config). Includes only pto_orchestration_api.h. Zero runtime symbol dependencies.

3. Six phases of one task submission

All happen inside submit_task_common (runtime/pto_orchestrator.cpp:507-688), instrumented by CYCLE_COUNT_LAP calls:

  1. Alloc (g_orch_alloc_cycle) — task ring slot allocation, heap ring packed-output allocation, prefetch_payload, bind_buffers, task_state.store(PENDING), scope_tasks_push. Blocks on back-pressure here.
  2. sync_tensormap (g_orch_sync_cycle) — acquire-load fc.last_task_alive, possibly cleanup_retired every PTO2_TENSORMAP_CLEANUP_INTERVAL=64 retired tasks.
  3. lookup+dep (g_orch_lookup_cycle) — explicit_deps loop (retire-skip + fanin append) and compute_task_fanin (pto_dep_compute.h:81-129): per non-OUTPUT tensor, Step A creator retention from tensor->owner_task_id, then Step B tensor_map.lookup — hash-bucket walk + check_overlap cascade (L1 byte-range, L2 hyper-rectangle, L3 conservative-OTHER). INOUT+COVERED removes the entry. Inline fanin builder caps at PTO2_FANIN_INLINE_CAP, spills to fanin_pool above that.
  4. tensormap_ins (g_orch_insert_cycle) — register_task_outputs (pto_dep_compute.h:140-154): for each INOUT and OUTPUT_EXISTING, allocate entry from free list or bump region, 64-byte copy_from_tensor memcpy, hash-bucket and per-task chain prepends.
  5. param_copy (g_orch_args_cycle) — task.task_id / kernel_id[3] / packed_buffer_* writes, per-producer fanout_count++, fanin metadata into payload, and payload.init(args, result, alloc_result, layout) — the actual tensor + scalar deep copy into the GM payload that AICore will read.
  6. fanin+ready (g_orch_fanin_cycle) — single SPSC push of the slot-state pointer to sched->wiring.queue. The actual fanout wiring (lock + dep_pool prepend + early-finished check + ready-queue push) is deferred to scheduler thread 0's drain_wiring_queue (scheduler/pto_scheduler.h:671-711).

scope_end cost is tracked separately (g_orch_scope_end_cycle) — iterates scope tasks and calls scheduler->on_scope_end to release the +1 scope reference on each producer's fanout_count.

Raw device-log excerpts on the 1280-task Case1 workload (verbatim, paths anonymized):

# paged_attention_unroll (auto-dep, PTO2_SCOPE())
Thread 3: === Orchestrator Profiling: 1280 tasks, total=646.960us ===
Thread 3:   task+heap_alloc: 171.280us (26.5%)  work=171.280us wait=0.000us  atomics=1280
Thread 3:   sync_tensormap : 56.400us (8.7%)
Thread 3:   lookup+dep     : 80.720us (12.5%)
Thread 3:   tensormap_ins  : 70.520us (10.9%)
Thread 3:   param_copy     : 235.600us (36.4%)  atomics=2048
Thread 3:   fanin+ready    : 32.440us (5.0%)  work=32.440us wait=0.000us
Thread 3:   avg/task       : 0.505us
Thread 3: === TensorMap Lookup Stats ===
Thread 3:   lookups        : 3584, inserts: 768
Thread 3:   chain walked   : total=11, avg=0.0, max=1
Thread 3:   overlap checks : 0, hits=0 (0.0%)
Thread 3: PTO2 total submitted tasks = 1280, already executed 1256 tasks
# paged_attention_unroll_manual_scope (manual deps, PTO2_SCOPE(PTO2ScopeMode::MANUAL))
Thread 3: === Orchestrator Profiling: 1280 tasks, total=486.820us ===
Thread 3:   task+heap_alloc: 161.580us (33.2%)  work=161.580us wait=0.000us  atomics=1280
Thread 3:   sync_tensormap : 33.080us (6.8%)
Thread 3:   lookup+dep     : 19.360us (4.0%)
Thread 3:   tensormap_ins  : 3.520us (0.7%)
Thread 3:   param_copy     : 252.120us (51.8%)  atomics=2048
Thread 3:   fanin+ready    : 17.160us (3.5%)  work=17.160us wait=0.000us
Thread 3:   avg/task       : 0.380us
Thread 3: === TensorMap Lookup Stats ===
Thread 3:   lookups        : 256, inserts: 0
Thread 3:   chain walked   : total=0, avg=0.0, max=0
Thread 3:   overlap checks : 0, hits=0 (0.0%)
Thread 3: PTO2 total submitted tasks = 1280, already executed 1051 tasks

4. TensorMap cost and sequentiality

The orchestrator is one thread, so steps 2/3/4 run strictly in order with no pipelining: N tasks back-to-back cost N×(sync + lookup + insert). The TensorMap is private to the orchestrator (no atomics), so there's no contention — but also no parallelism. Auto-dep mode runs compute_task_fanin and register_task_outputs; manual-dep mode short-circuits both (pto_dep_compute.h:84-86 and :142-144). That's why the manual run shows inserts=0 and lookups=256 (only one trivial lookup per submit, no chain walked, no overlap check).

Side-by-side per-phase breakdown on the same 1280-task Case1 workload, same commit, both runs on a free a2a3 device under task-submit exclusive lock:

Phase unroll (auto-dep) unroll_manual_scope Δ
total (sum of phases) 646.96 us 486.82 us -24.8%
avg/task 0.505 us 0.380 us -24.8%
task+heap_alloc 171.28 us (26.5%) 161.58 us (33.2%) -5.7%
sync_tensormap 56.40 us (8.7%) 33.08 us (6.8%) -41.3%
lookup+dep 80.72 us (12.5%) 19.36 us (4.0%) -76.0%
tensormap_ins 70.52 us (10.9%) 3.52 us (0.7%) -95.0%
param_copy 235.60 us (36.4%) 252.12 us (51.8%) +7.0%
fanin+ready 32.44 us (5.0%) 17.16 us (3.5%) -47.1%

TensorMap-internal stats:

Stat unroll (auto-dep) unroll_manual_scope
lookups 3584 256
inserts 768 0
chain walked total / avg / max 11 / 0.0 / 1 0 / 0.0 / 0
overlap checks / hits 0 / 0 0 / 0

Observations:

  • param_copy stays the largest single phase in both runs (36-52%) — the unavoidable Tensor + scalar deep copy into the GM payload, tensormap-independent.
  • Eliminating TensorMap collapses two phases (lookup+dep + tensormap_ins) from a combined 151.24 us (23.4%) to 22.88 us (4.7%), a 128.36 us / 19% absolute drop on total.
  • sync_tensormap does not go to zero in manual mode because it is unconditional (pto_orchestrator.cpp:574-578) — it still loads last_task_alive and may run cleanup_retired even with zero inserts.
  • fanin+ready drops 47% — orch is pushing fewer slot states through the SPSC wiring queue because explicit deps don't synthesize redundant edges.
  • task+heap_alloc is essentially flat — the ring buffers are uncontested at this 1280-task / 16K-window scale.
  • The 3584 lookups in the auto run come from the 4-tensor-input per submit × 1280 submits × ~0.7 non-OUTPUT-tensor fraction. Even with max chain length of 1 and zero overlap checks (this workload's tensors don't share base addrs in the same hash bucket — unsurprising for paged attention), the per-lookup cost is non-trivial because lookup always pays the hash + cache-line fetch + iterate cost.

5. Orch ↔ scheduler interaction factors

Two distinct cost regimes for orch↔sched data sharing on the hot path:

  • Factor A — Write-once-by-orch, read-by-sched payload (capacity-bound, small): PTO2TaskDescriptor, PTO2TaskPayload, and the per-slot PTO2TaskSlotState payload/task pointers are written once by the orchestrator at step 5, read once by the scheduler at dispatch (and again by AICore at execute). No coherence ping-pong: orch dirties, sched reads. Per-task payload is bounded (≤16 tensors + ≤16 scalars), and active payload working set fits in L2 by design — task_window_size is small, default 16384/ring, often as small as 16. Cost: bandwidth only.
  • Factor B — Shared coordination cache lines (coherence-bound, big): lines that both threads write or one writes and the other reads on the hot path force snoop / invalidation round-trips. The hot lines are:
    • PTO2RingFlowControl (pto_shared_memory.h:54-76) — current_task_index and last_task_alive are on separate 64B-aligned lines on purpose, so each is single-writer.
    • PTO2TaskSlotState (pto_runtime2_types.h:316-398) — single 64B line carrying both orch-managed (task_state=PENDING, bind_buffers, active_mask, fanout_count increments on producers) and sched-managed (task_state=COMPLETED/CONSUMED, fanout_refcount, fanin_refcount, fanin_count) fields. Pings on every fan-out edge.
    • sched->wiring.queue slot lines — orch pushes (step 6), sched batch-pops (drain_wiring_queue). One ping per submit.

Factor B is what scales with fan-out density. The recent deferred-wiring redesign (orch no longer takes fanout_lock per producer, no longer allocates dep_pool) was a targeted cut on this; the remaining orch-side coherence work is the per-producer fanout_count++ increment. Auto-dep mode pays more on this dimension too, because every discovered TensorMap edge becomes a fanout_count++ on the producer slot's hot cache line. Manual mode discovers fewer redundant edges → fewer pings.


Evolution directions are discussed in follow-up comments below.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

performancePerformance regression or optimization

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions