qinghuan
← 评测、复现与研究实践 / 全部笔记

单元 47 / 评测、复现与研究实践

P1 · 重点提升L3 · 深入

推理系统、KV Cache 与服务性能边界

推理时先处理已有输入,再逐 token 生成;看 KV 缓存怎样复用前文计算,检查因果掩码位置,并用分页共享模型估算服务显存。

本篇目录 · 按需要跳转

这篇重点学什么

本篇重点:对比使用与不使用 KV Cache 的结果,分清先处理提示内容(prefill)和逐步生成(decode),再检查查询与键数量不同时的掩码、分页共享及写时复制。

开始读正文 ↓
学过相近内容?查看哪些可以跳过

两条路线有些基础相同。已经学会的部分可以跳过,另一条路线只用来补你当前需要的内容。

成本与推理性能

共同基础先说清统计哪些调用、哪些阶段,再比较费用、耗时和单位时间处理量(吞吐)。

另一条路线补充什么?

逐次记录调用费用,缺少用量时标为未知;拆开从请求开始到结束各阶段的耗时,再设置应用预算。

读完这篇,你能做什么

在 CPU 上对比完整与增量注意力结果;用 Java 检查分页引用、分支修改前复制,以及容量不足时失败不会留下半完成状态。

学习理由、准备要求与知识点

为什么学它

缓存用错可能悄悄改变输出,能容纳多少缓存、怎样调度也影响并发。单个请求跑得快,不能代表服务在多请求下也表现好。

先读这些内容

知识点清单

  • 预填充与解码阶段
  • 每层K和V缓存
  • 位置及非方形掩码
  • KV容量估算
  • 分页与内部碎片
  • 前缀共享写时复制
  • 连续批处理与公平性
  • 首token和逐token延迟

运行前先确认论文机制按文中版本说明;Java 17 完整算法,Python 3.14 与 PyTorch 2.9.1+cpu 的小型 CPU 实验。未执行 GPU、多机或大规模模型训练。

01先弄懂它是什么

语言模型逐个生成 token 时,如果每次都重算全部历史,会做很多重复工作。对因果 Transformer,在模型权重、位置规则和前缀固定的推理过程中,过去位置的表示不依赖后来追加的 token,因此可以保存每层已经算出的键 K 和值 V,让新查询 Q 直接读取,这就是 KV Cache。它缓存的是中间张量;改了权重或前缀后,不能直接沿用旧缓存,也不能把它当作整份回答缓存。

一次请求通常先处理已有提示,建立初始缓存,这叫预填充;随后进入解码,为每个活跃请求逐步计算一个或少量新 token。两阶段的计算形状、内存读取和调度压力不同。缓存能省掉反复计算历史 K、V 的工作,但新查询仍要读取允许范围内的历史键值。因此上下文变长后,每一步的成本仍可能增加。

粗略KV容量可按2×层数×批量×缓存长度×KV头数×头维度×每元素字节估算,二表示K和V。使用分组查询注意力时KV头数可能小于查询头数,不能把所有头数混用。公式只估计张量有效载荷,分配器碎片、页表、激活、模型权重和运行时工作区还要另算。

文中这几个词,先认一认

预填充
先处理提示里已有的多个 token,并为各层建立历史缓存的阶段。
写时复制
多个分支先共享同一份数据,某个分支准备修改时再复制一份,避免影响其他分支。
首token延迟
从请求到达到用户能看到第一个输出 token 的时间,包含排队、处理提示等等待。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

在Python单头实验中,序列长度五、向量维度四。完整注意力用5×5下三角掩码;增量第t步只有一个查询和t+1个历史及当前键,这些键全部允许,因此不再额外套上左对齐下三角。带三token前缀再计算两token块时,则需按绝对位置构造2×5布尔掩码。

PyTorch2.9的scaled_dot_product_attention在非方形且is_causal为真时使用上左对齐因果偏置。若把长度一的新查询配上完整历史键,这种默认形状语义只允许开头部分,并不自动理解缓存位置。实验同时展示正确显式掩码和错误默认用法的结果差异,防止“API名称带causal就一定正确”的误解。

