10 月上旬,Python 策略在 Polymarket 上的回放时间系数一周里上调三次。10-11 的测量和修改记录里有两块明显开销:worker 往容器喂数据时逐行 write,以及冷解码时逐行把 chunk 交给 gzip。前者占回放时间,后者占取数时间。
系数一路往上调
回测的墙钟预算按「秒/市场日」乘市场日数算,系数按(平台, 语言)分开定。polymarket/python 原来的 120 秒是按 Node 的比例推出来的,没实测过。
- 10-07:按生产成功 run 重标,记录的每市场日耗时上界为 110 到 135 秒,包含取数。前一天一个模板 run 在宿主降速时超时,提交记录称慢了约 45%;同一源码在 20 分钟后、重叠的数据日期上正常完成。系数改成 175。
- 10-08:宿主变慢时,同一个轻策略回放约 31 µs/事件,折合每市场日 195 秒左右,175 秒连杀三单。改成 210,单次上限从 36 降到 30 个市场日。
- 10-11:刚上线的挂单排队模型让 Python 引擎每个事件更贵,生产上 spread-quote 做市模板跑一天要 198 秒,贴着 210。再改成 260,单次上限降到 24。
回放上限固定为 108 分钟,可售市场日上限还要扣掉 3 分钟基础预算,再除以系数。
10-08 还查出一个让 run 超时的外因:预热。worker 每天会把可售窗口里的市场日提前解码进本机缓存,同一份代码、同一个区间,04:28 没有预热时 13.0 分钟跑完,05:01 预热开跑后两次都在 14.7 分钟的预算处被杀。预热进程已经是 Nice=19,在 2 个 vCPU 上挡不住。改成有客户 run 在跑时预热主动暂停,检查点放在打开对象前、每个 chunk、每个市场收尾三处;查库失败时,已经暂停的保持暂停,没暂停的放行并退避。
先量一个空读者
要分清时间花在策略、harness 还是 worker 喂数据上,我写了一个测量脚本 scripts/bench-sandbox-replay.mjs,可以把同一个市场日分别喂给真沙箱、空读者或者 /dev/null。
在生产 worker 上,经 docker 转发给空读者,一个市场日就要 109 到 132 秒,两个核打满。当时做市模板单日回放记录为 198 秒。
原因在 createLineWriter。它是 worker 和 ot run 共用的喂数据函数,负责处理背压和 EPIPE,原来每行调一次 stream.write()。在真实管道上,这就是每行一次 write 系统调用。一个 BTC 市场日 590 万行、808MB,写进 /dev/null 花掉 worker 35 秒 CPU,写进管道 73 秒,读的那一端平均每次只读到大约 320 字节。
改成累计到 64 × 1024 个字符再写。ASCII 事件行约为 64KiB;阈值按 JavaScript 字符串长度计,不是严格的字节上限:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const WRITE_BATCH_CHARS = 64 * 1024;
const write = (line) => {
if (writeError) return Promise.reject(writeError);
if (wakeDrain) return Promise.reject(concurrent());
if (ended) return Promise.reject(new Error('createLineWriter: write after end'));
pending += `${line}\n`;
return pending.length < batchChars ? Promise.resolve() : flush();
};
write.end = async () => {
if (ended) return;
ended = true;
await flush();
stream.end();
};
harness 仍从自己的读缓冲逐行取事件,批量写保持输入字节和顺序不变。
批量带来一个新约束:行可能停在 writer 里等下一次 flush,所以流必须经 write.end() 结束。直接 stdin.end() 会丢掉最后一批,两边「回放的市场比喂进去的少」的检查会把 run 拒掉。测试里除了断言字节和顺序不变、写次数不超过 ceil(总字符/64K)+1,还扫了 worker 和 ot run 的源码,禁止出现 stdin.end()。
测量脚本自己的两个坑
测量脚本在 worker 上跑之前要先确认机器空闲,不能和客户的 run 或预热抢 CPU。判断「有没有预热在跑」用的是:
1
timeout $T pgrep -f 'prewarm-archive.mjs|fetch-binance.mjs'
pgrep -f 匹配完整命令行,而 timeout 进程自己的命令行里就带着这个模式,所以它永远能匹配到自己,脚本永远认为机器在忙。改成 '[p]rewarm-archive.mjs|[f]etch-binance.mjs',正则仍然匹配目标进程,但不再匹配包含方括号的字面字符串。
第二个是计数开销。批量写之后一次 write 带很多行,我在包装 sink 的地方逐块扫换行符来数行数,结果在 worker 上给被测进程加了大约 25 秒 CPU。测量本身成了瓶颈之一。后来只计字节数和写次数,完整性改由读者那边按字节核对。
冷解码:每行一次 zlib 线程池往返
另一块是冷解码,就是一个市场日不在缓存里、要从原始归档现解的时候。解码会把每个市场的事件写成一个 .jsonl.gz 落盘,原来是这样:
1
2
3
4
5
await pipeline(
Readable.from(inWindow.map((e) => `${JSON.stringify(e)}\n`)),
createGzip(),
createWriteStream(path.join(eventsDir, tmpFile)),
);
每行一个 chunk,会反复经过 zlib 线程池。提交记录中的采样显示,主线程 59% 的时间在空闲等待。改成累计约 1Mi 个字符再交给 gzip,同样按字符串长度计:
1
2
3
4
5
6
7
8
9
const GZIP_CHUNK_CHARS = 1 << 20;
export function* gzipChunks(events, max = GZIP_CHUNK_CHARS) {
let buf = '';
for (const e of events) {
buf += `${JSON.stringify(e)}\n`;
if (buf.length >= max) { yield buf; buf = ''; }
}
if (buf) yield buf;
}
BTC 5 分钟市场的一天样本,冷解码从 245.9 秒降到 50.4 秒。该次对照的 289 个输出文件逐字节相同,因此没有更换已有解码缓存的路径或使其失效。这是这次实现和样本的验证结果。
同一天还测了 .gz 与 Parquet,用 runner 的选文件函数和读取边界,并核对三方行数。历史规则记录输入解析只占冷解码约 19%,解码缓存存成 Parquet 也不省空间,因此这次没有把回测输入改成 Parquet。
harness 里的重复劳动
剩下的才是 harness 和引擎本身(镜像 1.21.2),几处都是零结果变化:
- harness 里有一份盘口,只用来找开盘时的报价,但它对每个事件都更新。回放自己另有一份。现在拿到开盘报价后就不再更新,两个 harness 同改。
- Python harness 解析每行改用 C 实现的 scanner,行不完整时回落到
json.loads。 - Python 引擎的
to_ticks按值缓存。一天的盘口事件要调用几千万次,而价格只有几百个不同的值;结果是纯函数,缓存是精确的,NaN 不进缓存。 - 快照排序用
itemgetter,热路径去掉重复的ev.get,tracking 部分改成普通循环。
本地样本上,做市模板从 72.6 秒降到 55.1 秒,临近结算吃单模板从 36.9 秒降到 21.8 秒,报告和成交逐字节相同。worker 上另外加了一个按 ABBA 顺序(1.21.1、1.21.2、1.21.2、1.21.1)对比两个镜像的测量清单,用来抵消宿主机快慢随时间的漂移。
系数还没往回调
截至这篇记录的 10-13,polymarket/python 系数仍为 260 秒/市场日。scripts/replay-rate-report.mjs 按平台 × 语言统计成功 run 的秒/市场日,按部署时间分段,跨段 run 不计入。它使用的 scanned.wall_ms 含取数,是回放耗时的上界;新系数尚未确定。
预热那边反而要更多时间。10-13 发现 Polymarket 35 天窗口单形态要预热 735 次,实测大约 1.75 次/分钟,原来每平台 240 分钟的预算只能覆盖约 18 天。改成每平台 450 分钟,systemd 的 RuntimeMaxSec 按同一算式从 12 小时放到 19 小时。前一天改成声明 book 时自动带上 bbo,预热只剩一种形态,否则要预热的形态不止一种。
outcometick 工程笔记(21/21)