博客專欄

EEPW首頁 > 博客 > 徹底搞懂PyTorch index_add 操作涉及的優(yōu)化技術(shù)

徹底搞懂PyTorch index_add 操作涉及的優(yōu)化技術(shù)

發(fā)布人:計算機視覺工坊 時間:2023-01-19 來源:工程師 發(fā)布文章
作者丨BBuf

來源丨 GiantPandaCV

本文把pytorch index_add算子的代碼抽取出來放在:https://github.com/BBuf/how-to-optim-algorithm-in-cuda/blob/master/indexing/index_add_cuda_pytorch_impl.cu 。如果不太熟悉PyTorch的話也可以直接看這個.cu文件,有問題請在這個repo提issue。
0x0. 前言

我們可以在 PyTorch 的文檔中找到 torch.index_add_ 的定義(https://pytorch.org/docs/stable/generated/torch.Tensor.index_add_.html#torch.Tensor.index_add_):

圖片

簡單來說就是我們需要根據(jù)index的索引完成對當(dāng)前Tensor dim維度的inplace加和,注意被加數(shù)是由另外一個Tensor src決定的。在PyTorch的codebase中搜索index_add,我們可以發(fā)現(xiàn)這個操作應(yīng)用得非常廣泛,比如說作為as_strided算子的backward的一部分,作為一些sparse操作的一部分等等。我最近研究了一下,發(fā)現(xiàn)PyTorch對index_add算子的cuda kernel進(jìn)行了較為精細(xì)的優(yōu)化,主要有兩個亮點,本篇文章就來學(xué)習(xí)一下。順便提一下,在PyTorch中index_add的cuda kernel實現(xiàn)在https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/cuda/Indexing.cu#L712 ,如果你想自己詳細(xì)讀這個代碼我建議先編譯一下PyTorch再進(jìn)行調(diào)試和閱讀,編譯PyTorch源碼可以參考:https://github.com/BBuf/how-to-optim-algorithm-in-cuda/tree/master/how-to-complie-pytorch-from-source(這個也是參考PyTorch官方的教程,補充了幾個報錯的坑) 。

0x1. 亮點1: 按照index的元素個數(shù)派發(fā)不同的實現(xiàn)

PyTorch優(yōu)化的出發(fā)點是,index_add操作中index這個Tensor是尤其重要,它決定了輸入Tensor的哪些位置會被重新賦值,然后index的元素可多可少。如果使用同一套naive的計算邏輯可能會因為重復(fù)訪問index導(dǎo)致全局內(nèi)存訪問過多,而如果index很大那么為了保證性能kernel又需要滿足足夠的并行度才可以。為了平衡這兩種情況,PyTorch按照index的元素個數(shù)實現(xiàn)了2套kernel。這2套kernel的實現(xiàn)在:https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/cuda/Indexing.cu#L576-L675 。然后根據(jù)index元素個數(shù)進(jìn)行dispatch:https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/cuda/Indexing.cu#L801-L829 。圖片在這里插入圖片描述我在 https://github.com/BBuf/how-to-optim-algorithm-in-cuda/blob/master/indexing/indexing_pytorch_explain.cu#L381-L505 這里以PyTorch文檔展示的例子(https://pytorch.org/docs/stable/generated/torch.Tensor.index_add_.html#torch.Tensor.index_add_)為例記錄了各個中間變量的值,并加上了一些方便理解的注釋,感興趣的可以查看。我們這里展示一下當(dāng)index的元素很少的時候的indexFuncSmallIndex kernel實現(xiàn)(代碼中的設(shè)置是index元素個數(shù)少于16):

// 如果索引的數(shù)量很少,我們更喜歡使用這個Kernel來避免重新加載 index。
// 這個kernel實際上適用于幾乎所有問題大小的選擇,但如果選擇的索引數(shù)量很大,
// 那么indexFuncLargeIndex Kernel是增加并行度的更好選擇。
// 下面的innerSize就是輸人的self張量忽略dim維度的切片大小,對于每一個indices[i],我們都要處理innerSize個元素的copy

// selfAddDim(dstAddDim) = 0
// sourceAddDim(srcAddDim) = 0
// sliceSize(innerSize) = 3
// selfAddDimSize(dstAddDimSize) = 5
// selfNumel(dstNumel) = 15
// selfInfo.sizes(dst): 1, 3, 
// selfInfo.strides(dst): 3, 1,
// sourceInfo.sizes(src): 1, 3, 
// sourceInfo.strides(src): 3, 1
// indexInfo.sizes(indices): 3, 
// indexInfo.strides(indices): 1,

template <typename T, typename IndicesType, typename IndexType, int DstDim, int SrcDim, int IdxDim,
          typename func_t>
__global__ void indexFuncSmallIndex(cuda::detail::TensorInfo<T, IndexType> dst,
                                    cuda::detail::TensorInfo<T, IndexType> src,
                                    cuda::detail::TensorInfo<IndicesType, IndexType> indices,
                                    int dstAddDim,
                                    int srcAddDim,
                                    IndexType innerSize,
                                    int64_t dstAddDimSize,
                                    int64_t dstNumel,
                                    const func_t& op,
                                    T alpha)
 
{
  // In order to avoid reloading the index that we are copying, load
  // it once to handle all of the points that are being selected, so
  // it can be reused as much as possible. This kernel is chosen when
  // this is a good choice (small number of chosen indices), since
  // re-accessing indices in addition to src elements can be slow.
  // 為了避免重新加載我們正在復(fù)制的索引,加載一次以處理所有正在選擇的點,以便盡可能地重復(fù)使用它。 
  // 當(dāng)這是一個不錯的選擇(選擇的索引數(shù)量很少)時,就會選擇這個Kernel,
  // 因為除了 src 元素之外,重新訪問索引可能很慢。
  for (IndexType srcIndex = 0; srcIndex < indices.sizes[0]; ++srcIndex) {
    // Lua indices begin at 1
    IndexType dstIndex =
        indices.data[cuda::detail::IndexToOffset<IndicesType, IndexType, IdxDim>::get(srcIndex, indices)];
    CUDA_KERNEL_ASSERT(dstIndex < dstAddDimSize);

    // We stride over the output ignoring the indexed dimension
    // (innerSize), whose offset calculation is handled differently
    for (IndexType linearIndex = blockIdx.x * blockDim.x + threadIdx.x;
         linearIndex < innerSize;
         linearIndex += gridDim.x * blockDim.x) {
      IndexType dstOffset =
          cuda::detail::IndexToOffset<T, IndexType, DstDim>::get(linearIndex, dst);
      dstOffset += dstIndex * dst.strides[dstAddDim];

      IndexType srcOffset =
          cuda::detail::IndexToOffset<T, IndexType, SrcDim>::get(linearIndex, src);
      srcOffset += srcIndex * src.strides[srcAddDim];

      T val = src.data[srcOffset] * alpha;
      op(dst.data, dstOffset, dstNumel, &val);
    }

  }
}

我們可以看到首先有一個for (IndexType srcIndex = 0; srcIndex < indices.sizes[0]; ++srcIndex) 的循環(huán)來避免重復(fù)加載 index Tensor(這個時候index Tensor信息由indices管理),后續(xù)的實驗結(jié)果也將證明這個優(yōu)化在 index 元素個數(shù)比較小而 self Tensor 比較大的時候是有一定性能提升的。然后選定一個indices[i] 之后就啟動一堆線程計算完這個indices[i]對應(yīng)的 self Tensor的一個切片(linearIndex < innerSize)。indexFuncLargeIndex Kernel我就不展示了,感興趣的小伙伴可以直接閱讀代碼實現(xiàn)。實現(xiàn)完這兩個Kernel之后,我們可以在 https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/cuda/Indexing.cu#L753-L778 這里看到PyTorch分別為這兩個Kernel設(shè)置了不同的GridSize和BlockSize。

// selfAddDim = 0
// sourceAddDim = 0
// sliceSize = 3
// selfAddDimSize = 5
// selfNumel = 15

#define SMALL_INDEX(TENSOR_TYPE, INDICES_TYPE, TYPE, SELF_DIM, SOURCE_DIM, IDX_DIM)     \
  indexFuncSmallIndex<TENSOR_TYPE, INDICES_TYPE, TYPE, SELF_DIM, SOURCE_DIM, IDX_DIM>   \

    <<<smallIndexGrid, smallIndexBlock, 0, stream>>>(                                   \
      selfInfo, sourceInfo, indexInfo,                                                  \
      selfAddDim, sourceAddDim, sliceSize, selfAddDimSize,                              \
      selfNumel, reduce_add, alpha_value);                                              \
  C10_CUDA_KERNEL_LAUNCH_CHECK();

#define LARGE_INDEX(TENSOR_TYPE, INDICES_TYPE, TYPE,                        \
                    SELF_DIM, SOURCE_DIM, IDX_DIM, IDX_IS_MAJOR)            \
  indexFuncLargeIndex<TENSOR_TYPE, INDICES_TYPE, TYPE,                      \
                      SELF_DIM, SOURCE_DIM, IDX_DIM, IDX_IS_MAJOR>          \
    <<<largeIndexGrid, largeIndexBlock, 0, stream>>>(                       \

      selfInfo, sourceInfo, indexInfo,                                      \
      selfAddDim, sourceAddDim, sourceTotalSize,                            \
      (IDX_IS_MAJOR) ? sliceSize : numIndex,                                \
      selfAddDimSize, selfNumel, reduce_add, alpha_value);                  \
  C10_CUDA_KERNEL_LAUNCH_CHECK();

  // small index以正在索引的每個切片的大小為基準(zhǔn)來設(shè)定GridSize和BlockSize,同時要考慮到需要滿足足夠多的wave保證利用率
  const dim3 smallIndexGrid(std::min(ceil_div(sliceSize, (ptrdiff_t)128), (ptrdiff_t)(mpc * 8)));
  const dim3 smallIndexBlock(std::min(sliceSize, (ptrdiff_t)128));

  // large index以source 張量的總大小為基準(zhǔn)來設(shè)定GridSize和BlockSize,同時要考慮到需要滿足足夠多的wave保證利用率
  const dim3 largeIndexGrid(std::min(ceil_div(sourceTotalSize, (ptrdiff_t)128), (ptrdiff_t)(mpc * 8)));
  const dim3 largeIndexBlock(std::min(sourceTotalSize, (ptrdiff_t)128));

