春江暮客

春江暮客的个人学习分享网站

用 Python cProfile 排查 CSV 脚本慢:定位热点并验证集合查询优化

2026-10-25 技术
用 Python cProfile 排查 CSV 脚本慢:定位热点并验证集合查询优化

定时任务能跑完,CSV 多了以后却越来越慢。先加线程或重写整段程序,容易把时间花在不影响结果的地方。本文用标准库 cProfile + pstats 找出反复扫描列表的热点,只改一次查询容器,再核对输出和实际耗时。

面向初级到中级读者,适合在 Linux 本地复现。示例使用 CPython 3.10 或更新版本,无需第三方包;本文在 Linux、CPython 3.11.4 下测试。如果任务是卡住不动,可先看用 faulthandler 获取线程堆栈。

1. 方法:先定位,再验证

  1. 对同一份输入运行列表版本,保存函数级性能记录。
  2. 找到 is_allowed() 的查询开销,把允许列表在循环外转换为集合。
  3. 确认两份 CSV 字节一致,再关闭分析器测量整个命令。

本文演示的是字符串 ID 的存在性查询。重复的允许 ID 不增加输出行数,输入记录的重复项和顺序必须保留。它不执行多表连接,也不把全部输入行装入内存。

2. 准备环境与数据

  1. 在同一终端中新建练习目录,后续命令都在这里执行:
python3 --version
python3 -m cProfile --help
mkdir cprofile-csv-demo
cd cprofile-csv-demo
  1. 生成 20000 条记录和 5001 条允许列表项。其中只有 5000 个不同的允许 ID,输入中每个 ID 出现两次;部分备注含中文、逗号和换行。
python3 - <<'PY_DATA'
import csv
from pathlib import Path

with open("samples.csv", "x", encoding="utf-8", newline="") as file:
    writer = csv.writer(file)
    writer.writerow(["sample_id", "note"])
    for i in range(20000):
        note = f"record {i}"
        if i % 4999 == 0:
            note = f"记录 {i}, item\nsecond line"
        writer.writerow([f"S{i % 10000:05d}", note])

with Path("allowed.txt").open("x", encoding="utf-8") as file:
    for i in range(0, 10000, 2):
        file.write(f"S{i:05d}\n")
    file.write("S00000\n")
print("Created 20000 records and 5001 allow-list entries")
PY_DATA

数据文件使用 x 模式创建,已存在时会报错,避免覆盖。重新演示时可换一个新目录。CSV 按官方文档以 newline="" 打开,交给 CSV 解析器处理跨行字段。

3. 保存完整筛选脚本

把下面代码保存为 filter_samples.py:

"""Profile list versus set membership while filtering a demo CSV."""
import argparse
import csv
from pathlib import Path
import sys


def is_allowed(sample_id, allowed_ids):
    return sample_id in allowed_ids


def filter_samples(source, allow_file, output, index):
    allowed_ids = allow_file.read_text(encoding="utf-8").splitlines()
    if index == "set":
        allowed_ids = set(allowed_ids)
    kept = 0
    with source.open(encoding="utf-8-sig", newline="") as incoming:
        reader = csv.reader(incoming, strict=True)
        if next(reader, None) != ["sample_id", "note"]:
            raise ValueError("expected header: sample_id,note")
        with output.open("x", encoding="utf-8", newline="") as outgoing:
            writer = csv.writer(outgoing)
            writer.writerow(["sample_id", "note"])
            for number, row in enumerate(reader, 1):
                if len(row) != 2 or not row[0]:
                    raise ValueError(f"invalid data record {number}")
                if is_allowed(row[0], allowed_ids):
                    writer.writerow(row)
                    kept += 1
    return kept


