堆栈重新审视

2021-05-15 04:27:32

这是我以前的帖子上的后续帖子的后续,但旨在自包含。

众所周知,GPU是有效的阵列结构数据,在那里可以并行地在阵列的元素上操作。最后限制并不意味着操作必须完全独立;众所周知,GPU良好的算法,如前缀总和,其中一种简单的方法将意味着顺序数据依赖性,但是复杂的算法可以利用问题中固有的并行性。

在Piet-GPU中,我有一个特殊的愿望与从根本上结构化的数据一起使用:场景描述。特别地,存在由剪辑路径描述的剪辑节点,并且在被组成之前,该剪辑路径掩蔽其子项。嵌套水平不提前限制。在SVG中,这由Clippath元素表示,树结构在XML结构中显式。所有其他现代化的2D图形API和格式都具有类似的功能。对于Web Canvas,相应的方法是剪辑,但在这种情况下,树结构由保存和还原呼叫的嵌套表示。

Piet-GPU的设计原则之一是该场景被呈现为一系列元素。树结构由匹配Begin_Clip和end_clip元素表示。确定每个元素的边界框的问题可以大致表示为此伪代码:

Stack = [ViewPort.Bbox]结果= [] for Element for figure:match元素:begin_clip(path)=> stack.push(intersect(path.bbox,stack.last()))end_clip => stack.pop()_ => pass结果.push(intersect(元素.bbox,stack.last())))

基本上,这表示每个绘图元素被所有剪辑路径夹在树的根目录中的所有剪辑路径。具有精确的边界框对于管道中后期阶段的性能和正确性至关重要。在Piet-GPU的剪辑中有一点讨论(希望将很快成为一篇文章的博客中的一个问题)。

边界框交叉点的计算并不特别棘手。如果它只是一系列的推动序列,并且没有流行音乐,它可以通过“矩形交叉点”扫描(广义前缀和)操作来建模。棘手的部分是堆栈,基本上是无尺寸的。在我以前的帖子中,我有一些想法如何处理,因为堆栈是一个长嘴巴,但提案并不是那么实用。对于一个,它需要额外的划痕空间,这很难提前束缚。

从那时起,我想出了一种更好的算法。事实上,我认为这是一项非常好的算法,甚至可能被认为是关于可以在GPU上合理地实施的内容的边界。

对于这篇文章的其余部分,我将删除边界盒并仅考虑堆栈结构;这些可以在后来加回而没有任何严重的困难。这在文献中是已知的,作为“括号匹配问题”并具有其他名称。我更喜欢“堆栈长旋”,因为将其识别为单个子是一个明确的指南,即平行(或增量)算法是可能的,但我不确定该术语已捕获。我更喜欢“长阀”术语的另一个原因是它强调纯叠层可以与其他轴向矩形交叉口的其他长块组成;概念上我认为该算法实现的数据类型为堆栈< t>其中t:monoid。

堆栈= [] I for i在0..len(输入):匹配输入[i]:'(' => stack.push(i)')' => stack.pop()结果.push(stact.last())

在这里,每个开放的括号都分配了树中其父级的索引,并且每个关闭括号都分配了相应的打开括号的索引。

