Performing convolutions in groups (but not grouped convolution!)

marcman411: <p>I have a tensor with a batch of multi-channel 2D image patches and I want to efficiently apply the same convolutional filters to each image patch. How can I do this?</p>
<p>Let’s say:</p>
<p>B = # batches<br>
N = # patches per batch<br>
C = # input channels per patch<br>
D = # output channels per patch<br>
H = height of patch<br>
W = width of patch</p>
<p>My input tensor <code>x</code> is B x N x C x H x W.<br>
Given a convolution function <code>conv(x)</code>, I want to be output to output B x N x D x H x W.</p>
<p>Typically, these patches are spatially fairly small, on the order of 8x8 pixels or so. However, N can be like 20,000+.</p>
<p>I have tried collapsing the first two dimensions to a  BN x C x H x W tensor, but it appears that PyTorch’s convolution implementation is designed such that the convolution operation is parallelized per batch within a for-loop. Thus, I’m just looping over BN batches sequentially. This is quite slow (I’ve tested it) and doesn’t really take advantage of a GPU at all as it’s largely sequential.</p>
<p>The other thing I looked at was a grouped convolution. I collapsed dimensions <code>1</code> and <code>2</code> so I had a B x NC x H x W tensor, and then set <code>groups=N</code>. However this results in a B x D x H x W output, whereas I would be looking for a B x ND x H x W output. If I explicitly provide that multiplier to the number of channels (e.g. <code>out_channels=N*1024</code> or something), however,  PyTorch tries to initialize a massive weight matrix for the convolution, which is certainly unnecessary and obviously exceeds the capabilities of my device.</p>
<p>Finally, I considered tiling the patches so that the tensor was B x C x NH x W (or some variation thereof), but this would now require some funky striding or padding to make sure I wasn’t sampling between patches.</p>
<p><em>In summary, I am looking for an efficient operation that will apply a D x C x K x K weight matrix to my B x N x C x H x W input tensor and produce a B x N x D x H x W output tensor.</em> That is, a convolution in groups, but not a grouped convolution. <strong>Is this possible using the existing tools in PyTorch?</strong> Everything is so nicely aligned in memory that is seems like it ought to be. I am open to any necessary permutation of dimensions.</p>

albanD: <p>Hi,</p>
<p>Doing the <code>BN x C x H x W</code> is the right thing to do here I think.<br>
Why do you think it runs over batch sequentially? Which device are you using to run this (CPU or GPU)?</p>

marcman411: <p>Hmm. Interesting. The code base has definitely changed a bit since I last dived into the convolution implementations about a year ago, but <a href="https://github.com/pytorch/pytorch/blob/64d58c2f415a4651df821f57d840db3645267274/aten/src/THCUNN/generic/SpatialConvolutionLocal.cu#L121" rel="nofollow noopener">this THCUNN implementation</a> appears to use a for-loop. I am using the GPU backend.</p>
<p>I guess I’m also just surprised by how poorly this scales if it’s not sequential. When testing such that the actual spatial convolution is fixed (i.e. C x H x W doesn’t change). My network with N=1280 processes a batch in ~0.4 seconds on average. When N=20480, takes almost 5 seconds. That’s linear scaling, which seemed to suggested that these batches are being processed sequentially.</p>

albanD: <blockquote>
<p>That’s linear scaling, which seemed to suggested that these batches are being processed sequentially.</p>
</blockquote>
<p>Or that it is already compute bound <img src="https://discuss.pytorch.org/images/emoji/apple/wink.png?v=12" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20">  at N=1280<br>
If you’re using the GPU backend in particular, check <code>nvidia-smi</code> to see what the utilisation is.</p>
<p>Also you are most likely not using the THCUNN code but cudnn.<br>
If you have especially unusual sizes (compared to classical resnet-like CNN), try setting <code>torch.backends.cudnn.benchmark = True</code>. This will allow cudnn to test more algorithms (so the first time it sees a new size it will be slower, but subsequent runs should be faster).</p>