PagedAttention将逻辑连续的KV切成固定大小块,通过映射使用非连续物理空间,并支持共享与写时复制。Java仅用整数token页建模引用关系:分叉时共享前缀,向共享未满尾页追加前先复制,最后一个引用离开才释放。它不存实际KV张量、不执行GPU内核,也没有实现高并发调度。

先跑通一个最小例子

PyTorch CPU执行experiment.py,固定五token单头注意力,对比完整、增量和带前缀分块计算。

experiment.py · python
import math
import torch
import torch.nn.functional as F
torch.set_num_threads(1)
torch.manual_seed(47)
x=torch.randn(5,4,dtype=torch.float64)
wq,wk,wv=[torch.randn(4,4,dtype=torch.float64) for _ in range(3)]
q,k,v=[x@w for w in (wq,wk,wv)]
def attend(q,k,v,**kwargs):
    return F.scaled_dot_product_attention(q[None,None],k[None,None],v[None,None],dropout_p=0.,**kwargs)[0,0]
full=attend(q,k,v,is_causal=True)
keys=torch.empty((0,4),dtype=torch.float64);values=keys.clone();out=[]
for t in range(5):
    keys=torch.cat((keys,x[t:t+1]@wk));values=torch.cat((values,x[t:t+1]@wv))
    out.append(attend(x[t:t+1]@wq,keys,values,is_causal=False))  # 只有历史与当前键,单步可看全部缓存。
incremental=torch.cat(out)
assert torch.allclose(full,incremental,atol=1e-10,rtol=1e-10)
assert keys.shape==(5,4) and values.shape==(5,4)
assert torch.allclose(keys,k) and torch.allclose(values,v)
# 带三token前缀的两token块,按绝对位置建立2乘5掩码。
allowed=torch.arange(5)[None,:]<=torch.arange(3,5)[:,None]
chunk=attend(q[3:],k,v,attn_mask=allowed,is_causal=False)
assert torch.allclose(chunk,full[3:],atol=1e-10,rtol=1e-10)
wrong=attend(q[-1:],k,v,is_causal=True)  # 非方形默认上左对齐,不能表达当前位置4。
assert not torch.allclose(wrong,full[-1:],atol=1e-8,rtol=1e-8)
assert torch.isfinite(incremental).all()
print(f"max_cache_error={(full-incremental).abs().max().item():.3e} cache_tokens={len(keys)}")
print("PASS 6 checks; CPU single-head attention, no GPU serving benchmark")

核对运行结果max_cache_error小于1e-10,cache_tokens=5,PASS 6 checks;未运行GPU吞吐测试。

正在加载流程图…

预填充建立缓存,解码复用缓存;容量、有效长度与淘汰策略决定可服务边界。
查看流程图源码(Mermaid)
flowchart TD
  N0["预填充建立每层键值缓存"]
  N1["按实际位置更新缓存和掩码"]
  N2["解码读取允许的历史键值"]
  N3["共享前缀并在写入前复制"]
  N4["按容量和延迟预算调度回收"]
  N0 --> N1
  N1 --> N2
  N2 --> N3
  N3 --> N4

按顺序看,每一步做了什么

  1. 预填充建立每层键值缓存
  2. 按实际位置更新缓存和掩码
  3. 解码读取允许的历史键值
  4. 共享前缀并在写入前复制
  5. 按容量和延迟预算调度回收

03对照实现代码

PyTorch注意力掩码与缓存位置 (新标签页)↗

PyTorch 2.9文档;实验2.9.1+cpu · torch.nn.functional.scaled_dot_product_attention,attn_mask与is_causal

原创CPU注意力桥梁,参考固定PyTorch接口;未实现PagedAttention CUDA内核或完整Transformer。

