近期处理的工作涉及到将多个序列在时间轴上对齐,pandas.merge_asof 是一个很常用的选择,但是当待对齐的序列数量增多时,它的写法和性能都会出现问题。这里以 merge_asof 为主进行分析,merge / join 是同类问题,concat 因为支持多参数所以情况略有不同,但同样存在「写成 pairwise 累积」时的性能陷阱。

背景

最近遇到的工作场景是这样的:

  1. 从数据库里读出 18 个参数各自的 (time, value) 序列;
  2. pandas.merge_asof 把这 18 个序列在时间轴上对齐成一张宽表;
  3. 多个时间段重复 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 的行数(不是并集);
  • leftright 都要求按 on 列已排序。

所以多序列对齐的常见写法就是「以第一个序列为参考时间轴,循环 merge_asof 进去」:

1
2
3
4
5
def merge_asof_chain(frames):
out = frames[0]
for f in frames[1:]:
out = pd.merge_asof(out, f, on="time", direction="backward")
return out

为什么这样写会慢

设有 个序列,每个长度大约为 。在第 次循环里:

  • out 已经有 列、 行;
  • pd.merge_asof(out, f_i, ...) 内部需要重新分配一张大小为 的结果表,把 out整列拷贝进去,再拼上新一列。

把每一步的拷贝代价加起来:

而真正「有用」的工作(在 里查找 asof 对应的下标)只有 。当 增长时,前者主导,复杂度从线性退化到平方merge / join 的 pairwise 累积写法是同样的道理。

一种朴素的多路对齐实现

既然瓶颈是「重复把累积结果拷来拷去」,那么把所有目标列一次性算好、最后再组装一次 DataFrame 即可。对每个非参考序列做一次 np.searchsorted(这就是 direction="backward" 的本质:查不大于目标时间的最大下标),然后用花式索引取值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def kway_align(frames):
ref_t = frames[0]["time"].to_numpy()
cols = {"time": ref_t}
first_name = [c for c in frames[0].columns if c != "time"][0]
cols[first_name] = frames[0][first_name].to_numpy()

for f in frames[1:]:
name = [c for c in f.columns if c != "time"][0]
t_arr = f["time"].to_numpy()
v_arr = f[name].to_numpy()
# backward asof: largest index i such that t_arr[i] <= ref_t
idx = np.searchsorted(t_arr, ref_t, side="right") - 1
out = np.full(ref_t.shape, np.nan, dtype=v_arr.dtype)
valid = idx >= 0
out[valid] = v_arr[idx[valid]]
cols[name] = out
return pd.DataFrame(cols)

复杂度退回到了 ,且 DataFrame 只构造一次。值得注意的几点:

  • 这里假设所有 frames 都已经按 time 升序,否则需要先排序;
  • 只覆盖 direction="backward"forward / nearest 也可以用 searchsorted 表达;
  • tolerance 等参数没有实现,需要时可以在 idx 之后做一次 mask;
  • 输出对齐到 frames[0] 的时间轴,与 merge_asof_chain 完全一致,方便对照。

性能对比

测试条件:每个序列约 20k 个不规则时间戳,float32 值,序列数 取 2 / 5 / 10 / 18 / 20 / 50;每组取 3 次中的最快值;脚本见文末。结果如下:

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,并且趋势仍在扩大——这正是上一节里那个 项在发挥作用。

merge_asof 链式 vs 手写多路对齐

concat 也有同样的问题

pandas.concat 表面上看「天然支持多参数」,似乎不会落入上面的陷阱,但实际工程里很容易写成 pairwise 累积:

1
2
3
4
# 反例:每次循环都重新分配整张大表
out = frames[0]
for f in frames[1:]:
out = pd.concat([out, f], axis=0, ignore_index=True)

正确写法是把所有 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) 只分配并写入一次。

concat 一次性 vs pairwise 累积

简单结论

  • 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.3numpy==2.2.6。结果会因机器和 pandas 版本略有波动,但量级和趋势是稳定的。

参考