Triton使用和Softmax实现
参考 Triton论文
参考 GPU MODE Lecture 14: Practitioners Guide to Triton
从Trinton主页引用的话
现代 GPU 的架构大致可以分为三个主要组件 ——DRAM、SRAM 和 ALU—— 在优化 CUDA 代码时必须考虑每个组件:
- 来自 DRAM 的内存传输必须合并为更大的事务,以利用现代内存接口的大型总线宽度。
- 数据在被再次使用之前,必须手动存储到SRAM中,并且要对数据进行管理,以便在检索数据时尽量减少共享内存存储体冲突的情况。
- 计算必须在流式多处理器(SM)之间和内部仔细分区和调度,以促进指令 / 线程级并行性并利用专用 ALU(例如Tensor Core)。
这几句话可能比较抽象,下面给一下这几个组件的指标可能感受更直观,参考Which GPU(s) to Get for Deep Learning: My Experience and Advice for Using GPUs in Deep Learning。
其中指出:
- 全局内存访问(高达 80GB):
~380
个周期 - L2 缓存:
~200
个周期 - L1 缓存或共享内存访问(每个流式多处理器高达 128 kb):
~34
个周期 - 融合乘法和加法,ab+c(FFMA): 4 个周期
- Tensor Core 矩阵乘法:1 个周期
每个操作总是由 32 个线程组成的Warp执行,Warp中的线程必须相互等待。GPU 上的所有内存操作都针对warp进行了优化。
根据Simplifying CUDA kernels with Triton: A Pythonic Approach to GPU Programming的说法,GPU中的HBM(High Bandwidth Memory)等价于我们讲的Global Memory,SRAM对应的是L1和L2 Cache对应的是Shared Memory,这几个词在一些文档中可能会有不同的叫法,但是意思是一样的。
A100中的内存带宽约为 2TB/s,L1 缓存带宽:~100-200
TB/s 理论带宽,L2 缓存带宽:~4-7 TB/s
理论带宽。
再看OpenAI的三条说明的意思就是:
- 因为DRAM很大,比较容易占满总线带宽,所以尽量合并传输的事务可以减少传输的时间,让高速公路跑满。
- 如果数据要重复利用,反复参与计算,尽量让他们在SRAM当中能够缓存住,比如L1的读取只要34个cyle,能比从L2中快6到7倍。
- 尽量跑满并行度,并且利用更高效的计算单元,比如Tensor Core。
这个是OpenAI给出的GPU架构的简图,我们需要明确不同内存,缓存,和执行单元的周期之间的关系就比较好理解GPU计算当中的性能瓶颈。
Triton的目标其实就是优化 HBM -> SRAM -> 寄存器
的带宽,这在Torch里面直接实现不了,通过一些融合算子是可以减少写回到HBM的。
Triton的文档给出的很多实现的代码,可能都不太奏效了,笔者自己测试下来并没有超过torch本身的实现,
可能torch本身也再不断改进吧,这些差别很快就超越了,但是在一些写自定义融合算子方面应该还是比较有优势的。
Triton的使用
和CUDA对应的关系:
- 程序(Program):处理数据块的kernel实例。
- PID(程序 ID):等同于 CUDA 中的块 ID。
- 向量化操作:在多个数据点上同时操作(triton不需要用户关心向量操作的并行化)。
先给出变量和修饰器的解释,大部分文档都混在注释里面不是很好阅读,我觉得先介绍一些简单概念再看代码会比较好一点。
@triton.jit
装饰器表示这个函数会被编译。tl.constexpr
代表常量表达式,可以让编译器在编译期间直接求值,可以当作常量使用了。BLOCK_SIZE
对于GPU来说是比较固定的,因为一个block是有threads数上限的。
通过执行cuda-samples中的deviceQuery
可以发现L40的显卡BLOCK_SIZE最大是1024,大部分显卡应该都是这个固定大小。
1 | Total number of registers available per block: 65536 |
pid = tl.program_id(axis=0)
应该是对应的CUDA中的threadIdx.x
的作用,对应block的一维下标,pid = tl.program_id(axis=1)
应该是对应的CUDA中的threadIdx.y
的作用,对应block的二维下标。
autotune
是一个黑盒优化,通过内部的小benchmark的方式去基于key的变量,优化configs里面的参数。
下面是一个只有两个配置的搜索空间,当n_elements
的值发生变化的时候,会自动选择最优的配置。
1 | @triton.autotune(configs = [ |
BLOCK_SIZE表示的是一个program
负责的BLOCK大小,放在triton的语境下更像是L2 Cache的大小
。
但是cuda当中的block是包含n个thread的,表示的是并行线程的大小
。
笔者的这个说法可能不太精准,但是这两种风格导致Cuda写一些element wise的操作比较合适.
每个element wise的操作都是一个thread,这样可以充分利用GPU的并行性。
而triton比较适合一些Reduce操作,例如对数据(也就是矩阵)切BLOCK,然后每个kernel去负责一个block,
他的好处就是比如softmax这样的在行上做reduce操作会比较直观,而矩阵乘法也可以沿着MxK,KxN的维度,沿着不同的维度切块。
Triton能够帮你把矩阵乘法优化得很不错,虽然可能还比不上精准手写的Cuda算子。
Triton的范式和CUDA的Single Instruction, Multiple Thread (SIMT)不一样,官网给出了一个简化的例子。
这是CUDA like的写法,每个threadId.x
代表的线程只算一个element
。
1 | BLOCK = 512 |
Triton文档中的Matrix乘法简化来说就是并行计算M*N个block(沿着K所代表的维度)。
这是Triton的写法,每个Program负责了一个block,他就少了一个block的切分维度:
1 | BLOCK = 512 |
笔者比较疑惑,单就这两个代码他们的并行度貌似是不一样的,难道是把block那一层隐式的放在了load
和store
当中,他的load
和store
其实是隐含了并行能力的。
援引知乎的文章,Triton中一直是以Block为中心来计算,直到Lowering到LLVM和PTX才会转为Thread为中心的计算,而这些对于使用Block抽象进行编程的用户来说都是无感的。是符合笔者预期的,triton简化了CUDA的写法,block具体的线程数的,每个线程处理多少元素,triton自己会去帮你处理。
当使用 triton 的时候,x = tl.load(x_ptr + offsets, mask=mask)
时,我们正在加载到 L2 缓存 或者叫 SRAM 中。
根据Torch的blog,以及参考 OpenAI/Triton MLIR 迁移工作简介,Triton编译的过程是@triton.jit
装饰器通过遍历提供的 Python 函数的抽象语法树(AST)来工作,以便使用通用的 SSA 构造算法即时生成 Triton-IR。
然后,生成的 IR 代码被我们的编译器后端简化、优化和自动并行化,最后被转换成高质量的 LLVM IR,最终是 PTX(Nvidia GPU的汇编),可以在最近的Nvidia GPU上执行。
矩阵乘法
这段代码是基于K切BLOCK,比上面的代码要好理解一点。
1 |
|
BLOCK_SIZE 和 GROUP_SIZE 的优化。
一次计算的时候尽量用满L2 Cache,所以可以把多个BLOCK放到一个GROUP里面,这个GROUP变成了grid的切分,但在GROUP里面我们
再去做BLOCK级别的计算,要计算好对应的线性空间中的stride。
Softmax
下面的代码只能在n_cols
小于BLOCK_SIZE
的数据上运行。
1 | import triton |