def main():
    parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    parser.add_argument("source", type=Path)
    parser.add_argument("allow_file", type=Path)
    parser.add_argument("output", type=Path, help="new output CSV; must not exist")
    parser.add_argument("--index", choices=["list", "set"], default="list")
    args = parser.parse_args()
    try:
        kept = filter_samples(args.source, args.allow_file, args.output, args.index)
    except (OSError, ValueError, csv.Error) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    print(f"kept={kept}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

--index list 保留原始列表;--index set 只在读取允许文件后构建一次集合。两种模式用同一个 is_allowed()、同一个读取循环和写出路径,方便比较。

这个练习只接受 sample_id,note 两列表头及非空 ID。它保留原始字符串,允许文件每行就是一个 ID,不额外去掉首尾空格。大文件可沿用CSV 流式处理方法逐条读取,但允许列表仍需放入内存。

4. 采集列表版本的性能记录

  1. 查看脚本帮助,再把分析结果写入 list.prof:
python3 filter_samples.py --help
python3 -m cProfile -o list.prof filter_samples.py \
  samples.csv allowed.txt list.csv --index list

正常输出 kept=10000。list.csv 保存筛选结果,list.prof 是分析数据,不是文本报告。-o 会覆盖同名分析文件,读取和分析时使用同一个 Python 环境。

  1. 分别按累计耗时和自身耗时排序,并查看谁调用了成员查询函数:
python3 - <<'PY_STATS'
from pstats import Stats, SortKey

stats = Stats("list.prof").strip_dirs()
print("=== cumulative time ===")
stats.sort_stats(SortKey.CUMULATIVE).print_stats(12)
print("=== function's own time ===")
stats.sort_stats(SortKey.TIME).print_stats(12)
print("=== membership call sites ===")
stats.print_callers("is_allowed")
PY_STATS

根据 Python 性能分析文档,ncalls 是调用次数,tottime 不含子函数耗时,cumtime 包含子函数耗时。累计时间适合找调用入口,自身时间适合找实际工作点;不要把父子函数的累计时间相加。

本例 is_allowed() 应调用 20000 次。列表查询发生在它内部;在本次测试中它是主要热点。具体秒数、源码行号和排序会随环境变化。如果报告只显示 main() 很慢,继续查看其子函数,或用 print_callers() 确认调用位置。

5. 只构建一次集合,再检查热点

执行集合版本,并对照相同函数:

python3 -m cProfile -o set.prof filter_samples.py \
  samples.csv allowed.txt set.csv --index set
python3 - <<'PY_COMPARE'
from pstats import Stats, SortKey

for name in ("list.prof", "set.prof"):
    print(f"=== {name} ===")
    Stats(name).strip_dirs().sort_stats(SortKey.TIME).print_stats("is_allowed")
PY_COMPARE

两次都应输出 kept=10000,is_allowed() 调用次数也保持 20000。变化的是每次查询的工作量:列表需要逐项比较,集合为重复成员查询提供哈希索引。不要在 is_allowed() 里面反复调用 set(allowed_ids),否则又会重复构建索引。

集合类型文档说明集合保存互不重复的可哈希元素。这里元素是字符串,去掉允许列表的重复项不影响存在性判断。集合不保存原列表顺序,但我们遍历的是 CSV 输入,因此结果顺序保持不变;如果业务需要按允许列表排序或计算重复次数,就不能直接套用这个改法。

6. 校验输出,再测量真实命令

  1. 验证字节、记录数、顺序、重复 ID 和带换行的备注:
python3 - <<'PY_CHECK'
import csv
from pathlib import Path

assert Path("list.csv").read_bytes() == Path("set.csv").read_bytes()
with open("set.csv", encoding="utf-8", newline="") as file:
    reader = csv.reader(file)
    assert next(reader) == ["sample_id", "note"]
    rows = list(reader)
assert len(rows) == 10000
assert [row[0] for row in rows] == [
    f"S{i % 10000:05d}" for i in range(20000) if i % 2 == 0
]
assert rows[0][1] == "记录 0, item\nsecond line"
assert sum(row[0] == "S00000" for row in rows) == 2
print("OK: identical bytes, 10000 rows, order and repeated IDs preserved")
PY_CHECK

应看到 OK: identical bytes, 10000 rows, order and repeated IDs preserved。这里只为核对小型演示数据把输出装入列表,不是大文件处理方式。

  1. 在不启用 cProfile 的情况下各运行三次,取中位数:
python3 - <<'PY_TIME'
from pathlib import Path
from statistics import median
import subprocess
import sys
import tempfile
from time import perf_counter

with tempfile.TemporaryDirectory(prefix="cprofile-timing-") as directory:
    for index in ("list", "set"):
        elapsed = []
        for run in range(3):
            output = Path(directory) / f"{index}-{run}.csv"
            start = perf_counter()
            subprocess.run([
                sys.executable, "filter_samples.py", "samples.csv",
                "allowed.txt", str(output), "--index", index,
            ], check=True, stdout=subprocess.DEVNULL)
            elapsed.append(perf_counter() - start)
            assert output.read_bytes() == Path("list.csv").read_bytes()
        print(f"{index}: median_seconds={median(elapsed):.6f}")
PY_TIME

perf_counter()适合测量经过的时间。这里测量包括解释器启动、文件读写和筛选,计时用输出在临时目录中自动清理。三次本地运行仅用于确认这个改动,固定先列表后集合的顺序、文件缓存和服务器负载都会影响结果,不应据此宣传固定加速倍数。

分析器自身有开销,因此用它定位函数,用未启用分析器的命令比较整体耗时。如果优化后内存上涨,可继续用tracemalloc 快照比较检查索引的内存代价。

7. 常见问题

现象 处理方法
File exists 输出使用 x 模式;指定一个尚不存在的输出文件。
表头或记录报错 按示例生成两列 CSV,核对列数和空 ID;失败时可能已留下部分输出,检查后换新输出路径重跑,不把残缺文件交给下游。
优化后行数相同但结果不同 先比较完整输出;检查大小写、空白清洗、排序和重复项规则是否被同时改动。
看不到明显热点 使用实际有代表性的输入量;先确认本例确实生成了 20000 行,避免只测启动耗时。
cumtime 最大的是文件或网络读取 检查等待来源,减少重复 I/O;集合查询只能解决这里演示的查找开销。
另一环境无法读取 .prof 在生成记录的 Python 环境中运行 pstats,或重新采集;分析文件不保证跨版本兼容。

本例只在当前进程中采集,不能自动展开子进程内部工作。输出文件从头写入,发生中途错误时不保证完整;它是受控的性能练习,接入正式流水线前应补上输出提交策略。

小结

先保存函数级性能记录,确认重复工作在哪里,再改动对应的数据结构。保留输入顺序和重复记录,用完整输出核对语义,最后关闭分析器测量命令耗时。对这个筛选任务,把允许 ID 转成一次性集合就能减少重复扫描。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io flashppi flask folium frontend game generator git github-actions google google-research grep gzip harness hls html http hugo imports indexnow javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance phaser pillow pip postfix preprocessing print protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tensorflow tkinter tls toml tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite webp wordcloud wordpress workflow yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它