🧠 量化回测中的“内存黑洞”:一次从子进程泄漏到稳定运行的实战复盘

在量化系统开发中,我们常把注意力放在因子逻辑、信号生成和收益曲线这些“看得见”的部分。
但真正让策略从 notebook 走向生产环境的,往往是那些“看不见”的工程细节——比如,为什么程序跑完了,内存却不释放?
本文记录一次典型的大数据并行处理内存泄漏问题的完整排查与解决过程,希望能帮你避开这个“坑”。


❓ 问题初现:任务完成,内存却“赖着不走”

设想这样一个常见场景:

  • 你需要为 5000 只股票 × 10 年日线数据 构建技术因子(如均线、波动率、换手率等)
  • 数据总量约 1500 万行
  • 为加速处理,你使用多进程并行:每批处理 100 只股票,共启动 15 个 worker

逻辑上一切合理,本地小样本测试毫无问题 ✅。

但当全量运行时,诡异现象出现了:

[15:03:47] ✅ 所有批次处理完成
[15:04:01] ✅ 最终结果已保存
...
(10秒后)
系统内存使用率:62% —— 且持续不降!

更奇怪的是,通过进程监控发现:

  • 主进程仅占 9GB 内存
  • 另有 15 个子进程合计占用 10GB+ 内存,且未退出

这就像工人干完活却不离开工地,还霸占着工具和材料 🛠️。


🔍 第一步排查:是 Python 对象没释放吗?

直觉反应:是不是中间 DataFrame 没删干净?

于是加上经典三件套:

del result_df
gc.collect()
time.sleep(10)

结果:毫无作用。内存岿然不动。

💡 启示:问题不在主进程的 Python 堆内存,而在子进程持有的底层资源


🔍 第二步排查:谁在“赖着不走”?

使用 psutil 监控进程树:

import psutil, os

def log_memory():
    main = psutil.Process(os.getpid())
    children = main.children(recursive=True)
    print(f"主进程内存: {main.memory_info().rss / 1e9:.1f} GB")
    print(f"子进程总内存: {sum(c.memory_info().rss for c in children) / 1e9:.1f} GB")

输出:

主进程内存: 9.1 GB  
子进程总内存: 10.2 GB

结论明确:子进程任务虽完成,但进程未退出,内存无法归还操作系统


🤔 根本原因:跨进程传递了“不可序列化”的对象 + 并行框架的生命周期策略

我们最初使用的是 joblib.Parallel,代码如下:

with Parallel(n_jobs=15) as parallel:
    lazy_frames = parallel(
        delayed(build_factor_lf)(stock_batch) 
        for stock_batch in batches
    )

其中 build_factor_lf 返回一个 pl.LazyFrame

⚠️ 表面问题是 pickle 失败

  • 多进程必须通过 pickle 序列化返回值
  • pl.LazyFrame 是一个执行计划,不是纯数据,可能包含闭包、自定义函数或复杂依赖
  • 当 pickle 失败(即使静默失败),子进程无法正常通知主进程“我已完成”,导致卡住

但这只是导火索,真正的深层原因在于:joblib 默认使用的 loky 后端采用了“持久化进程池”模型

🔥 核心机制差异:Worker 是否“用完即焚”?

框架 Worker 生命周期策略 内存回收确定性
joblib.Parallelloky 启动一批 worker 长期驻留,用于复用加速后续任务 ❌ 低:worker 不会立即退出,即使任务完成
concurrent.futures.ProcessPoolExecutor 每个 worker 一次性使用,任务结束即退出 ✅ 高:with 块结束时强制调用 shutdown(wait=True)

loky 的设计初衷是优化高频小任务的启动开销。但在我们的场景中:

  • 任务是重量级、一次性、大数据量
  • 子进程内部创建了大量 Polars LazyFrame、DataFrame 等重型对象
  • 即使函数返回的是简单字符串,Polars 引擎或 Python 解释器仍可能在 worker 进程中残留状态
  • 而由于 loky 不杀死 worker,这些内存就永远无法归还操作系统

