Gradient Aggregation in DistributedDataParallel

username: maxbit
cooked: <p>Hi,</p>
<p>this question is somewhat in between PyTorch’s implementation of DistributedDataParallel and Paramters Server in general.  My “theory” source is the Dive into Deep Learning book [1].</p>
<p>Figure 12.7.1 on [1] suggests the following approach:<br>
Assume batch size 32. If we have one GPU and 128 training datapoints, each epoch has 4 mini batches. After every mini batch, we update our model (i.e. there are 4 updates). Hence, we have to calculate four gradients (one per minibatch).</p>
<p>For the multiple GPUs case, assume two GPUs and 128 training datapoints. We feed each GPU a mini-batch, let them calculate a gradient, sum them and update our model with the sum. Hence, there are two steps instead of four involved (from a BSP point of view).</p>
<p>My questions are the following:</p>
<ol>
<li>
<p>Is my described understanding on how parameter servers work correct? Especially I am not sure if we keep the same batch size 32 per GPU or if we have to divide the batch size by the number of GPUs.</p>
</li>
<li>
<p>Why do we sum the gradients instead of averaging them? This is even more confusing to me, as in the DistributedDataParallel documentation of PyTorch (cannot post the link due to the link limit for new users), there is the following statement:</p>
</li>
</ol>
<blockquote>
<p>When a model is trained on M nodes with batch=N, the gradient will be M times smaller when &gt;compared to the same model trained on a single node with batch=M*N (because the gradients &gt;between different nodes are averaged). You should take this into consideration when you want to &gt;obtain a mathematically equivalent training process compared to the local training counterpart.</p>
</blockquote>
<p>There are two things confusing here:</p>
<p>2.1. It states that the gradients are averaged, which does not comply to the D2L book which states that we sum the gradients.</p>
<p>2.2. Until now, I always thought, in mini-batch gradient descent, the loss function (optimization goal) averages the error of the mini-batch data points. Hence, if M nodes run mini-batch gradient descent with batch size N, and we take the average of their gradients, we should receive a number that is in the same order of magnitude as if 1 node runs mini-batch gradient-descent with batch size N<em>M, as the single node averages the error functions of n</em>m data points for the loss function, while with M nodes we just take the average of averages.</p>
<p>I am not sure whether the DistributedDataParallel class of PyTorch can be seen as a parameter server (especially because there even is a guide on how to build a parameter server in PyTorch [3]), but it maps to what is described in the book as a parameter server.</p>
<p>Any help on resolving my confusion is much appreciated. Thank you very much!</p>
<p>Kind regards,<br>
Maximilian</p>
<p>[1] <a href="https://d2l.ai/chapter_computational-performance/parameterserver.html" class="inline-onebox" rel="noopener nofollow ugc">13.7. Parameter Servers — Dive into Deep Learning 1.0.3 documentation</a></p>
<p>[2] <a href="https://pytorch.org/docs/stable/generated/torch.nn.parallel.DistributedDataParallel.html" class="inline-onebox" rel="noopener nofollow ugc">DistributedDataParallel — PyTorch 2.1 documentation</a></p>

