Home系统日知录
系统日知录

系统日知录

@青藤木鸟

《系统日知录》会持续更新数据库、分布式系统、存储、ML System 相关的想法、翻译、笔记和文章,通过深入浅出的持续解析,帮助业务开发程序员建立底层知识体系。

写代码不是全部,系统是综合学问。

不谋全局者,不足谋一域;
不学系统者,不足学编程。

专栏是买断制,在保证每篇文章的知识密度的基础上,持续不定期更新。会随缘将一些文章分享到公众号:“木鸟杂记”。关注该公众号后回复:“优惠券”,可以领取本专栏八折优惠券。

关于专栏内容,想交流可以留言、也可加我微信 qtmuniao。有个交流群,如果想加群可备注。
订阅379
文章124
最后更新:2024-1-25 10:13
查看 【系统日知录】 详情查看 【青藤木鸟】 主页
分享到微信打开

免费内容

2025-7-29 16:16

深入理解大模型 1: Transformer,一切的基石

Princeton COS 597R "Deep Dive into Large Language Models" 是普林斯顿大学的一门研究生课程,系统探讨了大语言模型原理、准备和训练、架构演进及其在多模态、对齐、工具使用等前沿方向中的应用与挑战。注意,该课程侧重概念理解和研究,而非工程实现。我之前是在分布式系统和数据库内核方向,但这两年转到一家大模型公司做数据。本笔记主要是我对课程论文的梳理和精要。不同的是,我会结合在工作中解决实际问题的一些体感,给出一点转行人不同视角的思考,希望能对同样想从工程入门算法的同学一点帮助。本篇主要关注大模型的奠基之作——Transformer。首先要明确问题域,Transformer 试图解决的是序列建问题,最主要的代表就是语言建模和机器翻译。其次,需要知道前驱方法—— RNN(循环神经网络)和 CNN(卷积神经网络)存在的一些问题,才能知道 Transformer 的创新之处。最后,Transformer 的解决要点的在于“多头注意力机制”和“位置编码”。序列建模序列建模是对一个有顺序的元素序列进行建模,以捕捉元素间的依赖关系(因果性,万物的法则),从而可以:预测序列中下一个元素(语言建模)判断序列的合法性(语法检查)将一个序列转化为另一个序列(机器翻译)序列建模是一个相当泛化的概念,很多 NLP 任务本质上都是序列建模问题。更进一步,现实中的很多问题自动化,都可以转化为序列建模问题。再看一些序列建模的例子感受下:因此,随着大模型的成功,这个方法也是现在被看做最有希望通向 AGI 的路径之一。前序方案下面我们来看看传统的 递归结构(RNN/LSTM) 和 卷积结构(CNN) 在进行序列建模时存在的主要问题。递归结构递归结构如 RNN / LSTM / GRU 曾长期主导序列建模,其公式是:$$ h_{t}=f(h_{t−1},x_{t}) $$可以用图片来辅助理解:其中 $h_{t}$ 是 t 位置(或者说“时刻”)的隐藏状态, $x_{t}$ 是 t 位置的输入。直观上很好理解,就是将之前的输入序列压缩到一个隐藏状态 $h_{t}$ (hidden state)中,然后不断向后传递。从图中的圈,可以更好理解为什么叫递归结构。这种结构主要有两个问题:无法进行并行计算长路径信息稀释可以看出,这个结构中每一个隐藏状态的计算( $h_{t}$ ),都依赖上一个结果( $h_{t-1}$ ),这种前后相继的依赖关系导致我们想得到最后的状态时,只能一步步地进行串行计算。从而没有办法充分利用 GPU 的并行能力,网络稍微一大训练起来就非常慢。另外,不同元素间的状态传递都依赖这样一步步的计算,可想而知,当距离足够远之后,序列前面元素的信息将会被稀释到什么地步。因此,RNN 结构很难捕捉过长距离的两个词之间的依赖。卷积结构卷积神经网络(CNN)在上一波以 CV 为主要应用领域的人工智能浪潮中大放异彩,核心特点就在于其并行性,以及可深层堆叠性(当然主要依赖同时期创造的残差网络等技巧),从而构建出足够复杂的网络结构以容纳足够多信息。因此 CNN 被引入序列建模,代表如 ByteNet、ConvS2S,试图通过局部感受野+多层堆叠来并行化建模。但 CNN 也有其问题:单层卷积视野有限不能捕捉绝对位置信息由于单个卷积核通常不会太大(比如图像中的卷积核通常是 33 or 55,太大效果不太好),因此为了捕捉长距离依赖,通常会进行多层堆叠,以扩大感受野。但网络过深,不仅会让训练更难,也会大大推高训练成本。本质在于,卷积方式对长距离建模效率较低。不同于图像,对于序列来说,我们通常在位置或者时间维度上进行一维卷积。由于卷积核本质上是位置无关的(没有位置信息,只是一个权重,左边和右边参数一样),因此只能捕捉卷积核视野内的相对位置信息。另外,卷积在图像领域很好用的一大原因是:平移不变性。举个例子,一个猫出现在左上角和右下角,都是一个猫。但在语言序列中,一个词的含义是非常依赖上下文的。因此,卷积对序列来说,并非一个原生的结构。核心架构在分析了问题域和前序方案的短板之后,我们来看看 Transformer 是如何设计网络结构来解决这两个问题的。但在详解之前,我们再铺垫一些相关的概念。相关概念编码器-解码器结构(encoder-decoder structure)。是为了解决我们之前提到的序列建模中的 seq2seq 的一类问题而提出的,主要应用于基于神经网络的机器翻译领域。最早被提出时(2014)是基于 RNN 的。其基本工作原理是:编码器将输入序列编码为固定长度的上下文向量(context vector)。从直觉上理解,该向量类似于输入句子的”摘要“。解码器利用该上下文向量作为起始的隐藏状态,通过”自回归“的方式逐步生成输出序列。因此,最初的编码器解码器结构,是通过一个固定长度的中间向量来“桥接”输入和输出序列的。可想而知,当输入序列的信息量变大(比如序列变长),这个固定长度的”桥“会成为瓶颈。自回归(auto-regressive)。自回归最开始是统计学和时间序列分析中的一个概念,其基本含义是一个变量当前的值等于过去值的线性组合,再叠加一个随机扰动项。在大模型中,就是输出下一个 token 时,模型会将已经输出的 token 作为上下文一起送入模型。举个例子,当 GPT 生成“天空是蓝色的”这句话时:首先生成“天空”。然后利用“天空”作为上下文,生成“是”。再利用“天空是”作为上下文,生成“蓝色”。最后利用“天空是蓝色”作为上下文,生成“的”。这中间会有一些冗余信息,也是推理 infra 的着重发力的一个方向:KVCache。当然,这里的 KV 概念会涉及到 Attention 一些概念了,之后会展开讲。自注意力(self-attention)。注意力机制的最开始引入是为了解决编码器解码器结构中间桥接向量“信息瓶颈”的问题。其基本做法是:编码时:(粒度作细)不再将整个输入序列编码成一个固定长度向量,而是为每个词生成一个“关系”向量,该向量包含了该词和序列中其他词的“亲疏”关系,当然,该词的位置信息也被以某种方式编码了进去。解码时:(分亲疏)生成每个词时,也不再仅依赖固定长度的上下文向量,而是动态的关注输入序列不同部分。实现上,就是利用之前得到的每个词的“关系”向量,来对输入序列按”注意力“(亲疏性)进行加权,得到下一个词。形象来说,注意力机制:粒度作细:不再为整个输入序列生成做”摘要“,而为每个词逐词做摘要。分亲疏:不是粗暴利用一个固定长度上下文做解码,而是每次解码时,动态的获取当前最应该关注的输入序列的部分词,来做加权。架构有了上面铺垫,我们再来看论文中这张 Transformer 经典的架构图。主干的左右两侧遵循了经典的编码器-解码器结构。在编码器一侧,堆叠了 N=6 个基本层,堆叠的时候通过残差和归一化(Add & Norm)方式来规避深层神经网络的梯度消失和爆炸问题。每个基本层包含两个基本单元,多头注意力(Multi-Head Attention)和前馈网络(Feed Forward)。前馈网络其实就是有一个隐藏层的三层全连接网络,后面会详细解析这两个基本构件。右侧的解码器结构和编码器基本结构大体相同,层数也相同,变化有二。第一,加了一层交叉注意力模块,以将编码器抽取的**“上下文”**桥接过来。第二,注意力计算时是要先做掩码的,使得每个词只能关注到其以前的词,而看不到其之后的词。最后就是通过一个线性层,然后利用 softmax 进行概率归一化。另外,输入时也可以分成两块:token 到向量转换:tokenizer 这里没画。token 到向量的转换函数也是可以学的。叠加位置信息:给每个 token 的向量叠加一个其所在句子中的位置信息。注意力在计算注意力的时候,我们会为每个 token 的向量引入三个额外表征:Q(query),K(key),V(value)。即从 token 原向量(设为 x)中通过“投影”(矩阵变换)的方式在另外空间抽取(或者说衍生)三种不同用途的表征。下面我们通过一个“去图书馆查资料”的例子来理解下。想象一下,你要写一篇关于“人工智能对经济的影响”的论文,需要去图书馆查找一些相关文献。原始输入向量 (x):就像你脑海中模糊的想法或一个高度抽象的词,比如“经济”。这个想法本身包含了很多维度的含义。查询 (Query - Q):为了查找资料,你不能只抱着“经济”这个模糊的想法。你需要把它具化成一个问题或查询,比如“寻找关于AI技术如何改变就业市场的书籍”。这个具体的查询就是Q。它是从你的原始想法“经济”派生出来的,但更具方向性。键 (Key - K):在图书馆里,为了让每一本书能被快速检索到,都会有自己的标签或关键词,比如“AI”、“就业”、“自动化”、“市场分析”等。这些标签就是K。它们代表了这本书的摘要,是用来和“查询”进行相关性匹配的。值 (Value - V):书本的实际内容就是V。一旦你的查询(Q)和某本书的标签(K)高度匹配,你就可以去阅读这本书的详细内容(V)来获取更多信息。那为啥不直接用原始向量 x 与其他 token 的向量做乘积就好了?原因有多方面:角色解耦:如果将 QKV 都使用 x,则需要该向量同时“分饰三角”,其实是做了一种“先验”的强约束关系。交叉注意力时更能明显体现这一点。表达能力:将原始向量投影到不同空间中,本质上是一种更内聚的抽取,而且这个抽取方式也是可以进行参数化学习的,从而极大提升了灵活性和表达能力。支持多头:正因为同一个 x 可以有不同的“抽取”方式,Transformer 的多头并行地进行不同维度的 feature 抽取才有意义。Transformer 中用到了两种注意力方式:自注意力(self-attention):编码器部分中的注意力层,捕捉序列中 token 的的内在依赖关系。此时,QKV 都来自同一个序列的不同投影。交叉注意力(cross-attention):编码器和解码器间的桥接部分,利用编码器捕捉到的内在依赖关系,进行下一个 token 的预测,此时 Q 来自解码器,KV 来自于编码器。在给定窗口内,注意力模块会为每个 token 计算和其他 token 的关联性,从而为每个 token 得到一个对其他所有 token 的关系向量。计算相关性时,可以用加法注意力或点积(乘积)注意力,两者效果类似,但因为 GPU 对点乘进行了高度优化,所以选择了后者。最后为了保持输出的分布稳定性(点乘后均值会放大 $\sqrt{d_k}$,搜下点积公式就可以看出),因此要加一个缩放(除以 $\sqrt{d_k}$)将均值搞回去,以避免将 softmax 函数推入梯度极小的区域。那为什么用多头呢?这点其是借鉴了卷积神经网络(CNN)中的多头抽取、深层堆叠的特点,分别抽取不同维度的特征,最后利用线性层重新组合到一块,效果更佳。论文中采用了 h=8 个并行注意力层,或称为头(head)。对于每一个头,使用 $d_{k} = d_{v} = d_{model} / h = 64$,即增加数量的同时,减小每个头的维度。从而在保持计算量差不多的同时,可以并行抽取多个特征。前馈神经网络层(FNN)是一个三层的全连接神经网络(输入层,隐藏层,输出层)。类似于 CNN 中多头后面的卷积核 = 1 的组合层,提供了额外的非线性(主要是通过激活函数)。直观上理解,自注意力层是“横向”地(在窗口内跨 token)整合信息,它将所有相关词的上下文信息聚合到每个 token 的表示中。而 FFN 层是“纵向”地(词间不互相影响,在每个词内的特征维度上)深化和提炼这些聚合后的信息。它在每个位置上独立地进行特征学习和重塑,使其能够更好地捕捉该位置上更抽象、更高层次的特征。位置编码由于注意力模块是计算的序列中不同 token 的相对关系,是没有位置信息的。为了让网络学到 token 间的位置信息,也即我们之前提到的因果关系,Transformer 额外引入了位置编码。为了让位置编码能够简便地叠加到 token 的 embeding 上,让其保持了和 embeding 同样的维度 512 。从而可以利用简单的加法方式进行叠加。这个编码可以通过参数学出来,也可以事先固定。Transformer 选择了后者,因为发现和学出来的差不多(但当然,后面不同工作进行了大量改良)。Transformer 采用了一种正弦波的编码方式,特点在于可以捕捉位置的可加性。小结本文从首先明确了问题领域——序列建模,讨论了为什么序列建模可以成为解决一大类通用问题的基础;然后简单分析了之前 RNN 和 CNN 存在的一些问题:难以并行和长距离依赖捕获;最后剖析了 Transformer 的解决方案和主要架构——多头自注意力机制。此外,我们可以追踪一个 token 对应的 embedding 的变换过程(变换过程中维度是保持 512 不变的),来更深的理解 Transformer。编码器部分:位置编码:加法,叠加位置信息自注意力层:捕捉输入序列中 token 向量间关系压缩到表征向量中(两头看)FNN 线性层:增加一些非线性性,锤炼每个 token 的表征向量解码器部分:掩码自注意力层:捕获输出序列中的 token 和其前序 token 的间依赖,压缩到表征向量中(只向前看)交叉注意力:将编码学到输入序列的 token 在句子中的相关性表征送给解码器,从而让新的表征向量既带有输入序列的信息、也带有输出前序 token 的信息。从而准备好了预测下一个 token 的所有信息。FNN 线性层:锤炼每个 token 的表征向量,增加一些非线性性多层堆叠:通过残差和正则,以在保持训练稳定性的前提下加深网络。从而进行反复抽取提炼,以更多参数容纳更多信息量。

