摘要:传统上,分析数据库引擎使用现代多插槽多核 CPU 提供的任务并行性来扩展查询执行。在过去的几年中,由于其海量数据并行特性和高内存带宽,GPU 作为处理分析查询的加速器开始受到关注。最近为 CPU 设计连接算法的工作表明,经过精心调整的利用底层硬件的连接实现可以胜过原始的、忽视硬件的对应物,并在现代多核服务器上提供出色的性能。然而,对于 GPU 的硬件意识连接算法,还没有系统地分析分区(分区与非分区连接)、数据位置(数据适合和不适合 GPU 设备内存)和访问模式的维度(倾斜与均匀)。
在本文中,我们介绍了一系列新颖的、基于分区的 GPU 连接算法的设计和实现,这些算法经过调整以利用各种 GPU 硬件特性来解决 GPU 的两个主要限制——有限的内存容量和慢速 PCIe 接口。通过全面的评估,我们表明:i) 硬件意识在 GPU 连接中起着关键作用,类似于 CPU 连接,即使没有数据驻留在 GPU,我们的连接算法也可以处理 10 亿元组/秒,ii) 经过调整以利用 GPU 硬件的基于基数分区的 GPU 连接可以大大优于非分区哈希连接,iii) 硬件意识 GPU 连接可以有效地克服 GPU 限制并匹配甚至优于最先进的 CPU 连接。
关键词:join, GPU,databases, analytics
随着数据呈指数级增长的持续趋势,加上对实时分析的需求不断增长,现有数据分析系统对扩展查询执行产生了巨大压力。因此,过去几年见证了越来越多地采用通用图形处理单元 (GPU) 作为数据密集型应用程序的首选加速器。尽管从数值分析到机器学习等多个领域越来越多地采用 GPU,但由于在 GPU 上支持通用连接操作非常复杂,因此尚不清楚 GPU 是否适合加速分析 SQL 数据库引擎,存在两个原因。
首先,仓储应用程序中的分析查询本质上是复杂的,并且由大到大的连接组成。基于 CPU 的内存分析引擎使用的连接算法基于所有数据都驻留在内存的假设进行了优化,并且这种假设通常成立,因为现代服务器可以配备 TB 的内存。但是,为 GPU 量身定制的连接算法无法做出这样的假设,因为 GPU 的板载容量有限,即使是高端 GPU 也被限制为 32GB 的设备内存。其次,鉴于数据不能完全放置在 GPU 内存中,并且必须应用分页技术来维护当前所需的工作集,要么通过统一虚拟寻址 (UVA) 隐式地通过,要么在需要的时候通过 PCIe 总线显式地传输数据块。传输成本很高,因为它们的吞吐量受 PCIe 带宽的限制,理论上最大为 15.8 GB/秒。
尽管存在这些挑战,但与 CPU 相比,GPU 提供了一些优势。首先,GPU 使用数千个内核提供大规模并行性,这些内核可以一起以比 CPU 高几个数量级的吞吐量执行计算。例如,每个 Nvidia Tesla V100 GPU 将 5120 个 CUDA 内核打包到多个流式多处理器 (SM) 中,可以提供 14 TeraFlops 的单精度浮点性能。其次,使用紧密集成的高带宽内存技术,GPU 为设备内存提供接近 1 TB/秒的带宽。第三,与初代 GPU 不同,现代 GPU 还提供了多种功能,例如功能类似于 CPU 缓存的可编程片上共享内存,峰值带宽为几 TB/秒,线程和扭曲同步原语,重叠计算的技术 I/O,所有这些都为适当优化软件以克服上述瓶颈提供了必要的工具。
最近为 CPU 设计连接算法的工作表明,精心调整的利用底层硬件的分区连接实现在现代多核服务器上展示了出色的可扩展性和性能。然而,对于 GPU 的硬件意识连接算法,还没有系统地分析分区(分区与非分区连接)、数据位置(数据适合和不适合 GPU 设备内存)和访问模式的维度(倾斜与均匀)。在本文中,我们通过为 GPU 设计和实现硬件感知哈希连接算法来弥补这一知识差距。为此,我们做出以下三项贡献:
我们提出了一系列基于分区的 GPU 连接算法的设计,这些算法经过微调以利用 GPU 硬件特性。在这样做的过程中,我们果断地表明,硬件意识对于与 CPU 对应的 GPU 连接很重要,即使所有数据都驻留在 GPU 上,简单地将基于 CPU 的连接切换到 GPU 的原始方法也会留下大量未开发的处理能力. 当数据驻留在配备单个 GPU 的标准双插槽服务器上时,我们的 GPU 连接算法可以处理 45 亿元组/秒。
据我们所知,我们展示了第一个基于分区的 GPU 连接,即使没有数据驻留在 GPU 上,它也能很好地适应大到大的连接。我们的 GPU 外算法采用了一种新颖的协处理方法,该方法在 CPU 上执行分区并在 GPU 上连接执行,以实现协同流水线执行。在这样做的过程中,我们表明一刀切的方法不适合 GPU 连接,因为需要根据数据位置自定义连接算法才能实现最佳性能和利用率。我们的 GPU 外连接可以充分利用 PCIe 带宽和 GPU 处理能力来实现 10 亿元组/秒的吞吐量,即使没有数据驻留在 GPU 上。相比之下,标准哈希连接甚至无法充分利用 PCIe 带宽。
我们提供了一个全面的评估,将我们的 GPU 连接算法与最先进的 CPU 对应物和流行的商业 GPU 数据库在多种工作负载下进行比较。通过这样做,我们展示了微调的 GPU 连接如何不仅在所有场景中都优于 CPU 连接。我们还展示了我们的协处理连接如何能够将使用数十个 CPU 的纯 CPU 配置替换为只有少数 CPU 和单个 GPU,从而减少资本支出。
在本节中,我们将概述 GPU 硬件和注重硬件的、最先进的 CPU 连接,为这项工作做好准备。
每个 GPU 由数千个计算核心组成,这些计算核心被组织成称为流式多处理器的单元。每个这样的处理器都有计算核心、一个寄存器文件、共享内存和缓存。 GPU 将一组 32 个线程组合成一个 warp,它构成了调度和执行的基本单元。 warp 中的所有线程在锁定步骤中执行相同的指令,但在不同的数据项上。导致线程发散的条件指令或分支通过依次执行对应于分支的每个可能执行侧的线程集来处理。 GPU 硬件还能够将来自线程的多个内存请求组合成一个请求,从而减少内存事务的数量。然而,为了使这种合并成为可能,来自线程的数据访问应该遵循顺序访问模式,每个线程访问相对于其在 warp 中的前任的后续内存位置。
在编程级别,CUDA 使用称为线程块的一组线程的概念将 GPU 的海量数据并行特性暴露给应用程序。每个线程块由一个多处理器执行。每个线程的本地变量存储在寄存器文件中,并且是线程私有的。但是,GPU 硬件包含板载共享内存,该内存在块中的所有线程之间共享。 GPU 上的共享内存类似于 CPU 上的缓存,因为它们是 KB 大小的,并且可以以低延迟和几 TB/秒的带宽提供对数据的快速访问。但是,与不可编程的 CPU 缓存不同,CUDA 向应用程序公开共享内存以允许在同一块中的线程之间快速共享数据。在全局层面,GPU 还包含数十 GB 大小的设备内存。由于紧密集成和高带宽内存技术的使用,现代 GPU 的设备内存提供接近 1 TB/秒的带宽。尽管如此,与共享内存相比,设备内存要慢得多。
在本文中,我们将注意力限制在通过 PCIe 总线连接到系统的离散 GPU 上。 GPU 硬件包含多个 DMA 复制引擎,用于 CPU 内存和 GPU 内存之间的双向数据传输。 CUDA 通过使用异步内存复制操作为应用程序提供与计算重叠传输的能力,这些操作可以利用这些 DMA 引擎来充分利用 PCIe 带宽。现代 GPU 和最近的 CUDA 环境还提供了一套丰富的数据共享和线程同步功能。为了使数据能够在同一个 warp 内的线程之间共享,CUDA 提供了特殊的 warp shuffle 指令。在本文后面,我们将展示如何使用这些指令来加速连接操作中的探测。 GPU 长期以来也支持线程同步的原子操作。在基于 Fermi 架构的旧 GPU 中,这些原子操作是使用锁定机制来支持的,因此会导致严重的性能损失。然而,基于 Maxwell、Pascal 或 Tesla 架构的现代 GPU 使用共享内存实现高效的原子操作。我们在连接的构建阶段使用这些操作进行同步,以创建共享哈希表。
构成现代连接算法基础的规范哈希连接算法分两个阶段运行。在第一阶段,称为构建阶段,扫描两个输入关系 R 中较小的一个,并使用散列函数用元组填充散列表。第二阶段,称为探测阶段,然后扫描第二个输入关系 S,并探测 S 中每个元组的哈希表以找到匹配的 R 个元组。 Shatdal 等人研究了规范哈希连接的缓存性能,并观察到当哈希表大于缓存大小时,几乎每次哈希表查找都会导致缓存未命中。由于缓存未命中会导致 CPU 停顿,它们会导致处理能力的未充分利用。
为了解决这个问题,他们重新设计了哈希连接算法以实现缓存意识,并提出了一种分区哈希连接变体。分区散列连接首先执行一个分区步骤,以便可以将散列表分区为高速缓存大小的块,这些块可以在每个分区的整个处理过程中存储在高速缓存中。但是,高分区扇出可能会导致 TLB 未命中并导致性能损失,因为用于写入分区的不同输出位置的数量很大。为了解决这个问题,Boncz 等人建议应该使用较小扇出的多个分区通道,以避免这种性能缺陷 [1]。这导致了基数分区哈希连接算法。图 1 显示了基数分区哈希连接示例中涉及的步骤。两个输入都使用单遍算法进行分区。对于这个例子,两个 TLB 条目就足够了。然后在输入表 R 的每个分区上构建哈希表,并使用相应的 S 分区探测连接匹配。
在文献中众所周知,分区基数散列连接是通用的、可扩展的算法,用于在多核 CPU 上并行化连接。因此,在本节中,我们首先为所有数据都驻留在 GPU 的场景构建一个 GPU 感知的分区基数连接。在这样做的过程中,我们证明了将连接算法调整为具有 GPU 意识是一项不平凡的任务,需要探索由多个优化目标组成的广泛设计空间。在第四节中,我们将扩展设计以涵盖数据不适合 GPU 内存的场景。
正如我们在第 II -A 节中所描述的,现代 GPGPUs 包含提供比 CPU 内存高得多的带宽的板载设备内存。尽管 GPU 具有高带宽内存,但共享内存的有效利用对于实现高整体吞吐量非常重要,因为它的速度和对于更复杂的访问模式的效率。为了利用共享内存进行分区连接,至少有一个连接中的工作集必须适合共享内存。然后,构建阶段的数据结构被创建并存储在共享内存中,从而允许有效的探测阶段依赖于外部关系的快速顺序扫描和共享内存查找。分区扇出需要足够高,以保证生成的分区适合为计算分配的共享内存。然后,join算法只需要考虑两个关系中相同hash值对应的工作集对。分区设计选择背后的直觉与支持基于 CPU 的基数散列连接的缓存意识论点紧密对应,并构成了两种算法之间的并行。
然而,分区算法的扇出也受到可用共享内存量的限制。每个输出分区的元数据需要存储在共享内存中,以便在不涉及较慢设备内存的情况下对其进行访问和更新。此外,还需要 shuffle 空间,以便线程可以在将数据存储到最终目的地之前重新排列数据,从而减少由于合并导致的内存事务数量。因此,分区算法最多可以有几千个分区的扇出。此限制构成了与基于 CPU 的基数哈希连接的另一个并行,其中扇出受 TLB 大小的限制。
为了充分利用共享内存,我们使用了多通道分区算法,该算法的大小可生成适合共享内存的批量元组。每次传递都会为每个分区生成一个桶的链接列表。为了分摊指针追踪的开销并改进扫描合并,每个桶都是一个元素数组,其容量是 GPU 线程块大小的倍数。最初,分配一个桶池,并将其中的一个子集分配给分区,每个分区一个,然后线程开始将元素存储到各自的桶中。当存储桶已满时,与它相关的分区会从池中分配一个新的分区,该池链接在前一个存储桶之后。生成的数据结构允许以合并访问的方式访问分区的数据,只需遵循指向桶之间转换的链接指针。
对于第一个之后的分区步骤,我们以循环方式将数据分配给 CUDA 块。我们尝试了两种粒度,一次单个存储桶或一次分区(完整的存储桶链)。后一种方法的优点是,对桶链进行子分区的 CUDA 块是新分区的唯一生产者,因此可以在快速共享内存中本地维护所有数据。相比之下,前者在每一步处理不同分区的桶,因此花费更多时间初始化内部数据结构和访问 GPU 内存中的数据。但是,如果数据出现偏差,则某些存储桶链会明显更长,从而导致负载不平衡并降低一次分区分配的性能,因为运行时间最长的 CUDA 块定义了总执行时间。因此,我们在时间分配中选择了桶,尽管它对于均匀分布的情况更糟。最后一步产生的长桶链被分解并分配给不同的流式多处理器,以在探测阶段平衡负载。
在工作集的大小已充分减小后,可以计算由构建和探测阶段组成的实际连接。在构建阶段,较小的工作集存储在共享内存中,可能作为一种数据结构,可以实现高效的相等查找,例如哈希表。在探测阶段,合并扫描从设备内存中读取另一个工作集,并将连接字段与共享内存中的元组进行比较。在本文中,我们实现了用于探测的嵌套循环连接和哈希连接内核。
在嵌套循环连接实现中,较小的工作集最初被连续复制到共享内存。然后,扫描另一个工作集,以便将其元素与共享内存中的元素进行比较。传统的实现,如在 CPU 中,将在此时单独执行所有成对比较。然而,这个实现是通过考虑扭曲级同步原语和分区元素有一些公共位的知识来优化的。线程读取外部工作集的一个元素,然后每个 warp 协作扫描共享内存的内容,如清单 1 所示。我们避免让每个线程通过利用内部 warp 通信扫描 32 个值来从共享内存中读取所有值一次并使用 ballot 指令来发现匹配项。 warp 的每个线程在每个步骤中仅从共享内存中读取 32 个值中的一个,第 4 行。然后,线程迭代它们读取的值的位,并使用选票将它们广播到其他线程,第 6-9 行。使用按位操作,线程检查广播的位是否与它们从外部表中读取的值中的相应位相匹配,第 9 行。这只需要对分区中未使用的位执行此操作。因此,通过位掩码操作,warp 中的每个线程将其寄存器中的值与 32 个内部关系值进行比较,并在几次投票操作后检索匹配位掩码,从而减少内存读取次数。
在实现 hash 连接中,较小的工作集存储在共享内存中。我们使用哈希表存储它,该哈希表为每个哈希表槽使用链表,并使用偏移量来表示列表节点之间的链接。共享内存的有限大小允许我们将偏移量修剪为 16 位以减少内存占用。哈希表是通过使用 CUDA 的原子交换将每个列表的头部的引用替换为对每个新元素的引用来并行创建的,从而将元素添加到列表的前面。创建哈希表后,扫描设备内存工作集,并使用相同的哈希函数对每个值进行查找。查找遵循链的指针,以便找到具有连接字段相等的元素。在接下来的部分中,默认使用此实现,因为它比嵌套循环更有效,正如我们将在第 V 节中看到的那样。
生成连接输出是连接算法的最后一个主要组成部分。合并后,设备内存访问效率最高。为每个线程使用不同的结果缓冲区效率不高,因为它会使内存处于未使用状态。因此,我们将 warp 生成的结果缓存在共享内存中,并在缓冲区已满时将它们顺序刷新到 GPU 内存。在探测各自链的每个步骤中,warp 的线程以锁步方式执行,使用它们的同步原语来计算其分配的缓冲区内的写入偏移量,直到它被填满。然后,线程将缓冲区的内容刷新到内存,使用原子增量操作计算全局偏移量,并存储任何不适合缓冲区的未完成输出。使用这种方法,可以避免随机访问。连接操作的结果在大小上可能很大,并且在设备内存中实现它会引入开销,即使是合并写入也是如此。
我们描述的分区连接算法针对的是驻留在 GPU 上的数据集。尽管如此,在实践中,数据量远远大于 GPU 有限的设备内存,即使对于中型数据库也是如此。更糟糕的是,分区阶段的数据访问模式和存储桶链的扫描不适合 CUDA 的统一内存,这是一种允许在 CPU 和 GPU 之间按需移动内存页面的机制,由于内存访问的局部性较差以及访问期间只需要页面的一小部分这一事实。在其他情况下,统一内存可以毫不费力地管理传输,达到接近 PCIe 带宽的吞吐量水平,这是这种情况的上限。对于手头的任务,需要设计一种更专业的方法,基于异步内存传输和流,以使 PCIe 带宽饱和并几乎完全掩盖计算开销。
首先,我们将考虑两个关系中较小的 R 可以存储在 GPU 内存中的情况。我们假设关系已经转移到 GPU 并经历了第 3 节中描述的划分阶段。然后,另一个关系 S 可以拆分为足够小的块序列。这些块中的每一个都可以转移到 GPU 内存中,并且可以使用 GPU 驻留算法计算块与 R 的连接。所有单个块的连接与 R 的并集等于 S 和 R 的连接,这是一个重要的属性,因为它提供了一种在 GPU 中计算总结果的方法,尽管这两个关系不能完全随时搭配在那里。
CUDA 流的使用允许同时进行 PCIe 传输和 GPU 执行。我们使用一个流进行传输,另一个用于 GPU 执行本身,将同一块上的任务与事件同步。我们还在 GPU 中为两个块保留缓冲区,一个正在传输,另一个正在处理。缓冲区的角色在每一步都交换。图 2 显示了我们交换缓冲区时的流水线步骤以及我们对它们执行的操作。只要数据通过 PCIe 以明显低于 GPU 连接吞吐量的速率传输,就有机会以传输速度执行完全连接。在这种情况下,GPU 计算单元将处于空闲状态,直到有块可用,而传输单元将始终处于忙碌状态,因为在选择使用缓冲区时,前一个块将已被处理。 GPU 上的执行与传输完全重叠,除了最后一个块的处理外,它实际上是隐藏的。因此,总执行时间是数据的传输时间加上最后一个块的 GPU 执行时间。在我们的评估中,我们证明了我们的 GPU 内实现可以支持这种执行策略,当一个关系在 CPU 中时,会产生接近 PCIe 带宽的连接吞吐量。
连接的输入关系的另一种情况是它们都不适合 GPU 全局内存。在这种情况下,即使我们通过 PCIe 总线流式传输更大的关系,所描述的连接算法的工作集在任何给定时间都不能完全驻留在 GPU 上。较小的关系只能部分存储,因此一些匹配元素不会在全局内存中。此外,依靠诸如 UVA 之类的技术是不切实际的,因为连接的不规则访问模式会导致部分关系被多次转移。这将增加 PCIe 总线上的流量,这已经成为与一个 GPU 驻留关系连接的瓶颈。
可以通过在主机内存上进行另一级别的分区来规避该问题。我们已经说明了分区连接可以减少连接的工作集,以便哈希表适合 GPU 的共享内存。同样的原则也适用于全局内存。假设这两个关系在连接属性上是共同分区的,那么一个关系的一个分区中元素的所有可能匹配都包含在另一个关系的相应分区中。只要最小的分区完全适合 GPU 全局内存,就可以使用通过 PCIe 流式传输数据的执行策略来加入各个共同分区。整体连接结果由共同分区的各个连接的结果组成。
一般情况下,不知道连接属性,需要在连接时计算分区。我们使用多核 CPU 的基数分区算法将关系划分为适合 GPU 全局内存的共同分区。两个输入中的每一个都被分成块,每个块都分配给一个本地到数据线程,该线程对其进行分区并为每个分区生成一个桶列表。消耗输入关系后,将来自不同线程对应于同一分区的列表连接起来。然后,共同分区通过 PCIe 总线传输到 GPU 全局内存。到达那里后,使用前面段落中提出的分区散列连接算法计算连接。如果两个共同分区的总大小大于 GPU 内存,则将它们进一步分区。
由于数据总是必须至少通过 PCIe 总线传输一次,无论分区算法和 GPU 连接的吞吐量如何,总吞吐量的上限是传输速率。我们通过流水线隐藏 CPU 和 GPU 处理的计算成本来匹配这个速率。 CPU 端的处理、内存传输和 GPU 连接都可以异步执行。我们通过 CUDA 的事件同步处理内存传输和相应 GPU 操作之间的依赖关系,而主机端处理与传输的依赖关系通过它们的执行顺序隐式解决。传输和 GPU 操作之间以及 GPU 操作之间的依赖关系要求在下一个操作可以访问之前使用缓冲区完成操作。只要处理任务的执行时间低于相应的传输时间,总的执行时间就会略大于传输时间。
起初,这两种关系都以其原始顺序在主机内存中。我们对最小的关系进行分区并将分区存储在固定内存中以允许更快的异步传输,并且我们启动将一组工作分区传输到 GPU。然后,我们开始处理管道。我们将较大的表细分为可以通过剩余 GPU 内存流式传输的块。然后,对于每个块,我们遵循分区管道,传输与工作集相对应的分区并加入 GPU 内的共同分区。这些阶段在不同的块之间重叠。该概念的简化版本如图 3 所示。然而,在实践中,我们没有严格的管道槽。一旦上一步完成,我们就会启动序列的每个操作。块和工作集之间连接的组合结果包括工作集所包含的分区的连接的整体结果。正如我们稍后在第 V 节中所展示的,分区散列连接使 PCIe 带宽饱和,并且不会由于依赖关系而停止传输。如果所使用的算法实现了足够高的输出吞吐量,那么分区的成本也会被隐藏起来。理想情况下,对应于工作集的 CPU 分区输出的一部分应该以高于 PCIe 带宽的速率生成,这样传输就不会面临饥饿并且始终进行。
在处理完第一个工作集后,将另一个工作集传输到 GPU 设备内存。此时,所有块都已被分区并位于固定内存中。结果,不再有分区阶段,管道由传输和连接组成。
为了实现在多插槽系统中维持到 GPU 的传输所需的吞吐量,分区算法需要支持 NUMA。但是,这需要设置数据和结果的亲和性,以便线程在此步骤期间执行本地访问。结果,部分分区数据将位于相对于 GPU 的远端套接字中,并且必须通过 QPI 传输。在这种情况下,吞吐量通常低于从本地套接字传输的吞吐量。更糟糕的是,当 QPI 出现拥塞时,即使由于缓存一致性协议,现有流量也会干扰传输,并且它们的吞吐量会显着降低。为避免此问题,我们使用 CPU 线程手动将数据从远套接字传输到近套接字中的固定内存。我们使用此 NUMA 感知副本扩展了管道中处理的 CPU 阶段。然后,这个副本构成了第一个工作集之后的管道的 CPU 阶段。
传输和 CPU 处理的重叠以及 QPI 上的高速缓存一致性流量共享同一内存系统的资源。在多线程密集的情况下,近套接字的内存带宽可能会饱和,从而导致传输吞吐量和 CPU 处理吞吐量的崩溃。因此,需要配置重叠进程,以便内存系统可以支持正在进行的操作。由于需要最大化传输速率以及无法直接控制缓存一致性协议的事实,我们人为地减少了分区消耗的内存带宽。首先,我们使用非时间提示以避免读取用于输出的内存位置。其次,我们减少了计算中使用的线程数,以直接缓解内存压力。基于分区期间预期的每线程内存带宽消耗,我们选择最大线程数,以允许足够的带宽将任何重叠数据传输到 GPU 以全吞吐量运行,因为它们位于关键路径上。这个优化目标是配置建议的执行策略的一个重要因素。我们将在未来的工作中动态更改执行期间的线程数。
GPU 有两个不同的 DMA 引擎,同时支持从 CPU 到 GPU 以及从 GPU 到 CPU 的异步传输。这种技术为管理 GPU 内部和外部的数据池开辟了不同的可能性。在这项工作的背景下,我们使用此功能来支持在评估 GPU 外执行策略期间检索在 GPU 内存中实现的结果。共同点是添加另一个流以及许多事件同步,以保证数据(即结果可用)和资源依赖性(即输出缓冲区是免费的)。
对于第一个执行策略,结果物化反映了输入端的双缓冲。需要两个输出缓冲区来创建输出管道。同时,第一个缓冲区被 GPU 连接内核填满,另一个缓冲区正在通过异步传输传输到 CPU。一旦传输完成并且部分连接完成,缓冲区就会交换角色。图 4 描述了与正常执行管道的当前状态相关的此操作。如果结果的大小最多与输入相同,则可以通过重叠来隐藏传输到 CPU 的成本,但最后一次传输除外。但是,如果结果的大小大于输入,则 GPU 执行会由于输出缓冲区相关性而停止,结果,输入传输会由于输入缓冲区相关性而停止。然后执行时间由输出传输确定。
对于第二种执行策略,可以通过向协处理管道添加另一个阶段来完成结果物化,以用于输出数据的 PCIe 传输。我们再次使用两个交替的输出缓冲区来计算输出并以流水线方式将其传输到 CPU。由于严格的内存限制,可能会出现两个输出缓冲区与其他 GPU 操作共享的情况。这为执行引入了额外的依赖项。在再次需要缓冲区之前完成传输输出时,流水线执行会隐藏输出传输。
执行策略的性能取决于关于在每个步骤中选择处理的分区工作集的两个基本假设。首先,每个工作集的大小需要适合为小关系分配的 GPU 内存。其次,对于第一个工作集,到 GPU 的传输与 CPU 块的分区重叠,因此它的大小应该足够大以隐藏 CPU 分区执行时间。
当使用基数分区等算法时,数据倾斜会导致分区大小不均。在偏斜的较小关系的情况下,工作集的原始选择可能会使两个假设中的每一个都无效。一方面,如果在同一个工作集中放置了太多的大分区,则输入无法放入缓冲区。另一方面,如果在第一个工作集中放置了太多的小分区,则 CPU 分区无法完全隐藏,导致传输停止,直到数据可用。因此,需要一种更精细的方法来选择工作集。
我们将分区打包成合适的工作集的方法遵循两个步骤。在第一步中,使用背包算法生成第一个工作集作为最大化元素总数的分区集,条件是它们适合分配的 GPU 内存,包括填充。然后将其余分区贪婪地打包到工作集中,每个工作集都适合分配的 GPU 内存,并且最多可以将子分区后保留的空间超过阈值的一个分区放入工作集中。后一个限制的想法是我们在缓冲区中量化 GPU 内存,超过阈值的分区需要更多空间用于分区结果和 GPU 上第一次分区传递的中间结果。
在本节中,我们将描述用于测量上述方法性能的实验。
我们在配备两个 12 核 Intel Xeon E5-2650L v3、256 GB 主内存和一个具有 8 GB 设备内存的 Nvidia GTX 1080 GPU 的 Red Hat Enterprise Linux 7.2 机器上运行我们的实验。在运行实验时,机器上安装了 CUDA 9.0。
对于工作负载,我们采用了在之前的几项研究中用于评估基于 CPU 的连接的方法。该工作负载模拟了具有两个窄表的典型内存连接处理场景,每个表由一个 4 字节键和一个以列方式存储的 4 字节值组成。我们使用一张表作为构建表,另一张表作为探测表。由于表的基数因实验而异,我们将其与每个实验一起描述,并在图 7-13 的注释和配置中进行描述。
我们多次运行每个实验,并将执行时间计算为迭代的平均执行时间。我们使用连接算法每秒处理的元组的总吞吐量作为我们的比较指标。我们通过将两个输入关系的组合大小除以查询运行时来计算总吞吐量。
嵌套循环与散列连接。我们首先讨论存储在 GPU 内存中的两种关系的实验。在图 5 中,我们绘制了第 III 节中描述的两个连接变体的连接总吞吐量以及连接共同分区的吞吐量,一个嵌套循环,使用 warp shuffle 实现,以及一个 hash join,使用 GPU atomics 实现。每个 CUDA 线程块都为 2048 个元素、1024 个线程和 256 个哈希表桶的哈希连接配置了共享内存。对于这个微基准,每个输入都有两个 4 字节的列、一个连接键和一个有效负载。键是唯一且统一的。每个线程在本地聚合输出有效负载列,最后以原子方式更新全局聚合。我们每张表使用 200 万个元组,改变分区数量,并根据分区大小绘制结果。
最初,嵌套循环变体对于小分区比 hash join 变体具有更高的吞吐量,但 hash join 变体在较大分区时优于它。在每个分区有 1024 个元素之前,加入共同分区的吞吐量会增加,尽管对于 hash join 的情况,速度会更高。这是因为我们在更大程度上利用了流式多处理器的资源,这些资源已预先分配给 1024 个元素。然后,两种技术的吞吐量都会下降。对于由于冲突而导致的散列连接,而对于嵌套循环,由于其二次复杂性。嵌套循环的减少更为明显,并反映在总连接吞吐量上。尽管如此,分区阶段仍占主导地位,总体差异很小。
共享与设备内存。在下一个实验中,我们将评估使用共享内存与设备内存来加入共同分区的优势。我们连接两个具有与以前相同特征的表,但我们改变它们的大小并保持分区数量不变。我们使用两个分区传递来创建 2 15 2^{15} 215 个分区,并在共享内存或设备内存中使用 hash join 来连接共同分区。我们为每个分区的 4096 个元素、512 个线程、2048 个哈希表存储桶预留了足够的共享内存,并聚合了连接的输出。在图 6 中,我们描述了加入共同分区的吞吐量以及两个变体的总吞吐量。使用共享内存的版本在探测和整体连接方面具有更高的吞吐量。使用共享内存,随着数据集大小的增加,共同分区连接的吞吐量会增加,因为每个分区有更多的元素,并且资源利用率再次提高。这种模式由 GPU 内存版本跟踪,但是一旦链开始形成,吞吐量就会开始下降。最终结果是,对于最大的关系大小,共享内存方法的速度提高了 30% 以上。尽管分区占执行时间的大部分,但仍会发生这种情况。
注释和配置 对于图 7 - 13,除非另有说明,否则绘图使用注释,即带有具体线的配置表示连接结果的聚合,而虚线和点的混合模式表示结果实现。同样,实线表示 1 : 1 连接,虚线表示 1 : 2,点划线表示 1 : 4 连接。此外,对于每个 CUDA 块,我们为 4096 个元素和 2048 个哈希表桶分配了足够的共享内存。对于分区内核,我们使用每个 CUDA 块 1024 个线程和每个块 512 个线程来加入共同分区。我们将我们提出的 GPU 连接策略(在图中标记为 GPU 分区)与非分区 GPU 哈希连接、GPU 非分区和最先进的 CPU 算法、优化的分区哈希连接 PRO 和非分区哈希连接 NPO 进行比较。我们直接将这些研究提供的源代码用于 CPU 算法。由于我们的服务器在两个插槽上配备了 24 个 CPU 内核,NPO 和 PRO 都在所有 48 个线程上完全并行执行。
物化的影响。在图 7 中,我们评估了实现连接结果的影响。我们针对从 100 万到 1.28 亿个元组的大小相等的 GPU 内关系运行我们提出的算法,并测量结果在 GPU 内存中实现时的吞吐量与在有效负载上计算聚合时的吞吐量。我们观察到实现输出的版本与跟踪关系大小聚合的版本。由于两种关系具有相同的不同值,连接的选择性很高,并且许多结果元组被物化。尽管如此,写入缓冲区和内存访问的额外同步开销不会显着降低性能。尽管探测散列连接表引起了分歧,并且在不同的周期中为一个扭曲的线程发生了匹配,我们的缓冲技术设法结合结果写入并减少开销。
分区连接与非分区连接。接下来,我们将分区散列连接实现与非分区 GPU 散列连接进行比较,以防数据已经放置在 GPU 中。对于我们的 GPU 分区散列连接,我们将分区数保持在 2 15 2^{15} 215 个分区并使用上述配置。对于非分区 GPU 连接,我们使用两种实现方式,一种是传统的“链式”实现,将哈希表存储桶存储为与偏移指针连接的元素链,另一种具有使用完美的哈希函数,作为非分区连接的最佳情况。对于“链式”,在探测哈希表时,需要三到四次随机内存访问:一次用于哈希表本身,一次用于密钥,一次用于检查链中是否没有后继者,以及匹配,对有效负载的访问。完美的哈希算法旨在结合无冲突的知识和唯一键的连续范围。元组的有效载荷存储在一个密集数组中,使用键作为偏移量。每次探测操作只需要一次随机访问,因此该算法构成了 GPU 上非分区散列连接的最佳情况。
图 8 中这三种算法的实线描绘了三种算法在可适合 GPU 内存的各种数据集大小下实现的吞吐量。最初,分区连接的性能比未分区的连接更差,但它的吞吐量受益于更大的数据集大小,因为分区开销被摊销并得到回报。因此,当输入关系有超过 800 万个元组时,它的性能优于替代方案。相比之下,非分区连接的吞吐量开始时很高,但随着关系大小的增大而迅速恶化。
我们针对不同的构建与探测表的比率重复该实验,并在同一图中绘制了两倍和四倍于构建关系的探测关系的结果,分别用虚线和虚线绘制。对于每个构建端表大小,我们在探测端保留相同的一组不同值,与其大小无关,因此随着输入比率的增加,匹配的数量也会增加。这种趋势类似于同样大小的表,但我们的分区方法吞吐量的改进更加陡峭,因为在更小的关系大小下优于非分区实现。
有效负载大小 图 9 和 10 显示了有效负载大小对分区和非分区 GPU 连接性能的影响。我们使用后期实现来检索使用元组标识符作为连接有效负载的多个属性。我们聚合而不是具体化有效负载值,因为实现成本很常见。图 9 绘制了两种方法在不同探头端有效负载下的吞吐量。由于对后期物化属性的顺序读取,对于更大的探测端有效负载,非分区连接超过了分区连接,而分区连接对元组重新排序并导致随机访问。图 10 显示了改变构建端有效负载时的吞吐量。在这种情况下,双方重新排序元组并进行随机访问。因此,分区连接保持其边缘。但是,随着随机访问次数的增加,差异会减小。
流式探测连接。接下来,我们检查其中一个关系不适合 GPU 内存的情况。我们将构建表的大小固定为 6400 万个元组,并改变探测表的大小,使其不同的值保持不变。探测表被分解成构建表一半大小的块。所有表最初都在 CPU 内存中。图 11 绘制了结果在 CPU 内存上实现以及在 GPU 内聚合的情况下执行策略的吞吐量。 GPU 吞吐量随着更大的探测关系而增加,因为未完成的计算变得不那么重要并且接近 PCIe 带宽,这是此类连接的瓶颈。此外,我们再次注意到物化结果引入了开销,但不会导致显着的性能下降。
协处理连接。在我们执行的实验中,GPU 连接的最终执行策略涉及协同处理,当两种关系都不适合 GPU 内存时使用协同处理。本实验的关系大小从 2.56 亿到 10.24 亿不等。我们不会超过 10.24 亿个元组,因为对于 1 : 4 的关系大小比,20.48 亿个元组的较小关系会导致总数据集大小为 80 GB,从而为 CPU 端处理留下足够的内存空间。 16 个线程用于协处理的 CPU 分区阶段,以及具有非临时内存提示的软件管理缓冲区,以减少带宽消耗。我们在 CPU 上执行 16 路分区,第一步将 5 个分区用作 GPU 内部的工作集。这是因为对于我们的配置,CPU 基数分区通过可以达到大约 40 GB/s 的吞吐量,因此 5 个分区的生成速度足以使 PCIe 饱和,并且仍然适合我们实验中最大数据集的设备内存。
图 12 中的绿线显示了我们的协同处理连接在不同关系大小和构建-探针大小比率下实现的吞吐量。有两个重要的观察结果。首先,在大多数情况下,吞吐量对关系大小仍然不敏感。这表明协处理对于大型关系是稳健的。其次,比较图 7 和图 11,我们看到当只有构建表驻留在 GPU 时,我们的连接算法提供 14 亿元组/秒的吞吐量,当两个表都不驻留在 GPU 时,提供 12 亿元组/秒的吞吐量。当只有构建表位于 GPU 内存中时,我们的连接算法使 PCIe 带宽完全饱和。由于 GPU 外的两种情况在 PCIe 上都存在瓶颈,因此我们希望在更快的互连(如 NVLink 或 PCIe 4.0)下,我们的连接算法将提供更高的吞吐量。
与最先进的 GPU 连接进行比较。此外,我们将我们的算法与两个最先进的支持 GPU 的分析引擎 DBMS-X 进行比较,这是一个商业引擎,它使用代码生成为 GPU 查询执行生成高效代码 和 CoGaDB ,一个支持 GPU 的研究型 DBMS 系统,具有一次操作员执行模型。
在图 14 中,我们展示了我们的算法在加入 TPC-H 表时针对两个系统的执行时间。我们测量 lineitem 表的两个连接的执行时间,一个与 customers 表,一个与 orders 表,我们对两个不同的比例因子 10 和 100 重复实验。对于比例因子 10,第一个连接有一个工作集 500 MB,第二个 600 MB,不包括其他系统使用的任何压缩。对于比例因子,我们在运行实际测量之前多次运行每个查询,以允许系统将数据加载到 GPU 中。之后,所有三个系统都在 GPU 驻留数据集上运行。我们观察到我们的算法优于两个系统。对于比例因子 100,工作集为 5 GB 和 6 GB。在加入客户表时,我们观察到我们的算法以及 DBMS-X 的吞吐量增加,这与之前的实验一致。在与订单表连接时,DMBS -X 返回错误,而我们恢复到算法的流式变体,该算法在每次查询时通过 PCIe 传输 lineitem 表。不幸的是,CoGaDB 在加载比例因子 100 时未能调整内部数据结构的大小。
图 15 显示了我们的分区连接和两个系统对同样大小的表实现的吞吐量。只要基数低于 32 M 元组,DBMS-X 就会对驻留在 GPU 的数据执行连接。除此之外,DBMS-X 不会将数据加载到 GPU 内存中,而只是在 CPU 内存驻留表上执行 GPU 外连接。我们的连接算法实现能够将此限制推到 128 M 元组。我们怀疑这种差异是由于用于表示键的数据类型的内部整数大小差异造成的。 CoGaDB 还设法达到 128 M 元组,但它并非设计为在不适合 GPU 内存中两侧之一的连接上操作,因此无法运行两个更大的数据集。
图 15 显示当数据驻留在 GPU 时,我们的算法和 DBMS-X 的性能都更好。然而,我们的算法在所有情况下都优于 DBMS-X。当数据驻留在 GPU 上时,我们的算法在吞吐量方面比 DBMS-X 提高了 1.5 - 2 倍。当数据不是驻留在 GPU 时(图的右极端),这种差异会扩大到 10 倍。
在本小节中,我们将我们提出的 GPU 连接策略与最先进的 CPU 实现进行比较。
GPU 大小的数据 首先,我们检查图 8 中适合 GPU 内存的数据集的情况。对于每种方法,数据都是功能单元的本地数据。我们观察到分区和非分区的连接系列具有相同的行为,无论硬件如何。分区连接(PRO 和分区)得到改进,直到达到最佳状态,而非分区连接(NPO 和非分区)在较小的数据集上表现良好,但在较大的数据集上表现不佳。同样,随着探测表大小的增加,分区连接吞吐量的提高在这两种情况下都变得更加陡峭。有趣的是,对于足够大的数据集,PRO 优于非分区 GPU 哈希连接。这证明了非分区散列连接算法在 GPU 上的低效率。其次,对于所有关系大小,GPU 实现总是优于它们的 CPU 对应物。对于分区连接,GPU 的吞吐量高达 40 亿元组/秒,是 CPU 版本的 4 倍加速。
GPU 外比较 图 11 将仅构建表位于 GPU 内存中时的探测流策略与 CPU 分区散列连接进行了比较。图 12 显示了我们的协同处理策略的类似比较,其中两个表都超出了 GPU 内存。我们看到,在所有情况下,对于我们的实验设置,GPU 实现的性能都优于 CPU 连接,实际上加速比随着探针尺寸的增加而增加,接近 PCIe 带宽。但是,与所有数据都驻留在 GPU 的情况相比,加速仍然有限。有趣的是,我们看到协处理连接的好处随着数据集大小的增加而增加,因为协处理吞吐量保持不变,而 CPU 连接吞吐量呈下降趋势。这是因为协同处理受传输和分区步骤的约束,它们在增加关系大小时保持相同的吞吐量,而对于 CPU 连接,分区大小会增加,缓存优化的效果会减弱。当关系之一的大小增加时,差异甚至更大。这与保持构建关系大小不变的探测流案例不同。这表明协处理提供了接近 PCIe 的带宽性能,并且比 CPU 实现更好地扩展,因为它保持了恒定的吞吐量。
CPU 利用率从以上结果可以想象,24 核以上的多核 CPU 可以匹配或优于 GPU 执行策略。因此,使用我们的 GPU 感知连接算法的一个优势是,可以使用更少的 CPU 内核实现与基于 CPU 的连接相同的吞吐量。图 13 绘制了不同线程数的 CPU 分区散列连接的吞吐量与在分区阶段使用相同数量的线程时协同处理实现的吞吐量。我们看到 CPU 实现的吞吐量与线程数成正比。相反,协同处理实现的吞吐量迅速增加,超过了只有 6 个线程的最快 CPU 设置。协同处理在 16 个线程后达到平台期,在 26 个线程后出现小幅下降。此时,内存带宽已饱和,这会降低与分区重叠的 PCIe 传输的吞吐量。这个结果表明,使用我们的单 GPU 和 6 个内核的协处理连接,我们可以匹配使用近 10 倍 CPU 内核的基于 CPU 的连接的性能。这种优势对于混合事务和分析处理系统 (HTAP)(如 Caldera)特别有用,它使用 CPU 进行事务处理,使用 GPU 进行分析处理。
NUMA 效果 在图 16 中,我们展示了在传输到 GPU 之前暂存到近套接字的重要性。我们绘制了两个具有唯一键的关系的连接的吞吐量,这些关系在不同的数据集大小下匹配 1 : 1,并看到执行中间副本提高了性能。这是因为分区会干扰来自远端套接字的传输,从而降低整体性能。
在图 17-20 中,我们检查了我们的算法在倾斜数据下的性能。在图 17 中,我们绘制了输入表算法的吞吐量,每个表有 3200 万个元组,GPU 驻留数据的不同 zipf 分布。我们仅在探针侧、构建侧和两者上显示偏斜的情况。对于两个输入都偏斜的情况,我们提出了两个表具有相同偏斜和相同流行值的最坏情况。对于此图,当结果由于高偏斜值的数据爆炸而溢出 GPU 内存时,我们不会将结果刷新回 CPU,而是覆盖它们以隔离 GPU 内的性能。在图 18 中,我们为每个输入 5.12 亿个元组和我们使用协处理算法的 GPU 外数据的情况生成了相同的图。我们包括聚合和具体化连接结果的情况。在图 19 中,我们展示了具有重复的均匀分布对 GPU 输入和输出数据的影响。
从第一个图中我们观察到,对于 GPU 驻留数据集,如果另一侧是均匀的,则探测侧的偏斜对算法的吞吐量的影响非常小。构建端的偏差对算法的影响更大,但在大多数情况下,我们仍然获得高于 CPU 驻留数据集的吞吐量。当输入具有相同的偏差时,性能会在 0.75 倍后崩溃,这是两种影响的结合:1)每个共同分区的哈希表的构建端停止适合共享内存,因此我们求助于哈希-基本块嵌套循环 2)对于非常流行的值有太多的匹配,我们的算法接近全对全比较,遵循长链以获得高百分比的输入。对于大于 GPU 内存的数据集,我们从第二个图中观察到我们的算法更具弹性。由于互连比我们算法的 GPU 内部分慢得多,因此在 zipf 因子接近 1 之前,我们看不到性能下降。GPU 端的分区和连接仍然比 PCIe 总线执行得更快。在相同偏斜的情况下,在 0.75 的 zipf 之后,探测阶段的计算开始导致 PCIe 带宽无法覆盖的开销。在这些情况下,GPU 会受到与 GPU 内情况相同的问题的影响。在结果物化的情况下,GPU 内和 GPU 外的情况都会受到很小的惩罚,但具有相同偏差的 GPU 外的极端情况除外,在这种情况下,输出结果会爆炸,导致大量数据通过 PCIe 互连写入 CPU 端。
在图 20 中,我们展示了偏斜如何影响我们的协同处理算法在不同输入大小下的吞吐量。我们绘制了当两个输入具有完全相同的分布和相同流行值的 zipf 因子 0.25 和 0.5(最坏情况)以及均匀分布时的吞吐量。当汇总结果时,我们观察到对于高达 0.5 的 zipf 因子,与统一情况相比,没有性能损失。此外,正如我们在之前的实验中已经表明的那样,统一数据也不受物化的影响。最后,我们观察到对于输出爆炸的更大数据集,由于大量输出数据,性能会崩溃。
最后,在图 21 中,我们展示了在使用不同数据传输机制时,对于适合 GPU 内存的工作集,统一唯一键的情况下的吞吐量。在第一个图中,我们展示了数据驻留在 GPU 时的吞吐量,就像在我们的 GPU 内实验中一样,使用 UVA 仅用于加载数据、在 UVA 上加载和生成分区以及在 UVA 上执行整个算法的情况。最后一个栏显示使用统一内存加载输入的吞吐量。在图 22 中,我们展示了我们的算法对使用三种方法(UVA、统一内存和我们的方法)中的每一种加载的 GPU 外数据的吞吐量。前两种方法决定放置和数据传输,而对于最后一种方法,我们自己处理。
几十年来,GPU 已广泛用于加速从游戏到交互式显示的可视化应用程序。然而,传统的 GPU 受到几个主要限制,使其不适用于通用数据分析。首先,使用 GPU 的应用程序必须分别管理主机 (CPU) 和设备 (GPU) 内存,从而使可编程性复杂化。其次,GPU 设备内存容量太有限,无法存储所有数据。因此,应用程序必须在 GPU 上执行计算之前,通过慢速 PCIe 总线将数据从系统手动复制到设备内存。因此,尽管在 2000 年代后期的初步工作表明 GPU 可以提供比 CPU 的性能显着提高,但它们并未在行业中广泛使用,因为在 GPU 上运行的分析查询大部分时间都用于传输数据。
然而,在过去几年中,GPU 正在从针对特定领域的内存受限加速器发展为通用处理器,由于处理能力和内存带宽的持续增加,GPU 的性能在性能方面得到了根本性的改进,由于新的接口,例如NVLink,以及由于在 CUDA 编程模型中引入新功能而实现的可编程性。因此,人们对设计基于 GPU 的分析数据库引擎重新产生了兴趣。但是,这些系统不支持连接,或者使用非优化的、传统的非分区散列连接。此外,这些系统假设至少一个(如果不是两个)关系是 GPU 驻留的。在本文中,我们提出了一系列分区基数连接算法,这些算法经过微调以利用 GPU 硬件。在此过程中,我们首次研究了基于 GPU 的、硬件意识的连接算法,即使没有数据驻留在 GPU 上也可以工作。
Kaldeway 等人对传统哈希连接和分区连接的 GPU 实现进行了比较,并评估了 UVA 的效果。对于传统的 GPU 方法,其中输入数据首先复制到 GPU 内存,性能几乎相同。相反,当通过 UVA 执行访问时,传统方法更胜一筹,因为分区版本需要多次通过。对于探测阶段,PCI-e 带宽(当时为 6.3 GB/秒)是瓶颈,而对于构建阶段,由于使用随机原子内存访问,计算是实际瓶颈。在这项工作中,我们证明优化的分区连接算法可以大大优于非分区变体。我们还表明,我们优化的分区基数连接可以使 PCI-e 带宽饱和,并提供比 [18] 报告的结果至少高一个数量级的吞吐量。
Rui 和 Tu 选择了两阶段分区散列连接。他们讨论了新 GPU 特性与旧 GPU 相比的影响,即增加的内核和寄存器数量、优化的原子操作、动态并行性以及处理和传输的重叠。他们还开发了一种流水线工作流程,用于处理不适合 GPU 内存的关系。但是,他们将两种关系都超过 GPU 内存的情况留作未来的工作。在本文中,我们展示了 CPU-GPU 协同处理如何有效地执行 GPU 外连接。此外,我们的方法通过使用 GPU 原子操作而不是构建直方图,避免了每个分区步骤的额外传递。
He 等人研究了耦合 CPU-GPU 架构的哈希连接,两个单元并置在同一集成芯片中,避免了 PCI-e 传输。作者将卸载、数据划分和流水线执行确定为主要的协同处理机制,并将哈希连接分析为一系列细粒度的步骤,以便应用这些机制。他们报告了显着的性能改进,尽管这项工作是针对耦合架构的。我们表明,即使对于离散 GPU,硬件意识 GPU 连接也可以提供非常高的吞吐量。
在本文中,我们实现了一种用于在 GPU 上执行连接的分区算法。我们证明,对于驻留在 GPU 的数据集,该算法实现了非常高的性能,构建和探测步骤的吞吐量接近 CPU 的内存带宽。接下来,我们检查了 GPU 外关系连接的情况,更具体地说,是至少一个关系不适合 GPU 的场景,并制定了执行策略。对于前者,较大的关系通过 GPU 流式传输,内存传输和内核执行小心重叠,而对于后者,主机参与协同处理场景,在将关系流式传输到 GPU 之前对其进行分区。在这两种情况下,PCI-e 的带宽都已经饱和。