username: maxbit
cooked: <p>Hi,</p>
<p>I’ve had some discussion with Shen Li as one of the authors of the VLDB DDP paper via mail and we want to move our discussion here to make it accessible to everyone. Find our past communication here:</p>
<blockquote>
<blockquote>
<p>For the multiple GPUs case, assume two GPUs and 128 training datapoints. We feed each GPU a mini-batch, let them calculate a gradient, sum them and update our model with the sum. Hence, there are two steps instead of four involved (from a BSP point of view).</p>
</blockquote>
<p>It depends. If you still wanna maintain batch size at 32 for each iteration, the per process batch size should schrink to (32 / world_size). But this is an application decision, and PyTorch won’t make the call for you.</p>
<blockquote>
<p>Now, in the DDP documentation, one can find the following statement: When a model is trained on M nodes with batch=N, the gradient will be M times smaller when compared to the same model trained on a single node with batch=M*N (because the gradients between different nodes are averaged). You should take this into consideration when you want to obtain a mathematically equivalent training process compared to the local training counterpart.</p>
</blockquote>
<p>It’s two step computation. Since AllReduce can only do sum/prod/max/min, we cannot directly use AllReduce to do mean. Hence, it uses AllReduce to sum the gradients and divide the gradient by the <code>world_size</code>​.</p>
<blockquote>
<p>Now, in the DDP documentation, one can find the following statement: When a model is trained on M nodes with batch=N, the gradient will be M times smaller when compared to the same model trained on a single node with batch=M*N (because the gradients between different nodes are averaged). You should take this into consideration when you want to obtain a mathematically equivalent training process compared to the local training counterpart.</p>
</blockquote>
<p>I believe the OSS author who added this line is removing it on master: </p>
<p>Because it actually depends on the loss function. Check out this:  </p>
<blockquote>
<p>My second question is also about the above statement from the DDP documentation. In mini-batch gradient descent, the loss function (optimization goal) averages the error of the mini-batch data points. Hence, if M nodes run mini-batch gradient descent with batch size N, and we take the average of their gradients, we should receive a number that is in the same order of magnitude as if 1 node runs mini-batch gradient-descent with batch size N*M, as the single node averages the error functions of nm data points for the loss function, while with M nodes we just take the average of averages. I do not understand why the documentation says there is a change in order of magnitude. If we would sum instead of average in the DDP module, it would make sense tho.</p>
</blockquote>
<p>This is true, but depends on the loss function. Nothing prevents uses from using <code>sum()</code>​ as the loss function.</p>
<blockquote>
<p>Lastly, I would like to ask why you do not consider the DDP module a parameter server. Isn’t it doing exactly what a PS is supposed to do?</p>
</blockquote>
<p>These papers can answer that. I just list two, but many other papers also view it this way, i.e., consider AllReduce and PS as difference schemes, which makes sense to me, as one is collective communication, and another is P2P.</p>
<ol>
<li>

</li>
<li>

</li>
</ol>
<blockquote>
<p>You do provide an tutorial on how to implement a PS with the RPC framework, but I do currently not understand why this is necessary. The tutorial states that the DDP module is only to be used for single-node multi-GPU computations, but in the sourcecode of <code>distributed.py</code> is stated that multi-node computations are supported as well.</p>
</blockquote>
<p>Can you point me to the line that claims DDP is single-node multi-GPU? This is certainly wrong. DP is single-node multi-GPU, but DDP can run on multi-node. Also, we heavily rely on the community. If you feel anything is wrong in the code or doc. Please feel free to send in PRs to fix them.</p>
<hr>
<ol>
<li>My first question is about the gradient reduction. Figure 12.7.1 on<br>
[1] suggests the following approach for gradient reduction:<br>
Assume batch size 32. If we have one GPU and 128 training datapoints,<br>
each epoch has 4 mini batches. After every mini batch, we update our<br>
model (i.e. there are 4 updates). Hence, we have to calculate four<br>
gradients (one per minibatch).</li>
</ol>
<p>For the multiple GPUs case, assume two GPUs and 128 training datapoints.<br>
We feed each GPU a mini-batch, let them calculate a gradient, sum them<br>
and update our model with the sum. Hence, there are two steps instead of<br>
four involved (from a BSP point of view).</p>
<p>Now, in the DDP documentation, one can find the following statement:<br>
When a model is trained on M nodes with batch=N, the gradient will be M<br>
times smaller when compared to the same model trained on a single node<br>
with batch=M*N (because the gradients between different nodes are<br>
averaged). You should take this into consideration when you want to<br>
obtain a mathematically equivalent training process compared to the<br>
local training counterpart.</p>
<p>The confusing part is that PyTorch averages the gradients. I do not<br>
understand why this is the case, as [1] states we sum the gradients (and<br>
that - in my opinion - makes sense as we want to parallelize the<br>
gradient calculation and after parallel calculation go into the<br>
direction of all gradients together).</p>
<ol start="2">
<li>
<p>My second question is also about the above statement from the DDP<br>
documentation. In mini-batch gradient descent, the loss function<br>
(optimization goal) averages the error of the mini-batch data points.<br>
Hence, if M nodes run mini-batch gradient descent with batch size N, and<br>
we take the average of their gradients, we should receive a number that<br>
is in the same order of magnitude as if 1 node runs mini-batch<br>
gradient-descent with batch size N*M, as the single node averages the<br>
error functions of nm data points for the loss function, while with M<br>
nodes we just take the average of averages. I do not understand why the<br>
documentation says there is a change in order of magnitude. If we would<br>
sum instead of average in the DDP module, it would make sense tho.</p>
</li>
<li>
<p>Lastly, I would like to ask why you do not consider the DDP module a<br>
parameter server. Isn’t it doing exactly what a PS is supposed to do?<br>
You do provide an tutorial on how to implement a PS with the RPC<br>
framework, but I do currently not understand why this is necessary. The<br>
tutorial states that the DDP module is only to be used for single-node<br>
multi-GPU computations, but in the sourcecode of <code>distributed.py</code> is<br>
stated that multi-node computations are supported as well.</p>
</li>
</ol>
</blockquote>
<p>To continue the discussion, I would like to follow up:</p>
<p>It seems like this PR was actually fixing the documentation for DDP: <a href="https://github.com/pytorch/pytorch/pull/47156" class="inline-onebox" rel="noopener nofollow ugc">Fix inaccurate note in DistributedDataParallel by 0x4f5da2 · Pull Request #47156 · pytorch/pytorch · GitHub</a></p>
<p>Now it states:<br>
When a model is trained on <code>M</code> nodes with <code>batch=N</code>, the<br>
gradient will be <code>M</code> times smaller when compared to the same model<br>
trained on a single node with <code>batch=M*N</code> if the loss is summed (NOT<br>
averaged as usual) across instances in a batch (because the gradients<br>
between different nodes are averaged).</p>
<p>I do not understand the last part in brackets. I do see (and this is also what I suggested in my initial post) that if we sum the gradients (so just AllReduce with sum), that the resulting gradient will be M times smaller. But I am a little confused on loss vs gradient summing/averaging here. What we do on each node is calculate the loss per mini-batch. This loss per mini batch is the average loss for all data points in the mini batch, see e.g. [1] (e.g. for MSE mse = \frac{1}{mini batch size} \sum_{i=1}^mini batch size (x-y)^2). This results in a gradient per mini-batch (and each node handles a mini-batch, so one gradient per node). Now the question is whether we want to sum or average the gradients of the nodes.</p>
<p>Hence, for me it should be:<br>
When a model is trained on <code>M</code> nodes with <code>batch=N</code>, the<br>
gradient will be <code>M</code> times larger when compared to the same model<br>
trained on a single node with <code>batch=M*N</code> if the gradients are summed across instances in a batch.</p>
<p>Because in the distributed case, you sum M gradients. In the non-distributed case, you just have one gradient which results from the average loss.</p>
<p>Because what we can change is whether we want to AllReduce with a sum and than divide by the total amount of gradients (=mean) or just AllReduce with sum. I do not understand what the loss has to do with that as we deal with gradients on that level.</p>
<p>The second thing I do not understand is why we want to average instead of summing the gradients in the first place. According to the book in my initial post, it should be summing and for me, that’s more intuitive as well. Is there are reason why we average by default?</p>
<p>[1] <a href="https://adventuresinmachinelearning.com/stochastic-gradient-descent/" class="inline-onebox" rel="noopener nofollow ugc">Adventures in Machine Learning</a></p>