2024-3-12 6:58

本专栏的正确“打开姿势”和“优惠信息”

首先非常感谢你的订阅。本专栏是一个大规模数据系统从业者的絮絮叨叨,涵盖的主题包括存储、数据库、分布式系统、AI Infra 、计算机基础等庞杂的系统知识。由于主题零散、内容驳杂,以下是一些建议的本专栏的“打开姿势”。在一切开始之前,可以关注我的公众号:“木鸟杂记”,回复:“优惠券”,即可获取订阅本专栏的八折优惠券。 订阅了之后欢迎分享你喜欢的文章,如果你的朋友通过你分享的文章订阅后,你都可以拿到之后订阅费(会随着文章数的增多不断上调)的百分之二十!订阅后如果专栏内容不符合预期,二十四小时内可以随时退款。目录和索引我会定期更新所有文章目录到简介中,根据此目录,作为一个新读者,你可以快速通过标题找到你想要的文章。注:📘 代表该篇是论文解读🔥 表示该篇启发+留言人数较多也可以通过顶部的标签,来快速筛选想要看的某类文章:当然,也可以修改排序方式,来从老文章读起:最后,对于某些你感觉不错,以后想回看,或者想稍后阅读的文章,也可以点击收藏。启发和留言如果某一篇文章帮到了你,一定要不要吝啬点一个“有启发”,或者留言,根据这些反馈,我可以在未来调整写作方向,产出更多大家感兴趣的文章。让“赞”和“吐槽”都来的更猛烈些吧!读者社区我们有个相关的小论坛:https://distsys.cn/ ,我专门开了版面:https://distsys.cn/t/column,每次发文章后,会将摘要发到论坛上,如果你想进行公开讨论可以去该贴下回复,或者单开主题。我们有个读者群,但是由于我没有定期组织活动,所以群里长期也没有人发言。如果这样你还想进,那就扫下面二维码吧。这个群,以后我能想到的作用,也就是发了新文章之后,转到群里,如果大家有即时想法,可以直接在群里讨论了。