對于index的元素個數(shù)比較小也就是smallIndex的情況,線程快的數(shù)量由sliceSize來決定,而對于index元素個數(shù)比較大也就是largeIndex的時候線程塊的數(shù)量則由輸入Tensor self的總元素數(shù)量來決定。我個人感覺這里設(shè)置GridSize和BlockSize還是存在一定問題的,在profile的時候ncu對于index比較小并且輸入Tensor也不太大的情況會提示grid太小無法充分發(fā)揮并行性的問題。建議閱讀https://mp.weixin.qq.com/s/1_ao9xM6Qk3JaavptChXew 這篇文章設(shè)置更合理的BlocSize和GridSize,或許可以提升smallIndex Kernel的性能。比如index很小但是輸入Tensor只有一個維度的情況下,這個時候PyTorch只會啟動一個Block以及一個Thread,這顯然是個bad case:圖片在這里插入圖片描述

0x2. 亮點2: 維度壓縮減少坐標(biāo)映射的計算量

index_add里面的第二個優(yōu)化亮點是對Tensor的維度壓縮,對應(yīng)代碼的https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/cuda/Indexing.cu#L793, https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/cuda/Indexing.cu#L787 ,這個維度壓縮是什么意思呢?假設(shè)index_add操作的輸入Tensor是三個維度假設(shè)形狀為(32, 1024, 1024),而dim設(shè)置為0。那么在cuda Kernel中索引位置的時候是可以提前把dim后面的維度給合并起來的(這里使用TensorInfo數(shù)據(jù)結(jié)構(gòu)來完成,其實本質(zhì)上就是操作這個TensorInfo對象維護(hù)的Tensor的stride和size,具體可見這里的實現(xiàn):https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/CollapseDims.h#L22),這樣子原始的輸入Tensor的形狀就會變成(32, 1024)。這樣在indexFuncSmallIndex和indexFuncLargeIndex Kernel里面做坐標(biāo)映射的時候就可以降低計算量以及降低對全局內(nèi)存的訪問提升帶寬。注意,這里的維度壓縮也可以壓縮dim之前的所有維度為一個維度,這樣子最終kernel需要處理的self輸入張量維度只有1,2,3三種情況。雖然這個優(yōu)化是算法層面的優(yōu)化,但是也間接讓cuda kernel的帶寬進(jìn)行了提升和計算量進(jìn)行了下降。實際上這個思路也啟發(fā)了我在oneflow中實現(xiàn)index_add的kernel,我也是間接做了維度壓縮。以這個例子來說:

x = torch.randn(3210241024).to("cuda")
t = torch.randn(1510241024).to("cuda")
index = torch.randint(032, (15,)).to("cuda")
x.index_add_(0, index, t)
torch.cuda.synchronize()

使用ncu在a100 pcie 40g上profile,我發(fā)現(xiàn)使用了維度壓縮優(yōu)化之后將這個cuda kernel從接近300+us的運行速度提升到了180+ us。

0x3. 實戰(zhàn)性能表現(xiàn)

我這里對比了一下PyTorch的index_add和oneflow中index_add的性能表現(xiàn)。做性能profile的時候,我使用了以下腳本:

import torch

x = torch.randn(32*1024*1024).to("cuda")
t = torch.randn(15).to("cuda")
index = torch.randint(01024, (15,)).to("cuda")
x.index_add_(0, index, t)
torch.cuda.synchronize()

x = torch.randn(32*10241024).to("cuda")
t = torch.randn(151024).to("cuda")
index = torch.randint(01024, (15,)).to("cuda")
x.index_add_(0, index, t)
torch.cuda.synchronize()

x = torch.randn(3210241024).to("cuda")
t = torch.randn(1510241024).to("cuda")
index = torch.randint(032, (15,)).to("cuda")
x.index_add_(0, index, t)
torch.cuda.synchronize()