marcman411: <p>I can’t imaging we’re at the compute bounds though. I’d thought of that, but H=W=4 in those tests and C scales by powers of 2. Effectively, I am running this with data equivalent to input image sizes of 3x256x512 in batches of 4 on each GPU. GPU utilization is at 100% though… I honestly don’t understand why this should throttle stuff so badly. I’m using a GTX 1080T and I’ve historically had better performances than this with larger image sizes.</p>
<p>I will try cudnn benchmark. Maybe that could help.</p>

marcman411: <p>I benchmarked against the same number of elements but arranged as a B x C X H x W tensor with N evenly distributed among H x W and it takes about 0.3 seconds per batch with N=20480. I don’t think it’s a compute issue. Even if batches are parallelized, I think it must be to a lesser extent (or have inherently less gain from parallelization) than the spatial convolutions.</p>

albanD: <p>Hi,</p>
<p>You might be bound by the cuda kernel launches as well if each operation is very small and you don’t use cudnn.<br>
If you use cudnn, the benchmark mode should help.</p>

marcman411: <p>The benchmark brought the batch time down to ~3.5s on average, so it definitely helped (thanks!), but I think there’s gotta be more to the story.</p>

albanD: <p>Do you have a small code sample I can use to experiment locally? Because I’m not sure what the <code>3.5s</code> means here. (Input size / batch size / cuda,cudnn version / hardware etc)</p>
<p>One  common cuda perf pitfalls is when we end up launching many many kernels which do very small tasks. So you spend more time launching the kernel than doing compute.</p>
<ul>
<li>This can be because of the internal implementation (THCUNN or cudnn) that does some assumptions (small batch size here) that you don’t.</li>
<li>This can be because your code does these ops and it should be batched differently.</li>
</ul>
<p>For the first point, there is no magic kernel to perform all convolutions, so many specialized kernels are needed to get the best perf. It’s possible that such specialized kernel is not in cudnn <img src="https://discuss.pytorch.org/images/emoji/apple/confused.png?v=9" title=":confused:" class="emoji" alt=":confused:"></p>

marcman411: <p>Let me see if I can produce a minimal example, and I’ll get back to you. Some of this is intertwined with other stuff I’m working on. I’ll try to throw together a mini-profiler.</p>
<p>In theory, this should lend itself nicely to the <code>im2col</code> convolution variant at the very least. Instead of parallelizing the copy over C x H x W, it would parallelize it over N x C x H x W (as NHW in this case is roughly the same as HW in a Normal spatial convolution over larger images) and have some extra checks in the double for-loop over the filter data to make sure that it’s not copying from the wrong “patch”.</p>

marcman411: <p>So I went and rolled my own “Patch Convolution,” reimplementing <code>im2col</code> and <code>col2im</code> with a 5-D tensor (B x C x P x H x W) and launch it with C * P * H * W threads. It’s definitely slower than cudnn would be (my CUDA skills are naive at best), but it runs the same network as described above in ~1.6s per batch. I know it’s not really possible to really demonstrate the difference without that profiler I promised, but it seems to me that this result suggests that the “optimal kernel” for my problem just might not be in the cudnn library, as you posited.</p>
<p>In the meantime, I can live with my 5x slowdown for now…</p>
<p>On another note, this operation is totally something that PyTorch should support though. It’s a fairly trivial change from the standard <code>im2col</code> for conv2D. The only major differences are in how we stride and index. I’m happy to share my baseline implementation, but I am sure the PyTorch devs can do a better job than me.</p>

ptrblck: <p>I’m a bit confused about the numbers.</p>
<p>Did you make sure to synchronize the device before starting and stopping the timer (or did you profile the kernels via nsight)?<br>
For cudnn benchmark, some warm-up iterations are necessary in addition as <a class="mention" href="/u/alband">@albanD</a> explained.</p>
<p>What is your use case by the way?<br>
If you want to apply soma operation on each small kernel, you might want to <code>unfold</code> the image, and perform the op e.g. via matmul.</p>