核心机制代码 · python
import torch
import torch.nn.functional as F
def cached_chunk(query, keys, values, past_length):
    positions = past_length + torch.arange(query.shape[-2])  # 当前查询的绝对位置。
    allowed = torch.arange(keys.shape[-2])[None,:] <= positions[:,None]  # True表示可见历史,含当前但不含未来。
    return F.scaled_dot_product_attention(query, keys, values, attn_mask=allowed, dropout_p=0., is_causal=False)  # 原创CPU桥梁,显式掩码负责因果约束。

这个注意力接口的布尔 attn_mask 用 True 表示允许参与,而且形状必须能按广播规则扩展到查询与键的权重矩阵。换别的掩码 API 时,要重新检查 True 的含义。本篇短函数限定 CPU 张量、没有 padding,past_length 表示已有前缀长度;真实批次中各请求长度可能不同,还必须结合每条请求的有效长度与填充位置来限制读取。

文档给出非方形is_causal的上左对齐行为。本文显式按绝对位置生成掩码,并令is_causal为假避免重复指定不同规则;单步增量可见全部缓存是该实验无未来键时的特例,不代表训练或多token块总能关闭因果限制。

Transformers固定版本缓存说明强调新token的cache_position以及包含过去与当前位置的掩码长度。动态缓存拼接便于理解,但反复torch.cat有复制开销,不能据此推断生产缓存应采用同样分配方式。实际模型还包含多层、多头、位置编码和不同缓存类型。

PagedAttention原论文讨论逻辑块与物理块映射、共享引用及写时复制,以减少预留和碎片浪费。本文Java对应其中数据管理直觉,不是vLLM源码移植;吞吐提升必须在指定硬件、请求长度分布和并发条件下测量,CPU张量等价性测试不能提供这样的证据。

代码先调用谁,再调用谁

  1. 读取缓存有效长度与查询位置
  2. 组合历史及当前K和V
  3. 构造绝对位置可见掩码
  4. 调用注意力后保存更新缓存

04动手写一个例子

支持分支与写时复制的分页缓存模型

以固定容量整数页保存序列,支持创建、追加、分叉、读取和释放;共享未满尾页先复制,容量不足在修改引用前拒绝。

输入 1
页大小2,a=[1,2,3],分叉b并追加4
输出 1
a保持[1,2,3],b=[1,2,3,4],共3页
输入 2
容量1,未满页被两分支共享,再追加
输出 2
拒绝且两分支内容不变

序列保存页ID列表,物理页保存已用长度和引用次数。分叉只复制页表并增加对应页引用,不复制内容;共享的完整页永不原地追加,新增token进入新页。共享尾页尚有空位时才需要写时复制,从而保证一个分支写入不会污染另一分支。

页引用次数等于所有存活序列页表中指向它的项数,归零才移除。申请新页先检查容量,再改变旧引用与页表,因此显式配额失败保持原状态;它不承诺处理虚拟机内存耗尽等任意异常。实现仅单线程,真实并发需要锁或其他同步与生命周期协议。

设页大小B、单序列页数P、有效token数T:非分配追加摊还O(1),新页零初始化或写时复制为O(B),分叉和释放O(P),读取O(T+P)。若U个物理页、所有序列页表项总数M,缓存存储为O(UB+M)。不能把共享分支创建误写成常数时间,因为页表仍需复制。

为什么这样写是对的每页refs等于存活页表中的引用数量;共享页修改前复制,非尾页已满,归零页释放。

时间 追加摊还O(1),分配或复制O(B);分叉/释放O(P),读取O(T+P)额外空间 缓存O(UB+M),读取另需O(T)

B为页大小,U为物理页数,M为所有页表项总数;哈希操作按期望常数计。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 73 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    static void near(double expected,double actual) {
        if (!Double.isFinite(actual) || Math.abs(expected-actual)>1e-9*Math.max(1,Math.abs(expected)))
            throw new AssertionError(expected+" != "+actual);
    }
    static void check(boolean ok) { if(!ok) throw new AssertionError(); }
    static void bad(Runnable r) {
        try { r.run(); } catch(IllegalArgumentException e) { return; }
        throw new AssertionError("invalid input accepted");
    }
