merge_asof 在多序列对齐下的效率问题
近期处理的工作涉及到将多个序列在时间轴上对齐,pandas.merge_asof 是一个很常用的选择,但是当待对齐的序列数量增多时,它的写法和性能都会出现问题。这里以 merge_asof 为主进行分析,merge / join 是同类问题,concat 因为支持多参数所以情况略有不同,但同样存在「写成 pairwise 累积」时的性能陷阱。
背景
最近遇到的工作场景是这样的:
- 从数据库里读出 18 个参数各自的
(time, value)序列; - 用
pandas.merge_asof把这 18 个序列在时间轴上对齐成一张宽表; - 多个时间段重复 1、2 之后,再用
pandas.concat沿时间轴把若干段拼接起来。
加载全量训练数据时延迟异常高。给三段流程分别加上计时之后发现:数据库读取占比并不大,绝大部分时间都消耗在第 2 步的对齐和第 3 步的拼接上。
问题的共同点是:API 设计上偏向「两两操作」,使用者很容易写成「滚雪球」式的累积循环 out = op(out, frames[i]),从而引入不必要的
merge_asof 接口回顾
pandas.merge_asof(left, right, on=..., direction=...) 做的是「按近邻键 (asof) 关联两个有序表」:对 left 的每一行,按 on 列在 right 里找方向上最近的一行(backward / forward / nearest),把对应字段拼过来。
关键点:
- 它只接受两个 DataFrame,没有 N-ary 重载;
- 输出的行数 =
left的行数(不是并集); - 对
left和right都要求按on列已排序。
所以多序列对齐的常见写法就是「以第一个序列为参考时间轴,循环 merge_asof 进去」:
1 | def merge_asof_chain(frames): |
为什么这样写会慢
设有
out已经有列、 行; pd.merge_asof(out, f_i, ...)内部需要重新分配一张大小为的结果表,把 out的列整列拷贝进去,再拼上新一列。
把每一步的拷贝代价加起来:
而真正「有用」的工作(在 merge / join 的 pairwise 累积写法是同样的道理。
一种朴素的多路对齐实现
既然瓶颈是「重复把累积结果拷来拷去」,那么把所有目标列一次性算好、最后再组装一次 DataFrame 即可。对每个非参考序列做一次 np.searchsorted(这就是 direction="backward" 的本质:查不大于目标时间的最大下标),然后用花式索引取值:
1 | def kway_align(frames): |
复杂度退回到了
- 这里假设所有
frames都已经按time升序,否则需要先排序; - 只覆盖
direction="backward",forward/nearest也可以用searchsorted表达; tolerance等参数没有实现,需要时可以在idx之后做一次 mask;- 输出对齐到
frames[0]的时间轴,与merge_asof_chain完全一致,方便对照。
性能对比
测试条件:每个序列约 20k 个不规则时间戳,float32 值,序列数
merge_asof 链式 (s) |
手写 K-way (s) | 加速比 | |
|---|---|---|---|
| 2 | 0.0033 | 0.0033 | 1.00x |
| 5 | 0.0156 | 0.0126 | 1.23x |
| 10 | 0.0359 | 0.0293 | 1.23x |
| 18 | 0.0826 | 0.0535 | 1.54x |
| 20 | 0.1054 | 0.0617 | 1.71x |
| 50 | 0.4174 | 0.1584 | 2.64x |
可以看到:
时两者基本持平,毕竟手写实现在序列数量很少时没什么优势; - 在我实际遇到的
规模上已经能拿到约 1.5x 的提升; 越大差距越明显,到 时手写实现快了 2.6x,并且趋势仍在扩大——这正是上一节里那个 项在发挥作用。

concat 也有同样的问题
pandas.concat 表面上看「天然支持多参数」,似乎不会落入上面的陷阱,但实际工程里很容易写成 pairwise 累积:
1 | # 反例:每次循环都重新分配整张大表 |
正确写法是把所有 frame 一次性传给 concat:
1 | out = pd.concat(frames, axis=0, ignore_index=True) |
每段长度 5k 行,结果如下:
concat(frames) 一次 (s) |
累积写法 (s) | 累积/一次 | |
|---|---|---|---|
| 2 | 0.0003 | 0.0002 | 0.75x |
| 5 | 0.0004 | 0.0010 | 2.58x |
| 10 | 0.0007 | 0.0046 | 6.46x |
| 18 | 0.0011 | 0.0103 | 9.07x |
| 50 | 0.0052 | 0.0890 | 16.97x |
| 100 | 0.0111 | 0.3673 | 33.00x |
差距随 pd.concat([out, f]) 都要为 out + f 的总长度重新分配缓冲区并整段拷贝,累计开销又是 pd.concat(frames) 只分配并写入一次。

简单结论
merge_asof不支持 N-ary,链式累积调用在多序列对齐场景下复杂度从线性退化到平方;当待对齐序列数较多(≥10)时,用np.searchsorted自己手写一次多路对齐是简单且明显更快的方案。merge/join的 pairwise 累积写法存在同样的问题。concat虽然支持多参数,但一定要一次性传 list,不要写成循环里两两拼——这是 pandas 文档里反复提醒的常见陷阱,实测在拼接 100 段时差异可达 30 倍以上。- 工程上更一般的教训:当一个 API 是「两两」语义、而你的数据是「一组」语义时,先停下来想想累积调用会不会带来
的隐性开销,必要时绕过 API 在 NumPy 层做一次性的批量计算。
本文测试运行环境:Windows + CPython 3.x,pandas==2.3.3,numpy==2.2.6。结果会因机器和 pandas 版本略有波动,但量级和趋势是稳定的。






