(给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.javarr 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 -> LoadVectorAddF -> 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) { // 加 2dataF[i+0] = dataI[i+0] + 0.33f; // i+0dataF[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和AVXCPU 特性实现了哪些矢量指令。有利可图:由于成本模型已经在 扩展 过程中应用过,因此我们现在只检查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; // 1dataI2[i+1] = (int)(11.0f * dataF2[i+1]); // 2dataI2[i+0] = (int)(11.0f * dataF2[i+0]); // 3dataF1[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 的图是否具有循环。如果有 -> 删除所有 packsremove_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 toc = condition; (true)v1 = …; (c)v2 = …; (!c)x = Phi(c, v1, v2); (true)// translates tox = CMove(condition, v1, v2);// vectorizedc_vec = condition[i : i + 4]; // vector of conditionsv1_vec = v1[i : i + 4]; // compute both values for true / false branchv2_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技能
点赞和在看就是最大的支持❤️