Skip to content

[BugFix] Fix AsyncEnvPool, weight-sync and multi-collector replay storage correctness bugs - #4701

Merged
theap06 merged 9 commits into
pytorch:mainfrom
theap06:fix/v015-envs-collectors-correctness
Nov 7, 2026
Merged

theap06 merged 9 commits into
pytorch:mainfrom
theap06:fix/v015-envs-collectors-correctness

Conversation

@theap06

@theap06 theap06 commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Fixes crashes, hangs and stale weights in AsyncEnvPool, weight synchronization and multi-collector replay storage. Each fix has a regression test that fails or hangs on main.

  • Collector(AsyncEnvPool(...)) no longer crashes: env_index is written as a NonTensorStack on every call.
  • The AsyncEnvPool shared-memory exchange now carries keys added by outer transforms, rewards on _step, and partial-reset masks.
  • AsyncEnvPool shutdown no longer spins forever on a closed result queue.
  • MultiProcessWeightSyncScheme workers keep their own copy of the weights instead of reading the trainer's live tensors (which let policies act on half-written weights). Weights are sent in one shared buffer. Adds a weight-sync benchmark.
  • Syncing a non-policy model (e.g. a postproc module) no longer hangs the trainer; a worker that fails to apply weights now raises an error.
  • Multi-process collectors with a replay buffer lay out storage with the workers' time length, fixing a worker crash with batched envs.

Breaking changes

  • MultiProcessWeightSyncScheme workers no longer see trainer weight changes until update_policy_weights_() is called. All in-tree users already call it. Each worker holds one extra copy of the weights, and an 8M-parameter send takes ~12 ms instead of ~1 ms.
  • A worker that fails to apply weights now makes update_policy_weights_() raise instead of hanging.
  • With backend="threading" and stack="lazy", AsyncEnvPool.step(td) no longer writes "next" into the caller's td; use the returned tensordict.
  • The shm exchange passes through unknown input keys instead of raising KeyError.

Motivation and Context

These crash a collector, hang the trainer, or let a policy act on half-written weights.

  • I have raised an issue to propose this change (required for new features and bug fixes)

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation (update in the documentation)
  • Example (update in the folder of examples)

Checklist

  • I have read the CONTRIBUTION guide (required)
  • My change requires a change to the documentation.
  • I have updated the tests accordingly (required for a bug fix or a new feature).
  • I have updated the documentation accordingly.

AsyncEnvPool.step, _step and step_and_maybe_reset wrote "env_index" into
their input as torch.arange, while reset, _reset and the registered
NonTensor spec use a NonTensorStack. A Collector keeps that input as its
carrier and stacks it into a rollout buffer built from the spec, so
Collector(AsyncEnvPool(...)) failed on its first batch with every backend.

All five entry points now share _set_env_index, which writes the
NonTensorStack expanded to the input batch size.
…, rewards and reset masks

With exchange="shm", wrapping the pool in a TransformedEnv or a Collector
did not work:

- write_input rejected every input entry outside the child schema, such
  as "step_count" and "episode_reward" from outer transforms or
  ("collector", "traj_ids") from a collector. These entries now stay in
  the parent process. For the public reset, step and step_and_maybe_reset
  they are merged back into the results, which is what the queue
  exchange and SerialEnv return. The private _reset and _step drop them,
  as their outputs never carry their inputs.
- The worker published the output of the private _step (reward, next
  observation, done flags) into the root of the result slot, which has
  no reward entry. It now publishes under the slot's "next" entry, which
  has exactly that schema, and _async_private_step_recv reads it from
  there.
- The slot schema had no "_reset" entry, so partial resets requested by
  an outer TransformedEnv could not reach the children. The exchange
  template now adds one boolean entry per reset key.

Non-tensor values at schema keys still raise.
…queue

ProcessorAsyncEnvPool._drain_result_queues treated every EOFError or
OSError from get_nowait as "item consumed, keep draining". That holds
when a removed item's tensors cannot be rebuilt, but a queue whose pipe
is closed raises OSError on every call without consuming anything, so the
loop never ended.

This is what hung test_recv_timeout_bounds_whole_call[threading-queue]
for 120 s in the optdeps CI run of 2026-11-06 (job 127602086906). The
pytest-timeout traceback shows the main thread inside AsyncEnvPool.__del__
-> shutdown -> _drain_result_queues -> get_nowait -> poll of a
multiprocessing pool. That pool came from the preceding multiprocessing
variant of the same test, where pytest.raises keeps it in a reference
cycle. The cyclic garbage collector reclaimed it during the threading
variant and finalized the queue connections before the pool's __del__
ran. The threading recv timeout logic itself is fine: that variant takes
2 s when no stale pool is collected during it.

After a failed get, the drain now checks whether the queue can still be
polled and moves on to the next queue if it cannot.
…the weights

MultiProcessWeightSyncScheme is documented as sending weight copies through
queues, but on CPU the workers ended up reading the trainer's own parameters:
pickling a tensor through a torch.multiprocessing queue moves its storage to
shared memory in place, the sender queued the live parameters, and the worker
bound the received tensors as its parameters (apply_weights(inplace=False)).
Every optimizer step on the trainer was therefore visible to the workers
without update_policy_weights_(), and an update landing during a policy call
produced a forward that read two weight versions (168 torn forwards out of
3000 steps in a 2-worker repro with an 8M-element parameter).

