Questions about PyTorch Hogwild!

gvalk: <p>Hey all!</p>
<p>First, I want to thank all Torch developers &amp; contributors for a great piece of software and the community for some very informative discussions and resources.</p>
<p>I’m working on a project where I’d like to use Hogwild! / ASGD [1, 2] to replicate / approximate Mikolov’s word2vec [3]. Overall, the code structure can be described as:</p>
<ul>
<li>The main process initializes a model and adds it to shared memory (<code>model.share_memory()</code>).</li>
<li>The main process also computes certain statistics of the entire dataset, which are shared with the background processes for putting together instances to train with (see [3] and Mikolov’s papers)</li>
<li>The main process starts <code>P</code> background processes using Python’s <code>multiprocessing</code> module.</li>
<li>Each background process is responsible for training the model with a (roughly equal) subset of the input data, as shown in the torch example [2]. For example, if <code>P=5</code>, each bg process trains with 1/5 of the original data. If <code>P=20</code>, each process will work with 1/20-th of the data, and so on.</li>
<li>Just like the sample code [2], there is no <em>explicit</em> locking.</li>
</ul>
<p>I’d like all this to be done in CPU. Below is some sample code for what is going on</p>
<h4><a name="sample-code-1" class="anchor" href="#sample-code-1"></a>Sample Code</h4>
<pre><code class="lang-python"># This is what the main process code looks like
def training(n_procs: int, n_docs: int, n_words: int, emb_size: int):
    model = MyDoc2Vec(n_docs, n_words, emb_size)  # internally initializes nn.Parameters
    model.share_memory()

    docs_per_proc = math.ceil(n_docs / n_procs)

    bg_processes = []
    for proc_i in range(n_procs):
        start_doc = proc_i * docs_per_proc
        end_doc = min(start_doc + docs_per_proc, n_docs)

        p = mp.Process(target=bg_training, args=(model, start_doc, end_doc), daemon=True)
        bg_processes.append(p)
        p.start()

    for p in bg_processes:
        p.join()
    
    return


# This is what the background process code looks like
# Though not shown here, the dataset to read from is available globally
# The background processes do not have to read the dataset again before doing any work.
def bg_training(model: nn.Module, start_idx: int, end_idx: int):

    # torch.set_num_threads(1)  # See Q1 for details

    optimizer = torch.optim.SGD(model.parameters(), lr=INITIAL_LR)  # INITIAL_LR is an imported constant
    loss_func = NegativeSampling()
    
    for doc_idx in range(start_idx, end_idx):
        # Single class with 3 np.array (in sync). Can be &gt; BATCH_SIZE. 
        # See _NCEBatch in https://github.com/inejc/paragraph-vectors/blob/master/paragraphvec/data.py#L325C7-L325C16 as an example
        doc_batches = get_batches_from_doc(doc_idx)

        losses = []
        offset = 0
        while offset &lt; len(doc_batches):
            batches_subset = doc_batches.get(offset, offset+BATCH_SIZE)  # BATCH_SIZE is an imported constant

            ## If I turn this section OFF, all processes show up as active in htop (see Q2)
            # Converts elements to torch.LongTensor. No CUDA involved
            batch_tensors = batch_subset.to_tensor()

            # Scores from the model &amp; loss computation
            scores = model.forward(batch_tensors)  # &lt;- See Q1: deadlock here. No locking in the model
            loss = loss_func.forward(scores)
            loss_value = loss.item()  # to help with reporting in the end
            losses.append(loss_value)

            # backprop section
            model.zero_grad()
            loss.backward()
            optimizer.step()            

            offset += BATCH_SIZE

    # Do reporting here (outside the loop)

    return
