仓库源文

.. Kenneth Lee 版权所有 2025

:Authors: Kenneth Lee :Version: 0.2 :Date: 2025-03-10 :Status: Draft

填空问题


设计文档的“填空”问题我在单位内部写过多个博客进行批判。这两天评审一个设计文档的 时候又发现多个例子,我就想补充到到这里来。才发现这里从来没有讨论这个问题,所以 我就着记录这些例子的机会,在这里也总结一下这个问题。

我们做设计,是把产品做成的需要,对于一个较大的产品或者软件,我们要考虑的,会遇 到的问题都非常多,我们经常要在无数的细节中找到问题的“主要矛盾和主要的矛盾方面”。 为了保证我们正确抓住这些主要矛盾和主要的矛盾方面,我们需要建立一个或者多个“视 图(View)”:我们看到了产品设计的无数细节,我们挑出某个方面的关键信息,把这些 信息组合到一个逻辑空间中,从而可以在一个封闭的空间中对问题进行推理。

具象化举一个例子:你写一个内存分配函数,你看到的细节是函数入口参数(比如size), 可以分配的内存的整个空间。但凭这个信息你可以写好这个程序吗?很难,你要分别考虑 如下视图:

  1. 有多少个线程按什么样的规律访问这片内存?
  2. 你提供的API的全集是什么?除了可以malloc,还可以realloc吗?会有对齐要求吗? 会有缺页保护的要求吗?会对内存使用的NUMA节点有要求吗?
  3. 出错要循什么出错流程处理?直接系统异常?返回错误码?设置全局错误变量(比如 errno)?还是自动修复?
  4. ……

这些每个考虑的角度,就是一个设计方面的视图。不独立考虑一个个这样的视图,只盯着 malloc这个函数的实现,你没法写好这个函数。而这些视图基本上没法写成程序,到头来, 它们就会变成设计,或者呈现为设计文档。

我这里举的例子还是相对简单的,很多这种设计只简单“记在心中”就可以。但如果把这个 要求放大,比如变成“设计一台服务器”,或者“设计一个指令集”,不落实成文档,几乎没 有办法维护这么多复杂的逻辑。不管理这种设计,我们通常也能设计出产品——的第一个版 本,甚至运气好一点——的几个版本,但基本上都延续不下去。因为产品初期就几个功能几 个场景,有大把的余量让你规避冲突,但等功能都加上来后,这个你怎么加逻辑都没法不 和其他功能冲突了。

如果要对应前面的例子,你的内存分配可能也就一个线程访问,所以你不上锁也无所谓, 或者你上了锁,性能也不会受影响。但如果变成有多个线程访问呢?访问的并行度需要很 高呢?中间还有可能在信号处理和中断处理中调用呢?一开始的逻辑空间如果没有考虑这 些要素,后面的逻辑根本就补充不进去。甚至可能你一开始就没有考虑过这个问题,就这 样加入了很多的代码,之后你在多线程访问下逻辑到底可以自洽还是不能自洽呢?你根本 就不知道,因为你又看不完那么多代码,你怎么知道怎么代码里面具体有没有考虑到两个 线程上下文同时访问会不会错呢?

这就是设计的必要性,没有设计,长远来说,代码根本很难维护,甚至都不用进入维护阶 段,在开发阶段可能就会因为第一波需求没法综合在一起就失败了。

但初学者还在学习代码的反馈,对代码的反馈缺乏认识,写下的代码不跑一下他都不一定 知道结果是什么样的,这种情况你要求他能提前考虑到其他要求,就很难了。这种没什么 可说的,他们只能写一些简单的代码,如果需要做产品,就需要系统工程师带着。

但慢慢这些人会成长,他们也开始做设计了,这些刚刚进入设计的工程师,不一定能这么 快搞明白设计的必要性,因为他们的成功都来自“直接编码”。他们只是在“名义”上觉得 “设计师”都是上司,很厉害,都听说“设计(文档)”,但他们还没有感受到设计有什么用, 也不知道为什么要写设计文档。他们写设计文档是完成某个仪式,而不是认为设计是帮助 他编码的。

我在本文中要说的这个“填空”说的就是这种情况:设计师因为某种仪式要求,自己觉得需 要学习设计也行,某个公司的开发流程要求也罢,决定要写文档了,写这个文档他只是为 了完成这个仪式,要写一个“正确”的文档,而不是因为“代码直接编编不下去”,或者“没 把握这个代码这样假设是不是可以”,所以才决定写一个文档。