marcman411: <p>I used <code>torch.cuda.synchronize()</code> to profile. My use case is simply convolving on a set of many many patches. <code>unfold</code> might do the trick actually. I’ve been looking through the docs, and correct me if I’m wrong, but isn’t this basically creating the “column” matrix from the <code>im2col</code> operation?</p>
<p>Once I get some stuff going, I will try to put together a proper profile still.</p>

ptrblck: <p>Yes, <code>unfold</code> an be used to apply the <code>im2col</code> operation, which might be cheaper for your use case instead of a lot of small convolutions.</p>

marcman411: <p>Yeah, so that’s what I implemented before that gave me the best speed up so far–except I went and wrote my own CUDA kernels. Something tells me <code>unfold</code> and <code>fold</code> are probably much more efficient than my <code>im2col</code> and <code>col2im</code>… ;-). I’ll throw this in and see what kind of speed it brings. Do <code>unfold</code> and <code>fold</code> leverage cudnn? That is, will <code>torch.backends.cudnn.benchmark = True</code> add anything?</p>

albanD: <p>Hi,</p>
<p>No cudnn does not provide such functions.<br>
But <code>unfold</code> and <code>fold</code> are mostly playing with size and stride, not doing anything. on the GPU really so you don’t need cuda kernels for them right?<br>
Then you only need a matmul which is already implemented.</p>
<p>Does something like this works for you?</p>
<pre><code class="lang-auto">inp = torch.randn(1, 3, 10, 12)
w = torch.randn(2, 3, 4, 5)

inp_unf = torch.nn.functional.unfold(inp, (4, 5))
out_unf = inp_unf.transpose(1, 2).matmul(w.view(w.size(0), -1).t()).transpose(1, 2)
out = torch.nn.functional.fold(out_unf, (7, 8), (1, 1))
</code></pre>

marcman411: <p>Alright, I put together a small profiler comparison between the different methods we talked about. It’s also totally possible my profiler is not timing things most accurately. Please feel free to correct me if I’m doing something incorrectly.</p>
<p>The profiler compares:<br>
(1) <code>torch.nn.Conv2D</code> on a BP x C x H x W tensor<br>
(2) My custom <code>patch_im2col</code> CUDA kernel on a B x C x P x H x W tensor<br>
(3) <code>torch.nn.unfold</code> + <code>torch.matmul</code> on a B x P x C x H x W tensor</p>
<p>I made a <a href="https://github.com/meder411/Patch-Conv" rel="nofollow noopener">GitHub repo</a> so you can actually run it locally or inspect the code, if you’re interested in trying that.</p>
<p>The forward pass results averaged over 20 iterations on a single NVIDIA GTX 1080T:</p>
<pre><code class="lang-auto">Parameters:
B = 1
P = 20480
H = 4
W = 4
in_channels = 32
out_channels = 64
kernel_size = 3
padding = 1
stride = 1
dilation = 1

Various patch convolution methods:
  Using-unfold avg. time: 0.058497 seconds
  Rolled-into-batch avg. time: 0.089629 seconds
  Custom-patch-im2col avg. time: 0.036741 seconds


B = 1
in_channels = 32
H = 600
W = 600
Compare to traditional convolution with B x C x H x W inputs with similar number (actually more) of elements:
  nn.Conv2d: 0.037487 seconds
</code></pre>
<p>This test doesn’t really differentiate in an obvious way, so maybe it’s the backward pass that differentiates the speed. I am not 100% sure how to profile that though. Perhaps either of you could shed some light? <a class="mention" href="/u/alband">@albanD</a> <a class="mention" href="/u/ptrblck">@ptrblck</a></p>

