Introduction
以Halide
/TVM
为代表的张量运算编译器,引入了计算与调度分离的概念,使得大家可以只用写一套计算描述(Tensor Expression
,只与计算的数学形式有关),用不同的调度原语(Schedule Primitive
)来描述如何去优化程序(如何做矩阵分块,绑定线程,利用缓存,设计流水线,利用硬件的加速单元),这个过程可以手动,也可以利用自动化的调度模板生成(例如 AutoTVM
)并搜索,从而为不同的硬件后端生成高效的代码。
但是最初的Halide
和TVM
都是针对稠密/规整运算设计,因此很难对于深度学习中的稀疏算子做统一的抽象和优化,在之前TVM
对于稀疏矩阵需要手写每一个手动优化好的算子,无法利用调度原语设计统一的优化方案。
稀疏运算编译器,随着 TACO
(The Tensor Algebra Compiler
)等项目的出现重新进入人们视野,其主要思路是计算与数据结构分离,使得大家可以只写一套计算描述和一套数据结构的描述。而在图形学中很多数据有 spatially sparse(全局稀疏,局部稠密)的特性,使用专门设计的的层次化数据结构来存储会有更高的效率。
因此能否把这两类工作结合到一起,从而减少手写稀疏算子的负担,应对深度学习中各种不同算子/格式的组合,是 SparseTIR
这个项目的初衷。
包括TACO
在内的稀疏矩阵编译器,利用他们强大的格式抽象可以表示结构化稀疏,但是目前并没有很好的硬件加速指令支持,从而并没能实现对结构化稀疏运算的有效加速。这是因为在张量编译器中,为了使用这些硬件指令,我们将程序中的子结构与硬件指令的描述相匹配,并替换为相应的指令实现,这个操作由特定的调度原语实现。
Halide
和TACO
这些编译器把所有的调度信息存储在schedule tree
(调度树)/provenance graph
(来源图)这样的中间数据结构上,这样的设计过于中心化:每个调度原语都会对调度树本身带来一些约束,调度原语之间可能会互相影响,给引入新的硬件指令带来了困难。
TVM
中新的算子级别抽象 TensorIR
的提出正是为了解决如何使用硬件加速单元的问题,TensorIR
放弃了 schedule tree
这些中间数据结构,而是把所有的信息记录在IR
本身,通过这样的设计解耦了调度原语之间的相互制约,调度原语本身只会与IR
进行交互,因此加入新的硬件指令不需要考虑其对其它调度原语的影响,可以更有效率地支持大量的硬件指令。
每一个调度原语都被写成了一个带参数的程序转换函数(效果上等价于Compiler Pass
),为了与硬件加速单元适配,TensorIR
提出了 block
(块)这样的抽象,每一个 block
都是整个运算的一个子结构(可以描述计算或者数据搬运),调度原语可以操作这些 block
及其外部的循环结构,与加速单元的语义描述相匹配的 block
可以被替换为相应的硬件加速指令。
相比于原来的Tensor Expression
前端,TensorIR
的表达力足够强大,有循环和”块”这样的数据结构,已经可以用来手写一些稀疏算子(TVM
原有的Tensor Expression
只能描述循环体内的操作),但是相比于TACO
,仍然需要手动处理稀疏数据结构的编码和解码过程。
因此,面临的问题几个:
TVM
等传统编译器并不支持稀疏运算的支持TVM
中初始的Relay
甚至TensorIR
都不能很好的描述稀疏数据结构TACO
没有很好的硬件加速指令支持,运算速度较慢TACO
中的调度算法的设计过于中心化,每个调度原语都会对调度树本身带来一些约束,调度原语之间可能会互相影响。
因此,这里提出了 SparseTIR
来应对这些问题
系统架构
SparseTIR
整个的编译流程可以总结为上图。
SparseTIR
分为了三个阶段:Stage I
(坐标空间计算)→ Stage II
(位置空间计算)→ Stage III
(TensorIR
),并设计了两个lowering
(递降)算法,分别表示I
到II
阶段,II
到III
阶段的转换。
程序被变换成TensorIR
之后,就可以直接使用TVM
已有的pass
继续生成不同设备的代码。
在SparseTIR
中,我们也希望所有的调度都在IR
本身完成,这里将所有的调度原语也分为了三类,分别在IR
的三个阶段使用,这样的设计有别于以往的张量编译器中single-shot scheduling
(一次性发射所有的调度指令)的设计,有助于我们同时考虑高层级和低层级的优化:
- 在第一阶段中,我们可以进行稀疏格式感知的优化(例如
DecomposeFormat
)。 - 在第二和第三阶段中,我们的
IR
更接近与底层,可以进行硬件感知的优化(例如使用硬件加速指令等等)。
其中不同的阶段的调度选择,以及格式分解的选择,构成了SparseTIR
的整体搜索空间,因此可以进行(格式*调度的整体优化)
SparseTIR
没有像TACO
一样提出具体的搜索算法,而是提出了一个框架让用户自己指定搜索空间。
需要注意的是
SparseTIR
暂时未支持Auto Scheduling
,也没有任何默认的算法
SparseTIR
的抽象设计
首先明确, SparseTIR
是依赖于 TVM
与 TensorIR
的,因此,这个实际上是以 TensorIR
为基础的一个处理稀疏数据拓展版本。
这里作者提出一个观点:
我们观察了非结构化稀疏运算的
GPU
算子常用优化,主要要点在于load-balance
(负载均衡)和访存优化。这两者都属于被研究了几十年的老大难问题,性能高度依赖于非零元素本身的分布。深度学习中很多稀疏矩阵的非零元素分布是固定的(stationary-sparsity
),如果我们能在编译期就利用这些信息提前做好调度,那么可以省掉很多运行时调度的开销。
因此,SparseTIR
设计了可组合式的抽象:Composable Formats(可组合的格式) 和 Composable Transformations(可组合的程序变换)
可组合的格式
Composable Formats
代表,虽然很多矩阵本身很难挖掘出整体结构,但是我们把它分解成很多更小的结构化稀疏矩阵之和,不同的部分用不同的格式来存,其中每一个小矩阵都更加对硬件友好,相比于把整个稀疏矩阵做 padding
或者 block-sparse
化,带来的块内碎片会显著更少。如下图所示:
可组合的程序变换
把程序变换分解到 IR 的各个层级,多级调度,使得我们可以在同一个框架下完成数据感知的高语义层级优化和偏硬件层级的优化。
格式抽象与矩阵存储
格式描述是 SparseTIR
的一等公民,其对于矩阵的描述与存储延续了 TACO
的一些理念:
SparseTIR
创建了一个类似的 axis(sparse iterator)
结构,保留了TACO
的 dense/compressed(sparse)
属性,并增加了一组属性fixed/variable
,表示当前维度是定长还是变长
这有助于我们生成更高效的代码——编译期能得到的信息越多,可能做的优化就越多。
- 传统的稠密矩阵的每一个维度在
SparseTIR
中都会使用dense_fixed
来标注,代表其连续而且定长; - 对于非零元位置不连续(稀疏)的维度,我们使用
sparse
来标注,需要指定indices
数组存储其非零元的坐标; - 对于长度不一定的维度,我们使用
variable
来标注,需要指定indptr
数组,储存每一个fiber
(fiber
是”行”和”列”在高维稀疏矩阵中的拓展)的初始指针位置。
所有的 sparse
和 variable
的维度都必须指定一个其依赖的维度,如果把每个axis
向其依赖的axis
连一条边,那么在一个SparseTIR
的程序中,所有的axis
会组成一个森林,而森林中每一个树的根节点都是 dense_fixed
的。
例子如下:
其存储的实例如下:
这里的 就是一个 CSR
形式表示的矩阵,上图例子可以表示
采用迭代器的存储设计,使得描述更易于拓展,可以定制更加复杂的矩阵结构,也方便描述一些具有特定分布的稀疏矩阵。
解决了矩阵的存储描述,我们还需要解决矩阵相乘的抽象,换而言之就是如何进行计算:
我们需要一个数据结构来指定程序的迭代空间,因此我们创建了一个叫做sparse iteration
(稀疏迭代空间)的数据结构,其语法结构如下:
其接收一组迭代器( [I, J, K]
),并生成一组循环变量 i, j, k
,在这个稀疏迭代空间的主体中我们可以写任意的数学表达式,来描述我们的计算,在稀疏迭代空间的语义中,我们无需考虑 A
, B
,X
, Y
具体的存储格式,只需当作他们是稠密数据结构来描述数学运算,我们将这个阶段的数学运算称为(坐标空间计算)。
Stage
解读
Stage I
- 格式分解
- 空间坐标计算
- 调度
在前文中我们提到可以把计算分解到更小的结构化稀疏矩阵中,在SparseTIR
中,这通过一个pass
: DecomposeFormat
来实现:
用户需要提供一组FormatRewriteRule
(使用TVMScript
)来指定新的子矩阵格式,作为参数传给DecomposeFormat
这个pass
,随后这个pass
将会生成新的子矩阵所需要的axis
和sparse buffer
,并把原来在原有稀疏迭代空间中的矩阵运算改写成在新的结构化稀疏迭代空间上的运算
如下图例子所示,将CSR
格式的计算分解为BSR
格式的计算后生成的稀疏矩阵-矩阵乘法(SpMM
)的IR
,块大小为 2,每行有 2 个非零列的 ELL 格式。除了对新格式的稀疏计算外,还产生了另外两个将数据从原始格式复制到新格式的稀疏迭代。当要分解的稀疏矩阵是静止的,我们可以在预处理步骤中进行数据复制,以避免运行时格式转换的开销。
这个 pass
会为新建的矩阵分配 axis
与缓冲区,并且会生成迭代空间,如:若定义了 ,代码如下:
在分解矩阵后,pass
会重写计算规则如下:
这一步就被称之为 空间坐标计算
注意这个
pass
本身对数据是不可知的,如何生成新的子矩阵每个维度的indptr/indices
等信息,需要用户在运行时完成
SparseTIR
为一些论文中所使用的格式分解模式提供了一些运行时函数,对于通用的格式分解,如何自动化地推断出indptr/indices
等信息,仍然是一个有趣的研究问题
关于第一阶段的调度,定义了两个调度源语,sparse_reorder
和 sparse_fuse
。
sparse_reorder
稀疏迭代中的稀疏轴的顺序影响第二阶段生成的循环的顺序。这个基元可以对稀疏轴的顺序进行操作sparse_fuse
将一个给定的稀疏迭代中的几个迭代器融合成一个。例如想要一个单一的循环而不是两个在所有非零元素上迭代的嵌套循环。
我们可以从下图理解这两个源语:
Stage II
区分 Stage-I
和 Stage-II
的一个重要标志是坐标空间(coordinate space)和位置空间(position space)的区分
其含义是逻辑上的坐标和在压缩存储格式下的物理位置,例如下图中,假设我们以 1-base
计数,元素-1
的坐标为4
,而位置为2
对应到 SparseTIR
中,我们在1
阶段的迭代变量和稀疏矩阵的访问语义均是在坐标空间中,到了2
阶段,我们希望把所有的稀疏迭代数据结构改写成TensorIR
中的嵌套循环和block
,并把稀疏矩阵访问和循环变量的语义改写到坐标空间,使得2
阶段的IR
与TensorIR
的调度原语兼容。
我们设计了一个pass
: Sparse Iteration Lowering
,用于从1
阶段到2
阶段IR
的转换,坐标空间到位置空间的转换算法涉及稀疏结构的压缩/解压过程。
这个 pass
分为四步:
-
Auxiliary buffer materialization
在创建axis
时,indptr
和indices
的指针被指定为参数。在第二阶段,我们需要明确声明这些辅助缓冲区,以便在确定循环范围和翻译坐标时访问它们的值。除了辅助缓冲区外,我们还创建了指示缓冲区值域的提示,这些提示在第二阶段执行调度时用于整数集分析。下图展示了其工作方式: -
Nested loop generation
这一步将第一阶段的稀疏迭代重组为第二阶段的嵌套循环:我们在稀疏迭代中的每个axis
发出一个循环。产生的循环从 开始。它们被TensorIR
的块结构分开,建立边界以防止跨块循环的重新排序。此外,我们在最内层生成的循环内添加一个块,并将原始稀疏迭代的主体置于其中。下图显示了不同稀疏迭代的嵌套循环结构: -
Coordinate translation
这一步将用于访问稀疏缓冲区的指数从坐标空间改写为位置空间,以弥补第一阶段和第二阶段之间的语义差距。具体的做法可以参照下图:这里使用了在第一步中创建的辅助缓冲区
J_indptr
与J_indices
-
Read/Write Region Analysis
缓冲区的读/写区域信息对于TensorIR
的块构造是必要的。我们执行一个缓冲区区域分析来收集缓冲区的访问信息,并将每个块内访问的所有读/写区域联合起来,并将它们注释为块属性
关于这阶段的调度,其负责操作循环(fuse
/reorder
/split
),在内存层次中移动数据(cache_read
/cache_write
),将循环与物理/逻辑线程绑定以使其并行化,并在硬件中使用矢量/张量指令(vectorize
/tensorize
)。由于在第二阶段已经完成了 SparseTIR
与 TensorIR
中调度源语的兼容,因此,我们在第二阶段完全支持 TVM
调度源语。
值得一提的是,SparseTIR
目前只支持生成嵌套循环,尚不支持生成 co-iterations
co-iteration
结构指在两个稀疏向量的交集/并集做迭代,但具体的介绍在论文中并没有提及,暂时不清楚这应该是一个什么样的问题
Stage III
此阶段是我们的目标IR
: TensorIR
,在2
阶段中我们依然保留了axes
和 sparse buffers
这些数据结构,而3
阶段中我们需要把它们全部改写成普通的 buffer
,删去所有的稀疏抽象。
这里我们通过 Sparse Buffer Lowering
方法来解决:
利用 indptr
信息,计算出在一维连续储存下的offset
,举例而言, 会被翻译为 ,于是,所有的 sparse buffers
都能被扁平化为一维向量的形式,如下图所示:
比如,二阶段中的
会被翻译为:
C
被改写为了一维向量,而对这些 buffer
的访问规则也进行了相应的改写,第3
阶段的IR
是完全的TensorIR
,可以直接使用TVM
的编译栈生成后端代码。
TVM
后端上的变化
由于在格式分解之后我们会生成许多在这些小矩阵上的算子,对于 CUDA
后端,启动这些 kernel
会有一定的开销,因此我们在 TVM
的加入了Horizontal Fusion
作为后处理,把这些小矩阵上的算子融合到同一个算子中,例如下图所示:
图源自论文原作者
源码解析
在之前提到,SparseTIR
是 TIR
的一个稀疏化版本,以 TIR
为基础发展而来的,因此我们只需要在 TIR
版本上看 SparseTIR
的改进即可。
并且 SparseTIR
的工作重点在数据的表示与处理上,所以这里就从data structure
→ pass
→ schedule
来解读源码
在 sparsetir/python/tvm/script/tir/__init__.pyi
中列出了 sparse
增加的数据结构与函数
数据结构
Axis
在之前提到过,这是一个迭代器,其实现如下:
这里向 tvm
框架中注册了一个 Object
类,其名称为 Axis
注意这里的 PrimExpr
实际上是一个 Node
类,称为源语表达式,用于调度时的分析等,而 Node
的定义如下:
而 Var
是可以看作是一个迭代器类,定义如下
Axis
具体的参数解释如下:
name
:Axis
的名称parent
:Axis
的父节点length
: 当前Axis
的长度上界nnz
: 从根节点(最初的Axis
)到当前Axis
的非零元素的累计数量nnz_cols
: 当前行非零列数,仅对固定轴有效indptr
: 索引指针indices
: 非零元素的索引idtype
: 索引的数据类型(可以是int32
,float16
等)sorted
: 索引是否能被排序
矩阵声明
矩阵分为四种:
- 稠密定长
- 稠密变长
- 稀疏定长
- 稀疏变长
-
dense_fixed_axis
定义如下:实际在使用时,我们可以通过如下方式使用:
其中
m
是这里要求的length
构造的映射通过下面来实现:
-
dense_variable_axis
定义如下应用时如下:
这里
(I, J2)
构成了第一维连续,第二维也连续但是不定长的迭代空间,第一维的长度为n
,第二位的最大长度也为n
同样,映射的类实现如下:
这里的
nnz
可以为None
-
sparse_fixed_axis
具体使用如下:
注意这里,我们同时指定了
length
与nnz
的值 -
sparse_variable_axis
具体使用如下:
在这里我们还指定了
indices
的值
以上就是基础的数据结构的定义,需要注意的是,这里实际上还是抽象,真正底层的实现是使用 tvm
中的 Array
对于
Buffer
部分python
部分只有定义,完整的实现在cpp
中src/tir/transforms/lower_sparse_buffer.cc
,在此实现中,会将声明和match_sparse_buffer
降低到match_buffers
,然后通过lower_match_buffer.cc
中的实现,将指针与buffer
绑定在一起当然,如果作用域不是全局,那么其储存位置在
shared memory
上,属于临时分配的buffer
,不需要与指针进行绑定
编译
在之前提到,SparseTIR
的目标是转化为 TensorIR
,但是为实现可组合格式与可组合变换,他加入了两个 pass
来完成这一点:
DecomposeFormat
目的是把计算分解到更小的结构化稀疏矩阵,最后进行空间坐标计算Sparse Iteration Lowering
目的是将空间坐标转换为位置坐标,进行稀疏结构的压缩/解压
下面对三个阶段进行阐述,详细解读这两个 pass
的做法
Stage I
考虑 DecomposeFormat
,其需要传递一个 FormatRewriteRule
类的参数,此类的定义如下:
实际上需要传递的就是:
- 重写方法的名称
- 新矩阵的存储格式
- 需要被重写的缓冲区
- 重写前的迭代器
- 重写后生成的迭代器
- 重写前后迭代器的映射方式
- 格式的映射方式
示例如下:
其中 ell.specialize
是一个脚本序列化参数的方法,通过将缺少的参数填入来完成一个脚本函数的生成,比如上面,就会将 bucket_size
的值填入所有 nnz_cols_symbol
占用的位置
最后的是两个 csr2ell
的索引映射方式(索引包括 indices
与 indptr
)
这里提供了很强的自定义性,用户可以设计自己的矩阵存储格式,只需要重写索引的映射规则,那么就可以进行转化
传入这个自定义的参数后,执行
注意这里的 mod
是一个 Tensor IR
模块,通过传入这个参数,进行矩阵格式的重组。
此 pass
的实现在 src/tir/transforms/sparse_format_decompose.cc
中
定义如下:
对照 python
中的接口:
实际上就是返回生成的 IR
,这里我们考虑 lambda
函数 pass_func
中的 SparseFormatDecompose
:
注意到这里需要判断 SparseTIRLevel
是否为 2
,防止 lowering
错误
主要是因为
SparseTIR
提供了与TIR
相互转化的功能
这里返回的 f
就是分解完矩阵后的 IR
了,在这之后,消除多余的缓冲区即可:
其 C++
实现为:
这里就完成了分解后的迭代与计算规则重写,最后返回 IR
接着,我们需要进行调度,在这一阶段支持的调度只有 reorder
与 fuse
,其接口如下:
我们可以通过下面的方法来使用(mod
为IR
):
而其具体实现为:
较为复杂的是 fuse
的实现,这里根据 fuse
规则,重写了循环迭代,并且生成了新的 Axis
来适应新的循环迭代器
Stage II
在这一步,我们需要将 SparseTIR
的迭代重写为 TIR
的迭代形式
因此,在这里加入了一次 lowering
:
其 C++
实现为:
此函数的流程与 [Stage II](#Stage II
) 中描述的一致:
- 更新缓冲区的映射,包括建立辅助缓冲区等
- 将循环进行展开
- 在展开的循环中插入
block
进行包装 - 完成空间坐标到位置坐标的计算映射
生成符合 TIR
一样拥有块结构的迭代后,我们进入这一阶段的调度,而这里的调度我们可以直接使用 TIR
中的调度源语,而不需要自己再去实现 SparseTIR
的调度源语了(因为迭代结构一致)
举例如下:
显然,这里的调度源语都是 TIR
中已经实现的内容
Stage III
在这一步,所做的就是把 buffer
与 axis
lowering
到 TIR
的级别上,我们具体的使用如下:
这样,得到的 mod
就是一个 TIR
级别的 IR
了
其具体做法如下:
注意到这里的 SparseTIRlevel
已经降为 1
,这是因为在 Stage II
中,我们降低了其调度这一级别到 TIR
抽象
所以在这里,我们需要做的就是把 Sparse Buffer
与 Axis
抽象全都删去,将矩阵乘转变为通过 offset
完成的向量乘(也就是说通过偏移量来计算最后的结果),因此这一步实际上就是在计算 offset
完成这一步后,我们就到了 TIR
级别的抽象,相当于我们已经进入了 TVM
,直接使用 TVM
的 build
即可:
然后调用函数运行:
复现
经作者指出,AE 的所有代码与实验(baseline 等)均在已 开源 中。 此部分在论文的附录
B.3 Description
中提及
在 example
中给出了如何使用 SparseTIR
的示例,如 spmm
:
SpMM
在 RTX 3090Ti
上复现 SparseTIR(no-hyb)
与 SparseTIR(hyb)
的结果如图:
SDDMM
在 RTX 3090Ti
上复现 dgl
与 SparseTIR(hyb)
的结果如图: