Describe the bug
On current master (b2145896daeddefcb70d9301c04c4d0a03b71bb6 as of 2026-06-12), the ZeRO stage 1/2 overlap communication path appears to assume that the stream current at average_tensor() time is the same stream that produced all contiguous gradient bucket writes.
This assumption can be false when the gradient hooks are triggered from different autograd/compiled-backward streams. In a profiler trace, the copy_ operations that write parameter gradients into the IPG buffer are distributed across multiple device streams, while average_tensor() only makes the reduction stream wait for get_accelerator().current_stream() at the time average_tensor() is called.
As a result, reduction_stream may start reading the IPG buffer (div_, narrow, cat/concat, all-reduce/scatter path) before all streams that wrote slices of that same bucket have completed.
Relevant code on master
The bucket copy is done in reduce_independent_p_g_buckets_and_remove_grads():
|
new_grad_tensor = bucket.buffer[bucket.index].narrow(0, bucket.elements, param.numel()) |
|
new_grad_tensor.copy_( |
|
grad_reduc.view(-1) if not self.zenflow else grad_reduc.permute( |
|
*reversed(range(grad_reduc.ndim))).contiguous().view(-1)) |
new_grad_tensor = bucket.buffer[bucket.index].narrow(0, bucket.elements, param.numel())
new_grad_tensor.copy_(
grad_reduc.view(-1), non_blocking=self.device == get_accelerator().device_name())
The bucket is later reduced here:
|
self.average_tensor(bucket.buffer[bucket.index].narrow(0, 0, bucket.elements), comm_dtype) |
self.average_tensor(bucket.buffer[bucket.index].narrow(0, 0, bucket.elements), comm_dtype)
But average_tensor() only waits for the current stream at the moment it is called:
|
def average_tensor(self, tensor: torch.Tensor, communication_data_type: torch.dtype): |
|
if self.overlap_comm: |
|
stream = self.reduction_stream |
|
if not get_accelerator().resolves_data_dependency(): |
|
stream.wait_stream(get_accelerator().current_stream()) |
|
get_accelerator().current_stream().wait_stream(stream) |
|
else: |
|
stream = get_accelerator().current_stream() |
|
|
def average_tensor(self, tensor: torch.Tensor, communication_data_type: torch.dtype):
if self.overlap_comm:
stream = self.reduction_stream
if not get_accelerator().resolves_data_dependency():
stream.wait_stream(get_accelerator().current_stream())
get_accelerator().current_stream().wait_stream(stream)
else:
stream = get_accelerator().current_stream()
This is not sufficient if the same bucket.buffer[bucket.index] was filled by copy_ calls issued on multiple earlier streams.
Observed behavior
Configuration characteristics:
- ZeRO stage 1/2 code path
overlap_comm: true
contiguous_gradients: true
reduce_scatter: true
- bf16 training
torch.compile enabled
Symptoms:
- loss becomes
NaN very early, starting from step 1 in our run
- disabling
torch.compile avoids the issue
- disabling
overlap_comm avoids the issue
- adding a device-wide synchronize in the DeepSpeed reduction path avoids the issue, but is too heavy
- adding stream synchronization from the actual gradient-copy streams to
reduction_stream also avoids the issue
Profiler trace observation:
- the gradient bucket
copy_ operations corresponding to the DeepSpeed contiguous-gradient path are emitted on more than one device stream
- later,
average_tensor() / concat / reduction reads the same IPG bucket on the reduction stream
- the existing wait only covers the stream returned by
get_accelerator().current_stream() when average_tensor() is entered, not all streams that previously wrote slices into the bucket
Conceptually, this can happen as:
stream A: copy grad slice A -> ipg bucket
stream B: copy grad slice B -> ipg bucket
stream C: calls average_tensor(); current_stream == stream C
DeepSpeed today:
reduction_stream waits stream C
Missing dependency:
reduction_stream should also wait stream A and stream B before reading the bucket
Why this is different from existing overlap_comm fixes
This is related to, but not fully covered by, previous fixes:
The remaining issue here is narrower: even on current master, average_tensor() still assumes that one current stream represents all producers of the current contiguous gradient bucket. With compiled autograd / torch.compile, gradient hooks can run under different current streams, so the bucket can have multiple producer streams.
Possible fix direction
One conservative fix is to record the unique streams used for the IPG bucket copy operations, then make reduction_stream wait for those streams before average_tensor() reads the bucket.
Sketch:
# after new_grad_tensor.copy_(...)
self._record_ipg_copy_stream(bucket)
# inside average_tensor(), after switching to reduction_stream
self._wait_for_ipg_copy_streams(bucket)
Where _record_ipg_copy_stream() stores unique get_accelerator().current_stream() values for the current IPG bucket, and _wait_for_ipg_copy_streams() calls current_stream.wait_stream(copy_stream) for each recorded stream not already covered by the existing stream.wait_stream(current_stream).
This avoids a device-wide synchronize and only adds stream dependencies for actual producer streams of the bucket.
Expected behavior
overlap_comm: true with contiguous_gradients: true should not allow the reduction stream to read the IPG bucket before all streams that wrote gradient slices into that bucket have completed.
Describe the bug
On current
master(b2145896daeddefcb70d9301c04c4d0a03b71bb6as of 2026-06-12), the ZeRO stage 1/2 overlap communication path appears to assume that the stream current ataverage_tensor()time is the same stream that produced all contiguous gradient bucket writes.This assumption can be false when the gradient hooks are triggered from different autograd/compiled-backward streams. In a profiler trace, the
copy_operations that write parameter gradients into the IPG buffer are distributed across multiple device streams, whileaverage_tensor()only makes the reduction stream wait forget_accelerator().current_stream()at the timeaverage_tensor()is called.As a result,
reduction_streammay start reading the IPG buffer (div_,narrow,cat/concat, all-reduce/scatter path) before all streams that wrote slices of that same bucket have completed.Relevant code on master
The bucket copy is done in
reduce_independent_p_g_buckets_and_remove_grads():DeepSpeed/deepspeed/runtime/zero/stage_1_and_2.py
Lines 1119 to 1122 in b214589
The bucket is later reduced here:
DeepSpeed/deepspeed/runtime/zero/stage_1_and_2.py
Line 1575 in b214589
But
average_tensor()only waits for the current stream at the moment it is called:DeepSpeed/deepspeed/runtime/zero/stage_1_and_2.py
Lines 1230 to 1238 in b214589
This is not sufficient if the same
bucket.buffer[bucket.index]was filled bycopy_calls issued on multiple earlier streams.Observed behavior
Configuration characteristics:
overlap_comm: truecontiguous_gradients: truereduce_scatter: truetorch.compileenabledSymptoms:
NaNvery early, starting from step 1 in our runtorch.compileavoids the issueoverlap_commavoids the issuereduction_streamalso avoids the issueProfiler trace observation:
copy_operations corresponding to the DeepSpeed contiguous-gradient path are emitted on more than one device streamaverage_tensor()/ concat / reduction reads the same IPG bucket on the reduction streamget_accelerator().current_stream()whenaverage_tensor()is entered, not all streams that previously wrote slices into the bucketConceptually, this can happen as:
Why this is different from existing overlap_comm fixes
This is related to, but not fully covered by, previous fixes:
reduction_streaminaverage_tensor().The remaining issue here is narrower: even on current master,
average_tensor()still assumes that one current stream represents all producers of the current contiguous gradient bucket. With compiled autograd / torch.compile, gradient hooks can run under different current streams, so the bucket can have multiple producer streams.Possible fix direction
One conservative fix is to record the unique streams used for the IPG bucket copy operations, then make
reduction_streamwait for those streams beforeaverage_tensor()reads the bucket.Sketch:
Where
_record_ipg_copy_stream()stores uniqueget_accelerator().current_stream()values for the current IPG bucket, and_wait_for_ipg_copy_streams()callscurrent_stream.wait_stream(copy_stream)for each recorded stream not already covered by the existingstream.wait_stream(current_stream).This avoids a device-wide synchronize and only adds stream dependencies for actual producer streams of the bucket.
Expected behavior
overlap_comm: truewithcontiguous_gradients: trueshould not allow the reduction stream to read the IPG bucket before all streams that wrote gradient slices into that bucket have completed.