← 返回周历 入门教材 视频 DeepInfer 101
入门上篇

Python 起步:从一行计算到一批请求

浏览文字约 8 分钟 · 手算与练习请按正文分段完成 源文件:Markdown 本地渲染版 · 无需联网
本页目录 · 4 节

假设有三条请求,分别需要 2、0、3 个位置。我们想把它们依次放进一段连续空间。第二条没有数据,该给它留一个位置吗?第三条从哪里开始?这是后面学习 KV cache 会遇到的问题。不过眼下不需要知道什么是缓存:画五个格子,拿一支笔,就能开始。

这一章从计算一条请求开始,最后让同一段程序处理任意长度的列表。可以一口气读,也可以每算通一个例子就停下来试一试。会写函数、能解释循环中变量如何改变的读者,可以直接读第二章:调试与测试。

1. 让计算产生一个可以继续使用的值

用编辑器打开课程根目录,再打开终端。课程根目录的判断标准是能直接看到 course 文件夹:离线ZIP解压后进入这一层;若打开的是 moliflower.github.io 源码仓库,先运行 cd _courses/deepinfer。下文所有 python course/... 都从这里运行,不从 course 文件夹内部运行。终端用来输入命令,.py 文件用来保存Python代码;不要把终端命令写进 .py 文件。

python --version
python course/foundations/demo.py
python course/tools/grade.py foundations --question q0

需要 Python 3.10 或更新版本。若 Windows 的 python 打开商店、显示“系统无法访问此文件”或找不到命令,可在 PowerShell 从课程根目录运行本课程的启动器:

.\course\run.ps1 --demo
.\course\run.ps1 foundations --question q0

启动器依次探测可运行的 Python,也会尝试当前用户 Codex 配套的 Python;不会安装包或修改系统设置。如果均不存在,按 Python 官方 Windows 使用说明 安装 Python 后重开终端。WSL/Linux 中若只有 python3,将全文命令名换成 python3。后文的 python course/tools/grade.py 参数 在 Windows 也可以统一换成 .\course\run.ps1 参数。

示例前 3 行应为 count = 3、bytes = 6、returned = 7,随后打印三轮累计过程。Q0 应通过;后续题初次运行出现 NotImplementedError 表示练习尚未填写。先看错误末尾的题号,不要修改测试。

先看示例的第二行。三个元素,每个占两个字节,总量是六个字节。在 Python 解释器里输入下面两行,会看到两种不同的写法得到同一个数:

>>> 3 * 2
6
>>> element_count = 3
>>> element_count * 2
6

这里的 >>> 是解释器的提示符,不是代码的一部分。第一行让解释器求一个表达式的值;赋值则让名字指向一个值,本身不会显示结果。把同一段算术放进文件运行时,单独写一个表达式也不会显示什么。想在终端看结果,用 print;想让另一段计算拿到结果,用 return。这两件事不一样。

def pair_count(request_count):
    return request_count * 2

reserved = pair_count(3)
print(reserved + 1)

运行文件,屏幕出现的是 7,不是 6。先求出调用的结果 6,赋给 reserved,再加一,最后才打印。现在只把函数里的 return request_count * 2 换成 print(request_count * 2):屏幕先出现 6,下一行加法却会报错。原因不是乘法失败,而是函数走到末尾没有返回结果,调用者拿到的是 None。理解这一点后就能判断 Q1 为什么要返回字节数,而不是把它打印出来。

2. 从一条请求走到一批请求

现在换一种计量方式:不按字节付费,而是每四个位置领一组。要存九个元素,前八个填满两组,最后一个也得有地方放,所以必须领三组。这里需要的不是近似值,而是能装下所有元素的最少整数。用 9 // 4 得到填满的两组,用 9 % 4 得到剩下的一个元素。是否再领一组,取决于余数是不是零,而不是输入是不是偶数。

先不写函数,把 8、9、0 分别代入。8 和 9 的整除结果都等于 2,却只有 9 需要第三组;0 没有余数,而且一组也不需要。这三个紧挨边界的输入比随便试三个大数更有用。Python 的 if 会先求条件的真假,只在条件为真时执行缩进的语句。比如下面的计数只记录未装满的那组,不会额外增加一个完整组:

tail_groups = 0
if 9 % 4 != 0:
    tail_groups = 1
print(tail_groups)

这段代码只回答一个问题。如果手头有一批长度,就不该为每条请求复制一份程序。列表把值按顺序放在一起;for length in lengths 让同一个名字在每一轮指向下一个值。下面把每条请求限制在最多两个位置。先预测两次打印,再运行:

lengths = [2, 0, 3]
accepted = []
for length in lengths:
    accepted.append(min(length, 2))
print(accepted)
print(lengths)

第一行是 [2, 0, 2],第二行仍是 [2, 0, 3]。min 挑出两数中较小的那个,append 把它加到新列表末尾。零长度仍代表一条请求,不能顺手删掉。若一开始写的是 accepted = lengths,那不是复制,而是给同一份列表起了第二个名字;往里追加甚至会改变正在遍历的列表。这里新建空列表,既保住原始输入,也让每条输入恰好贡献一条输出。

3. 用一个不变的关系追踪边界

回到开头,这次按原始长度存放,不做截断。给五个格子从 0 到 4 编号。第一条占下标 0、1,下一条从 2 开始;第二条长度为 0,第三条仍从 2 开始。第三条用掉 2、3、4,因此全部放好后,第一个尚未使用的位置是 5。我们记录的是边界,不是各请求最后一个元素的下标。

本轮读到的长度放置前的位置放置后的位置该请求使用的范围
202从 0 开始,到 2 之前
022空范围
325从 2 开始,到 5 之前

于是完整边界是 [0, 2, 2, 5]。相邻边界相减,恰好还原输入 [2, 0, 3]。特别值得保留的是中间重复的 2:如果为了让数字「好看」而去重,就无法还原第二条请求了。

每轮开始时,当前位置都等于之前所有请求的长度之和。这句话就是循环不变式:位置会变,位置与已处理输入之间的关系不变。还没有处理任何请求时,总和为 0;每读一个长度就把它加进去,关系对下一轮仍成立。因此空列表的边界也有一个数 [0],并非没有边界。试着遮住表格,换成 [1, 4, 0, 2] 走一遍:你应能得到五个边界,最后一个是 7。

4. 把推导变成自己的程序

只修改 foundations.py 的待实现部分,保留给出的参数检查。Q0 是已完成的运行示例;以下题目可分别运行,不必写完全部才得到反馈。题页是需要时再查的规格与提示,不是继续读正文的必经站点。

练习用它检查什么
Q1:计算字节调用者拿到结果;0 个元素占 0 字节
Q2:最少分组刚好装满与多一个元素的区别
Q3:限制长度输出是新列表,原输入不变
Q4:连续存储边界空请求与零长度请求都不丢失边界
python course/tools/grade.py foundations --question q1 --failfast
python course/tools/grade.py foundations

卡住时可以把第一条命令的 --failfast 换成 --hint 1。通过以后,给出一个你没有在本章见过的输入,先把边界写在纸上,再运行自己的代码。两者不同时,最值得追的是第一个不一致的位置。这正是下一章:调试与测试的起点。