DeepSeek总结的PostgreSQL解码GIF文件SQL移植到DuckDB的性能优化方法

原文地址

https://db.cs.uni-tuebingen.de/theses/2025/ann-kathrin-claessens/claessens-2025.pdf

2.6 性能评估

运行第2.5节中提出的GIF解码器查询的DuckDB代码,很快就会发现它相当慢。即使采用稍后讨论的CTE物化技术,其速度也显著慢于原始的PostgreSQL查询。在本子章节中,将比较不同版本查询运行EXPLAIN ANALYZE(参见第1.4节)所得的总时间。通过检查查询图,识别出这些性能差异的可能原因,以便创建一个针对性能优化的新实现。

2.6.1 可读性优化和CTE物化的效果

如第1.4节所述,DuckDB仅在特殊情况下对CTE进行物化[3],而PostgreSQL在大多数情况下会自动将CTE仅计算一次,即使它被同级CTE多次引用[7]。显式物化CTE以消除这种差异,当应用于CTE lzw_params及其源时,会对整体执行时间产生影响。物化这个特定的CTE之所以影响巨大,是因为它在解压缩算法(CTE decompression_steps)的递归步骤中被引用。除了递归调用自身之外,lzw_params是它引用的唯一CTE。由于它执行大量迭代,这解释了为什么CTE物化仅对CTE lzw_params及其源image_blocks如此有效,因为这意味着它们只执行一次,而不是每次迭代都执行。

将PostgreSQL推理查询移植到DuckDB得到的初始DuckDB查询,在其解压缩算法的递归步骤中嵌套了一个额外的WITH子句(参见第2.3.3节)。然而,物化其CTE并未带来任何好处。外部WITH子句的所有CTE(除了最后一个对输出行进行排序的CTE)都可以被物化,而不会对性能产生任何负面影响。在初始DuckDB版本和针对可读性优化的版本中都进行了这些物化后,使用EXPLAIN ANALYZE来比较它们与原始PostgreSQL查询的性能(见表11)。

版本 时间
PostgreSQL 1.68秒
DuckDB (初始) 281.46秒
DuckDB (CTE物化) 150.30秒
DuckDB (可读性优化) 119.79秒

表11:不同版本的GIF解码器查询(移植到DuckDB、CTE物化、以及可读性优化)在92x92测试图像的GIF数据上的平均运行时间比较

在没有任何CTE物化的情况下,初始适应DuckDB的时间约为281秒。进行物化后,总执行时间平均几乎减半。然而,这仍然比原始查询慢89倍以上。针对可读性优化的物化版本性能没有比物化的初始查询适应版本明显更差,这是意料之中的。除了几个查询简化和解压缩算法CTE中连接结构的不同之外,它们之间的主要区别是宏的使用,这不应造成显著的开销(参见第1.4节)。事实上,根据查询图工具提供的分析(见表12),重构后的连接似乎导致了查询性能的改进。该分析包括了查询中递归CTE的运行时间,这涵盖了表中显示的若干其他操作符的时间。

阶段 时间 百分比
总计时间 154.36秒 100.00%
递归CTE 153.44秒 99.40%
投影 86.04秒 55.74%
哈希连接 43.89秒 28.43%
哈希分组 6.03秒 3.90%
右定界连接 3.11秒 2.02%
阶段 时间 百分比
总计时间 123.14秒 100.00%
递归CTE 122.34秒 99.36%
投影 77.05秒 62.57%
哈希分组 6.11秒 4.96%
右定界连接 5.98秒 4.86%
哈希连接 3.65秒 2.96%

表12:DuckDB查询图工具为物化的初始查询(左)和可读性优化版本(右)提供的运行时分析

两个查询版本之间的主要区别是,物化的初始DuckDB查询的评估需要大量时间用于哈希连接 操作。查看查询图,递归CTE decompression_steps中一个成本高昂的哈希连接似乎是导致此问题的主要原因。可读性优化(包括将此CTE递归步骤的FROM从句从嵌套的WITH子句重构为LATERAL连接)恰好极大地降低了这些成本。作为交换,右定界连接操作符的时间略有增加。由于针对可读性优化的查询性能更好得多,因此将其作为起点来讨论进一步的性能优化。

