这份实验把 第 9 讲 的四条手算轨迹变成代码。先修 Lab3、L9;预计 2–3 小时,可按两次 60–90 分钟拆开。只用标准 Python,编辑 course/labs/lab04/lab04.py,不要编辑参考答案或测试。模型只管理块 ID 和引用数,没有 KV 数值、并发或 GPU。
1. 开始前:定位目录、预测失败
以下命令从课程根目录 deepinfer/ 执行,也就是能看到 course/ 的目录;离线 ZIP 解压后也用这个位置。在个人主页源码仓库中,先进入 _courses/deepinfer/。Windows 可把 python course/tools/grade.py 替换为 ./course/run.ps1。
python course/tools/grade.py lab04 --question q1 --unlock
python course/tools/grade.py lab04 --question q1 --failfast
python course/tools/grade.py lab04 --question q1 --hint 1
初始 NotImplementedError 是起点,不是环境损坏。先写下预期输出,再实现一个函数,最后只跑这一题。不要在首个错误出现时直接打开整份 solution。
2. 先画状态,而不是先写循环
构造器已提供:BlockPool(capacity) 创建长度为 capacity 的 refs 列表。capacity 是非负整数且排除 bool。available() 统计 refs=0 的块数。公开 refs 是为了观察,不意味着调用者应该直接改它。
| 时刻 | 操作 | 返回值 | refs |
|---|---|---|---|
| 0 | 建立容量 3 的池 | 新池 | [0,0,0] |
| 1 | allocate(2) | [0,1] | [1,1,0] |
| 2 | retain([0]) | None | [2,1,0] |
| 3 | allocate(1) | [2] | [2,1,1] |
| 4 | release([0,1]) | None | [1,0,1] |
| 5 | allocate(1) | [1] | [1,1,1] |
最后复用块 1,而不是新增不存在的块 3。把这张表抄到自己的笔记,每完成一题就执行当前已经支持的前几行。
3. Q1:先证明放得下,再分配
allocate(count):count 必须是非负整数,bool 不合法。按最小 ID 选择空闲块,把它们的 refs 设为 1,返回新的 ID 列表。申请 0 返回 []。数量不足抛 MemoryError;非法数量抛 ValueError。失败不能部分修改 refs。
建议按三步完成:
- 先只验证 count,确认 -1、1.5、True 都被拒绝。
- 只读地列出空闲候选,手算 refs=
[1,0,1,0]时应为[1,3]。 - 比较候选数量与 count,成功后才统一修改所选位置。
检查点:容量 3,先申请 2 再申请 2,第二次必须失败且保留 [1,1,0];随后申请 1 仍能拿到 [2]。如果第二次失败后第三次也失败,通常是在容量判断之前写入了状态。容量 0、申请 0 也必须成功。
python course/tools/grade.py lab04 --question q1 --failfast
4. Q2:共享增加所有权,不增加内容副本
retain(block_ids) 返回 None。输入必须是不重复整数 ID 的列表;每个 ID 在池范围内,且 refs>0。负数、越界、bool、浮点数、重复 ID、空闲 ID 或非列表都抛 ValueError。空列表有效,输入列表不改变。
第一步,把验证工作写成只读检查:类型、范围、占用、重复。第二步,确认整批都合法后,每个 refs 加一。Q3 可以复用同一验证逻辑,但不要为了复用而提前改变状态。
检查点:[1,1,0] 上 retain [0] 得 [2,1,0];retain [0,2] 必须失败且仍是 [2,1,0]。如果变成 [3,1,0],说明前一个合法元素已被过早更新。
为什么 retain 一个空闲块是错误,而不是方便的分配?
retain 表示“新增一个已有块的所有者”,不能证明空闲块的内容有效,也没有完成 allocate 的资源选择。把两种操作混在一起会破坏接口契约。缓存层若要重新保护一个无人引用但内容仍有效的块,需要专门的集成协议;Lab4 不实现这个协议。
5. Q3:release 是减一,不是清零
release(block_ids) 使用与 Q2 相同的整批验证。合法后每个 refs 减一;归零就允许重新分配,返回 None。不能产生负引用数。任何失败都不改变 refs,也不改输入。
先只实现“单块有两个所有者”的情景:2→1,此时不可分配;再实现 1→0,此时可分配;最后加入多块批次。release([0,0]) 必须拒绝,不能把一个所有者的重复 ID 当成两次释放。
调试必做:从 refs=[1,0] 调用 release([0,1])。记录调用前状态、期望异常、调用后状态。期望是 ValueError 和 [1,0],不是 [0,0]。如果错,找到第一条过早执行的写操作,再回归同一输入。
6. Q4:位置分解后再查表
locate(block_table, token_index, block_size) 返回 (物理ID, 块内偏移)。table 必须是非负整数 ID 列表,允许 ID 不连续或重复;B 为正整数;t 为整数;整数均排除 bool。要求 0 <= t < len(table)*B,其余非法输入抛 ValueError。不修改块表。
先在纸上算商与余数,再查表:table=[9,2,7],B=4,t=4 应为 (2,0),t=11 为 (7,3);t=12 越界。空表没有有效位置。该函数不知道物理池容量和真实序列长度,不要擅自增加“物理 ID 必须小于表长”或“最后一块全都已写入”的假设。
分层检查:为何 table=[9]、t=0、B=4 是合法映射?
表长为 1 表示这条请求有一个逻辑块,不表示物理池只有一块。物理 ID 9 可以来自更大的池。locate 只做映射;池范围由资源管理层负责,真实序列长度由请求层负责。
7. 整体验证、解释与交付
python course/tools/grade.py lab04
现有测试还包含 150 步固定种子所有权轨迹,逐块核对引用数。随机轨迹有助于发现组合错误,但不能替代你解释前面的最小反例。
每题自评 5 分:测试 3 分、手算和解释 2 分,四题共 20 分,保持 Unit2 试运行口径,不并入 Unit1。交付实现、上表实际轨迹、一个失败修复记录,并回答:为什么共享块不能在首个请求结束时清零?为什么 refs 总和不是占用块数?为什么 locate 不能证明 token 已经写入?然后进入 第 10 讲 与 HW5。