← 文章 / 数据与数据库
Hacker News 4小时前 · 2026-09-17 11:21:25 · 1 阅读

训练 4B 模型生成比 Postgres 快 81% 的查询计划

Leis 等人早在 2015 年就提出了这个问题。十年后的今天,他们再次提出了同样的疑问

尽管自最初探索以来已经积累了十年浩繁的研究成果,他们发现查询优化器仍远未令人满意。

初次得知这一点时,我感到有些意外。Postgres 数据库理应对表中存储的所有数据了如指掌,不是吗?这难道很难做到吗?

事实表明:难度极高。实际上,查询优化器需要执行的一项特定任务——连接顺序规划(join ordering)——已知是 NP 困难问题

查询优化器确实很难,但验证优化器选择的查询计划是否优秀却相对容易。简单来说,优秀的查询优化器会生成运行高效的计划,而糟糕的则会生成缓慢的计划。语言模型尤其擅长学习那些输出易于验证的任务。由于优化的唯一维度是查询的执行时间,这个问题完美地转化为通过强化学习来激励模型生成更快查询计划的行为。

下文将拆解我进行的一项实验,旨在探索:能否对一个小型开放权重模型进行监督微调(SFT)和基于智能体的强化学习(RL)后训练,使其生成的 Postgres 查询计划优于 Postgres 的默认计划?

对于这个问题,答案是肯定的,且效果显著。亮点包括:

  • 初始无法为 113 条连接密集型查询中的 99 条生成计划的一个 4B 模型,经过训练后实现了 44.7% 的延迟降低
  • 构建了一套 Postgres 测量基准,以最小化并发容器间 Linux 页缓存争用的噪声影响
  • 设计了一种自定义的 GRPO 变体,用于在本质上充满噪声的环境中为强化学习 rollout 打分
  • 将强化学习任务拆分至两台机器:在一台租赁的 2x H100 节点上运行 vLLM 和训练器,同时在桌面四台机器上运行四个 Postgres 容器
  • 对五百条 GPT-6 Astra 智能体轨迹执行了离策略蒸馏

让我们从头说起。

深入查询优化器内部

考虑 IMDb 数据集 的以下片段:

-- IMDb 的 title 表(电影、剧集、单集等)[约 100 万行]
title (
  id              integer PRIMARY KEY,
  title           text,
  production_year integer,
  kind_id         integer -- 外键 -> kind_type
)

-- 电影 <> 公司 的关联表 [约 200 万行]
movie_companies (
  id              integer PRIMARY KEY,
  movie_id        integer, -- 外键 -> title.id
  company_id      integer, -- 外键 -> company_name.id
  company_type_id integer, -- 外键 -> company_type.id
  note            text
)

-- 公司名称、来源等信息 [约 10 万行]
company_name (
  id           integer PRIMARY KEY,
  name         text,
  country_code text     -- '[us]'、'[jp]' 等
)

-- 某部作品的公司角色查找表 [4 行]
company_type (
  id   integer PRIMARY KEY,
  kind text -- 'production companies'、'distributors' 等
)

-- 作品类型查找表 [7 行]
kind_type (
  id   integer PRIMARY KEY,
  kind text -- 'movie'、'tv series'、'episode' 等
)

假设我想回答这样一个问题:“2000 年代出产作品最多的日本公司有哪些?”我们可能会写出这样的查询:

SELECT cn.name,
       COUNT(*) AS titles
FROM   title AS t,
       movie_companies AS mc,
       company_name AS cn
WHERE  t.id = mc.movie_id
  AND  mc.company_id = cn.id
  AND  cn.country_code = '[jp]'
  AND  t.production_year BETWEEN 2000 AND 2009
GROUP  BY cn.name
ORDER  BY titles DESC
LIMIT  10;

这条查询会返回 10 家日本公司,以及它们在 2000 到 2009 年间关联的作品数量,按从高到低排序。

但 Postgres 是如何得出这些结果的呢?

Postgres 获取数据的路径并非固定不变,它很大程度上取决于我们所说的选择性谓词(也就是 WHERE 子句里的过滤条件)。

为了说明这一点,我们去掉日本公司过滤条件和年份范围过滤条件,再看这条查询:

SELECT cn.name,
       COUNT(*) AS titles
FROM   title AS t,
       movie_companies AS mc,
       company_name AS cn
WHERE  t.id = mc.movie_id
  AND  mc.company_id = cn.id
GROUP  BY cn.name
ORDER  BY titles DESC
LIMIT  10;

mc 只能通过 mc.company_id = cn.idcn 连接,t 只能通过 t.id = mc.movie_idmc 连接。

这些约束条件产生两种 严格来说,如果考虑结合律,八种 join 树。但在这里我们不这样做,因为这不会影响 join 结果关系的大小。 有效的 join 树:

针对此查询的两种 join 树。底部的 join 先执行;其结果作为根 join 的输入。

表或查询结果的 cardinality(基数)指其包含的行数。假设相关表的基数如下:

  1. $cn = 100\text{k}$
  2. $mc = 2\text{m}$
  3. $t = 1\text{m}$

考虑到我们的 join,我们得到以下基数:

$$(cn \bowtie mc) = 2\text{m}, \text{ then } \bowtie t = 2\text{m}$$ $$(t \bowtie mc) = 2\text{m}, \text{ then } \bowtie cn = 2\text{m}$$

无论这三张表以何种顺序进行 join,总是有 2m 行进入第二个 join。

现在让我们加回选择性谓词:

  1. $cn' = 5\text{k}$(假设 100k 家公司中有 5% 是日本公司)
  2. $mc = 2\text{m}$(不变)
  3. $t' = 200\text{k}$(假设 1m 个标题中有 20% 制作于 2000 年代)