2.6.2 性能优化

到目前为止讨论的性能方面并未解决为什么该程序的原始PostgreSQL版本如此快的问题。查询图工具提供的分析(表12)显示,针对可读性优化的查询版本超过99%的运行时间是由递归CTE引起的。此查询中有三个递归CTE:blocksimage_datadecompression_steps。查看从DuckDB查询图工具获得的查询图,CTE blocks的时间显示为小于0.0016秒,image_data为0.04秒,而decompression_steps大约需要122秒。因此,以下讨论的性能优化主要集中在CTE decompression_steps上。

检查递归CTE decompression_steps的子图很快发现,单个投影 操作占用了其运行时间的绝大部分。大约76秒,这也代表了表12所示分析中投影所需77.05秒的主要原因。这个昂贵的投影 与包含new_code_table列的子查询相关,其中更新了码表表示。由于此子查询的其他部分并不突出为潜在的成本高昂操作,很可能是在此CTE上下文中,向字典添加新代码/模式对的方法成本高昂。如第2.3.2节所讨论的,原始查询中用于表示码表的方法在DuckDB实现中不可用。可以想象,所选的替代方案性能不如原始方案。因此,下文探讨了一种实现码表更新的替代方法,并进行了实验以验证其是否有潜力提高性能。

检查码表中存储的键值发现,它们是整数值。如代码清单25所示,添加到表中的每个键的值是前一个键值加1。在这种情况下,键值可以轻松转换为有序数据结构的位置索引:

设𝑘为解压缩算法期间添加到码表的第一个键的整数值。那么下一个键的值为𝑘+1,再下一个为𝑘+2,依此类推。对于按添加顺序存储条目的数据结构,将每个键值减去𝑘−1可得到其位置索引(其中添加到结构的第一个条目的索引为1)。如第2.5.4节所讨论的,键值范围从0到"信息结束代码"值(eof_code)的初始码表条目实际上并未存储在字典中。相反,初始化或码表重置后添加的第一个键值是"信息结束代码"值加1(见代码清单25)。因此,𝑘−1对应于"信息结束代码"的值。从任何键值中减去它即可得到条目的位置索引。有了这种计算索引的简便方法,就不再需要存储键/值对。相反,颜色模式可以存储在列表中。只要新模式被追加到列表的末尾,使其按添加顺序出现,就可以使用从LZW代码值计算出的索引来访问它们。

通过比较两个递归查询(代码清单29)的性能,测试了这是否会带来任何好处:一个是将键/值对添加到类型为MAP(INT, INT[])的字典,另一个是将值添加到类型为INTEGER[][]的列表。为了模拟GIF解码器查询执行码表更新的方式,查询设计为从一个空的MAP或列表开始,并在每个递归步骤中添加一个条目,使其大小随着每次迭代而增加。为了更准确地表示码表更新,添加的列表长度也应增加。为了简化起见,查询的每次迭代将只添加一个长度为1的列表。

sql 复制代码
-- 代码清单29:添加𝑁个元素到MAP(左)或列表(右)的递归示例查询。
-- 左:MAP
WITH RECURSIVE map_update AS
(
    SELECT 0 AS count,
           MAP()::MAP(INT, INT[]) AS dict
    UNION ALL
    SELECT count + 1,
           MAP_CONCAT(dict, MAP {count: [42]})
    FROM map_update
    WHERE count < ⟨ N ⟩
)
SELECT dict, CARDINALITY(dict) AS "# entries"
FROM map_update
WHERE count = ⟨ N ⟩;

-- 右:列表
WITH RECURSIVE map_update AS
(
    SELECT 0 AS count,
           []::INT[][] AS dict
    UNION ALL
    SELECT count + 1,
           dict || [[42]]
    FROM map_update
    WHERE count < ⟨ N ⟩
)
SELECT dict, LEN(dict) AS "# entries"
FROM map_update
WHERE count = ⟨ N ⟩;