这样不带设计目标,而仅仅是为了完成某个仪式而写的“设计文档”,就叫填空。还是用前 面的malloc函数为例,如果他看到别人的设计文档中有个“设计约束”的章节,可能他也会 学着写一个这样的章节。但别人做这个设计,可能是因为有一些严格的外在要求,不放在 这里推整个推理不利,所以才加进来的。比如他可能收到一个要求:“要分配的内存是非 连续的,但以页为粒度的”,这就是一个“设计约束”,设计师考虑他的算法的时候要时刻 考虑到让自己记录可用内存的时候要用非连续的数据结构去记录它。之后到底是多少个线 程上下文来访问这个内存呢?没人告诉他,但这个对他的算法影响很大,他可能又会增加 一个“假设”的章节,说明他的设计是建立在“有多个线程同时来访问”这个基础上的。 这些设计都是有效的。

但只会填空的设计师可能也会写“设计约束”和“假设”的章节,但他并没有体会到这两个条 件的重要性。他可能写的约束是“必须用C语言写”,这句话没有错,但并没有外在的限制 在约束他必须用C语言写,他只是选择了使用C语言来写,这个根本不是他的约束,他这个 约束的设计,就成了一个“填空”了。同样,他的假设也有可能写“支持多线程访问”(因为 他看过他的“前辈”是这样写的),但他的需求不一定是这样,这样的结果,也是让设计变 成一个“填空”了。还有更多的,把写好的代码拷贝到文档中,显得自己在写一个设计文档。 这都是“填空”的例子。

我这次要记录的“填空”的例子是这样的:我评审的是一个指令集手册。我之前已经写了一 部分通用指令的介绍了,我是把这些指令直接分类存放就好了,因为通用指令嘛,你没有 什么好介绍的,除了一些内存读写指令有相关性我用了一章单独介绍外,其他的算法类的, 都罗列就行了。

但这位新加指令的设计师,他做的是一个加速功能,指令之间的关系是很密切的,他也学 我之前的写法一样,也直接出一个列表就算“写完”了。这就是我前面说的“填空”,我和他 沟通的时候,发现可以这样说明白给他听:你考虑你是一个开发者,你仅仅看到你提供的 每条指令,但不告诉你必须先调用谁,再调用谁,也不总结你这个功能加入了多少个寄存 器,你能写出代码来吗?

所以,我发现对刚开始做设计的工程师来说,也许可以通过这样的心法来自己判断自己是 在做设计还是在填空:如果我不做这个设计,影响了谁?是谁(自己或者用户都行)不看 我的结论(约束)会在设计中留下破绽?如果找不到这个人,那么你的设计就是“填空”了。

我在评审他的设计中还发现一个问题:他把一个单独的功能,放到两条指令上了(因为一 条指令放不下这种信息),我就挑刺说,如果两条指令中间发生了中断会怎么样,或者我 不连续写两条指令怎么样?

这不完全是“填空”的问题,更像是缺乏“可能性穷举”的问题,但我也要把这个例子放到这 里来,因为如果站在“用户看到这个定义如何写代码”的角度上,这个问题可以用前面一样 的心法去校验。

不过,设计的时候如果想不到这些可能性的工程师,可能也没有能力想到用户也是需要穷 举所有的可能性的。这个只能说提一嘴罢了。

其他问题

下面的问题不完全是“填空”的问题,只是我要记录这个案例,把和这个设计相关的问题也 一起放在里面。

我看到这个设计犯了几次的错误:控制不住视图的范围。比如他设计的是一个类似GPU的 SMIT的加速器。有过GPU设计或者底层使用经验的读者应该知道,GPU使用的是一种可以称 为“锁步执行”的方案。也就是说,单个核的所有硬线程,其实都是共享同一个指令 Counter(PC)的,所以虽然你看着有共享计算Kernel的多个线程同时执行,但其实在硬 件上这些线程的每一步,都是同步的,如果执行的过程中发生了跳转,硬件需要一些寄存 器记住每个线程在跳转的哪一边,从而不执行不在这个分支上的线程的指令。但这个东西 说是这样说,你写Kernel的时候,其实很大程度上是忽略这一点的。所以GPU的手册对开 发者暴露的是:你写的Kernel代码,会被N条Lane的多个线程同时执行,完成你这条Lane 的计算。而不会直接暴露你用来控制这个同步过程的寄存器给开发者。所以,尽管你的硬 件设计有这个控制多条Lane的寄存器,但你在Kernel的接口上是不会暴露这个寄存器的。

而我评审的这个设计,就是直接在手册中给了客户这个寄存器。问题是,看了半天,你都 不知道用户拿着这个寄存器干什么。

这就是“控制不住视图的范围”,做架构设计,我们希望尽量维护每个视图的稳定性,这样 我们修改的时候影响的范围就是有限的。如果每个视图都看到所有的东西,这个系统就没 法维护了。Kernel的开发者知道(这里说“知道”是说他用了你这个逻辑)你有多少条Lane, 知道你怎么控制这些Lane的,知道你的物理寄存器有多少个,知道你的流水线哪里会做 Forward,你修改这些模块的时候会直接影响到软件上啊。以后这个系统怎么改呢?随便 改哪里可能都要几百几千的软件工程师一起动作,还要在几亿几百亿的应用场景中同步升 级的啊。