</code></pre>
<p>Based on my observations so far, I have a few questions that I could use some input / help with:</p>
<h4><a name="q1-deadlocks-in-background-processes-2" class="anchor" href="#q1-deadlocks-in-background-processes-2"></a>Q1: Deadlocks in Background Processes</h4>
<p>In my first attempt(s), I was hit by deadlocks in the background processes as discussed in [4].  This was eventually addressed by switching to <code>intel-numpy</code>. Setting <code>torch.set_num_threads(1)</code> (suggested in the link) or the equivalent <code>OMP_NUM_THREADS</code> “worked”, in the sense that the deadlocks were gone, but the efficiency was abysmal (6h+ for a single epoch on a small-ish dataset).</p>
<p>Switching to PyTorch 2 did not help in my case.</p>
<p>Are these the only alternatives to this problem (deadlocks)? Is there some better approach?</p>
<h4><a name="q2-performance-of-background-processes-3" class="anchor" href="#q2-performance-of-background-processes-3"></a>Q2: Performance of Background Processes</h4>
<p>As mentioned above, using <code>set_num_threads(1)</code> (in the bg process) or setting <code>OMP_NUM_THREADS=1</code> had terrible performance.</p>
<p>I should mention here that the machine I’m using has sufficient resources both in RAM (say, 1Tb) and CPU (say, 128 CPUs). No other resource-hungry process runs on it. I was also explicitly setting the env var <code>OMP_NUM_THREADS</code>, to prevent the background processes from assuming they could each spawn a number of threads equal to the machine’s CPUs (which resulted in resource contention and a lot of kernel-space computation).</p>
<p>As a broader pattern, I noticed that, everything else being the same, I was getting better (lower) wall clock time when using <em>fewer</em> processes with <em>more</em> threads, rather than the other way around. For example, 3 processes with 48 threads were considerably faster than 24 processes with 6 threads. This is in line with the above observation that <code>n_procs=96</code> and <code>OMP_NUM_THREADS=1</code> had terrible performance, even though, theoretically, we should be seeing (close to) “perfect” parallelism (I understand this is idealizing things).</p>
<p>Equally surprising, but in line with the above, when using more processes and fewer threads, <code>htop</code> would report underutilization of the available CPUs. In particular:</p>
<ul>
<li>For <code>n_procs=3</code> and <code>OMP_NUM_THREADS=48</code>, <code>htop</code> reports ~96 CPUs as active (2*48).</li>
<li>For <code>n_procs=24</code> and <code>OMP_NUM_THREADS=6</code>, <code>htop</code> would report ~<strong>12</strong> CPUs as active (2*6).</li>
<li>For <code>n_procs=2</code> and <code>OMP_NUM_THREADS=64</code>, <code>htop</code> would report ~<strong>128</strong> CPUs as active (2*64).</li>
</ul>
<p>It’s almost as if only 2 background processes were “really” doing any work. The last combination suggests that there is really enough work to go around. At the same time, if I turn off the <code>torch</code>-related parts of the code (basically, reading through the doc, sampling, etc), more processes appear active in <code>htop</code></p>
<p>Is this behavior expected, known or otherwise sound familiar to anyone? Separately, is there some guidance for the number of threads an (multiprocess) application should have? I understand there’s no silver bullet here, but a rule of thumb (depending on operations involved) would be super helpful.</p>
<h4><a name="q3-doc2vec-hogwild-with-python-4" class="anchor" href="#q3-doc2vec-hogwild-with-python-4"></a>Q3: Doc2Vec Hogwild! with Python</h4>
<p>As mentioned in the original paper [5], Hogwild! is well suited when the optimization problem is sparse. For W2V &amp; Doc2Vec, this translates to updating (very) few words &amp; docs at a time.</p>
<p>If I’m piecing things together properly &amp; using the verbiage of an open source implementation for Doc2Vec [6], we want a small <code>batch_size</code>. Each batch contains 1 positive and several negatives. The <code>batch_size</code> controls how many such batches we process in a single forward + optimization pass.</p>
<p>Very small (e.g, 5, 20, 100) or even small-medium batch sizes (e.g., 1000), resulted in extremely poor performance (separately from the above comments): a very long time was spent in forward passes of the model &amp; computing the loss. Significant time was also spent in backprop. Though I didn’t profile this, I <em>think</em> the time was really just spent in the Python interpreter, which only involves torch operations. Links <a href="https://github.com/inejc/paragraph-vectors/blob/master/paragraphvec/loss.py" rel="noopener nofollow ugc">https://github.com/inejc/paragraph-vectors/blob/master/paragraphvec/loss.py</a> and <a href="https://github.com/inejc/paragraph-vectors/blob/master/paragraphvec/models.py#L31" rel="noopener nofollow ugc">https://github.com/inejc/paragraph-vectors/blob/master/paragraphvec/models.py#L31</a> are good reference points about what’s involved.</p>
<p>Increasing the batch size to 10000 dramatically dropped the wall clock time; the processing time per doc was ~2-3 orders of magnitude better and the performance (efficiency) kept improving beyond that, though the difference was less noticeable.  Granted, I didn’t do a complete scan of batch sizes to know where performance really started taking off.  Unfortunately, I think that as we increase the batch size, the more likely we are to invalidate the assumption of a “sparse optimization problem” and sparse updates that Hogwild! relies on. Basically, larger batch sizes increase the probability that different processes – with multiple parallel threads – will operate on the same word at the same time, which we want to avoid.</p>
<p>With the above in mind, a few questions:</p>
<ol>
<li>
<p>Could the slowdown be attributed to something other than the interpreter? E.g., is there any additional initialization happening by PyTorch internally (e.g., thread pools, other) that might explain the perf difference? I understand that PyTorch will build a computational graph internally to track things, but the overhead (2-3 orders of magnitude) seems too much (I think) for such things. Might there be something else going on I should look at?</p>
</li>
<li>
<p>Is there an alternative strategy (API, implementation) or design that would allow me to use smaller batch sizes without a performance penalty? E.g., would I need to use the C++ API instead? Or is there some obvious torch API I should be using here?</p>
</li>
<li>
<p>Is my understanding correct that a larger batch size (as described above) is counter to the idea of a “sparse optimization problem” in terms of how this gets implemented / executed in PyTorch?</p>
</li>
</ol>
<h4><a name="configuration-5" class="anchor" href="#configuration-5"></a>Configuration</h4>
<ul>
<li>Ubuntu Linux 20.04</li>
<li>Python 3.9</li>
<li>Torch 1.13.1+cu117</li>
<li>numpy 1.21.6  // intel-numpy 1.21.4</li>
<li>Python <code>multiprocessing</code>. The drop-in replacement <code>torch.multiprocessing</code> had the same behavior.</li>
</ul>
<p>Sorry for the long post. Let me know if clarifications are needed.  Thanks in advance!</p>
<h2><a name="references-6" class="anchor" href="#references-6"></a>References</h2>
<p>[1] <a href="https://pytorch.org/docs/stable/notes/multiprocessing.html#asynchronous-multiprocess-training-e-g-hogwild" class="inline-onebox" rel="noopener nofollow ugc">Multiprocessing best practices — PyTorch 2.0 documentation</a><br>
[2] <a href="https://github.com/pytorch/examples/blob/main/mnist_hogwild/train.py" rel="noopener nofollow ugc">https://github.com/pytorch/examples/blob/main/mnist_hogwild/train.py</a><br>
[3] <a href="https://github.com/tmikolov/word2vec/blob/master/word2vec.c" rel="noopener nofollow ugc">https://github.com/tmikolov/word2vec/blob/master/word2vec.c</a><br>
[4] <a href="https://github.com/pytorch/pytorch/issues/17199" class="inline-onebox" rel="noopener nofollow ugc">Deadlock with multiprocessing (using fork) and OpenMP / PyTorch should warn after OMP and fork that multithreading may be broken · Issue #17199 · pytorch/pytorch · GitHub</a><br>
[5] <a href="https://people.eecs.berkeley.edu/~brecht/papers/hogwildTR.pdf" rel="noopener nofollow ugc">https://people.eecs.berkeley.edu/~brecht/papers/hogwildTR.pdf</a><br>
[6] <a href="https://github.com/inejc/paragraph-vectors#parameters" class="inline-onebox" rel="noopener nofollow ugc">GitHub - inejc/paragraph-vectors: 📄 A PyTorch implementation of Paragraph Vectors (doc2vec).</a></p>