2023-7-1 16:34

数据处理的大一统——从 Shell 脚本到 SQL 引擎

“工业流水线”的鼻祖,福特 T 型汽车的电机装配,将组装过程拆成 29 道工序,将装备时间由平均二十分钟降到五分钟,效率提升四倍 ,下图图源。这种流水线的思想在数据处理过程中也随处可见。其核心概念是:标准化的数据集合:对应待组装对象,是对数据处理中各个环节输入输出的一种一致性抽象。所谓一致,就是一个任意处理环节的输出,都可以作为任意处理环节的输入。可组合的数据变换:对应单道组装工序,定义了对数据进行变换的一个原子操作。通过组合各种原子操作,可以具有强大的表达力。则,数据处理的本质是:针对不同需求,读取并标准化数据集后,施加不同的变换组合。Unix 管道Unix 管道是一项非常伟大的发明,体现了 Unix 的一贯哲学:程序应该只关注一个目标,并尽可能把它做好。让程序能够互相协同工作。应该让程序处理文本数据流,因为这是一个通用的接口。— Unix Pipe 机制发明者 Malcolm Douglas McIlroy上述三句话哲学正体现了我们提到的两点,标准化的数据集合——来自标准输入输出的文本数据流,可组合的数据变换——能够协同工作的程序(如像 sort, head, tail 这种 Unix 自带的工具,和用户自己编写的符合管道要求的程序)。让我们来看一个使用 Unix tools 和管道来解决实际问题的例子。假设我们有一些关于服务访问的日志文件(var/log/nginx/access.log ,例子来自 DDIA 第十章),日志的每一行格式如下:// $remote_addr - $remote_user [$time_local] "$request" // $status $body_bytes_sent "$http_referer" "$http_user_agent" 216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1" 200 3377 "<http://martin.kleppmann.com/>" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36" 我们的需求是,统计出日志文件中最受欢迎的五个网页。使用 Unix Shell ,我们会写出类似的命令:cat /var/log/nginx/access.log | # 读取文件,打入标准输出 awk '{print $7}' | # 取出每行按空格分割的第七个字段 sort | # 对每行按字面值进行排序 uniq -c | # 归并重复行,并给出重复次数 sort -r -n | # 按重复次数降序进行排序 head -n 5 # 输出前五行 可以看出上述 Shell 命令有以下几个特点:每个命令实现的功能都很简单(高内聚)所有命令通过管道进行组合(低耦合),当然这也要求可组合的程序只面向标准输入、标准输出进行编程,无其他副作用(比如输出到文件)输入输出面向文本而非二进制此外,Unix 的管道的另一大优点是——流式的处理数据。也即所有程序中间结果并非都计算完成之后,才送入下一个命令,而是边算边送,从而达到多个程序并行执行的效果,这就是流水线的精髓了。当然,管道也有缺点——只能进行线性的流水线排布,这也限制了他的表达能力。GFS 和 MapReduceMapReduce 是谷歌 2004 年的论文 MapReduce: Simplified Data Processing on Large Clusters 提出的,用以解决大规模集群、并行数据处理的一种算法。GFS 是与 MapReduce 配套使用的基于磁盘的分布式文件系统。MapReduce 算法主要分为三个阶段:Map:在不同机器上并行的对每个数据分区执行用户定义的 map() → List<Key, Value> 函数。Shuffle:将 map 的输出结果(KV 对)按 key 进行重新分区,按 key 聚集送到不同机器上, Key→ List<Value>。Reduce:在不同机器上并行地对 map 输出的每个 key 对应的List<Value> 调用 reduce 函数。(下图源 DDIA 第十章)每个 MapReduce 程序就是对存储在 GFS 上的数据集(标准化的数据集)的一次变换。理论上,我们可以通过组合多个 MapReduce 程序(可组合的变换),来满足任意复杂的数据处理需求。但与管道不同的是,每次 MapReduce 的输出都要进行“物化”,即完全落到分布式文件系统 GFS 上,才会执行下一个 MapReduce 程序。好处是可以进行任意的、非线性的 MapReduce 程序排布。坏处是代价非常高,尤其考虑到 GFS 上的文件是多机多副本的数据集,这意味着大量的跨机器数据传输、额外的数据拷贝开销。但要考虑到历史上开创式的创新,纵然一开始缺点多多,但会随着时间迭代而慢慢克服。GFS + MapReduce 正是这样一种在工业界开创了在大规模集群尺度上处理海量数据的先河。SparkSpark 便是为了解决 MapReduce 中每次数据集都要落盘的一种演进。首先,Spark 提出了标准的数据集抽象——RDD,这是一种通过分片的形式分散在多机上、基于内存的数据集。基于内存可以使得每次处理结果不用落盘,从而处理延迟更低。基于分片可以使得在机器宕机时,只用恢复少量分片,而非整个数据集。逻辑上,我们可以将其当做一个整体来进行变换,物理上,我们使用多机内存承载其每个分片。其次,基于 RDD,Spark 提供了多种可灵活组合的算子集,这相当于对一些常用的变换逻辑进行“构件化”,可以让用户开箱即用。(下面图源 RDD 论文)基于此,用户可以进行任意复杂数据处理,在物理上多个数据集(点)和算子(边)会构成一个复杂的 DAG (有向无环图)执行拓扑:关系型数据库关系型数据库是数据处理系统的集大成者。一方面,它对外提供强大的声明式查询语言——SQL,兼顾了灵活性和易用性。另一方面,他对内使用紧凑、索引友好的存储方式,可以支撑高效的数据查询需求。关系型数据库系统同时集计算和存储于一身,又会充分利用硬盘,甚至网络(分布式数据库)特点,是对计算机各种资源全方位使用的一个典范。本文不去过分展开关系型数据库实现的各个环节,而是聚焦本文重点——标准的数据集和可组合的算子。关系型数据库对用户提供的数据基本组织单位是——关系,或者说表。在 SQL 模型中,这是一种由行列组成的、强模式的二维表。所谓强模式,可以在逻辑上理解为表格中每个单元所存储的数据必须要符合该列“表头”的类型定义。针对这种标准的二维表,用户可以施加各种关系代数算子(选择、投影、笛卡尔乘积)。一条 SQL 语句,在进入 RDBMS 之后,经过解析、校验、优化,最后转化成算子树进行执行。对应的 RDBMS 中的逻辑单元,我们通常称之为——执行引擎,Facebook Velox 就是专门针对该生态位的一个 C++ 库。传统的执行引擎多使用火山模型,一种属于拉( pull-based )流派的执行方式。其基本概念就是以树形的方式组织算子,并从根节点开始,自上而下的进行递归调用,算子间自下而上的以行(row)或者批(batch)的粒度返回数据。近些年来,基于推(push-based)的流派渐渐火起来了,DuckDB、Velox 都属于此流派。类似于将递归转化为迭代,自下而上,从叶子节点进行计算,然后推给父亲节点,直到根节点。每个算子树都可以拆解为多个可以并行执行的算子流水线(下图源,Facebook Velox 文档)我们把上图顺时针旋转九十度,可以发现他和 Spark 的执行方式如出一辙,更多关于 velox 机制的解析,可以参考我写的这篇文章。但无论推还是拉,其对数据集和算子的抽象都符合本文一开始提出的理论。小结考察完上述四种系统之后,可以看出,数据处理在某种角度上是大一统的——首先抽象出归一化的数据集,然后提供施加于该数据集之上的运算集,最终通过组合的形式表达用户的各种数据处理需求。

