沉默的代价 —— 来自 zip 的教训
目录
波澜不惊
记不太清楚那天的天气了。我像往常一样起床上班地铁轰隆一小时。
到公司抓紧时间写好处理数据的代码,然后告诉运维帮我开 24 台 GPU 服务器。
“ip.txt”
没过一会儿,运维甩给我一个包含 24 个服务器 IP 的文件。
“我有一些依赖要安装,代码要放上去,还得配 aws,难不成我要一台一台连接上去,然后一台一台敲上去?”
我咨询了下运维有没有什么捷径可走,运维说 xshell。此时的我有点疑惑,因为我之前几乎没怎么用过 xshell,向来都是 VS Code 和 Windows Terminal。
下了一个 30 天试用版,地方也好找,“发送键到所有会话”,啪啪啪在一台机器上输入命令,命令马上复制到了其他机器,很快就部署完了。
开始启动 1000 核的 client,开个 htop 看到 client 这边波澜不惊,开个 nvtop 发现 server 那边也是波澜不惊,只不过是高水位的波澜不惊。
我去接杯水,看着他们波澜不惊,我心里也波澜不惊了。
惊涛骇浪
由于我是处理完一批上传一批结果的,所以最终全部处理完之后,需要合并一下结果。单看每一份文件似乎没啥问题,但是合并完去重之后发现,非常多重复的,理论上来说是不可能的,肯定是哪里出了问题。
于是我回去翻代码,看看是哪里写重了还是循环里变量重复使用还是怎么回事,然后我把注意力放到了下面的这段代码上:
1 | texts = ['Here', 'are', 'some', 'texts'] |
注意最后一行的 zip
,第一个是每个样本对应的 id,shape 为 (len(texts),)
;第二个是样本对应的概率 shape 为 (batch_size, num_labels)
。
所以问题就来了,len(texts) != batch_size
,也就是说 zip 的两个参数长度不等。那么此时 Python 会怎么办?
取短舍长。
当较短的参数消耗完时,迭代就会停止:
1 | list(zip(range(3), ['fee', 'fi', 'fo', 'fum'])) |
而不是抛出异常,甚至 warning 也不会抛出。所以运行时你不会发现任何问题。这就很危险了。
幸好 Python 社区也注意到了这个问题,2020 年 Brandt Bucher 发了一个 PEP 618,提议为 zip 函数增加一个参数 strict
以进行长度检查。该 PEP 最终通过并合并在了 3.10 版本中。所以如果你是用的是 >=3.10 的版本并且想要两个参数完全相等,那么可以指定 strict=True
来强制限定,如果长度不等则会抛出 ValueError
异常:
1 | list(zip(range(3), ['fee', 'fi', 'fo', 'fum'], strict=True)) |
所以,我得改下代码重新跑,再花一次 24 台 GPU 服务器的钱。虽然钱不是我出,但是还是觉得挺愧疚。
风平浪静
由于各种原因,切版本是不太现实的。所以直接将 ids
改为 batch_ids
即可。但为了以后一旦写错让程序抛出异常,我还是在 zip
前手动加了一个 assert
来进行长度检查。以后在涉及 zip
的地方,一定要多加检查,使用 assert
或者 strict=True
来显式抛出异常。Python 的默认行为也太容易出错了,我觉得至少得抛出个 warning 吧。
所以,zip,顾名思义,拉链。拉链的两边长度不等时,根据生活经验,你只能拉到较短一边的尽头。Python 中的 zip
同理,如果 A 和 B 长度不等,那么 zip(A, B)
的结果长度就是 min(A, B)
。这不是问题。
问题在于,这是静默发生的。
算是一个自己不小心挖的坑吧,修复 bug,再次运行,回归风平浪静。