static final class Page {
        final int[] tokens;int used=0,refs=1;
        Page(int size){tokens=new int[size];}
    }
    static final class Cache {
        final int blockSize,capacity;int nextId=0;
        final Map<Integer,Page> pages=new HashMap<>();
        final Map<String,List<Integer>> chains=new HashMap<>();
        Cache(int blockSize,int capacity) {
            if(blockSize<1||capacity<1) throw new IllegalArgumentException("capacity");
            this.blockSize=blockSize;this.capacity=capacity;
        }
        void create(String name) {
            if(name==null||name.isBlank()||chains.containsKey(name)) throw new IllegalArgumentException("name");
            chains.put(name,new ArrayList<>());
        }
        List<Integer> get(String name) {
            List<Integer> chain=chains.get(name);if(chain==null) throw new IllegalArgumentException("unknown sequence");return chain;
        }
        int allocate(Page copy) {
            if(pages.size()>=capacity||nextId==Integer.MAX_VALUE) throw new IllegalArgumentException("capacity exhausted"); // 配额先检查,失败不修改引用关系。
            Page page=new Page(blockSize);
            if(copy!=null) {System.arraycopy(copy.tokens,0,page.tokens,0,copy.used);page.used=copy.used;}
            int id=nextId++;pages.put(id,page);return id;
        }
        void append(String name,int token) {
            List<Integer> chain=get(name);Page last=chain.isEmpty()?null:pages.get(chain.get(chain.size()-1));
            if(last==null||last.used==blockSize) {int id=allocate(null);chain.add(id);last=pages.get(id);}
            else if(last.refs>1) {
                int id=allocate(last);last.refs--;chain.set(chain.size()-1,id);last=pages.get(id); // 共享的未满尾页在写入前复制。
            }
            last.tokens[last.used++]=token;
        }
        void fork(String from,String to) {
            List<Integer> copy=new ArrayList<>(get(from));create(to);
            for(int id:copy) pages.get(id).refs++;chains.put(to,copy); // 分支共享页,页引用次数按序列映射计数。
        }
        int[] read(String name) {
            List<Integer> chain=get(name);int size=0;for(int id:chain)size+=pages.get(id).used;
            int[] out=new int[size];int at=0;
            for(int id:chain){Page p=pages.get(id);System.arraycopy(p.tokens,0,out,at,p.used);at+=p.used;}return out;
        }
        void release(String name) {
            List<Integer> chain=get(name);chains.remove(name);
            for(int id:chain) if(--pages.get(id).refs==0) pages.remove(id); // 最后一位引用者离开后才释放物理页。
        }
    }
    public static void main(String[] args) {
Cache c=new Cache(2,4);c.create("a");c.append("a",1);c.append("a",2);
        check(c.pages.size()==1&&Arrays.equals(c.read("a"),new int[]{1,2}));
        c.append("a",3);check(c.pages.size()==2&&Arrays.equals(c.read("a"),new int[]{1,2,3}));
        c.fork("a","b");c.append("b",4);check(c.pages.size()==3&&Arrays.equals(c.read("a"),new int[]{1,2,3})&&Arrays.equals(c.read("b"),new int[]{1,2,3,4}));
        c.release("b");check(c.pages.size()==2);c.release("a");check(c.pages.isEmpty());
        Cache full=new Cache(2,2);full.create("a");full.append("a",1);full.append("a",2);full.fork("a","b");full.append("b",3);
        check(full.pages.size()==2&&Arrays.equals(full.read("a"),new int[]{1,2}));
        Cache tight=new Cache(2,1);tight.create("a");tight.append("a",1);tight.fork("a","b");bad(()->tight.append("b",2));
        check(tight.pages.size()==1&&Arrays.equals(tight.read("b"),new int[]{1}));tight.release("b");tight.append("a",2);check(tight.read("a").length==2);
        bad(()->new Cache(0,1));
        bad(()->c.release("a"));
        System.out.println("PASS 8 cases");
    }
}

这些特殊情况也要试一试

  • 完整共享页追加只分配新页
  • 共享未满尾页触发复制
  • 配额失败不改变已存内容
  • 重复释放和非法页大小拒绝