username: mrshenli
cooked: <aside class="quote no-group" data-username="maxbit" data-post="2" data-topic="101715">
<div class="title">
<div class="quote-controls"></div>
<img loading="lazy" alt="" width="24" height="24" src="https://discuss.pytorch.org/letter_avatar_proxy/v4/letter/m/bbce88/48.png" class="avatar"> maxbit:</div>
<blockquote>
<p>Because in the distributed case, you sum M gradients. In the non-distributed case, you just have one gradient which results from the average loss.</p>
</blockquote>
</aside>
<p>This is not accurate. In the distributed case, the processes first use AllReduce to sum the gradient and then divide it by world size. So, the gradient is averaged. If you need a sum instead, you can multiple the <code>param.grad</code> field by <code>world_size</code>.</p>
<blockquote>
<p>I do not understand what the loss has to do with that as we deal with gradients on that level.</p>
</blockquote>
<p>This is mainly for mathematical equivalence, which can have impact on how to tune other params like LR. E.g., if you use MSE as the <code>loss_fn</code> in local training, then the grad you get is per-sample average. When switching to DDP in this case, you might still want grads being per-sample average, and hence it should be AllReduce sum divided by <code>world_size</code>. However, if you use <code>sum()</code> as the <code>loss_fn</code> in local trianing, the grad you get is per-batch sum. And when switching to DDP (per process batch size probably should be <code>N/M</code> in this case), you might also want to keep the grad as per-batch sum. In this case, the grad should be AllReduce sum without division.</p>
<blockquote>
<p>The second thing I do not understand is why we want to average instead of summing the gradients in the first place. According to the book in my initial post, it should be summing and for me, that’s more intuitive as well. Is there are reason why we average by default?</p>
</blockquote>
<p>That’s not sth that PyTorch Distributed package can decide for you. It is application’s choice.</p>