Platform
a2a3 (Ascend 910B/C hardware)
Runtime Variant
tensormap_and_ringbuffer
Description
The heap reclaim front is tied to the task watermark last_task_alive. When the
oldest live task in a ring is a zero-byte extent (scope-gated, COMPLETED,
but still holding a lagging consumer), it pins last_task_alive; the heap bytes
of later, already-CONSUMED positive-extent tasks then cannot be
reclaimed — even though those bytes are physically reclaimable and the head task
itself owns no heap. The ring fills and reports HEAP_RING_DEADLOCK while
reclaimable space exists. This violates the most basic runtime contract —
consumed bytes are reclaimable — because a zero-byte live task owns no heap
and must not block retirement of the consumed bytes behind it.
Steps to Reproduce
Add a minimal ST (adds `tests/` only, no `src/` runtime change):
`tests/st/a2a3/tensormap_and_ringbuffer/heap_reclaim_watermark/`. Task graph (all
on ring 1):
1. A holder (AIC spin kernel, ~1.3 s) occupies one core to gate HC.
2. A **zero-extent dummy head H** and its consumer HC share **one scope** — H is
scope-gated (alive) at the moment HC registers its dependency on H, so HC
holds H's fanout; HC is also gated on the holder, so it cannot run while the
holder spins → H stays `COMPLETED` but un-consumed, pinning ring 1's
`last_task_alive` at task 0 (a zero-byte task).
3. Three filler producer/consumer pairs, each in its own scope, each producer
allocating a 1 MiB scratch and each consumer copying `scratch[0]` to an
external Y and fully consuming it. Ring 1 is squeezed to 2.5 MiB (~2 fillers).
(Key: HC must register its dependency **inside H's scope** — otherwise H, having
no consumer at submit time, auto-retires before HC is submitted and the explicit
dependency is skipped in `pto_orchestrator.cpp`, so H never pins.) Run:
PTO2_RING_HEAP=268435456,2621440,268435456,268435456 \
python -m pytest \
tests/st/a2a3/tensormap_and_ringbuffer/heap_reclaim_watermark/test_heap_reclaim_watermark.py \
-s -v -p no:xdist --platform a2a3 --device <N>
(On a shared box wrap it in `task-submit --device <N> --run "..."` with a per-run
`ASCEND_PROCESS_LOG_PATH`.)
Expected Behavior
The third filler needs an earlier, already-consumed filler's bytes reclaimed;
those bytes are reclaimable, so the test should pass with every Y[0] == 42.
Actual Behavior
Upstream deadlocks; the device log shows:
FATAL: Task Allocator Deadlock - Heap Exhausted! ring=1
No reclaim progress for ~500 ms (25000000 cycles wall clock).
Task ring 1: current=6, last_alive=0, active=6/16384 (0.0%)
Heap ring 1: top=2097152, tail=0, size=2621440, used=2097152, available=524288
Requested: 1048576 bytes
Head task 0: state=1, consumers=0/1, scope_released=1
Host-side orch_error_code=2 HEAP_RING_DEADLOCK. last_alive=0 and tail=0 — the
reclaim front is pinned at task 0 (the zero-byte head); Head task 0: state=1 (COMPLETED), consumers=0/1, scope_released=1; used=2 MiB, available=0.5 MiB, Requested 1 MiB — two fillers' bytes are consumed but not
reclaimable; active=6/16384 (0.0%) — task slots are far from full, so this is
heap-bound (a reclaim defect), not a capacity limit.
Git Commit ID
6284040
CANN Version
9.0.0
Driver Version
26.0.rc1
Host Platform
Linux (aarch64)
Additional Context
Fix corroboration (same ST file, same PTO2_RING_HEAP, only the runtime
differs): the fixed branch passes with Y[0] == 42 (1 passed). Since the
fix runs all fillers through the 2.5 MiB ring, the ring is large enough —
upstream's failure is the reclaim front pinned by the zero-byte head, not
capacity.
Root cause: reclaim (update_heap_tail) historically advanced the heap tail
only up to last_task_alive; once that watermark is pinned by a not-fully-
consumed head, later consumed bytes cannot retire. A zero-extent head owns no
heap and should never participate in the heap-reclaim blocking decision.
Suggested fix: give the heap an independent reclaim cursor
(heap_reclaim_task_id_) decoupled from the task watermark: a zero-extent task
is never a stopping condition (skipped); positive extents stay strictly FIFO,
stopping at the first one that is neither covered by the watermark nor
CONSUMED; a consumed positive extent may retire ahead of the watermark
(consumed_ahead_of_watermark). See the fixed pto_ring_buffer.h::update_heap_tail.
lib-free regression test — test_task_allocator.cpp::ReplaysTask2639ZeroExtentHeadAndConsumedTask2640
TEST_F(TaskAllocatorTest, ReplaysTask2639ZeroExtentHeadAndConsumedTask2640) {
constexpr int32_t INITIAL_TASK_ID = 2639;
constexpr uint8_t RING_ID = 3;
current_index.store(INITIAL_TASK_ID);
last_alive.store(INITIAL_TASK_ID);
allocator.init(
descriptors.data(), WINDOW_SIZE, ¤t_index, &last_alive, heap_buf, HEAP_SIZE, &error_code,
slot_states.data(), INITIAL_TASK_ID, RING_ID
);
auto zero_head = allocator.alloc(0);
ASSERT_FALSE(zero_head.failed());
ASSERT_EQ(zero_head.task_id, 2639);
publish_allocation(descriptors, zero_head, RING_ID);
slot_states[zero_head.slot].fanout_count = PTO2_FANOUT_SCOPE_BIT | 64;
slot_states[zero_head.slot].fanout_refcount.store(PTO2_FANOUT_SCOPE_BIT | 51, std::memory_order_relaxed);
slot_states[zero_head.slot].task_state.store(PTO2_TASK_COMPLETED, std::memory_order_release);
auto consumed_bytes = allocator.alloc(HEAP_SIZE);
ASSERT_FALSE(consumed_bytes.failed());
ASSERT_EQ(consumed_bytes.task_id, 2640);
publish_allocation(descriptors, consumed_bytes, RING_ID);
slot_states[consumed_bytes.slot].task_state.store(PTO2_TASK_CONSUMED, std::memory_order_release);
auto next = allocator.alloc(64);
ASSERT_FALSE(next.failed());
EXPECT_EQ(next.packed_base, static_cast<void *>(heap_buf));
EXPECT_EQ(last_alive.load(std::memory_order_acquire), INITIAL_TASK_ID);
EXPECT_EQ(allocator.heap_reclaim_task_id(), 2641);
EXPECT_EQ(slot_states[zero_head.slot].task_state.load(std::memory_order_acquire), PTO2_TASK_COMPLETED);
}
Related lib-free tests: StopsAtFirstLivePositiveExtent,
ConcurrentBackpressureReclaimsWithoutLastAliveAdvance,
DecoupledReclaimAdvancesTailAcrossHeapWrap.
Real-workload corroboration (same defect, natural form): qwen3-14B prefill at
PTO2_RING_HEAP=4G x4 (16 GiB heap — capacity is clearly not the limit) deadlocks
on ring 3 under upstream — Head task 5807: state=1, consumers=52/64, scope_released=1, No reclaim progress ~500ms — while the fix PASS (142s).
Reproduced-at-HEAD: dynamically reproduced on main HEAD 62840403 (real
a2a3, orch_error_code=2 HEAP_RING_DEADLOCK; device dump identical — ring=1, last_alive=0, Head task 0: state=1, consumers=0/1, scope_released=1) as well as on
fork point 8cdb306c. Consistently, pto_ring_buffer.h (containing
update_heap_tail) is byte-identical across 8cdb306c..62840403, and the
three lines this repro's setup depends on — the zero-output auto-retire
(count_registrable_outputs == 0), the already-retired-producer dep skip
(dep_local_task_id < dep_last_task_alive), and the scope-gated fanout reference —
are unchanged; the 36 intervening commits touch only pto_orchestrator.cpp
(diagnostics #1448, marker consolidation #1410, dispatch marker #1411,
early-dispatch #1405).
Reproduction range & fix: reproduced across 8cdb306c..HEAD 62840403, over
which pto_ring_buffer.h is byte-identical — so this is not a regression from
those commits. The introducing commit was not bisected; update_heap_tail
advancing the heap tail off last_task_alive is a long-standing design in the
allocator, not a recent change. Fixed on our fork by c396b6e7 ("Fix: decouple
FIFO heap reclaim from task watermark", which introduces the independent
heap_reclaim_task_id_ cursor) [+ 611c79e9].
Reproduction — full ST sources
Drop these into tests/st/a2a3/tensormap_and_ringbuffer/heap_reclaim_watermark/, then
run the command in Steps to Reproduce. No src/ change is required.
test_heap_reclaim_watermark.py
#!/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.
# -----------------------------------------------------------------------------------------------------------
"""heap_reclaim_watermark: a zero-byte un-consumed head must not freeze reclaim
of later consumed heap bytes.
A holder task spins for the whole filler phase. A zero-extent dummy head H and
its consumer HC share one scope, so H is scope-gated (alive) when HC registers
its dependency on H; HC is also gated on the holder, so H stays COMPLETED but
un-consumed for the whole filler phase, pinning the ring task watermark at a
zero-byte task (the shape of a scope-gated barrier head with lagging consumers).
Three filler producer/consumer pairs (each 1 MiB scratch, each in its own scope)
then run on the same ring; with the ring sized to hold ~2 fillers, the third
filler needs an earlier filler's consumed bytes reclaimed -- but a runtime that
couples heap reclaim to the task watermark cannot free bytes past the pinned
zero-byte head, so it reports false heap exhaustion.
Run ring 1's heap down to ~2.5x the scratch via env so it holds ~2 fillers:
PTO2_RING_HEAP=268435456,2621440,268435456,268435456 \
python -m pytest .../test_heap_reclaim_watermark.py \
-s -v -p no:xdist --platform a2a3 --device <N>
Expected on a2a3: fixed simpler passes and every Y[0]==42; upstream 8cdb306c
fails (heap deadlock) because the zero-byte head freezes reclaim of the
consumed filler bytes.
"""
import torch
from simpler.task_interface import ArgDirection as D
from simpler_setup import SceneTestCase, TaskArgsBuilder, Tensor, scene_test
SENTINEL = 42.0
INIT_VAL = -1.0
NUM_FILLERS = 3
@scene_test(level=2, runtime="tensormap_and_ringbuffer")
class TestHeapReclaimWatermark(SceneTestCase):
"""A zero-byte un-consumed reclaim head must not block later consumed bytes."""
RTOL = 0
ATOL = 0
CALLABLE = {
"orchestration": {
"source": "kernels/orchestration/heap_reclaim_watermark_orch.cpp",
"function_name": "aicpu_orchestration_entry",
"signature": [D.INOUT, D.INOUT, D.INOUT, D.INOUT],
},
"incores": [
{
"func_id": 0,
"name": "FILL_CONST",
"source": "kernels/aic/kernel_write_const.cpp",
"core_type": "aic",
"signature": [D.OUT],
},
{
"func_id": 1,
"name": "COPY_FIRST",
"source": "kernels/aic/kernel_copy_first.cpp",
"core_type": "aic",
"signature": [D.IN, D.INOUT],
},
{
"func_id": 2,
"name": "SPIN_HOLDER",
"source": "kernels/aic/kernel_spin_holder.cpp",
"core_type": "aic",
"signature": [D.INOUT],
},
],
}
CASES = [
{
"name": "ZeroByteHeadPinsReclaim",
"platforms": ["a2a3"],
"config": {"aicpu_thread_num": 4, "block_dim": 1},
"params": {},
},
]
def generate_args(self, params):
tensors = [Tensor(f"y{i}", torch.full((16,), INIT_VAL, dtype=torch.float32)) for i in range(NUM_FILLERS)]
tensors.append(Tensor("yholder", torch.full((16,), INIT_VAL, dtype=torch.float32)))
return TaskArgsBuilder(*tensors)
def compute_golden(self, args, params):
# Each filler's FILL_CONST writes SENTINEL to its scratch[0]; the
# matching COPY_FIRST copies it to Y[i][0]. The holder writes SENTINEL to
# Yholder[0]. All fillers and the holder must complete.
for i in range(NUM_FILLERS):
getattr(args, f"y{i}")[0] = SENTINEL
args.yholder[0] = SENTINEL
if __name__ == "__main__":
SceneTestCase.run_module(__name__)
kernels/orchestration/heap_reclaim_watermark_orch.cpp
/*
* 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.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* heap_reclaim_watermark: minimal repro of heap reclaim coupled to the task
* watermark.
*
* A holder task spins on core 0 long enough for the whole filler phase. A
* zero-extent dummy head H and its consumer HC are submitted in one scope, so H
* is scope-gated (alive) at the moment HC registers its dependency on it -- HC
* therefore holds H's fanout. HC is also gated on the holder, so it cannot run
* until the spin finishes; H thus stays COMPLETED-but-un-consumed for the whole
* filler phase, pinning the ring task watermark last_task_alive at a zero-byte
* task (the shape of a scope-gated barrier head with lagging consumers).
*
* H is the ring's lowest task id. After it, NUM_FILLERS producer/consumer pairs
* each run in their own scope on the SAME ring: the producer allocates an S-byte
* scratch, the consumer copies scratch[0] into an external Y and fully consumes
* it. With the ring sized to hold ~2 fillers, the later fillers need an earlier
* filler's bytes reclaimed. Those bytes are CONSUMED and physically reclaimable,
* but they are ahead of the watermark H pins. A runtime that couples heap
* reclaim to last_task_alive cannot free them -> false heap exhaustion. A
* runtime whose heap-reclaim cursor is decoupled skips the zero-byte head and
* frees the consumed filler bytes.
*
* Args layout: [Y0, Y1, Y2, Y3, Yholder] (external outputs the host verifies)
*/
#include <cstdint>
#include "pto_orchestration_api.h" // NOLINT(build/include_subdir)
#define FUNC_FILL_CONST 0
#define FUNC_COPY_FIRST 1
#define FUNC_SPIN_HOLDER 2
// Ring holds ~2 fillers; the 3rd needs an earlier consumed filler reclaimed.
static constexpr int32_t NUM_FILLERS = 3;
// Must outlast the allocator's ~500 ms no-reclaim deadlock-detection window so a
// broken runtime latches the deadlock before HC releases H (~1.3 s on a3).
static constexpr uint64_t HOLDER_SPIN_ITERS = 1000000000;
extern "C" {
__attribute__((visibility("default"))) PTO2OrchestrationConfig aicpu_orchestration_config(const L2TaskArgs &orch_args) {
(void)orch_args;
return PTO2OrchestrationConfig{
.expected_arg_count = 4,
};
}
__attribute__((visibility("default"))) void aicpu_orchestration_entry(const L2TaskArgs &orch_args) {
const Tensor *ys[NUM_FILLERS] = {
&orch_args.tensor(0).ref(),
&orch_args.tensor(1).ref(),
&orch_args.tensor(2).ref(),
};
const Tensor &ext_Yholder = orch_args.tensor(3).ref();
// S-byte filler scratch = [1024, 256] float32 = 1 MiB. The ring is sized
// ~2.5 MiB via PTO2_RING_HEAP so it holds ~2 fillers at a time.
uint32_t scratch_shapes[2] = {1024, 256};
TensorCreateInfo scratch_ci(scratch_shapes, 2, DataType::FLOAT32);
// Holder spins for the whole filler phase; HC is gated on it.
L0TaskArgs holder_args;
holder_args.add_inout(ext_Yholder);
holder_args.add_scalar(HOLDER_SPIN_ITERS);
PTO2TaskId holder_id = rt_submit_aic_task(FUNC_SPIN_HOLDER, holder_args).task_id();
// H and HC share a scope so H is scope-gated (alive) when HC registers its
// dependency on it; HC then holds H's fanout past scope close.
PTO2TaskId h_id;
PTO2_SCOPE() {
L0TaskArgs h_args;
h_id = rt_submit_dummy_task(h_args).task_id();
L0TaskArgs hc_args;
PTO2TaskId hc_deps[] = {h_id, holder_id};
hc_args.set_dependencies(hc_deps, 2);
rt_submit_dummy_task(hc_args);
}
for (int32_t i = 0; i < NUM_FILLERS; i++) {
PTO2_SCOPE() {
L0TaskArgs fill;
fill.add_output(scratch_ci);
TaskOutputTensors fill_outs = rt_submit_aic_task(FUNC_FILL_CONST, fill);
const Tensor &t = fill_outs.get_ref(0);
L0TaskArgs copy;
copy.add_input(t);
copy.add_inout(*ys[i]);
rt_submit_aic_task(FUNC_COPY_FIRST, copy);
}
}
}
} // extern "C"
kernels/aic/kernel_spin_holder.cpp
/*
* 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.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* Spins for args[1] iterations, then writes 42.0f to args[0][0]. Holds its
* AICore long enough that a dependent (gated on this task) stays un-run while
* other tasks make progress.
*
* Args: args[0] = output Tensor* (INOUT), args[1] = spin_iters.
*/
#include <cstdint>
#include <pto/pto-inst.hpp>
#include "tensor.h"
#ifndef __gm__
#define __gm__
#endif
#ifndef __aicore__
#define __aicore__ [aicore] // NOLINT(whitespace/braces)
#endif
#include "intrinsic.h"
#ifdef PTO_CPUSTUB_HPP
#define dcci(...) \
do { \
} while (0)
#endif
#ifndef SINGLE_CACHE_LINE
#define SINGLE_CACHE_LINE 0
#endif
#ifndef CACHELINE_OUT
#define CACHELINE_OUT 0
#endif
extern "C" __aicore__ void kernel_entry(__gm__ int64_t *args) {
__gm__ Tensor *out_tensor = reinterpret_cast<__gm__ Tensor *>(args[0]);
__gm__ float *out = reinterpret_cast<__gm__ float *>(out_tensor->buffer.addr) + out_tensor->start_offset;
int32_t spin_iters = static_cast<int32_t>(args[1]);
volatile int32_t acc = 0;
for (int32_t i = 0; i < spin_iters; i++) {
acc++; // volatile keeps the loop; ++ avoids int32 overflow UB for large counts
}
(void)acc;
out[0] = 42.0f;
dcci(&out[0], SINGLE_CACHE_LINE, CACHELINE_OUT);
}
kernels/aic/kernel_write_const.cpp
/*
* 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.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* Writes a fixed sentinel (42.0f) to args[0][0]. Used as the producer in the
* dummy_task scene tests so a downstream consumer can verify the dependency
* chain (producer -> ... -> dummy -> consumer) propagates the value intact.
*/
#include <cstdint>
#include <pto/pto-inst.hpp>
#include "tensor.h"
#ifndef __gm__
#define __gm__
#endif
#ifndef __aicore__
#define __aicore__ [aicore] // NOLINT(whitespace/braces)
#endif
#include "intrinsic.h"
#ifdef PTO_CPUSTUB_HPP
#define dcci(...) \
do { \
} while (0)
#endif
#ifndef SINGLE_CACHE_LINE
#define SINGLE_CACHE_LINE 0
#endif
#ifndef CACHELINE_OUT
#define CACHELINE_OUT 0
#endif
extern "C" __aicore__ void kernel_entry(__gm__ int64_t *args) {
__gm__ Tensor *out_tensor = reinterpret_cast<__gm__ Tensor *>(args[0]);
__gm__ float *out = reinterpret_cast<__gm__ float *>(out_tensor->buffer.addr) + out_tensor->start_offset;
out[0] = 42.0f;
dcci(&out[0], SINGLE_CACHE_LINE, CACHELINE_OUT);
}
kernels/aic/kernel_copy_first.cpp
/*
* 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.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* Consumer kernel for the dummy_task scene tests: copies args[0][0] -> args[1][0].
*
* If the dummy_task in the middle of the chain has correctly waited on the
* producer and notified this consumer, args[1][0] will equal the producer's
* sentinel (42.0f). If the dependency chain broke or the dummy somehow ran a
* kernel that clobbered args[0], the value will differ.
*/
#include <cstdint>
#include <pto/pto-inst.hpp>
#include "tensor.h"
#ifndef __gm__
#define __gm__
#endif
#ifndef __aicore__
#define __aicore__ [aicore] // NOLINT(whitespace/braces)
#endif
#include "intrinsic.h"
#ifdef PTO_CPUSTUB_HPP
#define dcci(...) \
do { \
} while (0)
#endif
#ifndef SINGLE_CACHE_LINE
#define SINGLE_CACHE_LINE 0
#endif
#ifndef CACHELINE_OUT
#define CACHELINE_OUT 0
#endif
extern "C" __aicore__ void kernel_entry(__gm__ int64_t *args) {
__gm__ Tensor *in_tensor = reinterpret_cast<__gm__ Tensor *>(args[0]);
__gm__ Tensor *out_tensor = reinterpret_cast<__gm__ Tensor *>(args[1]);
__gm__ float *in = reinterpret_cast<__gm__ float *>(in_tensor->buffer.addr) + in_tensor->start_offset;
__gm__ float *out = reinterpret_cast<__gm__ float *>(out_tensor->buffer.addr) + out_tensor->start_offset;
out[0] = in[0];
dcci(&out[0], SINGLE_CACHE_LINE, CACHELINE_OUT);
}
中文总结
堆回收前沿被绑在 task 水位 last_task_alive;当队头是一个零 extent、scope-gated、COMPLETED
但有滞后 consumer 的 task,它钉住水位,后面已消费的正 extent 字节就回收不了,明明有空间却报
HEAP_RING_DEADLOCK。最小 ST 用一个自旋 holder 门控 HC、H 与 HC 同 scope(让 H 在 HC 登记依赖时
还 scope-gated 存活,否则会 auto-retire 钉不住),再用 3 个 1 MiB filler 填 2.5 MiB 环:upstream
main HEAD 62840403(及 fork point 8cdb306c)死锁(last_alive=0, Head task 0 zero-extent, consumers=0/1),修复分支 c396b6e7 通过。修复=给 heap 独立的、与水位解耦的回收 cursor,跳过零字节队头;lib-free 回归测试见上方折叠块,
真实 workload 佐证见 qwen prefill PTO2_RING_HEAP=4GB。
Platform
a2a3 (Ascend 910B/C hardware)
Runtime Variant
tensormap_and_ringbuffer
Description
The heap reclaim front is tied to the task watermark
last_task_alive. When theoldest live task in a ring is a zero-byte extent (scope-gated,
COMPLETED,but still holding a lagging consumer), it pins
last_task_alive; the heap bytesof later, already-
CONSUMEDpositive-extent tasks then cannot bereclaimed — even though those bytes are physically reclaimable and the head task
itself owns no heap. The ring fills and reports
HEAP_RING_DEADLOCKwhilereclaimable space exists. This violates the most basic runtime contract —
consumed bytes are reclaimable — because a zero-byte live task owns no heap
and must not block retirement of the consumed bytes behind it.
Steps to Reproduce
Expected Behavior
The third filler needs an earlier, already-consumed filler's bytes reclaimed;
those bytes are reclaimable, so the test should pass with every
Y[0] == 42.Actual Behavior
Upstream deadlocks; the device log shows:
Host-side
orch_error_code=2 HEAP_RING_DEADLOCK.last_alive=0andtail=0— thereclaim front is pinned at task 0 (the zero-byte head);
Head task 0: state=1 (COMPLETED), consumers=0/1, scope_released=1;used=2 MiB, available=0.5 MiB, Requested 1 MiB— two fillers' bytes are consumed but notreclaimable;
active=6/16384 (0.0%)— task slots are far from full, so this isheap-bound (a reclaim defect), not a capacity limit.
Git Commit ID
6284040
CANN Version
9.0.0
Driver Version
26.0.rc1
Host Platform
Linux (aarch64)
Additional Context
Fix corroboration (same ST file, same
PTO2_RING_HEAP, only the runtimediffers): the fixed branch passes with
Y[0] == 42(1 passed). Since thefix runs all fillers through the 2.5 MiB ring, the ring is large enough —
upstream's failure is the reclaim front pinned by the zero-byte head, not
capacity.
Root cause: reclaim (
update_heap_tail) historically advanced the heap tailonly up to
last_task_alive; once that watermark is pinned by a not-fully-consumed head, later consumed bytes cannot retire. A zero-extent head owns no
heap and should never participate in the heap-reclaim blocking decision.
Suggested fix: give the heap an independent reclaim cursor
(
heap_reclaim_task_id_) decoupled from the task watermark: a zero-extent taskis never a stopping condition (skipped); positive extents stay strictly FIFO,
stopping at the first one that is neither covered by the watermark nor
CONSUMED; a consumed positive extent may retire ahead of the watermark(
consumed_ahead_of_watermark). See the fixedpto_ring_buffer.h::update_heap_tail.lib-free regression test —
test_task_allocator.cpp::ReplaysTask2639ZeroExtentHeadAndConsumedTask2640Related lib-free tests:
StopsAtFirstLivePositiveExtent,ConcurrentBackpressureReclaimsWithoutLastAliveAdvance,DecoupledReclaimAdvancesTailAcrossHeapWrap.Real-workload corroboration (same defect, natural form): qwen3-14B prefill at
PTO2_RING_HEAP=4G x4(16 GiB heap — capacity is clearly not the limit) deadlockson ring 3 under upstream —
Head task 5807: state=1, consumers=52/64, scope_released=1,No reclaim progress ~500ms— while the fixPASS (142s).Reproduced-at-HEAD: dynamically reproduced on
mainHEAD62840403(reala2a3,
orch_error_code=2 HEAP_RING_DEADLOCK; device dump identical —ring=1, last_alive=0, Head task 0: state=1, consumers=0/1, scope_released=1) as well as onfork point
8cdb306c. Consistently,pto_ring_buffer.h(containingupdate_heap_tail) is byte-identical across8cdb306c..62840403, and thethree lines this repro's setup depends on — the zero-output auto-retire
(
count_registrable_outputs == 0), the already-retired-producer dep skip(
dep_local_task_id < dep_last_task_alive), and the scope-gated fanout reference —are unchanged; the 36 intervening commits touch only
pto_orchestrator.cpp(diagnostics #1448, marker consolidation #1410, dispatch marker #1411,
early-dispatch #1405).
Reproduction range & fix: reproduced across
8cdb306c..HEAD62840403, overwhich
pto_ring_buffer.his byte-identical — so this is not a regression fromthose commits. The introducing commit was not bisected;
update_heap_tailadvancing the heap tail off
last_task_aliveis a long-standing design in theallocator, not a recent change. Fixed on our fork by
c396b6e7("Fix: decoupleFIFO heap reclaim from task watermark", which introduces the independent
heap_reclaim_task_id_cursor) [+611c79e9].Reproduction — full ST sources
Drop these into
tests/st/a2a3/tensormap_and_ringbuffer/heap_reclaim_watermark/, thenrun the command in Steps to Reproduce. No
src/change is required.test_heap_reclaim_watermark.pykernels/orchestration/heap_reclaim_watermark_orch.cppkernels/aic/kernel_spin_holder.cppkernels/aic/kernel_write_const.cppkernels/aic/kernel_copy_first.cpp中文总结
堆回收前沿被绑在 task 水位
last_task_alive;当队头是一个零 extent、scope-gated、COMPLETED但有滞后 consumer 的 task,它钉住水位,后面已消费的正 extent 字节就回收不了,明明有空间却报
HEAP_RING_DEADLOCK。最小 ST 用一个自旋 holder 门控 HC、H 与 HC 同 scope(让 H 在 HC 登记依赖时还 scope-gated 存活,否则会 auto-retire 钉不住),再用 3 个 1 MiB filler 填 2.5 MiB 环:upstream
mainHEAD62840403(及 fork point8cdb306c)死锁(last_alive=0, Head task 0 zero-extent, consumers=0/1),修复分支c396b6e7通过。修复=给 heap 独立的、与水位解耦的回收 cursor,跳过零字节队头;lib-free 回归测试见上方折叠块,真实 workload 佐证见 qwen prefill PTO2_RING_HEAP=4GB。