原文地址
https://db.cs.uni-tuebingen.de/theses/2025/ann-kathrin-claessens/claessens-2025.pdf
3.6 性能评估
3.6.1 可读性优化和CTE物化的效果
对原始PostgreSQL查询及将其移植到DuckDB后的初始版本运行EXPLAIN ANALYZE的结果如表31所示。此外,我们还考察了将外部WITH子句中的公共表表达式(如第1.4节所述)物化后以及应用了可读性改进后的运行时间。这些时间受到输入参数threshold的影响,该参数指示有多少个令牌被添加到提示中。由于任何版本的推理查询完成一个完整句子的运行时间都相对较长(超过1分钟),因此性能测量在threshold = 1的条件下进行。需要注意的是,即使此设置恰好导致向提示中添加一个令牌,所得的测量结果并不反映恰好推理GPT一次的运行时间,而是推理两次的时间。这是因为决定CTE gpt外部递归何时停止的WHERE条件取决于所选用于继续提示的令牌的值。如果它是"文本结束"令牌,则该行被丢弃,递归结束。因此,在可以评估停止外部递归的条件之前,必须先执行内部递归(该递归计算GPT输出,然后根据其预测选择下一个令牌)。
| 版本 | 时间 |
|---|---|
| PostgreSQL | 7.46秒 |
| DuckDB (初始) | 33.34秒 |
| DuckDB (CTE物化) | 33.12秒 |
| DuckDB (可读性优化) | 32.68秒 |
表31:不同版本GPT推理查询(原始版、移植到DuckDB版、外部WITH子句CTE物化版、以及可读性优化版)向提示 'Happy New Year! I wish you' 添加一个令牌的平均运行时间比较
迄今为止考虑的任何DuckDB版本的GPT推理查询都明显比原始PostgreSQL查询慢。将外部WITH子句中的CTE物化没有显示出显著效果,因为查询性能与初始DuckDB版本仍然非常相似。对于那些在整个查询中被多次引用的CTE,物化它们预期会提高性能,特别是如果这些引用发生在递归中(第2.6节中观察到会影响运行时)。然而,在gpt的递归步骤中被引用的唯一外部CTE是存储单行输入参数的那个。这并不代表一个昂贵的计算,防止其重复计算能带来显著好处。它也是外部WITH子句中唯一被多次引用的CTE。与此同时,最内层WITH子句中的一些CTE是CTE物化的有希望的候选者。尤其是与自身连接的CTE heads,符合已知场景的标准,即DuckDB会为每个引用评估CTE,而不是自动物化它[3]。然而,尝试物化内部WITH子句中的CTE可能会导致当前查询结构出现内部错误。这个问题在应用了第3.6.2节讨论的查询优化后得到解决。因此,最内层CTE的物化将在下一节讨论。
3.6.2 性能优化
一个简单但有效的性能改进措施是消除CTE transformer(代码清单60)中最内层WITH子句与CTE的FROM子句中其他子查询之间的关联。这种关联的存在是为了让最内层CTE可以访问当前的block_id,这是获取当前transformer块参数所必需的。通过将确定block_id的子查询定义为最内层WITH子句中的一个额外CTE,使得其他最内层CTE可以引用这个新的CTE而不是关联子查询,从而显著提高了性能(表32)。代码清单85重点展示了应用于CTE transformer递归步骤的上述变更,以解关联其子查询。
sql
-- 代码清单85:在CTE `transformer`(代码清单60)中,通过在最内层`WITH`子句中重复子查询"curr"作为一个CTE来移除子查询关联,以提高性能
-- 摘自CTE "transformer"的递归步骤:DuckDB
SELECT ...
FROM (
-- 获取当前块的id
SELECT block_num AS block_id
FROM previous
WHERE block_num < 12
LIMIT 1
) curr
CROSS JOIN -- 新增:不使用横向连接
(
WITH q AS
(
SELECT block_num AS block_id -- 新增:将子查询"curr"重复为"q"
FROM previous
WHERE block_num < 12
LIMIT 1
),
ln_1_b_params (values) AS
(
SELECT ln_1_b.values
FROM ln_1_b, q -- 新增:与"q"连接
WHERE ln_1_b.block = q.block_id -- 新增:引用"q",而不是"curr"
),
...
对原始PostgreSQL推理查询和解关联后的DuckDB版本运行EXPLAIN ANALYZE得到的运行时间如表32所示。它们针对向提示添加一个令牌和添加八个令牌(对于示例提示构成完整句子)进行了比较。
| 条件 | PostgreSQL | DuckDB (解关联后) |
|---|---|---|
threshold = 1 |
7.46秒 | 6.87秒 |
threshold = 8 |
87.05秒 | 44.37秒 |
表32:不同版本GPT推理查询向提示 'Happy New Year! I wish you' 添加若干令牌的平均运行时间比较
仅通过这一更改,当threshold为1时,DuckDB查询突然略微优于原始PostgreSQL查询。当threshold指示的外部迭代次数增加时,这种微小的优势会累积起来。当threshold为8时,DuckDB查询的运行时间大约仅为原始PostgreSQL查询的一半。
此外,解关联最内层WITH子句允许对其CTE进行物化。为了观察物化对查询评估的影响,建立了一个DuckDB Python API来运行查询。这允许在查询中使用由Python函数创建的用户定义函数(UDF)[3]。这种方法并未用于实现诸如逐元素向量操作之类的功能,因为它会导致性能下降,很可能是由于上下文切换造成的开销[17]。然而,UDF可用于验证CTE没有被不必要地多次评估。为此,定义了一个简单的Python函数,它接收一个值,将其添加到一个先前定义为全局变量的列表中,并返回未改变的输入值。根据此函数创建了一个UDF(见代码清单86)。在查询中调用此函数的副作用是,定义为全局变量的列表长度增加1。查询执行后,该列表的长度将在终端中打印。通过将UDF应用于最内层CTE的索引列place,列表的长度表示在查询评估期间为此CTE计算的行数。
python
# 代码清单86:从一个将其参数添加到全局列表的Python函数创建UDF
values = []
def showvals(value):
global values
values.append(value)
return value
# 从Python函数创建UDF
con = duckdb.connect()
con.create_function("showvals", showvals, [BIGINT], INTEGER)
计算的行数与预期值(如果CTE在每次迭代中只评估一次)进行比较(见表33)。对于单次推理,预期的CTE计算行数等于transformer中的块数(n_blocks = 12)乘以CTE的行数。计算行数的公式如表33所示。它们都取决于提示中的当前令牌数(n_tokens)。有些还取决于多头注意力步骤中使用的头数(n_heads = 12)。由于threshold = 1意味着GPT被推理两次,并且每次推理后提示中添加一个令牌,因此必须用n_tokens增加1的值进行第二次计算。标记化完成后,示例提示表示为七个令牌的列表。这意味着对于CTE mha_norm(其行数与提示当前状态中的令牌数一样多),预期计算行数为 n_blocks * 7 + n_blocks * 8 = 12 * 7 + 12 * 8 = 180。其他CTE的预期数量,以及在物化CTE之前和之后使用UDF获得的实际数量,如表33所示。
| CTEs | 行数公式 | 预期行数 | 无最内层CTE物化时的计算行数 | 物化CTE后的计算行数 | 减少倍数 |
|---|---|---|---|---|---|
mha_norm |
n_tokens |
180 | 1080 | 180 | 6 |
heads |
n_heads * n_tokens |
2160 | 12960 | 2160 | 6 |
sm_input, sm_diff, sm_exp, softmax, attention, mha |
n_heads * n_tokens^2 n_tokens |
16272 180 | 32544 360 | 16272 180 | 2 2 |
ffn_norm, ffn_a, ffn |
n_tokens |
180 | 180 | 180 | 1 |
表33:(对于threshold = 1且初始令牌数为7时)最内层WITH子句中每个CTE的预期和测量计算行数,在物化CTE前后。预期数量通过将行数乘以n_blocks = 12计算,一次用n_tokens = 7,一次用n_tokens = 8。
表33显示,在物化最内层WITH子句中的CTE后,它们的计算行数等于预期数量。如果不进行CTE物化,该数量最多高出6倍。这表明CTE物化确保了它们不会被不必要地多次计算。既然已经确认transformer实现中的CTE不再被过度评估,我们再次使用EXPLAIN ANALYZE测量运行时间。如表34所示,threshold = 1的运行时间再次略有下降,导致threshold = 8时的加速比之前更大。
| 条件 | DuckDB (解关联后) | DuckDB (解关联并最内层CTE物化后) |
|---|---|---|
threshold = 1 |
6.87秒 | 5.85秒 |
threshold = 8 |
44.37秒 | 37.79秒 |
表34:解关联后的DuckDB推理查询与额外应用了最内层CTE物化版本的查询向提示 'Happy New Year! I wish you' 添加若干令牌的平均运行时间比较
3.6.3 结论
尽管将查询移植到DuckDB最初导致性能下降,但仅通过移除一个关联就足以使其性能超越原始PostgreSQL查询。通过应用CTE物化,这种效果进一步增强。虽然对于向示例提示添加单个令牌,运行时间之间的差异不到2秒,但随着查询向提示添加更多令牌,这种差异变得越来越大。
除了与不同数据库管理系统相关的优化之外,还可以对查询的逻辑或结构进行更改以提高性能。如第3.1节所述,Quassnoi实现的作为PostgreSQL查询(在本论文中已适应DuckDB)的GPT推理算法是简化版[15]。为了简单起见,未包括一些可能带来更好性能的改进。例如,查询在每次GPT推理前重新计算提示中所有令牌的向量嵌入。相反,代表输入提示的原始令牌列表可以在嵌套递归之前仅计算一次。这类更改并非特定于任何数据库管理系统,因此可以同时应用于PostgreSQL和DuckDB查询。
4. 结论
所选的PostgreSQL查询已成功移植到DuckDB。此过程不仅揭示了两个数据库管理系统在支持的函数和数据类型方面的一些差异,还揭示了它们在查询评估方法上的不同。
除了必要的更改以适应某些函数或操作符的不同行为之外,移植GIF解码器查询还需要重新检查输入数据的最佳数据类型。因此,必须为新的输入数据类型确定构成查询基础的基本操作集的新实现。对于GPT推理查询,使用了列表函数来实现对向量执行的大量操作。每个DuckDB查询得到的SQL代码通过减少嵌套代码块的数量、将整个查询中重复的复杂表达式隐藏在宏定义中以及其他提高可读性的措施进行了优化。优化的代码通过解释为实现解决问题所完成的子目标以及每个子目标的实现方式进行了呈现。
在评估查询性能时,结果显示,提高可读性的优化与提高性能的优化不能完全分离。一些可读性优化也带来了性能提升。包含无法并行化的复杂递归的GIF解码器和GPT推理查询,在移植到DuckDB后,最初性能都比原始查询差。对于GIF解码器查询,观察到有关CTE物化的不同规则对递归的性能有影响。此外,还确定了递归中的一个操作对运行时间较长有贡献。它被替换为观察到效率高得多的替代实现。这些优化结合减少关联子查询使用的努力,显著提高了DuckDB查询的性能,尽管它仍然落后于原始查询。在GPT推理查询的性能优化过程中,解关联递归CTE中的子查询产生了更大的影响。仅此一项措施就使得DuckDB查询性能超过了原始PostgreSQL查询。在内部递归中应用CTE物化进一步提高了查询性能。