Skip to content

[Feature] execute_compiled cannot pass ring sizing (ring_heap) to CallConfig.runtime_env #2586

Description

@lwDavid

Background

pypto-lib-side problem. models/deepseek_v4_pro/prefill_attention_csa.py,
prefill_attention_hca.py and prefill_attention_swa.py fail on device with:

[ERROR] validate_runtime_impl: [runtime_maker.cpp:945] simpler runtime failed:
        orch_error_code=2 sched_error_code=0 runtime_status=-2
[ERROR] error detail: orch_error_code=2 HEAP_RING_DEADLOCK -
        the task allocator could not reserve the heap bytes required for another task

These are single-chip (L2) kernels. Their per-task args and intermediates
exceed the runtime's compile-time default output heap
(CHIP_HEAP_SIZE, 256 MiB per ring —
simpler src/a5/runtime/tensormap_and_ringbuffer/runtime/runtime_types.h:72).
Measured on device: 512 MiB per ring is already enough for all three.

The runtime exposes exactly the knob needed —
CallConfig.runtime_env.ring_heap — and pypto surfaces it as
RunConfig.ring_heap. There is no way to reach it from an L2 dispatch
driven by execute_compiled.

Reproduction environment

Component Version
pypto 750131ca
simpler (pypto runtime gitlink) dbdd041e
pto-isa (runtime/pto_isa.pin) be5ccb76
ptoas (toolchain/versions.env) v0.60
pypto-lib 4e488fb
Platform / runtime a5 (Ascend 950), tensormap_and_ringbuffer

Repro: DEEPSEEK_V4_VARIANT=pro python models/deepseek_v4_pro/prefill_attention_swa.py -p a5 -d <dev>


Summary

execute_compiled cannot carry per-task ring sizing
(ring_task_window / ring_heap / ring_dep_pool), so an L2 dispatch driven
by it always runs at the runtime's compile-time defaults. Please make
execute_compiled able to pass a RunConfig (or at minimum the ring fields)
through to CallConfig.runtime_env.

Motivation / Use Case

pypto has two parallel L2 dispatch surfaces, and ring sizing exists on only
one of them.

Surface A — the work_dir surface (execute_compiled). No ring parameter:

def execute_compiled(work_dir, args, *, platform, device_id, dfx=_DfxOpts(),
                     level=2, aicpu_thread_num=None,
                     analyze_auto_scopes_for_deps=False) -> None

It calls compile_and_assemble(work_dir, platform) and then
execute_on_device(...), which builds a fresh CallConfig() and sets exactly
aicpu_thread_num, the five DFX flags and output_prefix — and nothing else:

cfg = CallConfig()
if aicpu_thread_num is not None:
    cfg.aicpu_thread_num = aicpu_thread_num
cfg.enable_chip_swimlane = enable_chip_swimlane
cfg.enable_dump_args = enable_dump_args
cfg.enable_pmu = enable_pmu
cfg.enable_dep_gen = enable_dep_gen
cfg.enable_scope_stats = enable_scope_stats
if output_prefix:
    cfg.output_prefix = output_prefix

cfg.runtime_env occurs zero times in python/pypto/runtime/device_runner.py,
and ring_heap / ring_task_window / ring_dep_pool occur zero times in both
device_runner.py and ir/compiled_program.py (checked at 750131ca).

Surface B — the RunConfig / CompiledProgram surface. ring_heap is a
RunConfig field and reaches the device through a single application point:

RunConfig(ring_heap=...)
  -> compiled.build_call_config(rc)
    -> _build_call_config(...)                    # runtime/runner.py:979
      -> _apply_ring_overrides(cfg, rc)           # called at runner.py:1019,
        -> cfg.runtime_env.ring_heap = ...        #   defined at runner.py:957

Only three entry points reach it: ChipWorker.run(compiled, *args, config=rc),
benchmark(compiled, args, config=rc), and the L3 path
(distributed_runner._make_call_config).

Note compiled(*args, config=rc) is not one of them. _invoke_compiled
(ir/compiled_program.py:658) already holds a RunConfig and hands it to
execute_compiled — but only four fields survive the call:

execute_compiled(
    output_dir,
    coerced,
    platform=execution_platform,
    device_id=config.device_id,
    dfx=_DfxOpts.from_run_config(config),
    aicpu_thread_num=config.aicpu_thread_num,
)

