Host runtime trace markers — [STRACE]¶
simpler_run() spans several host-side stages (bind, runner_run,
validate) plus, inside runner_run's enqueue-through-drain lifetime, an
on-NPU AICPU window. TMR subdivides that window into preamble / SO-load /
graph-build / post-orch; HBG emits the whole device wall without those
device-orchestrator phases. The two headline walls (host_wall / device_wall, see
l2-timing.md) cannot show where the time goes.
[STRACE] markers are simpler's answer — host-side trace spans emitted to the
log, analogous to Android atrace/systrace. A consumer (e.g. pypto-serving)
reads the per-stage breakdown from the log, with no code change on its
side and no API contract: run() returns None, so markers (not a return
value) are the channel, and the log is the one sink the L3 parent and its L2
children share.
[STRACE] rides on the compile-time SIMPLER_HOST_STRACE macro (default on, in
src/common/task_interface/profiling_config.h — separate from the
SIMPLER_DFX gate on the device Orch/Sched markers) and is emitted at
LOG_TIMING (the default threshold) — no new env var or flag. In a
SIMPLER_HOST_STRACE-off build the RAII macros compile to nothing. In an
enabled build, Host call sites also read the live process threshold before
constructing span attributes; WARN, ERROR, and NUL therefore disable the
instrumentation work as well as the output. Device logging still uses the
initialization-time policy described in logging.md.
Where the records go¶
The host logger writes to stderr until it is given a directory, and then it
writes to <directory>/host.<pid>.log instead. The directory is
CallConfig.output_prefix — the one every other diagnostic artifact already goes
under — so a run that has one gets its host log beside its other artifacts, and a
run that does not keeps its records on the console. There is no separate switch to
configure, and the runtime never derives the path itself.
The destination belongs to the logger, not to a record. Everything that logger
writes follows it: LOG_* records, [STRACE] spans, [CLOCK_ANCHOR], the
host-orch phase= cost-share summaries, and the spans Python emits through
unified_log_host_span. Nothing declares an intent and no record kind is treated
specially, so there is no state in which part of a run's log is in one place and
part in another. The first non-empty directory in a process wins, and it is the
destination rather than a preference: a record the file cannot take — a path
that does not fit, a failed open, a failed write — is dropped and counted, not
relocated to stderr. That is what keeps "the log is complete" and
"dropped_record_count is zero" the same statement. stderr is the destination
only while no directory is bound.
Python's own log records are part of this stream too. Seeding the native
threshold installs a handler on the simpler logger that forwards each record
through unified_log_*, so a Python logger.warning(...) carries the same
envelope and the same clock as a C++ record and lands in the same place. It also
still reaches the root logger, because propagation is left alone — that is what
keeps a console readable and caplog working, and it makes the console a view of
the log rather than a second half of it. Records logged before a worker is
initialized have no handler to forward through yet and stay on the root logger.
See logging.md.
Each process has one bounded background writer. A producer formats a complete
record of at most 512 bytes and submits it through a 4096-slot lock-free queue;
after that writer is published, it never performs file/stderr I/O or waits for a
flush. The pre-writer hierarchical initialization window is the deliberate
exception: it writes synchronously so startup diagnostics survive the final
local fork. The writer appends each accepted record directly to the process
file, or to stderr while no directory is bound. Queue-full, bounded-claim
failure, and final output failure increment an explicit process drop counter, attributed by cause. A quiescent boundary writes that breakdown into the log as [HOSTLOG_DROPS] when it has grown, so a reader holding only the log can tell that the timing below it is derived from an incomplete one. Worker shutdown and os._exit()
call sites wait only boundedly for accepted records, so a stuck output cannot
turn task execution or teardown into an unbounded wait; a timeout reports both
pending and dropped counts. WARN, ERROR, and depth-zero spans use the same
async path as every other steady-state record. An overlong ordinary record is
truncated to 512 bytes and ends in ~\n.
Every DSO that compiles the logger holds its own buffered stream on that one
file, so the buffering above is per module rather than per process: a WARN from
one module does not put another module's pending records on disk. Unloading a
module closes its stream, so a dlclose — which the sim device runner performs
on the AICPU SO at every teardown — leaves no records behind in the mapping it
drops. A stream inherited across fork belongs to the parent and is left alone,
so a child never flushes the parent's copied buffer a second time.
Reading a run back¶
Every record carries its own pid, so the tools take several inputs and
concatenate them, and a directory expands to the host.*.log files inside it:
A process writes its [CLOCK_ANCHOR] ahead of its first record and into the same
stream, so each file is self-contained for wall-clock recovery. If an input is
truncated or a process's file is missing, the pids in it stay monotonic-only and
clockAnchors is absent from the output rather than wrong — the tool warns when a
pid emitted spans and no anchor was found for it, which is the signal that this
happened.
Marker grammar¶
Every host log record starts with a CLOCK_MONOTONIC nanosecond timestamp:
Each process emits one TIMING-level mapping from that clock to wall time when its logger starts:
For host-clock records, consumers recover an approximate absolute timestamp
with wall_ns + record_mono_ns - mono_ns. Their event ordering and duration
calculations remain entirely on the monotonic clock and are unaffected by
wall-clock corrections. Records tagged clk=dev use the separate device-clock
domain described below and do not use this anchor.
strace_timing.py applies that mapping to both --trace-out and --swimlane.
The visible Perfetto axis remains monotonic; each mapped host event exposes the
exact decimal wall_ts_ns and a UTC wall_time in its arguments, while the JSON
top level retains the source mappings in clockAnchors. Nanosecond epoch values
are strings because JSON consumers commonly use IEEE-754 numbers, which cannot
represent current epoch nanoseconds exactly. A log with no anchor for a pid gets
no wall time for that pid's events and is otherwise unaffected, and clk=dev
records never receive host wall time.
One line per span, emitted on scope exit
(src/common/log/include/common/strace.h):
| Field | Meaning |
|---|---|
v |
format version; the parser branches on it. Lets device-side markers align later by reusing the prefix + adding fields. |
pid tid |
process / thread id — L3 parent and each L2 child are distinct pids, so they land on separate lanes. |
inv |
64-bit process-wide simpler_run invocation id (allocated from an atomic, so (pid, inv) is unique even across concurrent calls) — a grouping key only (gathers one call's spans), NOT a token index. Set once per call. |
hid |
callable content hash (ELF Build-ID 64), stable across slot reuse / processes / runs. The parser buckets by hid; the most-frequent bucket is decode (one invocation per token), a once-seen bucket is prefill. |
depth |
thread-local nesting depth (++ on enter, -- on exit). The parser rebuilds the call tree from depth — not from timestamp containment. |
name |
dotted span name (self-locating even without the tree). |
ts dur |
start + duration in ns. Maps 1:1 onto a Chrome-trace "X" event. For host spans ts is CLOCK_MONOTONIC (steady_clock), same-host cross-process comparable. For clk=dev device spans (see below) ts is instead a device-clock start offset on a per-invocation origin — comparable to the other device spans (so the orch∪sched window is recoverable), not the host clock. |
k=v ... |
optional per-span attributes (e.g. ntensor=4); a parser that doesn't recognize one ignores it. |
Span names and attributes percent-encode control bytes and record delimiters.
They are length-capped (with ~ marking truncation) so each marker remains a
single atomic pipe write even when forked workers share captured stderr.
strace_timing.py decodes both on the way back in, so a consumer reading its
output sees the original text; a consumer reading the raw log does not.
Span tree¶
chip.run (= host_wall)
├─ chip.run.bind
│ ├─ chip.run.bind.args (ntensor=N: per-tensor device_malloc + H2D)
│ ├─ chip.run.bind.prebuilt (TMR: prebuilt runtime-arena cache hit or build + upload)
│ └─ .{arena_build,static_arena,gm_heap,shared_mem,runtime_init,host_orch,
│ graph_upload,arena_h2d,host_view_close}
│ HBG host prepare-path segments, with SIMPLER_HBG_BIND_BREAKDOWN_ENABLE=1
├─ chip.run.runner_run (device enqueue + completion drain)
│ └─ chip.run.runner_run.device_wall (whole on-NPU AICPU wall)
│ └─ .{preamble,so_load,graph_build,config_validate,arena_wire,sm_reset,post_orch,orch,sched}
│ TMR device-domain (clk=dev): AICPU subdivision of the on-NPU wall
└─ chip.run.validate
The device_wall span exists for both runtimes. Its
.{preamble,so_load,graph_build,config_validate,arena_wire,sm_reset,post_orch,orch,sched}
children are TMR-only; HBG orchestration runs on the host and stamps none of
those phases. All emitted device spans are tagged clk=dev. They are not host
steady_clock spans: the AICPU stamps raw sys-counter cycles into a host-allocated buffer
(whose address rides on KernelArgs::device_wall_data_base), the host reads it
back after stream-sync, converts cycles → ns, and emits the marker. orch/
sched are the orchestrator/scheduler windows that formerly only appeared as
device-log lines. A phase that was never stamped
(0 ns) is skipped — e.g. so_load is ~0 on a cached-callable run. See
device-phases.md for the device-side mechanism.
The phased native-run interface preserves this same marker contract. Prepare
allocates one inv and records the host-wall start; prepare, the child progress
path's launch/drain lifecycle, and finalize bind that (inv, hid) while
emitting their spans. Finalize releases the runner claim, destroys the per-run
state, and then emits the stored chip.run wall, so the root includes that
cleanup tail.
No trace scope or synthetic nesting remains active between C API calls. For
direct phased use the host wall is the full prepare-to-finalize lifetime,
including time the caller spends polling or doing other host work; blocking
simpler_run is the same phases composed back-to-back.
| Depth | Span names |
|---|---|
| 0 | chip.run |
| 1 | chip.run.bind, chip.run.runner_run, chip.run.claim_release, chip.run.validate |
| 2 | chip.run.bind.args, chip.run.bind.prebuilt, the other HBG chip.run.bind.* segments, chip.run.runner_run.device_wall |
| 3 | TMR phase spans chip.run.runner_run.device_wall.{preamble,so_load,graph_build,config_validate,arena_wire,sm_reset,post_orch,orch,sched} and optional task_slot_* spans |
Host scheduler spans¶
Every hierarchical worker that drives next-level children emits these spans
through the logger compiled into _task_interface, since the orchestrator and
scheduler code they run is the same at every level above the chip. The leading
word names the level that emitted them, so <level> below is one of node
(L3), network1 (L4), network2 (L5) or network3 (L6) — see
python/simpler/worker_level.py for the
ladder. It is never host: that names the processor this span ABI belongs to,
which every one of those levels runs on:
| Span | Decision point |
|---|---|
<level>.graph_build |
serialized Python graph callback |
<level>.submit |
next-level task publication after slot allocation |
<level>.dispatch |
scheduler handoff to a worker thread |
<level>.frame_submit |
local child mailbox-frame publication |
<level>.activate |
prepared-frame activation |
<level>.complete |
terminal child progress handling |
<level>.post_fence_retirement |
run erase + quiescent compaction, after the completion fence |
<level>.scheduler_loop |
one scheduler-loop iteration that drained a completion or dispatched a task |
Their attributes carry the available run_id, task_slot, group_index,
worker_id, dispatch_id, endpoint kind, and the dispatch's pipeline lease
(slot_id / generation).
<level>.scheduler_loop is the exception to that list and to the tree below: a
loop iteration serves whichever runs were ready, so it belongs to no run and
carries no key at all. Its own attributes are the counts:
| Attribute | Meaning |
|---|---|
drained dispatched |
completions handled and tasks handed to a worker in this iteration |
drain_ns |
how much of the span was phase 1, so the two phases are separable from one record |
spins |
iterations since the previous span that did neither |
An iteration that neither drained nor dispatched emits nothing. The loop
skips its condition-variable wait entirely whenever any worker is busy, so its
iteration count is bounded by CPU speed rather than by work — one span per
iteration would be unbounded, and the ratio that matters is recoverable without
them: the gap between two spans on the scheduler lane is the wait, and spins
reports how many empty iterations the gap contained.
The word is resolved once per process, from its Worker's level, and the first
binding wins: a SpanScope keeps the name pointer it was handed, so a process
that inits Workers at two levels keeps the first one's word and logs a warning
rather than relabelling spans that are still open. One process therefore carries
one vocabulary — which is what makes an L4 run readable, since its own spans say
network1. while each of its L3 children says node..
One process contributes at most two host lanes, because the scheduler runs on
one thread: the facade thread emits <level>.graph_build and <level>.submit,
and the scheduler thread emits the other five. role=worker on
<level>.frame_submit, <level>.activate and <level>.complete names the
worker a dispatch targets, not the thread that ran it.
The spans reach the logger over the fixed POD SimplerHostSpan ABI in
common/host_span.h. _task_interface compiles the host logger directly, so
there is no nullable sink and host-span support cannot disappear because a
separate logger DSO was absent. Every other host consumer also compiles a
private logger implementation, then binds it to the
SimplerHostLogState owned by _task_interface. The hierarchical parent seeds
that state before fork(); a chip child re-seeds its inherited copy and passes
the same pointer to every runtime module it loads. The threshold and one-anchor
coordination are therefore shared within each process without relying on
RTLD_GLOBAL logger symbols. A private logger is silent before that binding;
afterward its enabled query follows the shared Host threshold on every span.
ext. — spans from outside simpler¶
Every level word above belongs to simpler, so a span from any other producer
leads with the reserved word ext. and names itself:
All three segments are required. ext.foo names no span of its own and
ext..foo names no producer, so neither attributes to anyone — such a span is
still recognized as external (it can never be mistaken for ours) but renders in
an unattributed lane. A producer segment may itself be one of our level words:
ext.host.foo attributes to a producer called host and remains external.
What the namespace guarantees, in both directions:
| Guarantee | What holds |
|---|---|
| Visible in | --swimlane, on the emitting pid/tid. This is the only view that renders external spans. |
| Excluded from | the TPOT, rounds and --tree tables and --trace-out, all of which key on (pid, inv). inv is our native run epoch and no public surface exposes it, so admitting external spans would collapse every one of them into a single forged invocation. |
| Cannot affect | lane naming, lane splitting, or dispatch-flow pairing. Our views infer those only from our own spans, so role, slot_id, run_id and depth on an external span carry whatever meaning its producer wants. |
| Process label | a process that emitted only external spans is labelled external producer <name> (pid=N), with no simpler prefix. A producer emitting into one of our processes — the common case for a caller of the public API — leaves that process labelled as ours. |
The contract is executable: tests/ut/py/test_strace_timing.py asserts each row
above under the ext. heading near the end of the file. A repository adapting to
this namespace can read those tests as the specification and mirror them against
its own emitter.
Emitting your own spans with simpler.trace¶
A caller puts its own phases on this timeline through simpler.trace
(python/simpler/trace.py), rather than
formatting records itself. One producer, one span, one gate is the whole surface:
from simpler import trace
with trace.span("my_phase", batch=16, layer=3):
...
@trace.span("my_func")
def f(): ...
with trace.span("checkpoint", token=17): # a marker is a span of no work
pass
if trace.enabled(): # only then build costly attributes
with trace.span("expensive", **compute_attrs()):
...
A library names itself, so its spans attribute to it wherever it is imported; a
caller that names nothing writes under ext.app.:
tracer = trace.producer("pypto") # -> ext.pypto.<span>
with tracer.span("decode_layer", layer=3):
...
There is deliberately no separate instant/marker call. A zero-duration event
would be a second event concept, and dur=0 is already what our own emitting
side writes for a phase that was never stamped — c_api_shared.cpp skips a device
phase whose duration reads back 0 — so a public API producing it would put two
meanings on one value. A marker is a span whose body does no work, which records a
real short interval instead.
Three properties hold by construction, which is the reason to call this rather
than the seven-argument _emit_host_span underneath it:
| Property | How |
|---|---|
| One clock | the wrapper reads _monotonic_now_ns, the extension's binding for the steady_clock every host record's prefix and every C++ span is stamped with. A caller never handles a timestamp, so this does not rest on Python's time.monotonic mapping to the same clock as C++'s on the platform in hand. |
| One namespace | every name is prefixed ext.<producer>., so trace.span("node.dispatch") emits ext.pypto.node.dispatch — one of our level words is only ever a leaf. Nothing validates the name against them, because nothing a caller passes can reach them. |
| One gate | trace.enabled() is unified_log_host_span_enabled(), the same runtime query the C++ emit sites read, and it is false in a build with host spans compiled out. Every producer asks that one query. |
inv, hid and depth are our correlation keys and stay off this surface — a
caller has no value for them and no external span reaches the views that read
them, so each record carries 0. Correlation goes in an attribute like any other
field. A producer name is one dot-free word (attribution splits on dots); a span
name may be dotted to nest, and both are percent-encoded by the record writer, so
neither can inject the record grammar. The default producer is the constant app
rather than a name derived from the running program: every such derivation is a
heuristic with its own special cases, and one trace.producer(...) call names an
application better than any of them.
Cost of one with trace.span(name, k=v, k2=v2) attempt, measured on this repo's
aarch64 dev box (median of 7 batches, 200k iterations each): 995 ns with the
gate closed against 8998 ns emitting. The closed-gate figure is dominated by
Python itself — an empty pure-Python with block costs 272 ns there and the gate
query 166 ns — so a genuinely hot loop should ask trace.enabled() once rather
than open a span per iteration. That is what the query is public for.
This API does not make the record format a supported external contract. The
v=1 field exists for that decision; making it is separate from offering the
emitter.
It is also not a side channel for callers. [STRACE] is the one timeline
format (see Reading the markers below), and a caller's interval is a span for
the same reason a runtime's own bind segment is. What the reserved namespace
separates is whose span it is, not which format it uses — a producer outside
this repository writes the same records, on the same clock, through the same
gate, and they render on the same lanes.
Reading the markers — strace_timing.py¶
# TPOT table (per-callable, decode = most-invoked hid bucket)
python -m simpler_setup.tools.strace_timing path/to/host_or_device.log
# also emit the established per-invocation call-tree JSON
python -m simpler_setup.tools.strace_timing path/to/log --trace-out strace.json
# emit the L3/L4 host scheduler timeline on real OS pid/tid lanes
python -m simpler_setup.tools.strace_timing path/to/log --swimlane host_swimlane.json
# ... and subdivide the bind stage with a runtime's own per-event records
python -m simpler_setup.tools.strace_timing path/to/log --swimlane host_swimlane.json \
--host-phase-records outputs/<case>/host_phase_records.jsonl
The tool groups by (pid, inv), rebuilds each invocation's tree from depth,
buckets by hid, and prints each callable's mean chip.run plus per-stage
means. With --trace-out it writes one ph:"X" event per span on a synthetic
per-invocation lane, so each call renders as an isolated nested tree in
Perfetto / chrome://tracing.
--swimlane is a separate view. Host slices keep their real OS pid/tid, and
task submission-to-dispatch handoffs render as flow arrows.
A runtime subdivides a stage it owns with these same markers. [STRACE] is
the one timeline format: an interval a reader can place on a timeline is a span,
whoever measured it and however it was captured. chip.run.bind.args and
chip.run.bind.prebuilt come from the tensormap runtime rather than the
platform; the device sub-phases are the TMR AICPU's own breakdown, read back from
a cycle buffer and re-emitted as spans; and ext. (above) admits producers
outside this repository entirely. So the grammar was never a fixed per-run-stage
set, and a stage's segments do not need a second format.
What the collection mechanism may be, on the other hand, is open: an RAII scope,
a fixed-slot device buffer, or a host record pool are all ways to measure, and
each one ends by emitting a span. --host-phase-records reads such a pool from
the artifact the runtime wrote and draws the per-event operations inside their
chip.run.bind, matched on (pid, inv). Both sides are the same
CLOCK_MONOTONIC axis, so nothing is converted. A bind segment the log already
carries as a span is skipped rather than drawn twice, and the log's copy is the
one kept — it carries the segment's attributes, where an artifact record carries
only detail. See
host_build_graph's profiling levels
for what that runtime records, and
hbg-bind-phases.md for what those segments are and
what produces both the log and the artifact on a real decode network.
One exception, because a K-deep pipeline is not K threads. The direct-chip
lane drives prepare(N+1) and finalize(N) from the same OS thread, so a 40-run
K=2 stress produces 40 overlapping run lifetimes on one tid. Perfetto nests
slices by timestamp containment within a track, so flattening them there puts
run N+1's spans inside run N's root — false nesting that hides the very
overlap the view exists to show. A thread whose depth-0 spans overlap is
therefore split into one lane per pipeline slot (pipeline slot 0 (tid …)),
which reads as a plain sequence because a run holds its slot exclusively. The
overlap then shows where it belongs: across lanes. Each slice keeps os_tid in
its args, and a thread that ran sequentially — the L3 scheduler, which carries a
slot but never interleaves — keeps its real tid.
Chrome Trace JSON has only one visible timestamp axis, so putting the raw
per-invocation device clock beside CLOCK_MONOTONIC would create a multi-day
empty interval. The converter therefore keeps clk=dev records, with their
original ns timestamps, in the top-level unalignedDeviceSpans array instead of
traceEvents; it does not guess a clock offset. Perfetto opens directly on the
host activity, while the existing tables, tree, and --trace-out still provide
the device-phase timing views.
Async pipeline proof¶
The phased native lane claims that run N+1's preparation runs concurrently
with run N's device execution. That claim is checkable from a captured log
without any new marker family: the windows are already in the chip.run
tree, and the root span already carries the identity that tells two runs apart.
| Property | Read from |
|---|---|
| successor's preparation | chip.run.bind — its arena build + host orchestration |
| predecessor's device work | chip.run.runner_run |
| when a successor may launch | chip.run.claim_release |
| which run each belongs to | root chip.run attrs, joined by (pid, inv) |
Only claim_release was added for this: it wraps release_native_run inside
finalize, the point a successor's launch becomes admissible, and no other span
marks that boundary. node.post_fence_retirement covers the L3 orchestrator's
release_run tail for the same reason.
The identity is run_id / dispatch_id / run_epoch / slot_id / generation. Each
field means one thing: run_id and dispatch_id are zero on the direct-chip
lane, which allocates neither, and run_epoch is a per-process monotonic counter
that is always set — so it is what orders runs when the other two are absent.
NativeDispatchIdentity.sequence makes that choice in the parser, where it is
visible, rather than in the record.
Per adjacent run on one child process, the command requires bind(N+1) to
overlap runner_run(N) — the intervals intersect — and runner_run(N+1) not
to start before claim_release(N). It exits nonzero on a missing identity, a
missing span, or an ordering violation.
Reading bind rather than the whole prepare is deliberate: bind sits inside
prepare, so an overlap it reports is one the prepare certainly had.
--require-hidden-prepare adds the stronger claim that the preparation also
finishes inside the predecessor's device window — fully hidden rather than
merely concurrent. That is a statement about pipeline depth and it is sensitive
to host scheduling, so it is opt-in and the scene test asserts only the overlap
property.
The scene test carries its own negative controls¶
tests/st/a2a3/host_build_graph/concurrent_prepare_stress drives three arms
through one pipeline driver and reads this same verdict, so each control differs
from the positive arm in exactly one variable:
| Arm | Variable moved | Required verdict |
|---|---|---|
| overlap stress | — | accepted, one check per adjacent pair |
| serial submission | one run in flight instead of two | rejected, did not overlap |
| diagnostics config | enable_scope_stats set |
accepted |
| orch-phase swimlane | enable_chip_swimlane 4 |
rejected, did not overlap |
The second arm is what makes the first a detector rather than a formality.
Between the pipeline and the verdict sits a chain — which spans are emitted,
where their endpoints land, bind standing in for preparation, runner_run
being a host wall span that includes caller polling — and if any link reported an
intersection independent of real concurrency, the positive arm would still be
green. Matching the message matters: it separates a real rejection from the
vacuous "need at least two complete native runs" one.
The last two hold a boundary that is otherwise invisible.
allow_prepared_successor once folded in CallConfig::diagnostics_any() — the
OR of all five diagnostic flags — because a collector's setup wrote
runner-global state during preparation, which a prepared successor would have
done while its predecessor was still running against it. The pools and that
per-run state are now built and reset under the execution claim, so a diagnostic
configuration overlaps like any other.
Level 4 is the exception, and the reason it is not simply the same case: a
host-orchestrating bind opens a clock-correlation session on the resident
swimlane collector and samples its HostOrchestrationBegin anchor into it. That
cannot move under the claim — the anchor's meaning is when host orchestration
began — so until the session is per-run, a level-4 run neither carries a
prepared successor nor is one. Nothing else would notice either boundary
moving: the lane declines to stage silently rather than raising, so a run that
lost its overlap and one that kept an overlap it should not have look the same
from outside.
Staging has three inputs and only that one is reachable from a submission. The
other two — the runtime PipelineContract's pipeline_depth and the runtime's
concurrent-prepare capability symbol — are compile-time properties: every onboard
runtime declares depth 2 (PTO_PIPELINE_MAX_DEPTH is 2) and returns 1 from the
capability impl, while the sim platform hardcodes 0, so overlap never happens
under simulation. Because neither is configurable, being unable to stage a
successor is a failure in the scene test rather than a skip — a skip would
report green for the one state in which the property cannot hold. The platform
gate runs first, so the sim path never reaches that assert.
The two negative arms differ in how long they are meant to last. The serial one
names no mechanism — one run in flight cannot overlap under any admission policy
— so it is permanent. The diagnostics one is deliberately perishable:
concurrent_native_prepare_supported_impl keeps collector-bearing configurations
sequential only until their state is per-epoch, and once that lands and
diagnostics_any() leaves allow_prepared_successor, this arm fails with the
very did not overlap it now requires. Delete it then; do not restore the
serialization — its value and its lifetime both come from the fallback being
silent.
Why markers, not a return value¶
Android's atrace writes to the ftrace trace_marker sink and systrace renders
it; nobody changes their code to be observed. [STRACE] mirrors that: the
runtime emits, tooling renders, the caller is untouched. Concretely, run()
returns None: an L3 DistributedWorker.run has no single device wall, and a
return-value channel could not carry each L2 child's host/device breakdown up
anyway. The log can. This is also why device phases are emitted as markers from
the host C++ rather than threaded back through any return struct to Python.