进阶选读 · 生产服务中的容量调度与隔离

场景长请求、短请求和多个候选共享有限KV空间。

做法按token预算做准入和连续批处理,记录页占用与回收;前缀键包含模型和位置等兼容信息,跨租户复用必须满足隔离策略;在目标硬件测首token及逐token尾延迟。

复杂度真实KV读写随层、头和上下文长度增长,分页减少分配浪费并不消除注意力计算;调度和GPU内核开销需要实测。

好处与代价较大页减少页表开销但增加尾页浪费,前缀共享节省空间却增加引用管理与隐私隔离复杂性。

05项目里怎么用

服务性能报告应区分排队、预填充和解码耗时,并给请求长度分布、并发数、输出长度及采样配置。平均tokens每秒可以随着批处理增大而提高,同时单个用户尾延迟变差;因此需要吞吐与延迟共同约束的测试,而不是用单请求最快一次代表生产能力。

缓存复用条件必须包含影响KV值的全部因素,例如模型权重或适配器、token前缀、位置编码和相关执行配置。只比较可见文本不足以证明token序列相同,更不能忽略租户权限。取消、超时和异常退出都应释放引用,重复释放或错误共享可能造成泄漏与跨请求污染。

本文CPU实验验证完整与增量注意力在同一数学设置下相符,Java验证分页内容和容量协议。没有测量GPU显存、FlashAttention、真实vLLM服务或多机通信;后续应在可控负载下加入取消、抢占、长短混合和OOM恢复实验,再讨论实际吞吐收益与资源成本。

容易踩的坑

  • KV缓存被当答案缓存
  • 非方形causal掩码忽略位置
  • 共享尾页原地写入污染分支
  • 只统计权重忽略KV容量
  • 单请求速度当生产吞吐保证

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
连续动态缓存代码直观增长复制与分配开销小型验证和原型
固定预分配缓存位置稳定、减少动态分配预留未用空间长度上限明确的工作负载
分页共享缓存减少碎片并复用前缀页表与引用管理复杂并发服务与多候选生成

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为何缓存K和V而非所有旧Q?

展开参考答案与推导

新步骤用新Q查询过去K和V,旧Q的输出已计算,不需要重新查询。

02缓存后注意力每步常数时间吗?

展开参考答案与推导

不是,新查询仍读取允许的历史键值,成本随上下文和模型形状变化。

03写时复制为何必要?

展开参考答案与推导

共享未满页被一个分支写入会改变其他分支看到的数据,先复制可保留隔离。

换个情况,还会处理吗

01增量输出与完整不同先查什么?

展开参考答案与推导

检查缓存长度、位置、掩码、padding、dtype及模型是否同一评估设置。

02并发增加吞吐升尾延迟也升?

展开参考答案与推导

拆开排队和执行,按延迟目标限制批次与准入,而非只追求总吞吐。

03取消后显存一直不降?

展开参考答案与推导

检查页引用和回收路径,区分可复用内存池保留与真正仍被活跃请求引用。

写代码试一试

01构造两查询五键掩码。

输入 / 条件
过去3个token,新2个
输出 / 预期
首行可见0至3,次行0至4

提示 按绝对位置比较

展开参考答案与推导

键位置小于等于past加当前查询偏移时允许。

02验证共享尾页。

输入 / 条件
a为123,页大小2,b追加4
输出 / 预期
a不变且b为1234

提示 复制引用数大于一的尾页

展开参考答案与推导

先申请复制页,再减旧引用并替换b页表。

03估计KV载荷。

输入 / 条件
层数L,批量N,长度T,KV头H,维度D,字节S
输出 / 预期
2×L×N×T×H×D×S

提示 K和V各一份

展开参考答案与推导

另外报告页表、碎片、工作区与权重,不能当设备总内存。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

在 CPU 上对比完整与增量注意力结果;用 Java 检查分页引用、分支修改前复制,以及容量不足时失败不会留下半完成状态。

本篇重点加练