2023-6-11 15:23

生活工程学(一):多轮次拆解

我们在工程实践中,有些构建代码的小技巧,其背后所体现的思想,生活中也常常可见。本系列便是这样一组跨越生活和工程的奇怪联想。这是第一篇:多轮次拆解,也即,很多我们习惯一遍完成的事情,有时候拆成多个轮次完成,会简单、高效很多。我在进行 code review 时,有时会看到一些新手同学在一个 for 循环中干太多事情。这常会造成多层嵌套,或者 for 循环内容巨大无比。此时,如果不损失太多性能,我通常建议同学将要干的事情拆成多少个步骤,每个步骤一个 for 循环。甚至,可以每个步骤一个函数。当然,这些全是从维护角度着眼的。因为人一下总是记不了太多事情,一步步来,而不是揉在一块来,会让每个步骤逻辑清晰很多。后者,我通常称之为”摊大饼“式代码,这种代码的特点是写时很自然,但是维护起来很费劲——细节揉在一起总会让复杂度爆炸。软件工程中的最小可用原型,也是类似的理念。这种理念,其实在”函数式“编程中也随处可见,即对一个数据集操作时,我们会链式的应用一系列变换函数,从而让数据流清晰的展示出来。在大数据处理中,这种范式就更常见了,比如 spark 论文中提到的:errors.filter(_.contains("HDFS")) .map(_.split(’\\t’)(3)) .collect() SQL 查询引擎在实现时也是用的类似机制,即将一个查询语句,转换成对一个行列组成的二维数据集,施加多轮次的算子变换。如下图所示。图源:CMU15445,查询引擎讲义。我高中时学过一点点素描,虽然没有入门,但其多轮次的做图技法给我印象很深:先勾轮廓,再逐层完善。打线的时候也是一层层的打,而非一个地方画完再画另一个地方。我最近常常翻译文章,开始时,我总是务求一遍翻译好。但结果就是非常慢,且很容易放弃。后面开始使用多轮次、逐层打磨法。一开始用 ChatGPT 帮忙翻译一遍,然后自己再对照原文订正语义,最后扫一遍调换语序理顺词句等等。常言道,好文章是改出来的,应该也是这个道理。滑铁卢大学教授 Srinivasan Keshav 在其 ”How to Read a Paper“ 中阐述了经典的”三遍(three-pas approach)读论文“方法,也是类似的思想:The first pass:鸟瞰式略读,抓摘要、章节标题、结论等重点内容。The second pass:稍微细一些,但不要陷入细节。The third pass:细读,完全理解。其中任何一步都可以及时停止:这可能不是你需要的论文。但我之前读论文就长陷入一个误区,我愿称之为”地毯式读法“——逐字句过每一个细节。包括我刚开始进行 code review 时,也常常陷入这个误区。一次性的、按顺序把事情做完,是大部分人的天性,但这种天性往往是低效的,我们要通过不断地训练来克服。说起来,我出去点菜的时候,也常用两遍法——第一遍把想吃的都加上,第二遍考虑各种约束(偏好强弱、价格高低、吃过与否等等)来将菜品去到一个合理的范围内。我想背后的原因是:人的注意力是有限的,因此只擅长一次专注的做好一件事情。人的认知也是一个由浅入深的过程,一层层细化便是利用了这个特点。

