(给ImportNew加星标,提高Java技能)
导读
背景:SIMD 和自动向量化
SSE
和 AVX
,ARM 的 NEON
和 SVE
)。它们利用向量寄存器,可以保存多个相同类型的值。例如,一个 avx512
寄存器(512 位)可以保存 64 个字节、16 个整数/浮点数或者 8 个长整数/双精度浮点数。因此,它们可以使用单个指令加载、存储、加法、乘法等多个值,但通常与标量(单个)值具有相同的成本(每个周期的指令数和延迟)。static void test(float[] data) {
for (int j = 0; j < N; j++) {
data[j] = 2 * data[j]; // ------------ 标量
}
}
static void test_unrolled(float[] data) {
// 循环展开前
for (int j = 0; j < N; j+=4) { // 每次增加4个元素
data[j + 0] = 2 * data[j + 0];
data[j + 1] = 2 * data[j + 1];
data[j + 2] = 2 * data[j + 2];
data[j + 3] = 2 * data[j + 3];
}
// 循环展开后
}
static void test_vectorized(float[] data) {
// 循环展开前
for (int j = 0; j < N; j+=4) { // 每次增加4个元素
vector_4floats v1 = data[j : j + 4]; // 向量加载
vector_4floats v2 = vector_4floats(2, 2, 2, 2); // 向量常数
vector_4floats v3 = v1 * v2 // 逐元素:4个并行乘法
data[j : j + 4] = v3; // 向量存储
}
// 循环展开后
}
Test.java
):public class Test {
static int N = 100;
public static void main(String[] strArr) {
float[] data0 = new float[N];
for (int i = 0; i < 10_000; i++){
init(data0);
test(data0);
}
}
static void test(float[] data) {
for (int j = 0; j < N; j++) {
data[j] = 2 * data[j]; // <-- this is vectorized
}
}
static void init(float[] data) {
for (int j = 0; j < N; j++) {
data[j] = j;
}
}
}
./java -XX:CompileCommand=printcompilation,Test::test -XX:CompileCommand=compileonly,Test::test -Xbatch -XX:+TraceLoopOpts Test.java
./java -XX:CompileCommand=printcompilation,Test::test -XX:CompileCommand=compileonly,Test::test -Xbatch -XX:+TraceSuperWord Test.java
// -XX:LoopMaxUnroll=5 限制展开因子最大为 4。
rr ./java -XX:CompileCommand=printcompilation,Test::test -XX:CompileCommand=compileonly,Test::test -Xbatch -XX:+TraceSuperWord -XX:+TraceLoopOpts -XX:LoopMaxUnroll=5 Test.java
rr replay
// 在应用 SuperWord 之前设置断点:
(rr) b SuperWord::SLP_extract
// 在应用 SuperWord 之前提取循环的所有节点:
(rr) p lpt()->_head->dump_bfs(1000,lpt()->_head, "#A+$")
// 运行 SuperWord:
(rr) finish
// 在应用 SuperWord 之后提取循环的所有节点:
(rr) p cl->dump_bfs(1000,cl, "#A+$")
LoadF -> LoadVector
AddF -> AddVF // 注意: "2 * x" 被替换成了 "x + x"
StoreF -> StoreVector
4x
展开)。for (int j = 0; j < N; j++) { data[i] = 2 * data[i]; }
Phi
节点:一个持有 i(
IV:归纳变量),另一个持有存储状态。我将所有的加载、加法和存储操作与数据数组 data
中的相应偏移量对齐。我们可以看到,这里的所有加载和存储操作都在同一个内存切片上,即 float 数组 data
。i+2
次迭代的 LoadF
依赖于第 i+1
次迭代的 StoreI
。但是我们可以证明它们没有访问内存中相同的位置。因此,我们进行了依赖性分析,得到了一个改进的依赖性图。在其中,我们忽略了不访问内存中相同位置的加载和存储之间的依赖关系。在我们的示例中,我们可以删除循环迭代之间的所有依赖关系。DAG
:有向无环图。给定
:具有基本块(循环体,无控制流)的DAG
。目标
:修补DAG
,使标量操作打包成 SIMD 指令。新的DAG
必须保留旧的DAG
的行为。同构
:为了将标量操作打包成单个 SIMD 指令,它们必须是相似的(简化:相同的Opcode
和velt_type
)。独立性
:如果两个操作之间没有路径,则两个操作是独立的。我们只能将独立的操作打包成 SIMD 向量,因为它们是同时执行的,因此一个操作不能以任何方式使用另一个操作的输出。相邻的内存操作
:具有可证明的确切偏移量sizeof(type)
的两个内存操作。因此,可以将相邻的两个加载或两个存储器打包成单个矢量加载或存储器(我们可以避免使用使一切变得更加复杂的收集和散布操作)。包
:一个n 元组
[s1, ..., sn]
,其中s1,...,sn
是独立的
和同构的
。对
:是大小为二的包
。PackSet
:Pack
的集合。
同构
和 独立性
是 SLP 向量化的必要条件。它们有助于在其图邻域中对各个节点做出“本地”决策。但是它们不足以确保没有循环。我们将稍后详细讨论此问题。打包相邻的
、同构的
和独立的
内存操作对
,这是初始的 PackSet
。pack
或 PackSet
将被过滤掉。最后,我们可以安排 PackSet
,并用矢量节点替换其中的 C2 IR 节点。i-1
),在第二个例子中,我们存储 "forward" (i+1
)。在第一个例子中,循环迭代是独立的,而在第二个例子中,我们看到上一次迭代的 StoreF
存储到下一次迭代的 LoadF
加载的位置。这种依赖关系必须得到尊重。现在,我们看到负载不是独立的。vector_width / sizeof(type)
的倍数的循环。在 JVM 代码中,这针对每个 type
进一步细化。对于 8 字节的 long
,需要展开的次数要少于 2 字节的 short
。展开更少意味着算法必须处理更少的节点,并更快地处理它们。for (int i = 0; i < RANGE; i+=2) { // 加 2
dataF[i+0] = dataI[i+0] + 0.33f; // i+0
dataF[i+1] = dataI[i+1] + 0.33f; // i+1
}
算法步骤 1:对齐分析
不幸的是,这篇论文对这部分介绍得很简略,没有提供详细信息。它提到了同一作者的另一篇文章,但我并没有找到。我不得不到 JVM 实现中翻看细节。然而,这部分还有许多 bug 正在修复中,并且其中对齐分析部分是错误的,而其他的例子能提供的信息也很有限。
首先,需要从 C2 节点池中提取 依赖图。这里利用了由 逃逸分析(一种确定哪些内存访问是相关的,哪些是可证明无关的算法)发现的“内存切片”。我们可以忽略所有切片之间的内存依赖关系。在内存切片内,需要确保 C2 图中的 RAW(写后读)、WAR(写后读)和 WAW(写后写)依赖关系得到正确地处理。但是,如果可以证明访问的内存区域不重叠,则可以忽略 RAW、WAR 和 WAW 依赖关系。现在,依赖图仅由这样的内存边和 C2 图中的所有数据边(依赖关系 数据->数据、数据->memop、memop->数据)组成。
在 JVM 代码中,我们将循环中的内存地址表示如下:
address = base + stride*iv + const [+ invar]
base
与数组引用的基础相关联。iv
引用循环变量 Phi
。stride
是循环迭代之间的字节距离。const
是我们与 base
的常量偏移量。可能存在一个 invar
值,在所有循环迭代中不变。有了这些信息,我们可以尝试证明内存访问是非重叠的,并且我们可以潜在地找到两个内存访问之间的字节偏移,这有助于我们确定相邻的内存操作。AlignVector
HotSpot JVM 标志时)确保严格对齐。许多 CPU 需要矢量内存访问在内存中具有一定的 X
字节对齐(例如 4 字节或 8 字节)。如果执行的矢量内存访问未对 X
字节对齐,则可能会导致性能较差。某些 CPU 还会抛出 SIGBUS
错误。其他 CPU 只是忽略较低的位,这会导致访问到与预期不同的位置(因此导致错误的结果)。best
)。所有其他 packset 都必须与 best
对齐的偏移量对齐。然后,我们可以调整 Pre-loop 的迭代计数,以使 best
对于内存对齐到 X
字节。由于所有其他 packset 与 best
相对于 X
具有 X
字节对齐,它们也与内存对齐到 X
字节。X
作为最大 packset 的 vector_width
。这是次优选择,应当改进。packs
?“相邻”的内存访问是一个明显的起点,因为我们希望它们在同一个向量操作中,并且按照正确的顺序。我们采用归纳方法,并用“相邻的独立同构内存操作对”为种子来填充 PackSet
。LoadNode::Identity
和 StoreNode::Identity
)。pairs
开始,我们迭代地使用非内存 pairs
扩展 PackSet
,直到无法再添加新的 pairs
。follow_use_defs
: 寻找inputs
。follow_def_uses
: 寻找outputs
。
pairs
必须是:同构
且独立
的(可打包成 SIMD 向量指令)。过滤
阶段,我们会删除那些需要打包输入或解包输出的 packs
。use-def
链的 pairs
。我们现在迭代地将 packs
拼接在一起。[s1, .., sj] + [sj, .., sn] -> [s1, .., sj, .., sn]
每个节点现在最多只能在一个“pack”中。大小不是 2 的幂次方的“pack”都将被删除。
如果“pack”超过了硬件允许的大小,则将其分成多个。
细节:到目前为止,我们只显示了“pairs”是“独立”的。我们如何知道现在“packs”是“独立”的?“pair 独立性”仍然存在距离“>= 2”的依赖关系。例如,“data[i+2] = 2 * data[i]”在距离 2 处具有循环依赖关系。
该论文表明,在对齐分析期间确保了“独立性”。它假设不会添加任何跨越“对齐边界”的“pair”。没有提供更多细节。
在 JVM 代码中,目前是这样做的:如果启用了“AlignVector”,则我们已经知道所有向量都与最大向量宽度对齐。对于其余部分,我们假设硬件没有对齐要求。“SuperWord::find_align_to_ref”找到了具有“类似”引用的存储(或加载)的最大组。然后它选择具有最小偏移量的引用(组“mem_ref”)。通过分析表示为“address = base + stride * iv + const [+invar]” 的地址来执行此操作。我们对所有这样的组进行迭代。如果两个组位于同一内存片中,则检查这两个“mem_refs”是否矢量宽度对齐(同一偏移量模矢量宽度)。这确保了内存片内没有“pair”会跨越此“对齐边界”。这本身并不能真正保证独立性。但是,在 JVM 中,我们不实现打包/解包向量节点,也不实现向量排列。这意味着每个“向量通道”保持独立。
但是,如果我们使用“-XX:CompileCommand=option, package.Class::method, Vectorize”标志,则会打开“_do_vector_loop”标志。“IntStream forEach()”方法隐式启用此功能(“vmIntrinsics::_forEachRemaining”)。禁用了“mem_ref”对齐检查。因此,我们可以创建跨越“对齐边界”的“pairs”。在这种情况下,我们无法确定在“组合”之后“packs”是否独立。我们需要在“pack”级别进行额外的“独立性”过滤。
算法步骤 5: 过滤 Packset(已实现,有利可图,循环依赖)
这是一步附加的步骤,论文中没有描述,但在 JVM 中实现了。我们检查所有的 packs
是否满足以下条件:
已实现
:我们是否可以生成所需的 SIMD 指令?这取决于硬件。这些检查还会查询Matcher::match_rule_supported_superword
。请参阅x86.ad
中的Matcher::match_rule_supported_vector
,了解为哪些SSE
和AVX
CPU 特性实现了哪些矢量指令。有利可图:
由于成本模型已经在 扩展 过程中应用过,因此我们现在只检查packs
是否可以连接到所有输入和输出。评论中还有一些未完成的任务。循环依赖
:
pack
级别上的独立性
并不能保证packs
之间没有循环依赖。
下面是论文中的一句话:
想法是这样的:在安排日程之前,我们必须确保 packs
是独立的。但是,仅在包级别上的独立性是不足够的,我们还需要确保在安排之前 packs
(组)是无环的。
在论文中,他们提供了以下示例:
这个例子当前无法进行矢量化,因为它具有“矢量通道”排列,并且包不匹配。
然而,在这个调查过程中,我发现了下面这个例子。它目前被错误地矢量化了,我已经为此提交了一个 bug:
static void test(int[] dataI1, int[] dataI2, float[] dataF1, float[] dataF2) {
for (int i = 0; i < RANGE/2; i+=2) {
dataF1[i+0] = dataI1[i+0] + 0.33f; // 1
dataI2[i+1] = (int)(11.0f * dataF2[i+1]); // 2
dataI2[i+0] = (int)(11.0f * dataF2[i+0]); // 3
dataF1[i+1] = dataI1[i+1] + 0.33f; // 4
}
}
注意:dataI1 == dataI2
和 dataF1 == dataF2
。我只使用了两个引用,这样 C2 就不知道,不会优化负载存储之后的负载。
第 1 行和第 4 行是同构的和独立的。第 2 行和第 3 行也是如此。我们创建了包 [1,4]
和 [2,3]
,并进行向量化。但是,我们有以下依赖关系:1->3
和 2->4
。这在两个包之间创建了循环依赖关系。
结论:在 package 级别上的独立性是必要的条件,但不足够。我们必须明确检查包图中是否引入了任何循环,以便我们可以保证输出 DAG 的确是无环的,并具有相同的语义(相同的效果/结果)。
算法步骤 6:调度(修补图)
我们用向量操作替换包中的节点,并相应地连接它们。我没有对此进行太多调查,因此无法提供更多细节。
实施概述
这是 HotSpot JVM SuperWord 实现的快速概述,附有一些注释:
// 执行分析以确定循环应展开多少次
// 基于使用的类型以及能够适合于向量的元素的数量
IdealLoopTree::policy_unroll_slp_analysis
// 简化自代码:
bool SuperWord::SLP_extract() {
// 查找内存切片
// 构建块成员的反后序(rpo)列表
// 我们应该进行向量化吗?检查是否有存储或减少
if (!construct_bb()) {return false;}
// 为每个内存片段构建依赖图:
// 对于切片中的每两个 memop,检查它们是否 "!SWPointer::not_equal"(除了 Load -> Load)
dependence_graph();
// 当不需要上位位时将较窄的整数类型往回传递。
// 例如:char a,b,c; a = b + c;
// AddI 得到 velt_type char。
compute_vector_element_type();
// 初始 Packset:查找相邻的同构、独立的 memop 对
// 执行对齐分析(目前正在进行一些构造/错误修复)
find_adjacent_refs();
// 将 PackSet 从 memops 扩展到非 memops 对
// 按照 use->def 和 def->use 进行跟踪
extend_packlist();
// 将配对组合在一起:[a, b] + [b, c] -> [a, b, c]
// 如果大于最大向量大小,则将它们拆分成多个
combine_packs();
// 实现?-> 取决于硬件
// 有盈利吗?-> 所有使用和 def 是否都可以向量化?
// 循环依赖?-> packs 是否会引入循环依赖?
filter_packs();
// 检查具有 packs 的图是否具有循环。如果有 -> 删除所有 packs
remove_cycles();
// 改变图:用向量操作替换标量操作
schedule();
output();
}
JDK-8298935:在SuperWord :: find_adjacent_refs中修复create_pack逻辑中的独立性错误 JDK-8304042:C2 SuperWord:调度必须删除具有循环依赖关系的包
这是我们可以/应该考虑的更多任务:
代码有很多未完成的任务(例如实现PackNode和ExtractNode) 我最近添加了一些: JDK-8302662:[SuperWord]循环向量化当最后一次迭代的值在循环后使用时(Jatin) JDK-8302673:[SuperWord] MaxReduction和MinReduction应该对int进行向量化(Jatin) JDK-8302652:[SuperWord]减少应该在循环后发生,如果可能的话(Emanuel?) JDK-8303113:[SuperWord]研究是否通过默认启用 _do_vector_loop
可以创建加速(Emanuel?)JDK-8300865:C2:ProdRed_Double中的产品缩减未进行向量化(Jatin) JDK-8287087:C2:按需执行SLP缩减分析(Roberto) JDK-8255622:将所有向量化测试组合到一个目录中(Vladimir K?) JDK-8260943:重新审查由8076284添加的向量化优化(Vladimir K?一些有问题的代码被硬禁用) 调查:我们在哪里甚至没有开始可以工作的SuperWord?我们在哪里无法在SuperWord期间进行向量化?我们能找到并修复这些情况吗? 我们应该CMove更多,以吸收控制流吗? 分步访问?收集/分散 调查FMA何时/是否/如何工作-仅使用 Math.fma
?更多的 独立性
通过更精细的内存片
?推测:两个相同类型的数组是单独的对象?
JDK 8289422:修复并重新启用矢量条件移动 JDK 8283091:在SLP中支持不同数据大小之间的类型转换 JDK 8231441:AArch64:初始SVE后端支持 JDK-8245158:C2:为一些手动展开的循环启用SLP(缺少测试!) JDK-8192846:支持cmov矢量化用于float JDK-8153998:掩码矢量后循环 JDK-8151573:范围检查消除的多版本 JDK-8149421:矢量化后循环 JDK-8139340:增强SuperWord以在Intel AVX cpu上支持矢量条件移动(CMovVD) JDK-8135028:支持矢量化双精度sqrt JDK-8129920:矢量化循环展开(在SuperWord之后再次展开) JDK-8080325:SuperWord循环展开分析 JDK-8078563:限制缩减优化(当它有利时) JDK-8076284:提高并行流( forEachRemaining
)的矢量化JDK-8074981:整数/ FP标量缩减优化
只需要超级字级并行性:使用SLP进行系统控制流向量化(2022) 使用掩码向量指令处理控制流。循环融合/合迭代:每个元素代表一个循环。基本上:通过在控制流合并时使用 CMove/select/blend 以及掩码加载·存储将控制流展平为单个块。
int x;
If (condition) { x = v1; } else { x = v2; }
// translates to
c = condition; (true)
v1 = …; (c)
v2 = …; (!c)
x = Phi(c, v1, v2); (true)
// translates to
x = CMove(condition, v1, v2);
// vectorized
c_vec = condition[i : i + 4]; // vector of conditions
v1_vec = v1[i : i + 4]; // compute both values for true / false branch
v2_vec = v2[i : i + 4];
x_vec = blend(c_vec, v1_vec, v2_vec); // select from true / false branch
goSLP - 全局优化超字级并行框架 (2018) 使用整数线性规划进行语句打包。不适用于 JIT。 Look-ahead SLP: 在可交换操作存在的情况下进行自动矢量化 (2018) 通过向前看来重新排序可交换操作,以改进同构并实现更多的矢量化。
转自:Emanuel Peter,
链接:eme64.github.io/blog/2023/02/23/SuperWord-Introduction.html
- EOF -
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
点赞和在看就是最大的支持❤️