这个问题的另一个例子是这样的:设计师提供了两条指令,用于“内存分配和释放”,这个 很奇怪,硬件角度哪来的内存分配和释放?内存管理整个就是软件角度的概念,怎么会出 现在硬件接口这个视图上呢?我去问了一下,原来设计者在加速器里面实现了一个私有的 Buffer空间,它这个是用来从这个分配空间中分配内存的。但如果你要开辟这个新的概念 空间,你的建模就应该包括:Buffer如何定义,大小多少,是否和内存一同编置,是否会 出异常,能否做地址翻译,内存序如何定义……等等等等一系列问题了。而设计师居然就觉 得“我这个和软件的内存管理是一样的,你按这个理解就行”?这个例子给我的感觉是,设 计师控制不住视图的范围,是因为建模逻辑空间的时候完全是没有边界感的,不知道建立 一个概念空间的所有概念包括那些内容,是否能在一个概念空间中概念自洽,这些他可能 都没有认识,这种情况下,就不可能认识“视图”的含义了,视图能被理解就是因为它有边 界,从而可以被分成一个区域一个区域去穷举。对这个边界没有感知,就不可能感知到范 围的“补集”在哪里,也没有能力对不同的集合进行穷举了。

下面是另一个错误类型:单一流程。还是这个SIMT的执行过程,比如我们设定了要执行N 条Lane,但引擎只有M条Lane,那么执行的时候需需要分成多个串行的步骤执行了。设计 者把这个串行执行的步骤描述了。但设计完这个,我们肯定马上需要穷举所有的和设想不 一致的其他“异常”流程了。比如在串行的过程中发生中断或者异常会怎么样?是所有的 Lane都被上下文保存还是,只有当前执行的Lane?在执行的过程中如果不同的Lane都访问 相同的内存地址,这个内存序是什么样的?这些都是开发过程中开发工程师需要处理的。 你不能认为你的系统就你自己设想的这样运行吧?

更要命的是,有时设计师甚至就没考虑过编程的人怎么用这个系统。比如这个设计中,设 计师只说了会分成多个Lane执行。居然完全没有提到开发工程师在kernel代码中怎么知道 自己所在的Lane是哪个。这又是一个缺乏经验的误区:设计师认为“我把我设计的每个东 西的细节都告诉你了,怎么你还觉得信息不够呢?”——问题是,你是为客户设计的啊。你 吧砖头垒成一个猪窝,就算你把每块砖怎么放都告诉我了,它也不是我要的房子啊。当然, 这样比喻有点过了,你确实还是打算垒一个人住的房子的,但你不确定我这个角度的使用 逻辑,告诉我你建造的逻辑,它也没法满足我的使用需要啊。

再看一种类型的错误:忽略前置逻辑。增加这个SIMT的执行逻辑后,在使用这个引擎的时 候如果出现异常,要保存的上下文就很多了。这个设计就直接开始说,如果发生异常,这 个SIMT会如何如何保存上下文。问题是,作为一个完整的指令集,全文一开始就定义了一 个异常处理模型的,你加入一个新的引擎,你直接就开始重新说异常处理模型,就算你这 个模型和全局的模型是完全一致的,这都不可接受。这个原理就和你把两个完全一样的逻 辑写了两个函数一样。对开发者来说,看到两个这样的函数,在架构上会认为这两者只是 “当前版本刚好以后,但语义不同,未来肯定是要修改的。”但在这个指令集的设计中,我 们所有引擎都不能给全局的异常处理逻辑冲突啊。

下一种错误类型:用角色地位取代流程定义。概念空间定义的时候,我们常常首先关心一 个概念的定位,因为定位是最不容易改变的信息。就好比你把一个人的位置定义为裁判, 你不会让他帮任何一个球队踢球,不会让他负责采访球员。但角色地位是粗糙的,不是精 细的定义,往下做设计,特别是在我们的例子中,我们写构架手册,角色在流程中的行为 才具体完成角色的定义。在所述的设计中,设计师对多个寄存器都定义了角色,而没有说 明这个角色实际在什么流程中起什么作用。比如他会说,XX寄存器是SIMT引擎异常状态寄 存器。这定义了这个寄存器的角色,但我们无法精细知道程序怎么写,我们需要知道这个 寄存器在什么时刻被更新,这才能真正定义这个寄存器的确切行为。而为了定义这个“时 刻”,我们就需要定义“程序序”(PO)等概念,因为没有这个序,我们无法声明某个观察, 出现在什么行为和什么行为之间。我们只要代入用户的角度,想象用户作为软件开发者会 根据什么来写程序,就不会忘了要定义这个东西。所以,出现这个问题,说到底还是没有 站在用户的角度来考虑问题。