Further support for the impression that DDP does not average the gradients automatically: if I add
for param in ddp_model.parameters():
    dist.all_reduce(param.grad.data, op=dist.reduce_op.SUM)
    param.grad.data /= world_size 

right after the backward() operation, the output is what I expect (other than the use of the deprecated op within pytorch):
Running basic DDP on two GPUs same machine: rank 1.
Running basic DDP on two GPUs same machine: rank 0.
/home/paepcke/anaconda3/envs/birds/lib/python3.9/site-packages/torch-1.7.1-py3.9-linux-x86_64.egg/torch/distributed/distributed_c10d.py:142: UserWarning: torch.distributed.reduce_op is deprecated, please use torch.distributed.ReduceOp instead
  warnings.warn("torch.distributed.reduce_op is deprecated, please use "
/home/paepcke/anaconda3/envs/birds/lib/python3.9/site-packages/torch-1.7.1-py3.9-linux-x86_64.egg/torch/distributed/distributed_c10d.py:142: UserWarning: torch.distributed.reduce_op is deprecated, please use torch.distributed.ReduceOp instead
  warnings.warn("torch.distributed.reduce_op is deprecated, please use "
Epoch0 batch0: Before states across gpus are equal
Epoch0 batch0: After states across gpus are equal
Epoch0 batch1: Before states across gpus are equal
Epoch0 batch1: After states across gpus are equal
Epoch0 batch2: Before states across gpus are equal
Epoch0 batch2: After states across gpus are equal
Epoch1 batch0: Before states across gpus are equal
Epoch1 batch0: After states across gpus are equal
Epoch1 batch1: Before states across gpus are equal
Epoch1 batch1: After states across gpus are equal
Epoch1 batch2: Before states across gpus are equal
Epoch1 batch2: After states across gpus are equal
Rank 0 is done.
Rank 1 is done.

If I set async_op=True the states are different as they are in the absence of the explicit gradient averaging. This effect seems to indicate that the two processes really do need to run in lockstep.
Simply for cut/paste convenience, I attach the runnable code below. Only the above modification was added to the code in the previous reply.
run like this: python minimal_ddp_launcher.py minimal_across_two_gpus_ddp.py
Two files:
=============== CUT: File minimal_across_two_gpus_ddp.py:
class MinimalDDP:
    '''Test whether DDP really does something'''
    
    epochs  = 2
    batches = 3

    #------------------------------------
    # setup
    #-------------------

    def setup(self, rank, world_size):
        os.environ['MASTER_ADDR'] = 'localhost'
        os.environ['MASTER_PORT'] = '12355'

        # initialize the process group
        dist.init_process_group("nccl", rank=rank, world_size=world_size)

    #------------------------------------
    # demo_basic
    #-------------------

    def demo_basic(self, rank, world_size):
            
        print(f"Running basic DDP on two GPUs same machine: rank {rank}.")
        self.setup(rank, world_size)
    
        # create model and move it to GPU with id rank
        model = ToyModel().to(rank)
        ddp_model = DDP(model, device_ids=[rank])
    
        loss_fn = nn.MSELoss()
        optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)

        dist.barrier()
        
        for epoch_num in range(self.epochs):
            for batch_num in range(self.batches):
                
                optimizer.zero_grad()
                outputs = ddp_model(randn(20, 10).to(rank))
                labels = randn(20, 5).to(rank)
                
                #********* Begin Portion of Interest ******
                before_model = ddp_model.cpu()
                before_state = copy.deepcopy(before_model.state_dict())
                if rank == 1:
                    torch.save(before_state, f"/tmp/before_rank1.pth")
                ddp_model.to(rank)
                
                loss_fn(outputs, labels).backward()
                #******
                for param in ddp_model.parameters():
                    dist.all_reduce(param.grad.data, 
                                    op=dist.reduce_op.SUM,
                                    async_op=False)
                    param.grad.data /= world_size 
                #******
                optimizer.step()

                after_model = ddp_model.cpu()
                after_state = after_model.state_dict()
                if rank == 1:
                    torch.save(after_state, f"/tmp/after_rank1.pth")
                ddp_model.to(rank)
                                
                dist.barrier()
                
                # Read the other's before/after states:
                if rank == 0:
                    other_before_state = torch.load(f"/tmp/before_rank1.pth")
                    other_after_state  = torch.load(f"/tmp/after_rank1.pth")                
                
                    # Before states should be different:
                    states_equal = True
                    for before_parm, other_before_parm in zip(other_before_state.values(),
                                                              before_state.values()):
                        if before_parm.ne(other_before_parm).any():
                            states_equal = False
    
                    print(f"Epoch{epoch_num} batch{batch_num}: Before states across gpus are {('equal' if states_equal else 'different')}")


                    # After states should be the same:
                    states_equal = True
                    for after_parm_other, after_parm in zip(other_after_state.values(),
                                                       after_state.values()):
                        if after_parm_other.ne(after_parm).any():
                            states_equal = False
    
                    print(f"Epoch{epoch_num} batch{batch_num}: After states across gpus are {('equal' if states_equal else 'different')}")

                #********* End Portion of Interest ******
                # Clean GPU memory:
                outputs.cpu()
                labels.cpu()

        dist.barrier()

        self.cleanup()

    #------------------------------------
    # cleanup 
    #-------------------

    def cleanup(self):
        dist.destroy_process_group()
        print(f"Rank {rank} is done.")
        
# ------------------------ Toy Model ----------

class ToyModel(nn.Module):
    def __init__(self):
        super(ToyModel, self).__init__()
        self.net1 = nn.Linear(10, 10)
        self.relu = nn.ReLU()
        self.net2 = nn.Linear(10, 5)

    def forward(self, x):
        return self.net2(self.relu(self.net1(x)))

# ------------------------ Main ------------
if __name__ == '__main__':

    # Started via minimal_ddp_launcher.py,
    # which sets the rank:
    
    rank           = int(sys.argv[1])
    world_size     = 2
    min_ddp = MinimalDDP().demo_basic(rank, world_size)

========== CUT And file minimal_ddp_launcher.py:
import subprocess
import os, sys

class MinimalDDPLauncher:
   
    def run_demo(self, demo_script, world_size):
        procs = []
        for rank in range(world_size):
            print(f"Starting {demo_script}[{rank}] of {world_size}")
            procs.append(subprocess.Popen([demo_script, str(rank), str(world_size)]))
            
        for proc in procs:
            proc.wait()

# ------------------------ Main ------------
if __name__ == '__main__':

    if len(sys.argv) < 2:
        print("Usage: {minimal_within_two_gpus_ddp.py | minimal_across_two_gpus_ddp.py}")
        sys.exit(1) 
    curr_dir = os.path.dirname(__file__)
    script_path = os.path.join(curr_dir, sys.argv[1])
    
    launcher = MinimalDDPLauncher()
    launcher.run_demo(script_path, 2)