你已经运行了科学模型的批处理程序,经过数小时的计算后,它输出了一个结果。然而,结果是错误的。
你怀疑计算中存在错误,但不确定具体是什么问题,而缓慢的反馈循环使得调试变得更加困难。
如果能不花费数天时间运行程序就能调试并加速它,那该多好?
虽然我不是科学家,而是一名软件工程师,但我曾在科学计算领域工作了一年半。我想提供一个解决这类问题的潜在方案:日志记录,特别是我和同事们发现非常有用的一个日志库。
但在介绍解决方案之前,有必要先了解这些问题的根源:科学计算的特定特性。
从我们的角度来看,科学计算具有三个特定特性:
每个特性都伴随着相应的问题需要解决:
在本文的其余部分,你将看到日志记录如何帮助解决这些问题。
你的批处理程序终于完成了——它只花了 12 个小时——但结果是明显错误的 。
更糟糕的是,这种情况通常只在使用真实数据时发生,因此很难通过测试重现。而且,你无法在一个需要 12 小时运行的程序中使用调试器逐步调试代码!
你需要的是批处理程序运行时实际操作的记录。
也就是说,你需要记录:
我倾向于使用 Eliot 日志库 来为科学计算添加日志记录。Eliot 的工作方式与大多数日志库非常不同,虽然它最初是为分布式系统设计的,但它也非常适合科学计算。
为了理解这一点,让我们看一个例子。
考虑以下程序:
def add(a, b):
# ... 实现 ...
def multiply(a, b):
# ... 实现 ...
def multiplysum(a, b, c):
return multiply(add(a, b), c)
print(multiplysum(1, 2, 4)) # (1 + 2)*4 ⇒ 12
正如注释所说,我们期望输出为 12
。但当我们运行它时:
$ python badmath.py
0
我们得到了 0
,而不是 12
——代码中有些地方出错了。
因此,我们使用 Eliot 添加了一些日志记录:
out.log
。@log_call
装饰器,记录每个函数的输入和输出。Eliot 还有其他更复杂的 API,但我们在这里不会深入讨论。
from eliot import log_call, to_file
to_file(open("out.log", "w"))
@log_call
def add(a, b):
# ... 实现 ...
@log_call
def multiply(a, b):
# ... 实现 ...
# 等等。
现在我们已经添加了日志记录,我们可以运行程序,然后使用 eliot-tree 程序可视化结果:
$ python badmath.py
0
$ eliot-tree out.log
80bee1e8-7e95-43f9-a7d1-8c06fcb43334
└── multiplysum/1 ⇒ started 2019-05-01 20:54:57 ⧖ 0.001s
├── a: 1
├── b: 2
├── c: 4
├── add/2/1 ⇒ started 2019-05-01 20:54:57 ⧖ 0.000s
│ ├── a: 1
│ ├── b: 2
│ └── add/2/2 ⇒ succeeded 2019-05-01 20:54:57
│ └── result: 3
├── multiply/3/1 ⇒ started 2019-05-01 20:54:57 ⧖ 0.000s
│ ├── a: 3
│ ├── b: 4
│ └── multiply/3/2 ⇒ succeeded 2019-05-01 20:54:57
│ └── result: 0
└── multiplysum/4 ⇒ succeeded 2019-05-01 20:54:57
└── result: 0
如果你查看日志,你会看到一个操作树:
multiplysum()
接受三个输入(1
、2
和 4
)。add()
,参数为 1
和 2
,并返回 3
。multiply()
,参数为 3
和 4
,并返回 0
。0
是 multiplysum()
的最终结果。现在你可以看到问题出在哪里了:在 multiply()
函数中,因为 3×4
绝对不等于 0
。
简而言之,Eliot 的日志模型将日志记录为一系列操作,使你能够追踪逻辑和数据的流动。
你可能会遇到的第二个问题是计算速度慢——还记得那个 12 小时的批处理作业吗?通常的解决方案是性能分析,但分析器有一些局限性:
f()
在某些输入上可能很快,但在其他输入上非常慢——但分析器只会告诉你 f()
平均较慢。Eliot 也可以在这方面提供帮助。首先,它支持跨多个进程的跟踪,包括对 Dask 并行计算框架的内置支持。
更重要的是,Eliot 可以告诉你日志中跟踪的操作的经过时间和输入。
double()
何时变慢?考虑以下慢速程序:
@log_call
def main():
A = double(13)
B = double(0)
C = double(4)
return A * B * C
main()
鉴于其简单性,我们知道速度慢是由于 double()
,但在更复杂的程序中,我们可以通过分析器来确认这一点。但具体是 double()
的哪个输入导致了速度变慢——是其中一个?还是全部?
我们使用 Eliot 运行程序,然后仅提取 double
操作的日志:
$ python slow.py
$ eliot-tree out.log | grep -A1 double.*started
├── double/2/1 ⇒ started 2019-04-24 19:17:00 ⧖ 0.0s
│ ├── a: 13
--
├── double/3/1 ⇒ started 2019-04-24 19:17:00 ⧖ 10.0s
│ ├── a: 0
--
├── double/4/1 ⇒ started 2019-04-24 19:17:10 ⧖ 0.0s
│ ├── a: 4
我们可以看到,double()
对于输入 13
和 4
几乎没有花费时间——但对于输入 0
,它花费了很长时间。
通过记录操作的经过时间及其输入,Eliot 可以帮助你精确定位哪些输入导致了速度变慢。
最后一个问题是信任——在你分享你的工具或结果之前,你需要相信它确实做了它应该做的事情。当你分享你的工具或发现时,你会希望其他人相信你的论点和结果。
一些确保信任的技术包括:
但即使有了所有这些技术,如果你的软件是一个不透明的黑匣子,结果将更难被信任。也许你过度拟合了历史数据,或者返回了看似合理但错误的结果。
因此,为了获得信任,你还需要提供一个关于软件操作的连贯解释:
Jupyter 是一个擅长呈现解释的工具。你可以在代码执行之间穿插可视化和文字解释,将科学软件变成一个自我解释的叙述。
Jupyter 的问题在于它与软件工程的开发方式不太兼容。例如:
Eliot 是另一种解释软件工作原理的方式,因为它可以提供程序执行的跟踪记录,包括计算的中间结果。由于它是一个日志库,而不是像 Jupyter 这样的运行软件的方式,因此它与标准软件工程开发实践很好地集成。
缺点是,它提供的解释通常只对代码作者有意义。你不能合理地与他人分享 eliot-tree
的输出。
因此,虽然它可以帮助你增加对自己软件的信任,但它无法帮助你获得他人的信任。
日志记录将帮助你:
当然,为了获得这些好处,你需要在批处理作业开始神秘失败之前添加日志记录。所以花几分钟时间 了解更多关于 Eliot 的信息,然后为你自己的软件添加日志记录。