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解析引擎速度较快,但类型推断本身需要扫描数据,会带来额外耗时。
gc.collect()再采样,或直接使用df.memory_usage(deep=True).sum()统计DataFrame自身占用,两者结合看更准确。四、两种基线的对比与判读
把两种方案放在同一台机器、同一文件、同一口径下运行,可以得到类似下表的示意结果(数值为教学假设,非实测):
| 方案 | 耗时(秒) | RSS增量(MB) | 数据类型 | 主要开销来源 |
|---|---|---|---|---|
| csv.reader 全量列表 | 约 9.5 | 约 2100 | 全部为 str | 2400万个字符串对象 |
| pandas.read_csv 默认 | 约 4.2 | 约 780 | 数值列+object列 | 类型推断与临时缓冲 |
判读这组基线要抓住三个要点。第一,耗时上pandas通常更快,因为C引擎的解析循环比Python层逐行append高效得多;第二,内存上pandas通常更省,因为数值列用定长类型存储,避免了海量字符串对象;第三,csv模块的优势在于可控性和低依赖,当你需要流式处理、字段级定制或环境不允许安装pandas时,它仍是合理选择。基线不是要判定谁“更好”,而是给出后续优化的参照坐标。
五、让基线可复现的实操要点
基线最容易出问题的地方不是代码,而是环境不一致。建议把测量封装成一个脚本,固定文件路径、编码、重复次数,并输出结构化结果,便于后续优化版本直接对比。下面是一个可直接改造的测量骨架:
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,还是改用更底层的解析方式,都可以拿这组基线数字来衡量收益,而不是凭感觉判断“好像快了一点”。