那个夜晚,我的电脑自己重启了

某天夜里,一个长任务跑到一半,我的整台电脑自己关机又开机了。不是程序——是机器。前一刻还有个进程在运行;下一刻我就盯着一个全新的登录界面,仿佛有人拔掉了电源线又重新插了回去。

我花了好几天小心翼翼地守着那个我确信至关重要的资源。而崩溃却来自一个我从未想过要去盯的资源。

没有任何消息的崩溃

最令人不安的地方在于,可读的东西竟如此之少。没有堆栈跟踪,没有异常,没有标记结束的日志行。只有一次突兀的重启,以及埋藏在操作系统自己记录里的、相当于一个数字化耸肩的东西:系统在未正常关机的情况下重启了。

一个正常的 bug 会留下一具尸体。这一个却留下一间空房。而一个没有消息的错误本身就是一种线索——它通常意味着故障发生在你的程序之下的某个地方,发生在一个不会在拖垮一切之前停下来解释自己的层里。

我预算错了内存

我当时在 GPU 上跑一个非常大的批量任务,而我对那个批量的大小是精心估算过的——按照 GPU 的内存来估算。我知道这张卡有多少 VRAM,我大致知道每个工作单元会占用多少,我也做过算术,确保稳稳地待在上限之下。在我看来,内存是个已经解决了的问题。

它确实解决了,只不过解决的是错误的那块内存。这张卡的 VRAM 自始至终都好好的;如果崩溃那一刻我盯着它的仪表,它看起来会完全平静。我实际撞上的上限,是主机的系统 RAM——电脑其余部分运行所依赖的那块普通内存——而我对它根本没做任何预算,因为在我的心智模型里,它压根就不是约束所在。

主机 RAM 去哪了

把一个巨大的批量任务发往 GPU,在主机这一侧并不是免费的。在 GPU 能运行任何东西之前,这些工作必须先被准备好、布置好、再交接过去——而这份准备工作就住在系统 RAM 里。这套布置中的每一片,单独看都不大。可一旦乘以一个大到足以喂饱 GPU 的批量,这些不大的片便堆叠成了某种庞然大物。

系统 RAM 被填满了。操作系统做了它在内存吃紧时会做的事——它溢出到磁盘上,然后连磁盘也快用光了。严重的内存压力并不总是用一个可捕获的异常来宣告自己。它可能意味着一次失败的分配、一个被杀掉的进程,或者整台机器陷入不稳定。我这台陷入不稳定的程度,狠到足以重启。

我为什么从没料到

事后想来令人懊恼的是,我确实在认真监控——我只是监控错了那块仪表。我的眼睛盯着 GPU,那个我优化过、操心过、并为之自鸣得意的资源。而这整段时间里,压力正在一个我没有理由去看的地方积聚,因为我早已认定问题不会出在那儿。

这是一个我如今见过太多次、再也无法称之为巧合的模式:你给自己正在思考的那个约束装上仪表,然后对自己没在思考的那个视而不见。bug 并没有躲藏。它就坐在一个你自己亲手造出来的盲点里——而造出它的,正是你对极限位置所抱的那份笃定。

修复之道:预期那堵墙,然后弹开

修复不是一个更好的计算。它是放弃了”我能事先算出安全大小”这个念头本身。我没有去预测机器到底能撑住多大的批量,而是让机器来告诉我:尝试这个批量,如果它撞上一个可恢复的内存错误,就把那当作一个后退的信号——保守地缩减工作量并重试,一路收缩直到它放得下。

这是个谦卑的小循环,而它远比我那套精打细算的算术稳健得多,恰恰是因为它不依赖于我事先理解了每一项隐藏的成本。我不再需要确切知道那堵墙在哪里;对于可恢复的内存错误,无论跑在什么硬件上,这个进程都能摸索出一个可行的大小。像那一夜那样的主机级耗尽是更棘手的情形——它仍然需要外部的限制和监控,而不只是一个重试循环。

资源假设在变成灾难之前都悄无声息

我留下的教训跟 GPU 本身没什么关系。它是:资源假设是隐形的,直到它变成灾难的那一刻为止。你完全可以对自己想象中的那个极限判断得分毫不差,却依然被另一个你没想到的极限击垮——而故障一旦降临,往往不带任何解释,因为出问题的那一层,在你的错误消息所栖身的那一层之下。

于是我不再试图对资源做到精确,而开始试图对它们抱有防御。精确假定你了解所有的成本。防御假定你至少漏掉了一个。在一个本应连续运行数小时、无人值守、依赖着你并不完全掌控的内存的系统里,第二种假定是唯一安全的那个。

一个崩溃的程序会给你留下一条消息。一台重启的机器给你留下的是一片沉默。我已经学会把那片沉默当作我的硬件曾告诉过我的、最诚实的几句话之一:你预算错了东西。

— 没有信号,没有回报,这不是投资建议。