Span Groups#
Span granularity in NeMo-RL is controlled by span_groups in the telemetry: config block. The spec accepts a preset keyword, a comma-separated list of individual group names, or a mix (e.g. default,generation,reward).
For the general span-group mechanism — how gating works, why a disabled group costs ~nothing — see lens: span groups. This page covers NeMo-RL’s groups and the per-algorithm span hierarchy.
Preset keywords#
Preset |
Groups included |
Relative cost |
|---|---|---|
|
|
Lowest — safe for production |
|
|
Moderate |
|
every group ( |
Highest — dev/debug |
per_step deliberately omits per_prompt#
Every group in per_step emits a bounded number of spans per training step, so the preset’s cost scales with steps. per_prompt is the exception: its spans are emitted once per prompt, so a 10k-prompt rollout produces roughly 20k spans where the phase groups produce a fixed handful. Folding it in would make per_step scale with dataset size instead, which is not what someone asking for step detail is asking for.
Ask for it explicitly when debugging an individual rollout:
span_groups: per_step,per_prompt
or take all. See Per-prompt spans for what you get.
per_step includes job, and what that costs#
job is the whole-run root span, so enabling it alongside step nests every training step under one ever-growing trace rather than giving each step its own root. That is a real cost on a long run: the trace grows without bound and most backends get slow to render it.
It is included anyway because job is the only run-scoped span, and several things depend on one existing. current_trace_carrier() captures it to hand to the trajectory collector — with no job span the carrier is empty, the collector’s spans re-root, and the entire async rollout path vanishes from the waterfall (see Async rollout spans). A trace that is large is more useful than a trace that is missing its rollouts.
If you want the bounded-per-step shape instead, list the groups without job rather than using the preset:
span_groups: step,checkpoint,evaluate,setup,model_init,rollout,generation,logprob,reward,advantage,policy_update,data_processing,data_plane,efficiency
There is no subtraction syntax — SpanRegistry.resolve only unions presets and bare group names, so per_step,-job does not work.
RLSpanGroup#
Defined in nemo_rl/telemetry/span_groups.py, and declared to lens by register_span_groups() when that module is imported.
nemo-lens ships no span-group names of its own: each consuming library registers what it emits into lens’s SpanRegistry under its own namespace (NeMo-RL’s is nemo_rl), and telemetry.span_groups resolves against whatever is registered in the process. So the groups marked “base” below — the ones lens used to define — are declared by NeMo-RL too. They keep their original names, which are also the names Megatron registers for the same phases; a group two libraries both register is shared, and enabling it turns on both.
One consequence worth knowing: registration is an import side effect, so a group is only selectable once its module has been imported. nemo_rl/telemetry/setup.py imports this module before calling setup_telemetry for that reason.
Group |
Origin |
Controls |
|---|---|---|
|
base |
the whole-run root span ( |
|
RL |
startup, before the first step: |
|
base |
|
|
base |
|
|
base |
|
|
base |
|
|
RL |
|
|
RL |
the driver-side |
|
RL |
|
|
RL |
|
|
RL |
|
|
RL |
|
|
RL |
|
|
RL |
|
|
RL |
spans emitted once per prompt: |
|
RL |
idle phases on async GRPO — driver-side |
Examples#
telemetry:
enabled: true
# Coarse spans only — default
span_groups: default
# Per-step traces (rollout / generation / reward / advantage / policy update)
# span_groups: per_step
# Coarse job trace + generation spans only
# span_groups: default,generation
# Everything
# span_groups: all
Per-algorithm span names#
Span names follow rl.<algorithm>.<phase>, where <phase> is the Timer key the same block records — so a span and the timing/train/<phase> metric measuring it carry one name rather than two, and correlating a slow span with its timing series needs no mapping. The Timer key is the authority: it is pre-existing and already published as a metric name, so a new span takes its name from the timer rather than the reverse.
Two spans deliberately do not follow it. rl.<algorithm>.step wraps total_step_time and rl.<algorithm>.evaluate wraps total_validation_time: a span’s duration is intrinsic, so naming one after a total_*_time measurement is tautological, and these two are the umbrella spans a reader meets first in a waterfall. They are named after the operation instead. Every span inside them matches its timer key.
The controlling group is shown for each; a span is emitted whenever its group is enabled in the process that opens it.
Algorithm |
Spans |
|---|---|
Startup (all algorithms) |
|
GRPO (sync + async) |
|
GRPO (async only) |
|
GRPO / PPO (async only) |
|
PPO |
|
SFT |
|
SFT v2 |
|
DPO |
|
RM |
|
Distillation |
|
SingleController |
|
SingleController (rollout) |
|
Transfer queue |
|
NeMo-Gym |
|
vLLM (driver-side) |
|
vLLM (worker-side) |
|
Policy / value (worker-side) |
|
rl.<algo>.job is a function-level span (via umbrella_trace_fn) wrapping the whole run. Both shipped presets enable it, so each rl.<algo>.step nests under it. Drop the job group from an explicit group list to make every step a root trace instead.
Startup: what happens before the first step#
The setup group covers everything between process start and the first training
step. It is in both shipped presets: startup is a fixed handful of spans
emitted once per run, so it costs nothing at steady state, and “why was the first
step so late” is a question a coarse preset needs to answer. model_init is in
both presets for the same reason and travels with it — without it the worker
build shows as one opaque block with rl.vllm.load_model, usually its largest
part, missing from inside.
rl.startup (launcher)
├── rl.setup.ray_init rl.ray.cluster_source=started_local
├── rl.setup.tokenizer
├── rl.setup.data (run_grpo.py)
├── rl.setup.nemo_gym_config (single controller, if gym is on)
└── rl.setup.workers
├── rl.vllm.load_model (generation worker, separate trace)
├── rl.policy.load_model (training worker, separate trace)
└── rl.value.load_model (PPO critic, separate trace)
The three worker-side loads carry rl.backend — megatron or automodel for
the trainer, so one query compares the same phase across backends — and each is
emitted once per worker process, so a slow rank shows up as one long span among
its peers rather than an average.
rl.startup exists because init_ray() and the algorithm’s setup() are
separate top-level calls in the launcher; without a span across them their
phases arrive as unrelated root traces. It closes before training begins, so
the job span and everything nested under it form a trace of their own rather
than joining startup’s.
rl.setup.ray_init is emitted by init_ray() itself, so every launcher gets it
without opting in. It carries rl.ray.cluster_source:
attached_external (a cluster ray.sub or KubeRay already had running),
reused_local (one an earlier NeMo-RL run left behind), or started_local (paid
to boot a new one). Attaching and booting differ by tens of seconds, which is the
usual reason two otherwise identical runs disagree on time-to-first-step.
No bucket on any startup span#
Unlike the other leaf groups, setup spans carry no rl.bucket at all. The
phases nest (rl.startup over rl.setup.workers over rl.vllm.load_model) and
the worker builds run concurrently under parallel init, so a rollup adding
them by bucket would multiply startup rather than measure it. The flat number
lives in the rl.setup.duration metric at phase=total_setup — see
Metrics — startup phases. These spans are for shape.
The initial buffer fill#
On async GRPO the driver blocks before its first step until the replay buffer
holds a full batch. That wait is rl.init.total (category init/total), a child
of no step — it happens before the loop.
It carries no rl.bucket, for the same reason as the collector’s loop waits:
the generation fleet is busy for that entire window and its
rl.grpo.generation spans join the same trace, so bucketing the wait would
charge one stretch of wall clock to two buckets. The init/total metric keeps
its overhead bucket, because it is read as a single per-run number rather than
summed beside sibling spans. The membership list for this rule is
UNBUCKETED_SPAN_CATEGORIES in nemo_rl/telemetry/instrumentation.py.
Async PPO records the same init/total timer but emits no span for it yet.
Async rollout spans come from the collector actor#
In an async run, no rollout is generated on the driver. Every trajectory comes
from inside AsyncTrajectoryCollector, a separate Ray actor, which calls
init_telemetry_worker(rank=0, world_size=1) in its constructor. Explicit rank
because it is a singleton, not a member of a ranked group, and its runtime_env
is a copy of the driver’s environment — so without this its spans would carry
whatever RANK the driver happened to have. The driver reports itself the same
way, for the same reason.
It flushes through flush_telemetry(), which the driver calls before ray.kill,
since a kill runs no atexit handler. That call stops the collection loop and
waits (bounded) for in-flight batch workers first: the shutdown is terminal, so
a still-running thread would keep opening spans against a dead processor.
Each batch worker opens one rl.grpo.generation span — the same name the sync
path uses on the driver, so the two modes read alike — carrying
rl.weight_version, rl.target_weight_version, rl.num_generations_per_prompt
and rl.num_prompt_groups. The last one is the batch width: a gap-filling batch
covers a fraction of a full one, so without it a short span looks like an
unexplained speed-up. It is in the rollout group, so it is an
umbrella and carries no rl.bucket: several batch workers run at once, so
their durations sum past wall time and cannot enter a bucket rollup.
rl.sc.generate_and_push is an umbrella for the same reason, though it sits in
per_prompt rather than rollout (see Per-prompt spans).
The SingleController dispatches one asyncio task per prompt group, bounded by
async_rl.max_inflight_prompts — num_prompts_per_step in most recipes and
1280 in one — so that many spans can be open at once. Tagged productive they
would sum to a large multiple of the wall clock they happened in. On the sync path the
productive generation term comes from the rl.vllm.generate spans on the
generate() call instead; the async single-controller path has no generation
span yet.
It also carries rl.rollout.attempt, because the span is opened inside the
dispatch retry loop: a skipped group is substituted in place and the loop opens
another span. attempt > 0 is generation whose tokens were discarded — the same
thing async GRPO reports as wasted/failed_trajectory.
Getting the collector into one waterfall#
Ray does not propagate OTel context, so an actor’s spans start their own trace
by default. The driver captures its active span as a W3C traceparent carrier
with current_trace_carrier() — taken inside rl.grpo.job, at the point the
collector is constructed — and passes it as the actor’s trace_carrier
argument. The collector reopens it with remote_trace_context() in both the
collection-loop thread and every batch-worker thread. Per thread, not once per
process: OTel context is a ContextVar, and threading.Thread inherits none.
The result is a single trace per run:
rl.grpo.job (driver)
├── rl.grpo.step (iteration 1) (driver)
│ ├── rl.idle.buffer_starvation
│ └── rl.grpo.policy_training
├── rl.grpo.generation weight=7 (collector, thread A)
├── rl.idle.generation_limit_pause (collector, loop thread)
├── rl.grpo.generation weight=8 (collector, thread B)
└── rl.grpo.step (iteration 2) (driver)
This requires the job group to be enabled. current_trace_carrier()
returns an empty dict when no span is recording, and remote_trace_context({})
is a no-op, so the collector falls back to root spans. per_step and all
both enable job alongside rollout/efficiency, so the unified view is what
you get by default:
telemetry:
span_groups: per_step # or: all
default is the exception: it has job but neither rollout nor
efficiency, so the collector emits nothing at all and there is no rollout
detail to attach.
Note the cost this buys. A run-long root span means one trace accumulating
every step and every rollout batch for the whole job. That is the deliberate
trade described in per_step includes job —
on a very long run, prefer an explicit group list without job and accept
per-step root traces.
Two consequences worth internalizing before reading an async trace:
One span per batch, not per sample.
generate_asyncis dispatched one coroutine per sample, so spanning it would emit thousands of mutually overlapping spans per step.There is no
productivegeneration span in async mode, and there cannot usefully be one.rl.vllm.generateis only reached through the synchronous rollout path, and an async run never takes it:async_grpo_trainrequires an async generation engine, so even validation goes throughgenerate_async, which carries no span today. Generation is a continuously-batched pipeline overlapping training, so its productive contribution is a utilization question — answered by fleet metrics — not a span duration. A span-derived goodput ratio on an async run therefore has no productive generation term; do not read it as “generation contributed nothing.”
NeMo-Gym spans cross the HTTP boundary#
A Gym rollout leaves the driver over two hops, and each needs its own mechanism:
driver / collector NemoGym actor Gym server
──────────────────── ───────────── ──────────
rl.grpo.generation
└─ run_rollouts.remote() ───▶ rl.gym.run_rollouts
Ray hop: └─ aiohttp POST ──────────▶ FastAPI
carrier kwarg HTTP hop: server spans
traceparent header
The Ray hop uses the carrier kwarg described above:
dispatch_with_trace_context injects it at the call site and
@accepts_trace_context on NemoGym.run_rollouts reopens it in the actor.
The HTTP hop needs no NeMo-RL code at the call site, because the request is
made inside Gym’s own client rather than by NeMo-RL. The NemoGym actor calls
instrument_aiohttp_client() in its constructor, which patches the aiohttp
client class so every outgoing request carries a traceparent taken from the
ambient context. That is why the Ray hop has to work first: with no context
attached in the actor, the header carries nothing useful.
The HTTP hop is gated on the per_prompt group, so it is off under both
default and per_step, and on under all or an explicit
per_step,per_prompt. The instrumentor spans every request off the global
tracer without consulting the enabled-group set, so whatever turns it on pays a
span per HTTP call for the rest of the run — prompts × turns × tool calls,
which is a higher volume than the ~2-per-prompt spans per_prompt already
exists to fence off. Gating it on rollout instead would put that volume in
per_step, whose contract is that its cost scales with steps rather than
dataset size. The group has to describe the volume, not the phase, even though
these spans sit inside the rollout.
Completing the trace on the server side is a change in the Gym repository, not
this one — its app has to call nemo.lens.contrib.fastapi.instrument_fastapi.
Until it does, the header arrives and is ignored, and Gym’s internal spans (if
any) stay in their own traces. Everything up to and including the client-side
HTTP span still nests correctly.
Two failure modes degrade quietly rather than breaking a rollout. Without
nemo-lens[aiohttp] installed the constructor logs a warning once and Gym’s
HTTP calls start their own traces; with telemetry disabled entirely both calls
are no-ops.
Per-prompt spans#
Two spans on the SingleController path are emitted once per prompt rather than once per step or per batch:
Span |
Emitted |
Bucket |
|---|---|---|
|
one per dispatch attempt, in the SC actor |
none (umbrella) |
|
one per group commit, nested inside the above |
none (umbrella) |
They share the per_prompt group, which is a cardinality axis rather than a
phase — the one group in RLSpanGroup that does not name a stage of work. What
governs whether you want these is not that one is a rollout span and the other a
transfer-queue span; it is that their count scales with the prompt count. A
10k-prompt rollout emits roughly 20k of them, against a fixed handful per step
from every phase group. That is why they are reachable only from all or an
explicit per_step,per_prompt.
If 20k per rollout is more than you want, the only lever is per_prompt itself
— the two go together. Dropping data_plane does not thin them out: inside a
rollout the put is gated on per_prompt rather than data_plane (see below),
so data_plane governs only the batch-shaped data-plane spans. With
per_prompt off, transfer-queue time inside a rollout still shows up as a gap
between the rl.sc.* phase spans.
Why the group has to come from the caller#
rl.data_plane.put is emitted by MetricsDataPlaneClient, and there is one
client per process, shared by callers with wildly different cardinality:
SC actor process, one client from build_data_plane_client()
├─ RolloutManager → TQReplayBuffer.commit → put_samples() once per prompt
└─ _advantage_stage(meta) → put_samples() once per batch
Same op, same client, counts orders of magnitude apart. So neither the op name
nor a constructor argument can distinguish them, and the client cannot see its
caller. The rollout path therefore marks its region with
instrumentation.per_prompt_scope(), a ContextVar the client consults — the
same mechanism bucket_scope
uses for the same reason. Entering the scope also means the put is gated with
the rollout span: turn per_prompt off and both disappear.
A rollout’s put is unbucketed where a batch stage’s put is overhead. That is
deliberate: rollouts overlap each other and training, so summing their durations
into overhead would push that bucket past the wall clock it happened in, by up
to max_inflight_prompts. The scope propagates to nested calls and to
coroutines started inside it, but not to raw threads — a data-plane call handed
to a thread pool would read as batch-shaped.
Coverage gaps#
A group being enabled does not guarantee spans: something has to emit them. Known blanks today, so an empty trace is not read as a broken exporter:
Area |
State |
|---|---|
SGLang / TRT-LLM / Megatron generation workers |
uninstrumented — no |
|
no span, so async rollouts and async validation have no generate breakdown under |
|
the sync data-plane counterpart of the async collector — uninstrumented, so its rollouts produce no spans |
Worker flush outside async GRPO |
only |
|
no spans |
Startup phases inside |
|
|
|
|
the timer is recorded, but |
SingleController generation workers |
the actor’s own phases are instrumented and the |
SingleController token-capture dispatch |
with |
|
these call the instrumented loops but never |
Non-presharded worker calls |
the presharded data-plane entrypoints are parented, but |
Worker |
|
Granularity guidance#
Span groups |
Relative cost |
Recommendation |
|---|---|---|
Disabled ( |
None |
The default |
|
Lowest |
Safe for all production runs |
|
Moderate |
Per-step profiling; one trace for the whole run |
|
Highest |
Development / deep debugging |
A process with telemetry disabled has an empty span-group set — is_span_group_enabled() returns False everywhere, so no span objects are created at all. The disabled path is a frozenset lookup and an immediate return. See lens: architecture.