Performance Optimization Guide
1. Optimization Techniques
1.1 Double Buffer Pipeline
Use Case
All fusion operators with “communication phase + computation phase” two-stage structure. buffer_num typically takes 2.
Optimization Principle
Use buffer_id = global_id % buffer_num to rotate among multiple physical buffers: computation of iteration i reads buffer i % 2 while communication side is writing buffer (i+1) % 2, two phases overlap in time. Without double buffering, each phase must serially wait for previous phase to completely finish.

Implementation Key Points
AllGather + GEMM operator:
barrier_all()placed between two phases, ensures buffer is written before being read by computation.Coordinating with fine-grained signals: Allocate independent signal slot groups for each buffer. Compared to
barrier_all_vecrequiring “all Producers of buffer 0 complete before Consumer of buffer 0 can start”, fine-grained signals allow Consumer to start reading when first Producer signal arrives, tighter pipeline.Ring reuse determination: When
buffer_num < num_blocks_s, buffers are cyclically reused, at this time must implement credit return (Consumer resets signal after reading), otherwise second round reuse causes Producer to block permanently. This is one of the most common deadlock causes, see Section 2.1.
1.2 Full-Core Utilization
Typical scenario:
Already launched all Vector Cores with
[vec_num, 1, 1], but small-scale scenario performance poor;Profiling’s
Block Numequalsvec_num, but actual working cores fewer thanvec_num;for task_idx in range(logical_core_id, total_prod_tasks, n_role_cores)wheretotal_prod_tasksis small, causing manylogical_core_idto get empty range and exit directly;The smaller total_prod_tasks, the worse performance.
Optimization Principle
Under one-dimensional flattened task distribution, actual participating cores are:
active_cores = min(total_prod_tasks, n_role_cores)
utilization = active_cores / n_role_cores
Full core condition = total_prod_tasks >= n_role_cores
When total task count is less than role core count, excess cores idle throughout. The problem is not how many cores launched, but assignable task granularity is not fine enough.
Implementation Key Points: S-dimension Flattening
For shape [S, H, D],
Wrong approach is to flatten one-dimensionally within each S-block separately—at this point each round’s task count is only num_blocks_d * H * rank_size, far less than core count in small H scenarios.
Correct approach is to flatten (global_id_s, task_idx) into a one-dimensional work ID, letting S dimension also participate in parallel distribution:
total_prod_tasks = num_blocks_d * H * rank_size
total_work = num_blocks_s * total_prod_tasks # Includes S dimension, far greater than n_role_cores
for wid in range(logical_core_id, total_work, n_role_cores):
global_id_s = wid // total_prod_tasks # Unpack S-block
task_idx = wid % total_prod_tasks # Unpack (rank, h, d)
buffer_id = global_id_s % buffer_num
“Which dimension to merge” decision:
Reason |
Dimension to merge |
Flattened |
|---|---|---|
Few tasks per single block (small H) |
|
|
Still insufficient (small S and small H) |
Further merge |
|
Multiple D tiles exist |
Merge |
|
Core Occupancy Quantification Example
Taking num_blocks_d = 1, rank_size = 2 (i.e., total_prod_tasks = 2H), n_role_cores = 36 as example:
H |
|
Working cores (out of 36) |
Idle cores |
Full core? |
|---|---|---|---|---|
8 |
16 |
16 |
20 |
No |
12 |
24 |
24 |
12 |
No |
24 |
48 |
36 |
0 |
Yes |
28 |
56 |
36 |
0 |
Yes |
32 |
64 |
36 |
0 |
Yes |
40 |
80 |
36 |
0 |
Yes |
48 |
96 |
36 |
0 |
Yes |
56 |
112 |
36 |
0 |
Yes |
Above table is calculated core occupancy by formula, not measured time.
1.3 Fine-Grained Signal Synchronization Replacing Global Barrier
This is the optimization with most obvious benefit for pure communication operators, but not applicable to all operators.
Applicability Criterion
Scenario |
Applicable? |
Reason |
|---|---|---|
Pure communication Producer-Consumer pipeline (Reverse All2All) |
Strongly recommended |
No Cube computation, fine-grained signals avoid slowest Producer blocking all Consumers |
Single rank kernel |
Not needed |
No cross-card communication |
Multi-round loop pipeline |
Recommended |
Greater benefit in double-buffer scenario |
Judgment criterion: If Consumer only needs to wait for specific Producer task to work independently (rather than must wait for all Producers to complete), then applicable fine-grained synchronization.
Optimization Principle
Synchronization Mode |
Consumer Wait Time |
Total Pipeline Latency |
|---|---|---|
|
|
|
|
|
|
Therefore: The more uneven Producer latency distribution (e.g., cross-card bandwidth differences between different ranks), the greater benefit; when latency is completely uniform, the two are close.
Implementation Key Points: Producer 5 Steps / Consumer 6 Steps
Producer: wait(waitValue=0) → consume_token → tl.store → fence() → notify(target_rank)
Consumer: wait(acquire) → consume_token → tl.load → tl.store → fence() → notify(rank, 0)
Signal value semantic convention:
0 = Consumer has finished reading, Producer can write
Non-0 = Producer has finished writing, Consumer can read
Consumer must notify(rank, 0) to reset slot after reading, otherwise next round Producer’s wait(waitValue=0) will block permanently.
Since NPU architecture does not guarantee memory consistency for multi-core concurrent writes to the same cacheline (DataCache) (64B), signal memory layout (flat int64 array, each slot 64 bytes):
signal_mem_ptr
+ buffer_id * (num_blocks_d * H * rank_size) * 8 # Double-buffer grouping
+ rank_factor * (num_blocks_d * H) * 8 # rank grouping
+ task_idx * 8 # task slot
Platform-Defined Semantics (these are hard facts, don’t re-derive):
Fact |
Engineering Implication |
|---|---|
|
Returned when data has arrived at remote. Under UDMA path, no need for fence before notify |
|
MTE path must strictly follow |
|
Only waits, doesn’t reset. Multiple waiters on same slot is safe |
Waiter / Resetter Count Constraints
Since dl.wait doesn’t consume signal, constraint falls on reset side:
Slot Type |
Resetter Count |
Waiter Count |
|---|---|---|
Needs credit return (buffer ring reuse) |
Exactly 1 |
Engineeringly required also exactly 1 |
No reset (UDMA cross-card, no buffer reuse) |
None |
Arbitrary many, no constraint |
1.4 Swizzle Loop Transformation
Use Case
L2 hit rate of distributed GEMM is low;
Sequential rank access causes cross-card bandwidth underutilization;
All cores simultaneously accessing same L2 cache line produces contention;
Naive row-major tile scheduling causes poor K dimension reuse.
Optimization Principle
Swizzle simultaneously solves two orthogonal problems:
L2 Cache Contention—under row-major order, adjacent tiles access non-overlapping rows of B matrix, L2 must evict and reload for each new row.
Cross-Rank Bandwidth Imbalance—under naive scheduling, all cores first access rank 0, then rank 1… At any moment only one inter-card link is active.
Nz pattern interleaves N (column) dimension with Z (rank) dimension while maintaining M dimension locality; and reverses M direction on odd N groups, constructing zigzag (boustrophedon) traversal, doubling temporal locality window.
Two Core Functions
# GEMM phase: optimize tile access order
data_row_idx, data_col_idx = gemm_swizzle2d_Nz(
iter_id, data_row_shape, data_col_shape,
tile_row_shape, tile_col_shape, swizzle_offset=7
)
# Communication phase: decide which rank tile goes to
row, col, rank_idx, comm_row_size, comm_col_size = dist_swizzle2d_Nz(
iter_id, rank_size, data_row_shape, data_col_shape,
tile_row_shape, tile_col_shape, comm_npu_split=1
)
dist_swizzle2d_Nz internally performs two-level rank permutation:
Stride permutation:
rank_idx = (rank_idx * rank_stride) % rank_stride + (rank_idx * rank_stride) // rank_sizeData-shift permutation:
rank_idx = (rank_idx + data_tile_idx) % rank_size
Effect: When rank_size = 8, comm_npu_split = 1, inner 8 iterations simultaneously point to 8 different ranks, 8 inter-card links simultaneously saturated, rather than serially using one by one.
1.5 UDMA Programming
Applicable scope: Target hardware is Ascend 950, and
aclshmeminitialized withdata_op_engine_type = ash.OpEngineType.UDMA.
Use Case
Cross-card whole block data movement exists, and movement range is known before kernel launch, can be expressed with single
putmem/getmemat once.
Illegal Use Case
Movement between local shmem ↔ local GM (must go through MTE path);
Three Hard Constraints
Constraint 1 — Address Type Asymmetry
Interface |
dst Requirement |
src Requirement |
|---|---|---|
|
Must be remote shmem address |
Local address is fine (shmem or regular GM) |
|
Must be local address (shmem or GM) |
Must be remote shmem address |
Note putmem’s dst receives symmetric offset address on remote rank with same pointer value as local, not the mapped pointer returned by dl.symm_at(). This is fundamentally different from MTE link usage, the most error-prone point.
Constraint 2 — Same pe Must Be Initiated by Single Core
Root cause: quiet() interface only retains one signal slot per pe, cannot distinguish multiple concurrent requests.
Constraint 3 — Whole Block Transfer, Avoid Per-Tile Calls
Each UDMA call has inherent initiation and polling overhead. Splitting one large block transfer into multiple small block calls brings additional overhead of multiple calls.
2. Anti-Patterns and Performance Issues
2.1 Deadlock or Signal Out-of-Bounds After Migration from barrier_all to wait/notify
Fine-grained synchronization benefit is considerable, but transformation process often has issues. When troubleshooting, first look at “failure timing”, it locates root cause better than failure phenomenon:
Failure Timing |
Suspected Root Cause |
|---|---|
All small shapes pass, large shapes hang |
Credit return missing (only when |
Starts hanging from 2nd round buffer reuse |
Credit erased by multiple resetters (lost signal) |
First launch normal, errors after multiple launches |
Host side missed |
Deadlock after increasing grid |
Causes aicore batch scheduling, Consumer not resident, Producer can’t get credit |
Memory corruption |
Slot address formula drifts from host-side allocation size |
Locating Means
No output in hanging scenarios, need to actively plant debugging points. Host-side watchdog timeout then dump signal’s non-zero distribution:
Signal Slot Stuck Value |
Meaning |
|---|---|
Stuck at 1 |
Consumer didn’t wait—slot address calculated wrong, or slot has no waiter at all |
Stuck at 0 |
Producer didn’t notify—notify loop and store loop range mismatch |
Hangs after 2nd round reuse slot |
Credit return missing, or erased by multiple resetters |
Additionally add one line total_vec assertion:
# In kernel
if global_vec_id == 0:
tl.store(dbg_ptr, total_vec)
# Host side
assert dbg.item() == NPUUtils().get_aivector_core_num(), \
f"launch mode mismatch: total_vec={dbg.item()}"
Other High-Frequency Issues
Missing
dl.consume_token—compiler will considerdl.waithas no subsequent dependency, thus eliminate entiredl.waitas dead code. This is not “out-of-order execution”, but synchronization completely disappears.# Correct: first all store, after fence then all notify for task_idx in range(start, total, step): tl.store(...) libshmem_device.fence() for task_idx in range(start, total, step): # Three parameters completely same as above dl.notify(...)