2023-3-6 16:53

影响我写代码的三个 “Code”

国内很多大学的计算机专业,比较偏重基础和理论的“灌输”(就我当时的体验,现在可能会好一些),对于代码能力,虽然也有一些课程实验,但往往不太够用。于是,在进入正式工作前,很多同学就会对自己代码水平不太自信。下面我就根据我自身的写代码经历提供一些建议。一些经历我是 2010 年上的北邮,当时也是很迷糊的就进了计算机专业。自然的,在大学一开始也谈不上什么学习规划。只能是沿用着高中的学习方法,懵懂地跟着老师走——上课就听课,课余就自习做作业。结果便是,学习效率很低,上课听不太懂、题目做不通透。但总归,上完计算机导论后,编程作业都是自己啃出来的,跌跌撞撞的完成之后,慢慢地竟感受到了编程的乐趣。我们当时大作业最多的几门课,C++ 程序设计、算法和数据结构、操作系统、计算机网络、微机原理等,现在想来,大部分都都跟玩具一样。后来做了国外一些知名大学公开课的实验才知道,要打造好一个实验项目,是非常难的事情:首先,得适配学生的水平,准备详尽的实验材料。其次,得搭好代码框架,在合适的地方“留白”,给学生“填空”。最后,还得构建足够好的自动化测试平台,进行打分。如果从头开发,这里面涉及到的复杂度、需要花的心思,并不比发一篇顶会论文简单。那作为教授来说,有这些时间,我为什么不去发一篇论文呢?毕竟国内高校都是科研第一、教学老末。因此,我在本科课内,代码水平也并没有打下太好的基础。后面在读研和工作中,不断摸索。回头来看,对我代码能力提升比较快的有这几个 “Code”:LeetCode、Writing/Review Code Loop、Clean Code。LeetCode在说 LeetCode 前,想先说说工作后,见到的一类神奇的人——打过算法比赛(通称 ACM,其实是 ICPC 和 CCPC)的同学的印象。这类同学的一大突出特点,用最简练的语言来形容,就是:出活快。几年的竞赛经历,让他们只要在脑袋中对需求(题目)理解之后,就能在最短的时间内转化为代码。由于太过懵懂,我自然是没有打过竞赛,等反应过来竞赛的诸般好处时,已经大三下了。当时,校队也不会招这么“大龄”的队员了,就算招,门槛也非常高。也是大学诸多憾事中的一件了。后来读了研,在找工作前一年时,LeetCode 已经相当流行了,便也和同学组队,互相激励着刷了起来。当时题目还不是特别多,到研二暑假找实习时,大概把前两百多道刷了两遍。一开始,会不断思考题目是什么意思,该用什么算法解,有时半天想不出来,便去看高票答案。很多高票解真的是精妙而简练,这大概也是当时 LeetCode 最吸引人的地方之一。慢慢的对各种类型题目有些感觉之后,就开始练速度和通过率。也就是上文说的,在理解题目后,能够迅速转变为 bug free 的代码。因此,虽然没有打过比赛,但是通过 LeetCode 的训练,确实也有了类似竞赛的收获。但自然,在深度、广度和速度上都远不及那些“身经百赛”的同学。不过我已经是受益匪浅:对常见数据结构和算法掌握纯熟。比如现在说起六种排序,特点、使用场景、背后原理,可以做到如数家珍;比如说起树的各种递归非递归遍历,脑动模拟递归执行过程,也是信手拈来;再比如链表、队列、图等特点,也能在脑中边模拟,边换成代码。学到了很多精巧的代码片段“构件”。比如如何二分、如何迭代、如何处理链表头尾节点、如何设计基本数据结构的接口等等。这些偏“原子”的构件,是我后来工作中写代码的血肉来源。但只有这些,是远远不够的,一到大项目里,写出的代码就很容易——“有佳句无佳章”。Writing/Review Code Loop遇到上述窘境,便是因为缺少中大型项目的磨练。表现在空间上,不知道如何组织上万行的代码,如何划分功能模块、构建层次体系;体现在在时间上,没有经过项目“起高楼、宴宾客、楼塌了”的构建-腐烂-重构循环。工程中在理解代码和组织代码时有个矛盾:可理解性。作为维护人员,我们学习代码时,多喜欢顺着数据流和控制流来理解,所谓根据某个头,一路追查到底,是为纵向。可维护性。但作为架构人员,我们组织代码时,为了容易维护,多是按照围绕模块来组织代码——把关联紧密的代码聚合到一块,是为横向。所以我们在拿到一个大工程时,如果立即按模块、地毯式的看代码,肯定会昏昏欲睡、事倍功半。不幸的是,由于多年读书养成的强大习惯,这个毛病跟了我很多年。正确的打开方式是,要像对待团在一起的多条线一样,找到“线头”,然后慢慢往外揪。在项目中,这些线头是:service 的 main 函数、各种单测入口。但我们在构建一个大工程时,又得反着来:先搭建一个揉在一起的主流程,然后逐渐迭代。就像盘古开天辟地一样,随着时间而演化,让天慢慢的升高、地慢慢下降,让整体化为地上四极、山川河流、太阳月亮。如是迭代,将一个混沌的流程,慢慢地模块化。比如常用的工具模块(utils)、业务相关基础模块(common)、控制模块(controller、manager)、RPC HTTP 等回调处理模块(processor)等等。但当然,如果你已经有了构建某种类型系统的经验,则并不需要在构建初期经历这个漫长过程,可以直接按经验分出一些模块。更有甚者,你已经形成了自己的一个代码库,比如时钟、网络、多线程、流控等等,在构建新的工程时可以直接拿来就用。剩下的问题就是对于细节的微调:1. 我们在进行分层时,边界处的功能,是往上升,还是往下沉;2. 某个较完整的结构,是拍平到使用类里,还是单独拎出来;这些形形色色的决策,都没有一个定则,更多的还是根据场景的需求、工期的长短等诸多实际情况,便宜行事。而这种背后的决策,则是在长时间对中大型项目的学习、对别人修改的 Review、自己上手搭架子和修修补补中,一点点形成的直觉和偏好。就像股票市场有周期一样,工程代码也是有其周期。不经历一个股市牛熊周期,我们不敢轻言空多;不经历过一个工程构建-成熟-腐烂的周期,我们也不敢轻言取舍。即,没有这些经验,我们就没办法在工程构建初期,预见到其最常用的打开方式,进而面向主要场景设计,牺牲次要场景的便利性。单元测试的重要性,怎么强调都不为过。一方面,能不能写出的单元测试,意味着你代码的模块边界是否清楚;另一方面,通过设计好的输入和输出,测试能够保证某种“不变性”,之后无论你怎么微调、重构,只要能跑过之前的测试,那就可以放心一半。另一半,就要靠自己和别人不断 Review 、测试集群线上集群不断地迭代了。所以,这个过程是一个无休止的 loop,不断的磨,尔后不断地提升。Clean Code最后说说对代码的品味。小节标题是:Clean Code,是因为我对代码的品味,最初是从 Clean Code: A Handbook of Agile Software Craftsmanship 这本书建立起来的。其第二章对命名——这个工程中“最难”的事情——的阐述,给我印象很深。工作中,我们常说,某某某对代码有“洁癖”。我也多少有一些,但我不以为是洁癖,而是一种对美的欣赏和追求。代码的美体现在哪里呢?我这里稍微抛个砖(当然,我之前也写文章就代码命名问题啰嗦过,感兴趣的可以点这里可以去看看):一致性。比如具有相同含义的实体,使用相同的命名;而需要区分的实体,则要通过命名域(namespace)、前缀来进行甄别。从而给读者造成最小的心智负担。体系性。是指我们在做一组相关接口时,要考虑其体系性。比如增删改查,比如生产消费,比如预处理、处理、处理后,比如读取写入等等。体系性又包括对称性和逻辑性,让人在拿到一组接口时,就能最小成本地理解其是如何相互联系、又是如何具有区别的。没有赘肉。写代码,不要啰嗦,不要啰嗦,不要啰嗦。如果不小心啰嗦了,说明你可能没有想清楚所解决问题的本质。复杂的表象,在不断地剥离杂质后,往往有很简单的关窍。抓住这些关窍,再往其上附着骨肉,同时理清楚一对多、多对多等依赖关系,往往能化简为繁。在审美之外,还要说说建模(在某种程度上和隐喻是相通的)。毕竟,我们在说工程的“构建”时,本身就是借助的建筑学中的隐喻。软件工程中,类似的隐喻随处可见。我们大脑在认知新事物时,多建立在对旧的模型套用(好一点叫化用)上。因此,如果在处理模块时,如果能从经典的模型库中,找到一个相对合适的抽象,往往能够极大降低用户理解门槛。比如经典的生产者消费者模型、树形组织模型、路由器模型、线程调度模型、内存模型等等。此外,也可以使用某种常见意像、隐喻来命名项目,往往也能在易理解性上收获奇效。比如监控系统,可以叫“鹰眼”;比如各种流水线管控,可以叫“富士康”(手动斜眼);再比如更常见一些的数据采集,我们都知道他叫——“爬虫”。最后,世间的事情往往是多方印证、互相补足的——如果你想写好代码,就不能只写代码,你得去读读历史、学学美术、写写文字,建立一套你自己的审美偏好,然后将其理念平移到写代码里来,才能写出符合直觉、具有美感的好代码。