码表最大增长到4095个条目,除非清除代码导致提前重置。这对应于大小为4095 - eof_code的字典表示(不包括初始表条目)。然而,在达到4000个条目之前很久,就可以观察到两种存储表方法之间的性能差异。表13显示了为代码清单29中的两个查询对不同迭代次数𝑁运行EXPLAIN ANALYZE获得的运行时间。这些值是连续5次测量的平均值。

𝑁 = 500 𝑁 = 1000 𝑁 = 1500 𝑁 = 2000 𝑁 = 2500 𝑁 = 3000 𝑁 = 3500 𝑁 = 4000
MAP 0.32秒 2.52秒 6.65秒 15.40秒 28.62秒 44.69秒 61.26秒
列表 0.03秒 0.05秒 0.07秒 0.09秒 0.12秒 0.15秒 0.19秒

表13:递归添加𝑁个条目到MAP与添加到INTEGER[][]列表的平均运行时间

表13中呈现的结果在两个查询之间存在巨大差异。对于向MAP添加条目的递归查询,当迭代次数接近4000时,运行时间急剧增加,几乎达到一分半钟的标记。同时,向列表添加条目的递归查询的运行时间仅接近0.25秒。由于这些运行时间之间存在巨大差异(如图4所示),很明显,将码表表示更改为列表有很大的潜力来改善GIF解码器查询的性能。

图4:表13中值的散点图(使用MAP用 x 表示,使用列表用 o 表示)

将码表表示从MAP更改为类型INTEGER[][]相对容易,因为涉及的代码行不多。表14展示了涉及码表的表达式在两种数据类型下的比较。

