Taichi可以更精细地控制并行度和每个元素(element)的操作,大大提高了用户操作的灵活性。Torch将这些细节抽象成张量级的操作,让用户可以专注于机器学习的模型结构。作为机器学习和计算机图形学领域炙手可热的框架和编程语言,Torch和Taichi能否取长补短并结合使用?答案是肯定的。在本文中,作者将通过两个简单的例子来演示:如何使用TaichiKernel在PyTorch程序中实现特殊的数据预处理和自定义算子,告别手写CUDA,以轻量便捷的方式提升机器学习模型算法开发效率和灵活性。Case1:数据预处理Padding是机器学习中常用的一种预处理方法。例如,用户在对图像进行卷积运算时,需要对图像的边缘进行填充,以保证图像输入输出前后的尺寸不变。一般情况下,padding方法包括零填充或其他预设方式,例如torch.nn.functional.pad提供的重复填充和循环填充。但有时我们想在边缘填充特殊的纹理或图案,但没有一个优化良好的PyTorch算子可以适应这种场景。解决方案有两种:使用PyTorch或Python对矩阵元素进行逐个操作;手动编写C++或CUDA代码并访问PyTorch。前者的计算效率很低,会阻碍神经网络的训练速度;后者学习曲线陡峭,实际操作起来很麻烦,开发过程漫长。那么,有更好的解决方案吗?接下来,我们将通过一个例子来展示如何使用太极拳来填充砖墙纹理的边缘。用太极给PyTorch“添砖加瓦”!第一步,我们在PyTorch中创建一块“砖块”,如下图所示。为了更好的观察填充规律,我们给这块“砖”填充了渐变色:填充的基本单位。第二步,我们要在错位的x轴上重复这块“砖头”,如下图效果:由于PyTorch没有为此类padding提供nativeoperators,为了提高运行效率,需要将填充过程重写为PyTorch的一系列原生矩阵操作:deftorch_pad(arr,tile,y):#image_pixel_to_coordarr[:,:,0]=image_height-1+ph-arr[:,:,0]arr[:,:,1]-=pwarr1=手电筒。flip(arr,(2,))#map_coordv=torch.floor(arr1[:,:,1]/tile_height).to(torch.int)u=torch.floor((arr1[:,:,0]-v*shift_y[0])/tile_width).to(torch.int)uu=torch.stack((u,u),axis=2)vv=torch.stack((v,v),axis=2)arr2=arr1-uu*shift_x-vv*shift_y#coord_to_tile_pixelarr2[:,:,1]=tile_height-1-arr2[:,:,1]table=torch.flip(arr2,(2,))table=table.view(-1,2).to(torch.float)inds=table.mv(y)gathered=torch.index_select(tile.view(-1),0,inds.to(torch.long))返回gatheredwithTimer():gathered=torch_pad(coords,tile,y)torch.cuda.synchronize(device=device)这一系列的矩阵运算不是特别直观,并且需要在GPU内存中保存多个中间结果矩阵。一个明显的缺点是它可能无法在内存相对较小的卡上运行。如果我们使用Taichi,我们可以非常直接地描述这个操作:@ti.kerneldefti_pad(image_pixels:ti.types.ndarray(),tile:ti.types.ndarray()):forrow,colinti.ndrange(image_height,image_width):#image_pixel_to_coordx1,y1=ti.math.ivec2(col-pw,image_height-1-row+ph)#map_coordv:ti.i32=ti.floor(y1/tile_height)你:ti.i32=ti.floor((x1-v*shift_y[0])/tile_width)x2,y2=ti.math.ivec2(x1-u*shift_x[0]-v*shift_y[0],y1-u*shift_x[1]-v*shift_y[1])#coord_to_tile_pixelx,y=ti.math.ivec2(tile_height-1-y2,x2)image_pixels[row,col]=tile[x,y]withTimer():ti_pad(image_pixels,tile)ti.sync()这段代码的逻辑很简单:遍历输出图像的每个像素,计算当前像素在输入“砖块”图像中对应的位置,最后复制该位置的颜色到这个像素。虽然看起来是一个一个地写入每个像素,但Taichi将内核的顶层for-loop编译成高度并行的GPU代码。同时,在上一段代码中,我们直接将两个PyTorchTensor传给了Taichi函数ti_pad,Taichi会直接使用PyTorch分配的内存,不会因为两个框架之间的数据交互而产生额外的开销。最后,实际计算性能为:在RTX3090GPU上运行时,PyTorch(v1.12.1)耗时30.392ms[1],而Taichi版本的Kernel仅耗时0.267ms[2],Taichi相对于PyTorch的加速比超过100倍。*由于实现细节和运行的硬件,加速比会略有不同。实际上,上述PyTorch底层实现需要启动58个CUDAKernel。在本例中,Taichi将所有操作编译到1个CUDA内核中。更少的Kernel减少了GPU函数启动的开销,Taichi相比PyTorch实现省去了很多冗余的内存操作。GPU上的内存操作远比计算操作“昂贵”,这也是加速比被夸大的根源。Taichi的设计遵循“Megakernel”的设计原则:使用单个大Kernel来完成尽可能多的计算逻辑,这与机器学习系统设计中常见的“算子融合优化”相同。在数据预处理方面,一方面,太极具有更细的操作粒度,可以灵活适应研究者的不同需求。另一方面,太极可以实现更高的计算性能,显着提高预处理部分的速度。当然,预处理只是机器学习训练和推理过程中的一小步。对于机器学习领域的研究者来说,大量的时间花在了模型的正向和反向计算算子上。那么对于定制高性能的ML算子,Taichi有什么好的解决方案呢?案例2:自定义高性能ML算子存在与预处理相同的问题。在很多情况下,研究人员使用的算子非常新,或者只是他们自己发明的,在PyTorch中找不到很好的支持。考虑到机器学习训练和推理的计算量大、成本高,许多研究人员不得不学习CUDA并想方设法调优以提高计算效率。但是CUDA代码编写和调试难度大,会拖慢模型的迭代速度。有一篇知乎文章[3]描述了一个精彩的例子:作者开发了RWKV语言模型,使用了一个类似于一维depthwiseconvolution的自定义算子。这个算子本身计算量很小,但是由于PyTorch中没有原生支持,所以运行起来很慢。为了解决计算性能问题,作者编写了CUDA代码,并使用了循环合并、SharedMemory等多种技术进行优化,最终性能达到了PyTorch所达到性能的20倍。参考这篇文章和发布的CUDA代码,我们也用同样的优化方法实现了对应的Taichi版本。那么Taichi在这个例子中表现如何呢?请看下图:RTX3080上的RWKV运行时间,单位毫秒,越低越好。基线是指代码直接实现了算法,没有做任何优化。v1-v3代表不同的优化版本。CUDA实现代码见[4],Taichi实现代码见[5]。我们可以看到,在使用相同优化技术的前提下,Taichi版本实现了非常接近CUDA的性能,在某些情况下甚至略快。Taichi是如何达到这种性能水平的?这有多容易?下面我们就以Baseline版本为例,体验一下如何用Taichi轻松实现深度卷积算子!operator本身的操作过程很简单:遍历两个输入Tensorw和k,将对应位置的元素相乘,通过一个累加循环计算出s存入输出Tensorout。Python实现(非常慢且易于理解)defrun_formula_very_slow(w,k,B,C,T,eps):out=torch.empty((B,C,T),device='cpu')forbinrange(B):对于范围内的c(C):对于范围内的t(T):s=eps对于范围内的你(t-T+1,t+1):s+=w[c][0][(T-1)-(t-u)]*k[b][c][u+T-1]out[b][c][t]=sreturnout这段代码非常直观易懂,但是它运行得很快太慢了,以至于测试的数据无法将其放入上图中......
