2. 单文件逐笔CSV读取基线实现

在开始任何优化之前,我们必须先回答一个看似简单却极其关键的问题:当前的单文件逐笔CSV读取到底有多慢、多耗内存?没有基线,后续所有“优化”都只是主观感受。本节的任务就是用Python内置csv模块和pandas.read_csv两种方式读取同一个逐笔CSV文件,在默认参数下测量耗时与内存占用,形成可复现、可对比的性能基线。

一、基线对象与测量口径

逐笔CSV通常具有“行数多、字段多、单行短”的特点,例如一条逐笔成交记录可能包含证券代码、成交时间、成交价格、成交数量、买卖方向、成交编号等字段。为了让基线具有代表性,我们假设一个教学示例文件tick_trades.csv:约200万行、12列,文件大小约260MB,字段以字符串和数值混合为主。这个规模属于单机可处理但已能暴露性能差异的区间。

测量口径必须固定,否则数据不可比。建议明确三点:第一,计时范围只包含“打开文件到数据全部读入内存”的过程,不含后续计算;第二,内存占用以读取完成后进程的常驻内存增量(RSS增量)为准,可用psutil在读取前后各采样一次;第三,每个方案重复运行3次,取中位数,首次运行因操作系统缓存未预热可单独记录。示例中给出的数值均为合理假设的示意值,实际结果需在目标机器上实测。

二、内置csv模块的基线实现

内置csv模块的默认行为是逐行迭代、逐字段返回字符串,不做类型推断。最直接的写法是用csv.reader把每一行转成列表并追加到一个大列表中:

import csv, time, psutil, os

def baseline_csv_reader(path):
    proc = psutil.Process(os.getpid())
    mem_before = proc.memory_info().rss
    t0 = time.perf_counter()
    rows = []
    with open(path, "r", encoding="utf-8", newline="") as f:
        reader = csv.reader(f)
        header = next(reader)
        for row in reader:
            rows.append(row)
    elapsed = time.perf_counter() - t0
    mem_after = proc.memory_info().rss
    return header, rows, elapsed, (mem_after - mem_before) / 1024 / 1024

这里有两个容易被忽略的细节。其一,newline=""是官方推荐写法,可避免某些平台下换行符被二次处理导致字段错位。其二,csv.reader返回的每个字段都是str,200万行×12列意味着约2400万个Python字符串对象,每个字符串对象还有额外开销,因此内存占用会显著高于文件本身。这正是基线要暴露的问题:csv模块的“轻量”只体现在依赖上,不体现在内存效率上。

如果只关心遍历而不需要保留全部数据,可以改用生成器逐行处理,内存会大幅下降,但这就改变了“全部读入”的口径,不能与pandas直接对比。基线阶段我们坚持“全量载入内存”,因为后续优化往往要在全量数据上做向量化计算。

三、pandas.read_csv的基线实现

pandas的默认read_csv会做类型推断、处理缺失值、构建索引,功能更强但默认开销也更大。基线代码非常短:

import pandas as pd

def baseline_pandas(path):
    proc = psutil.Process(os.getpid())
    mem_before = proc.memory_info().rss
    t0 = time.perf_counter()
    df = pd.read_csv(path)
    elapsed = time.perf_counter() - t0
    mem_after = proc.memory_info().rss
    return df, elapsed, (mem_after - mem_before) / 1024 / 1024

默认参数下,pandas会尝试把数值列推断为int64或float64,把低基数列推断为object。对逐笔数据而言,成交编号、证券代码这类列常被推断为int64,而买卖方向、时间字符串则保持object。object列内部仍是Python字符串指针数组,内存效率并不理想,这也是后续优化的重要切入点。此外,默认的C解析引擎速度较快,但类型推断本身需要扫描数据,会带来额外耗时。