The ring fields are dropped here silently. This is also why the fix below is
natural: the RunConfig is already in hand at the call site, it just has
nowhere to go.

Why this only became a problem recently. Ring sizing used to be the
PTO2_RING_* environment variables, which are process-global and therefore
worked on both surfaces. When simpler retired them and sizing moved onto
CallConfig.runtime_env, only Surface B was rewired. Surface A was left
behind, and with the env route gone an L2 execute_compiled dispatch now has
no way to size its rings.

Why this matters to pypto-lib specifically. run_jit(runtime_cfg=...) is
deliberately designed as a pass-through to execute_compiled's keyword
arguments
— no lib-owned field vocabulary, so pypto-lib does not have to
re-adapt every time the runtime surface changes. A knob that exists in
RunConfig but not on execute_compiled breaks that contract: the lib either
carries a custom field with custom routing, or the kernel simply cannot ask for
the knob. We would like to keep runtime_cfg a pure pass-through.

Measured today at the last Python frame before the mailbox
(simpler.worker.Worker.run), an L2 dispatch through execute_compiled
receives ring_heap = [0, 0, 0, 0], i.e. "fall through to the compile-time
default", with no way for the caller to change it.

Proposed API / Behavior

Preferred — let execute_compiled take a RunConfig:

def execute_compiled(work_dir, args, *, platform, device_id, dfx=_DfxOpts(),
                     level=2, aicpu_thread_num=None,
                     analyze_auto_scopes_for_deps=False,
                     config: RunConfig | None = None) -> None

forwarded to execute_on_device, which applies it with the existing
_apply_ring_overrides — the same code path Surface B already uses, so both
surfaces stay in step by construction.

This is preferred over adding three individual keywords because it makes the
two surfaces converge permanently: any future RunConfig knob then reaches
L2 with no pypto-lib change at all
, which is exactly the property
runtime_cfg was designed to have.

Minimum acceptable — three explicit keywords, forwarded the same way:

execute_compiled(..., ring_task_window=None, ring_heap=None, ring_dep_pool=None)

Secondary, same area. execute_on_device already has a parameter literally
named runtime_env:

def execute_on_device(..., runtime_env: dict[str, str] | None = None) -> None

but it is OS environment variables applied through _temporary_env(), not
CallConfig.runtime_env. The collision reads exactly like the surface being
asked for here, and since the PTO2_RING_* knobs were retired, setting it has
no effect on ring sizing at all. Renaming it (e.g. os_env / env_overrides)
would remove a real trap.

Alternatives Considered

  1. Environment variables — gone. PTO2_RING_TASK_WINDOW / PTO2_RING_HEAP
    / PTO2_RING_DEP_POOL were retired in simpler.
  2. compiled(*args, config=RunConfig(ring_heap=...)) — silently ineffective;
    _invoke_compiled drops the ring fields (ir/compiled_program.py:658).
  3. Route pypto-lib's L2 dispatch through ChipWorker instead — works, and is
    what pypto-lib PR
    #1089 currently does
    as a stopgap. We consider it the wrong layer: it forces pypto-lib to own a
    ring-key vocabulary and a second dispatch route, which is precisely what
    runtime_cfg-as-pass-through was meant to avoid. It also loses
    execute_compiled's dep-capture subprocess, so that route has to refuse
    swimlane / dep_gen runs. We intend to revert it once execute_compiled
    carries the knob.
  4. Raising the compile-time default — a global change that costs every
    program device memory for the benefit of a few.

Additional Context

Evidence that this is ordinary under-provisioning and nothing structural —
injected at simpler.worker.Worker.run, all outputs validated:

Case ring_heap (all four rings) Result
prefill_attention_swa default ([0,0,0,0] -> 256 MiB) HEAP_RING_DEADLOCK
prefill_attention_swa 512 MiB PASS
prefill_attention_swa 1 GiB PASS
prefill_attention_csa 512 MiB / 1 GiB PASS
prefill_attention_hca 512 MiB / 1 GiB PASS

One doubling of the default is enough. A stale comment in our tree claimed
4 GiB on all four rings still deadlocked; that measurement went through a route
which dropped the value, and does not reproduce.

Related: the ring sizing must also cover the benchmark dispatch, which is a
second, independent launch — sizing only the correctness run moves the deadlock
rather than removing it. benchmark() already accepts config=, so this is
only a note for whoever wires execute_compiled.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions