如何编写可维护的跑实验代码

当你实现了一个工具,需要在不同配置下跑实验的时候,跑实验的代码很可能变得越来越复杂,以至于难以维护。

Python dict转array

比如你有一个实验数据表示不同工具的时间消耗{"tool A": [1.1, 2.0, 3.9, ...], "tool B": [...], "tool C": [...]}。然后你想要画一个散点图。但是散点图需要直接传入内部数据的数组,此时,似乎自然而然地就会直接list(data.values())

血和泪的教训。。。实验数据可能会产生错位!!!因为.values()调用不会保证顺序!!还是要按照自己图上的label,从dict里好好一个个取出,才能正确地按顺序得到list。

善于@cache减少重复运算。

基于Jupyter notebook分块运行python代码非常方便。将实验数据收集划分为不同的函数,然后加上@cache修饰,使得重跑时不会重复运行代码。

如果用python,首先定义一个实验数据类,指定每个实验结果文件夹,然后定义一些获取实验数据的成员函数,需要参数的用@cache修饰,不需要参数的用@cached_property修饰,这样无需考虑反复调用获取的复杂度问题。

1
2
3
4
5
6
7
8
class NSStats:
native_methods = None
def __init__(self, result_path:str) -> None:
self.result_path = result_path

@cached_property
def time(self):
return match_unix_time_file(f"{self.result_path}/docker_stderr.txt")

然后可以利用类似defaultdict的思想,自动在取dict的时候实例化我们这个类。只需要直接拿结果文件夹路径访问这个dict,然后访问参数直接得到对应的实验数据。

1
2
3
4
5
6
7
8
9
10
class NSDataDict(dict):
def __missing__(self, key):
self[key] = NSStats(key)
return self[key]

ns_data_dict = NSDataDict()

for folder in os.listdir(xxx):
st = ns_data_dict[f'{BASE}/{folder}']# type: NSStats
time = st.time

标记正常运行完成,而不是被杀或者超时

在跑实验的脚本里使用touch创建对应的文件,例如 touch XXX_FINISHED 表示已经完成,通过检测对应文件是否存在判断是否运行完成。

如何测量内存和时间

建议优先使用/usr/bin/time -v。如果实验在docker里运行,则修改docker容器,在docker内部使用/usr/bin/time

我之前实验在docker里运行,于是开始搜索是否有监控docker容器运行的内存和时间的工具,发现没有之后就开始用python脚本自己写了一个。写起来很折磨,因为其实考虑清楚细节挺复杂的。最后选择了监控docker stats的输出的方式,统计每个容器的时间和内存。最让人沮丧的事情是,最后分析实验数据的时候发现,自己测出来的时间不准!!最后很让人崩溃

如何控制命令运行超时

使用timeout命令,同时加上--kill-after=60s防止命令因为卡死不退出的情况。即使使用了docker,也不要使用docker kill等命令,而是修改docker镜像,在内部使用timeout。

结合测量时间和内存的话,通常需要在运行实验的命令前加上一长串(/usr/bin/time -v /usr/bin/timeout --kill-after=60s $TIMEOUT ...)。虽然一开始可能感觉奇怪,但实际上相比其他的方案会简单很多。我也在python脚本里集成了监控运行的docker容器,超时调用docker kill的功能,最后导致复杂度的暴增,有时也无法有效地停止卡死的容器。

一个可能的问题是,如果强行终止程序,即使有了一些结果程序也没保存下来。这时候建议同时设置这个硬超时,和程序命令行里的软超时(略小于硬超时时间)。

如何多进程并行运行实验,同时控制并行数量

使用python脚本将需要运行的命令打印出来,需要顺序运行的命令之间可以用分号隔开。使用\x00作为并行运行命令的分隔符,并通过管道传给xargs命令(例如:python cmds_print.py | xargs -0 -I CMD --max-procs=1 bash -c CMD)。即使是用docker运行,也应该把docker run命令打印出来然后用该方案运行。

这种方案的另外一个好处是,可以先将需要运行的命令收集起来,然后按照想要的方式打印出来作为执行顺序。有时候会发现,自己运行的实验命令是一个二维的情况,生成的时候按照参数配置生成方便,但是如果运行的时候按照数据集运行,则便于自己在实验没完全跑完时手动分析处理。此时可以将运行的命令矩阵转置一下(list(*zip(arr))),再打印出来。

之前没有发现这个方案,又自己写并行运行的方案。写出来也很复杂。。

如何在文件数据集的子集上分析

为这个数据集创建新的文件夹,内部使用软链接的方式,链接到原数据集文件夹。通过转换为在一个新的数据集上分析的问题,复用分析单个文件夹的逻辑。

如何给实验数据画图

(最好想好自己要画的图的类型)将需要画图的数据转换为简单的格式,如json/csv。然后将数据文件发送给GPT4,说明需求,让GPT4的code interpreter功能为自己画图。

不得不说,我在matplotlib seaborn等画图库上调试和debug的时间很多。数据可视化已经是GPT4能干得不错的事情。

实验数据如何存储,pickel序列化?,存数据库?

使用tee命令或者输出重定向的方式,将实验的相关原始输出都记录下来,包括/usr/bin/time -v的输出。数据完全不存储数据库,而是按需直接访问对应实验文件夹,在输出中匹配得到。

本质上实验数据输出的原始文件夹,也可以看作一种“数据库”。如果还要单独存一份的话,那一方面要维护一个实验数据提取脚本,一方面要维护从数据库读取然后转换画图的脚本。时刻警惕复杂度的上升,不然它会成为软件工程的灾难和自己的噩梦。

善用环境变量传递参数

当整个实验环境被打包,在复杂的模块套模块的情况里,如果要通过最外层命令行设置里面模块的参数,则需要将参数一直保持和传递,极大地增加了复杂性。然而可以通过环境变量传递参数,在最外层设置环境变量,在内部模块则可以直接获取到(例如python,os.environ['XXX'])。