测量内存时要注意:pandas读取过程中会产生临时对象,读取完成后部分临时内存可能尚未被垃圾回收。建议在计时结束后调用一次gc.collect()再采样,或直接使用df.memory_usage(deep=True).sum()统计DataFrame自身占用,两者结合看更准确。

四、两种基线的对比与判读

把两种方案放在同一台机器、同一文件、同一口径下运行,可以得到类似下表的示意结果(数值为教学假设,非实测):

方案耗时(秒)RSS增量(MB)数据类型主要开销来源
csv.reader 全量列表约 9.5约 2100全部为 str2400万个字符串对象
pandas.read_csv 默认约 4.2约 780数值列+object列类型推断与临时缓冲

判读这组基线要抓住三个要点。第一,耗时上pandas通常更快,因为C引擎的解析循环比Python层逐行append高效得多;第二,内存上pandas通常更省,因为数值列用定长类型存储,避免了海量字符串对象;第三,csv模块的优势在于可控性和低依赖,当你需要流式处理、字段级定制或环境不允许安装pandas时,它仍是合理选择。基线不是要判定谁“更好”,而是给出后续优化的参照坐标。

单文件逐笔CSV读取基线:两条路径与测量口径 tick_trades.csv 约200万行 × 12列 约260MB,UTF-8 csv.reader 逐行 每字段返回 str,append 到列表 pandas.read_csv 默认参数,C引擎,类型推断 2400万个 str 对象 内存膨胀,耗时偏高 数值列定长 + object列 解析快,内存相对可控 统一测量口径(保证可比) ① 计时:open → 数据全部载入内存,不含后续计算 ② 内存:读取前后 RSS 增量,配合 gc.collect() 与 memory_usage(deep=True) 交叉验证 ③ 重复:每方案 3 次取中位数,首次冷缓存单独记录 基线判读(示意值,需实测替换) csv.reader:约 9.5s / RSS +2100MB  pandas 默认:约 4.2s / RSS +780MB 结论:pandas 在速度与内存上占优;csv 模块胜在可控与低依赖。基线坐标已建立,后续优化以此为参照。

五、让基线可复现的实操要点

基线最容易出问题的地方不是代码,而是环境不一致。建议把测量封装成一个脚本,固定文件路径、编码、重复次数,并输出结构化结果,便于后续优化版本直接对比。下面是一个可直接改造的测量骨架:

import gc, json, statistics

def measure(fn, path, repeat=3):
    times, mems = [], []
    for _ in range(repeat):
        gc.collect()
        _, _, t, m = fn(path)
        times.append(t); mems.append(m)
    return {"time_median_s": round(statistics.median(times), 3),
            "rss_delta_mb": round(statistics.median(mems), 1)}

result = {
    "csv_reader": measure(baseline_csv_reader, "tick_trades.csv"),
    "pandas_default": measure(baseline_pandas, "tick_trades.csv"),
}
print(json.dumps(result, ensure_ascii=False, indent=2))

运行后应得到类似{"csv_reader": {"time_median_s": 9.5, "rss_delta_mb": 2100}, "pandas_default": {"time_median_s": 4.2, "rss_delta_mb": 780}}的输出。请务必把这里的数字替换为你机器上的真实结果,并记录CPU型号、内存大小、Python与pandas版本、磁盘类型。这些元信息是基线的一部分,缺了它们,后续对比就失去意义。

常见误区:用time.time()计时、只跑一次、或在Jupyter中带着已有变量测量内存。前者精度不足,中者受缓存与调度噪声影响大,后者会把内核已占用的内存算进来。基线阶段多花十分钟把口径做扎实,后面每一轮优化都能省下大量返工。

至此,我们已经有了一条清晰的参照线:同一文件、同一口径下,内置csv模块与pandas默认读取各自的耗时与内存表现。后续无论是调整dtype、分块读取、指定usecols,还是改用更底层的解析方式,都可以拿这组基线数字来衡量收益,而不是凭感觉判断“好像快了一点”。