Companion illustration for
NVIDIA-NeMo/RL #3592.
Permalinks pin the PR head 5b4a732.
Seven pages on this PR, one question each โ this one: one bug, start to finish.
The health ledger keeps a small record per generation shard: what state it is in, how many
requests it has failed in a row, how many restarts it has used. When a shard is restarted, the
process is destroyed and a new one takes its place, so most of that record is about something
that no longer exists.
mark_loaded
is where the ledger is told the replacement is up, and it clears two of the counters, with the
reason written next to them: "carrying that count into a fresh engine would let a single
unlucky probe re-condemn it immediately, burning a restart attempt for nothing." That reason
is right. It also applies to two fields it does not clear.
Shard 1 of four. The thresholds are the shipped defaults: three failed requests in a row condemn a shard, and a shard gets five restarts before it is retired for good.
"Partial" is about the weights, not the state. A refit is a weight transfer
from the trainer to the engines. If one is aborted halfway, every engine that was receiving is
left holding a mix of old and new weights โ a partly-applied update.
mark_weights_partial is what records that, by moving the
shard to STALE so it stays in the next refit but serves nothing meanwhile.
So state_before_partial means "what this shard was before the abort
pulled it out of service". It is written in
exactly one place โ that function, and only when the shard is not already
STALE โ so it never records STALE itself. That guard is deliberate: the
field exists so a completed refit can put the shard back where it was, and "back to
STALE" would say nothing.
It never decides whether a shard joins a refit. That is decided by the
state on its own: DEAD, RESTARTING and RETIRED are
treated as absent and left out; STALE is not. A
STALE shard takes part in the transfer normally โ that is the only way it stops
being stale. This field is read once, at the very
end of a completed refit, and only to pick which state the shard goes
back to.
And it is not a shortcut back to HEALTHY โ it is what makes that harder. Without it a suspect shard would go SUSPECT, then STALE when a refit aborts, then straight to HEALTHY when the next one lands, and its failure count would be wiped by a refit that had nothing to do with why it was failing. That count is the only thing that can catch an engine which answers health checks and still fails every request, so wiping it means such an engine is never caught. The field keeps the suspicion alive across the abort.
That is why the third column still reads SUSPECT.
condemn_silent_participant moves the shard to
DEAD and does not touch this field at all. Only one other line ever changes it:
report_refit, which reads it and clears it in the same
breath. Nothing in between โ not the restart โ resets it, which is the whole bug.
It would be easy to read the figure as a rare interleaving. It is not โ on the path that
condemns a shard for going silent inside a weight transfer, the third column always leaves
state_before_partial set to SUSPECT.
That is because
suspected_shards()
is defined as "shards that are STALE and whose state_before_partial is
SUSPECT", and
the recovery
picks its culprit from exactly that list. The test that chooses the shard is a test that
the field is set, so every shard condemned this way carries it into DEAD. Nothing
between there and the next refit ever clears it.
Run against the real module, with no Ray and no GPU:
A brand-new engine that loaded cleanly and completed a full weight transfer comes back
SUSPECT, holding the dead engine's failure count, one request away from being killed
again. report_refit takes an
early return
on that branch, so it skips the reset that would have zeroed the count.
The first three events are the same either way: the shard really was failing requests, and it really was the right one to condemn. Everything below is about the last two โ what the restart hands to the refit, and what the refit does with it.
The edit that gets there:
Both lines go in mark_loaded, in the block it already uses to clear the probe counters โ after line 426, before the transition on line 427. Nothing else moves.
The bug in one line: a guard built to survive one aborted refit also survives a new process. Keeping a suspicion across an abort is right โ the engine is the same one, and it is still suspect. Keeping it across a restart is not: that engine is gone, and the record outlives it.
What this costs. One spurious condemnation, and it is not cheap: the shard is
killed and reloaded again โ minutes with a quarter of the fleet down โ and one of its five
restarts is spent, all triggered by a single unrelated failed request. It does not repeat, because
report_refit clears the field as it reads it, so the shard is correct from the second
cycle on. But it is a real restart of a healthy engine, and it makes the restart budget a
misleading number: a shard can be two restarts from retirement having only genuinely failed once.