最近把一条把行情数据加工成训练样本的批处理管线从头到尾梳了一遍,挤出来的提速从 1.5× 到将近 8× 不等。这篇按"性价比从高到低"排,顺便记两个我一开始觉得很妙、结果根本没用的坑——希望大家少走。
这条管线大致几段:①分钟聚合(把 tick 压成分钟级序列)→ ②一段滚动统计(跨多日的窗口聚合)→ ③tick 特征落盘 → ④样本生成(组装成训练用的 npy)→ ⑤模型推理 + 下游计算(出逐标的的当日数值)。下面的改动散落在这几段里。
一句话总结:先 profile 再动手;并行化串行热点最便宜;存储格式选对是白送的;而下游的离散决策会放大微小数值差,验收要按这个尺度来。
第一刀(最赚):一个串行循环,在 256 核机器上只用了 1 核
①分钟聚合里有个逐标的的循环,一直是单线程跑完 ~5000 个标的。机器有 256 核,它用 1 个。改成并行算各标的、最后顺序写盘,再把 gzip 从 Optimal 降到 Fastest:
| 数据格式 | 改前 | 改后 | 加速 |
|---|---|---|---|
| parquet 线(样例日 A) | 89.0s | 11.3s | 7.9× |
| parquet 线(样例日 B) | 66.8s | 10.3s | 6.5× |
| 老格式线(样例日 A) | 95.7s | 22.7s | 4.2× |
| 老格式线(样例日 B) | 88.6s | 26.7s | 3.3× |
输出逐字节一致。一个 Parallel.For + 改个压缩档,拿到 6–8×——这是整轮性价比最高的改动。教训也朴素:热点是不是真在吃满核,值得先看一眼。
第二刀:样本生成的读取,CSV → parquet,1.5–1.9×
④样本生成要读海量行情。把存储从 gzip 文本(CSV.gz)换成 parquet 列式读取后(省掉 gzip 解压 + 文本→浮点解析两道开销):
| 并行度 | CSV.gz 读 | parquet 读 | 加速 |
|---|---|---|---|
| 32 | 28.1s | 15.1s | 1.86× |
| 64 | 15.7s | 10.3s | 1.52× |
| 128 | 10.4s | 9.9s | 1.05× |
低并发优势最大(瓶颈正是单文件解析);高并发时机器饱和、两者收敛。写盘速度则和 CSV.gz 基本持平——所以这是"写不亏、读白赚"。
第三刀:②滚动统计累计 2.6×,而且是被 profile 逼出来的
②那一段是整条链最慢的。这刀分两段,但真正的功劳是先做了 profile:
profile(单次运行,40 日回溯窗口):
加载输入 = 63.5s ← 78%
内层数值循环 = 14.4s ← 18%
其余汇总/落盘 ≈ 1.2s ← ~1%
瓶颈压根不在我一开始以为的"内层 N² 数值循环",而在加载。于是:
record→record struct(C#):中间结果是个 ~N² 量级的字典,逐日 add/subtract 在构造上千万个引用类型对象。改成值类型内联进字典,免掉每天数千万次堆分配。~1.4×,数值完全不变。- 加载并行化 + parquet 列式读:把那 63.5s 的大头打下来。~1.7×,输出逐字节一致。
累计:123.8s → ~48s,2.6×。
第四刀:全链路 parquet + float32,体积砍半还无损
把这条链的几处中间产物全换成 parquet,数值列再从 double 降到 float32(因为下游消费端本来就是 float 精度,存 double 纯属浪费):
| 数据 | double | float32 | 缩减 |
|---|---|---|---|
| 中间集 A(45 天窗口) | 1.4G | 837M | −40% |
| 中间集 B(单日数千文件) | 414M | 263M | −36% |
而结果与 double 版逐字节一致,个别派生量差异 ~1e-9(可忽略)。写盘速度与 CSV 持平,读取更快。纯体积/带宽收益,零精度损失。
顺手做了个对称的小工具:ColumnarParquetWriter(Cols("a,b,c") 声明列、Add(...) 链式写)+ ColumnarParquetReader(Strings/Floats/Doubles(名或序) 取整列),让读写 parquet 跟当年拼 CSV 一行一样顺,类型还能自动推断。
两个"看着聪明其实白忙"的坑
坑一:给内层数值循环上 SIMD。 我本以为 N²×N 的内层是热点,做了掩码点积 + 向量化。结果:没提速(92.6s vs 88.2s,还略慢),而且改了输出。原因前面 profile 已经写了——内层只占 18%,且向量长度太短,SIMD 的水平求和开销盖过收益。已回退。
坑二:把嵌套字典换成数组索引(省 N² 次字符串哈希)。 听起来很对。但 profile 显示它瞄准的那两趟合计只占 0.5%。改了等于白改,直接没做。
这俩坑是同一个教训:别凭直觉认定热点。 两次"聪明优化"都瞄错了靶子,profile 一跑就现形。
一个反直觉的收尾:换了存储格式,最终输出差多少?
换格式总担心数值对不上。把整条链跑到底,对比 parquet 线 vs CSV 线的最终逐标的输出:
| 样例日 | 标的数 | 完全相同 | 相关系数 |
|---|---|---|---|
| A | 6034 | 99.1% | 0.999928 |
| B | 6044 | 99.4% | 0.999931 |
| C | 5975 | 98.7% | 0.999683 |
注意:不是"所有标的都差最后几位",而是"~99% 完全一致、~1% 整段不同"。 因为下游有离散决策(阈值/排序这类非连续逻辑)——上游一点点数值差,偶尔会把某个标的"推过判定边界",结果就从一档跳到另一档,整段不同。
这是个值得记住的现象:离散决策(阈值、排序、top-N 选择)会把上游微小数值差放大成个别大差异。 所以换格式/换精度的验收标准,应该是"整体相关性极高 + 看清那 1% 差在哪、为什么",而不是"逐位一致"。
带走这几条
- 先 profile,再优化。 直觉认定的热点经常是错的(本轮两次踩坑都因此)。
- 并行化串行热点 = 最便宜的大收益(6–8×)。先确认热点有没有吃满核。
- 存储选对(列式 + 合适精度)是白送的:读更快、体积更小,且可做到无损。
- 下游有阈值/排序时,微小数值差会被放大成个别大差异——按这个尺度验收,别期待逐位一致。