位置身份与共享缓存生命周期决定增量计算正确性,重点检查同内容不同计算条件。

  • 位置及非方形掩码
  • 前缀共享写时复制

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为四 token 前缀再生成三个 token 的解码画调用时序,记录每步 Q、K、V 的逻辑长度与位置身份,区分预填充和解码。

    提交内容
    时序与形状表
    怎样算完成
    • 缓存位置可追踪
    • 历史和当前 token 区分

    练到的知识点预填充与解码阶段 · 每层K和V缓存 · 位置及非方形掩码

  2. 作业 02代码推演

    设两层、批量 3、KV 头 2、头维 8、长度 17、每元素 2 字节,计算有效载荷;再用页大小 4 比较预留与尾部浪费。

    提交内容
    容量预算表
    怎样算完成
    • K/V 双份及单位明确
    • 载荷与分配开销分开

    练到的知识点KV容量估算 · 分页与内部碎片

  3. 作业 03原理问答

    两个请求 token 前缀相同,但位置起点或适配器不同。为共享 KV 定义兼容条件,区分可共享原始 token 与可共享已计算 KV。

    提交内容
    缓存对象与兼容性矩阵
    怎样算完成
    • 缓存层次明确
    • 条件对应实际计算依赖

    练到的知识点位置及非方形掩码 · 前缀共享写时复制

综合应用

  1. 作业 04编程实践

    实现 CPU 小注意力的完整、单 token 增量和分块增量三条路径,对长度 7 的输入比较输出及掩码。

    提交内容
    实现与三路径差异记录
    怎样算完成
    • 绝对位置一致
    • 非方形掩码显式验证

    练到的知识点每层K和V缓存 · 位置及非方形掩码

  2. 作业 05排错实验

    给分页缓存加入三次分叉、两个取消和一次容量拒绝,用随机但固定种子的操作序列核对内容、引用计数和回收。

    提交内容
    CPU 模型测试与状态日志
    怎样算完成
    • 失败操作的原状态可检查
    • 模型不冒充真实 GPU 缓存

    练到的知识点分页与内部碎片 · 前缀共享写时复制

  3. 作业 06面试场景

    长短请求混合到达时,总吞吐上升但短请求等待加长。设计调度评价,分别定义排队、首 token 与逐 token 延迟。

    提交内容
    负载协议与指标表
    怎样算完成
    • 延迟起止点明确
    • 公平性按子群报告

    练到的知识点连续批处理与公平性 · 首token和逐token延迟

  4. 作业 07编程实践

    CPU 小注意力保存一个前缀缓存后分叉两条不同续写,分别与无共享完整计算核对;注入位置错配并验证检查器拒绝。

    提交内容
    分叉实验及差异记录
    怎样算完成
    • 分支位置可追踪
    • 共享不改变其他分支结果

    练到的知识点位置及非方形掩码 · 前缀共享写时复制

  5. 作业 08排错实验

    为不超过十页的 CPU 缓存模型设计快照恢复实验,重载后分叉写入并检查共享关系;注入页 ID 遗漏,记录恢复器如何识别不完整状态。

    提交内容
    生命周期账本与不变量测试
    怎样算完成
    • 快照对象与共享关系可追踪
    • 不完整页记录不会静默丢弃

    练到的知识点前缀共享写时复制

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    选做真实推理服务基准:固定模型、长度分布和并发,比较两种批处理策略;无目标设备时仅提交可执行测量计划。

    提交内容
    实测报告或待执行协议
    怎样算完成
    • 设备与软件版本完整
    • 不从 CPU 数学核验推断 GPU 吞吐

    练到的知识点预填充与解码阶段 · KV容量估算 · 连续批处理与公平性 · 首token和逐token延迟

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    把 KV 共享迁移到编译器公共子表达式缓存:文本相同为何可能还不足以复用?建立上下文、版本和位置的对应关系,并给出一个错误复用反例。

    提交内容
    跨域依赖图与反例
    怎样算完成
    • 类比对应具体计算输入
    • 未映射的差异明确保留

    练到的知识点位置及非方形掩码 · 前缀共享写时复制

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