Data Parallelism Doesn't speed up training

sad_robot: <p><strong>Intro:</strong><br>
Hello I heard of the super simple api of data parallelism in PyTorch so I decided to give it a try but after profiling I found almost identical results between using &amp; not using the parallelism feature (DESPITE seeing all 4 GPUs active during training). In each instance I get roughly:<br>
<code>duration: 56.92420029640198, loss: 2.6403932571411133</code>…</p>
<p><strong>Code Comments:</strong><br>
I’m using Transformer XL pertained model from hugging face &amp; a custom training loop.<br>
I tried to keep the info as minimal as possible. The background regarding my training loop is that I was getting memory leaks unless I allocated a reusable mini_batch tensor. I’m concerned this could be related as I’m not sure how/if I need to distribute this Tensor manually to each GPU as well…</p>
<p><strong>Code:</strong></p>
<pre><code class="lang-auto"># ...
model = AutoModelWithLMHead.from_pretrained('xlnet-base-cased').to('cpu')
# ...

import torch as pt
from torch import optim

# NOTE: this is the 'memory efficient version' for CUDA
# pad_len := max number of tokens (input sequences padded to this length)
def train_loop(model, input_output_data, epochs=5, batch_size=256, pad_len=200):
    input, output = input_output_data
    n_examples = len(input)
    n_batches = int(n_examples/batch_size+0.99999)
    
    model.train() # turn on training
    optimizer = optim.Adam(model.parameters(), lr=0.001)
    
    # helpers for tensor dict manipulation
    slice_inputs = lambda x, a, b: {k:x[k][a:b] for k in x}
    cast_inputs = lambda x, device='cuda': {k:x[k].to(device) for k in x}
    def assign_dict(a,b):
        for k in b:
            a[k][:] = b[k]
    
    # they must be padded to the same size for batching to work...
    all_inputs = tokenizer(input, return_tensors='pt', padding='max_length',
                           truncation=True, max_length=pad_len)
    all_outputs = tokenizer(output, return_tensors='pt', padding='max_length',
                           truncation=True, max_length=pad_len)
    all_inputs = cast_inputs(all_inputs, 'cpu')
    all_outputs = cast_inputs(all_outputs, 'cpu')
    
    # The idea was to have a reusable mini-batch tensor to avoid memory leaks...
    inputs = slice_inputs(all_inputs, 0, batch_size)
    outputs = slice_inputs(all_outputs, 0, batch_size)
    inputs = cast_inputs(inputs, 'cuda')
    outputs = cast_inputs(outputs, 'cuda')

    last_loss = None
    
    for i in range(epochs):
        print(f'epoch: {i+1}/{epochs}')
        for j in range(n_batches):
            optimizer.zero_grad()
            torch.cuda.empty_cache()
            try:
                a, b = (j*batch_size, (j+1)*batch_size)
                assign_dict(inputs, slice_inputs(all_inputs, a, b))
                assign_dict(outputs, slice_inputs(all_outputs, a, b))
                
                loss = model(**inputs, labels=outputs['input_ids'])[0].mean()
            except Exception as e:
                pdb.set_trace()
                raise
            
            print(f'batch: {j+1}/{n_batches}, loss: {loss}')
            loss.backward()
            optimizer.step()
            last_loss = loss.item()
    return last_loss
</code></pre>
<p>Here is the code I use to actually perform the training (I switch <code>model=serial_model</code> to <code>model=fast_model</code> for comparison):</p>
<pre><code class="lang-auto"># Train and Profile
import time
import torch as pt

fast_model = pt.nn.DataParallel(model.to('cuda'))
serial_model = model.to('cuda:0')
model = serial_model # switch to fast_model for comparison

start = time.time()
final_loss = train_loop(model, (input, output), batch_size=10, epochs=1)
duration = time.time() - start
print(f'duration: {duration}, loss: {final_loss}')
</code></pre>
<p>P.S. LMK if you want full code, I tried to keep it minimal.<br>
Thanks for your help!</p>

eqy: <p>What is the amount of time spent loading data vs. doing computation on each of the GPUs?</p>
<p>Additionally, the use of <code>torch.cuda.empty_cache()</code> can add overhead if it is done for every batch. Is there some kind of issue where other processes are using the same GPU? Otherwise, this seems like it would be unnecessary.</p>

sad_robot: <aside class="quote no-group" data-username="eqy" data-post="2" data-topic="131554">
<div class="title">
<div class="quote-controls"></div>
<img loading="lazy" alt="" width="24" height="24" src="https://discuss.pytorch.org/user_avatar/discuss.pytorch.org/eqy/48/46233_2.png" class="avatar"> eqy:</div>
<blockquote>
<p>What is the amount of time spent loading data vs. doing computation on each of the GPUs?</p>
</blockquote>
</aside>
<p>I’m not sure exactly, judging based on <code>nvidia-smi</code> I’d estimate 50% time spent loading and 50% doing computation (this estimate is based on % of time I saw each GPU being utilized).</p>
<aside class="quote no-group" data-username="eqy" data-post="2" data-topic="131554">
<div class="title">
<div class="quote-controls"></div>
<img loading="lazy" alt="" width="24" height="24" src="https://discuss.pytorch.org/user_avatar/discuss.pytorch.org/eqy/48/46233_2.png" class="avatar"> eqy:</div>
<blockquote>
<p>Additionally, the use of <code>torch.cuda.empty_cache()</code> can add overhead if it is done for every batch.</p>
</blockquote>
</aside>
<p>You’re right it is probably not necessary it is just a remnant of a previous solution I tried to deal with a memory leak (which is now gone).</p>
<p>I will retest without clear cache and get back to you.</p>

eqy: <p>In that case you might see more improvement if you parallelize the data loading time. Have you tried using the dataloaders: <a href="https://pytorch.org/docs/stable/data.html" class="inline-onebox" rel="noopener nofollow ugc">torch.utils.data — PyTorch 1.9.0 documentation</a> for this part?</p>

sad_robot: <p>No I haven’t, I will try that next thanks!</p>
<p>That said I have already tested preloading entire dataset to GPUs. So aside from data loading, I know PyTorch recommends using DataParallelDistributed, but what else could there be that is causing such small speed gains from x4 parallelism?</p>

eqy: <p>There may be indeed a bottleneck from using one process instead of one-process per GPU, but seeing no speedup suggests that it indeed something else that is bottlenecking the GPUs… Generally it is preferable to get close to 100% utilization on a single GPU before moving to multiple GPUs.</p>

sad_robot: <p>I am actually seeing some speed up, just not as much as I expected…<br>
Currently I when testing <em>Preloading data onto GPU</em> (slightly faster than parallel data loading I imagine but less scalable). I am getting a time reduction from 42 secs → 25 secs per epoch (thanks to your help!).</p>
<p>That’s almost 2x speed with x4 GPUs, it’s not bad but I’m wondering if I should be seeing more?</p>
<p>P.S. I am seeing 100% GPU utilization on each GPU in spurts but not consistently.</p>

eqy: <p>At this stage it might be useful to drill down on the distribution of how time is spent in the training loop by adding a bunch of <code>time.time()</code> statements and narrowing down what the relative cost of each operation is. Note that to do this you would want to add <code>torch.cuda.synchronize</code> before and after starting timing for parts containing GPU operations to ensure that the timing information is accurate.</p>
<p>Once you’ve optimized the bottlenecks, you would then want to remove these synchronize calls to reduce the overhead.</p>

sad_robot: <p>Thanks! I’ll do that you’ve been super helpful!</p>