跳转至

CHANfiG: Easier Configuration

摘要

CHANfiG目前已经初步完成了开发目标,短期内可能就是完善一些测试准备1.0的发布了。 在这里,我希望分享一下写CHANfiG的心路历程。

诞生

大概是前年我在西湖的时候,我越来越感觉受不了MMDetection那繁琐的配置文件机制。 每次接触一个新任务,第一件事是顺着配置文件中复杂的继承机制在乱七八糟的文件中理配置结构。 还经常遇到修改一个参数没有生效,然后再翻半天配置文件去找是哪里出现问题了。 每次修改都要找半天配置文件在定位具体行再修改,也实在是太烦了。

于是,在跟思远大佬吐槽很多次之后,我诞生了写一个自己的框架的写法(也就是丹灵)。说起来容易做起来难,这个项目我先后开始过很多次,后都因为各种原因暂时搁置。 直到目前还处于develop阶段,XxxHub上master分支甚至是空的(虽然其实已经有过三四个项目是基于丹灵写的了)。

我最开始以为一个框架的核心在于Runner,这多简单,我十分钟就能撸一个出来(这里应用了夸张的修辞手法,实际上两三分钟应该就行)。 但前面很多次都没有继续下去的核心原因在于:我没法让丹灵和MMCV(现在是MMEngine)不一样。 我最核心的诉求其实就两点:

  1. 便捷的查看/跳转继承和调用的类
  2. 通过命令行随意修改任何配置

第一点其实很简单,只要我写成配置类而非dict,就可以利用LSP的跳转功能。

但第二点,卡了我很久很久。因为我不只是需要接住所有不在namespace中的参数,而且要创建一种具有层级关系的结构。 去年这个时候,我在StackOverflow上提了一个问题。 当时有一个人评论说需要自定义Namespace和/或Action才能实现。 直到今年五月份,我才有机会去深入看argparse的实现,也给了这个问题第一个回答–这其实也正是第一版的CHANfiG。

总结来说,很简单。 想要不报Unknown Args错误只要在parse之前首先读一遍所有传进来的参数,然后创建它。 至于嵌套,只需要处理所有的”.”,先创建一个新的CHANfiG对象然后把剩下的塞给他就好。 后来我也发现,当你写这样一个底层的库的时候,绝大多数操作都简单的让你怀疑人生(虽然……动不动就是一个RecursionError也很让人怀疑人生就是了……)。

结构

__dict__

第一版的CHANfiG总共只有50行代码,其中包括13行import和空行和3行注释。那时,CHANfiG还是一个Namespace的子类。甚至只能转换为一个dict,而无法从dict构建回来(笑)

但其实问题不大,解决了最大的问题,剩下只要改吧改吧加点儿功能就够了吧。 至少当时的我是这样以为的。 我还是太年轻了。

最开始的需求在于,Config对象需要有一些自己的属性。 比如说,YACS的CfgNode是支持冻结的,这总得要支持吧。 当时我就斯巴达了,这我特么怎么支持。 我们都知道,Python的__dict__是存储属性的,但这玩意儿我已经用来存配置了啊。 于是在很长的一段时间当中,如果你冻结了Config,那么它的__dict__中会多出来一个_frozen来表示他被冻了。 这怎么能忍? 叔可忍婶不可忍!

_storage

后来我在xx实验室工作的时候,跟MMEngine的同学聊起设计,决定把配置从__dict__中抽出来,变成一个和__dict__平级的变量,它被称作_storage

但对这个东西,我其实是不太满意的。因为_storage看上去就很像一个合法的配置值,用了这个名字的话可能会产生冲突。 当然我们也可以把它起名为__asotnareiolybxcmqwfpkywqfpuglzcxebnoarstlyqwofplnyusxczvkyowfupfqwfkczyuvqyfntqwkcquzixelpu__,这样可以在保证99.999999999999999999%的情况下不会遇到冲突。 但一来这未免看上去有些过于抽象/鬼畜,二来他并不是一。 只要不是1,根据墨菲定律,就一定会发生。 这对我来说很难接受

OrderedDict

让我们再次梳理一下,我们现在的需求是,一个嵌套的dict来存配置+一个普通的dict来存属性,但只有一个__dict__名字空着。 听上去似乎有点儿无解?

问题不大。

应该是我在存结果的时候也需要一个支持嵌套的dict,于是我把CHANfiG的Dict定义部分和Config部分分开了。 这个时候我突然意识到一件事情。 Python自带的dictOrderedDict)对象本身就是可以存储东西的,这使得他们的__dict__空出来根本没用到。 天底下那里有这种好事。 我甚至连.items().keys().values()都不用写了。

于是,我重写了OrderedDict类,然后基于它写了NestedDictConfig

这也给丹灵带来了莫大的好处,原本丹灵的Runner继承了Config类(我希望让self.xxx可以直接访问到config.xxx)。 我最开始的想法是在Runner中定义好所有的函数,暴露出来一个__init__函数来定义参数。 但我这样有很多额外的问题(抱歉时间太久了我也不记得具体出啥问题了……)

通过把NestedDictConfig解耦,我将Runner__dict__替换成了NestedDict对象。 你可能要说了,竟然还有这种操作? 没错,就是有这种操作。 事实上,Python文档对于__dict__的描述是这样的:A dictionary or other mapping object used to store an object’s (writable) attributes. 惊不惊喜?意不意外?

当然,Python还是有些问题的,虽然(至少截至目前)他还没给我造成什么影响。

Variable

你以为到这儿就要完了吗,快醒醒!

九月份吧,CHANfiG迎来了它的第一个issue(qswl这么有意义的东西居然被别人给抢了)。

在这里,他提出了一个需求,如果有多个意义相同的变量需要一起修改怎么办? 大家都知道,常见的intfloatstr这些都是不可变对象。 只要让他们变成可变对象不就好了,你可能会说。 嗯,我们也是这么实现的。 具体怎么变呢,创建一个list包一下就行。 然后,我写了,310行。 这包括6个判断等于方法和不计其数的计算方法。 等下你让我先平复一下心情。

后来我发现google写了一个ml_collections,于是观摩了一下代码库。 第一反应是写的都是些什么垃圾,甚至还不如Facebook写的。 后来甚至还发票圈吐槽过他们居然在Variable上用了计算图,好家伙。

但现在其实也在思考这是不是更优选择。 目前,Variable支持一个值有很多个名字。 但没法支持一个值是另一个值的几倍。 或许以后会加吧,hhh。

总结

不知不觉,CHANfiG已经从一个50行代码的小项目成长为一个2000行代码的不小的小项目了。

我将其定位成一个配置文件的底层库。 因此,我放弃了很多减少代码量的手段,因为我想尽可能的减少函数调用。 甚至当你看import的时候,你会发现我甚至没有用import xxx来进行包导入(其实目前还有一个),因为这会在执行的时候额外进行两次函数调用。 也是因此,和我知道的其他库(yacsEasyDictml_collections)相比,CHANfiG都要更快,也更节省内存(空间就是时间)。

这个诞生自丹灵的库最终成为了丹灵继续开发的最大障碍–我跟MM的开发者说完之后,CHANfiG很有可能会成为MMEngine的一部分,从而被所有MM系列库所使用。 还是有些小感慨吧~


路漫漫其修远兮,吾将上下而求索。


壬寅年大雪

于海淀路