🧪 实测表明:在处理 Polars 或 Pandas 大数据时,loky 更容易因内部状态混乱进入“僵尸”状态,而 ProcessPoolExecutor 因进程彻底退出,反而干净利落。


🛠️ 解决方案一:子进程只写文件,不传复杂对象

核心原则:计算与传输解耦

改造思路:

  1. 子进程内部完成全部计算
  2. 直接将结果写入临时 Parquet 文件
  3. 只返回文件路径(字符串)给主进程

伪代码如下:

# 子进程函数(顶层定义!)
def process_and_save(batch, batch_id, temp_dir):
    df = load_data(batch)
    factors = compute_technical_factors(df)
    output_path = f"{temp_dir}/batch_{batch_id}.parquet"
    factors.write_parquet(output_path)
    return output_path  # ← 只返回 str!

# 主进程
with tempfile.TemporaryDirectory() as tmp_dir:
    with ProcessPoolExecutor(max_workers=8) as executor:
        futures = [
            executor.submit(process_and_save, batch, i, tmp_dir)
            for i, batch in enumerate(batches)
        ]
        parquet_files = [f.result() for f in futures]

    # 合并结果
    final_df = pl.scan_parquet(parquet_files).collect()

✅ 效果:

  • 返回值是 str,100% 可 pickle
  • 子进程任务结束即退出
  • 操作系统回收其全部内存

🛠️ 解决方案二:选用生命周期更确定的并行框架

即使我们将返回值改为字符串,joblibloky 后端仍可能因持久化进程池策略导致内存残留

因此,我们果断切换到标准库的 concurrent.futures.ProcessPoolExecutor

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=8) as executor:
    ...
# ← 离开 with 块时,自动调用 shutdown(wait=True),强制等待所有子进程真正退出

为什么它更可靠?

  • 它基于 multiprocessing,行为更“原始”但更可预测
  • 每个 worker 是独立进程,任务完成后立即终止
  • 无后台 manager 进程试图“智能复用”,避免了状态积累
  • 异常传播直接,不会因 silent failure 导致 zombie 进程

💡 在“大内存、低频、批处理”场景下,确定性 > 微小性能优化ProcessPoolExecutor 正是为此而生。


📦 附加优化:控制单个子进程的内存峰值

解决了进程退出问题,新挑战浮现:单个子进程自己 OOM 了!

原因:每批 100 只股票 × 10 年 ≈ 30 万行 × 20 列,内存轻松突破 2GB。

对策:

1. 动态调整批次大小

def get_batches(stock_list, target_rows_per_batch=200_000):
    avg_rows_per_stock = estimate_avg_rows()  # 如 2500
    batch_size = max(1, target_rows_per_batch // avg_rows_per_stock)
    return chunk_list(stock_list, batch_size)

2. 限制并发数

max_workers = min(8, os.cpu_count())  # 不盲目用满 CPU

📌 经验法则:宁可多跑几批,也不要让任何一个进程撑爆内存


✅ 最终效果:干净、稳定、可扩展

改造后再次运行全量任务:

[15:30:00] ✅ 所有批次处理完成
[15:30:05] ✅ 最终结果已保存
[15:30:06] 主进程内存: 5.3 GB  
[15:30:06] 子进程总内存: 0.0 GB   ← 关键!
  • 内存使用率从 60%+ 降至 30% 以下
  • 后续因子计算、回测阶段不再因内存不足中断
  • 系统可稳定支持每日增量更新

📝 写在最后:量化系统的“隐形骨架”

在量化领域,我们常被 Alpha、Sharpe Ratio、最大回撤这些指标吸引。
但真正支撑策略长期运行的,是那些“看不见”的工程能力:

  • 如何高效处理千万级数据?
  • 如何避免内存泄漏导致服务崩溃?
  • 如何选择合适的并行模型,平衡性能与确定性?

这些问题没有炫酷的公式,却决定了你的策略是“玩具”还是“武器”。

尊重资源(内存、CPU、磁盘),就是尊重你的研究本身
当你开始思考“这个对象能不能 pickle?”、“这个进程会不会赖着不走?”,你就离生产级系统不远了。


如有类似“内存黑洞”经历,欢迎留言交流 💬。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