The sender now queues a shared-memory copy of the weights taken at send()
time (one per unique entry for the initial sync), the worker clones the
initial weights into private parameters, and every later update is copied
into those parameters while holding the collector's weight-update lock, the
same lock SharedMemWeightSyncScheme(atomic=True) uses. receive() reads the
queue before taking the lock so a manual receive does not block policy calls
while waiting for weights.

On CUDA the workers already received copies whenever the policy was not on
the worker device, so this aligns the CPU behavior with the documented
contract rather than changing it.

test_update_weights now expects MultiProcessWeightSyncScheme workers not to
see unsent trainer weights on CPU, and the tear regression test is
parametrized over both schemes.
…anging

With weight_sync_schemes={"policy": ..., "postproc.adv_module": ...} (as in
sota-implementations/ppo-async with advantage_on=worker), the receiver applied
the weights to the worker's module and then cascaded the update into
Collector.update_policy_weights_(model_id="postproc.adv_module"). The leaf
Collector only knew how to update its policy, so _maybe_fallback_update raised
KeyError("Collector has no local weight target ..."). The exception was
swallowed by the background receiver thread, no acknowledgment was sent, and
the sender waited forever (MultiProcessWeightSyncScheme) or for
WEIGHT_SYNC_TIMEOUT per worker (SharedMemWeightSyncScheme(atomic=True)).

Collector._maybe_fallback_update now resolves any other model_id as an
attribute path on the collector and copies the weights into that module in
place, so the cascade succeeds and single-process users can update such a
module the same way. A non-policy update no longer bumps the policy version
or notifies the policy of a weight update.

The receiver loops of SharedMemWeightSyncScheme and
MultiProcessWeightSyncScheme no longer leave the sender waiting when applying
the weights fails: the error is logged on the worker, a failure
acknowledgment is sent, and the sender raises a RuntimeError naming the
worker and the error once every targeted worker has answered, so later sends
stay aligned with their acknowledgments.

Tests: a MultiSyncCollector whose GAE postproc value network is synced by its
own scheme receives the trainer's weights and update_policy_weights_ returns
promptly, for both schemes; and a MultiProcessWeightSyncScheme update the
workers cannot apply raises instead of hanging, after which a valid update
still goes through.
…me length

A multiprocess collector initializes an empty LazyTensorStorage in the main
process by extending it with a fake batch of shape (*env_batch_size, 1), then
the workers write batches of shape (*env_batch_size, T) with
T = frames_per_batch_worker // env_batch_size.numel(). When the storage has
fewer dims than the batch (the default ndim=1 with a batched env), each stored
row was laid out with a time length of 1, so the first worker write with T > 1
failed with "indexed destination TensorDict batch size is [4, 1] ... differs
from the source batch size [4, 4]" and killed the worker. A single-process
Collector writing the same batches to the same storage works, since the
storage is laid out from the first real write.

The fake batch now has the same time length as the workers' batches, and
requested_frames_per_batch is set before the replay buffer is initialized so
that frames_per_batch_worker() can be used there. Unbatched envs and T == 1
get the same layout as before.

examples/satellite/sac_per_fully_async.py hit this in its CI configuration
(4 envs, 4 frames per env), but exited 0 because nothing in the main process
noticed the dead worker: the round-robin writer had already advanced the
buffer length before the failed write, so the trainer sampled the storage's
zero-initialized rows. The example now raises after shutting the collector
down if its worker exited with an error.
@pytorch-bot

pytorch-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/rl/4701

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit db67e25 with merge base c5a5e3a (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 6, 2026
Writing env_index as a NonTensorStack made AsyncEnvPool.step 11-15% slower
than main: tensordict.set moved every freshly built stack to the input's
device, and each child env then carried the NonTensorData through its step.
The stack is now built with its items already on the device (still fresh on
every call, so callers own the entry), and env_index is stripped from the data
dispatched to the children, which never read it (the workers write their own
index on the way back). Queue and threading are back to main's speed; shm
stays about 0.1 ms per step slower with mock envs. Adds a full-batch rollout
series to the AsyncEnvPool benchmarks.
A send copied every weight tensor into its own shared-memory segment,
which each worker then opened, so the cost grew with the number of
tensors. A tensordict of CPU weights is now consolidated into a single
shared buffer. For a 130-tensor policy a send takes 10.3 ms instead of
21.9 ms (8.3 ms on main, where workers alias the trainer's weights).
Adds test_multiprocess_weight_update to the collector benchmarks.
With exchange="shm", extra inputs under "next" were merged into the
next root returned by step_and_maybe_reset. The queue exchange and
SerialEnv drop them there, as step_mdp does.
@github-actions github-actions Bot added Benchmarks rl/benchmark changes BugFix labels Nov 7, 2026
@theap06
theap06 marked this pull request as ready for review November 7, 2026 07:11
@theap06
theap06 merged commit ff76e50 into pytorch:main Nov 7, 2026
125 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Benchmarks rl/benchmark changes BugFix CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Collectors Documentation Improvements or additions to documentation Examples Integrations/torch_geometric Integrations WeightUpdate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant