在过去的四个月里,我作为后端工程实习生与Cockroach Labs SQL Execution团队的优秀工程师们一起工作,在CockroachDB 20.1版本发布之前充分发掘矢量引擎的潜力。在此期间,我专注于改进CockroachDB的一些SQL运算符背后的算法,以减少内存的使用并加快查询的执行。在这篇博文中,我将介绍我对无序区分操作符背后的算法所做的改进,以及解决一些现有低效率问题的新算法。
在CockroachDB的20.1版本中,我们删除了矢量执行引擎上的experimental 标志。如果估计扫描的行数大于某个阈值(默认为1000行),CockroachDB会通过矢量化引擎运行一个查询子集。用户也可以使用矢量引擎来执行所有支持的查询,在他们的SQL shell中使用以下命令。
通过这个设置,CockroachDB默认会使用矢量引擎,只有在遇到不支持的查询时才会退回到旧的引擎。
矢量执行引擎
矢量执行引擎是一项有趣的技术,它可以显著提高SQL查询的速度。在我们早期的基准测试中,新引擎比我们现有的按行计算的SQL引擎快40倍。你可以在《我们如何建立一个矢量执行引擎》中阅读更多关于我们是如何建立矢量引擎的。
当矢量引擎完成了对所有图元的提取后,它将所有图元存储在一个列式内存布局中。这使我们能够建立对现代CPU缓存架构和分支预测器友好的SQL运算符,并能大大超过我们现有的一行一列引擎。然而,这些改进也是有代价的。当涉及到加快操作时,列式内存布局是很好的,但有时它使复杂的操作难以实现,如SQL连接或分组。另外,我们需要对我们的实现小心翼翼,这样我们就不会走任何捷径,这可能会导致矢量引擎在本质上表现得像老式的一行一策引擎。例如,如果紧缩循环内的逻辑非常复杂或涉及昂贵的操作,例如有太多的分支和即时内存分配,就会抵消拥有列式内存布局的好处。
为了理解这篇博客中提出的概念,我们建议你阅读我们之前的博文,用矢量执行的40倍快的哈希连接器。
无序分明是一个SQL操作符,它对输入的给定列进行分明操作。它从查询结果中删除那些在不同列上有重复值的图元。顾名思义,它被设计用来处理那些无序的列的输入。无序分明运算符需要跟踪它在整个生命周期内遇到的所有分明图元。
直观地说,运算符可以将所有独特的图元存储在内存的键值字典中。对于它遇到的所有后续图元,操作者只需要在字典中进行简单的查找,以确定这个图元是否已经被看到。这是因为在循环的每个迭代中,操作者需要执行多个计算步骤。
- 对输入元组的关键列进行哈希处理。
- 使用元组的哈希值执行字典查询。
- 如果存在相同的哈希键,在新元组和字典中具有相同哈希键的元组之间执行实际比较,以检查是否是哈希碰撞。
- 如果新元组实际上是唯一的,则执行一次字典插入。
每个迭代都涉及到哈希计算、多个分支和值比较(这可能非常昂贵,取决于SQL数据类型),以及插入(这可能导致内存分配和复制)。矢量引擎的意义在于将一个大循环减少为多个小循环,循环体内部的操作非常简单。这可以提高程序的缓存友好度,减少CPU内部的分支错误预测。
从CockroachDB 19.2开始,矢量无序分明运算符是用我们自己的矢量哈希表实现的。它的实现基于Marcin Zukowski的论文《平衡矢量查询执行与带宽优化存储》。矢量哈希表是我们实现高效矢量哈希连接器的关键。
因为最初的矢量哈希表是为哈希连接运算器设计的,它存储了很多额外的信息,而这些信息对于无序的不同运算器来说是不必要的。例如,为了实现哈希连接,哈希表需要存储所有图元,而不仅仅是独立图元。除了存储所有图元之外,哈希表还需要为每个桶建立一个链接列表,以便有效地遍历碰撞。在无序独立运算符中,这些辅助数据结构和计算都不需要。我们唯一关心的是我们遇到的第一个不同元组。因此,我们可以丢弃所有与第一个相同的后续图元,从而显著提高无序区分运算符的性能。
矢量哈希表
CockroachDB将哈希表的构建分为多个阶段。在第一阶段,它消耗了所有的输入并在内存中缓冲了所有的图元。然后,它对每个缓冲的元组进行哈希处理,并将具有相同哈希值的元组归入桶。对于每个桶,哈希表构建一个链表,以遍历所有图元。我们把这个链表称为哈希链。
在每个桶的哈希链构建完成后,哈希表遍历每个桶,检查是否有任何哈希碰撞。对于每个桶,哈希表为里面的每个不同的值创建一个 "相同 "的链接列表。这个新的链表的想法类似于哈希链。唯一的区别是,哈希链使我们能够遍历每个桶中具有相同哈希值的所有图元,而 "相同 "链表使我们能够遍历所有相同的图元。在这一点上,哈希表的构建已经完成。
为了更好地说明该算法,让我们看一个例子。假设每个输入元组都使用某种散列函数散列为1或2,并发生了散列碰撞。
Input: hash buffer:
+---+----+-----+ +-------+------+
| a | b | c | | index | hash |
+---+----+-----+ +-------+------+
| 2 |null|"2.0"| | 0 | 0 | <- hash collision \
+---+----+-----+ +-------+------+ *
| 2 |1.0 |"1.0"| | 1 | 1 | |
+---+----+-----+ +-------+------+ |
| 2 |null|"2.0"| | 2 | 0 | <- hash collision |
+---+----+-----+. -----> +-------+------+ *
| 2 |2.0 |"2.0"| | 3 | 0 | <- hash collision |
+---+----+-----+ +-------+------+ *
| 2 |null|"2.0"| | 4 | 0 | <- hash collision /
+---+----+-----+ +-------+------+
| 2 |1.0 |"1.0"| | 5 | 1 |
+---+----+-----+ +-------+------+
然后,哈希缓冲区被用来构建每个元组的哈希链。我们用两个数组 "第一 "和 "下 "来表示哈希表中的所有哈希链。第一个 "数组中的每个条目都从一个哈希值映射到 "下一个 "数组中的一个条目。next "数组中的这个条目代表哈希链链接列表的开始。我们使用一个0的值来代表链接列表的结束。
first: next:
+------+-------+ +-------+------+
| hash | keyID | | keyID | next |
+------+-------+ +-------+------+
| 0 | 5 | | 0 | N/A | <- not used, reserved
+------+-------+ +-------+------+
| 1 | 6 | | 1 | 0 | <- tail of hash chain of
+------+-------+ +-------+------+ hash value 0
| 2 | 0 | <- tail of hash chain of
+-------+------+ hash value 1
| 3 | 1 |
+-------+------+
| 4 | 3 |
+-------+------+
| 5 | 4 | <- head of hash chain of
+-------+------+ hash value 0
| 6 | 2 | <- head of hash chain of
+-------+------+ hash value 1
next "数组的第2个元素不被使用。因此,我们引入了一个由索引计算的 "keyID "列。为了得到 "keyID "的值,我们将每个条目的索引值移一。所以,keyID = index + 1。下一个 "数组条目的值只是指向同一哈希链链接列表中下一个条目的键ID。
如果我们把 "第一个 "和 "下一个 "数组的结果改写成更易读的形式,我们就有了这个。
+------------+---------------------------------------+
| hash value | keyIDs for the hash chain linked list |
+------------+---------------------------------------+
| 0 | 5 -> 4 -> 3 -> 1 |
+------------+---------------------------------------+
| 1 | 6 -> 2 |
+------------+---------------------------------------+
请注意,对于每个哈希链来说,键ID值是以单调递减的顺序排列的。这个属性对于哈希连接器来说并不重要,但它会给无序的分明带来一些麻烦。我们将在以后的博客中重新审视这个问题。
算法的下一步是探查我们刚刚建立的每个哈希链,并解决任何哈希碰撞问题。哈希表会浏览到目前为止它所缓冲的每一个元组。对于每个元组,它计算其哈希值并查找与此哈希值对应的哈希链。一旦找到相应的哈希链,每个元组都会将自己与哈希链的头部进行比较。如果它们的值不同,我们就遇到了哈希碰撞,因为不同的值被哈希到了同一个哈希值。关于我们如何实现这一步骤的更多细节,请参见关于哈希连接器的博文。哈希碰撞检测阶段的结果也是一组类似于我们用来存储哈希链的链接列表。从概念上讲,这看起来像下面这样。
+-------------------------------+---------------------------------------+
| keyID of the linked list head | linked list of keyIDs with same value |
+-------------------------------+---------------------------------------+
| 5 | 5 -> 4 -> 1 |
+-------------------------------+---------------------------------------+
| 3 | 3 |
+-------------------------------+---------------------------------------+
| 6 | 6 -> 2 |
+-------------------------------+---------------------------------------+
请注意,这与我们前面提到的哈希链不同。在哈希链中,我们使用链接列表来指向所有具有相同哈希值的图元。现在,链接列表中包含的图元的值是相等的。虽然这个信息对于哈希连接器来说很重要,以确保输出的正确性,但它对于无序的分明不是很有用。因此,当我们找到链表的头时,我们可以跳过建立链表。这个简单的优化实际上使无序分明运算符的性能提高了35~40%。
name old speed new speed delta
numCols=1/nulls=false/numBatches=1-8 128MB/s 181MB/s +41.41%
numCols=1/nulls=false/numBatches=16-8 107MB/s 145MB/s +35.51%
numCols=1/nulls=false/numBatches=256-8 45.1MB/s 82.6MB/s +83.15%
numCols=1/nulls=true/numBatches=1-8 71.5MB/s 112.1MB/s +56.78%
numCols=1/nulls=true/numBatches=16-8 65.6MB/s 98.9MB/s +50.76%
numCols=1/nulls=true/numBatches=256-8 22.8MB/s 41.5MB/s +82.02%
numCols=2/nulls=false/numBatches=16-8 146MB/s 190MB/s +30.14%
numCols=2/nulls=false/numBatches=256-8 50.5MB/s 103.1MB/s +104.16%
numCols=2/nulls=true/numBatches=1-8 97.2MB/s 131.6MB/s +35.39%
numCols=2/nulls=true/numBatches=16-8 82.0MB/s 111.2MB/s +35.61%
我们可以做得比这更好吗?当然可以。
回顾一下,在建立矢量哈希表的初始阶段,引擎首先将所有图元缓冲到内存中,然后再进行后续计算。这样的设计是为了让哈希表拥有所有它需要的哈希连接器的信息。然而,无序的不同运算符只需要不同的图元。这是我们第二个优化的关键:我们只需要在哈希表中存储不同的图元!这个优化策略也限制了内存消耗。这种优化策略也限制了无序的distinct的内存消耗,使其与输入中的独立图元的数量成正比,而不是与输入中图元的总数成正比。另外,由于我们只将不同的图元缓冲到内存中,我们减少了整个算法中需要执行的比较的总数。
矢量哈希表的唯一模式
在无序分明运算器完成构建哈希表后,运算器会浏览每一个 "相同 "的链表,并将每个链表的头部复制到其内部缓冲区。当运算器处理完所有的 "相同 "链表后,它输出所有它从哈希表中复制的缓冲图元。观察一下,当运算器通过每个 "相同 "的链表并复制每个链表的头时 只有的头部,内存访问模式是随机的,而不是顺序的。这种访问模式对CPU内部的缓存很不友好,这意味着操作者在通过每个 "相同 "链表时,会有大量的缓存丢失。我们可以通过给哈希表增加一个不同的模式来避免这种随机访问模式来改善这种情况。这意味着哈希表的输出应该只包含不同的图元,操作者可以简单地按顺序将其复制到其内部缓冲区。
为了在矢量哈希表中完全实现不同模式,我们需要修改建立哈希表的算法。首先,由于主要目标是消除将整个输入缓冲到内存中的需要,我们不应该在一开始就消耗整个输入,然后将所有图元缓冲到内存中。相反,我们可以逐步建立哈希表。这一点的实现非常简单。当我们获取一批新的图元时,我们首先在这批图元中进行重复数据删除。在所有重复的图元被从该批中删除后,我们将该批中剩余的图元与已经被缓冲的图元进行检查,看是否有其他重复的图元。由于我们只插入严格包含唯一图元的批次,我们可以确定在缓冲图元中没有重复的图元。当第二次重复检查完成后,我们可以简单地将新批次中剩余的所有图元直接追加到哈希表中,并且唯一性不变性仍将保持。这种方法反映在下面的高级代码片断中。
func build() hashTable {
ht := hashtable{}
for {
batch := input.Next(ctx)
if batch.Length() == 0 {
break
}
buildHashChains(batch)
// Removes the duplicate tuples within the new batch.
removeDuplicatedTuples(batch, batch)
// Removes the tuples from the new batch that already have duplicates
// in the hash table.
removeDuplicatedTuples(batch, ht.bufferedTuples)
ht.bufferedTuples.Append(batch)
}
return ht
}
现在,如果你熟悉我们在上一篇博文中实现的哈希碰撞处理方案,你会发现去重和检查哈希碰撞的过程非常相似。事实上,如果你仔细观察代码,它们几乎是相同的,只是有一些轻微的差别。
我们如何执行重复数据删除有两个阶段。在第一阶段,我们删除批处理本身中的重复图元。对于每个元组,我们遍历之前建立的哈希链。一旦发现哈希链中第一个相同的元组,我们就停止链接列表的遍历。这个算法本身是很直接的。然而,如果你仔细观察,你会发现这种优化不能完全保证算法的正确性。
我们之前提到,一旦我们建立了哈希链,哈希链内的keyIDs的值就会呈单调递减的顺序。换句话说,哈希链链接列表的头部将是拥有最大keyID的元组。重复数据删除算法在遇到一个具有相同值的元组时,会立即停止哈希链的遍历。该元组被作为独立元组排放出来。因为哈希链的顺序是相反的,head (即在一个批次内最后出现的元组)将是被排放的那个。回顾前面的例子。
first: next:
+------+-------+ +-------+------+
| hash | keyID | | keyID | next |
+------+-------+ +-------+------+
| 0 | 5 | | 0 | N/A | <- not use, reserved
+------+-------+ +-------+------+
| 1 | 6 | | 1 | 0 | <- tail of hash chain of
+------+-------+ +-------+------+ hash value 0
| 2 | 0 | <- tail of hash chain of
+-------+------+ hash value 1
| 3 | 1 |
+-------+------+
| 4 | 3 |
+-------+------+
| 5 | 4 | <- head of hash chain of
+-------+------+ hash value 0
| 6 | 2 | <- head of hash chain of
+-------+------+ hash value 1
在这种情况下,因为我们从keyID=5和keyID=6开始探测,我们最终会发出以下的keyID:5、6和4。然而,在CockroachDB的SQL引擎里面,不同的SQL操作符可以作为更复杂的查询的构建块。让无序的分明运算符确定地输出它遇到的第一个分明元组,使得SQL运算符和SQL规划器的工作更加容易。
这个问题的解决方案本质上是颠倒链接列表的顺序。由于重复数据删除过程是从哈希链的头部开始的,如果我们能确保哈希链内的keyID的值是单调增加的顺序,我们就能保证哈希表会输出它遇到的第一个元组。作为结果,我们有如下结果。
first: next:
+------+-------+ +-------+------+
| hash | keyID | | keyID | next |
+------+-------+ +-------+------+
| 0 | 1 | | 0 | N/A | <- not use, reserved
+------+-------+ +-------+------+
| 1 | 2 | | 1 | 3 | <- head of hash chain of
+------+-------+ +-------+------+ hash value 0
| 2 | 6 | <- head of hash chain of
+-------+------+ hash value 1
| 3 | 4 |
+-------+------+
下面是更新算法后的无序分明运算符的基准。
name old speed new speed delta
newTupleProbability=0.001/rows=4096/cols=2 154MB/s 156MB/s ~
newTupleProbability=0.001/rows=4096/cols=4 219MB/s 236MB/s ~
newTupleProbability=0.001/rows=65536/cols=2 136MB/s 384MB/s +181.65%
newTupleProbability=0.001/rows=65536/cols=4 245MB/s 518MB/s +111.55%
newTupleProbability=0.010/rows=4096/cols=2 154MB/s 155MB/s ~
newTupleProbability=0.010/rows=4096/cols=4 214MB/s 249MB/s +16.29%
newTupleProbability=0.010/rows=65536/cols=2 181MB/s 373MB/s +106.24%
newTupleProbability=0.010/rows=65536/cols=4 225MB/s 504MB/s +123.53%
newTupleProbability=0.100/rows=4096/cols=2 150MB/s 147MB/s ~
newTupleProbability=0.100/rows=4096/cols=4 207MB/s 204MB/s ~
newTupleProbability=0.100/rows=65536/cols=2 165MB/s 301MB/s +82.26%
newTupleProbability=0.100/rows=65536/cols=4 201MB/s 398MB/s +97.83%
newTupleProbability=0.001/rows=4096/cols=2 113MB/s 122MB/s +8.02%
newTupleProbability=0.001/rows=4096/cols=4 141MB/s 168MB/s +19.43%
newTupleProbability=0.001/rows=65536/cols=2 138MB/s 236MB/s +71.44%
newTupleProbability=0.001/rows=65536/cols=4 156MB/s 282MB/s +80.65%
newTupleProbability=0.010/rows=4096/cols=2 109MB/s 117MB/s +6.74%
newTupleProbability=0.010/rows=4096/cols=4 143MB/s 164MB/s +14.59%
newTupleProbability=0.010/rows=65536/cols=2 100MB/s 226MB/s +125.29%
newTupleProbability=0.010/rows=65536/cols=4 142MB/s 272MB/s +91.53%
newTupleProbability=0.100/rows=4096/cols=2 108MB/s 112MB/s +3.43%
newTupleProbability=0.100/rows=4096/cols=4 132MB/s 150MB/s +13.62%
newTupleProbability=0.100/rows=65536/cols=2 107MB/s 191MB/s +78.89%
newTupleProbability=0.100/rows=65536/cols=4 125MB/s 237MB/s +89.61%
我们可以看到,根据输入数据的特点,**我们可以在之前35%到40%的性能提升的基础上,有高达181%的性能提升。**此外,我们还看到无序区分运算符的内存占用率大幅下降。
name old alloc/op new alloc/op delta
newTupleProbability=0.001/rows=65536/cols=2 8.17MB 1.17MB -85.68%
newTupleProbability=0.001/rows=65536/cols=4 14.0MB 1.2MB -91.34%
newTupleProbability=0.010/rows=65536/cols=2 8.17MB 1.20MB -85.29%
newTupleProbability=0.010/rows=65536/cols=4 14.0MB 1.3MB -90.92%
newTupleProbability=0.100/rows=65536/cols=2 8.17MB 1.77MB -78.27%
newTupleProbability=0.100/rows=65536/cols=4 4.0MB 2.2MB -83.99%
我希望你喜欢了解我们的向量哈希表是如何在引擎盖下工作的,以及我们是如何不断发展我们的算法和实现的。我在这篇博文中所介绍的只是我们在CockroachDB 20.1版本中为矢量引擎所做工作的冰山一角。我非常感谢有机会与Cockroach实验室绝对优秀的工程师一起工作,我想真正感谢我的导师Alfonso,我的经理Jordan和我的队友Yahor,感谢他们给了我这个了不起的实习机会。感谢Cockroach Labs,这是一个伟大的旅程。
如果你有兴趣在Cockroach Labs工作,我们有实习和全职职位。