albanD: <p>Hi,</p>
<p>Thanks for the code.<br>
I quickly tried it.</p>
<ul>
<li>Your imports in your custom versions import modules from another package (old install I guess). They should be <code>import _patchconv_ext._patch_convolution as patch_conv</code> (and same for transposed).<br>
Running with this I get a cuda kernel failed for your code though <img src="https://discuss.pytorch.org/images/emoji/apple/confused.png?v=9" title=":confused:" class="emoji" alt=":confused:"> Do you see the same thing?</li>
<li>What are the timings you get with the default settings? So that I can compare with what I get.</li>
</ul>
<p>I slightly modified your test script (and removed your custom code that does not run here).<br>
You can find in this gist <a href="https://gist.github.com/albanD/e57425691686b708b3d57329d633ad33" rel="nofollow noopener">https://gist.github.com/albanD/e57425691686b708b3d57329d633ad33</a> the results I have locally.<br>
The conclusions I have:</p>
<ul>
<li>cudnn benchmark does give the expected results for the forward pass (close to the conv with similar number of ops)</li>
<li>For the backward, not sure why the unfold version is soo good and the others not. I expect the expands lead to very efficient backward?</li>
</ul>
<p>I tested on CUDA 10.0 with cudnn 76.02 on a Quadro GP100</p>

marcman411: <p>So I’m still not quite sure how to handle kernel fails and properly print the error. I thought what I had worked, but I’ve run into issues where it just says “kernel failed: no error”. Probably an out of memory issue? The GPU I tested on has ~12GB of RAM. I also have CUDA_NUM_THREADS=512. Maybe that value needs to change for your setup? It’s all hardcoded, unfortunately.</p>
<p>Also, I fixed those imports shortly after posting–this was all tied up in a different module at one point. It should work now.</p>
<p>In any case, I ran your better profiler script locally and un-commented custom code, which I ran as well. Here are the results on a GTX 1080T with CUDA 10.1 (still looking for the cudnn version) and PyTorch 1.2</p>
<pre><code class="lang-auto">######################### CUDNN FALSE


Various patch convolution methods:
  Using-unfold avg. time: 0.054104 seconds
  Rolled-into-batch avg. time: 0.996649 seconds


Compare to traditional convolution with B x C x H x W inputs with similar number (actually more) of elements:
  nn.Conv2d: 0.041167 seconds
######################### CUDNN TRUE


Various patch convolution methods:
  Using-unfold avg. time: 0.044344 seconds
  Rolled-into-batch avg. time: 0.072312 seconds
  Custom-patch-im2col avg. time: 0.037970 seconds


Compare to traditional convolution with B x C x H x W inputs with similar number (actually more) of elements:
  nn.Conv2d: 0.038860 seconds
######################### CUDNN TRUE BENCH


Various patch convolution methods:
  Using-unfold avg. time: 0.044354 seconds
  Rolled-into-batch avg. time: 0.040456 seconds
  Custom-patch-im2col avg. time: 0.038056 seconds


Compare to traditional convolution with B x C x H x W inputs with similar number (actually more) of elements:
  nn.Conv2d: 0.044387 seconds
######################### CUDNN TRUE BENCH


Various patch convolution methods:
  Using-unfold avg. time: 0.044427 seconds
  Rolled-into-batch avg. time: 0.035127 seconds
  Custom-patch-im2col avg. time: 0.037969 seconds


Compare to traditional convolution with B x C x H x W inputs with similar number (actually more) of elements:
  nn.Conv2d: 0.038883 seconds

</code></pre>
<p>It’s also possible that the unfold approach is taking advantage of not re-allocating memory each time? My method treats each layer independently and forward and backward passes re-allocate intermediate storage. Perhaps there’s some optimization going on in the PyTorch backend to not re-allocate and destroy GPU memory so much. Perhaps the intermediate storage is fixed through a full network pass somehow? I’m speculating now, but it might explain the performance gains.</p>

albanD: <p>Yes a deeper investigation would be needed for the unfold <img src="https://discuss.pytorch.org/images/emoji/apple/smiley.png?v=9" title=":smiley:" class="emoji" alt=":smiley:"></p>
<p>Does this mean that the cudnn perf with benchmark = True match your expectations?</p>