x = torch.randn(32*1024*1024).to("cuda")
t = torch.randn(1024).to("cuda")
index = torch.randint(01024, (1024,)).to("cuda")
x.index_add_(0, index, t)
torch.cuda.synchronize()

x = torch.randn(32*10241024).to("cuda")
t = torch.randn(10241024).to("cuda")
index = torch.randint(01024, (1024,)).to("cuda")
x.index_add_(0, index, t)
torch.cuda.synchronize()

測試環(huán)境為 A100 PCIE 40G,測試結(jié)果如下:

圖片

整體來說 PyTorch 在 index Tensor元素很小,但Tensor很大的情況下相比于oneflow有一些性能提升,其它情況和 OneFlow 基本持平,也有一些case是慢于oneflow比如index很小但是輸入Tensor只有一個維度的情況下,這個時候PyTorch只會啟動一個Block以及一個Thread,這顯然是個bad case。OneFlow的index_add實現(xiàn)在 https://github.com/Oneflow-Inc/oneflow/blob/master/oneflow/user/kernels/index_add_kernel.cu ,我們并沒有針對 index 的大小來單獨派發(fā)kernel,所以在某些case上性能暫時比PyTorch低一些,后續(xù)有需求的話可以繼續(xù)優(yōu)化下。

0x4. 總結(jié)

我這里相對粗糙的學(xué)習(xí)了一下調(diào)研PyTorch index_add這個算子的cuda實現(xiàn)的優(yōu)化技術(shù)。但PyTorch的這個index_add實現(xiàn)仍然有一些改進(jìn)空間,比如IndexToOffset的實現(xiàn)有取模操作,這個可以改成一次乘法和減法,可以節(jié)省計算指令。然后index_add 的兩個kernel來說,GridSize和BlockSize并不是很合理,有改進(jìn)空間。

0x5. 相關(guān)鏈接
  • https://github.com/pytorch/pytorch
  • https://github.com/Oneflow-Inc/oneflow/blob/master/oneflow/user/kernels/index_add_kernel.cu
  • https://github.com/BBuf/how-to-optim-algorithm-in-cuda


*博客內(nèi)容為網(wǎng)友個人發(fā)布,僅代表博主個人觀點,如有侵權(quán)請聯(lián)系工作人員刪除。



關(guān)鍵詞: AI

相關(guān)推薦

技術(shù)專區(qū)

關(guān)閉