操作 MAP码表 INTEGER[][]码表
初始化表 MAP()::MAP(INTEGER, INTEGER[]) []::INTEGER[][]
添加新模式 MAP_CONCAT(code_table, MAP {code: pattern}) `code_table
检索模式 code_table[code][1] code_table[table_index]

表14:使用MAP与列表涉及码表操作的SQL代码

最显著的区别是,从MAP中检索值使用提取的LZW代码值作为键,而列表表示的条目则使用索引进行访问,该索引必须首先从LZW代码计算得出。如前所述,这是通过减去"信息结束代码"值(eof_code)来实现的。为了完全匹配从MAP检索数据的功能(如表14所示),从列表检索条目时,当给定代码在码表表示中没有存储条目时,必须返回NULL。在GIF解码器查询中,所有长度为1的颜色模式的初始码表条目都会发生这种情况。由于它们没有存储在字典中,因此使用COALESCE函数在尝试从MAP检索条目返回NULL时返回长度为1的列表。

sql 复制代码
-- 代码清单30:摘自代码清单24:返回存储在MAP中或长度为1的颜色模式
COALESCE(code_table[code][1], LIST_VALUE(code))

但是,如果代码小于"信息结束代码"的值,则为表索引计算的值变为负数。小于0的索引不会导致列表访问返回NULL,而是从列表末尾开始计数[3]。将负表索引的值设置为0,反而会导致返回NULL的预期行为。这是使用GREATEST函数实现的,如代码清单31所示。

sql 复制代码
-- 代码清单31:将LZW代码转换为不变成负数的位置列表索引
SELECT GREATEST(0, code - eof_code) AS table_index

完成这些更改后,再次使用EXPLAIN ANALYZE观察查询的运行时间是否发生了变化。确实,对于同一个测试图像,时间已降至大约40秒的量级(所有性能优化的效果见表17)。虽然这快得多,但仍然比原始的PostgreSQL查询慢很多。根据DuckDB查询图工具的新性能分析结果(表15),剩余时间的大部分仍然在递归解压缩CTE中。投影操作的时间已大幅下降,现在不到1秒,但右定界连接哈希分组的成本仍然是整个PostgreSQL GIF解码器查询总运行时间的两倍多。

阶段 时间 百分比
总计时间 38.61秒 100%
递归CTE 38.01秒 98.45%
哈希分组 5.62秒 14.55%
右定界连接 5.43秒 14.07%
哈希连接 2.76秒 7.14%
投影 0.73秒 1.88%

表15:码表使用INT[][]表示的运行时分析

这些连接中成本最高的确实可以在解压缩CTE的递归步骤中找到,其FROM从句被实现为一行的LATERAL连接(参见第2.4.1节)。DuckDB在解关联连接方面表现更好[20],这一事实支持了这不会导致最佳性能的观点。

为了降低成本,对其FROM从句进行了重构,以减少LATERAL连接。将仅被引用一次的子查询中的代码移至该引用处。将不需要用于任何其他列的列定义向上移动到SELECT从句中。通过这些更改,FROM从句可以减少为三个表的连接:递归调用、包含算法参数的CTE以及一个嵌套的WITH子句(如果不强制执行物化,性能会更好)。查询图工具提供的新分析表16显示,这些措施实际上确实降低了哈希分组右定界连接的时间。

阶段 时间 百分比
总计时间 8.49秒 100%
递归CTE 7.97秒 93.91%
哈希分组 1.32秒 15.52%
右定界连接 0.88秒 10.35%
哈希连接 0.55秒 6.53%
投影 0.47秒 5.50%

表16:decompression_steps中减少LATERAL连接的运行时分析

这些更改导致最终平均运行时间略低于9秒(另见表17中的运行时间概览)。根据表16所示的分析,超过90%的时间仍然来自解压缩算法的递归CTE。仍有略多于1秒的时间是由decompression_steps中的哈希分组 操作符引起的。但是,即使此递归CTE子图中所有操作符的成本加起来也低于该CTE指示运行时间的一半。减少LATERAL连接使用的显著影响表明,性能较差的连接似乎也对递归CTE的整体运行时间产生不成比例的负面影响。除了递归步骤中相当复杂的FROM从句外,DuckDB实现仍然比PostgreSQL原始版本表现更差的另一个原因可能与以下事实有关:此查询的结构方式不允许DuckDB应用其策略来快速评估递归CTE。尽管DuckDB支持递归CTE中的并行化[17],但解压缩算法的迭代不能并行执行。每次迭代不仅依赖于前一次迭代的LZW代码和颜色模式,还依赖于码表的当前状态,这是算法所有先前迭代的结果。

版本 时间
可读性优化版 119.79秒
INT[][]码表版 38.39秒
INT[][]码表版 & 减少LATERAL连接版 8.86秒

表17:不同版本DuckDB GIF解码器查询的平均运行时间

2.6.3 结论

使用DuckDB的字典类型MAP来弥补HSTORE类型的不可用性,以及物化规则的不同,导致了性能显著下降。根据实现方式的不同,还观察到LATERAL连接阻碍了查询性能。这是因为它们影响了执行多次迭代的查询中最大CTE的递归步骤。由于无法对递归CTE的评估应用并行化,DuckDB无法针对此GIF解码器查询发挥其快速查询执行的全部潜力。然而,选择一种高效的方法来存储码表条目、根据DuckDB的优势设计连接模型以及显式物化CTE,可以显著提高查询的性能。

相关推荐
猫头虎6 小时前
基于信创openEuler系统安装部署OpenTeleDB开源数据库的实战教程
数据库·redis·sql·mysql·开源·nosql·database
kali-Myon6 小时前
2025春秋杯网络安全联赛冬季赛-day1
java·sql·安全·web安全·ai·php·web
数据知道6 小时前
PostgreSQL 性能优化:分区表实战
数据库·postgresql·性能优化
数据知道7 小时前
PostgreSQL 性能优化:如何提高数据库的并发能力?
数据库·postgresql·性能优化
数据知道7 小时前
PostgreSQL性能优化:内存配置优化(shared_buffers与work_mem的黄金比例)
数据库·postgresql·性能优化
yuanmenghao7 小时前
Linux 性能实战 | 第 10 篇 CPU 缓存与内存访问延迟
linux·服务器·缓存·性能优化·自动驾驶·unix
QT.qtqtqtqtqt8 小时前
SQL注入漏洞
java·服务器·sql·安全
数据知道8 小时前
PostgreSQL 性能优化:连接数过多的原因分析与连接池方案
数据库·postgresql·性能优化
数据知道8 小时前
PostgreSQL性能优化:如何定期清理无用索引以释放磁盘空间(索引膨胀监控)
数据库·postgresql·性能优化