$$(cn' \bowtie mc) \approx 100\text{k}, \text{ then } \bowtie\ t' \approx 20\text{k}$$ $$(t' \bowtie mc) \approx 400\text{k}, \text{ then } \bowtie\ cn' \approx 20\text{k}$$

第一种 join 顺序将 2m 条 movie_companies 记录筛选为日本公司的 5% 切片。假设均匀分布(我们稍后会讨论 为什么 做此假设),该 join 产生大约 100k 行。将结果与筛选后的 title 表进行 join 仅保留 2000 年代的 20% 记录。

第二种 join 顺序将 2m 条 movie_companies 记录筛选为 2000 年代制作标题的 20% 切片。同样假设均匀分布,第一个 join 产生 400k 行,这意味着我们将 400k 行传递给第二个 join。

如果我们选择第二种 join 顺序,工作量将是 4 倍。

不幸的是,事情并没有到此为止。

组合爆炸

每个 join 可以使用以下任意一种:

  1. Hash join
  2. Merge join
  3. Nested-loop join

现在重新引入交换律的影响。虽然交换律不会改变行数,但必须考虑它,因为它会影响所使用的连接算法,从而影响性能。因此,存在 4 种外连接/内连接的方向组合,共 8 种可能情况:

$(cn \bowtie mc) \bowtie t$
$t \bowtie (cn \bowtie mc)$

$(mc \bowtie cn) \bowtie t$
$t \bowtie (mc \bowtie cn)$

$(t \bowtie mc) \bowtie cn$
$cn \bowtie (t \bowtie mc)$

$(mc \bowtie t) \bowtie cn$
$cn \bowtie (mc \bowtie t)$

最后,每张表的扫描方式也有所不同。仅考虑四种扫描类型:

  1. 顺序扫描
  2. 索引扫描
  3. 仅索引扫描
  4. 位图扫描

2 种连接树:决定哪两张表先连接。 × 2² 种方向:两次连接中,每次都可交换内外表角色。 × 3² 种算法:两次连接中,每次均可选择 Hash、Merge 或 Nested Loop。 × 4³ 种扫描:三张表各自可选顺序、索引、仅索引或位图扫描。 = 4,608

执行这条查询共有 4,608 种不同方式。这其实还是低估了。计划可以并行执行,聚合可以使用哈希或排序等方式。

值得注意的是,Postgres 并不会评估所有这些计划。它使用动态规划(对于涉及 12 个以上连接的查询,使用遗传算法)来剪枝搜索空间。

更糟糕的是,每增加一个连接,搜索空间都会组合式地爆炸:

SELECT cn.name,
       COUNT(*) AS titles
FROM   movie_companies AS mc,
       company_name AS cn
WHERE  mc.company_id = cn.id
  AND  cn.country_code = '[jp]'
GROUP  BY cn.name
ORDER  BY titles DESC
LIMIT  10;

1 种连接树:只有两张表时,连接方式唯一。 × 2¹ 种方向:一次连接只有 2 种内外表方向。 × 3¹ 种算法:可选择 Hash Join、Merge Join 或 Nested Loop。 × 4² 种扫描:两张表各自可选顺序、索引、仅索引或位图扫描。 = 96

SELECT cn.name,
       COUNT(*) AS titles
FROM   title AS t,
       movie_companies AS mc,
       company_name AS cn
WHERE  t.id = mc.movie_id
  AND  mc.company_id = cn.id
  AND  cn.country_code = '[jp]'
  AND  t.production_year BETWEEN 2000 AND 2009
GROUP  BY cn.name
ORDER  BY titles DESC
LIMIT  10;

2 Join 树:3 张表在交换输入之前的连接方式数量。 × 2² 方向:2 个 join 各自可以交换内外侧输入。 × 3² 算法:2 个 join 各自可选择 hash、merge 或 nested loop。 × 4³ 扫描:3 张表各自可顺序读取,或通过 index、index-only、bitmap 扫描。 = 4,608

SELECT MIN(t.title) AS movie_title
FROM keyword AS k,
     movie_info AS mi,
     movie_keyword AS mk,
     title AS t
WHERE k.keyword LIKE '%sequel%'
  AND mi.info IN ('Bulgaria')
  AND t.production_year > 2010
  AND t.id = mi.movie_id
  AND t.id = mk.movie_id
  AND mk.movie_id = mi.movie_id
  AND k.id = mk.keyword_id;

8 Join 树:4 张表在交换输入之前的连接方式数量。 × 2³ 方向:3 个 join 各自可以交换内外侧输入。 × 3³ 算法:3 个 join 各自可选择 hash、merge 或 nested loop。 × 4⁴ 扫描:4 张表各自可顺序读取,或通过 index、index-only、bitmap 扫描。 = 442,368

SELECT MIN(t.title) AS movie_title
FROM company_name AS cn,
     keyword AS k,
     movie_companies AS mc,
     movie_keyword AS mk,
     title AS t
WHERE cn.country_code ='[de]'
  AND k.keyword ='character-name-in-title'
  AND cn.id = mc.company_id
  AND mc.movie_id = t.id
  AND t.id = mk.movie_id
  AND mk.keyword_id = k.id
  AND mc.movie_id = mk.movie_id;

25 Join 树:5 张表在交换输入之前的连接方式数量。 × 2⁴ 方向:4 个 join 各自可以交换内外侧输入。 × 3⁴ 算法:4 个 join 各自可选择 hash、merge 或 nested loop。 × 4⁵ 扫描:5 张表各自可顺序读取,或通过 index、index-only、bitmap 扫描。 = 33,177,600

SELECT MIN(lt.link) AS link_type,
       MIN(t1.title) AS first_movie,
       MIN(t2.title) AS second_movie
FROM keyword AS k,
     link_type AS lt,
     movie_keyword AS mk,
     movie_link AS ml,
     title AS t1,
     title AS t2
WHERE k.keyword ='10,000-mile-club'
  AND mk.keyword_id = k.id
  AND t1.id = mk.movie_id
  AND ml.movie_id = t1.id
  AND ml.linked_movie_id = t2.id
  AND lt.id = ml.link_type_id
  AND mk.movie_id = t1.id;

56 Join trees: 在交换输入表之前,6 张表的所有可能连接拓扑结构。 × 25 Orientations: 每个连接都决定哪张表作为外表,哪张表作为内表(可互换方向)。 × 35 Algorithms: 每个连接可选择 Hash、Merge 或 Nested Loop 三种算法之一。 × 46 Scans: 每张表可选择顺序扫描或索引扫描(包括 Index-Only Scan 和 Bitmap Scan)。 = 1,783,627,776

SELECT MIN(a1.name) AS writer_pseudo_name,
       MIN(t.title) AS movie_title
FROM aka_name AS a1,
     cast_info AS ci,
     company_name AS cn,
     movie_companies AS mc,
     name AS n1,
     role_type AS rt,
     title AS t
WHERE cn.country_code ='[us]'
  AND rt.role ='writer'
  AND a1.person_id = n1.id
  AND n1.id = ci.person_id
  AND ci.movie_id = t.id
  AND t.id = mc.movie_id
  AND mc.company_id = cn.id
  AND ci.role_id = rt.id
  AND a1.person_id = ci.person_id
  AND ci.movie_id = mc.movie_id;

696 Join trees: 在交换输入表之前,7 张表的所有可能连接拓扑结构。 × 26 Orientations: 每个连接都决定哪张表作为外表,哪张表作为内表(可互换方向)。 × 36 Algorithms: 每个连接可选择 Hash、Merge 或 Nested Loop 三种算法之一。 × 47 Scans: 每张表可选择顺序扫描或索引扫描(包括 Index-Only Scan 和 Bitmap Scan)。 = 532,030,685,184

SELECT MIN(an.name) AS cool_actor_pseudonym,
       MIN(t.title) AS series_named_after_char
FROM aka_name AS an,
     cast_info AS ci,
     company_name AS cn,
     keyword AS k,
     movie_companies AS mc,
     movie_keyword AS mk,
     name AS n,
     title AS t
WHERE cn.country_code ='[us]'
  AND k.keyword ='character-name-in-title'
  AND an.person_id = n.id
  AND n.id = ci.person_id
  AND ci.movie_id = t.id
  AND t.id = mk.movie_id
  AND mk.keyword_id = k.id
  AND t.id = mc.movie_id
  AND mc.company_id = cn.id
  AND an.person_id = ci.person_id
  AND ci.movie_id = mc.movie_id
  AND ci.movie_id = mk.movie_id
  AND mc.movie_id = mk.movie_id;

4,698 连接树:8 张表在输入交换前的所有连接方式。 × 27 方向:7 个连接中每个都可以交换内外表的输入方向。 × 3^7 算法:7 个连接中每个都选择哈希、归并或嵌套循环连接。 × 4^8 扫描:8 张表中每张都可以选择顺序扫描或索引、仅索引、位图扫描。 = 86,188,970,999,808

SELECT MIN(cn.name) AS producing_company,
       MIN(miidx.info) AS rating,
       MIN(t.title) AS movie
FROM company_name AS cn,
     company_type AS ct,
     info_type AS it,
     info_type AS it2,
     kind_type AS kt,
     movie_companies AS mc,
     movie_info AS mi,
     movie_info_idx AS miidx,
     title AS t
WHERE cn.country_code ='[us]'
  AND ct.kind ='production companies'
  AND it.info ='rating'
  AND it2.info ='release dates'
  AND kt.kind ='movie'
  AND mi.movie_id = t.id
  AND it2.id = mi.info_type_id
  AND kt.id = t.kind_id
  AND mc.movie_id = t.id
  AND cn.id = mc.company_id
  AND ct.id = mc.company_type_id
  AND miidx.movie_id = t.id
  AND it.id = miidx.info_type_id
  AND mi.movie_id = miidx.movie_id
  AND mi.movie_id = mc.movie_id
  AND miidx.movie_id = mc.movie_id;

20,340 连接树:9 张表在输入交换前的所有连接方式。 × 2^8 方向:8 个连接中每个都可以交换内外表的输入方向。 × 3^8 算法:8 个连接中每个都选择哈希、归并或嵌套循环连接。 × 4^9 扫描:9 张表中每张都可以选择顺序扫描或索引、仅索引、位图扫描。 = 8,955,727,561,359,360

SELECT MIN(n.name) AS voicing_actress,
       MIN(t.title) AS jap_engl_voiced_movie
FROM aka_name AS an,
     char_name AS chn,
     cast_info AS ci,
     company_name AS cn,
     info_type AS it,
     movie_companies AS mc,
     movie_info AS mi,
     name AS n,
     role_type AS rt,
     title AS t
WHERE ci.note IN ('(voice)',
                  '(voice: Japanese version)',
                  '(voice) (uncredited)',
                  '(voice: English version)')
  AND cn.country_code ='[us]'
  AND it.info = 'release dates'
  AND n.gender ='f'
  AND rt.role ='actress'
  AND t.production_year > 2000
  AND t.id = mi.movie_id
  AND t.id = mc.movie_id
  AND t.id = ci.movie_id
  AND mc.movie_id = ci.movie_id
  AND mc.movie_id = mi.movie_id
  AND mi.movie_id = ci.movie_id
  AND cn.id = mc.company_id
  AND it.id = mi.info_type_id
  AND n.id = ci.person_id
  AND rt.id = ci.role_id
  AND n.id = an.person_id
  AND ci.person_id = an.person_id
  AND chn.id = ci.person_role_id;

242,160 种 join 树:10 张表可能的连接方式(还没考虑交换输入顺序)。 × 29 种方向:9 次 join 各自可以交换外层和内层输入。 × 39 种算法:9 次 join 各自选择 hash、merge 或 nested loop。 × 410 种扫描:10 张表各自可以选择顺序扫描,或者 index、index-only、bitmap 扫描。 = 2,558,960,455,762,575,360

1,490,850 种连接树:11 张表在输入未交换时的所有连接方式。 × 2^10 种方向:每个 10 个连接均可选择外输入和内输入。 × 3^10 种算法:每个连接可在 Hash、Merge 或 Nested Loop 中三选一。 × 4^11 种扫描方式:11 张表均可通过顺序读取、索引、仅索引或位图扫描获取数据。 = 378,099,722,048,923,238,400

Training a 4B model to produce 81% faster query plans than Postgres

SELECT MIN(chn.name) AS character_name,
       MIN(mi_idx.info) AS rating,
       MIN(t.title) AS complete_hero_movie
FROM complete_cast AS cc,
     comp_cast_type AS cct1,
     comp_cast_type AS cct2,
     char_name AS chn,
     cast_info AS ci,
     info_type AS it2,
     keyword AS k,
     kind_type AS kt,
     movie_info_idx AS mi_idx,
     movie_keyword AS mk,
     name AS n,
     title AS t
WHERE cct1.kind = 'cast'
  AND cct2.kind LIKE '%complete%'
  AND chn.name IS NOT NULL
  AND (chn.name LIKE '%man%'
       OR chn.name LIKE '%Man%')
  AND it2.info = 'rating'
  AND k.keyword IN ('superhero',
                    'marvel-comics',
                    'based-on-comic',
                    'fight')
  AND kt.kind = 'movie'
  AND mi_idx.info > '8.0'
  AND t.production_year > 2005
  AND kt.id = t.kind_id
  AND t.id = mk.movie_id
  AND t.id = ci.movie_id
  AND t.id = cc.movie_id
  AND t.id = mi_idx.movie_id
  AND mk.movie_id = ci.movie_id
  AND mk.movie_id = cc.movie_id
  AND mk.movie_id = mi_idx.movie_id
  AND ci.movie_id = cc.movie_id
  AND ci.movie_id = mi_idx.movie_id
  AND cc.movie_id = mi_idx.movie_id
  AND chn.id = ci.person_role_id
  AND n.id = ci.person_id
  AND k.id = mk.keyword_id
  AND cct1.id = cc.subject_id
  AND cct2.id = cc.status_id
  AND it2.id = mi_idx.info_type_id;

11,932,560 Join trees: the ways 12 tables can be joined up, before any swapping of inputs. × 211 Orientations: each of the 11 joins can swap which input is outer and which is inner. × 311 Algorithms: each of the 11 joins picks hash, merge, or nested loop. × 412 Scans: each of the 12 tables is either read sequentially or via index, index-only or bitmap scans. = 72,630,206,166,931,876,085,760 lots!

2 3 4 5 6 7 8 9 10 11 12
Click to select the number of tables being joined together. From four tables onwards, queries on the left are from JOB. On the right is a rough estimate of the size of the search space.

Estimating, not counting

Postgres is in a tough spot here. It would be reasonable to think it could simply count cardinalities and pick the plan that minimizes the number of rows passed through to successive joins.

但这意味着 Postgres 在查询规划时能够统计基数。其实不能。要做到这一点,它得真正执行每一次 join 并数出结果行数,这就完全违背了快速查询优化器的初衷。查询优化器并不追求代价最小化的精确……它追求的是在各类查询上都“足够好”。 所以 Postgres 用统计信息来估算基数。规划器查询 pg_statistic 表,拿到每列的高频值及其出现频率,其余值则用直方图表示。一旦涉及 join,事情就复杂了:Postgres 并不知道一张表的行在另一张表上是如何分布的。为了绕过这个问题,它直接假设第一张表中某值的出现频率同样适用于第二张表——这就是我前面提到的均匀分布假设。 作为启发式方法,均匀分布假设没什么问题,可一旦失准,就会错得离谱。回头看之前的 join 顺序 $(cn' \bowtie mc) \approx 100\text{k}, \text{ then } \bowtie\ t' \approx 20\text{k}$:我们先按“5% 的记录来自日本公司”的假设过滤了 200 万条 movie_companies 记录。但如果那 5% 的日本公司实际上参与了 50% 的电影呢?第一个 join 就会产出 100 万行!代价模型会让我们选第一种 join 顺序,而实际上第二种更好,因为它只有 40 万行流向第二个 join。
Postgres 假设 - 10 万行 实际 - 10 万行 属于日本公司的 movie_companies 记录占比: 5%(均匀分布)
拖动滑块提高日本公司的产量,观察 Postgres 的估算保持不变,而实际行数随之变化。
早期 join 中的一个糟糕估算会沿着 join 树级联传播,污染后续所有估算。

如何驾驭这头大象

Postgres 总是选择代价最低的执行计划,而它的代价模型只有改源码才能改。那我们怎么引导它去选那些代价更高、但实际更好的计划呢? 答案是 pg_hint_plan

pg_hint_plan 是一个设计得简洁优雅的第三方扩展:只需在 SQL 语句上方添加结构化的“提示”(hints)注释,就能引导 Postgres 采用符合提示指令的查询计划。例如:

/*+
The hint block. It is an ordinary SQL comment with a leading +, so Postgres ignores it and pg_hint_plan reads it.
HashJoin(a b) Join a and b using a hash join.
SeqScan(a) Read table a with a sequential scan rather than an index.
*/

EXPLAIN
-- EXPLAIN prints the plan Postgres would use instead of running the query.
SELECT *
  FROM pgbench_branches b
  JOIN pgbench_accounts a ON b.bid = a.bid
  ORDER BY a.aid;
                                   QUERY PLAN
--------------------------------------------------------------------------------
 Sort
    The root of the plan. Rows flow upward, so this runs last: it orders the joined rows by a.aid.
   (cost=31465.84..31715.84 rows=100000 width=197)
   Postgres’s estimates for this node: startup cost..total cost, estimated rows out, and average row width in bytes.
   Sort Key: a.aid
   ->  Hash Join
      The join method the HashJoin(a b) hint asked for.
     (cost=1.02..4016.02 rows=100000 width=197)
        Hash Cond: (a.bid = b.bid)
        The join condition, taken from the ON clause.
        ->  Seq Scan on pgbench_accounts a
           The scan method the SeqScan(a) hint asked for. This is the probe side: each row looks up a match in the hash table.
          (cost=0.00..2640.00 rows=100000 width=97)
        ->  Hash
           The build side. The small table is read first and loaded into an in-memory hash table keyed on bid.
          (cost=1.01..1.01 rows=1 width=100)
               ->  Seq Scan on pgbench_branches b
                (cost=0.00..1.01 rows=1 width=100)
(7 rows)

示例来自 pg_hint_plan文档

该提示强制要求在连接 pgbench_accountspgbench_branches 时使用 HashJoin,并对 pgbench_accounts 表执行顺序扫描;实际的查询计划也很好地遵循了这一指令。

定义问题

既然我们可以利用 pg_hint_plan 的提示来影响 Postgres 选择不同(甚至更优)的查询计划,那么我们要探讨的初始问题是:

语言模型能否学会生成能产生更优查询计划的提示?

相关研究

为什么这个问题值得投入精力去解决?

我最初的思路是将查询语句和 Postgres 查询规划器所用的完全相同的信息集提供给模型。这本质上是在探究能否构建更优的基数估算器。但我不认为这是一条值得探索的路径,因为那意味着我们要去挑战数十年的基数估算研究成果。此外,仅推理延迟这一项成本,就远超模型相比 Postgres 极速查询优化器所能带来的任何收益。

第二个想法——我认为也是正确的解题方式——基于特定的数据库使用模式:重度分析型负载。当查询以次优的 Postgres 默认执行计划被运行成千上万次时,效率收益正在白白流失。相反,我们可以训练模型为特定查询寻找更优的执行方式。尽管训练过程可能需要预先执行该查询几十甚至上百次,但将所有查询运行摊薄后的成本将大幅降低。

目标并非试图在一次性查询的时间/效率帕累托前沿上战胜 Postgres,但我们有望在反复执行的查询上实现超越。

模型及其框架

我决定从一个小型的 4B 模型入手,因为我可以在家里那台配有两张 RTX 3090 的机器(名叫 FLOPper)上最轻松地自行完成训练和推理。

在我启动这个项目的同时,Qwen 3.8 系列模型发布了,但不幸的是没有 4B 版本。不过,我发现了德国一家名为 Empero 的小型实验室开发的 Qwen 3.8 4B 蒸馏模型,并对此颇感兴趣。他们以 Qwen 3.8 的 2.4T 模型作为教师模型,将其知识蒸馏到 Qwen 3.5 4B 中,从而生成了 empero-ai/Qwen3.8-4B-Distill。这款蒸馏模型并非全面优于其 3.5 基座模型:它在 MMLU 任务上表现更佳,而在 GSM8K 任务上稍逊一筹。换言之,这种蒸馏策略在评估通用知识广度时表现更好,但在多步数学推理上略逊。至于哪种更适用于我们的任务,目前尚不明确,但我最终决定使用这款蒸馏模型。

模型确定后,我搭建了一个轻量级的 agent 框架 qo-agent,用来编排 hint 的生成过程。它有下面六个工具:
  1. inspect_relation — 列出表的所有列(含类型和是否可空)、索引定义,以及预估行数和字节数
  2. get_column_stats — 获取 Postgres 对某个 relation 中 1-8 个列的 planner 统计信息
  3. get_plan — 获取默认计划的预估结果,或某个已提交候选计划的执行计划
  4. evaluate_candidate — 校验提议的计划操作,然后实际执行以获得耗时和计划诊断信息
  5. keep_default — 直接采用 Postgres 的默认计划作为候选,结束搜索
  6. finish — 接收一个已提交的候选 ID 或默认计划,结束搜索

为了利用结构化输出,agent 被要求生成 PlanAction JSON 对象。调用 evaluate_candidate 时,系统会把 PlanAction 对象编译成 hint,并前置到原始查询之前。

下面是一个 agent 的执行轨迹示例:

Agent →A tool call made by the model. get_plan("default")Ask the harness for the plan Postgres picks on its own. This returns the planner’s estimates only; nothing is executed.Tool  ←The harness’s reply, fed back into the model’s context. Default plan: (t ⋈ mc) ⋈ cn, hash joins, estimated rows per node Agent → evaluate_candidate({Propose one PlanAction. The harness validates it with a plain EXPLAIN, compiles it to hints, and, with execution feedback on, runs the query once after a warmup and reports the timing. "leading": { "left": { "left": "cn", "right": "mc" }, "right": "t" },The join tree: join cn with mc first, then join the result with t. This is the first tree from the figure earlier. "joins": [{ "relations": ["cn", "mc"], "force": "hash" }]Force a hash join for the cn ⋈ mc step. })Tool  ← c1The id the harness assigns to this candidate. get_plan and finish refer to candidates by id. · valid · novel plan · 118 ms · 0.91× defaultExecution feedback: the candidate’s measured time and its preliminary ratio to the default’s median. Below 1 means slower than Postgres. · 2 attempts leftEvery submission, valid or not, consumes one of the rollout’s candidate attempts. Agent → evaluate_candidate({ "leading": { "left": { "left": "t", "right": "mc" }, "right": "cn" },Try the other tree: t with mc first, then cn. "joins": [{ "relations": ["t", "mc"], "force": "nestloop" }],A nested loop for the first join, so the filtered t rows probe mc one at a time. "scans": [{ "relation": "mc", "force": "index" }]Make the inner side of that loop an index scan on mc. })Tool  ← c2 · valid · novel plan · 87 ms · 1.24× defaultFaster than the default: a ratio above 1 is a speedup. · 1 attempt left Agent → evaluate_candidate({ "leading": { "left": { "left": "t", "right": "mc" }, "right": "cn" }, "joins": [{ "relations": ["t", "mc"], "force": "hash" }]Same tree as c2, but a hash join for the first step, to see whether the nested loop was the win. })Tool  ← c3 · valid · novel plan · 100 ms · 1.08× default · 0 attempts leftThe candidate budget is spent. From here the only tool the model is offered is finish. Agent → finish({ "selected_candidate_id": "c2" })End the search by naming a candidate, including an earlier one. The harness then measures it against the default with the final paired protocol.Tool  ← Finished · selected c2
一条示例轨迹,Agent 最多可提交三个候选方案。

基准测试

没有基准测试,Agent 的性能就无从评估。幸运的是,构建这些基准测试的繁重工作已经完成。

连接顺序基准测试

Leis 等人在论文《How Good Are Query Optimizers, Really?》中提出了连接顺序基准测试(JOB)。他们利用它来评估基数估算和连接顺序优化,使用的正是我们熟悉的 IMDb 数据集。

该基准包含 113 条查询,分布于 33 种查询模板中。不同模板的区别在于关系骨架,它们引用不同的表,并通过不同的连接谓词将表关联起来。可以将其理解为一组具有结构相似性的问题族。由模板生成的查询保留原有的表和连接图拓扑结构,但会改变选择谓词。

以下是一个示例:

SELECT MIN(t.title) AS movie_title
FROM   company_name AS cn
JOIN   movie_companies AS mc ON mc.company_id = cn.id
JOIN   title AS t ON t.id = mc.movie_id
JOIN   movie_keyword AS mk ON mk.movie_id = t.id
JOIN   keyword AS k ON k.id = mk.keyword_id
WHERE  cn.country_code = :country_code
  AND  k.keyword = 'character-name-in-title';

查询模板 2 —— “与来自 X 国家且带有 character-name-in-title 关键词的公司相关联的电影中,字母序最靠前的片名是什么?”

…以下是由该模板生成的两条实际 JOB 查询:

SELECT MIN(t.title) AS movie_title
FROM   company_name AS cn,
       keyword AS k,
       movie_companies AS mc,
       movie_keyword AS mk,
       title AS t
WHERE  cn.country_code = '[de]'
  AND  k.keyword = 'character-name-in-title'
  AND  cn.id = mc.company_id
  AND  mc.movie_id = t.id
  AND  t.id = mk.movie_id
  AND  mk.keyword_id = k.id
  AND  mc.movie_id = mk.movie_id;

查询 2a —— “与德国公司相关联且满足上述条件的电影中,字母序最靠前的片名是什么?”

SELECT MIN(t.title) AS movie_title
FROM   company_name AS cn,
       keyword AS k,
       movie_companies AS mc,
       movie_keyword AS mk,
       title AS t
WHERE  cn.country_code = '[us]'
  AND  k.keyword = 'character-name-in-title'
  AND  cn.id = mc.company_id
  AND  mc.movie_id = t.id
  AND  t.id = mk.movie_id
  AND  mk.keyword_id = k.id
  AND  mc.movie_id = mk.movie_id;

查询 2d ——“与美国公司相关、按字母顺序排最前的电影片名是哪一部?”

基数估计基准(Cardinality Estimation Benchmark)

另一个相关基准是基数估计基准(CEB),出自论文 Flow-loss: Learning Cardinality Estimates That Matter。它同样基于 IMDb 数据库,但规模大得多——由约 13.6k 条合成查询组成,分属 16 个查询模板 CEB 对模板的定义比 JOB 更宽松。两个 CEB 模板可以共享相同的 join graph,仅在选择性谓词上不同。而在 JOB 中,每个模板的 join graph 都是唯一的。 。

训练集与测试集

CEB 体量足够大,很适合用来训练模型;JOB 则用来验证模型的表现。

你可能会疑惑:训练和测试都用 IMDb 数据库,这合理吗?如果效果不错,模型是不是只是学会了这一个特定数据库?

我认为这恰恰是关键所在。我们要的就是让模型充分学会 IMDb。按照我们的问题设定,如果这个 agent 是持续服务于某家公司、针对其特定数据库的分析负载,那就不需要对所有数据库都泛化。

真正需要担心的是,在 CEB 上训练时不要过拟合到 JOB 的查询模板。模型应该以这样的方式学会 IMDb:面对任何查询,即使是之前没见过的结构性查询类型,也能生成不错的执行计划。实际操作上,这意味着我们要从 CEB 中剔除与任何 JOB 查询结构相同的查询。

查询拓扑映射

我们把查询的“拓扑”定义为它的结构性 join graph(去别名的表名作为节点,join 作为边)。join graph 不包含任何选择性谓词,这里我们只关心 join。

如果某些 CEB 查询与 JOB 查询共享相同拓扑结构,它们就会被从训练集中移除。我写了一个小脚本,将所有 JOB 和 CEB 查询转换为拓扑结构并检查是否存在重叠。结果发现没有重叠,因此无需进行过滤。

JOB:113 个查询,33 个模板,33 种拓扑结构

  • JOB 1 · 4 个查询 · 5 张表 1
  • JOB 2 · 4 个查询 · 5 张表 2
  • JOB 3 · 3 个查询 · 4 张表 3
  • JOB 4 · 3 个查询 · 5 张表 4
  • JOB 5 · 3 个查询 · 5 张表 5
  • JOB 6 · 6 个查询 · 5 张表 6
  • JOB 7 · 3 个查询 · 8 张表 7
  • JOB 8 · 4 个查询 · 7 张表 8
  • JOB 9 · 4 个查询 · 8 张表 9
  • JOB 10 · 3 个查询 · 7 张表 10
  • JOB 11 · 4 个查询 · 8 张表 11
  • JOB 12 · 3 个查询 · 8 张表 12
  • JOB 13 · 4 个查询 · 9 张表 13
  • JOB 14 · 3 个查询 · 8 张表 14
  • JOB 15 · 4 个查询 · 9 张表 15
  • JOB 16 · 4 个查询 · 8 张表 16
  • JOB 17 · 6 个查询 · 7 张表 17
  • JOB 18 · 3 个查询 · 7 张表 18
  • JOB 19 · 4 个查询 · 10 张表 19
  • JOB 20 · 3 个查询 · 10 张表 20
  • JOB 21 · 3 个查询 · 9 张表 21
  • JOB 22 · 4 个查询 · 11 张表 22
  • JOB 23 · 3 个查询 · 11 张表 23
  • JOB 24 · 2 个查询 · 12 张表 24
  • JOB 25 · 3 个查询 · 9 张表 25
  • JOB 26 · 3 个查询 · 12 张表 26
  • JOB 27 · 3 个查询 · 12 张表 27
  • JOB 28 · 3 个查询 · 14 张表 28
  • JOB 29 · 3 个查询 · 17 张表 29
  • JOB 30 · 3 个查询 · 12 张表 30
  • JOB 31 · 3 个查询 · 11 张表 31
  • JOB 32 · 2 个查询 · 6 张表 32
  • JOB 33 · 3 个查询 · 14 张表 33

CEB:13,646 个查询,16 个模板,12 种拓扑结构

  • CEB 1a · 3,000 个查询 · 9 张表 1a
  • CEB 2a · 888 个查询 · 11 张表 2a
  • CEB 2b · 500 个查询 · 11 张表 2b
  • CEB 2c · 298 个查询 · 9 张表 2c
  • CEB 3a · 1,383 个查询 · 10 张表 3a
  • CEB 3b · 256 个查询 · 10 张表 3b
  • CEB 4a · 516 个查询 · 6 张表 4a
  • CEB 5a · 1,014 个查询 · 10 张表 5a
  • CEB 6a · 465 个查询 · 14 张表 6a
  • CEB 7a · 167 条查询 · 16 张表 7a
  • CEB 8a · 515 条查询 · 12 张表 8a
  • CEB 9a · 2,247 条查询 · 9 张表 9a
  • CEB 9b · 537 条查询 · 9 张表 9b
  • CEB 10a · 1,019 条查询 · 7 张表 10a
  • CEB 11a · 491 条查询 · 10 张表 11a
  • CEB 11b · 350 条查询 · 12 张表 11b

上述 JOB 和 CEB 模板以其拓扑结构生成的 Identicon 可视化结果。

如何让大象“哑声”

在开始基准测试智能体和执行训练之前,我们必须先讨论 Postgres 实际是如何运行的,因为这将直接影响训练过程。

先看几个基本事实:

  • FLOPper 的硬件配置为 16 核物理 CPU、64 GB 内存和 2 TB NVMe SSD
  • 我们使用的 IMDb 数据切片在磁盘上占用 8.5 GB
  • Postgres 会将查询执行期间检索的数据页缓存到缓冲区中
  • 操作系统还有一层文件系统缓存,功能类似但位于更底层

如果在 Postgres 上连续运行同一条查询 20 次,每次耗时并不相同。日常业务中这点波动无伤大雅,但本文的核心论点乃至训练流程本身,都依赖于精准衡量“某种执行方式是否比 Postgres 默认策略更快”。因此,我们必须竭尽全力消除 Postgres 的噪声。

首先,我需要摸清 Postgres 查询执行噪声到底有多大。

我在实验工作流中构建了一个“校准”环节。流程很简单:启动 $N$ 个基于 Postgres 镜像的 Docker 容器,每个容器分配固定数量的 CPU 核心和内存。起初我设置 $N = 4$;容器数太少会让后续训练慢得离谱,太多则可能引发更激烈的 CPU 争抢,噪声反而增大。每个容器配备 4 个核心,内存上限设为 8 GB。

容器启动时,使用完全相同的配置初始化 Postgres 并加载 IMDb 数据。随后,校准环节开启一个大小为 4 的线程池,将全部 113 条查询推入共享队列。每当某个容器完成一次查询测量,便立即从队列中取出下一条继续处理。

实际测量过程分为两个阶段:

  1. 先执行数次查询进行“预热”
  2. 然后再运行该查询 20 次,并记录每次的执行时间
queue job-01ajob-01cjob-01djob-01bjob-02ajob-02cjob-02bjob-02d +105 more
  1. container 0 — warmup measure idle
  2. container 1 — warmup measure idle
  3. container 2 — warmup measure idle
  4. container 3 — warmup measure idle
0 / 113 queries measured
四个容器从共享队列中取出 JOB 查询,对每个查询进行预热,直到其缓冲区计数器稳定,然后再运行 20 次。

那么,预热一个查询到底意味着什么?这需要一些操作系统的基础知识才能理解。

每当 Postgres 执行查询时,它都会向操作系统(在我们的例子中是 Linux)请求数据页。Linux 会先检查自己的文件系统缓存,也就是 page cache。如果这些页已经在缓存里,Linux 就直接返回;否则它会从磁盘读取,先存入缓存,再返回给 Postgres。而 Postgres 也会把收到的页保存在自己的 shared_buffers 缓存中,方便复用。当 shared_buffers 开始溢出时,Postgres 会逐出部分页;之后如果再次需要这些页,就得重新向 Linux 请求。

每当在 shared_buffers 中命中一页,Postgres 就会把一个叫 “shared hit blocks”(SHB)的计数器加一;如果不得不向 Linux 请求,则把 “shared read blocks”(SRB)加一。

只要在运行 EXPLAIN 时加上 BUFFERS 选项,Postgres 就能方便地报告这两个计数器。比如运行 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS, FORMAT JSON) 会输出类似这样的结果:

{
  "Plan": {
    "Node Type": "Aggregate",
    "Shared Hit Blocks": 1800786,
    "Shared Read Blocks": 52990,
    ...
  },
  "Execution Time": 189.2,
  ...,
}

这两个计数器让我们对查询的“热度”有了大致判断。每次预热运行后,我们会把本次的命中数和读取数与上一次对比,如果两者都在 2% 的偏差以内(且查询计划没有变化),就认为该查询已经预热完成,可以开始测量。一个查询至少需要两次预热才有对比基准,且无论情况如何,预热次数最多不超过五次。我们的思路是:如果计数器不再变化,说明数据已经稳定,这样在 20 次测量期间缓存波动就能降到最低。

Query A runs

shared_buffers(Postgres) / 页缓存(Linux) / 磁盘

查询 A 查询 B 共享命中块数 0 共享读取块数 0

查询 A 通过 Linux 系统调用填充 shared_buffers。查询 B 需要不同的页,在这个过程中逐出了 shared_buffers 中属于查询 A 的页。当 A 再次运行时,这些被逐出的页被计为读取。

我将 shared_buffers 设为保守的 128 MB,并进行了第一次校准:

0% 25% 50% 75% 100% 2 次预热 50 条查询 50 条中仍有 48 条在读取 3 次预热 46 条查询 46 条中仍有 26 条在读取 4 次预热 4 条查询 4 条中仍有 2 条在读取 5 次(设上限) 13 条查询 13 条全部仍在读取

预热后仍从 Linux 读取 完全驻留在 shared_buffers 中

第一次校准中,全部 113 条 JOB 查询根据其所需的预热次数分组,并按随后每次运行时仍从 Linux 读取的页占比定位。

仅运行两次,一半的查询就被判定为“热”了。看起来不错……至少直到我深入挖掘才发现并非如此。SRB 计数并未降至零;相反,它们始终稳定在某个较大的数值上。面对 8.5 GB 的数据库,仅有 128 MB 的 shared_buffers,Postgres 在每次执行时都持续未能命中自身缓存,而不断向 Linux 请求更多页。“稳定”并不等于“驻留”。

由于 Linux 的页缓存速度很快,这还不是世界末日。不幸的是,当我真正审视各种查询的 20 次测量数据时,一个新问题浮现了。让我们看其中一个特定的查询,job-13b

job-13b 128 MB shared_buffers

运行 1 运行 5 运行 10 运行 15 运行 20 14 次运行 · 186–204 ms 6 次运行 · 227–253 ms 180 200 220 240 260 ms 按运行顺序 按排序
job-13b 在 128 MB shared_buffers 下的 20 次测量运行。每个点代表一次运行。切换两个按钮可分别查看运行时的先后顺序,以及它们在 x 轴上的分布,此时它们会聚集成两个簇。

20 次运行中有 14 次落在 186 至 204 ms 之间。另外 6 次落在 227 至 253 ms 之间,比前者慢 14% 到 26% 不等。该查询甚至没有表现出均匀的噪声,它只是在不同时段呈现出两种不同的速度,且三分之一的时间运行在较慢的那一档。

起初,我想用变异系数(Coefficient of Variation)来量化噪声:

CV=20次运行的均值,单位ms.xˉ20次运行的标准差,反映典型运行距离均值的偏差,单位ms.s​×100%

CV 反映了测量结果的“波动”程度。例如,如果一个查询耗时 100 ms 且 CV 为 5%,可以认为它的波动约为 5 ms。对于 job-13b,其 CV 为 10.3%,效果并不理想。在这里,CV 也不是一个理想的衡量指标,因为它基于均值计算,容易受到少量异常运行结果的影响。

实际上,我们不太关心这 20 次运行结果的分散程度。我们真正关心的是,这种分散性在训练运行中,有多大频率会误导我们的测量标准。

如何“骗过”Agent

请容我稍作跳跃,以便更清晰地解释我们需要测量的具体内容。

在 Agent 实际运行过程中进行去噪时,不能只运行 Agent 提出的查询计划一次。我改为顺序执行三组交错的(candidate, default)对。选择三组是有点随意的决定,旨在提供一定的变异性度量,同时保持规模足够小,避免 Agent 评估过程的大部分时间都花在 Postgres 上。获取这三组候选/默认执行时间的元组后,分别计算三组候选时间和三组默认时间的中位数,并将两者之比作为最终的速度提升或下降指标。如果两个中位数的差异小于任意设定的 5%,则视为平局;在此平局区间之外,候选方案可被判定为提速或减速。

回到之前的 job-13b 示例。我们有 14 次执行聚集在一个较快的集群,另外 6 次在另一个较慢的集群。取三次运行的中位数听起来不错,但一旦意识到,理论上只要三次测量中有至少两次落入那个“较慢”的集群,中位数就会向那个出现频率较低的慢速集群偏移,情况就复杂了。

设想一个候选计划,它的执行表现与默认计划完全相同,不存在真实差异,因此正确的奖励应该是零。从观测到的 20 次耗时中,为“候选”抽 3 次、为“默认”抽 3 次。从 20 个数里抽 3 个共有 $\binom{20}{3} = 1{,}140$ 种方式;对于 job-13b,其中 230 种包含至少两次慢运行,也就是说,某一侧的中位数落进慢速聚集区的概率约为 20%。

也就是说,大约 20% 的情况下,我们会把一个完全虚假的 14-26% 加速或减速当作信号展示给模型。这很危险!

job-13b 128 MB shared_buffers · 一个 no-op 候选(即与默认完全相同)

20 次运行 候选 默认 180 200 220 240 260 ms

—— 抽样中……

0 轮 · 平局 0 · 虚假胜 0 · 虚假负 0 · 被骗率 0%

用 no-op 候选对自身进行测量。每一轮都从 job-13b 的 20 次运行中为候选抽 3 次、为默认抽 3 次,分别取中位数,并套用 5% 的平局区间。遍历所有可能的抽样,奖励被欺骗的概率约为 40%。

所以我们不能仅仅把 CV 当作要最小化的黄金指标——两个 CV 完全相同的查询,可能因为数据分布不同(一边是均匀的散布,另一边是两个相距超过 5% 的聚集区)而以不同的概率欺骗测量奖励。真正要最小化的,就是这个欺骗率本身。

我写了一个小脚本,直接从原始校准数据计算欺骗率。它的做法是在 20 次运行上滑动一个包含六次连续运行的窗口。对每个窗口,我们取两两交错的配对来代表一对交错的 (候选, 默认)。窗口大小为六时,得到形如 (t1, t2), (t3, t4), (t5, t6) 的配对。在任意一对中,$t_n$ 和 $t_{n+1}$ 可以互换角色,即谁是候选查询、谁是默认查询。这意味着每对有两种可能,因此每个包含三个元组的窗口有 $2 \times 2 \times 2 = 8$ 种可能。20 次测量意味着窗口会滑动 15 次,所以对给定查询总共有 $15 \times 8 = 120$ 种可能 每种可能对应一个二值结果,表示该模拟的候选/默认配对组合所产生的中位数之比是否超过了 5% 的平局区间。 。

我们从原始数据中派生出两个指标。首先,对于特定查询,如果 120 次模拟可能性中偏差超过 5% 的比例,即为该查询的空操作(no-op)错误率。我们将 113 个 JOB 查询的空操作错误率百分比汇总,再除以 113 得到均值,即“平均空操作错误率”。这代表在使用三次配对测量策略时,奖励信号被任一 JOB 查询误导的可能性。其次,我们将 113 个查询的空操作错误率从低到高排序,排在 90% 位置的数值被报告为“p90 查询”指标,用于衡量表现最差查询的误判率。

shared_buffers 设为 128 MB 且四个并发容器运行时,“误判率”脚本产生的平均空操作错误率及 p90 查询数值如下。每种配置我运行了两次校准,以评估两次运行间的潜在差异:

运行次数平均空操作错误率p90 查询中位数变异系数
15.0%13%2.3%
25.4%20%2.4%

这些结果并不理想。二十次空操作计划中有一次会得到奖励,且大约每十个查询中就有一个会在 13% 以上的时间被误判。

我们可以做得更好。

调优 Postgres

我主要关注 Postgres 暴露的两个内存相关设置:

  1. shared_buffers 决定 Postgres 自身缓存能保留多少数据库内容
  2. work_mem 决定单个排序或哈希操作在溢出到磁盘前可使用的内存量

我运行了四次校准:

shared_bufferswork_mem空操作错误率 (第 1 次 / 第 2 次)p90 查询中位数变异系数总耗时
128 MB4 MB5.0% / 5.4%13% / 20%2.3%95 s
2 GB4 MB1.8% / 1.2%1.3% / 0%1.1%60 s
128 MB32 MB7.0% / 6.6%20% / 23%2.6%94 s
2 GB32 MB1.7% / 1.3%0% / 0%1.2%60 s

出乎意料的是,work_mem 对噪声毫无影响,所有的权重都集中在 shared_buffers 上!

shared_buffers 设为 2 GB 时,中位数查询在结束预热阶段时,其 SRB(Shared Buffer Hits / 共享缓冲区命中次数)计数器恰好为零:工作集完全驻留在 Postgres 自身的缓存中。无效操作的错误率下降了约 4 倍,第 90 百分位查询被误导的概率也从 13%–20% 降到了几乎为零。我们的双峰查询 job-13b 的变异系数(CV)从 10.3% 降至 0.9%,所有 20 次运行的时间都落在 7 ms 的误差范围内。

我意外收获了一个最初没有刻意追求的好处:默认查询计划本身变得更快了。仅凭缓存驻留,所有 113 个 JOB 查询的总运行时间就从 95 秒降到了 60 秒。换句话说,对候选计划和默认计划进行实际测量都会显著提速,从而缩短训练过程所需的总时间。

此后,我固定使用 2 GB shared_buffers 和 4 MB work_mem

基线与指标

我使用两种指标来评估 Agent 的性能。

几何平均加速比

几何平均加速比Sgeo​=(i=1∏N​The candidate plan's execution time for some query ici​The default plan's execution time for some query ibi​​)The Nth root of the product. With this, a 2x and a 0.5x speedup cancel out to 1x.1/N

几何平均加速比赋予所有查询相同的权重。例如,在一个包含两个查询的样本中,如果查询 1 比基线快 2 倍,查询 2 比基线快 0.5 倍,那么 $S_{geo} = 1.00\text{x}$。无论查询 1 的基线是 5 分钟而候选只有 2.5 分钟,还是查询 2 从 25 秒退化到 50 秒,由于权重相等,它们的影响会相互抵消。

总工作量加速比

总工作量加速比Sworkload​=∑i=1N​The candidate plan's execution time for some query ici​∑i=1N​The default plan's execution time for some query ibi​​

总工作量加速比将整套查询视为一个批次。我们只需将所有基线时间相加,再除以候选时间的总和。在上面的例子中,$S_{workload} = 1.4\text{x}$。

这两个指标反映的东西不同。整体工作负载加速衡量的是实用性——分析师构建一组分析查询时,关心的自然是整批查询的总体运行时间缩短了多少。但从模型训练的角度看,整体工作负载加速可能完全由 agent 碰巧命中的某一个查询计划决定,其余查询也许都很糟糕。这意味着模型其实什么都没学到,只是运气好。而几何平均加速不关心绝对值,它衡量的是模型在整批查询上的真实学习效果:数值高于 1x 说明平均来看查询执行得更快了。

用前沿模型做能力上限验证

在把未经训练的 4B 模型跑进 qo-agent 框架之前,我想先验证这个问题在今天的前沿模型上是否真的可解。如果连 GPT-6 AstraQwen 3.8 2.4T 这样的模型都无法改进 Postgres 的默认查询计划,那我也没理由期待 4B 模型能做到。

我从 JOB 基准中抽取了 10 条查询作为小样本,在 Astra 和 Qwen 3.8 2.4T 上通过 qo-agent 框架分别做了测试:

模型候选数得分任务数Sgeo​ 几何平均加速Sworkload​ 整体工作负载加速性能回退数
Astra [m] 中等推理强度19/100.85x1.00x3
Astra [m] 中等推理强度510/102.54x2.12x0
Astra [m, r] 中等推理强度,开启推理摘要510/102.39x1.57x1
Qwen 3.8 2.4T [m] 中等推理强度17/102.02x1.30x1
Qwen 3.8 2.4T [m] 中等推理强度510/102.26x1.35x1

Astra 和 Qwen 3.8 2.4T 的评测均在 JOB 的同一组 10 条查询上运行。前沿模型在不同候选数量(即完整轨迹中允许生成的候选数,单个候选或 5 个)下进行了基准测试,对于 Astra,还测试了是否启用了推理摘要。我有一点惊讶:对比 5 候选评估,启用推理摘要后 Astra 的性能反而变差了,但这些评测只在 JOB 的一个 10 条查询的小切片上跑了一次,所以我归咎于随机波动。Astra 通过 OpenAI API 推理,Qwen 3.8 2.4T 则通过 Modal 经由 OpenRouter 运行。

由于单候选得分与 5 候选得分之间存在差异,可以看出智能体在候选的连续执行中具备上下文学习能力。这让我有信心坚持采用智能体多轮方案,而非尝试训练 4B 模型去擅长一次性生成计划。

在智能体运行期间,每个候选先预热一次,再测量一次。用尽候选尝试预算后,模型被仅呈现一个工具调用选项,即 finish,并被指示选择得分最高的候选(或保留默认计划)。候选选定后,运行三个交错的 (candidate, default) 对,并通过裁剪器处理:

$$S_i = \operatorname{clip}\left( \frac{\operatorname{median}(D_i)}{\operatorname{median}(C_i)},\ 0.1,\ 10 \right)$$

该裁剪器将两个中位数之商的约束在 $[0.1, 10]$ 区间内。这些裁剪值是稍作任意选取的;我发现它们防止了几何平均加速比被极端加速或极端回退过度影响。

原始来源: Hacker News

评论 (0)