索引0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 161111111117_pput((((((())(((())(()))))值 - 0 1 2 1 4 5 6 5 4 9 10 9 12 9 4 1 0

一个有趣的事实是,从这个元素重建堆栈的快照即非常简单,只是通过步行到root的父链路链。并且,重要的是,“所有快照”表示不比堆栈本身不得更多的空间(假设输入在嵌套深度上没有其他限制)。这种快照可以被认为是一种不变的数据结构,该数据结构被算法逐渐解析和揭示,而不是堆栈本身,这既是可变的,也需要每个实例需要O(n)空间,这是有效的严重问题GPU实施。

如果这种“长阀”语言太令人困惑或抽象,可以了解这一点的更具体的方法,这在解释完整的GPU算法也很有用。考虑在上述简单循环的每个迭代时堆栈的快照。然后,它结果是相当简单的,以表达在步骤i到堆栈到堆栈的命令在步骤j:弹出一些元素,然后按下一些新的元素。

该命令是一个蒙湿。它具有零:POP 0并按下空序列。每个输入都可以映射到这个monoid:a'('在位置,我只是pop 0,然后按[i],')'是弹出1,并推送任何东西。而魔术是可以组合任何两种序列。有两种情况,具体取决于第一个的推动是否大于或小于第二个流行。在这里以伪代码表示:

fn组合(a,b):如果len(a.push)> = b.pop:return {pop:a.pop,push:a.push [0 .. len(a.push) - b.pop] + B.Push}其他:返回{Pop:A.Pop + B.Pop - Len(A.Push),推送:B.Push}

在输入的切片上计算堆栈Monoid的能力,然后稍后将它们缝合在一起,对于并行处理这是必不可少的。否则,比纯粹顺序处理更难做得很难。

我以前的博客文章建议使用溢出功能的大小窗口。毫无疑问,这可以做到这一点,但处理泄漏将是非常繁琐的实施,也可以使用额外的划痕内存,这很难绑定。

解决方案是减少就地。之前,有两个从输入切片的两个单个元素,并且之后,有一个覆盖大小2 * k的输入切片。关键洞察力是洗机可以就地洗牌,需要1步2 * k平行线。

会计的细节略有乏味,但概念上这很简单。它有助于序列右对齐;请注意,B的BUSP序列中的所有元素都保留,因此无需发生任何内容。如果每个元素有一个线程,就像GPU计算着色器工作组一样,每个线程都只是根据两个输入的推送和流行数来决定其值:无论是空的吗(在这种情况下,它不需要写入)或来自两个输入序列之一的值。它可能最容易显示为图片:

然后,对于尺寸16的工作组,可以在4个阶段的树中计算该堆栈的堆栈monoid:

请注意,这些堆栈切片不是最终输出。相反,它们是用于计算最终输出的中间值。通常,在进行两个堆叠切片的组合时(Monoid元件),可以解决第二切片中的一些输出。例如,在组合单个按钮和单个流行音乐时,对应于POP元素的输出是推送的索引,但是由得到的堆栈切片为空。当以这种方式匹配括号时,它们被记录到输出中,然后从得到的堆栈切片中擦除。在其他情况下,输出仍在挂起,并等待后来的组合。此生成输出未在上图中显示,但在代码中占据突出功能。

该算法的中央创新正在使用解耦后退缝合堆栈段,并解决从一个分区交叉到另一个分区的引用。在分区 - 本地减少结束时,因此在查找阶段的开头,每个分区都发布了自己的单个元素。在查找阶段的末尾,每个分区都有一个堆栈的窗口,窗口的大小与分区大小匹配。然后,可以从该窗口解析分区的所有输出。

这种概念尺寸1024的快照,每1024个元素(挑选典型的分区大小)是键。所需的总存储器也是每个输入元素的一个单元,与纯粹连续的情况相同。并且通过切片而不是单个链接,工作组可以并行地处理切片中的所有元素。

该算法在分离的回头顶部分层。值得阅读论文以了解对算法的详细了解,特别是为什么它在现代GPU上运行良好(这并不明显),但我将在此处覆盖基本的想法。在最简单的形式中,每个分区都有三个状态:初始,它已发布其本地聚合(仅使用分区的信息),或者它已从开头发布整个前缀。分区按顺序地返回并根据状态进行。如果它仍然是最初的,它可以没有进展和旋转。如果它是本地聚合,它会使Monoid操作添加它,然后继续扫描。如果它命中已发布的版本,它将包含该版本,发布结果并完成。

这几乎解决了这个问题,但还有一个细节。即使您到达已发布的前缀时,也可能与其相结合的堆栈切片小于分区大小。当堆栈本身大于分区时会发生这种情况(如果通过分区大小界定的最大堆叠深度),并且第二个单个元素具有比推动更多的弹出。解决方案是按照最左边扫描分区发布的堆栈切片底部的链接。这是遵循前面描述的父母链的概括,但由于堆栈切片(通常)为1024个元素深,因此底部大多数链路允许跳过输入的巨大块。在使用随机括号序列的测试中,算法很少需要遵循多个这样的反向链接,并且遵循所有的反向链接(这可以通过绑定输入堆栈深度抑制)仅添加少量到总运行时间。轻微小心:对抗性输入可能强制解决可能大量的反向链接。

回到90年代早期,它是时尚的,旨在解决这样的问题。当时,尚不清楚大规模并行硬件看起来像什么,因此计算机科学家大多使用并行RAM(PRAM)模型。

在发布我的堆栈Monoid帖子后,一位同事将我指向婴儿车的匹配,特别是我从1994年找到了一篇论文,特别是:有效的Erew PRAM算法用于括号匹配。特别地,就地减少与其算法II基本相同(图4)。有时研究经典偿还!

PRAM模型对解决问题中固有的有多少固有的理论分析,但它不是当今实际GPU硬件的特别准确的模型。

出于多种原因,我提出的算法的详细理论分析是棘手的。对敌人的输入可以在一系列反向链接之后强制工作组的大小,尽管我期望在实践中,这几乎永远不会超过一个。通过回顾程度进一步复杂化。但是,有一个简单的情况:当堆栈被工作组大小界定时,从来没有反向链接,因此分析基本上与解耦后退相同。由于工作组大小通常为1024,因此对于解析任务和2D场景结构,它是合理的,它永远不会在实践中击中。即便如此,随机测试表明,即使使用更深的嵌套,算法仍然非常有效。

这算法的一部分开发是重新审视内存一致性,因为我对此进行了一些令人讨厌的错误。我能够以满意的方式解决它们。

当我探索实现前缀总和时,我在兼容解决方案的尝试是将用于发布聚合的缓冲区上的易失性限定符。这在我在NVIDIA GTX 10X0 GPU的测试中工作。我希望,虽然未正确指定任何地方,但对挥发性缓冲区的访问将隐式地获取和发布语义,就像MSVC中的情况一样。然而,这是不满意的,因为“它对一个GPU工作”确实是弱保证。

我当时的感觉是,vulkan记忆模式是前进的最佳方式,因为它在代码和GPU之间提供了精确的合同;代码可以恰好询问所需的内存相干级别,而GPU可以在其喜欢时尽可能地优化,只要它尊重约束。前缀和执行的当前版本需要vulkan内存模型扩展。

该问题是可移植性的:Vulkan是允许显式细粒度的内存语义,特别是分离获取和释放的唯一API,而不是仅仅具有屏幕屏幕,即甚至有特征是可选的。其他API仅提供轻松的原子学。

实验表明,单独的挥发性确实不足,并且在其他GPU上可以观察重新排序,即破坏可靠的发布。幸运的是,我相信有一种前进的方式;在缓冲区上使用相干限定符,并在发布商店或获取负载之前放置显式存储器障碍物呼叫。

着色器语言抽象有机会解决此问题。非常精确地指定所需的存储器语义,如vulkan内存模型,并将其编译为目标API。如果最近的vulkan可用,则是直接映射。弄清楚与其他API的确切平移并不容易,部分原因是文档稀疏;它真的需要详细的内幕知识了解这些其他GPU的工作。有点看GPUWeb#1621更多的讨论。我相信WebGPU是这项工作中最有希望的地方,但由于各种原因(缺乏对现有API的良好文档为一个),这也是棘手和微妙的,所以我希望它需要一些时间。还有其他潜在的努力观看,包括Rust-GPU。在任何情况下,我都会考虑运行解耦后退的能力是GPU基础架构是否可以运行合理雄心勃勃的计算工作负载的基准。

另一件事是要注意的是,当前的原型,而否则便携,可以陷入困境,因为GPU可能无法提供前进进度保证。在Piet-GPU中有代码(谢谢埃拉亚·埃尔),以保证在这种情况下的前进进展,基本上是一个慢慢使得在否则是自旋锁的内容的标量循环,并且在这里也需要类似的逻辑。

有关工作代码的最重要的事情之一是可以测量其性能。当然,测试它是正确的也很重要!

我在AMD 5700 XT上测量了这一实现,这是一个相当高的最终显卡,虽然不是线条。底线性能编号为每秒24亿元素(测试正在处理1M伪和谐Parens的序列)。这比验证结果的速率速度快约10倍。我会表征这一点,但不一定是傻瓜掉落。您可能不希望将数据集上传到GPU以匹配括号,但如果您可以将其集成到与其他阶段的管道中并保持GPU上的数据而不是向CPU进行回读,这可能是一个大的胜利。

打破性能并看看它如何随工作组大小而变化是有意义的。在下图中,我测量了仅局部减少的时间,以及包括回顾的完整算法。

本地减少的时间与LG(工作组大小)成比例,并将其测量如预期的那样。回顾后退的成本随着工作组大小的增加而降低,这并不奇怪 - 所需的发布操作总数与工作组大小成反比。由于(在此实现中)回顾更昂贵,因此最佳性能是在1024的工作组大小。一个较大的工作组可能稍微更好,但是看这个图表,而不是显而易见的。

此实现使用相对简单的顺序方法来回回顾。它基于我的先前前缀和实现,其中我能够避免通过推动工作组的大小(每个线程处理16个元素,用于16K元素的工作组;典型的GPU有一个限制来实现性能瓶颈;典型的GPU有一个限制每个工作组1024线程)。通过迭代每个线程的多个元素来增加工作组的大小,但不一定容易;共享内存将很快成为限制因素,因为该算法需要堆栈的共享内存,而在纯前缀总和中,Monoid结果最多可以存储在寄存器中。因此,我认为进一步提高性能的最佳前景并将其并行化回顾,如原版纸张所示。

我还尝试了其他潜在的优化。我尝试使用子组进行本地减少,但没有看到改进。 (非统一)子群机器()可能在AMD上具有差的性能特征。我还尝试了一个没有回顾的双通树减少,这给了一些令人鼓舞的结果:它几乎是回顾实现的两倍,但限于(分区大小)^ 2元素,即大约一百万元素。我强烈怀疑这种性能收益会蒸发,更多的通过来克服束缚。

当然,它也可能有算法改进;我会将此实施方式适度工作高效,具有至少LG(工作组大小)的工作因素,用于本地减少。 PRAM文献旨在提出一种更多理论上工作的效率算法,但它也是非常合理的,这也不会转化为实际的性能收益,这是由于额外的复杂性 - 到目前为止,我在GPU的经验强烈建议更好地表明更好。

如上所述,改变输入堆叠深度的输入只有一个次要性能凸点,证明了跟随反向链接的成本(并因此实现完全无界面的堆叠深度)不显着。实际上,这种算法可以平滑地处理具有非常深嵌套的序列,这对于其他实现可能是有问题的。例如,它可以触发递归下降解析器中的堆栈溢出。

总的来说,我对目前的性能感到满意,因为我认为剪辑堆栈处理将勉强出现在全Piet-GPU管道的简档中。请记住,软剪裁传统上传统的传统方法中最昂贵的操作之一,特别是在GPU上,其中通常需要分配划痕缓冲区以呈现掩码并进行合成。

我长期以来一直对GPU有效地处理具有某种形式树结构的数据的问题感兴趣。我强烈建议我进入堆栈的探索,提出了有效的GPU实现,但直到现在我没有实用的算法。

算法的主要成分,最重要的是由堆栈的堆栈(和并行算法中的CS CS文献和经典CS文献)的理论洞察力的顶部是:就可以减少堆积(意味着划痕内存紧密),堆叠的表示快照作为分区大小的切片,并在工作组中仔细注意尽可能多地执行。此算法还在顶部o上的图层

......