跳到正文

SYSTEMS · INTERVIEW NVIDIA · 0710

37 道题,考的是
AI Infra
一条完整系统链路

这份候选人回忆题把 AI Infrastructure(人工智能基础设施)从推理服务、GPU 算子与通信一路串到 C++、数学和算法。每题先用白话与技术图建立直觉,再进入 30 秒表达、公式推导和工程边界。

37QUESTIONS
AI Infra 概念主视觉:GPU 板卡、内存层级、张量矩阵与集群互联从左到右串联
01推理系统1202硬件与算子903C++604数学305算法7
概念主视觉从单卡硬件、内存与张量延伸到集群互联,概括本页的知识跨度;它不是某一款 NVIDIA 产品的精确拓扑,具体机制以逐题 SVG、公式和来源为准。

来源说明候选人回忆整理,非 NVIDIA 官方题库。题目表述可能与现场存在差异;本页用于技术知识展示,不代表 NVIDIA 的招聘标准或官方立场。

这 37 题,在考一条完整的系统链路

前 12 题从服务负载进入推理系统,随后下钻到 GPU、语言基础与现场编码。先建立地图,再逐题深入。

蓝色主线是系统设计链路;绿色节点强调 GPU 与 NVIDIA 生态相关的硬件边界;下方三组能力用于验证基础与编码稳定性。

三层阅读结构:直觉、原理、工程边界

  1. 01
    建立直觉

    先读白话解释和配图,弄清对象、数据流与最关键的因果关系。

  2. 02
    理解原理

    再看公式、实现路径与系统组件怎样协作,避免只记结论。

  3. 03
    看清边界

    最后对照工程取舍、常见误区与延伸问题,理解方法在什么条件下成立。

逐题讲解

37 道题符合当前条件

01

CHAPTER

推理系统

12 QUESTIONS · #

根据硬件计算和访存带宽,计算 Matmul 算子的最佳输入数据大小,再根据这个计算 MoE TopK 输入的最佳 token 数(Roofline 模型)

推理系统RooflineMoE调度
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题不是要背一个“最佳 token 数”,而是在问:给定一张 GPU 的计算速度和显存搬运速度,一次要喂多少行数据,矩阵乘才不会一直等权重从显存搬过来。这个判断会直接影响 MoE(Mixture of Experts,混合专家模型)中每个专家一次拿到多少 token,以及该不该为了凑大批量而牺牲在线延迟。

  1. 先把 GPU 想成厨房:计算单元是厨师,显存带宽是送菜速度。数据太少时,厨师做完一小口就等下一车食材,算子是访存受限;数据足够多、同一批权重能服务更多行时,厨师持续干活,才逐渐转为计算受限。Roofline(屋顶线模型)用“峰值算力 ÷ 显存带宽”得到两种状态的分界,叫 ridge point。

  2. 对矩阵乘 `C[M,N] = A[M,K] × B[K,N]`,大约要做 `2MKN` 次浮点运算,同时要读 A、读权重 B、写 C。把运算量除以搬运字节数,就是算术强度;令它等于硬件分界,就能尝试解出临界行数 `M*`。只有 `2KN − R(bA·K+bC·N) > 0` 时才存在正的有限解;否则即使把 M 加到无限大也碰不到计算屋顶。在大 K、N 场景里,BF16 下常可粗略记成 `M* ≈ 峰值算力/显存带宽`,但精确值仍与形状和数据类型有关。

  3. MoE 中一个 token 会被 TopK 路由到 k 个专家,因此 T 个 token 会产生约 `kT` 份“token—专家任务”。若共有 E 个专家且路由均匀,每个专家平均拿到 `kT/E` 行;想让它达到 `M*`,整个专家并行组需要约 `T = E×M*/k` 个原始 token。以 E=256、TopK=8、`M*=333` 为例,整个组约需 10,656 个 token;若 EP(Expert Parallel,专家并行)=32,平均到每卡约 333 个本地 token。

  4. 这个 `M*` 只是从“带宽受限”跨向“计算受限”的理论转折点,不是唯一最优值,更不是线上必须攒够才开算。Tensor Core 分块、专家冷热不均、All-to-All 通信、排队时间和 P99 延迟都会改变端到端最优点;真正做法是先用 Roofline 缩小候选范围,再用真实路由数据和真实矩阵形状逐档实测。

记住一句 Roofline 给的是“开始不再只等内存”的临界批量,不是万能最佳值;MoE 再用 `专家数 × 临界行数 ÷ TopK` 把它换算成 token 规模。

30″ 面试开口
Roofline 能算出的不是一个放之四海而皆准的“最佳 shape”,而是:给定 GEMM 的 K、N、数据类型以及硬件有效算力/带宽后,使算子从 memory-bound 跨到 compute-bound 所需的最小 M。…
先用算术强度找到 GEMM 的临界 M,再用路由 assignment 守恒关系反推 MoE token batch;公式给候选区间,真实转折点必须由 benchmark 校准。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

Roofline 能算出的不是一个放之四海而皆准的“最佳 shape”,而是:给定 GEMM 的 K、N、数据类型以及硬件有效算力/带宽后,使算子从 memory-bound 跨到 compute-bound 所需的最小 M。实际最优值还会受 Tensor Core tile、grouped GEMM 调度、L2 命中、量化 scale、kernel launch 和专家负载不均影响,必须用实测校准。

C[M,N] = A[M,K] × B[K,N]

Fgemm=2MKNF_{gemm}=2MKN

假设 beta=0,A、B 各从 HBM 读一次,C 写一次,则最理想的数据搬运量为:

Q=bAMK+bBKN+bCMNQ=b_A MK+b_BKN+b_CMN

算术强度:

AI(M,K,N)=2MKNbAMK+bBKN+bCMN(FLOP/Byte)AI(M,K,N)=\frac{2MKN}{b_AMK+b_BKN+b_CMN}\quad (\text{FLOP/Byte})

硬件 Roofline 的转折点(ridge point):

R=FpeakBWHBMR=\frac{F_{peak}}{BW_{HBM}}

AI=R,解出临界 M

M=RbBKN2KNR(bAK+bCN)M^*=\frac{R\,b_BKN}{2KN-R(b_AK+b_CN)}

前提是分母为正;如果分母不为正,说明即使 M→∞,这个 shape 在该数据搬运模型下也达不到计算屋顶。若 A/B/C 都是 BF16,即每元素 2 Byte,则:

M=RKNKNR(K+N)M^*=\frac{RKN}{KN-R(K+N)}

K、N >> R 时,权重 B 的读取占主导,AI≈M,因此可用非常好记的近似:

MRM^*\approx R

H100 + DeepSeek 专家 GEMM 数值例子

假设使用 H100 SXM,不使用结构化稀疏的 dense BF16 峰值约为 989.5 TFLOP/s,HBM 带宽 3.35 TB/s。NVIDIA 页面列出的 1,979 TFLOP/s 带有稀疏加速脚注,所以普通 dense GEMM 应取约一半:

R=989.5/3.35295.4 FLOP/ByteR=989.5/3.35\approx295.4\ \text{FLOP/Byte}

DeepSeek-V3 routed expert 的 hidden size 为 7168,expert intermediate size 为 2048。将 gate/up 两个投影融合后,可近似看成 K=7168,N=4096

Mgate/up333M^*_{gate/up}\approx333

down projection 为 K=2048,N=7168

Mdown363M^*_{down}\approx363

所以该理想模型给出的结论是:每个 expert 一次聚到约 300~360 行,才接近 H100 dense BF16 的理论 Roofline 转折点。它不是“线上必须攒到 333 token 才运行”;在线推理通常受延迟约束,会在更小的 M 上运行并处于 memory-bound。

从专家 GEMM 反推 MoE TopK 的 token 数

设:

  • E:routed expert 总数;
  • k:每个 token 激活的 expert 数;
  • T_group:一个 EP group 在本次 MoE 层中汇总的原始 token 数;
  • 路由均匀,暂不考虑 capacity padding 和冗余 expert。

总 token-expert assignment 数为 kT_group,每个 expert 的平均 GEMM 行数:

Me=kTgroupEM_e=\frac{kT_{group}}{E}

要让平均 expert batch 达到 M*

Tgroup=EMkT^*_{group}=\frac{EM^*}{k}

DeepSeek-V3 取 E=256、k=8、M*=333

Tgroup256×3338=10,656 tokensT^*_{group}\approx\frac{256\times333}{8}=10,656\ \text{tokens}

EP=P,每卡进入该 EP group 的本地 token 数相同,则:

Tlocal=EMkPT^*_{local}=\frac{EM^*}{kP}

例如 EP=32 时,理论上约需每卡 333 个本地 token,才能使平均每个 expert 得到约 333 行。这更像 prefill 大 token batch;decode 的每卡 token batch 往往只有个位数到几十,因此专家 GEMM通常仍是带宽受限。

工程上怎么真正选

  1. 用该公式得到候选区间,而不是直接上线。
  2. 将理论峰值替换为同精度下实测的有效 Tensor Core 吞吐和 HBM 带宽;通常还要乘利用率系数。
  3. 对真实 M 分布跑 grouped GEMM benchmark,至少扫 M={1,2,4,...,512},同时测 P50/P99,而不只测吞吐。
  4. 用真实路由 trace 统计每个 expert 的 token 数分布;用均值会低估长尾。可用 M_p95 或容量系数 c>1 规划 buffer。
  5. 将 All-to-All、permute/unpermute 和 padding 一并算入端到端 MoE 时间。单独让 GEMM compute-bound,可能反而因攒 batch 增大排队延迟。

常见追问

  • 为什么 AI≈M 大模型 decode 的权重矩阵远大于激活,B 的 KN 字节占主导;BF16 下 2MKN/(2KN)≈M
  • FP8 怎么算? 必须分别写 b_A、b_B、b_C,并加入 scale/反量化流量。不能简单把所有字节都除以 2;输出和累加通常不是 FP8。
  • 为什么公式和实测转折点不一致? 理想模型假设每个元素只过 HBM 一次,且忽略 launch、tile 浪费、寄存器压力、L2 和频率降档。

讲一下 FlashAttention?FA 怎么实现的?FlashDecoding 是什么?

推理系统FlashAttentionGPU数学基础
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题在问的不是一种新 Attention 公式,而是怎样让原来的精确 Attention 少跑几趟显存。FlashAttention(闪存注意力)主要解决长序列中间矩阵太大、反复读写很慢的问题;Flash-Decoding 则进一步解决生成阶段查询很少、GPU 并行任务不够的问题。

  1. 普通 Attention 会先算 `QKᵀ`,形成一张“每个 token 对所有 token 的关注分数表”,做 Softmax 后再乘 V。序列长度是 N 时,这张表有 N² 个元素;传统分步实现会把分数表写回 HBM 显存,再读出做 Softmax,再写回和读出,很多时间其实花在搬这张大表,而不是乘法本身。

  2. FlashAttention 把 Q、K、V 切成能放进片上 SRAM(靠近计算单元的小而快存储)的块。每次只拿一个 Q 块和一小段 K/V,在片上完成打分、Softmax 更新和加权求和,只把最终输出写回 HBM。可以把它理解为“边读一页、边更新答案”,不再把整本草稿摊在仓库里。

  3. 分块后仍能得到精确结果,是因为 Softmax 可以维护每一行的当前最大值、指数和以及加权结果;读到更大的新分数时,把旧累计值按比例缩放后继续累加即可。这叫 online softmax。它只是重新安排计算和数据流,稠密 Attention 依然要比较大约 N² 对 token,因此没有把 `O(N²)` 计算复杂度变小。

  4. Decode(逐 token 生成)时,当前 Q 往往只有一行,却要查看很长的历史 K/V,普通分块只产生少量任务,GPU 吃不满。Flash-Decoding 会沿历史 KV 再切成多个 split,让很多计算块并行得到局部输出和 Softmax 统计量,最后用 log-sum-exp 规则精确合并;短上下文或大 batch 时,这一步合并开销反而可能不划算。

记住一句 FlashAttention 不少算 N² 次关系,而是少把 N² 中间表搬进搬出 HBM;Flash-Decoding 再把长 KV 拆开并行,最后精确合并。

30″ 面试开口
FlashAttention 是精确 attention,不是稀疏或近似 attention。它没有改变 softmax(QKᵀ)V 的数学结果和 O(N²d) 计算量;核心是 tiling、算子融合和 online softmax,避免把 N×N 的 score/probability 矩阵写回 HBM,从而显著减少 HBM IO 和中间显存。…
FlashAttention 的关键不是少算,而是不落盘 N×N 中间矩阵;Flash-Decoding 再沿 KV 维制造并行度。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

FlashAttention 是精确 attention,不是稀疏或近似 attention。它没有改变 softmax(QKᵀ)V 的数学结果和 O(N²d) 计算量;核心是 tiling、算子融合和 online softmax,避免把 N×N 的 score/probability 矩阵写回 HBM,从而显著减少 HBM IO 和中间显存。

Flash-Decoding 面向 query length≈1、context 很长、batch 很小 的 decode 场景。普通 FA 只有很少的 Q block,无法占满 GPU;Flash-Decoding 进一步沿 KV 序列维切分并行,最后精确合并各 split 的 softmax 结果。

标准 attention 为什么慢

标准实现通常拆成:

S = QKᵀ        -> 把 N×N 的 S 写 HBM
P = softmax(S) -> 读 S、写 N×N 的 P
O = PV         -> 再读 P

计算量仍是 Θ(N²d),但 HBM 还要反复读写 Θ(N²) 的中间矩阵。序列很长时,这个 IO 比 Tensor Core 乘法更贵。

FA 的实现:分块 + online softmax

将 Q 切成行块 Q_i,K/V 切成列块 K_j,V_j。对一个 Q tile,只在 SRAM/寄存器中保留当前 score tile,以及每一行的三个状态:最大值 m、归一化分母 l、尚未归一化的输出累积 O

初始化:

m=,l=0,O=0m=-\infty,\quad l=0,\quad O=0

读入一个新的 KV tile 后:

Sj=QiKjT/dS_j=Q_iK_j^T/\sqrt d
m=max(m,rowmax(Sj))m'=\max(m,\operatorname{rowmax}(S_j))
Pj=exp(Sjm)P_j=\exp(S_j-m')
l=emml+rowsum(Pj)l'=e^{m-m'}l+\operatorname{rowsum}(P_j)
O=emmO+PjVjO'=e^{m-m'}O+P_jV_j

遍历完所有 KV tile 后输出 O/l。当最大值变大时,旧分块的累积量乘 exp(m-m') 即可重新缩放,因此无需保存此前的 scores。这也是不同 KV 分片结果能够精确合并的基础。

实现层面还会:

  • 将 QK、mask、scale、softmax、dropout(训练时)和 PV 融在一个 kernel 数据流里;
  • 选择适合 shared memory、寄存器和 Tensor Core MMA tile 的块大小;
  • backward 不保存整个 attention matrix,而是保存少量归一化统计并重算 score;这是“多做一点 FLOP,少做大量 IO”的典型取舍;
  • causal attention 跳过未来 token 对应的 tile;变长 batch 通过 cumulative sequence length/块表处理。

原始论文给出的额外内存是输入输出之外 O(N),而标准实现需显式保留 O(N²) 中间量。FA 的 HBM 访问复杂度也低于标准 attention。

Flash-Decoding 的三步

  1. 把长 KV 序列按 sequence 维切成 s 个 split;切分本身只是 view/索引。
  2. 每个 split 独立计算当前 Q 对局部 K_s,V_s 的 partial output,并写出该 split 的 m_s、l_s/O_s 或等价 LSE 状态。
  3. 启动一个很小的 reduction kernel,用 log-sum-exp 规则把所有 split 精确归并为最终输出。

若每个 split 返回已归一化 o_sLSE_s,则全局:

LSE=logseLSEs,O=seLSEsLSEosLSE=\log\sum_s e^{LSE_s},\quad O=\sum_s e^{LSE_s-LSE}o_s

什么时候用哪个

  • prefill:Q 很长,普通 FA 已有足够多的 Q tiles,主要使用 FlashAttention。
  • decode、batch 大且 context 不太长:paged decode kernel 或普通 decode attention 可能更好,split reduction 有额外开销。
  • decode、batch 小且 context 很长:Flash-Decoding 通过 KV split 提供更多 thread block,通常更合适。

常见追问

  • FA 降低计算复杂度了吗? dense exact attention 没有,仍为 O(N²d);主要降低 IO 和中间显存。
  • 为什么分块 softmax 仍然精确? softmax 可以由可结合的 (max, exp-sum, weighted-value-sum) 状态表示并按上述公式合并。
  • Flash-Decoding 和 speculative decoding 是一回事吗? 不是。前者是单步 attention kernel 的并行化;后者是一次验证多个候选 token,减少串行 model forward 次数。

PrefixCache 的作用是什么?和 KVCache 有什么区别?怎么实现?

推理系统Prefix CacheKV CachePD 分离
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题在区分两个很容易混淆的概念:KV Cache 是一次生成过程中必须保留的“历史计算结果”,Prefix Cache 是让不同请求共享相同开头的“复用索引”。前者避免每生成一个新 token 都重算全部历史,后者进一步避免相同系统提示或多轮会话被不同请求重复 Prefill。

  1. 模型处理每个历史 token 时,会在每一层算出 K(Key,键)和 V(Value,值)。Decode 下一个 token 时,新 Q 仍要和所有历史 K/V 做 Attention,所以把这些结果留在 KV Cache 中,就不用每一步从头再算;它保存的是模型内部张量,不是聊天文本本身。

  2. Prefix Cache 像给 KV 块做“内容指纹”。系统把 token 序列切成固定大小的块;vLLM 的块 hash 由前序 hash、当前完整块的 token ID 列表(`block_token_ids`)和 LoRA、多模态输入、`cache_salt` 等额外字段组成。模型 revision、tokenizer、位置编码与 KV 格式等兼容性还要由独立缓存命名空间隔离,不能跨模型误复用。新请求从头查找最长连续命中,命中的物理 KV 块只增加引用计数,之后只对未命中的后缀做 Prefill。按 vLLM v0.21.0 文档,v0.11 起默认使用 SHA-256;更快的 xxhash 变体是非加密哈希,只有明确接受碰撞与安全风险时才适用。Radix tree(基数树)也能用公共树枝表达共享前缀。

  3. 共享块必须只读:vLLM 只缓存完整块,两个会话在公共前缀后分叉时会各自申请新尾部,不会修改已经命中的完整块;只有实现还共享了可写的半满尾块时,才需要 copy-on-write(写时复制)。请求结束后,块可继续留作缓存;空间不足时再按最近使用、复用概率或重算成本驱逐。多租户还要把租户权限、LoRA、多模态输入等放入键,不能只看文字相同;salt/hash 也不能替代真正的鉴权。

  4. 复用成立有严格条件:token ID、模型权重、位置编码状态、LoRA、输入模态和 cache 精度等都要一致。它只省掉命中前缀的 Prefill 计算;后续 Decode 做 Attention 时仍然要读取那段历史 KV。因此第一次请求、没有公共开头,或瓶颈完全在长 Decode 时,Prefix Cache 帮助有限。

记住一句 KV Cache 是“当前序列继续生成必须用的历史状态”,Prefix Cache 是“跨请求找到并共享相同历史状态”的机制,而且主要节省 Prefill。

30″ 面试开口
KV Cache 是模型执行 attention 所必需的运行状态:保存当前序列历史 token 在每一层的 K/V(或 MLA latent)。Prefix Cache 是建立在 KV Cache 之上的跨请求复用与保留机制:识别相同 token prefix,让新请求直接引用已经算过的 KV blocks,从而跳过这段 prefix 的 prefill。…
KV Cache 是一次序列的运行状态;Prefix Cache 是跨请求识别相同前缀并复用这些 KV blocks 的索引与生命周期机制。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

KV Cache 是模型执行 attention 所必需的运行状态:保存当前序列历史 token 在每一层的 K/V(或 MLA latent)。Prefix Cache 是建立在 KV Cache 之上的跨请求复用与保留机制:识别相同 token prefix,让新请求直接引用已经算过的 KV blocks,从而跳过这段 prefix 的 prefill。

所以 Prefix Cache 不是另一种张量,也不能代替 KV Cache。它主要节省重复 prefix 的 prefill 计算;第一次请求、没有共享前缀、或只关心 decode 吞吐时,收益很小。

区别

维度KV CachePrefix Cache
服务对象当前活跃 sequence 的历史 attention多请求/多轮之间相同 prefix 的复用
生命周期通常从请求开始增长到请求结束请求结束后仍可作为可回收缓存保留
keyrequest + logical positionprefix 内容的 hash/radix path + 模型相关元数据
必要性自回归高效 decode 的基础可选的服务优化
主要收益避免每步重算历史 K/V避免重复 prefill;共享物理 KV block

常规 MHA/GQA 的 KV 容量可估算为:

BytesKV=2×L×S×nkv×dh×bBytes_{KV}=2\times L\times S\times n_{kv}\times d_h\times b

其中最前面的 2 表示 K 和 V。MLA 则缓存低维 latent 与 RoPE key,容量近似:

BytesMLA=L×S×(dc+drope)×bBytes_{MLA}=L\times S\times(d_c+d_{rope})\times b

哈希块式实现

以 vLLM 的 Automatic Prefix Caching 为例:

  1. 将 KV cache 划成固定 token 数的物理 blocks,sequence 的 block table 将逻辑块映射到物理块。

  2. 只对完整块建立缓存键。块键通常为:

    hash(parent_hash, block_token_ids, extra_hashes)
    
    extra_hashes 可包含:LoRA_ID、multimodal_hash、cache_salt 等

    parent_hash 使当前块的键包含此前全部 prefix 的语义,而不用把整段 token 反复放进 key。这里要区分“哈希字段”和“缓存命名空间”:vLLM 文档列出的块哈希组件是 parent hash、当前块 token 与上述 extra hashes;模型权重 revision、tokenizer、位置编码配置、KV layout/dtype 等兼容性条件仍必须由独立 engine/cache namespace 或等价隔离保证,不能因为它们没有逐项塞进块 hash 就跨模型共享。

  3. 新请求从左向右计算块 hash,在全局 hash table 中寻找最长连续命中;命中的物理 blocks 增加引用计数并直接挂到新请求的 block table。

  4. 从第一个未命中的 token 开始做增量 prefill,新写入的完整 block 再进入缓存索引。

  5. 多个 sequence 只能共享不可变的缓存块。vLLM 只为完整块建立 Prefix Cache,追加或分叉时通常直接为各自的新尾部申请块,不会回写已命中的完整块;只有某个实现额外共享了仍可写的 partial tail,才需要 copy-on-write。

  6. block 的活跃引用计数降到 0 后才可驱逐。常见策略为 LRU;更复杂系统会加入频率、长度、重算成本和传输成本。

多租户场景不能只追求命中率。按 vLLM v0.21.0 文档口径,v0.11 起 sha256 是 Prefix Cache 默认哈希算法;若还要求跨 Python/vLLM 版本、跨语言复现同一个键,可选用规范序列化的 sha256_cbor。该版本文档也列出速度更快的 xxhash / xxhash_cbor,但它们是非加密哈希;只有明确接受碰撞与安全风险时才应选用,不能把它们当成与 SHA-256 等价的安全配置。请求的 cache_salt 会进入首块 hash,把复用限制在同一信任域,降低跨租户命中和基于延迟推断缓存内容的风险。salt/hash 是缓存隔离的一层,不能替代租户鉴权与内存访问控制。

Radix tree 实现

另一条路线是 SGLang 的 RadixAttention:将 token 序列放进 radix tree,树边代表 token span,公共祖先天然对应共享 prefix。它特别适合 Agent、树搜索、few-shot、分支推理等存在嵌套/分叉 prefix 的工作负载。哈希块表更接近页缓存,radix tree 更直接表达层次关系;两者都需要引用计数、块分配和驱逐策略。

常见追问

  • 命中 100K prefix 后完全不用读它了吗? 只是不再重算它的 K/V;后续 decode attention 仍需访问这些历史 KV,除非模型本身使用稀疏/滑窗 attention。
  • 为什么只缓存 full block? 可简化不可变共享、hash 和内存管理;最后一个 partial block 通常继续由当前请求持有。
  • Prefix Cache 会改变结果吗? token、模型权重、位置编码状态、LoRA、多模态输入和 cache dtype 都一致时,复用是等价计算;键缺少这些维度就可能错误命中。

Chunk Prefill 在 MLA 中,KV load 之后做 proj,如果 KV 很大导致激活值很大怎么办?

推理系统KV CacheChunk Prefill上下文并行
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

MLA(Multi-head Latent Attention,多头潜变量注意力)把长期保存的 KV 压成较短的 latent,本来是为了省显存;但若计算时一次把整段历史全部还原成多头 K/V,临时激活仍会突然膨胀。这题在问怎样控制这个瞬时峰值,而不是怎样继续压缩持久 KV Cache。

  1. 可以把 latent 想成压缩包:仓库里存着它确实省空间,但一次把 128K token 的所有包都解压,桌面仍会爆满。以 DeepSeek 一类配置为例,同一层里 128K token 的完整展开临时量可到约 8 GiB,而这一层的压缩 cache 可能只有约 144 MiB。模型通常逐层执行,所以这里比较的是“当前层临时 workspace”与“同层持久 KV”,不能再把临时峰值额外乘上层数。

  2. 核心做法是沿历史 KV 的序列方向继续切段,而不只是缩小当前 query chunk。每次只加载一段压缩 KV,完成反量化、上投影、与 Q 的 Attention,再立即丢弃这段展开 K/V,然后处理下一段。可用 workspace 大小时,据每 token 展开量反推每段最多装多少 token,并为 Q、通信和双缓冲留余量。

  3. 不同段不能各自 Softmax 后简单平均。正确做法是像 FlashAttention 一样维护全局最大值、指数和与加权输出;每处理一段就用 online softmax 重新缩放并合并,所以数学结果不变,只存在正常的浮点舍入差异。若把“加载 → 投影 → Attention”融合在同一个 kernel,展开数据还能只活在 SRAM/寄存器里,不落回 HBM。

  4. 显存仍紧时,可以按层 gather、用完即释放,或让 KV 分片沿多卡环形流动;某些 MLA 实现还可通过矩阵吸收直接在 latent 上计算。不同方案会增加计算或通信,未必总更快;尤其 Prefill 的 Q 很多时,标准展开后的高效 FlashAttention 路径可能仍占优,需要按 `query 长度 × 历史长度` 实测分派。

记住一句 MLA 省的是长期 KV,临时展开仍可能爆;解决关键是“沿历史 KV 分段展开、当场计算、在线合并、马上释放”。

30″ 面试开口
不要把整段压缩 KV 一次性投影成完整多头 K/V 并落到 HBM。核心方案是:按 KV sequence 再分段,load → dequant/proj → attention → online-softmax merge,立刻释放该段的展开激活。…
长 prefill 不一次物化所有投影激活,而是把 load、projection、attention 做成受显存预算约束的 chunk 流水。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

不要把整段压缩 KV 一次性投影成完整多头 K/V 并落到 HBM。核心方案是:按 KV sequence 再分段,load → dequant/proj → attention → online-softmax merge,立刻释放该段的展开激活。进一步可融合 load/projection/attention,让展开后的 K/V tile 只存在于 SRAM/寄存器;显存仍不够时改用 MLA 的 latent/MQA 计算路径或 Ring Attention。

截至 2026-07-15,vLLM Ascend v0.18.0 的官方 Context Parallel 设计对 MLA chunked prefill 正是这样处理:AllGatherKV 对超长 context 会产生明显峰值显存,因此预设每轮最大 KV 量,分段完成 attention,并用 online softmax 顺序合并。这里描述的是该版本后端的实现边界,不代表其他 runtime 自动采用相同路径。

为什么 MLA 反而会冒出大激活

MLA 持久化的不是每头 K/V,而是低秩 latent:

ctKV=WDKVhtc_t^{KV}=W^{DKV}h_t

再由 up projection 恢复各头的 no-PE K 和 V。以 DeepSeek-V3 的公开配置为例:

  • n_heads=128
  • kv_lora_rank=512
  • qk_nope_head_dim=128
  • qk_rope_head_dim=64
  • v_head_dim=128

BF16 持久化 latent+RoPE key 每 token 每层约:

(512+64)×2=1,152 Bytes(512+64)\times2=1,152\ \text{Bytes}

但若一次性展开 no-PE K 和 V:

128×(128+128)×2=65,536 Bytes/token/layer128\times(128+128)\times2=65,536\ \text{Bytes/token/layer}

仅 128K token 的展开临时量就约 8 GiB/层(这是未考虑 TP 分头前的量级示例),而压缩 cache 约 144 MiB/层。MLA 节省的是持久 KV cache;错误的执行计划仍可能制造巨大临时 activation。

分段 projection + online softmax

假设 query chunk 长度为 Tq,历史 KV 长度为 S。把 S 切成 S_tile

for kv_segment in history:
    load compressed KV segment
    dequant + up-project this segment only
    compute Q × K_segmentᵀ and P × V_segment
    merge (m, l, O) by online softmax
    discard expanded K/V segment

令可用于展开激活的 workspace 为 W,本 rank 负责 h_local 个 head,则粗略上界:

StileWbhlocal(dqk,nope+dv)+other workspace per tokenS_{tile}\le \frac{W}{b\,h_{local}(d_{qk,nope}+d_v)+\text{other workspace per token}}

实际还要给 Q、score tile、输出、通信 buffer、量化 scale 和双缓冲留空间,因此不能把 W 全吃满。

可叠加的工程优化

  1. Kernel fusion:把 paged KV load、反量化、Wkv_b projection、RoPE 拼接、attention 融合,避免展开 K/V 写 HBM。
  2. 双缓冲与流水:segment j 计算时预取 j+1;通信、projection、attention 用不同 stream/event 重叠。
  3. 按层 gather、用完即丢:不要 all-gather 全模型所有层的 KV。官方 CP 设计在普通 prefill 中只 gather 当前层,计算后立即释放。
  4. MLA latent/MQA 路径(矩阵吸收):将 K/V up-projection 的权重吸收到 Q 与 output projection,使 attention 直接在 latent cache 上做,避免展开多头 K/V。代价是 latent 维可能大于单头维,计算量和 kernel shape 可能不如 prefill 的 MHA 路径,需按 Tq、S 选择。
  5. Ring Attention:每个 rank 只保留一段 KV,边传边算并在线合并,峰值更低;代价是 p 个通信阶段、实现和拓扑调优更复杂。
  6. 降低 cache/通信精度:FlashMLA 当前官方支持矩阵明确给出的是 token-level sparse decoding 使用 FP8 KV Cache、以 BF16 做矩阵计算;SM90 dense decoding 路径仍列为 BF16。其每 token FP8 KV 不是简单的 512+64 个低精度元素,而是 656 Bytes:512 个 E4M3 NoPE 值占 512 Bytes,4 个 FP32 分组 scale 占 16 Bytes,64 个未量化 BF16 RoPE 值占 128 Bytes。不能把这一能力泛化成所有 dense/prefill MLA kernel 都支持 FP8,且上线前必须做长上下文精度回归。

常见追问

  • 减小 query chunk 是否能解决? 只能减小 Q/score workspace,无法消除一次性展开整个历史 KV 的 O(S) 峰值;关键还要沿 KV 维分段。
  • 分段会改变 softmax 吗? 使用 (m,l,O) 的精确 online merge 不改变数学结果,只有正常浮点舍入差异。
  • 为什么 prefill 有时仍选择展开后的 MHA 路径? prefill 的 Q 很多,常是 compute-bound;标准 head_dim 的 Tensor Core/FA kernel 可能比 latent 大维度路径更高效。应做运行时分派,而不是一种实现包打天下。

针对 Agent 场景,KVCache 空间不够,有没有什么优化?

推理系统Prefix CacheKV Cache上下文并行
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

Agent 常带着长系统提示、工具说明、多轮对话和分支计划,KV Cache 会同时遇到“同一内容存了很多份”和“单个活跃会话本身太长”两类压力。这题需要按无损到有损的顺序解决:先去重复、再提高显存利用率、再把冷数据分层搬走,最后才考虑删减上下文。

  1. 第一步是去掉重复副本。公共 system prompt、工具 schema 和代码快照用 Prefix Cache 共享;多轮会话只给新 token 追加 KV;Agent 做树搜索或多方案分支时,共享只读祖先块并给各分支新建尾块,只有继续共享半满可写尾块的实现才需要写时复制。PagedAttention(分页注意力)按小块随用随分配,也能避免每个请求一开始就为最大长度预留一整段连续显存。

  2. 第二步是让每字节装更多有效 KV。若模型和推理 kernel 原生支持,可把 KV 从 BF16/FP16 降为 FP8 或 INT8,并对长上下文做精度回归;GQA、MQA、MLA 也会减少 KV 头数或维度。这里有重要边界:低精度必须有硬件与算子支持才真能提速,GQA/MLA 更是模型架构能力,不能给任意 MHA 模型临时打开一个开关。

  3. 第三步是分层存储。正在生成的热块留在 GPU HBM,等待工具调用或暂时休眠的会话异步搬到 CPU 内存、NVMe 或远端 KV 池,恢复前再预取;也可把超长序列沿多卡做 Context Parallel 分片。驱逐不能只看最近是否使用,还要看会话何时恢复、复用概率、重新计算和重新传输哪个更便宜。

  4. 如果以上仍不够,才考虑摘要旧对话、只留工具结果的结构化信息、滑动窗口或稀疏 Attention。这些方法会改变模型实际看到的上下文,属于产品或模型层面的有损取舍,必须验证任务效果。Prefix Cache 也不是万能药:它能省重复 Prefill 和物理副本,但活跃长会话每生成一步仍要读取有效历史 KV。

记住一句 Agent KV 优化的优先级是“共享去重 → 分页与低精度/模型能力 → 冷热分层 → 有损摘要”,且每一步都要确认算子和架构真的支持。

30″ 面试开口
Agent 的典型特征是 system prompt/工具定义长、多轮 append、同一上下文分叉、会话暂停后再恢复。最有效的顺序通常是:先消除重复副本,再提高 GPU KV 利用率,再分层存储,最后才做有损压缩。…
Agent 长会话的容量问题通常要组合解决:共享前缀、分页、分层 offload、量化/压缩和可选择的重计算。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

Agent 的典型特征是 system prompt/工具定义长、多轮 append、同一上下文分叉、会话暂停后再恢复。最有效的顺序通常是:先消除重复副本,再提高 GPU KV 利用率,再分层存储,最后才做有损压缩。对应措施是 prefix/session cache、paged blocks + 分支尾块管理、MLA/GQA/低精度 KV、CP 分片,以及 GPU→CPU DRAM→SSD/远端 KV pool 的分层 offload。

1)先把“重复”去掉

  • system prompt、tool schema、代码仓库快照等稳定前缀用 Prefix Cache;不要让每个 Agent session 各存一份。
  • 多轮会话只对新增 user/tool tokens 做增量 prefill,保留 append-only session KV。
  • Agent 做 beam/tree search 或并行 tool plan 时,只读的公共祖先 blocks 可以共享;分支通常新建尾块,只有继续共享半满可写尾块的实现才需要 copy-on-write。PagedAttention 与 radix tree 都适合表达这种共享关系。
  • 热 prefix 跨实例复制,冷 prefix 只保留一个远端副本;调度器同时考虑 cache locality 和实例负载。

2)提高 GPU 内存有效容量

  • block/page 分配,减少预留和外部碎片;按实际 token 增长分配,而不是按 max length 连续预留。
  • KV cache 量化为 FP8/INT8;节省接近 2 倍时,要验证长上下文累积误差、scale 粒度和 kernel 是否真的支持低精度直接计算。
  • 使用 GQA/MQA/MLA 架构。它们是模型结构能力,不能在不重新训练/转换验证的情况下对任意 MHA 模型“打开开关”。
  • 用 Decode Context Parallel 把 sequence 维 KV 分片到多个设备,消除 TP 域内 KV 副本;代价是每层 attention 多了通信。

3)做分层 KV 存储

建立 GPU HBM、CPU DRAM、本机 NVMe、远端 DRAM/SSD 的多级 cache:

  • 活跃会话的最新/最可能访问 blocks 留在 GPU;
  • 暂停会话或可复用 prefix 异步 offload 到 CPU/远端;
  • 恢复前预取,按层或按 block 与计算重叠;
  • 驱逐不只看 LRU,还看复用概率、prefix 长度、重算 FLOP、传输时间和租户优先级。

Mooncake 的 KVCache-centric 架构会利用 GPU 集群中闲置的 CPU、DRAM、SSD,并由全局调度器在 cache reuse、负载和 SLO 之间权衡。这比“GPU OOM 后临时 swap”更适合 Agent 的长生命周期状态。

4)降低必须保留的上下文

以下方法可能改变模型语义,应作为产品/模型策略而非纯 Infra 等价优化:

  • 对旧对话做摘要,把可恢复事实写入外部 memory/RAG,再截断原 token;
  • 只保留工具结果的结构化摘要,不重复保存大段网页/日志;
  • 模型原生支持时使用 sliding-window、local/global 或 sparse attention;
  • 基于重要性丢弃 KV 的方法需要任务级精度回归,不能宣称无损。

一个实用容量决策

驱逐分数必须使用一致量纲。不能直接拿“占用字节 × HBM 价格”和“复用概率 × 时间”相除;前者近似容量/货币成本,后者是期望延迟。若优化目标是延迟或 SLO,可对每个 block 在一个调度窗口内估算:

penaltyevictPreuse×max(0,min(Treload,Trecompute)Thit)penalty_{evict} \approx P_{reuse}\times \max\left(0,\min(T_{reload},T_{recompute})-T_{hit}\right)

再按每字节保留价值排序:

valueper byte=penaltyevictBytesKVvalue_{per\ byte}=\frac{penalty_{evict}}{Bytes_{KV}}

空间不足时优先驱逐 value_per_byte 小的 blocks。若目标是货币成本,则应把延迟/SLO 违约也换算成同一货币目标,并把预计驻留时间计入 HBM 成本。Agent 场景通常使长 system/tool prefix 的 P_reuse 很高,因此它们虽大,却未必应最先驱逐。

常见追问

  • offload 一定增加吞吐吗? 不一定。若每个 decode step 都同步从 CPU 拉历史 KV,PCIe 会成为灾难;offload 更适合暂停会话/可复用 prefix,或能按层预取并覆盖传输的实现。
  • 只开 Prefix Cache 能解决活跃长会话的 decode 读带宽吗? 不能。它去重和省 prefill,但每步 attention 仍需访问有效历史 KV。
  • Agent 最适合 LRU 吗? 未必。会话可能长时间等待工具后再次命中,普通 LRU 会误删;应加入 session 状态、工作流 DAG 和预计恢复时间。

PD 分离一定好吗?比如在 Agent 场景下 prefix cache 很长?

推理系统RooflinePrefix CachePD 分离
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

PD 分离是把 Prefill(一次读完输入)和 Decode(逐 token 生成)交给不同机器池。这题不是问它先进不先进,而是问:分开后减少的计算干扰,能否覆盖新增的排队、KV 传输和缓存失效成本;Agent 的长前缀恰好让这个取舍更明显。

  1. Prefill 常用大矩阵乘,偏计算密集,关心 TTFT(Time To First Token,首 token 延迟);Decode 每步反复读权重与 KV,偏带宽密集,关心 TPOT(Time Per Output Token,每个输出 token 延迟)。混跑时,一个超长 Prefill 可能堵住 Decode 迭代;分池后,两边可用不同并行度、batch 和扩缩容策略,延迟也更可控。

  2. 代价是请求要经历 P 队列、计算、KV 传输和 D 队列,而且两边通常都要放模型权重。若一个 Agent 的 100K token 前缀已经驻留在某台 D 机器上,本轮只新增几十个 token,那么真正要算的是很短的未命中后缀;把历史搬到 P、算完再把新 KV 搬回 D,可能比直接在这台 D 上做短 Prefill 更慢。

  3. 因此判断时要看“命中后还剩多少未计算 token”和“历史 KV 现在在哪里”,不能只看原始 prompt 总长度。长输入经常不命中缓存、Prefill 突发会伤害 TPOT、P/D 间有高速互联时更倾向分离;会话前缀命中高、每轮追加短、跨机网络慢、流量低到分池后难以组 batch 时,更倾向同机或混合池。

  4. 工程上通常做 cache-aware 路由:长 miss 送 P,D 上命中长 session 且后缀短则原地增量计算;只有预计节省的排队/执行时间大于 KV 迁移和冷启动成本才迁移。还可以粘性会话、热前缀复制、按层边算边传,并保留一部分 mixed worker,应对 P/D 流量比例变化。

记住一句 PD 分离的收益来自隔离 Prefill 与 Decode,边界则是队列和 KV 搬运;Agent 场景要按“未命中后缀 + KV 所在位置”做决定。

30″ 面试开口
PD(Prefill/Decode)分离不一定好。它在 prefill 计算密集、decode 带宽密集、两者互相干扰且 TTFT/TPOT SLO 严格时很有价值;代价是两套权重/资源池、额外排队、KV 传输和 cache locality 破坏。…
PD 分离不是固定答案;命中位置、KV 迁移成本、P/D 排队和上下文长度共同决定一次请求应走哪条路径。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

PD(Prefill/Decode)分离不一定好。它在 prefill 计算密集、decode 带宽密集、两者互相干扰且 TTFT/TPOT SLO 严格时很有价值;代价是两套权重/资源池、额外排队、KV 传输和 cache locality 破坏。Agent 的长 prefix 高命中场景中,真正要算的是“未命中 suffix 有多长”和“prefix KV 在哪里”,而不是原始 prompt 有多长。

如果 100K prefix 已驻留在某个 D 实例,而新一轮只有几十/几百 token,让请求继续在该实例做短增量 prefill,常常比把长 prefix 搬给 P、再把新 KV 搬回 D 更好。

PD 分离为什么有效

  • prefill 是大 GEMM/长 Q attention,偏 compute-bound,目标是 TTFT;
  • decode 每步 token 少、反复读权重和 KV,偏 memory-bound,目标是 TPOT;
  • colocate 时,一个长 prefill 会阻塞 decode iteration,造成 TPOT 抖动;
  • 分离后 P、D 可使用不同并行度、batch 和扩缩容策略。DistServe 的核心就是消除 P/D 干扰并按各自 SLO 配资源。

为什么长 prefix cache 可能让 PD 变差

设命中的 prefix 为 L_hit,真正要 prefill 的 suffix 为 L_miss。分离路径近似:

Tsep=Tqueue,P+Tload prefix to P+Tprefill(Lmiss)+Tsend new/full KV to D+Tqueue,DT_{sep}=T_{queue,P}+T_{load\ prefix\ to\ P}+T_{prefill}(L_{miss}) +T_{send\ new/full\ KV\ to\ D}+T_{queue,D}

cache-resident colocated 路径近似:

Tcoloc=Tqueue,D+Tprefill,D(Lmiss)T_{coloc}=T_{queue,D}+T_{prefill,D}(L_{miss})

L_miss 很小、L_hit 很大时,P 上的计算收益很小,加载/传输长 prefix 的成本却很大。更糟糕的是会话每轮都在 D→P→D 之间来回搬状态。

2026 年的 PPD(Prefill / Prefill-capable Decode)研究专门针对多轮服务验证了这一问题:固定把后续轮次都送到独立 P 节点,会重复制造增量 prefill 与 KV 往返;让一部分 D 节点按负载处理 append-prefill 可降低后续轮 TTFT。这里引用它作为“混合路由值得考虑”的实证,不把论文特定集群上的收益数字外推到所有模型和网络。

工程判断与架构选择

  1. cache-aware hybrid routing
    • cache miss 且 prompt 长:发往 P;
    • D 上命中长 session prefix 且 suffix 短:在 D/mixed worker 增量 prefill;
    • 命中远端共享 cache:比较 reload、recompute 和队列时间再选。
  2. 粘性会话 + 负载兜底:优先回到持有 session KV 的 D;当排队收益大于迁移代价才迁移。
  3. 全局 KV pool:P、D 不以某张卡为唯一所有者,按 block/层异步装载。Mooncake 就是 KV-centric 调度;但网络和存储仍是实际成本,不是“共享后免费”。
  4. 热 prefix 复制:减少所有请求集中到一台 cache-rich worker 的热点;复制数量由命中收益与显存代价决定。
  5. layer-wise transfer/overlap:P 算第 l 层时传第 l 层新 KV,避免等完整 prefill 后一次性传输。
  6. 保留 mixed 池:流量/命中分布变化大时,纯 P/D 固定切分会资源失衡;一部分 worker 可在 P、D 之间切换或处理短 cached prefill。

什么时候更倾向 PD 分离

  • 长 prompt cache miss 多;
  • prefill arrival burst 会明显破坏 TPOT;
  • 网络带宽高、P/D 拓扑亲和,KV 传输可覆盖;
  • TTFT 与 TPOT SLO 都严格,且 P/D 资源需求稳定可预测。

什么时候更倾向 colocate/hybrid

  • 多轮 Agent 的 session prefix 命中高、每轮 suffix 很短;
  • KV 只能驻留在 D,P 读取它需要跨慢网;
  • 低 QPS 下分成两个队列降低 batching;
  • P/D 流量比例变化大,固定资源池经常一边空闲一边排队。

常见追问

  • PD 分离只需传新 token 的 KV 吗? 如果 P 已能访问历史 prefix KV,可以只回传新增 KV;如果历史 KV 只在 D,本轮增量 prefill 的 attention 仍需要它,必须加载/传输或改在 D 上算。
  • 如何判定迁移? 估算 剩余服务时间收益 > KV迁移时间 + 目标队列等待 + cache冷启动,并设滞回阈值,避免请求反复抖动。
  • 论文说 KV 传输很小,是否可忽略? 只对论文的模型、拓扑和 placement 成立。DistServe 通过节点亲和让很多传输走 NVLink;跨节点 Agent 长 prefix 需要重新测。

CP、AG KV 和 AG Q 方案怎么选择?Agent 场景还能 AG KV 吗?

推理系统KV Cache上下文并行数学基础
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

CP(Context Parallel,上下文并行)把一条长序列的 KV 分散在多张 GPU 上,解决单卡放不下或读不动的问题;但当前 Q 必须看到完整历史,所以设备之间总要交换某些数据。这题把聚合 KV、聚合 Q、让 KV 沿环传递作为三种算法候选比较,不表示任一 runtime 当前都实现了三条路径。

  1. AG-KV(AllGather KV)让每卡暂时收齐完整历史 KV,再用成熟的普通 FlashAttention 计算本地 Q。它实现直接、无需再合并局部输出,但每卡接收量大约随历史长度 S 增长,而且会短暂物化完整 KV;长前缀时,通信量和显存峰值都可能最先出问题。

  2. AG-Q(AllGather Q)反过来:每卡保留自己的 KV 分片,只收齐当前 Q,对本地历史算出 partial output(局部输出)以及 Softmax 的最大值和指数和,再跨卡精确合并。若按 query token 均匀分片,可用本地 `Tq/p` 估算;但 vLLM Ascend 的 DCP 与 TP 复用通信域时,GQA 实际沿 Q head 维聚合,必须按真实张量布局算 bytes。

  3. Ring Attention 不在任何卡上拼完整 KV,而是让各分片沿设备环逐站流动;每到一段就计算并用 online softmax 累积。它的峰值显存最低,还可能把通信藏在计算后面,但要经历多轮传递与同步。截至 2026-07-15,vLLM Ascend v0.18.0 文档明确的 chunked-prefill 实现是 GQA 走 AG-Q、MLA 走 AG-KV;Ring 被列为可行/备选算法,不应表述为该版本已实现的同级生产路径。

  4. 选择时同时看通信字节、partial output 大小、峰值显存、集合通信延迟、设备拓扑和当前 runtime 的支持矩阵。经验上 `历史 KV 远大于当前 Q` 时算法上偏 AG-Q/Ring;Q 较大、KV 很小或被 MLA 高度压缩、同机 NVLink 很快时,AG-KV 仍可能更好。即使完整 KV 放不下,也可按段 AG-KV、算完释放,因此 Agent 场景并非绝对不能 AG-KV,只是必须把长前缀和往返通信算进去。

记住一句 CP 的本质是选择搬哪边:长历史、短查询通常搬 Q;压缩 KV、较大查询和高速互联时仍可搬 KV,放不下就分段,或在 runtime 支持时走 Ring。

30″ 面试开口
这里先统一术语:CP(Context Parallel)是把 sequence/KV cache 沿上下文维分片到 p 个设备;为了让每个 Q 看见完整历史,可以交换 Q、聚合 KV,或让 KV 沿环流动。…
AG-Q、AG-KV 与 Ring 是三种算法候选;选择不只看通信字节,还要看本地 Q/KV 布局、显存峰值、通信计算重叠,以及当前 runtime 版本是否真正支持。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

这里先统一术语:CP(Context Parallel)是把 sequence/KV cache 沿上下文维分片到 p 个设备;为了让每个 Q 看见完整历史,可以交换 Q、聚合 KV,或让 KV 沿环流动。下文把 AllGather KV(AG-KV)、AllGather Q(AG-Q)和 Ring Attention 作为三种算法候选比较,不表示任一 runtime、任一版本都同时实现了三条路径。

截至 2026-07-15,vLLM Ascend v0.18.0 文档明确写出:GQA chunked prefill 已实现 AG-Q,MLA chunked prefill 已实现 AG-KV;Ring-Attn 被列为可行/备选算法,但文档没有把它列入这两条已实现路径。回答时应把“算法上可选”与“当前后端已支持”分开。

选择的第一原则是比较本层通信量、峰值显存和可重叠性

  • S 很长、当前 query chunk Tq 很小,尤其 decode/Agent 长 prefix:算法上通常偏 AG-Q 或 Ring,前提是当前后端支持;
  • Tq 较大、KV 很小或被 MLA 高度压缩、同节点高速互联:AG-KV 可能更简单、更快;
  • AG-KV 的完整 KV 激活放不下:分段 AG-KV + online softmax,或 Ring;
  • 不能只看 bytes,还要看 collective latency、额外 reduction、kernel 成熟度以及 CP 是否与 TP 复用同一通信域。

Agent 场景当然还能 AG-KV;只是长 prefix 使它更容易不划算。MLA 压缩 KV、CP 较小、NVLink/HCCL 高带宽、分段后峰值可控,或 query chunk 本身也很大时,AG-KV 仍可能胜出。

三种方案的数据流

设历史 KV 总长度为 S,当前 query 的总 token 数为 Tq,CP 大小为 p,每 token 的 KV/Q/O 元素数分别为 dKV、dQ、dO,元素字节数为 b。为便于比较,下面先采用 sequence-sharded prefill 抽象:每个 rank 初始持有约 S/p 的 KV 与 Tq/p 的 Q。真实实现必须按 collective 前的本地张量字节数重算;例如 vLLM Ascend 的 DCP 与 TP 复用通信域时,GQA 的 Q 是沿 head 维 all-gather,并非这里的 token 维示意。

方案 A:AG-KV

每个 rank 保留本地 Q shard 和 1/p 的 KV,先 all-gather 得到完整 KV,再运行常规 prefill attention:

local Q shard + local KV shard
          ↓ AllGather KV
local Q shard + full KV
          ↓ FlashAttention
local O

若 KV 均匀分片,每 rank 一层大约接收:

VAGKV,recvp1pSdKVbV_{AGKV,recv}\approx\frac{p-1}{p}\,S\,d_{KV}\,b

优点是计算路径接近成熟的标准 FA,结果不需要跨 rank 合并;缺点是通信随长历史 S 增长,并且每个 rank 都会短暂物化 full KV。

方案 B:AG-Q

每个 rank 保留本地 KV shard,all-gather Q,让每个 rank 用完整 Q 对自己的局部 KV 做 partial attention,再用各 shard 的 max/LSE/output 做精确 softmax merge,最后按需要 reduce-scatter 输出:

local Q + local KV shard
   ↓ AllGather Q
full Q × local KV -> partial (m,l,O)
   ↓ merge / A2A / RS
final O shard

在上述均匀 sequence-sharded 抽象下,本地 Q 为 Tq/p × dQ,所以 Q gather 每 rank 约:

VAGQ,recvp1pTqdQbV_{AGQ,recv}\approx\frac{p-1}{p}\,T_q\,d_Q\,b

还要加 partial output 和 LSE 的合并通信,粗略为:

Vmerge=O(p1pTq(dOb+hq×4))V_{merge}=O\left(\frac{p-1}{p}T_q(d_O b+h_q\times4)\right)

S dKV >> Tq(dQ+dO) 时,AG-Q 通常更省通信和峰值显存。代价是多一步 partial-softmax merge,且 Q head/TP 布局可能需要额外变换。若 runtime 实际沿 head 维聚合 Q,应以本地 Q head shard 的真实 bytes 替换上式,不能机械套用 Tq/p

方案 C:Ring Attention

Q 留在本 rank,KV shard 沿 ring 逐跳流动;每收到一段就计算并在线更新 (m,l,O)

step 0: Q × local KV
step 1: Q × KV from left neighbor
...
step p-1: cover all KV shards

它不物化 full KV,可将通信与计算流水重叠,峰值内存最低;但有 p-1 个阶段,首包/同步延迟、负载平衡和故障处理更复杂。短 query 且每段计算太少时,通信未必能被覆盖。

vLLM Ascend v0.18.0 中 MLA 与 GQA 为什么选不同方案

该版本的官方实现中,GQA chunked prefill 使用 AG-Q,MLA 使用 AG-KV。原因不是“模型名称决定算法”,而是张量布局和通信量不同:

  • GQA 的 KV heads 少,而 TP/CP 下 Q heads 的布局使聚合 Q 后对本地 KV 做 partial attention 更自然;
  • MLA 持久 KV 是低维 latent,dKV 显著小于展开后的多头 K/V,因此 gather 压缩 KV 可能比想象中便宜,并可复用标准 prefill 路径;
  • 但 MLA 的 AG-KV 后若一次性 up-project 整段历史,仍会产生第 4 题的大 activation,所以官方实现对超长 KV 分段处理。

这是一条带版本号的实现案例,不是跨 runtime 的固定规则;Ring 仍可用于推导峰值显存和流水边界,但不能据此声称 vLLM Ascend v0.18.0 已把它作为上述 chunked-prefill 的现行生产路径。

Agent 长 prefix 的选择步骤

  1. 用本轮 Attention 实际可见的完整历史 STq 代入:S 仍包含命中的 prefix 与新增 suffix,Prefix Cache 命中只省掉重复 prefill,并不会让 decode/CP 少看那段历史。只有模型窗口截断、滑窗或稀疏 Attention 才会缩短这里的有效 S。decode 时 Tq≈batch,通常远小于 S
  2. 估算三种方案每层的 bytes、峰值 workspace 和 collective 次数。
  3. 判断链路:同机 NVLink 与跨机 IB/RoCE 的带宽、RTT 差异很大;AG-KV 若跨节点会更敏感。
  4. 若 AG-KV 只有峰值显存不满足,先尝试 segmented AG-KV,不必立即完全改 AG-Q。
  5. 以端到端 TPOT/TTFT 实测。AG-Q bytes 少,不代表 partial merge kernel 一定更快。

常见追问

  • CP 是否一定减少单请求延迟? 不一定。它减少每卡 KV 容量和局部 attention 工作量,但引入通信;更常见的直接收益是支持更长 context/更大 batch。
  • AG-KV 是否意味着永久复制完整 KV? 不必。可以只 gather 当前层、当前 segment,用完释放;持久 cache 仍保持分片。
  • AG-Q 的结果怎么合并? 每个 KV shard 返回局部 softmax 的 (m,l,O),按与 FlashAttention 相同的 log-sum-exp 规则精确合并。

Decode 如果输出长,怎么做负载均衡?怎么优化?

推理系统PD 分离MoE调度
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

Decode 输出很长时,请求会长期占着一个执行槽位和越来越多的 KV 显存,短请求早已结束,长请求却继续拖住整批,最终造成“有的实例忙死、有的实例空着”。这题重点是按剩余工作量持续调度,而不是简单按请求个数平均分配。

  1. 单个实例内要用 continuous batching(连续批处理):每生成一步就移走已结束请求,并立刻补入新请求,而不是让固定 batch 等最慢者。batch 限制也应看本轮 token 数、上下文长度和 KV 字节,而不只看有多少请求;长 Prefill 可切块穿插,让 Decode 保持稳定的 TPOT。

  2. 多实例调度时,一个拥有 10 个即将结束请求的实例,可能比只有 2 个超长请求的实例更空。调度器应滚动估算“预计剩余 token × 每步成本”,同时考虑已有上下文、KV 占用、cache locality 和队列时间;预测会错,所以每个迭代都要更新,并用 aging 或公平队列防止长任务永远排不到。

  3. 同一条自回归序列在时间上是串行的:第 t+1 个 token 必须等第 t 个 token 出来,不能让两台机器各自独立生成前后两半。能并行的是同一个 token 步骤里的模型/上下文分片,或者 speculative decoding(推测解码)先草拟多个 token、再由主模型批量验证,从而减少串行 forward 次数。

  4. 跨实例迁移活跃请求需要搬走与历史长度成正比的 KV,可能是数 GB;只有预计省下的剩余执行和排队时间,大于迁移、目标队列与冷启动成本时才值得做。扩容时先把新请求导向新实例、让旧实例自然排空往往更稳;还可用低精度 KV、上下文分片和 Flash-Decoding 降低长请求每一步的成本。

记住一句 长 Decode 要“每步重排、按剩余 token 与 KV 计负载”;同一序列不能拆开独立生成,迁移也必须先算搬 KV 是否回本。

30″ 面试开口
长输出的核心问题不是单次算子,而是请求占据 decode slot 和 KV memory 的时间很长,形成 straggler、batch 收缩和实例间负载漂移。应使用iteration-level continuous batching + 按预计剩余 token 工作量调度 + KV-aware 迁移/抢占;…
Decode 长尾来自剩余生成长度不可知;调度器要用 token budget、动态批处理与迁移/抢占控制尾延迟。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

长输出的核心问题不是单次算子,而是请求占据 decode slot 和 KV memory 的时间很长,形成 straggler、batch 收缩和实例间负载漂移。应使用iteration-level continuous batching + 按预计剩余 token 工作量调度 + KV-aware 迁移/抢占;同时用 speculative decoding、DCP/KV 分片和低精度 KV 降低单 token 成本。

不能把一条自回归序列的未来 token 直接横向切给多机独立计算,因为 token t+1 依赖 token t。可并行的是同一步的模型/上下文,或一次草拟并验证多个 token。

负载指标不能只看 request 数

一个实例有 10 个即将结束的请求,可能比另一个只有 2 个 100K-context 长推理更空。调度应估算:

Work(r)L^remain(r)×tstep(Lcontext(r),B,model)Work(r)\approx \hat L_{remain}(r)\times t_{step}(L_{context}(r),B,model)

其中 Lremain 很难准确预测,可用 max_tokens、任务类型、历史分布、当前 EOS hazard 形成区间估计;对无法预测的请求使用 aging/WFQ 保证公平。

单实例内

  1. Iteration-level continuous batching:每个 decode iteration 都移除完成请求并接纳新请求,避免整批等待最长序列。
  2. token-budget batching:限制本轮总 token/KV 工作量,而不是固定 request count;将相近 context length 的请求放一起可减少 kernel 尾部和 padding。
  3. chunked prefill 调度:给 decode 留固定 token budget,避免长 prefill 一次占满 iteration;严格 TPOT 下优先 decode。
  4. 抢占与换出:显存不足时对低优先级/长等待请求 swap 或 recompute。长 context 的重算非常贵,通常优先 offload KV;抢占太频繁说明 admission control 有问题。
  5. 公平与 SLO:对超长 generation 设置独立队列、权重或最大并发,防止短请求 head-of-line blocking,但不能让长请求永久饥饿。

多实例之间

  • 新请求发往预计 finish time 最小的 D 实例,而不是简单 round-robin。
  • 调度成本同时考虑 active tokens、KV bytes、context 分布、cache locality、网络和队列。
  • 迁移活跃请求时需搬 KV。只在:
Tsaved by rebalance>TKV migration+Tcold start+marginT_{saved\ by\ rebalance} > T_{KV\ migration}+T_{cold\ start}+margin

时迁移;否则让旧请求自然 drain 更便宜。

  • 对热点 session/prefix 适度复制,避免粘性调度把一台机器压爆。
  • 扩容时先把新请求导向新实例(drain-based balancing),比立即迁移所有长请求风险低。

降低长输出的单 token 成本

  1. Speculative decoding / MTP:draft 多个 token,由主模型一次验证,减少串行 forward 次数。DeepSeek-V3 原始技术报告在其自身评测中给出第二个 MTP token 接受率约 85%~90%、约 1.8× TPS;这不是跨模型或跨部署的通用倍率,实际收益随采样、任务、batch 与验证实现改变。
  2. Flash-Decoding / split-KV decode:小 batch 长 context 时沿 KV 维增加并行度。
  3. DCP/sequence-sharded KV:每卡只存/读部分历史,再合并 partial attention;以通信换容量与局部带宽。
  4. FP8/INT8 KV、MLA/GQA:减少每步必须读的 KV bytes。
  5. 模型支持时使用 sliding-window/sparse attention:把每步读取从全历史改为受限范围;这是模型语义/结构选择,不是任意模型的透明优化。
  6. admission control:根据 max_new_tokens 和 KV 配额提前拒绝/降级,避免系统先接收再在中途 OOM。

常见追问

  • 为什么迁移长请求很贵? 其 KV 大小与已生成/输入 context 成正比,可能需要跨网络搬数 GB;迁移收益却只来自剩余 token。
  • 能否把一个长 decode 请求拆到两台分别生成一半? 时间维不能;可用 TP/CP 并行同一个 token step,或 speculative decoding 并行候选验证。
  • 如何避免预测输出长度错误? 使用滚动估计与工作窃取;调度器必须在每个 iteration 更新,而不是只在 admission 时决定一次。

为什么要大 EP、大 DP?大 EP 开多大合适?固定单卡的 batch size 为 16,以 DeepSeek 为例,计算 EP 大小?

推理系统RooflineMoE
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

EP(Expert Parallel,专家并行)决定一组 GPU 怎样分摊 MoE 专家,DP(Data Parallel,数据并行)则复制多套服务去处理不同请求。题目既要解释为什么要把它们做大,也要指出“大”会引入什么成本,并在给定假设下推导 EP=32,而不是把 32 当成所有 DeepSeek 部署的固定答案。

  1. 大 EP 能把专家权重摊到更多卡上,并汇聚更多卡的 token,让每个 expert GEMM(专家矩阵乘)批量更大;代价是 token 要在更大的 All-to-All 域里发出和收回,跨节点延迟、拥塞和专家冷热不均都会放大。大 DP 则让更多副本并行接独立请求,主要提升总吞吐和排队能力,但会复制非专家权重、分散 KV 与前缀缓存,并不通常让单请求本身变快。

  2. 设 EP 大小为 P,每卡本步有 `B_local` 个原始 token,每个 token 选 k 个专家,共有 E 个 routed expert。整个 EP 组产生 `k×P×B_local` 份专家任务;若路由完全均匀,每个专家平均得到 `kPB_local/E` 个 token。给定希望达到的专家批量 `M_target`,可反解 `P = M_target×E/(k×B_local)`。

  3. 教学例取 DeepSeek-V3 的 E=256、TopK k=8、每卡 `B_local=16`,并额外假设希望每个专家平均也拿 16 个 token。代入得到 `P=16×256/(8×16)=32`:EP32 一共汇聚 512 个原始 token,产生 4096 份任务,平均分到 256 个专家,正好每个 16 份;同时每卡平均存 `256/32=8` 个原始专家。

  4. “单卡 batch=16”单独并不能唯一推出 EP=32,关键还缺 `M_target=16` 这个目标。若 Roofline 或实测认为专家要 64 行才高效,同一公式会给 EP=128;真实路由也不均匀,平均值掩盖热点专家。最终需先满足权重容量和拓扑,再用真实路由 trace 同时测 grouped GEMM、dispatch/combine 通信与 P99 不均衡。

  5. DeepSeek 官方材料本身也证明 EP 不是固定常数:2024 技术报告的 Decode 案例是 EP320,2025 年 2 月披露的 V3/R1 在线系统已是 routed-expert EP144。这是部署与负载演进,不是模型从 320 个专家改成了 144 个专家。

记住一句 EP 用更多卡换权重容量和专家批量,DP 用更多副本换请求吞吐;EP=32 来自明确的均匀路由与目标专家批量假设,不是常数。

30″ 面试开口
大 EP 的目的主要是分摊专家权重、汇聚更多 token 形成更大的 expert GEMM、扩大可部署模型规模;大 DP 的目的是复制服务实例扩大独立请求吞吐,并为 MoE 路由提供更大的 token pool。EP 不是越大越好:All-to-All 域、跨节点 RTT、负载不均、每卡专家数和通信 buffer 都会成为瓶颈。…
大 DP 聚合更多并发,大 EP 摊开更多专家;两者最终都要服务每个专家的有效 batch、显存容量与 A2A 拓扑。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

大 EP 的目的主要是分摊专家权重、汇聚更多 token 形成更大的 expert GEMM、扩大可部署模型规模;大 DP 的目的是复制服务实例扩大独立请求吞吐,并为 MoE 路由提供更大的 token pool。EP 不是越大越好:All-to-All 域、跨节点 RTT、负载不均、每卡专家数和通信 buffer 都会成为瓶颈。

对 DeepSeek-V3 的教学例子,若明确假设:

  • 256 个 routed experts;
  • TopK=8;
  • 每个 EP rank 本步有 16 个原始 token;
  • 路由完全均匀;
  • 希望每个 expert 平均收到 16 个 token;

EP=32。这与 DeepSeek-V3 官方 prefill 部署使用 EP32 一致,但后者是端到端 profiling 后的工程配置,不是上述四个数字唯一推导出的普适最优。

推导

设:

  • P 为 EP size;
  • B_local 为每 rank 进入 MoE 的 token 数;
  • E 为 expert 总数;
  • k 为 TopK;
  • M_target 为希望每个 expert 聚到的平均 token 数。

EP group 总原始 token 为 PB_local,总 assignment 为 kPB_local,因此:

Mexpert,avg=kPBlocalEM_{expert,avg}=\frac{kPB_{local}}{E}

反解:

P=MtargetEkBlocalP=\frac{M_{target}E}{kB_{local}}

代入 Mtarget=16、E=256、k=8、Blocal=16

P=16×2568×16=32P=\frac{16\times256}{8\times16}=32

也可直接看:EP32 共 32×16=512 个 token,产生 512×8=4096 个 assignments,平均分到 256 experts,正好每 expert 16 个。

必须指出的隐含假设

题目只说“单卡 batch size=16”仍然信息不足,除非同时给出或默认 Mtarget=16。若第 1 题 Roofline/实测得出的最佳 expert batch 是 Mtarget=64,同样公式会给出 EP=128;若目标约 333,则需要 EP≈666,已经超过 expert 数并需大量 expert replicas,通常不现实。

此外,线上路由不是完全均匀。若某些 expert 热,平均 16 不代表最热 expert 只处理 16;需要真实 trace、capacity factor 或 redundant expert。

为什么大 EP

  • 每卡仅存 E/P 个原始 experts,降低 expert weight 显存;
  • EP group 汇总 P 个 rank 的 token,提升每 expert grouped-GEMM 的 M;
  • 可以将热门 expert 做冗余副本并动态路由;
  • 代价是 dispatch/combine All-to-All 扩大,跨节点小包延迟与拥塞上升,任一热点 rank 会拖慢整个 collective。

为什么大 DP

  • attention/dense 部分独立处理不同请求,近线性扩大 aggregate throughput;
  • 不增加单请求模型并行通信;
  • 配合 DP-attention + EP-MoE,可让 attention 保持小 TP/高效率,同时 MoE 跨 DP ranks 汇总 token;
  • 代价是复制非专家权重、占用更多显存,并把 KV/cache locality 分散到更多实例。

DeepSeek-V3 的两份官方部署披露说明了什么

  • 2024 技术报告中的原始案例:Prefill 最小部署单元为 4 节点、32 GPU;attention 使用 TP4+SP 与 DP8,MoE 使用 EP32。每卡放 8 个原始 routed experts,另加 1 个冗余 expert。
  • 同一技术报告中的 Decode 案例:最小部署单元为 40 节点、320 GPU;attention 为 TP4+SP、DP80,MoE 为 EP320。每卡一个 expert,64 张卡用于 shared/redundant experts。报告还说明 decode 每 expert batch 通常不超过 256,瓶颈偏内存访问,因此使用更少 SM 做 dispatch+MoE+combine,并与另一 microbatch 的 attention 重叠。
  • 2025 年 2 月官方在线系统披露:V3/R1 的 Prefill 使用 routed-expert EP32 与 MLA/shared-expert DP32,4 节点中放置 32 个冗余 routed experts,每卡管理 9 个 routed experts 和 1 个 shared expert;Decode 则使用 routed-expert EP144 与 MLA/shared-expert DP144,18 节点中同样放置 32 个冗余 routed experts,每卡管理 2 个 routed experts 和 1 个 shared expert。

两份官方材料已经直接说明:EP32、EP320 或 EP144 都是特定时间、硬件、运行时和负载下的工程配置,不是模型结构唯一推导出的常数。Prefill 和 Decode 的合适 EP 本来就可以不同,而且会随部署演进:前者更重视大 batch 计算效率和双 microbatch overlap,后者更重视单 expert 权重读取、低延迟通信、冗余与 attention overlap。

选 EP 的实际流程

  1. 先满足权重容量、E 的可分片性和拓扑边界;优先让高频 A2A 留在 NVLink 域,跨节点再用分层通信。
  2. 用路由 trace 算各 P 下的 per-expert M 分布,而不只看均值。
  3. 分别 profile grouped GEMM、dispatch、combine,找 T_total(P) 最小点。
  4. 检查 P99 expert imbalance、网络拥塞、buffer 峰值和故障域。
  5. 热点明显时比较“继续增大 EP”与“复制 hot experts/EPLB”哪个更便宜。

常见追问

  • EP 可以大于 expert 数吗? 可以,但必须有 shared/redundant expert 或其他复制映射;否则多出的 rank 没有独立 expert。DeepSeek-V3 2024 技术报告中的 Decode EP320 就是一个包含冗余/shared 部署的历史案例,不代表后续在线系统仍固定使用 EP320。
  • 大 DP 会让单请求更快吗? 通常不会,主要提高多请求吞吐与排队能力。
  • EP32 为什么每卡 8 个 expert? 256/32=8;另一个冗余 expert 是负载平衡策略,不属于 256 个原始 expert 的均匀切分。

PD 分离配比怎么定?Decode 多大 batch size 合适?怎么由此反推 Prefill 的数量?

推理系统PD 分离MoE调度
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题是在做一套在线推理服务的容量规划:先找 Decode 在延迟和显存约束下能安全承载多大 batch,再把 Prefill、Decode 各自的 token 吞吐换算成请求吞吐,最后按真实流量反推出 P 池和 D 池要各放多少实例。它不是用理论 FLOPS 拍一个固定的 1:1 或 1:2 比例。

  1. Decode batch 变大时,一次能生成更多 token,吞吐通常先提高,但每一步也会更慢、KV 占用更大。要同时检查三条线:P99 TPOT 是否达标,模型权重、运行时和所有请求 KV 是否留有安全显存,以及 `TPS = batch/t_step` 是否已经到收益拐点。在线服务还要为长上下文和突发流量留余量;10%~30% 只能作为压测前的起始假设,最终要由真实流量、冗余目标和 SLO 校准。

  2. 仅知道 D 每秒生成多少 token 还不够。若一台 D 在目标 SLO 下是 1,400 token/s,而平均每请求输出 400 token,原始能力约为 3.5 请求/s;第一轮草算可再乘 0.6~0.8 的演示性安全利用率。这个范围不是通用推荐值:输出长度和上下文都有长尾,生产规划最好用真实 trace 回放,并把扩缩容时延和故障冗余一起纳入,而不是只除一个平均数。

  3. Prefill 同样先测满足 TTFT 的未命中 prompt token/s。开启 Prefix Cache 后,分母必须用 `L_miss`——真正还要计算的后缀长度,而不是完整输入长度;用 P 的 token/s 除以平均 `L_miss`,得到每台 P 的请求/s,再乘安全利用率。业务到达率分别除以 P、D 的安全请求能力并向上取整,就是两边实例数。

  4. 若已经配置了 `N_D` 台 D,也可用“D 池安全请求能力 ÷ 单台 P 安全请求能力”反推 `N_P`,但还要取与业务入口峰值所需数量中的较大者。最后必须复核 P→D 的 KV 传输带宽、两段队列、cache 命中和 burst;总平均吞吐刚好相等,并不保证 TTFT/TPOT 的 P99 不爆。

记住一句 先用 TPOT、KV 显存和吞吐拐点定 Decode batch,再把 P/D 的 token/s 都换成安全请求/s,按真实输入输出分布和到达率反推实例数。

30″ 面试开口
先用 SLO 和显存确定 Decode 的可行 batch,再用真实长度分布 profile 出每个 D replica 的“满足 TPOT 的 goodput”;Prefill 同样用 cache miss 后的 prompt 长度和 TTFT profile 容量。…
P/D 配比本质是两类服务站的流量守恒:分别测 service time,再按到达率、SLO 和安全余量配置容量。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

先用 SLO 和显存确定 Decode 的可行 batch,再用真实长度分布 profile 出每个 D replica 的“满足 TPOT 的 goodput”;Prefill 同样用 cache miss 后的 prompt 长度和 TTFT profile 容量。P:D 配比是两个服务中心的容量平衡:

NPμPλ/ρP,NDμDλ/ρDN_P\mu_P\ge \lambda/\rho_P,\qquad N_D\mu_D\ge \lambda/\rho_D

不是按 GPU FLOPS 静态拍一个 1:1 或 1:2。还必须验证 KV 传输带宽、两阶段队列和 burst headroom。

第一步:定 Decode batch size

Decode batch B 同时受三类约束:

  1. TPOT SLO:实测 t_step(B,Lctx) 的 P99 必须小于目标 TPOT;连续 batching 下还要算调度间隙。
  2. KV 显存
Wmodel+Wruntime+r=1BKVBytes(Lr)+Wcomm<MHBM×safetyW_{model}+W_{runtime}+\sum_{r=1}^{B}KVBytes(L_r)+W_{comm}<M_{HBM}\times safety

变长 batch 应按 token/block 总数算,不能简单用 B×平均长度 忽略 P99。 3. 吞吐拐点

TPSD(B)=Btstep(B)TPS_D(B)=\frac{B}{t_{step}(B)}

增大 B 到 TPS 增益很小、但 TPOT/排队明显变差时停止。

因此“合适的 B”通常在满足 P99 TPOT 和显存安全的最大值附近。在线低延迟服务还要为 burst 和长 context 留余量;10%~30% 只能作为压测前的演示性起点,不是行业通用常数,最终应由目标流量 trace、故障冗余和 SLO 反推。

第二步:把 D 的 token capacity 转成请求 capacity

若 profile 得到每个 D replica 在目标 SLO 下吞吐 TPS_D,平均输出长度为 E[Lo]

μDreq=TPSDE[Lo]\mu_D^{req}=\frac{TPS_D}{E[L_o]}

更准确做法是 trace replay,因为 t_step 随 context 增长,E[Lo] 的均值也掩盖长尾。做第一轮容量草算时可以用 ρ=0.6~0.8 演示安全利用率;它不是固定推荐值,生产取值仍要由到达过程、服务时间分布、扩缩容时延和目标 P99 校准,以免 M/G/k 队列在高利用率下尾延迟陡升:

ND=λρDμDreqN_D=\left\lceil\frac{\lambda}{\rho_D\mu_D^{req}}\right\rceil

第三步:profile Prefill 容量

Prefix Cache 开启后要用 Lmiss,不是原始 prompt 长度。若一个 P replica 在目标 TTFT/chunk 策略下能处理 TPS_P 个未命中 prompt tokens/s:

μPreqTPSPE[Lmiss]\mu_P^{req}\approx\frac{TPS_P}{E[L_{miss}]}

同理:

NP=λρPμPreqN_P=\left\lceil\frac{\lambda}{\rho_P\mu_P^{req}}\right\rceil

若已经定了 ND,并让 D 以安全容量 ρD μD 运行,可反推:

NP=NDρDμDreqρPμPreqN_P=\left\lceil \frac{N_D\rho_D\mu_D^{req}}{\rho_P\mu_P^{req}} \right\rceil

但最终仍应取业务到达率 λ 与上式所需的较大者,不能因为 D 暂时配置少就低估 P 的入口 burst。

一个具体例子

假设:

  • D 在 B=64 时 P99 step=45 ms,满足 50 ms TPOT;
  • TPS_D=64/0.045≈1422 token/s
  • 平均输出 400 token,因此 raw μD≈3.56 req/s
  • ρD=0.7,安全容量约 2.49 req/s;
  • P profile 为 50K 未命中 prompt token/s,平均 Lmiss=2000,raw μP=25 req/s
  • ρP=0.7,安全容量 17.5 req/s。

一台 P 理论上可喂约 17.5/2.49≈7 台 D。若部署 8 个 D:

NP=8×2.49/17.5=2N_P=\lceil8\times2.49/17.5\rceil=2

这个 2P:8D 只对上述 profile 和分布成立。若 Agent prefix hit 提高、Lmiss 降低,P 更少;若输出变短,D req/s 提升,反而需要更多 P 才能喂满 D。

别忘了 KV 传输与队列

每秒 KV 传输需求:

BWKV,need=λ×E[KVBytesnew/transferred]BW_{KV,need}=\lambda\times E[KVBytes_{new/transferred}]

要求链路在安全利用率下满足它,且 P→D placement 不产生热点。还应分别测:P queue TTFT、compute TTFT、KV transfer、D queue、TPOT;总平均吞吐相等并不代表 P99 SLO 成立。

常见追问

  • 为什么不能只按 prefill FLOPs/decode FLOPs 配比? decode 主要读权重/KV,prefill 主要做大 GEMM;两者受不同屋顶、batch 和 SLO 控制。
  • P 与 D 是否必须整数固定配比? 不必。可建立共享弹性池或 mixed workers,但切换模型/缓存状态有成本。
  • B 越大 TPS 一定越高吗? 通常先升后饱和;context KV 读、A2A、调度和显存会让收益递减,且 TPOT 可能先违反 SLO。

MoE 并行的 overhead 是什么?A2A 通信量是多少?

推理系统RooflineMoE
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

MoE 只激活少数专家,省的是矩阵乘计算,却要先把 token 送到专家所在 GPU、算完再送回来。这题要把“路由、重排、两次通信、小批量专家计算和负载不均”全部算作开销,并明确 A2A(All-to-All,全互发)通信量到底是单卡、整个组,还是收发两边合计。

  1. 完整数据流是:门控网络选 TopK 专家,统计各专家 token 数并重排打包,dispatch A2A 发往目标卡,执行 grouped expert GEMM,再用 combine A2A 把结果发回来源卡,恢复原顺序并按路由权重求和。每一步都有 kernel 启动、同步和不规则访存;最慢的热点专家还会让其他卡等待,所以“只算了 k 个专家”不代表端到端一定快。

  2. 设单卡有 B 个原始 token,TopK 为 k,隐藏维 H,每个元素 b 字节,EP 大小为 P。标准实现会为每个 token—专家任务发送一份 H 维激活,因此包含本地任务的逻辑数据是 `k×B×H×b`;均匀路由时约 `1/P` 留在本卡,单卡 dispatch 的远端发送量是 `kBHb×(1−1/P)`。

  3. combine 若以相同精度逐任务返回,单卡远端发送量同样大,所以一来一回约为 `2kBHb×(1−1/P)`。整个 EP 组的远端 send payload 再乘 P;若监控口径把每个字节的 send 和 receive 都加起来,还要再乘 2。量化时 dispatch 与 combine 可能字节数不同,需分别计算,不能只说“通信量是激活的两倍”而不交代口径。

  4. 公式只给理想 payload:真实系统还有 token 索引、路由权重、对齐与 padding、量化 scale、协议头、小消息往返时延和网络拥塞。也可能因多个目标专家恰在同一卡而做传输去重,但这不是通用保证。优化通常包括分层 A2A、通信低精度、重排/通信/GEMM 流水重叠,以及复制热门专家来缓解长尾。截至 2026-07-15,DeepEP V2 主线文档已在 ElasticBuffer 下统一高吞吐与低延迟接口,并不再支持旧版 0-SM RDMA low-latency EP;回答框架能力时要带版本。

记住一句 MoE 的单卡远端 dispatch 量记作 `kBHb(1−1/P)`,同精度往返再乘 2;报告结果前一定先说明是单卡、全组,还是 send+receive 口径。

30″ 面试开口
MoE overhead 不只是两次 All-to-All。完整路径是 gate/TopK → 计数与 prefix-sum → permute/pack → dispatch A2A → grouped expert GEMM → combine A2A → unpermute/加权归并;…
MoE overhead 不止两次 A2A:路由、重排、padding、专家负载不均和同步等待同样会进入端到端时间。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

MoE overhead 不只是两次 All-to-All。完整路径是 gate/TopK → 计数与 prefix-sum → permute/pack → dispatch A2A → grouped expert GEMM → combine A2A → unpermute/加权归并;还包括专家负载不均、padding/capacity、同步、通信占用 SM、跨节点拥塞和小 GEMM 低利用率。

若每 rank 有 B 个原始 token,hidden size H,TopK=k,EP size=P,通信元素 b Byte,路由到 rank 均匀,则标准 token-expert-pair dispatch 的远端 payload 为:

Vdispatch,send/rank=kBHb(11P)V_{dispatch,send/rank}=kBHb\left(1-\frac1P\right)

整个 EP group 单向注入网络的远端 payload:

Vdispatch,fabric=PkBHb(11P)V_{dispatch,fabric}=PkBHb\left(1-\frac1P\right)

combine 若以相同精度返回每个 assignment 的 hidden output,大小相同。因此 dispatch+combine:

Vroundtrip,fabric=2PkBHb(11P)V_{roundtrip,fabric}=2PkBHb\left(1-\frac1P\right)

先声明“通信量”的统计口径

  • 上式 fabric 是所有 rank 的远端 send payload 之和,local assignment 不上网络。
  • 若监控把 send 和 receive NIC bytes 都相加,数字再乘 2。
  • 一些库报告 logical bandwidth,会把 local traffic 也算进去;DeepEP README 明确提示其部分带宽数字包含 local rank traffic。
  • padding、alignment、量化 scale、token index、routing weight 和协议 header 未包含在理想公式里。

DeepSeek-V3、EP32、每卡 B=16 的数值例子

假设:

  • P=32
  • B=16
  • k=8
  • H=7168
  • dispatch 与 combine 都用 BF16,即 b=2
  • expert 在 ranks 间均匀,忽略 shared/redundant expert。

每 rank 共 128 个 token-expert pairs,包含 local 的逻辑 payload:

128×7168×2=1,835,008 Bytes1.75 MiB128\times7168\times2=1,835,008\ Bytes\approx1.75\ MiB

乘远端比例 31/32

  • dispatch 每 rank 远端 send 约 1.695 MiB;
  • dispatch 全 EP group 注入网络约 54.25 MiB;
  • combine 同样约 54.25 MiB;
  • 两阶段合计约 108.5 MiB/层/step 的远端 send payload。

如果 dispatch 用 FP8、combine 用 BF16,则约为:

27.1 MiB+54.25 MiB=81.4 MiB//step27.1\ MiB+54.25\ MiB=81.4\ MiB/层/step

若按 send+receive 两方向计 NIC 总字节,分别约 217 MiB 和 162.8 MiB。metadata 本身很小:例如每 assignment 一个 int32 expert/index 和 FP32 weight,单 rank约 128×8=1 KiB,但小 batch 时控制延迟仍不可忽略。

主要 overhead 分解

  1. Router:gate GEMM、sigmoid/softmax、group-limited TopK、归一化。
  2. Token layout:统计每 expert/rank token 数,prefix sum,permute、量化和 pack;大量不规则访存。
  3. Dispatch A2A:跨 rank 发 token activation;跨节点时受 NIC、RTT、拥塞、拓扑影响。
  4. 负载不均:collective/层延迟由最慢 rank 决定;热门 expert、capacity padding、空 expert 都降低利用率。
  5. Grouped GEMM:expert M 小且形状不同,Tensor Core tile 浪费;权重是否命中 L2 也影响巨大。
  6. Combine A2A:结果回源 rank,再 unpermute,按 TopK routing weight 加权求和。
  7. 资源干扰:通信 kernel 使用 SM、shared memory、L2 和 HBM;与 expert GEMM/attention overlap 不当会互相抢资源。
  8. 冗余/EPLB:复制 hot experts 可平衡负载,但增加权重显存、路由表更新和一致性管理。

优化方向

  • 分层 A2A:先 IB 跨节点聚合,再 NVLink 节点内转发,匹配拓扑;或使用直接低延迟 P2P/RDMA。
  • dispatch 使用 FP8,combine 按精度需求保留 BF16;scale 与量化融合到 pack/kernel。
  • permute、通信、grouped GEMM、combine 流水化;双 microbatch 隐藏通信。DeepSeek-V3 prefill 官方就同时处理两个 workload 相近的 microbatch 来重叠 attention/MoE 与 dispatch/combine。
  • redundant experts、周期性 EPLB 和动态路由解决热点;buffer 按 P99 token count,而不是最坏值无限预留。
  • decode 小 batch 仍优先压 RTT 与同步,prefill 大 batch 更看重吞吐和通信—计算重叠;但不要把当前 DeepEP 描述成两套彼此独立、固定不变的 API。截至 2026-07-15,DeepEP V2 主线文档已在 ElasticBuffer 下统一高吞吐与低延迟接口,并按分析模型配置通信 SM / QP;其 V2 说明还明确写出不再支持旧版“0 SM RDMA low-latency EP”。面试时应同时说清 workload 差异与版本边界。

常见追问

  • 为什么是 TopK 倍通信? 标准 dispatch 对每个 token-expert assignment 发送一份 activation。若同一 token 的多个 experts 恰好在同一 rank,专门实现可先去重传输再在目标 rank 复制,但不能把这种优化当作通用公式。
  • combine 能否只返回一个 token 向量? 只有目标端已拥有该 token 的全部 TopK expert 结果并能先聚合时才行;一般 TopK experts 分散在不同 ranks,需逐 assignment 回源再合并。
  • A2A bytes 不大为什么还慢? decode 是大量小消息,常由 RTT、同步和不均衡主导;带宽公式只给下界,不给实际延迟。

4 台服务器,每台 memory 大小是 N×N,服务器之间网络全连接,现在需要计算 C=A×B,A 和 B 都是 [N,N],怎么做并行?如果缓存空间只有 N×N/8,矩阵乘并行方案怎么做?

推理系统MoE调度GPU
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题考的是分布式矩阵乘怎样同时分配计算、数据和临时缓存。四台服务器互联并不意味着各自复制完整矩阵;更自然的做法是排成 2×2 逻辑网格,每台负责结果 C 的四分之一,并把 A、B 按小面板流过去。第二问首先要分清 `N²/8` 是快缓存,还是机器的全部存储。

  1. 把 A、B 都切成 2×2 块,每块大小是 `N/2 × N/2`,服务器 `Pij` 负责 `Cij`。例如 `C00=A00B00+A01B10`;所有与 C00 有关的部分和都在同一台累加,因此最后不需要再对整个 C 做 AllReduce。四台各算四分之一结果,计算量天然均衡。

  2. 执行可用 SUMMA(二维分块矩阵乘)理解:A 的一小条 panel 沿网格行广播,B 的对应 panel 沿网格列广播;每台拿到一对就更新自己的 C 块,再进入下一条 K 方向 panel。若每台总内存是 N² 个元素,常驻本地 A、B、C 各占 N²/4,共 3N²/4,只剩 N²/4 临时空间,所以不能同时再收两个完整块,必须把 panel 宽度控制在约 N/4 以内;要双缓冲重叠通信时还应更小。

  3. 若 `N²/8` 只是 GPU cache 或其他快存,而完整 A、B、C 在更慢的内存、磁盘或输入流中,就做 out-of-core tiled SUMMA:每次装一个 A tile、一个 B tile 和一个 C 累加 tile,计算时预取下一对,完成后把 C 写回。基本三块需满足 `3t² ≤ N²/8`;若 A、B 双缓冲则约需 `5t² ≤ N²/8`,实际还要给元数据留余量。

  4. 但若 `N²/8` 是每台唯一可用的全部存储,四台合计只有 `4×N²/8=N²/2` 个元素,连单独一个 N×N 输入矩阵都无法同时保存。没有外存、远端内存或流式输入时,这个问题在容量上就不可行;面试中应先指出这个硬边界,再讨论有 backing store 时如何分块,而不是凭空给出一个能运行的调度。

记住一句 四机矩阵乘用 2×2 SUMMA 分 C、流式广播 A/B panel;若 N²/8 是快存就继续分块,若是全部存储则总容量不足,必须依赖外存。

30″ 面试开口
4 台全连接服务器最自然是排成 2×2 逻辑网格,使用 2D block matrix multiplication/SUMMA。每台负责一个 N/2×N/2 的 C block,计算量完全均衡;A panel 沿行广播、B panel 沿列广播。…
通信下界、每机内存和网络拓扑共同决定分块;缓存收紧后,需要让 A/B panel 流过并让局部 C tile 留驻累加。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论

4 台全连接服务器最自然是排成 2×2 逻辑网格,使用 2D block matrix multiplication/SUMMA。每台负责一个 N/2×N/2 的 C block,计算量完全均衡;A panel 沿行广播、B panel 沿列广播。

每台容量为 元素时,总容量 4N² 足够保存 A、B、C 共 3N² 元素,但每台只剩 N²/4 workspace,不能天真地一次收两个完整 N²/4 block。应把内积维再切成宽度不超过 N/4 的 panels,流式广播。

若“缓存只有 N²/8”是每台唯一可用存储,则 4 台合计只有 N²/2,连一个输入矩阵都放不下,问题在没有外部存储/流输入的前提下不可行。若 cache 指快存,而 A/B/C 在慢存或远端,则使用 out-of-core tiled SUMMA,选择 tile 使 A、B、C tile 和双缓冲装得下。

情况一:每台 memory = N²

将矩阵切成 2×2 blocks:

A=[A00A01A10A11],B=[B00B01B10B11]A=\begin{bmatrix}A_{00}&A_{01}\\A_{10}&A_{11}\end{bmatrix},\quad B=\begin{bmatrix}B_{00}&B_{01}\\B_{10}&B_{11}\end{bmatrix}

每个 block 为 N/2×N/2=N²/4 元素。服务器 Pij 初始保存 Aij、Bij,负责输出 Cij

Cij=Ai0B0j+Ai1B1jC_{ij}=A_{i0}B_{0j}+A_{i1}B_{1j}

若不考虑临时 buffer,每台常驻:

Aij+Bij+Cij=3N2/4|A_{ij}|+|B_{ij}|+|C_{ij}|=3N^2/4

剩余 N²/4

Panelized SUMMA 调度

将 inner dimension k=0...N-1 按宽度 w 切 panel。一次 microstep:

  • P(i, owner_k) 沿逻辑行广播 A[i, k:k+w],大小 (N/2)×w
  • P(owner_k, j) 沿逻辑列广播 B[k:k+w, j],大小 w×(N/2)
  • 每个 Pij 执行:
Cij+=Ai,k:k+wBk:k+w,jC_{ij} += A_{i,k:k+w}B_{k:k+w,j}

最坏情况下一个 rank 同时需要两个远端 panels,workspace:

Nw/2+Nw/2=NwNw/2+Nw/2=Nw

要求 Nw≤N²/4,即 w≤N/4。选 w=N/4 时恰好使用剩余空间,需要 4 个 microsteps 覆盖整个 inner dimension;若要 A/B 双缓冲以覆盖通信,选 w≤N/8 更安全。

计算与通信量

每台负责 C 的四分之一:

  • 标量乘加数 N³/4;若一次乘加算 2 FLOP,则 N³/2 FLOP
  • 每台需要远端的 A 半块 N²/4 和 B 半块 N²/4,理想接收总量约 N²/2 元素;发送量对称;
  • panelization 改变消息个数和重叠,不改变理想总 payload。

全连接物理网络不代表应做 4 份完整矩阵复制。2D SUMMA 利用行/列子通信组;实际应把逻辑网格映射到带宽更高的拓扑,并用 nonblocking broadcast + GEMM overlap。

情况二:每台快缓存 = N²/8

先说可行性

若没有慢存、远端内存或输入流,4 台总容量:

4×N2/8=N2/2<N24\times N^2/8=N^2/2<N^2

连 A 都无法同时存在,因此任何“只在这 4 块 cache 内完成”的方案都不成立。面试中应先指出这一信息论层面的约束,再说明加入 backing store 后的方案。

有 backing store 时:out-of-core tiled SUMMA

选择方形 tile 边长 t。一次最基本计算需驻留一个 A tile、一个 B tile、一个 C accumulator:

3t2N2/8tN/243t^2\le N^2/8\Rightarrow t\le N/\sqrt{24}

若 A/B 都做双缓冲,则需 C+2A+2B

5t2N2/8tN/405t^2\le N^2/8\Rightarrow t\le N/\sqrt{40}

工程上可选整齐的 t=N/8:双缓冲共 5N²/64 < N²/8,还留有 metadata/通信空间。

此时每维 8 个 tiles。将 C tiles 按 (tile_i mod 2, tile_j mod 2) cyclically 分给 4 台服务器。每台对自己的 C tile:

load/zero one C tile
for k_tile = 0..7:
    prefetch/broadcast A[i,k] and B[k,j]
    C[i,j] += A[i,k] × B[k,j]
write C tile back to slow memory

4 台各负责 64 个 C tiles 中的 16 个,每个 C tile 做 8 次 tile GEMM,因此计算仍均衡。双缓冲使第 k 次 GEMM 与第 k+1 次读/通信重叠。

该简单调度一次只保留一个 C tile,可能重复加载 A/B。可把剩余 cache 用于一个 C tile strip 或矩形 blocking,增加 A/B 复用;原则是 tile 尺寸取 Θ(√M),接近经典矩阵乘通信下界:对 fast memory 容量 M,数据移动至少为 Ω(FLOPs/√M) 量级。

常见追问

  • 为什么不用把 A 按行、B 按列各复制两份? 可以换通信换内存,但这里每台内存有限;2D SUMMA 的每台存储、计算和通信更均衡。
  • C 的 partial sum 要 AllReduce 吗? 上述 2D 算法让每个 Cij 的所有 k panels 都在同一服务器累加,不需要最终 AllReduce;若沿 K 维做 1D split,则需要对完整/分块 C 做 Reduce-Scatter。
  • Strassen 是否更好? 工程中的大 dense GEMM 通常先优化数据分块、通信和 BLAS-3 效率;Strassen 常数、额外 workspace、通信和数值稳定性使它不是这道 Infra 题的默认答案。
02

CHAPTER

硬件与算子

9 QUESTIONS · #

Attention 变化:MHA、MQA、GQA、MLA,对算子的影响是什么?计算访存比如何变化?

硬件与算子KV CacheMoE数学基础
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题真正想问的是:模型生成下一个 token 时,要从显存里读多少历史信息,又要做多少计算。Attention 里会有很多组 Query(当前要查什么)、Key(历史内容的索引)和 Value(历史内容本身);不同结构主要改变 K/V 的保存方式,因此直接影响 KV Cache 大小和 Decode 速度。

  1. 先看 MHA:每个 Query Head 都有自己的一套 K/V Head,像每位读者都有一整套私人档案。这样表达能力直接,但历史越长,要保存和读取的 K/V 越多;单步 Decode 经常不是算力不够,而是在等这些档案从 HBM 显存搬过来。

  2. GQA 让一组 Query Head 共用一套 K/V,MQA 更进一步,让全部 Query Head 共用一套 K/V。Query Head 的数量和主要 Attention 点积没有按同样倍数减少,但 K/V Cache 与读取流量显著下降,所以“每搬一个字节能完成的计算”变多。代价是它们属于模型结构选择,不能对任意已训练 MHA 模型无损地打开一个开关。

  3. MLA 不是继续减少或共享 K/V Head,而是把每个 token 的 K 和 V 联合压成一个更短的 latent,也就是低维信息摘要。Decode 时通过矩阵吸收,把恢复 K/V 的投影矩阵合并到 Query 侧和输出侧,直接在 latent 空间完成关键计算,不必为整段历史展开完整多头 K/V。它通常多做一些计算,换来数量级更小的 KV Cache。

  4. 还要区分 Prefill 和 Decode。Prefill 一次处理很多 Query,矩阵大、数据复用高,常更偏计算受限;Decode 每步 Query 很少,却要扫很长的历史 K/V,更容易受显存带宽限制。因此 MQA、GQA、MLA 的省显存和省带宽优势,通常在长上下文 Decode 中最明显。

记住一句 记住:MHA→GQA→MQA 是让更多 Query Head 共享 K/V;MLA 则是缓存低维 latent,并用矩阵吸收避免还原整段 K/V。

30″ 面试开口
MHA、GQA、MQA 的主要区别是 多少个 Query Head 共享一组 K/V Head。它们的 Query 数量和 Attention 主体计算量基本不变,但依次减少 K/V 投影、KV Cache 容量和 Decode 时读取 KV Cache 的 HBM 流量,因此 Decode 的计算访存比依次升高。…
四种 Attention 的核心差别落在 KV 表示与共享粒度,它直接改变 KV Cache 字节数、投影算子和 decode 访存强度。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

MHA、GQA、MQA 的主要区别是 多少个 Query Head 共享一组 K/V Head。它们的 Query 数量和 Attention 主体计算量基本不变,但依次减少 K/V 投影、KV Cache 容量和 Decode 时读取 KV Cache 的 HBM 流量,因此 Decode 的计算访存比依次升高。MLA 不只是“共享 K/V Head”,而是把每个 token 的 K、V 联合压缩为一个低维 latent;Decode 时通过矩阵吸收直接在 latent 空间计算,用更多计算换取数量级更小的 KV Cache。

原始 MHA、MQA、GQA 和 MLA 分别见 Attention Is All You NeedFast Transformer Decoding: One Write-Head is All You NeedGQA: Training Generalized Multi-Query Transformer Models from Multi-Head CheckpointsDeepSeek-V2

先统一符号

  • B:batch 中的序列数;
  • S:当前已缓存的上下文长度;
  • L:Transformer 层数;
  • H:Query Head 数;
  • H_kv:K/V Head 数;
  • d:每个 Attention Head 的维度,先假设 K、V 维度相同;
  • e:每个 KV 元素的字节数。

四种结构可以写成:

结构H_kvQuery Head 与 KV Head 的关系每 token、每层缓存元素数
MHAH每个 Query Head 有独立 K/V2Hd
GQAG1<G<HH/G 个 Query Head 共享一组 K/V2Gd
MQA1所有 Query Head 共享一组 K/V2d
MLA不用普通 H_kv 描述联合缓存 KV latent 和解耦 RoPE Keyd_c+d_r

因此,普通 MHA/GQA/MQA 的完整 KV Cache 大小是:

M_KV = 2 × B × S × L × H_kv × d × e  bytes

从 MHA 改为 GQA-G,KV Cache 缩小 H/G 倍;改为 MQA,缩小 H 倍。

对算子图的影响

MHA

典型算子链是:

X
├─ GEMM: XWq → Q
├─ GEMM: XWk → K ─→ 写 KV Cache
└─ GEMM: XWv → V ─→ 写 KV Cache

QKᵀ → scale/mask → softmax → PV → output projection

每个 Query Head 都读取自己对应的 K/V。Decode 时 QKᵀPV 通常是长向量点积/矩阵向量型计算,天然容易受 KV Cache 带宽限制。

GQA / MQA

算子种类不变,但形状发生变化:

  • Q 投影输出仍为 H×d
  • K、V 投影输出从各 H×d 变为各 G×d1×d,投影权重和投影 FLOPs 同步减少;
  • Attention kernel 需要做 Query Head 到 KV Head 的映射,例如 GQA 中 kv_head = q_head / (H/G)
  • 不能真的把共享的 K/V repeat 成 H 份再写回 HBM,否则会把节省的带宽重新浪费掉。kernel 应在寄存器、Shared Memory 或 Cache 中复用一份 K/V;
  • 如果 grid 只沿 KV Head 划分,MQA/GQA 会因为 H_kv 太小而损失并行度。常见做法是同时沿 batch、Query Head Group、sequence tile 划分,并让同一 CTA 内的多个 Query Head 复用 KV tile。

对长度为 T 的一批输入,Q/K/V 线性投影的主要 FLOPs 近似为:

F_qkv ≈ 2T × d_model × (H + 2H_kv) × d

MHA 对应 H_kv=H,即括号内是 3H;MQA 在 H 很大时括号内接近 H,所以 Q/K/V 三个投影的合计 FLOPs 最多可接近缩小到原来的三分之一。单看 K/V 两个投影,它们从 2H 个 Head 输出降为 2 个,约缩小 H 倍;但 output projection 不变,Attention 中每个 Query Head 的点积也仍要计算,不能说整个 Attention FLOPs 缩小了 H 倍。

MLA

MLA 先联合压缩 K/V:

c_KV = W_DKV × x                 # c_KV 维度 d_c
K     = W_UK  × c_KV
V     = W_UV  × c_KV

为兼容 RoPE,DeepSeek-V2 还缓存一个解耦的共享 RoPE Key,维度记为 d_r。每 token、每层只需缓存:

[c_KV, k_R],共 d_c + d_r 个元素

关键是 Decode 的“矩阵吸收”:

  • W_UK 吸收到 Query 侧,先把每个 Query Head 变换到 d_c 维,然后直接与缓存的 c_KV 点积;
  • W_UV 吸收到 output projection,先用 Attention 权重对 c_KV 做加权和,再走合并后的输出投影;
  • 因而不必在每一步为全部历史 token 重建完整 K/V。

这使 Decode Attention 的形态从普通的 d 维点积变成以 d_c 为主的点积/归约。MLA 的单次计算量更大、寄存器和 Shared Memory tile 更大,但读取的 KV Cache 小得多。

工程上通常为 MLA 准备至少两条路径:

  • Prefill 路径:Q 长度较大,GEMM 和 tiled Attention 易获得较高利用率,可将 latent 批量上投影后使用类似 FlashAttention 的 kernel,或使用专门的 MLA Prefill kernel;
  • Decode 路径:Q 长度通常为 1,采用矩阵吸收后的 latent Attention,避免为整个历史上下文重建 K/V;
  • 两条路径的交叉点由 Query 长度、上下文长度、batch、GPU Tensor Core 吞吐和 HBM/L2 带宽共同决定,不能只凭模型结构硬编码。

Decode 的计算访存比推导

先只计算 Attention core 对历史 KV 的读取,不计权重读取、Q/输出张量、softmax 和 kernel 额外流量。

普通 MHA/GQA/MQA 在一步 Decode 中:

QKᵀ FLOPs ≈ 2BSHd
PV   FLOPs ≈ 2BSHd
总 FLOPs   ≈ 4BSHd

KV 读取字节 ≈ 2BSH_kv d e

所以理想的 KV 算术强度是:

I_KV ≈ (4BSHd) / (2BSH_kv d e)
     = (2/e) × (H/H_kv)  FLOP/Byte

对 FP16/BF16,e=2,公式进一步变成:

MHA: I_KV ≈ 1
GQA: I_KV ≈ H/G
MQA: I_KV ≈ H

这正是 MQA/GQA 对 Decode 有效的硬件原因:计算量大体不变,HBM 字节数下降。注意这只是 KV 流量的理想值;batch 很小时,模型权重读取也可能占主导,paged layout、Cache miss、重复加载和非合并访存也会降低实测强度。

MLA 经矩阵吸收后,每个历史 token 的 Attention core 主要计算量近似为:

score 的 latent 部分: 2H d_c
score 的 RoPE 部分:   2H d_r
latent value 加权和:   2H d_c

F_MLA ≈ 2SH(2d_c + d_r)

[c_KV, k_R] 从 HBM 理想地只加载一次:

I_MLA,ideal ≈ 2H(2d_c+d_r) / ((d_c+d_r)e)

实际 kernel 往往因为 Head Group 分块而把 latent 流加载 ρ 次,或由 L2 命中部分承接复用,此时更现实的表达是:

I_MLA ≈ 2H(2d_c+d_r) / (ρ(d_c+d_r)e),1 ≤ ρ ≤ H

因此“MLA 的计算访存比是多少”不能只给一个常数:必须说明是 最小 HBM 流量模型,还是某个具体 tiling 下测得的 DRAM bytes。

DeepSeek-V2 参数的定量算例

DeepSeek-V2 原论文与官方配置给出:H=128qk_nope_head_dim=128qk_rope_head_dim=64v_head_dim=128d_c=512L=60。这里必须注意:实际 Query/Key Head 是 128+64=192 维,Value Head 是 128 维,不能把二者都写成 128 维后再称为 DeepSeek-V2 的同形状对比。

按 FP16/BF16 缓存,一个保留相同 K/V 维度的朴素多头路径与 absorbed MLA 路径分别是:

同形状朴素 MHA:
  每 token 每层 KV = 128×(192+128)×2
                    = 81,920 B = 80 KiB

MLA:
  每 token 每层 KV = (512+64)×2
                    = 1,152 B = 1.125 KiB

本算例缩小倍数 = 81,920 / 1,152 ≈ 71.11×

DeepSeek-V2 论文另有“相对 DeepSeek 67B,KV Cache 减少 93.3%”的模型级报告;它比较的是两个不同模型基线,不是上面固定层数、Head 数与维度后的理论压缩比,二者不能混用。

把这组 H=128,d_k=192,d_v=128,e=2 参数放在一起比较,并令 GQA 有 G=8 个 KV Group:

结构每历史 token、每层 KV 读取每历史 token 的 Attention core FLOPs理想 I_KV
MHA81,920 B81,9201 FLOP/B
GQA-85,120 B81,92016 FLOP/B
MQA640 B81,920128 FLOP/B
MLA(ρ=11,152 B278,528241.78 FLOP/B

这张表比较的是 Attention core 的历史 KV 路径。它没有计入 QKV/output projection、模型权重、softmax、分页元数据和重复加载,因此不能直接当成端到端加速比。

S=8192、单序列:

MHA:每层 640 MiB,60 层约 37.5 GiB
MLA:每层 9 MiB,60 层约 540 MiB

对应每个历史 token 的 Attention core:

MHA FLOPs = 2×128×(192+128) = 81,920
MLA FLOPs = 2×128×(2×512+64) = 278,528

MLA / MHA 计算量 = 3.4×
MLA 理想 I_KV = 278,528 / 1,152 ≈ 241.78 FLOP/Byte
MHA 理想 I_KV = 81,920 / 81,920 = 1 FLOP/Byte

这个同形状算例直观说明 MLA 的取舍:Attention core 多做约 3.4 倍计算,换取约 71.1 倍 KV Cache 压缩;在理想复用下,KV 相关算术强度从约 1 提高到约 242 FLOP/Byte。 端到端速度不会等于这些倍率,因为还包括模型权重、投影、softmax、调度和通信。

Prefill 为什么不能直接套上面的公式

Prefill 有很多 Query Token,Q/K/V tile 能在片上反复复用;其 Attention FLOPs 随序列长度近似二次增长,而必要输入输出流量近似一次增长,所以更容易走向 compute-bound。MQA/GQA 仍能降低 K/V 投影和 KV Cache 写入,但 Attention 中 H 个 Query Head 的配对计算仍然存在。上面的 I_KV 公式专门描述 单步 Decode 读取历史 KV,不能拿来声称 Prefill 也有同样的 H 倍加速。

常见追问

追问 1:GQA/MQA 为什么不一定按 KV 缩小倍数加速?

因为端到端还要读模型权重、做 Q/output projection、softmax 和调度;共享 K/V 后可并行的 KV Head 也减少。若 kernel 把 K/V 展开、重复加载或 grid 太小,理论带宽收益兑现不了。

追问 2:MQA 一定优于 GQA 吗?

不是。MQA 的 KV 最小,但表达能力和模型质量可能受影响;GQA 用多个 KV Group 在质量、带宽和并行度之间折中。它们是模型架构选择,不是只改一个推理 kernel 就能无损切换。

追问 3:MLA 一定比 GQA 快吗?

不是。MLA 显著省 KV,但 absorbed Attention 的维度更大,可能转为 Tensor Core/计算受限;小上下文、小 batch 或 Prefill 场景未必占优。应分别对 Prefill、Decode 和真实请求分布测量。

追问 4:如何判断实现是否真的吃到了 MQA/GQA/MLA 的收益?

用 profiler 同时检查 DRAM bytes、L2 hit rate、Tensor Core 利用率、kernel grid、occupancy 和 achieved bandwidth;再对照上述理论最小流量。只看 kernel latency,无法区分是算法少读了数据,还是测试形状变了。

讲一下算子有哪些优化方法?

硬件与算子RooflineFlashAttention调度
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题不是让你背一长串 CUDA 技巧,而是看你会不会先找到“慢在哪里”,再选择对应办法。算子可以理解成 GPU 上完成一项局部任务的小程序;它可能在等计算单元、等显存数据,也可能任务太碎,时间主要花在启动和同步上,三种病因的药完全不同。

  1. 第一步是保留正确结果和真实输入形状,测出耗时、吞吐、显存流量与计算利用率。可以用 Roofline 做粗判断:如果每搬一个字节只做很少计算,多半是 memory-bound(访存受限);如果数据供应充分、计算单元接近忙满,则更像 compute-bound(计算受限)。大量很短的 kernel 还要单独检查启动和同步开销。

  2. 如果访存受限,重点不是再加几条乘法,而是少搬、连续搬、搬来后多用几次。常见做法包括合并连续访存、把数据分块放入 Shared Memory、融合相邻步骤、减少中间张量、使用较低精度,以及加载下一块时同时计算当前块。FlashAttention 就是“多做少量重算,换掉大量 HBM 读写”的典型例子。

  3. 如果计算受限,就要让 Tensor Core 或其他执行单元更有效地工作,例如选择匹配硬件指令的 tile、提高并行度、减少无效 padding 和分支。tile 不是越大越好:它会消耗寄存器和 Shared Memory,过大可能让一个 SM 同时驻留的线程块变少,甚至把寄存器内容溢出到较慢的 Local Memory。

  4. 最后才是工程闭环:对真实 shape 分布准备多个实现,逐个比较端到端 P50/P99、吞吐、显存和误差。融合、split-K、更多线程都可能在某些形状上变慢,所以每次优化都要重新做正确性与边界测试,并和成熟库的实现对照。

记住一句 记住:算子优化的顺序是“先定位瓶颈,再少算少搬、提高利用率、减少调度,最后用真实负载验证”。

30″ 面试开口
算子优化不是“把 CUDA 写得更复杂”,而是先用 Roofline 判断瓶颈,再围绕四件事做取舍:减少总工作量、减少慢层级数据搬运、提高硬件执行单元利用率、减少调度与同步开销。最后必须在真实 shape 分布上验证,而不是只测一个漂亮的 microbenchmark。 Roofline 的核心上界是: 其中 I=FLOPs/Bytes。…
算子优化先诊断再开药:计算、访存和调度瓶颈的动作不同,任何改动都要在真实 shape 上同时验证速度与精度。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

算子优化不是“把 CUDA 写得更复杂”,而是先用 Roofline 判断瓶颈,再围绕四件事做取舍:减少总工作量、减少慢层级数据搬运、提高硬件执行单元利用率、减少调度与同步开销。最后必须在真实 shape 分布上验证,而不是只测一个漂亮的 microbenchmark。

Roofline 的核心上界是:

P_attainable ≤ min(P_peak, BW_sustained × I)

其中 I=FLOPs/Bytes。该模型来自 Roofline 原始论文

1)算法级:先少算、少存,再谈指令优化

  • 使用更低复杂度或更 IO-aware 的算法,例如 FlashAttention 采用在线 softmax 和 tiling,避免把完整 S×S Attention 矩阵写回 HBM;见 FlashAttention 原始论文
  • 利用稀疏性、结构化稀疏、剪枝或 Top-K,避免无效计算;但要计入索引、路由、负载不均和通信成本。
  • 推理使用 FP16/BF16/FP8/INT8/INT4 等更低精度,减少字节并使用对应 Tensor Core;量化/反量化最好融合进主 kernel。
  • 去掉重复变换和不必要的 layout conversion、padding、copy、全量中间张量。
  • 对固定系数、shape 和小矩阵做常量折叠、预计算或 weight packing。

2)HBM 访存:让每个字节更有用

  • 合并访存:一个 warp 访问连续、对齐地址,减少内存事务;SoA/AoS、leading dimension 和向量化 load/store 都要围绕实际访问模式设计。
  • 提高复用:把会重复使用的 tile 从 Global Memory 搬到 Shared Memory,再从 Shared Memory 搬到寄存器;同一份数据尽量被多个 FMA 使用。
  • 处理 bank conflict:Shared Memory 通过 padding、swizzle 或改变 tile layout,使同一 warp 的地址分布到不同 bank。
  • 异步流水:使用异步 Global→Shared 拷贝、双缓冲或多 stage pipeline,让加载下一 tile 与计算当前 tile 重叠。
  • 缓存友好:调整遍历顺序和工作分配,提高 L2/L1 命中;对反复使用的小工作集可考虑 L2 persistence/access policy,但必须测收益。
  • Host↔Device 路径使用 pinned memory 和异步 copy,在有独立 copy engine 时与 kernel 重叠;不要为很小的 copy 支付大量 launch/同步开销。

CUDA C++ Best Practices Guide 对合并访存、Shared Memory、bank conflict、异步 copy 和 Host/Device 传输有官方说明。

3)片上计算:Tiling、Tensor Core 与寄存器

以一个 tile 的 GEMM C[m,n] = A[m,k]B[k,n] 为例,忽略缓存重复加载并假设 C 不需要预读时:

FLOPs = 2mnk
最少字节 ≈ e(mk + kn + mn)
I_tile ≈ 2mnk / (e(mk + kn + mn))

若语义是 C += AB,还要再计一次旧 C 的读取,即分母中的输出项由 mn 变为 2mn

增大 m,n,k tile 通常能增加复用和算术强度,但也会消耗更多 Shared Memory 与寄存器。因此 tile 不是越大越好:寄存器溢出会落到 Local Memory,Shared Memory 太大则减少每个 SM 的并发 block 数。

常见手段包括:

  • 选择匹配 Tensor Core 指令 shape 和数据类型的 tile,保证地址与维度满足对齐要求;
  • 使用寄存器 blocking,让 accumulator 长时间留在寄存器;
  • 通过 warp specialization,让部分 warp 负责搬运、部分 warp 负责计算;
  • double/triple buffering,隐藏 HBM 或 Shared Memory pipeline 延迟;
  • 使用向量化指令和合适的 layout,减少地址计算与指令条数;
  • 控制 register pressure,避免 spill,同时保留足够 ILP(Instruction-Level Parallelism)。

4)并行划分:既要足够多的工作,也要减少归约代价

  • 大矩阵使用二维/三维 tile;小 M/N 的 GEMM 可用 split-K 或 Stream-K 增加 CTA 数,但会引入 partial result 归约。
  • Reduction 先 warp 内 shuffle,再 block 内 Shared Memory 归约,最后必要时做第二阶段 kernel;避免每个元素直接争抢一个 Global Atomic。
  • 让每个 CTA 的工作量足够大以摊薄 launch 和索引开销,但不能大到只有少数 CTA,造成 SM 吃不满。
  • 避免 warp divergence;不可避免的分支可按数据重排,把执行相同路径的线程放在一起。
  • 对长序列 Attention 沿 Query、KV sequence 和 head 共同分块,避免只在 head 维提供并行度。

5)融合:省中间张量与 launch

常见融合包括:

  • GEMM epilogue 融合 bias、activation、scale、quantize;
  • Residual + Dropout + LayerNorm/RMSNorm;
  • QKV projection 后的 reshape、RoPE、写 KV Cache;
  • Attention 中 scale + mask + online softmax;
  • MoE 中 permutation、quantize、通信前 packing 和通信后 unpermute。

假设两个 kernel 之间有一个 N 元素中间张量,单独执行至少多一次 N×e 写和一次 N×e 读;融合理论上可少 2Ne 字节,再省一次 launch。但融合也会增加寄存器、Shared Memory 和代码尺寸,可能降低 occupancy,所以必须检查资源账本。

6)调度与框架开销

  • 大量短 kernel 可用 CUDA Graph 降低 CPU launch 开销;
  • 使用 stream 做无依赖计算、通信和 memcpy 的重叠,但避免无意义的全局同步;
  • 对极小算子可采用 persistent kernel 或批处理,把多请求/多任务合在一次 launch 中;
  • 复用 workspace 和内存池,减少频繁 cudaMalloc/cudaFree
  • 多 GPU 场景让通信分块并与计算重叠,优先减少跨 PCIe/NVLink/网络的总字节。

7)正确的优化闭环

  1. 记录真实 workload 的 shape、dtype、stride、batch 和长尾分布;
  2. 建立 FLOPs、理论必要 bytes、workspace 和精度基线;
  3. 用 Nsight Systems 定位 launch/同步/通信,用 Nsight Compute 看 DRAM、L2、stall、occupancy、Tensor Core;
  4. 用 Roofline 判断下一步该减 bytes 还是提高计算利用率;
  5. 每次只改变一个主要因素,并做数值误差与边界 shape 验证;
  6. 与成熟库比较,不仅比较平均 latency,也看 P95/P99、吞吐和端到端收益。

常见追问

追问 1:occupancy 越高越好吗?

不是。occupancy 足以隐藏延迟后,继续牺牲寄存器复用去追求 100% occupancy 可能更慢。高性能 GEMM 常用大量寄存器保存 accumulator,occupancy 不一定很高,但 ILP 和数据复用很好。

追问 2:split-K 什么时候有用?

当 M/N 很小、K 很大,普通二维切分产生的 CTA 数不足以占满 GPU 时。代价是 partial output 的额外写回、归约和同步;K 不够大或原本并行度充足时可能更慢。

追问 3:融合为什么可能负优化?

融合后 live variable 更多,引发寄存器 spill;Shared Memory 增大使 resident CTA 减少;不同子算子的最佳 tile 也可能冲突。要比较省下的 HBM/launch 与新增资源压力。

追问 4:memory-bound 算子怎么优化?

优先减少 bytes、合并访存、提高缓存/片上复用、融合和降精度。单纯增加线程或 FMA 指令通常不能突破 BW×I 的 Roofline 上界。

在 Infra 过程中怎么根据硬件特性进行优化?

硬件与算子MoEGPUC++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

同一份算子代码换到另一代 GPU 上,不一定更快,因为硬件擅长的精度、矩阵形状、片上空间和互联方式都可能变了。这题考的是“硬件感知”:先认识手里的机器,再把算法的工作方式和它的长处对上,而不是只看到宣传页上的峰值 TFLOPS。

  1. 先做硬件画像:有多少 SM、支持哪些 Tensor Core 精度、HBM 实测带宽多大、每个 SM 有多少寄存器和 Shared Memory、L2 多大,以及多卡之间走 PCIe 还是 NVLink。峰值数字只是上限,实际还会受频率、功耗、MIG 配置和数据类型影响,所以要结合运行时查询与 microbenchmark。

  2. 再看负载画像:矩阵的 M/N/K、batch、数据精度、stride 和长尾形状分别是什么。用有效算力÷有效带宽得到 Ridge Point,并把算子的计算/访存比与它比较:低于分界线时优先压缩与复用数据,高于分界线时优先提升 Tensor Core 和指令利用率。Decode 小矩阵与 Prefill 大矩阵通常不会选同一套 tile。

  3. 片上资源决定一个 tile 能不能跑得好。更大的 tile 可能增加复用,却也占用更多寄存器和 Shared Memory;资源过多会降低并发,寄存器不够还会 spill 到显存。因此工程上通常准备大矩阵、小 M、批量小矩阵等多个 kernel 版本,再按 GPU 能力、shape、dtype 和布局分派。

  4. 多卡系统还要看拓扑:高频 All-Reduce 或 All-to-All 尽量放在 NVLink/NVSwitch 域内,跨节点则减少小消息并尝试计算通信重叠。最终通过离线或受控在线 autotune(自动试跑候选配置)选择最快实现并缓存结果,同时保留兼容回退和数值测试,避免只对一个演示 shape 过拟合。

记住一句 记住:硬件优化不是“按型号写 if”,而是把真实 shape 映射到算力、带宽、片上资源和互联,再用多版本实测选择。

30″ 面试开口
硬件适配的核心不是按 GPU 型号写一堆 if,而是把 算子形状与算术强度 映射到 该硬件的计算吞吐、内存层级容量/带宽、并发资源和互联拓扑,然后做多版本 kernel dispatch 与真实负载 autotune。
Hardware-aware 不是按显卡型号写死规则,而是让硬件约束与输入形状共同生成候选,再用实测结果选择并缓存。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

硬件适配的核心不是按 GPU 型号写一堆 if,而是把 算子形状与算术强度 映射到 该硬件的计算吞吐、内存层级容量/带宽、并发资源和互联拓扑,然后做多版本 kernel dispatch 与真实负载 autotune。

1)先建立硬件画像

至少收集:

  • GPU compute capability、SM 数、warp size、每 SM 最大线程/CTA;
  • 各 dtype 的可用计算路径,例如 SIMT Core、Tensor Core 以及支持的累加精度;
  • HBM/GDDR 容量与实测带宽、L2 容量、每 SM 的 L1/Shared Memory、register file;
  • 异步 copy、Tensor Memory Accelerator、warp-group matrix 指令等架构能力;
  • PCIe、NVLink/NVSwitch、GPU Direct RDMA 和网卡拓扑;
  • CPU NUMA、Pinned Memory、GPU 与 NIC/CPU socket 的亲和性;
  • 功率、频率、MIG/MPS 等运行约束。

这些值应通过 runtime/driver API、官方 tuning guide 和实测获得,而不是只抄产品峰值。CUDA 提供 cudaGetDeviceProperties、occupancy API 等查询;架构能力可参考 CUDA Programming GuideAmpere Tuning GuideHopper Tuning Guide

2)用 Ridge Point 决定优化方向

设实际可持续计算吞吐为 P_peak,实测 HBM 带宽为 BW

I_ridge = P_peak / BW
  • I < I_ridge:更可能 memory-bound,应优先减少/复用数据;
  • I > I_ridge:更可能 compute-bound,应优先提高 Tensor Core、指令与调度利用率;
  • 接近 ridge 时,数据布局、cache 命中和 pipeline overlap 都可能决定最终瓶颈。

例如一块假想 GPU 对某 dtype 的有效吞吐是 1000 TFLOP/s、实测 HBM 是 3 TB/s,ridge point 约为 333 FLOP/Byte。Decode MHA 的 FP16 KV 强度约 1 FLOP/Byte,无论怎样堆 FMA 都很难 compute-bound;应减 KV bytes、增 batch 复用权重或改 Attention 架构。

3)根据片上资源选 tile,而不是照搬另一代 GPU

一个 block 能否驻留,粗略受以下最小值约束:

resident_blocks_per_SM ≤ min(
  架构最大 blocks/SM,
  floor(max_threads_per_SM / threads_per_block),
  floor(registers_per_SM / registers_per_block),
  floor(shared_mem_per_SM / shared_mem_per_block)
)

因此同一个 tile 在不同架构上可能有完全不同结果:

  • Shared Memory 增大后可以扩大 tile 或 pipeline stage;
  • register file/分配粒度不同,可能让原 kernel 从 2 CTA/SM 降到 1 CTA/SM;
  • Tensor Core 指令 shape 和支持 dtype 不同,需要重新安排 layout、warp 数与 accumulator;
  • Ampere 上可用异步 Global→Shared copy;Hopper 可进一步用 TMA 和 warp-group 级矩阵计算。应按 capability 选择实现,而不是在不支持的架构上模拟同一条路径。

4)针对不同 shape 做多版本 dispatch

一个“GEMM kernel”至少会因以下维度需要不同策略:

  • 大 M/N/K:标准大 tile,追求 Tensor Core 吞吐;
  • 小 M、大 N/K(典型 Decode GEMM):更多 split-K/Stream-K 或 persistent 调度;
  • batched 小矩阵:合并 batch、grouped GEMM,摊薄 launch;
  • skinny/irregular shape:避免 padding 浪费,调整 warp 划分;
  • contiguous 与 strided/broadcast layout:采用不同 load 路径;
  • FP16/BF16/FP8/INT8:不同转换、缩放、累加和对齐约束。

实际系统通常采用:预编译一组候选 kernel → 按 GPU capability、shape、dtype、stride 过滤 → 离线或在线 autotune → 缓存最佳配置。线上 autotune 必须限制探测成本并保证候选的数值语义一致。

5)针对内存层级适配数据布局

  • HBM 带宽紧张:量化、压缩 KV、融合、避免 transpose/materialize;
  • L2 较大:调整 batch/调度,让共享权重、KV block 或专家权重具有时间局部性;
  • Shared Memory 足够:扩大复用 tile,但检查 bank conflict 和 occupancy;
  • register 紧张:减小 accumulator、拆 epilogue 或减少 pipeline stage,避免 Local Memory spill;
  • 支持异步搬运:让 copy 和 compute 形成 steady-state pipeline;
  • Host↔Device 较慢:把预处理下沉 GPU、使用 pinned memory 和批量异步传输。

6)根据互联拓扑选择并行策略

  • NVLink/NVSwitch 内优先放高频 All-Reduce/All-to-All 的并行组;
  • 跨 PCIe 或跨节点通信更贵,尽量减少 Tensor Parallel 的高频小消息;
  • MoE Expert Parallel 要同时看总通信量、专家负载不均和 NIC/GPU 亲和性;
  • Pipeline Parallel 通信频率较低但有 bubble,应结合 microbatch;
  • 通信 kernel 和计算 kernel 使用独立 stream/资源时,要验证是否真正 overlap,不能仅从时间线“有重叠颜色”就下结论。

7)把精度能力纳入硬件适配

  • 支持某种低精度指令,不代表模型可直接切换;要定义量化粒度、scale 格式、累加精度和溢出策略;
  • TensorFloat-32、FP16、BF16、FP8 的指数/尾数范围不同,要结合数据分布;
  • 关键 reduction、normalization、logit 或累加可以保留更高精度;
  • 确定性要求可能禁止某些最快的 atomic/reduction 算法,这也是“硬件适配”的约束,不只是正确性开关。

常见追问

追问 1:为什么不能只看标称 TFLOPS?

标称值依赖 dtype、指令模式、频率和理想利用率。真实 kernel 还受 shape、padding、指令混合、数据供给和 occupancy 限制;memory-bound 算子更与 TFLOPS 几乎无关。

追问 2:新 GPU 上旧 kernel 为什么可能更慢?

资源分配粒度、cache/shared 配置、Tensor Core 指令、调度和最优 tile 都可能变化。旧 tile 可能产生 wave quantization、寄存器瓶颈或无法使用新异步路径。

追问 3:如何证明是硬件适配而不是过拟合 benchmark?

在真实 shape 分布、并发和功率设置上报告端到端吞吐与 P95/P99;同时展示候选覆盖率、回退路径、数值测试和多架构结果。只展示一个固定 shape 的峰值不够。

同样的输入,算子会出现随机的输出吗?AtomicAdd 等操作顺序随机会带来什么问题?

硬件与算子调度GPUC++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

同样输入多跑几次,GPU 算子有时会出现最后几位不同。它通常不是凭空产生随机数,而是很多线程同时工作时,合法的完成顺序每次可能不同;浮点数每一步都会舍入,换一个相加顺序就可能得到略有差别的结果。

  1. 先理解浮点加法不满足严格的结合律。例如 (1e20 + -1e20) + 3.14 会先抵消再得到 3.14,而 1e20 + (-1e20 + 3.14) 中的小数可能先被大数吞掉,最后接近 0。数学上只是加同一组三个数,计算机逐步舍入后却不一定 bitwise(每一位)相同。

  2. atomicAdd 只保证一次“读旧值、加上增量、写回去”不会被别的线程拆开,因此不会像普通并发写那样直接丢掉这次加法。它并不规定几千个线程谁先加谁后加;不同合法顺序叠加浮点舍入,就会让末位变化。并行归约树改变、多个 stream 并发或运行库选择不同算法,也可能产生同类差异。

  3. 必须把这种数值非确定性和 race condition(数据竞争)分开。前者的每次操作都合法,只是顺序与舍入不同;后者是多个线程对同一位置做缺乏原子或同步保护的读写,属于程序错误,结果不只是几位误差,而可能任意变化。传统 atomicAdd 只有 relaxed 内存序并受 device/block/system 作用域限制;现代 cuda::atomic 等 API 才能显式选择内存序和作用域。无论哪种 atomic,都不自动提供全局顺序和完整同步。

  4. 微小差异仍可能影响 Top-K 并列、阈值判断、训练复现和回归测试。普通数值测试应按误差预算使用 atol/rtol;若业务必须逐位一致,就要固定软件与硬件环境、算法和归约树,避免无序浮点 atomic,并接受确定性路径可能更慢。提高累加精度能减小误差,但不等于固定执行顺序。

记住一句 记住:atomicAdd 保证“这一次加法不被拆开”,不保证“大家按固定次序相加”;race 则是另一类并发正确性 bug。

30″ 面试开口
同样输入可能得到 非 bitwise 一致 的输出,但这通常不是数学意义上的随机,而是并行调度、归约顺序或算法选择不固定,再叠加浮点运算不满足结合律。atomicAdd 只保证一次 read-modify-write 不被打断,并不规定不同线程的全局执行顺序;对浮点数,顺序不同会导致舍入结果不同。…
atomic 保证单次更新完整,却不固定线程到达顺序;浮点归约顺序改变会带来舍入差异,race condition 则是另一类并发错误。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

同样输入可能得到 非 bitwise 一致 的输出,但这通常不是数学意义上的随机,而是并行调度、归约顺序或算法选择不固定,再叠加浮点运算不满足结合律。atomicAdd 只保证一次 read-modify-write 不被打断,并不规定不同线程的全局执行顺序;对浮点数,顺序不同会导致舍入结果不同。

CUDA 官方对 atomicAdd 的定义和作用域见 CUDA Programming Guide:Atomic Functions。这里必须区分 API:传统 atomicAdd 一类 legacy atomic 只提供 memory_order_relaxed,默认是 device scope,_block / _system 后缀分别改变作用域;较新的 cuda::atomiccuda::atomic_ref__nv_atomic_* 才支持显式选择 C++ memory order 与 thread scope。无论哪一种,单次原子 RMW 都不能自动替代完整的同步协议。

为什么浮点顺序会影响结果

IEEE 754 浮点加法每一步都可能舍入,因此:

(a + b) + c  不一定等于  a + (b + c)

例如 FP32 中三个贡献:

a = 1e20
b = -1e20
c = 3.14

(a+b)+c → 0+3.14 → 3.14
a+(b+c) → a+(-1e20) → 0

若数千个线程对同一地址做 atomicAdd(float),硬件可能以不同合法次序串行化这些原子操作。每个操作都没有“丢写”,但最后几 bit 甚至在病态数据上更大的误差可能变化。

哪些场景会不确定

  • Global Atomic 累加、scatter-add、embedding gradient、histogram;
  • 并行 reduction 的 block 完成顺序或 reduction tree 改变;
  • 同一库在多 stream 下根据 workspace/并发选择不同内部算法;
  • 动态 shape、不同 SM 数、不同 toolkit/driver 导致 kernel 或 tiling 改变;
  • FMA contraction、不同精度路径、TF32/混合精度造成不同舍入;
  • 真正的数据竞争:非原子读写同一位置且缺少 happens-before。这不只是末位误差,而是程序错误,结果可能任意。

cuBLAS Results Reproducibility 说明:在给定 toolkit 版本、相同 GPU 架构和相同 SM 数等条件下,单 stream 路径通常保证 bitwise reproducibility;多并发 stream、toolkit 版本变化或某些模式会破坏该保证,并给出了独立 workspace/handle 等处理方式。

会造成什么问题

  • 单测若使用 == 会偶发失败;
  • Top-K、argmax 或阈值附近的微小变化可能放大为离散选择变化;
  • 训练中一步的微差会经长期迭代放大,使 loss 曲线和最终 checkpoint 不一致;
  • 分布式训练中不同 rank 若做出不一致控制流选择,严重时可能死锁;
  • Debug、回归定位和故障复现变困难;
  • 若使用低精度且累加项动态范围很大,误差可能不止 1 ULP。

如何实现确定性

  1. 固定输入、seed、shape、GPU 型号、SM/MIG 配置、toolkit、driver 和库算法;
  2. 使用固定的 reduction tree,例如 block 内固定树归约,再用第二个 kernel 按固定顺序归约 block partials;
  3. 避免多个 block 直接对同一个 FP32 输出做无序 atomic,或先按 key 排序/分组;
  4. 为每个 stream 提供独立 workspace/handle,使用库的 deterministic 配置;
  5. 必要时提高累加精度、使用 pairwise/Kahan 类求和,降低误差,但注意“误差更小”不等于“执行顺序固定”;
  6. 测试使用经过误差预算的 atol/rtol;若业务必须 bitwise 一致,则单独维护 deterministic path 并接受性能成本。

必须区分的三个概念

  • Atomicity:一次 RMW 不被别的线程拆开;
  • Ordering / synchronization:不同操作先后关系和可见性;
  • Numerical reproducibility:多次运行是否得到相同 bit pattern。

atomicAdd 解决第一项,不自动解决后两项。

常见追问

追问 1:整数 atomicAdd 也会不确定吗?

如果只有加法、没有溢出或语义定义为模加,最终和通常与顺序无关;但返回给各线程的旧值仍取决于顺序。浮点加法因不满足结合律,最终值也可能变化。

追问 2:加一个 __syncthreads() 能让 Global Atomic 顺序固定吗?

只能同步同一 block 的线程,并不能规定不同 block 的执行顺序;即便分阶段同步,同一阶段内原子操作的顺序仍未必固定。

追问 3:确定性和跨平台可复现是一回事吗?

不是。一个 kernel 在同卡同版本上可以 deterministic,但换架构、编译器或库版本后因算法/FMA/近似函数变化而不同。跨平台 bitwise reproducibility 的约束更强。

FP32 下 9.9×10^9 - 1.0×10^-9 等于多少?会溢出吗?结果是什么?

硬件与算子GPUC++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题考的不是数值有没有超过 FP32 上限,而是大数附近还剩多少精度。FP32 能表示约 3.4×10^38,所以 9.9×10^9 离溢出很远;真正的问题是它只有约 7 位十进制有效数字,数值越大,相邻两个可表示数之间的空隙也越大。

  1. FP32 用 24 个有效二进制位记录数值。9.9×10^9 落在 2^33 到 2^34 之间,在这个区间,相邻 FP32 数的间距(也叫 1 ULP)是 2^(33-23)=1024。也就是说,这附近不存在“只相差 1”甚至“只相差 0.001”的 FP32 台阶。

  2. 减去 10^-9 相当于想在宽 1024 的台阶上挪动十亿分之一,变化只有约 9.77×10^-13 个 ULP,远小于舍入到下一个台阶所需的半个 ULP。计算后仍落回原来的同一个 FP32 位型,所以结果与减法前的大数逐位相同。这叫小数被大数的舍入粒度“吞掉”,不是下溢或溢出。

  3. 还要说明大数是怎样得到的。若源码直接写 FP32 字面量 9.9e9f,它先舍入成 9,900,000,256,减去 1e-9f 后仍是 9,900,000,256;若严格执行 9.9f × 1e9f,两项先各自变成 FP32,再做一次乘法舍入,得到相邻的 9,899,999,232,之后减小数仍不变。

  4. 因此只回答“9.9×10^9”会漏掉计算路径。实际工程中,巨大量级和微小修正混算时,可考虑更高精度、重新缩放数值,或改变计算顺序;但任何办法都要先确认业务真正需要保留多少有效信息。

记住一句 记住:FP32 在 99 亿附近每格相差 1024,减 10^-9 不会溢出,只会小到无法被记录;字面量与乘法路径还会落在两个相邻结果上。

30″ 面试开口
不会溢出。 在约 9.9×10^9 的数量级,FP32 相邻数间距(1 ULP)是 1024,而 10^-9 只有约 9.77×10^-13 ULP,所以减法中的小数会被完全“吞掉”,结果与减法前的大数 bitwise 相同。…
大数附近的浮点网格很稀,小于半个 ULP 的变化会被舍入吃掉;表达式的中间舍入也可能把结果放到相邻格点。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

不会溢出。 在约 9.9×10^9 的数量级,FP32 相邻数间距(1 ULP)是 1024,而 10^-9 只有约 9.77×10^-13 ULP,所以减法中的小数会被完全“吞掉”,结果与减法前的大数 bitwise 相同。

但“减法前的大数是多少”取决于题目要求怎样进行 FP32 求值:

  • 若写成一个 FP32 字面量 9.9e9f,结果是 9,900,000,256;减去 1e-9f 后仍是该值;
  • 若严格执行两次 FP32 运算 9.9f * 1e9f,乘积先舍入为 9,899,999,232;再减 1e-9f 后仍是该值。

面试时应先说明这个求值路径差异,再给主答案。

为什么 ULP 是 1024

FP32(binary32)有效精度为 24 个二进制位(含隐藏位)。9.9×10^9 位于 binade [2^33, 2^34) 中,因此该区间的间距是:

ULP = 2^(33-(24-1)) = 2^(33-23) = 1024

round-to-nearest 下,只有变化超过约半个 ULP,也就是 512,才可能舍入到相邻 FP32。这里:

1e-9 / 1024 = 9.765625e-13 ULP

远小于半个 ULP,所以减法结果不变。

本机 C 程序实测

为避免编译器只做常量折叠,测试使用 volatile float,并打印位型:

#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

static uint32_t bits(float x) {
    uint32_t u;
    memcpy(&u, &x, sizeof u);
    return u;
}

int main(void) {
    volatile float small = 1.0e-9f;

    volatile float literal = 9.9e9f;
    volatile float r1 = literal - small;

    volatile float a = 9.9f;
    volatile float billion = 1.0e9f;
    volatile float product = a * billion;
    volatile float r2 = product - small;

    printf("literal %.0f 0x%08x, result %.0f 0x%08x\n",
           literal, bits(literal), r1, bits(r1));
    printf("product %.0f 0x%08x, result %.0f 0x%08x\n",
           product, bits(product), r2, bits(r2));
    printf("ULP(literal)=%.0f\n",
           nextafterf(literal, INFINITY) - literal);
}

实测输出:

9.9e9f                = 9,900,000,256  bits 0x50138581
9.9e9f - 1e-9f        = 9,900,000,256  bits 0x50138581

9.9f * 1e9f           = 9,899,999,232  bits 0x50138580
(9.9f*1e9f) - 1e-9f   = 9,899,999,232  bits 0x50138580

ULP                   = 1024
结果与减法前相差      = 0 ULP

差异来自 9.9f 本身先舍入为约 9.8999996185,再参与 FP32 乘法;而 9.9e9f 是把精确十进制常量 9.9×10^9 直接舍入一次到 binary32。两者恰好落在相邻的两个 FP32 数上。

是否发生 overflow / underflow

  • 没有 overflow:FP32 最大有限值约为 3.402823466×10^389.9×10^9 离它非常远;
  • 1e-9f 本身也没有 underflow:它远大于 FP32 最小正规数约 1.17549435×10^-38
  • 小数消失属于大数与小数相加减时的 rounding absorption,不是 overflow,也不是 underflow。

IEEE binary32 格式和舍入规则的权威定义见 IEEE 754-2019

C/C++ 表达式陷阱

题面中的 10^9 是数学排版。如果原样写进 C/C++,^ 表示按位异或而不是乘方,并且与浮点操作数组合会编译失败。代码中应写 1e9fpowf(10.0f, 9.0f) 或明确的乘法;本题最自然的是 9.9e9f - 1.0e-9f

常见追问

追问 1:为什么不是“精确的 9,900,000,000”?

因为该数量级的 FP32 只能表示 1024 的整数倍,9,900,000,000 不是可表示点;它会舍入到相邻的某个倍数。

追问 2:用 FP64 呢?

在约 9.9×10^9 处,binary64 的 ULP 约为 2^(33-52)≈1.907×10^-61e-9 仍小于半 ULP,所以即便 FP64,减法结果也通常与大数相同;要保留这个差值需要更高精度或改变数值表达方式。

追问 3:这是 catastrophic cancellation 吗?

不是典型的“大致相等两数相减导致有效位丢失”。这里是数量级差太大,小操作数在对齐尾数和舍入时被吸收。

讲一下 GPU 有哪些 memory?

硬件与算子GPU
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

GPU 内存可以想成“越靠近计算单元越快、但越小”的多级工作区。面试里容易混淆的是:有些名字描述程序怎样访问数据,有些名字描述真实硬件,有些又只是自动缓存;理解谁能看、能活多久、通常落在哪里,比死背一张速度表更有用。

  1. 寄存器是每个线程手边最小最快的空间,常放地址、循环变量和矩阵乘累加值;用太多会降低同一 SM 能同时运行的线程数。Shared Memory 是一个线程块共同使用的片上 SRAM,适合缓存 tile 和线程协作,但需要同步,还要避免多个线程撞到同一 bank 造成串行。

  2. Global Memory 是容量最大的设备内存地址空间,模型权重、激活和 KV Cache 通常都在这里,物理上一般由 HBM/GDDR 承载。L2 是整个 GPU 共享的硬件缓存,L1 常位于每个 SM,并可能与 Shared Memory 共享片上容量;它们通常由硬件管理,不像 Shared Memory 那样可以精确指定每个字节放在哪。

  3. 最重要的命名陷阱是 Local Memory:Local 只表示“每个线程私有”,并不表示它在芯片内部。放不进寄存器的局部数组、动态索引对象或寄存器 spill,往往会落到设备 DRAM,再经过 L1/L2,速度可能接近 Global Memory。因此看到局部变量不能自动认为很快。

  4. Constant Memory 适合小型只读且同一 warp 常读取相同地址的数据,可通过片上 constant cache 广播;Texture 路径适合特定只读和空间局部访问。Unified Memory 则是 CPU/GPU 共享虚拟内存映像与页面迁移机制,不是额外的一块高速显存;页面故障和搬迁仍可能成为瓶颈。

  5. Blackwell 的第五代 Tensor Core 还带有专用片上 Tensor Memory(TMEM),主要保存特定矩阵操作数和累加结果。单 CTA 模式由其中一个 warp 动态申请;部分 tcgen05 指令还支持一对 peer CTA 各出一个 warp 共同申请和访问,所有分配都必须在 kernel 结束前释放。它是架构特定能力,不是所有 GPU 都有的通用层级;也不要把它和负责搬数据的 TMA,或存放在 device global memory 的 CUDA C++ __tile__ 变量混为一谈。

记住一句 记住:Register/Shared 靠近计算,Global 容量大,L1/L2 自动缓存;Local 只是线程私有。Blackwell TMEM 则是第五代 Tensor Core 的架构特定片上存储,可按单 CTA 或受支持的 CTA pair 粒度使用。

30″ 面试开口
CUDA 中要区分 编程可见的 memory space 与 物理存储/缓存层级。Global、Local、Shared、Constant、Texture 是地址空间或编程抽象;HBM/GDDR、register file、片上 SRAM 是物理实现;L1/L2 是缓存,不是需要 malloc 的独立地址空间;…
先区分编程抽象与物理路径;Blackwell TMEM 只服务第五代 Tensor Core 的特定指令,可按单 CTA 或受支持的 CTA pair 粒度使用,不能和 TMA、CUDA C++ 的 __tile__ 变量或通用 Shared Memory 混用。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

CUDA 中要区分 编程可见的 memory space物理存储/缓存层级。Global、Local、Shared、Constant、Texture 是地址空间或编程抽象;HBM/GDDR、register file、片上 SRAM 是物理实现;L1/L2 是缓存,不是需要 malloc 的独立地址空间;Unified Memory 是虚拟内存与迁移机制,也不是另一块物理显存。

CUDA 官方层级和作用域见 CUDA Programming Guide:GPU MemoryWriting CUDA SIMT Kernels:Memory Spaces

主要 memory space

类型可见范围 / 生命周期典型物理位置特点与用途
Register每线程 / kernelSM register file最低延迟;保存标量、地址、accumulator。使用过多降低并发,spill 后进入 Local Memory
Tensor Memory(Blackwell 第五代 Tensor Core)单 CTA,部分 tcgen05 指令可按 CTA pair 协作 / kernel 内显式释放Tensor Core 专用片上存储tcgen05 指令族承载特定矩阵操作数与 accumulator;是架构和指令集特定能力,不是所有 GPU 都有的通用 memory space
Local Memory每线程 / kernelDevice DRAM,经 L1/L2“Local”只表示线程私有,不表示片上;动态索引数组、大对象、寄存器 spill 常落这里
Shared Memory每 block / block 生命周期SM 上的 SRAM软件管理的 tile/cache、线程交换和归约;需要同步;bank conflict 会串行化访问
Distributed Shared Memorythread-block cluster同一 cluster 内多个 SM 的 Shared Memorycompute capability 9.0 起可让 cluster 内 block 访问彼此 Shared Memory,仍需 cluster 级同步
Global Memory所有线程 / allocation 生命周期GPU HBM 或 GDDR,经 L2/L1容量最大,模型权重、激活、KV Cache 的主要存储;需要合并访存与复用
Constant Memorygrid 可见 / CUDA contextDevice Memory + 每 SM constant cachekernel 只读,典型总空间约 64 KiB;warp 读同一地址可广播,不同地址会按不同请求序列化
Texture Memorygrid 可见 / allocation 生命周期Device Memory + texture cache只读访问路径,适合特定空间局部性,并支持寻址模式、格式转换和过滤
Surfacegrid 可见Device Memory与 CUDA array/图像对象相关的可读写路径,常用于图像类访问

Cache 层级

  • L1 / Unified Data Cache:每个 SM 独有,很多架构上与 Shared Memory 共享物理容量或可配置比例;
  • L2:全 GPU 的 SM 共享,是 Global/Local 等访问的重要缓存,也是不同 SM 之间数据交汇的层级;
  • Constant Cache:每 SM 独立,服务 constant/参数访问;
  • Texture Cache、Instruction Cache:服务对应访问类型。

Cache 一般由硬件管理,Shared Memory 由程序显式分配和组织。说“把数据放到 L2”通常只是通过访问策略提高驻留概率,而不像 Shared Memory 那样精确控制每个字节。

Blackwell 的 Tensor Memory 不是通用“第六级内存”

PTX ISA 对第五代 Tensor Core 的说明新增了专用片上 Tensor Memory(TMEM)。使用 .cta_group::1 时,由当前 CTA 的一个 warp 执行 tcgen05.alloc / tcgen05.dealloc;使用 .cta_group::2 的相关指令时,则由一对 peer CTA 各出一个 warp 共同分配或释放,并可访问这两个 CTA 的 TMEM。所有已分配 TMEM 都必须在 kernel 退出前显式释放。它与 register file、Shared Memory 都不同,也不能外推到 H100 等不具备该指令族的 GPU;更准确的说法是“Blackwell 上服务第五代 Tensor Core、具有单 CTA 与 CTA-pair 粒度的架构特定存储”。

三个相似名字不能混用:

  • Tensor Memory(TMEM) 是上面的片上存储;
  • Tensor Memory Accelerator(TMA) 是搬运多维 tensor 的异步数据传输引擎,不是一块存储;
  • 截至 CUDA 13.3,CUDA C++ 的 __tile__ 变量 被分配在 device global memory;这里说的是变量的 memory-space specifier,不是同名的函数 execution-space specifier,也不能把它解释成 TMEM。

精确定义见 PTX ISA:Fifth Generation TensorCore / Tensor Memory

容易混淆的两类内存

Local Memory 为什么可能很慢

局部数组虽然写在 kernel 函数体内,但如果无法放进寄存器,会进入 Local Memory。其物理 backing 在 Device Memory,延迟/带宽特征接近 Global Memory,只是 CUDA 按线程 ID 布局,某些相同相对偏移访问可合并。

可通过编译器的 register/local 使用报告和 profiler 的 local load/store 指标检查 spill。

Unified Memory 是什么

cudaMallocManaged 或系统分配场景提供 CPU/GPU 可访问的统一内存映像,由 runtime/驱动按需迁移或建立访问。它解决可编程性、过量订阅和一致地址空间问题,但物理页某一时刻仍位于 GPU Memory 或 System Memory;Page Fault 和迁移可能成为性能瓶颈。

Host 侧相关内存

  • Pageable Host Memory:普通 CPU 内存,异步传输通常需要 runtime 先做 staging;
  • Pinned Host Memory:页锁定内存,可获得更高、更稳定的 DMA 带宽,并支持真正的异步 H2D/D2H;
  • Mapped / Zero-copy Host Memory:GPU 可直接访问 System Memory,省显式 copy,但经 PCIe/互联访问,适合少量、一次性或集成式系统场景,不适合反复随机访问替代 HBM。

常见追问

追问 1:速度排序是不是 Register > Shared > L1 > L2 > HBM?

可作为粗略直觉,但不能当严格延迟表。命中、bank conflict、合并程度、并发、访问依赖和架构都会改变结果。Shared 的 bank conflict 访问甚至可能被严重串行化。

追问 2:const 变量一定在 Constant Memory 吗?

不一定。C++ 的 const 是类型/可修改性限定,不是 CUDA 地址空间限定;只有显式 __constant__、kernel parameter path 或编译器判定才可能走 constant memory/cache。

追问 3:L1 和 Shared 谁更快?

不能脱离模式比较。Shared 可显式复用、可预测,但需避免 bank conflict;L1 自动缓存,hit 时方便但驻留不可精确保证。高性能 kernel 常两者一起使用。

两个进程能同时操作同一个 GM 地址吗?GM 有虚拟地址吗?

硬件与算子KV CacheGPU
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

GM 指 GPU 的 Global Memory。它同样有虚拟地址:cudaMalloc 返回的是当前进程可以使用的设备虚拟地址,GPU 的内存管理单元再把它映射到物理显存。因此“两个进程看到相同的指针数字”和“两个进程访问同一块物理显存”不是一回事。

  1. 默认情况下,每个进程有自己的 CUDA context 和地址映射。把进程 A 的 64 位裸指针直接发给进程 B,B 的页表里通常没有这段映射,即使恰好出现相同的十六进制数字,也不代表同一物理页。地址只是进程内的坐标,还没有携带共享权限和 allocation 生命周期。

  2. 需要共享时,可用 CUDA IPC:A 创建显存 allocation 并导出一个可共享 handle,B 导入 handle 后获得自己进程中有效的 device pointer。也可用 VMM(虚拟内存管理)把“预留虚拟地址、创建物理 allocation、建立映射、设置访问权限”明确拆开。两个进程最终可映射同一物理 allocation,但各自的虚拟地址允许不同。还要留意 cudaMalloc 可能做底层子分配:官方建议 legacy IPC 只共享大小按 2 MiB 对齐的 allocation,避免意外暴露同一底层块里的其他数据。

  3. 映射成功只解决“双方都能碰到这块内存”,不解决“同时操作一定正确”。两边写不同区域通常可并行;一边写一边读同一区域,要用跨进程事件、外部 semaphore 或 host IPC 建立先后;双方写同一位置则需要合适作用域的原子操作或更高层协议,否则仍是数据竞争。普通 stream 顺序只约束自己的 stream。

  4. 还要区分 UVA、Unified Memory 与 IPC/VMM:UVA 统一一个进程里 Host 和多 GPU 的地址管理,Unified Memory 增加页面迁移与一致内存映像,IPC/VMM 才负责把 allocation 的共享能力交给另一进程。MPS 也主要改善多进程调度,并不会自动让所有进程的指针互通。

记住一句 记住:GM 有虚拟地址;跨进程要共享“物理 allocation 的 handle”并各自映射,随后还必须另外设计同步。

30″ 面试开口
GM 有虚拟地址。 cudaMalloc 返回的是当前进程 CUDA 地址空间中的 Device Virtual Address,GPU MMU 再把它映射到物理显存。两个进程默认拥有独立 CUDA context/地址空间,不能把进程 A 的裸 device pointer 直接交给进程 B;…
跨进程共享的是底层 allocation / memory object,不要求两个进程看到相同虚拟地址;并发正确性仍需同步协议。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

GM 有虚拟地址。 cudaMalloc 返回的是当前进程 CUDA 地址空间中的 Device Virtual Address,GPU MMU 再把它映射到物理显存。两个进程默认拥有独立 CUDA context/地址空间,不能把进程 A 的裸 device pointer 直接交给进程 B;但可以通过 CUDA IPC 或 VMM 导出/导入同一个物理 allocation,各进程获得自己的有效 device pointer 后并发访问。

CUDA 官方明确说明普通 device pointer 在创建它的进程之外无效;跨进程需要 IPC/VMM handle,见 CUDA Programming Guide:Interprocess Communication

“同一个地址”要拆成三层

  1. 同一个数值形式的 pointer:两个进程里都看到 0x...,不代表同一物理页;进程页表不同;
  2. 同一个物理 GM allocation:通过 IPC/VMM share handle 映射,可以做到;
  3. 同一个逻辑对象且并发正确:除了共享 allocation,还必须有生命周期、访问权限和同步协议。

Legacy CUDA IPC 的典型流程

进程 A:cudaMalloc
      → cudaIpcGetMemHandle
      → 通过 Unix socket / shared memory 把 handle 传给进程 B

进程 B:cudaIpcOpenMemHandle
      → 得到本进程内有效的 device pointer
      → kernel 访问同一底层 allocation

结束:B cudaIpcCloseMemHandle
     → A 确认无人使用后 cudaFree

不同进程对同一 IPC handle 调用 open,不保证得到数值相同的虚拟地址。程序不应把 pointer 数值相等作为共享成功的判断条件。

Legacy CUDA IPC 主要用于 Linux;Managed Memory 不能直接用这套 legacy IPC handle 分享。具体平台和设备限制以运行时文档为准。

还要注意 allocation 边界:cudaMalloc() 可能从更大的底层内存块做子分配,导出 IPC handle 时有机会把同一底层块中的其他子分配一并暴露给接收进程。CUDA 官方因此建议只共享大小按 2 MiB 对齐的 allocation;安全敏感场景更适合用 VMM 显式创建、授权和映射可共享 allocation。这是跨租户隔离问题,不只是性能细节。

VMM 如何说明“GM 有虚拟地址”

CUDA Driver VMM API 把过程显式拆为:

cuMemAddressReserve   # 只保留一段虚拟地址
cuMemCreate           # 创建物理 allocation handle,尚未映射
cuMemMap              # 把物理 allocation 映射到 VA
cuMemSetAccess        # 为设备设置访问权限

跨进程时,导出方用 cuMemExportToShareableHandle,接收方用 cuMemImportFromShareableHandle,再把同一 allocation 映射进自己的 VA 范围。官方流程见 CUDA Programming Guide:Virtual Memory ManagementCUDA Driver API:Virtual Memory Management

VMM 允许每个进程选择自己的 VA;即便刻意尝试保留相同数值的 VA,也不应让协议依赖这一点。

两个进程能否“同时写”

可以同时发 kernel 访问同一 allocation,但正确性分情况:

  • 写不同、不重叠的区域:通常可以并行;
  • 一个写、一个读同一区域:需要跨进程事件/外部 semaphore/host IPC 建立先后和可见性;
  • 同时写同一位置:普通 load/store 是数据竞争;需要支持相应 scope 的 atomic 或更高层同步,并验证目标设备和 memory type 的原子语义;
  • 普通 CUDA stream 顺序只约束本 stream,不能自动约束另一个进程的 stream。

Legacy IPC 可以共享以 cudaEventInterprocess 创建的 event handle,接收进程可在 stream 中等待;VMM 只解决 memory mapping,本身不提供完整生产者/消费者同步。

UVA、Unified Memory 和 IPC 的区别

  • UVA(Unified Virtual Addressing):在一个进程的统一虚拟地址空间里区分/管理 Host 与多个 GPU allocation;
  • Unified Memory:提供统一内存映像、按需迁移/映射和一致性机制;
  • IPC/VMM sharing:把 allocation capability 安全地传给另一个进程,并在对方地址空间建立映射。

“有 UVA”不等于另一个进程能使用我的裸 pointer。

常见追问

追问 1:MPS 会让两个进程天然共享 pointer 吗?

不会。MPS 主要共享 GPU 调度/执行服务,不是把所有客户端地址空间和 allocation 自动变成共享内存。显式共享仍应使用受支持的 IPC/VMM 机制。

追问 2:两个进程映射同一 allocation 后,L2 是否一致?

GPU 对受支持的共享映射提供其内存模型定义的可见性,但必须通过正确同步建立 happens-before;不能把“共享 L2”当成同步原语。

追问 3:为什么不能只传 64 位地址?

地址只在创建它的进程/context 映射中有意义,且没有携带 allocation 权限与生命周期。IPC/VMM handle 才是可移植的共享能力描述。

算子中的常量参数是放在哪个 memory 的?

硬件与算子GPUC++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

“算子里的常量放哪”没有一个统一答案,因为“常量”可能指启动 kernel 时传入的数字、小型全局表、源码字面量,也可能只是一个不能通过当前指针修改的大权重。C/C++ 的 const 主要约束代码的修改权限,并不会自动把数据搬进 CUDA Constant Memory。

  1. 像 n、alpha、以及 x/y 的指针值这类 kernel 参数,会放在 kernel parameter space,通常通过 constant-memory/constant-cache 路径读取。注意只有指针这个地址值属于参数;x 指向的整块数组仍在它实际分配的位置,通常是 Global Memory,写成 const float* 也不会改变这一点。

  2. 显式声明为 __constant__ 的设备变量,适合容量小、长期只读、很多线程反复使用的小表或系数。一个 warp 的线程读取同一地址时,constant cache 可以一次广播给大家;若 32 个线程读取许多不同地址,访问会被拆分,未必比合并的 Global Memory 读取快。逻辑 Constant Memory 也不是整块都常驻片上,它仍有设备内存 backing,只是在 SM 上配有小缓存。

  3. constexpr、数字字面量或局部 const 可能被编译器直接编码进指令、放进寄存器,或在常量折叠后完全消失。若局部对象大且寄存器压力高,也可能进入 Local Memory。放置由编译器与使用方式共同决定,不能看到 const 关键字就下结论。

  4. 大型模型权重即使只读,也通常仍在 HBM 的 Global Memory,因为 Constant Memory 容量太小。优化它们靠 L2/L1 命中、连续读取、分块复用和量化,而不是改一个 const。回答这题时先问清是哪类常量,再分别说明“地址值在哪里”和“指针指向的数据在哪里”。

记住一句 记住:const 决定“能否经这条路径修改”,不决定存储;参数、__constant__、字面量和大型只读权重要分别判断。

30″ 面试开口
如果题目指 kernel launch 时传入的标量/小结构参数,它们位于 kernel parameter space,编译器通常通过 constant-memory/constant-cache 路径访问;如果是显式声明的全局只读常量,应使用 constant。…
源码 const 只约束修改权限;数据最终落在何处,要看大小、传入方式和线程访问模式。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

如果题目指 kernel launch 时传入的标量/小结构参数,它们位于 kernel parameter space,编译器通常通过 constant-memory/constant-cache 路径访问;如果是显式声明的全局只读常量,应使用 __constant__。但普通 C++ const 并不决定物理位置:字面量可能编码进指令,局部常量可能进寄存器,const T* 指向的数据仍可能在 Global Memory。

CUDA Programming Guide:GPU Memory 明确说明编译器可把 kernel parameters 放入 constant memory;__constant__ Memory 描述了其作用域、生命周期和典型容量。

按常量类型分类回答

1)Kernel 参数
__global__ void scale_kernel(float* y, const float* x,
                             int n, float alpha) {
    // n、alpha 以及两个 pointer 值属于 kernel 参数
}
  • nalphaxypointer 值本身 由 launch 参数传入,通常经 parameter/constant cache 路径读取;
  • x 指向的数组仍在它实际分配的 memory 中,通常是 Global Memory;const float* 只表示 kernel 不应通过该指针修改数据;
  • 较大的只读按值参数可使用 CUDA 的 __grid_constant__ 注解,避免编译器为每线程创建副本,具体语义见 CUDA __grid_constant__ Parameters
2)显式 Device 常量
__constant__ float coeff[64];

// host 侧初始化
cudaMemcpyToSymbol(coeff, host_coeff, sizeof(host_coeff));

它具有 CUDA context 生命周期,每个 device 有自己的对象,kernel 中只读。适合所有线程反复读取相同或少量地址的小表、scale、卷积系数等。

constant cache 的最佳模式是一个 warp 的线程读取同一地址,此时可广播;若 32 个线程读取许多不同 constant 地址,请求会按不同地址拆分,可能比合并的 Global/L2 读取更差。

3)编译期字面量和局部常量
constexpr int TILE = 128;
const float inv_sqrt_d = 0.0883883476f;

编译器可能:

  • 把数编码为 instruction immediate;
  • 在使用前 materialize 到寄存器;
  • 常量折叠后完全消失;
  • 若局部对象较大或寄存器压力高,放到 Local Memory。

不能仅凭源码中的 const 推断它在 Constant Memory。

4)大型只读权重或查找表

Constant Memory 容量小,不适合模型大权重。大型只读张量通常仍放 Global Memory,依赖 L2/L1/read-only/texture 路径和 tiling 复用;若每线程索引不同,Global Memory 的合并访问往往比 constant cache 的分歧访问更合适。

常见追问

追问 1:Constant Memory 是片上的吗?

逻辑 constant memory 驻留在 device memory,SM 上有较小的 constant cache。命中时很快,但不能把整个典型 64 KiB constant address space 都理解成每个 SM 各有一份片上存储。

追问 2:为什么同地址广播快?

一个 warp 对同一 constant 地址的访问可以由一次 cache 请求广播给所有线程;访问 N 个不同地址通常需要拆为 N 组请求。

追问 3:kernel 参数过多怎么办?

可以打包小结构,但仍要关注 parameter limit 与访问方式;大型数据应传 pointer,不应把整个 tensor 按值作为参数。pointer 在 parameter space,pointee 在 Global/Shared 等实际地址空间。

单核什么情况下会出现两个线程比一个线程的耗时更短?

硬件与算子GPU算法
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这里的“单核两个线程”通常指支持 SMT/超线程的一个物理 CPU 核,同时保留两套线程状态并交错发射指令。它没有变成两个完整核心,而是让两个线程共享执行单元、缓存和带宽;能不能更快,取决于一个线程是否经常让这些资源闲着。

  1. 现代 CPU 一次能并行执行多条不同类型的指令,但单线程可能因为缓存未命中、长依赖链、分支预测失败或高延迟指令而停顿。线程 A 等待数据时,线程 B 若有准备好的指令,就能填进原本空着的执行槽。这样完成两份工作的总 wall time 可能缩短,这种收益本质上是“填泡”,不是算力翻倍。

  2. 还要先定义你在比较什么。两个独立任务的聚合吞吐可能提高,即单位时间完成更多请求;但两个线程争用资源后,每个任务自己的延迟可能变长。把一个任务拆成两个线程时,还必须有足够可并行工作,并扣除拆分、锁、同步和缓存一致性开销。

  3. 如果一个线程已经打满向量计算端口、load/store 单元、缓存或内存带宽,第二个线程没有空槽可用,只会增加争抢。两个 working set 互相挤出缓存、false sharing、锁竞争、任务太短或全核频率下降,也都可能让结果更慢。所以“memory-bound”也要区分是在等单次延迟,还是总带宽已经饱和。

  4. 验证时要把两个线程固定到同一个物理核的两个 SMT sibling,分别测单线程延迟、双线程单任务延迟和总吞吐,并观察 IPC、cache miss、内存带宽与频率。还应和“两个物理核各跑一个线程”对照,否则很容易把多核收益误说成超线程收益。

记住一句 记住:SMT 用第二个线程填补第一个线程的停顿,常提升总吞吐,却不保证降低单个任务延迟;资源已饱和时反而会更慢。

30″ 面试开口
如果“单核”指一个支持 SMT/Hyper-Threading 的物理 CPU Core,两个硬件线程在 单线程无法填满执行资源、经常因 cache miss、依赖或分支停顿 时,可以用另一个线程的就绪指令填补空槽,从而提高聚合吞吐、缩短完成两份工作的总时间。若单线程已经饱和关键执行端口、内存带宽或 cache,两线程通常无收益,甚至因资源竞争更慢。…
SMT 用第二个线程隐藏第一个线程的等待,通常改善总吞吐;两线程仍共享缓存、执行单元与带宽,单任务延迟未必降低。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

一句话结论

如果“单核”指一个支持 SMT/Hyper-Threading 的物理 CPU Core,两个硬件线程在 单线程无法填满执行资源、经常因 cache miss、依赖或分支停顿 时,可以用另一个线程的就绪指令填补空槽,从而提高聚合吞吐、缩短完成两份工作的总时间。若单线程已经饱和关键执行端口、内存带宽或 cache,两线程通常无收益,甚至因资源竞争更慢。

SMT 的原始机制是让多个线程在同一周期向 superscalar core 的多个执行单元发射指令,见 Tullsen、Eggers、Levy 的 Simultaneous Multithreading: Maximizing On-Chip Parallelism(ISCA 1995)

先明确“更快”比较的是什么

  • 两个独立任务各跑一遍:比较一个线程串行完成两份任务,与两个 SMT 线程并行完成两份任务的 wall time;
  • 一个任务拆成两个线程:除了 SMT 是否能填泡,还要求任务可并行,并计入拆分、同步和 cache coherence;
  • 单请求 latency:SMT 开第二线程后,单个请求可能反而变慢,但单位时间完成的请求总数变多;
  • 若 CPU 没有 SMT,同一时刻物理核只能执行一个线程,两个 runnable software thread 只会时间片切换,一般不会让纯计算更快。

两线程可能更快的条件

1)单线程存在长延迟 stall
  • LLC/DRAM miss、TLB miss,且单线程自身 memory-level parallelism 不足;
  • 长指令依赖链,后续指令无法发射;
  • 分支错误预测造成 pipeline bubble;
  • 某类高延迟指令使部分执行资源空闲。

当线程 A 等数据时,线程 B 可以发射就绪指令,隐藏部分延迟。

2)两个线程使用互补的执行资源

例如线程 A 偏整数/分支,线程 B 偏向量或 load/store,且没有共同打满同一个 bottleneck port。SMT 可以提高 issue width、execution port、ROB/queue 等资源的整体利用率。

3)还有带宽和缓存余量

两个线程增加 outstanding memory request,可能提高 memory-level parallelism;前提是内存带宽尚未饱和,两个 working set 不会把彼此挤出 cache,也没有严重 TLB/cache contention。

4)一个线程会阻塞或睡眠

若任务包含 I/O、锁等待或 page fault,另一个软件线程可在同一物理核被调度运行。即使没有 SMT,这也能减少物理核空闲时间;但这属于调度隐藏阻塞,不是两个纯计算线程同时执行。

一个简单的吞吐上界直觉

设单线程只利用了关键 issue/执行资源的比例 u,忽略额外争用时,两个相同线程的理想加速上界可粗略写为:

Speedup ≤ min(2, 1/u)
  • u≈0.5,理论上有接近 2× 的填泡空间;
  • u≈0.9,即使其他条件理想,上界也只有约 1.11×;
  • 实际还要扣除共享 cache、队列、带宽、同步和频率下降,因此通常低于该上界。

这不是严格性能模型,但适合面试中说明为什么“一个核有两个逻辑线程”不等于“性能翻倍”。

两线程通常更慢的情况

  • 单线程已经打满向量/FMA、load/store port 或 retirement bandwidth;
  • 已经达到内存带宽上限,第二线程只增加排队;
  • 两个 working set 争抢 L1/L2/TLB,cache miss 增多;
  • false sharing、锁竞争、barrier 和原子操作很多;
  • 两线程导致每线程可用 ROB、queue 或其他共享资源减少;
  • 功率/温度限制使 all-thread 频率下降;
  • 任务太短,线程创建、调度和同步开销大于节省时间。

如何验证

  1. 把两个线程 pin 到同一个物理核的两个 SMT sibling,而不是两个不同物理核;
  2. 分别测单线程 latency、双线程单任务 latency、aggregate throughput;
  3. 观察 IPC、执行端口利用率、cache/TLB miss、memory bandwidth、上下文切换和频率;
  4. 与“两个物理核各一个线程”对照,避免把 SMT 收益误认为多核收益;
  5. 使用足够长且稳定的 steady-state workload,排除线程创建和 turbo 波动。

常见追问

追问 1:GPU 的一个 CUDA Core 上两个线程也是这个逻辑吗?

GPU 更常以 warp 为调度单位,通过大量 resident warp 隐藏延迟;它与 CPU SMT 的共享 superscalar core 概念相似但不等同。回答前应先确认面试官说的是 CPU 物理核、GPU SM,还是 CUDA Core。

追问 2:为什么两个线程的单任务 latency 变长但总耗时变短?

两个线程共享资源,每个任务可能获得更少执行带宽,所以 individual latency 上升;但原本空闲的执行槽被利用,aggregate throughput 上升,完成两份任务的总 wall time下降。

追问 3:memory-bound 就一定适合 SMT 吗?

不是。若是 latency-bound 且带宽有余量,SMT 可用更多 outstanding request 隐藏延迟;若已经 bandwidth-bound,第二线程通常无益。

03

CHAPTER

C++

6 QUESTIONS · #

const int A 和 int const B 有什么区别?

C++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

const int* A 和 int const* B 没有区别,两种写法都表示“指向只读 int 的指针”。这里的“只读”是从这根指针的访问视角看:A、B 自己可以改成指向别处,但不能写 *A 或 *B,理解 const 到底贴着数据还是贴着指针是本题核心。

  1. 可以把指针想成一张写着地址的便签,*A 是便签指向的盒子。const 写在星号左边时修饰 int,所以可以把 A=&x 改成 A=&y,却不能通过 *A=3 改盒子内容。const int* 与 int const* 只是单词顺序不同,类型完全等价。

  2. “不能通过 A 修改”不等于对象永远不会变化。若 x 原本是普通 int,程序仍可直接写 x=3,或通过另一个 int* 修改它,之后 *A 也会读到新值;A 只是没有写权限。若对象本身定义为 const int,则任何试图用 const_cast 强行修改它的做法都可能产生未定义行为。

  3. const 在星号右边时修饰指针本身:int* const p 表示地址便签被钉住,p 不能改指向,但普通 int 可通过 *p 修改。const int* const q 则两边都 const,既不能让 q 改指向,也不能经 q 修改所指内容。读声明时可以从变量名向外看,紧挨变量名的 const 通常先约束指针。

  4. 函数接口里这种区别很重要。const T* 常用于承诺函数不经该指针修改输入;T* const 作为按值形参通常只限制函数内部那份指针副本,对调用者接口意义较小。把数据只读和指针固定分开表达,能避免误以为 const 自动让对象线程安全或永久不变。

记住一句 记住:const 在 * 左边管“指向的数据”,在 * 右边管“指针地址”;const int* 和 int const* 完全相同。

30″ 面试开口
没有区别。
从变量名向左读:星号左边的 const 约束经指针访问的内容,星号右边的 const 锁住指针保存的地址。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:没有区别。 两者都是“指向只读 int 的指针”,const 修饰的是 int,不是指针本身。

const int* A = nullptr;
int const* B = nullptr;

它们都满足:

  • 可以改变指针,让它指向另一个 int
  • 不能通过该指针修改所指对象;
  • 所指对象不一定真的是常量,只是经由这个指针不能修改。

下面的代码可以编译:

#include <iostream>

int main() {
    int x = 1;
    int y = 2;

    const int* A = &x;
    int const* B = &y;

    A = &y;       // 可以修改 A 自身
    B = &x;       // 可以修改 B 自身
    // *A = 3;    // 编译错误:不能通过 A 修改所指对象

    x = 3;        // 可以直接修改 x,因为 x 本身并不是 const
    std::cout << *A << ' ' << *B << '\n'; // 2 3
}

容易混淆的是下面两种声明:

int* const p = nullptr;        // p 是常量指针:p 不能改,*p 可以改
const int* const q = nullptr;  // 指针和所指内容经由 q 都不能改

读复杂声明时可以从变量名向外读:int * const p 中,紧挨 p 的是 const,所以 p 自身是常量;const int *p 中,const 修饰的是 int

A 指向 1,B 指向 2,交换 A 和 B,1 和 2 是常量

C++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题的关键是交换 A、B 保存的地址,而不是把常量 1 改成 2、把 2 改成 1。A 和 B 必须是“自己可以改、但指向只读整数”的 const int*;交换后只是两张地址便签换了内容,两个常量对象从头到尾都没有被写入。

  1. 整数文字 1 和 2 是字面量,不能写成 &1、&2 直接取地址。应先创建有存储位置的对象:const int one=1; const int two=2;,再令 const int* A=&one; const int* B=&two;。此时 *A 读到 1,*B 读到 2。

  2. 调用 std::swap(A, B) 会交换两个指针值。交换后 A 指向 two、B 指向 one,所以 *A 是 2、*B 是 1;one 仍等于 1,two 仍等于 2。整个过程不需要也不应该使用 const_cast,因为本题没有修改常量的需求。

  3. 若声明成 const int* const A,星号右边的第二个 const 会把 A 自身锁定,地址不能换,因此无法完成交换。若自己写函数,参数按值写成 void swap(const int* a, const int* b) 只会交换函数内的副本;要影响调用者,应传指针引用 const int*& a、const int*& b,或者直接在调用处用标准库 std::swap。

  4. 从所有权角度看,这只是两个非拥有指针改指向,不会复制或销毁对象。前提是 one、two 的生命周期覆盖 A、B 的使用范围;如果指向已经销毁的局部对象,即使 const 类型写对了,仍会成为悬空指针。

记住一句 记住:先创建 const 对象,再用可改指向的 const int* 保存地址;std::swap 换的是地址,不是常量内容。

30″ 面试开口
交换的是两个指针保存的地址,不是修改常量 1 和 2。
std::swap(A, B) 只互换两个指针变量保存的地址;目标常量没有被写入,也不能直接对字面量 1、2 取地址。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:交换的是两个指针保存的地址,不是修改常量 1 和 2。 因此应把 AB 声明为“可修改的、指向常量的指针”,然后使用 std::swap

注意,不能对字面量 12 直接取地址。应先创建两个有存储位置的常量对象:

#include <cassert>
#include <utility>

int main() {
    const int one = 1;
    const int two = 2;

    const int* A = &one;
    const int* B = &two;

    std::swap(A, B);

    assert(*A == 2);
    assert(*B == 1);
    assert(one == 1 && two == 2); // 两个常量对象完全没有被修改
}

交换前后关系是:

交换前:A -> one(1),B -> two(2)
交换后:A -> two(2),B -> one(1)

边界与坑:

  • 如果声明成 const int* const A = &one;,第二个 const 使 A 自身不可修改,此时不能交换。
  • const_cast 修改一个原本就被定义为 const 的对象是未定义行为,不是本题的解法。
  • 函数参数如果写成 void swap(const int* a, const int* b),参数是按值传入,函数内交换副本不会改变调用者;应使用引用 const int*& aconst int*& b,或直接在调用处使用 std::swap

C++ 拷贝构造函数是什么?

C++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

拷贝构造函数的作用是“参考一个已经存在的同类型对象,创建一个全新的对象”,典型签名是 T(const T& other)。它决定新对象怎样获得旧对象的状态,尤其当类管理内存、文件或其他资源时,直接复制每个字节往往不够安全。

  1. 先分清生命周期:T b=a; 中 b 原先不存在,所以是在构造 b,调用拷贝构造;T b; b=a; 中 b 已经活着,第二句要替换它当前的状态,调用拷贝赋值 operator=。赋值还要处理 b 原来拥有的资源和自赋值,不能把两者混成同一个概念。

  2. 如果成员都是 int、std::string、std::vector 这类具有正确值语义的类型,编译器生成的逐成员拷贝通常已经正确。最推荐的做法叫 Rule of Zero:让 RAII 类型自动负责资源获取和释放,类本身不手写拷贝、移动和析构。这样异常安全与维护成本通常都更好。

  3. 如果类直接保存一根拥有资源的裸指针,默认拷贝只会复制地址,两个对象可能同时认为自己拥有同一块内存,最终重复释放。此时拷贝构造必须明确语义:要么申请新空间并复制内容,形成独立所有权;要么使用 shared_ptr 等明确的共享所有权。通常还要配套实现析构、拷贝赋值、移动构造和移动赋值,也就是 Rule of Five。

  4. 按值传参、容器插入等场景可能需要拷贝,但现代 C++ 还会优先移动,C++17 的一些返回值场景甚至强制消除拷贝。因此“代码看起来产生了临时对象”不代表拷贝构造一定实际执行。面试答案应先讲语义,再讲编译器是否消除这次调用。

记住一句 记住:拷贝构造创建新对象,拷贝赋值改造旧对象;资源类要明确独立或共享所有权,普通类优先 Rule of Zero。

30″ 面试开口
拷贝构造函数用一个已有的同类型对象,初始化一个新的对象。
判断构造还是赋值,关键看目标对象之前是否存在;资源类型还必须明确旧资源如何处理与新对象如何拥有。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:拷贝构造函数用一个已有的同类型对象,初始化一个新的对象。 最典型的签名是:

T(const T& other);

“初始化新对象”和“给已有对象赋值”必须区分:

T a;
T b = a; // 拷贝构造:b 此前不存在

T c;
c = a;   // 拷贝赋值:c 已经存在,调用 operator=

一个可编译的例子:

#include <iostream>
#include <string>
#include <utility>

class Record {
public:
    explicit Record(std::string text) : text_(std::move(text)) {}

    Record(const Record& other) : text_(other.text_) {
        std::cout << "copy constructor\n";
    }

    Record& operator=(const Record& other) {
        std::cout << "copy assignment\n";
        text_ = other.text_;
        return *this;
    }

    const std::string& text() const { return text_; }

private:
    std::string text_;
};

int main() {
    Record a("AI Infra");
    Record b = a; // 拷贝构造

    Record c("old");
    c = a;        // 拷贝赋值

    std::cout << b.text() << ' ' << c.text() << '\n';
}

什么时候会调用?

常见场景包括:

  • 用同类型左值初始化新对象;
  • 按值传参、按值返回时,语义上需要复制对象;
  • 容器扩容或插入时需要复制元素。

但从 C++17 开始,一些按值返回和纯右值初始化场景会发生强制拷贝消除,此时即使语义上像“返回一个对象”,拷贝构造函数也可能完全不执行。不要用“打印是否出现”来判断程序是否符合值语义。

编译器会不会自动生成?

如果用户没有声明某些特殊成员函数,编译器通常可以隐式声明拷贝构造函数,并按成员逐个复制。是否生成、是否被定义为 delete,会受成员类型和用户声明的移动构造、析构函数等影响。

工程上常用两条规则:

  • Rule of Zero:优先让成员使用 std::stringstd::vector、智能指针等 RAII 类型,从而不手写拷贝、移动和析构。
  • 如果类直接管理裸资源,通常需要同时考虑析构、拷贝构造、拷贝赋值、移动构造、移动赋值,即 Rule of Five

常见错误是写成 T(T other):按值传入 other 本身就需要先拷贝,导致递归依赖;标准形式应是 T(const T&)

浅拷贝和深拷贝有什么区别?写一个拷贝构造函数,实现深拷贝

C++GPU
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题真正考的是“复制对象时,资源到底属于谁”。如果类里只有整数、字符串、`vector` 这类会自己管理资源的成员,复制通常很自然;但如果成员是一个指向堆内存的裸指针,只把指针值复制过去,并没有复制它指向的数据,这就会埋下共享修改和重复释放的问题。

  1. 先把裸指针想成一把仓库钥匙。浅拷贝只复印钥匙,因此新旧对象拿到的是同一个地址:任何一方修改仓库里的内容,另一方都会看到;两边析构时若都认为自己是主人,还可能对同一地址执行两次 `delete`,造成未定义行为。

  2. 深拷贝不是再复制一次地址,而是先按原对象的数据量申请一块新内存,再把每个元素复制进去。这样两个对象的指针地址不同、内容起初相同,之后修改或销毁其中一个都不会影响另一个;拷贝构造函数正是在“创建新对象”时完成这件事。

  3. 如果类直接拥有裸资源,只写深拷贝构造通常还不够:析构函数要释放资源,拷贝赋值要先妥善处理旧资源,移动操作还要转交所有权。`copy-and-swap` 可处理自赋值和异常安全,但实际工程更推荐用 `std::vector`、`std::string`、智能指针,让类型遵循 Rule of Zero。

  4. “深拷贝”也不是看到所有指针就一律复制目标。观察者指针本来就不拥有对象,`shared_ptr` 也明确表达共享所有权;正确做法是先定义资源的所有权和复制语义,再决定独立复制、共享,还是禁止复制。

记住一句 浅拷贝复制的是“地址”,深拷贝复制的是“所拥有的内容”;先说清所有权,才能判断哪一种复制才正确。

30″ 面试开口
浅拷贝只复制资源句柄或地址,两个对象可能指向同一份资源;深拷贝会创建一份独立资源,并复制资源内容。
浅拷贝只复制地址,容易产生共享修改和重复释放;深拷贝重新分配并复制内容,使两个拥有者彼此独立。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:浅拷贝只复制资源句柄或地址,两个对象可能指向同一份资源;深拷贝会创建一份独立资源,并复制资源内容。

假设类中保存一个裸指针:

int* data_;

编译器默认的成员复制只会复制地址。复制后两个对象的 data_ 相同,会产生两个典型问题:

  1. 修改一方会意外影响另一方;
  2. 两个析构函数对同一地址执行 delete[],造成 double free 和未定义行为。

下面给出一个完整的深拷贝实现。它不仅有拷贝构造,还实现了拷贝赋值和移动操作,使资源所有权完整:

#include <algorithm>
#include <cstddef>
#include <iostream>
#include <memory>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t size)
        : size_(size), data_(size ? new int[size]{} : nullptr) {}

    ~Buffer() {
        delete[] data_;
    }

    // 深拷贝构造:申请新空间,再复制内容。
    Buffer(const Buffer& other)
        : size_(other.size_),
          data_(other.size_ ? new int[other.size_] : nullptr) {
        if (size_ != 0) {
            std::copy_n(other.data_, size_, data_);
        }
    }

    // 拷贝赋值:copy-and-swap,兼顾自赋值和异常安全。
    Buffer& operator=(const Buffer& other) {
        if (this == &other) {
            return *this;
        }
        Buffer tmp(other);
        swap(tmp);
        return *this;
    }

    Buffer(Buffer&& other) noexcept
        : size_(std::exchange(other.size_, 0)),
          data_(std::exchange(other.data_, nullptr)) {}

    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            size_ = std::exchange(other.size_, 0);
            data_ = std::exchange(other.data_, nullptr);
        }
        return *this;
    }

    void swap(Buffer& other) noexcept {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }

    int& operator[](std::size_t i) { return data_[i]; }
    const int& operator[](std::size_t i) const { return data_[i]; }
    std::size_t size() const { return size_; }

private:
    std::size_t size_ = 0;
    int* data_ = nullptr;
};

int main() {
    Buffer a(2);
    a[0] = 10;
    a[1] = 20;

    Buffer b = a;
    b[0] = 99;

    std::cout << a[0] << ' ' << b[0] << '\n'; // 10 99
}

实际工程中更推荐直接写:

std::vector<int> data_;

std::vector 已经正确实现深拷贝、移动和析构,类可以遵循 Rule of Zero。深拷贝并不意味着所有指针成员都必须复制目标对象;例如观察者指针、共享资源和 std::shared_ptr 的语义本来就可能是共享,关键是先定义清楚所有权。

对象大小、虚函数表、继承关系分别如何影响 sizeof?

C++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题在问:一个 C++ 对象作为一块内存,里面究竟要放哪些东西。`sizeof` 不是在统计这个类写了多少代码,而是在统计每个对象实例本身需要占多少字节;成员布局、继承、对齐和多态实现都会影响结果。

  1. 先放进对象真正携带的数据:非静态数据成员和基类子对象都属于实例的一部分;静态数据成员属于整个类共享,不放进每个对象。成员函数、普通函数和虚函数的机器码也只保存一份,不会因为创建一万个对象就复制一万份。

  2. 接着考虑对齐。CPU 往往希望某类数据从特定倍数的地址开始,因此编译器会在成员之间或对象末尾塞入 padding(填充字节)。所以 `char` 加 `int` 不一定等于 1+4 字节,成员顺序不同也可能改变最终大小。

  3. 在主流 ABI(编译器约定的二进制布局规则)里,含虚函数的对象通常会多一个隐藏的 `vptr`,它指向类共享的 `vtable`。这是常见实现而非 C++ 标准强制;整张虚表不在每个对象里,增加虚函数通常只增加共享表项,不会逐个放大对象。

  4. 继承时要展开基类部分;多重继承可能需要多个 `vptr`,虚继承也可能增加定位虚基类的隐藏状态。`sizeof(*basePtr)` 看的是表达式的静态类型,不会因为它实际指向派生对象就自动变大;精确字节数最终依赖架构、ABI、编译器和编译选项。

记住一句 `sizeof` 数的是“实例内的数据、基类部分、隐藏状态和对齐”,不数函数代码,也不把共享的整张 vtable 塞进每个对象。

30″ 面试开口
sizeof 计算的是对象本身占用的字节数,包括非静态数据成员、基类子对象、对齐填充,以及实现为支持多态或虚继承加入的隐藏状态;它不包含成员函数代码,也不把整张虚函数表算进每个对象。
sizeof 统计实例内布局,不统计函数代码和整张虚表;具体隐藏字段与字节数取决于编译器 ABI。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:sizeof 计算的是对象本身占用的字节数,包括非静态数据成员、基类子对象、对齐填充,以及实现为支持多态或虚继承加入的隐藏状态;它不包含成员函数代码,也不把整张虚函数表算进每个对象。

标准能保证什么?

  • 非静态数据成员属于对象存储的一部分;静态数据成员不属于。
  • 成员函数和虚函数的机器码不存放在每个对象中。
  • 派生类对象包含其非虚基类子对象;虚基类在最派生对象中通常只有一份。
  • 对象要满足对齐要求,因此成员之间和对象尾部可能有 padding。
  • 空类的 sizeof 至少为 1,以保证不同完整对象可以有不同地址;但空基类可能触发 Empty Base Optimization,不额外占空间。

vptr / vtable 的常见实现

C++ 标准没有规定必须存在 vptrvtable。在主流 Itanium ABI、MSVC ABI 中,含虚函数的多态类对象通常包含至少一个隐藏的虚表指针 vptr

  • 第一次让一个继承分支变成多态,通常会让对象多出一个指针大小,再叠加对齐;
  • 再增加若干虚函数,通常不会继续增大每个对象,因为它们只是在类共享的 vtable 中增加表项;
  • 多重继承中,如果存在多个需要独立多态分派的基类子对象,派生对象可能包含多个 vptr;
  • 虚继承可能需要额外的指针或表项来定位虚基类,具体大小由 ABI 决定。

“虚函数表很大,所以每个对象也很大”是错误说法。vtable 通常由同一动态类型的所有对象共享,每个对象主要承担的是一个或多个指针的成本。

sizeof 看静态类型

sizeof 通常在编译期由表达式的静态类型决定,不会根据指针或引用所指对象的动态类型变化:

Base* p = new Derived;
sizeof(*p); // 等于 sizeof(Base),不是 sizeof(Derived)

下面的程序可在目标编译器上观察实际 ABI 的布局结果;不要把某台机器输出的具体数字当成 C++ 标准保证:

#include <iostream>

struct Empty {};

struct Plain {
    int x;
};

struct Poly {
    virtual ~Poly() = default;
    virtual void f() {}
    int x;
};

struct MoreVirtual : Poly {
    void f() override {}
    virtual void g() {}
    // 新增虚函数通常只增加共享虚表的表项。
};

struct Left {
    virtual ~Left() = default;
    int l;
};

struct Right {
    virtual ~Right() = default;
    int r;
};

struct Multiple : Left, Right {
    int m;
};

struct VirtualBase {
    int v;
};

struct VirtualDerived : virtual VirtualBase {
    int d;
};

struct Ebo : Empty {
    int x;
};

int main() {
    std::cout << "Empty: " << sizeof(Empty) << '\n';
    std::cout << "Plain: " << sizeof(Plain) << '\n';
    std::cout << "Poly: " << sizeof(Poly) << '\n';
    std::cout << "MoreVirtual: " << sizeof(MoreVirtual) << '\n';
    std::cout << "Multiple: " << sizeof(Multiple) << '\n';
    std::cout << "VirtualDerived: " << sizeof(VirtualDerived) << '\n';
    std::cout << "Ebo: " << sizeof(Ebo) << '\n';
}

面试时最好按下面顺序分析一个具体类:

  1. 展开所有基类子对象和非静态成员;
  2. 判断常见 ABI 下需要几个 vptr / 虚基定位信息;
  3. 按每个成员的对齐要求计算内部 padding;
  4. 最后把总大小补齐到类的最大对齐倍数;
  5. 强调最终精确值依赖 ABI、编译器、架构和编译选项。

不用额外空间交换两个 int:加减法和异或法怎么做?

C++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这是一道用来检查整数运算和边界意识的小题:不用第三个变量,能否让 `a`、`b` 的值互换。加减法和异或法都能在特定前提下完成交换,但“看起来省了一个变量”并不代表更快、更省内存或更适合生产代码。

  1. 加减法依次做 `a=a+b`、`b=a-b`、`a=a-b`。代数上第一步临时保存两数之和,第二步还原旧 `a`,第三步还原旧 `b`;问题是两个合法 `int` 相加也可能超范围,而 C++ 的有符号整数溢出属于未定义行为。若两个引用绑定同一对象,第二步还会变成“当前值减自身”并把它清零,所以函数开头也必须先做同址检查。

  2. 异或法利用 `x^x=0` 和 `x^0=x`,依次做 `a^=b`、`b^=a`、`a^=b`。如果 `a`、`b` 是两个不同对象,它能逐步把旧值还原出来,并且不会有算术溢出。

  3. 同址别名不是异或法独有:若两个引用都指向 `x`,异或法第一步 `x^=x` 会清零,加减法第二步也会用同一个当前值相减并清零。两种函数都应先判断 `&a == &b`;此外,异或法连续的数据依赖也可能让它比普通交换更难优化。

  4. 现代编译器通常会把普通交换的临时值放在寄存器里,不会真的额外申请一块昂贵内存。面试中可写出两种推导并主动指出风险,工程代码则直接用 `std::swap(a, b)`,让类型系统和标准库选择清楚、可靠的实现。

记住一句 “无临时变量”只是技巧题:两种写法都怕同址引用,加减法还怕有符号溢出;真实代码优先 `std::swap`。

30″ 面试开口
两种方法都能在特定前提下工作,但生产代码应使用 std::swap。
技巧解法要主动讲失败条件:加减法和异或法都怕同址引用,加减法还可能发生有符号溢出;工程里优先使用语义清楚的 std::swap。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:两种方法都能在特定前提下工作,但生产代码应使用 std::swap “不用额外变量”不代表更快;编译器可以把临时变量放在寄存器里。加减法与异或法都有同址别名陷阱,加减法还多一项有符号溢出风险。

加减法

void swapByArithmetic(int& a, int& b) {
    if (&a == &b) { // 两个引用绑定同一对象时无需交换
        return;
    }
    a = a + b;
    b = a - b;
    a = a - b;
}

代数上,执行过程为:

a = old_a + old_b
b = (old_a + old_b) - old_b = old_a
a = (old_a + old_b) - old_a = old_b

但在 C++ 中,有符号整数溢出属于未定义行为。即使 old_aold_b 都是合法 intold_a + old_b 仍可能溢出。它也不能省略同址检查:若 ab 都绑定到 x,第一句把 x 改成 2x,第二句读取的两个操作数已经是同一个新值,结果会把 x 置为 0。因此这不是一个对所有引用和所有 int 都正确的通用函数。

异或法

void swapByXor(int& a, int& b) {
    if (&a == &b) { // 必须处理两个引用绑定同一对象
        return;
    }
    a ^= b;
    b ^= a;
    a ^= b;
}

因为 x ^ x = 0x ^ 0 = x,所以在 ab 是不同对象时可以完成交换。若不检查 &a == &b,调用 swapByXor(x, x) 的第一步就会把 x 清零。

推荐写法

#include <utility>

void swapInt(int& a, int& b) {
    std::swap(a, b);
}

int main() {
    int a = 1;
    int b = 2;
    swapInt(a, b);
}

异或交换还会建立串行数据依赖,常常不如普通交换利于优化。面试中可以先写出题目要求的两种方法,再主动指出:两者都要处理同址引用,加减法还要排除有符号溢出,工程上另有可读性与优化问题。

04

CHAPTER

数学

3 QUESTIONS · #

点 A、B 形成向量,点 C 到直线 AB 的投影点为 C′,求 C′

数学GPU数学基础
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题要找的是:从点 C 垂直落到直线 AB 上,脚落在哪里。关键不是背二维坐标公式,而是把直线上任意点写成“从 A 沿 AB 方向走 t 倍”,再用投影误差与直线方向垂直这个条件求出 t。

  1. 令方向向量 `d=B-A`,直线上的点都可写成 `A+t d`。参数 t 表示从 A 沿 AB 走了多少倍:t=0 是 A,t=1 是 B,0 到 1 之间在线段内部,负数或大于 1 则落在延长线上。

  2. 投影点 C′ 要满足 `C-C′` 与 d 垂直,也就是点积为 0。整理后得到 `t=((C-A)·(B-A))/||B-A||²`,再代回 `C′=A+t(B-A)`;点积衡量同方向分量,所以这个 t 正是在问 C 沿 AB 方向走到了哪一格。

  3. 若题目问无限直线,直接使用这个 t;若问线段 AB 上的最近点,就要把它截断为 `tSegment=min(1,max(0,t))`。t<0 时最近的是 A,t>1 时最近的是 B,否则最近点就是线段内部的垂足。

  4. 还要处理 A=B:此时方向向量为零,分母 `||B-A||²` 也是零,所谓直线方向并不存在。若把它视作退化线段,最近点直接取 A;浮点实现里通常用一个小容差判断“接近零”,避免除以极小数。

记住一句 先算 `t=((C-A)·(B-A))/||B-A||²`,直线直接代回,线段把 t clamp 到 `[0,1]`,A=B 单独处理。

30″ 面试开口
令方向向量 d=BA\mathbf d=B-A,则 C 在直线 AB 上的正交投影为 \( C'=A+\frac{(C-A)\cdot(B-A)}{\|B-A\|^2}(B-A). \)
先把 C−A 投到 B−A 上得到参数 t,再回到 A+t(B−A);若题目是线段,最后把 t 截断到 [0,1]。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:令方向向量 d=BA\mathbf d=B-A,则 C 在直线 AB 上的正交投影为

C=A+(CA)(BA)BA2(BA).C'=A+\frac{(C-A)\cdot(B-A)}{\|B-A\|^2}(B-A).

推导

直线 AB 上任意一点都可写成:

P(t)=A+t(BA)=A+td.P(t)=A+t(B-A)=A+t\mathbf d.

投影点 CC’ 满足误差向量 CCC-C’ 与直线方向 d\mathbf d 垂直:

(CC)d=0.(C-C')\cdot\mathbf d=0.

代入 C=A+tdC’=A+t\mathbf d

(CAtd)d=0,(C-A-t\mathbf d)\cdot\mathbf d=0,

于是:

t=(CA)ddd=(CA)(BA)BA2.t=\frac{(C-A)\cdot\mathbf d}{\mathbf d\cdot\mathbf d} =\frac{(C-A)\cdot(B-A)}{\|B-A\|^2}.

tt 代回即可得到开头公式。这个公式适用于二维、三维乃至任意欧氏空间。

如果问的是线段 AB 上的最近点

直线投影参数 tt 可能小于 0 或大于 1。若目标是线段而不是无限延伸的直线,需要截断:

tsegment=min(1,max(0,t)),C=A+tsegment(BA).t_{\text{segment}}=\min(1,\max(0,t)), \qquad C'=A+t_{\text{segment}}(B-A).
  • 0t10\le t\le1:投影在线段内部;
  • t<0t<0:最近点是 A;
  • t>1t>1:最近点是 B。

边界条件是 A=BA=B。此时 BA2=0|B-A|^2=0,直线方向不存在;若把问题理解为“到退化线段的最近点”,答案就是 A(也等于 B)。浮点实现中应判断分母是否小于一个容差,而不是机械地和 0 精确比较。

Sigmoid 函数是什么?怎么求导?

数学GPU数学基础
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

Sigmoid 是一个把任意实数分数压到 0 和 1 之间的 S 形函数,常用于表达“更偏向负类还是正类”。这题通常既要会写定义和导数,也要解释为什么输入绝对值很大时,训练中的梯度会变得很小。

  1. 定义是 `σ(x)=1/(1+e^(-x))`。x=0 时输出 0.5;x 越大输出越接近 1,x 越小输出越接近 0,但理论上不会真正等于两端,因此值域是 `(0,1)`。它可以把模型的原始分数映射成二分类倾向,但不是凭空校准好的真实概率。

  2. 对 `(1+e^(-x))^(-1)` 使用链式法则,可得 `σ′(x)=e^(-x)/(1+e^(-x))²`。把其中两部分认成 σ(x) 和 `1-σ(x)`,就得到更好记也更好复用的形式:`σ′(x)=σ(x)(1-σ(x))`。

  3. 导数在 x=0 时最大,为 1/4;当 `|x|` 很大时,输出已经贴近 0 或 1,曲线几乎水平,导数也接近 0。反向传播要把许多层导数相乘,这些小数连续相乘会让早期层几乎收不到学习信号,这就是梯度消失的直觉。

  4. 计算机实现还要防数值溢出:x 很负时直接算 `e^(-x)` 可能巨大。可按 x 的正负选择等价公式;做二分类训练时通常直接使用框架的 logits 与损失融合实现,而不是先手算 Sigmoid 再取对数。

记住一句 `σ(x)=1/(1+e^-x)`,`σ′=σ(1-σ)`;`|x|` 越大曲线越平,梯度越接近 0。

30″ 面试开口
Sigmoid 把任意实数压缩到 (0,1)(0,1),定义为 \( \sigma(x)=\frac{1}{1+e^{-x}}, \)
Sigmoid 把实数压到 0 与 1 之间;中央斜率最大,两端饱和后导数接近 0,深层传播时容易让梯度变小。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:Sigmoid 把任意实数压缩到 (0,1)(0,1),定义为

σ(x)=11+ex,\sigma(x)=\frac{1}{1+e^{-x}},

它的导数可以复用函数值:

σ(x)=σ(x)(1σ(x)).\sigma'(x)=\sigma(x)(1-\sigma(x)).

推导

σ(x)=(1+ex)1\sigma(x)=(1+e^{-x})^{-1},使用链式法则:

σ(x)=(1+ex)2(ex)=ex(1+ex)2.\begin{aligned} \sigma'(x) &=-(1+e^{-x})^{-2}\cdot(-e^{-x})\\ &=\frac{e^{-x}}{(1+e^{-x})^2}. \end{aligned}

另一方面:

1σ(x)=111+ex=ex1+ex,1-\sigma(x) =1-\frac{1}{1+e^{-x}} =\frac{e^{-x}}{1+e^{-x}},

所以:

σ(x)(1σ(x))=11+exex1+ex=ex(1+ex)2.\sigma(x)(1-\sigma(x)) =\frac{1}{1+e^{-x}}\frac{e^{-x}}{1+e^{-x}} =\frac{e^{-x}}{(1+e^{-x})^2}.

面试常见追问

  • σ(0)=0.5\sigma(0)=0.5
  • 导数最大值在 x=0x=0,为 1/41/4
  • x|x| 很大时,输出接近 0 或 1,导数接近 0,容易出现梯度消失。
  • 直接计算很大的负数输入时,exp(-x) 可能溢出。稳定实现可分支计算:
σ(x)={11+ex,x0,ex1+ex,x<0.\sigma(x)= \begin{cases} \frac{1}{1+e^{-x}}, & x\ge0,\\ \frac{e^x}{1+e^x}, & x<0. \end{cases}

在二分类损失中,通常直接使用框架提供的 BCEWithLogitsLoss 一类“logits + loss”融合实现,避免先算 Sigmoid 再取对数造成的数值精度问题。

Softmax 函数是什么?为什么要减 max?

数学数学基础
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

Softmax 处理的不是一个数,而是一整组类别分数(logits):它把这些可正可负的分数变成全部为正、总和为 1 的分布。面试重点往往是“为什么先减最大值”,这实际上是在保持数学答案不变的前提下保护浮点计算。

  1. 对第 i 个分数,Softmax 定义为 `p_i=e^(z_i)/Σ_j e^(z_j)`。指数会放大分数差异,分母再把所有项归一化,所以每个 p 都在 0 到 1 之间且总和恰好为 1;一个分数上升还会挤压其他类别的份额。

  2. 指数增长非常快,例如 `e^1000` 在常见浮点格式里会变成无穷大,最后可能出现 `inf/inf=NaN`。令 `m=max(z)`,先把每项改成 `z_i-m`,最大的指数就变为 `e^0=1`,其余指数都不超过 1,大幅降低上溢风险。

  3. 减 max 不会改变答案,因为分子和分母都多乘了同一个 `e^(-m)`,约分后仍是原来的比例。例如 `[1000,999]` 可安全地改算 `[0,-1]`;很小的项可能下溢成 0,但这通常比整个结果变成 NaN 更可控。

  4. 训练分类模型时,框架常把 `log_softmax` 和交叉熵融合,用 `m+log Σ e^(z-m)` 的 LogSumExp 形式继续保持稳定。Softmax 各输出彼此耦合,不是 K 个互不相关的 Sigmoid;与 one-hot 交叉熵结合后,对 logits 的梯度可简化为 `p-y`。

记住一句 Softmax 用“指数再归一化”形成分布;统一减 max 只平移分数、不改比例,却把最大的指数压到 1。

30″ 面试开口
Softmax 把一组 logits 转成总和为 1 的正数分布。计算前减去最大 logit 不改变数学结果,却能显著降低指数溢出的风险。
所有 logits 同减最大值不会改变相对概率,却把最大指数固定为 1,从源头避免 inf 除 inf。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

结论:Softmax 把一组 logits 转成总和为 1 的正数分布。计算前减去最大 logit 不改变数学结果,却能显著降低指数溢出的风险。

对向量 z=(z1,,zK)z=(z_1,\ldots,z_K),定义:

softmax(z)i=ezij=1Kezj.\operatorname{softmax}(z)_i =\frac{e^{z_i}}{\sum_{j=1}^{K}e^{z_j}}.

m=maxjzjm=\max_j z_j,则:

ezimjezjm=eziememjezj=ezijezj.\frac{e^{z_i-m}}{\sum_j e^{z_j-m}} =\frac{e^{z_i}e^{-m}}{e^{-m}\sum_j e^{z_j}} =\frac{e^{z_i}}{\sum_j e^{z_j}}.

所以 Softmax 对“所有元素同时加减同一个常数”不变。

数值稳定性的意义

例如 logits 是 [1000, 999]。直接计算 e1000e^{1000} 很可能得到 inf,随后出现 inf / inf = NaN。减去最大值后变成 [0, -1]

softmax([1000,999])=softmax([0,1]),\operatorname{softmax}([1000,999]) =\operatorname{softmax}([0,-1]),

此时最大的指数是 e0=1e^0=1,不会向上溢出。较小项仍可能下溢成 0,但这通常表示其概率已经小到当前浮点格式无法分辨,比出现 NaN 更可控。

导数

Softmax 的 Jacobian 不是逐元素独立的。令 pi=softmax(z)ip_i=\operatorname{softmax}(z)_i

pizj=pi(δijpj),\frac{\partial p_i}{\partial z_j} =p_i(\delta_{ij}-p_j),

即:

pizi=pi(1pi),pizj=pipj(ij).\frac{\partial p_i}{\partial z_i}=p_i(1-p_i), \qquad \frac{\partial p_i}{\partial z_j}=-p_ip_j\quad(i\ne j).

若 Softmax 后接交叉熵,设 one-hot 标签为 yy,对 logits 的梯度会化简为:

Lzi=piyi.\frac{\partial L}{\partial z_i}=p_i-y_i.

工程中通常使用融合的 log_softmax + NLLLoss 或 cross-entropy kernel。其核心是 LogSumExp:

logjezj=m+logjezjm,\log\sum_j e^{z_j} =m+\log\sum_j e^{z_j-m},

这样既避免上溢,也减少中间概率舍入带来的精度损失。

05

CHAPTER

算法

7 QUESTIONS · #

排序二叉树,替换分数相同的节点

算法MoEGPUC++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题先要确认数据规则:下面假设 `score` 是二叉搜索树(BST)的唯一排序键,同一个分数只保留一条学生记录。目标是在插入新记录时,若已经存在相同 score,就更新那一节点的姓名等业务数据,而不是再造一个重复键。

  1. BST 的规则是:当前节点左边的 score 都更小,右边都更大。拿新记录从根开始比较,小就走左子树,大就走右子树;这样每一步都排除另一半路径,不需要扫描整棵树。

  2. 当 score 相等时,只替换节点里的 payload(例如姓名、班级),保留 score 和原来的左右子树。因为排序键没变,左边仍然小、右边仍然大,BST 的结构和不变量都不会被破坏。若一路走到空位置,则说明没有同分节点,正常创建新节点。

  3. 设树高为 h,查找或更新只沿一条根到叶路径,因此时间是 `O(h)`,递归版本的调用栈也是 `O(h)`。平衡树中 h 约为 `log n`,普通 BST 若输入有序可能退化成链,此时最坏是 `O(n)`;需要稳定上界可用 AVL、红黑树或 `std::map`。

  4. 如果业务允许多个同分学生,就不能覆盖,应让一个 score 对应记录列表,或把 `(score, id)` 作为复合键。若替换时连 score 也要改,更不能原地改键,必须删除旧键再重新插入,否则左右子树的大小关系可能立即失效。

记住一句 在“score 是唯一键”的前提下,按 BST 找到相等键只更新 payload;复杂度是 `O(h)`,平衡时才是 `O(log n)`。

30″ 面试开口
按普通 BST 查找即可;遇到相等键时更新 payload,不要重新插入一个重复键。
在 score 是唯一排序键、同分只留一条记录的前提下,命中后更新业务字段即可;复杂度取决于树高 h。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

先澄清题意

“排序二叉树”通常指二叉搜索树(BST),但“替换分数相同的节点”至少有三种解释:

  1. 树以 score 为唯一键;插入新记录时,如果已有相同分数,就用新记录更新旧记录;
  2. 相同分数的记录需要同时保留;
  3. 给一棵普通二叉树,替换所有分数等于目标值的节点。

下面先采用最常见、也最符合 BST 语义的假设:

每个节点是一条学生记录,score 是 BST 的唯一排序键。执行 insertOrReplace(record) 时,小于当前分数向左,大于向右,等于时替换该节点的业务数据,并保留原节点的左右子树。

结论:按普通 BST 查找即可;遇到相等键时更新 payload,不要重新插入一个重复键。 若树高为 hh,时间和递归栈复杂度分别为 O(h)O(h);平衡树中 h=O(logn)h=O(\log n),退化成链时 h=O(n)h=O(n)。如果要求最坏 O(logn)O(\log n),应使用 AVL、红黑树或 std::map

C++17 实现

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Student {
    int score;
    std::string name;
};

class ScoreBST {
private:
    struct Node {
        explicit Node(Student value) : value(std::move(value)) {}

        Student value;
        std::unique_ptr<Node> left;
        std::unique_ptr<Node> right;
    };

    std::unique_ptr<Node> root_;

    // 返回 true 表示替换了已有节点;false 表示插入了新节点。
    static bool insertOrReplace(std::unique_ptr<Node>& node, Student value) {
        if (!node) {
            node = std::make_unique<Node>(std::move(value));
            return false;
        }

        if (value.score < node->value.score) {
            return insertOrReplace(node->left, std::move(value));
        }
        if (value.score > node->value.score) {
            return insertOrReplace(node->right, std::move(value));
        }

        // score 相同,只更新业务数据;左右子树保持不变。
        node->value = std::move(value);
        return true;
    }

    static void inorder(const Node* node) {
        if (!node) {
            return;
        }
        inorder(node->left.get());
        std::cout << node->value.score << ':' << node->value.name << '\n';
        inorder(node->right.get());
    }

public:
    bool insertOrReplace(Student value) {
        return insertOrReplace(root_, std::move(value));
    }

    void printInorder() const {
        inorder(root_.get());
    }
};

int main() {
    ScoreBST tree;
    tree.insertOrReplace({80, "Alice"});
    tree.insertOrReplace({60, "Bob"});
    tree.insertOrReplace({90, "Carol"});

    const bool replaced = tree.insertOrReplace({80, "David"});
    std::cout << std::boolalpha << "replaced=" << replaced << '\n';
    tree.printInorder(); // 60:Bob, 80:David, 90:Carol
}

正确性说明

在任意节点:

  • 若新分数更小,根据 BST 不变量,可能匹配的节点只会在左子树;
  • 若更大,只会在右子树;
  • 若相等,替换当前记录。由于排序键 score 没有变化,原左子树仍全部小于它,原右子树仍全部大于它,因此 BST 不变量保持成立。

递归最终要么命中唯一的相等键,要么到达空位置并插入,所以操作正确。

常见变体

  • 同分记录都要保留:不能简单覆盖。可把每个 BST 节点改成 score -> vector<Record>,或使用 (score, uniqueId) 作为复合键。标准库可用 std::multimap<int, Record>
  • 相同分数时替换整个节点对象:新节点必须接管旧节点的 leftright,否则会丢失两棵子树。若只是更新业务字段,替换 payload 更简单安全。
  • 普通二叉树中替换所有同分节点:排序性质不可用,需要 DFS/BFS 扫描所有节点,时间为 O(n)O(n)
  • 替换后分数也改变:不能在原位置直接改排序键。正确做法是删除旧键,再按新键重新插入,否则会破坏 BST 顺序。

实现 copyRandomList:带 random 指针链表的深拷贝

算法C++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

普通链表只要沿 `next` 复制即可,但这题每个节点还有一根 `random`,它可能指向任意节点、自己或空。深拷贝必须让新链表的 random 指向“对应的新节点”,不能偷偷指回旧链表;难点就是如何建立旧节点到新节点的映射。

  1. 第一遍在每个原节点后面插入它的副本,把 `A→B→C` 变成 `A→A′→B→B′→C→C′`。这样无需哈希表也得到一个固定规则:任何原节点 x 的副本就是 `x->next`。

  2. 第二遍复制 random。若原节点 x 的 random 指向 r,那么 x 的副本应指向 r 的副本;因为 r 的副本正好是 `r->next`,所以写成 `x->next->random = x->random ? x->random->next : nullptr`,自指和空指针也自然覆盖。

  3. 第三遍把交织结构拆成两条链:恢复每个原节点的 next,同时把相邻副本连起来。拆完后,旧链表回到原样,新链表的 next 和 random 都只连接新节点,因此才是真正独立的深拷贝。

  4. 三遍扫描都是线性时间,总时间 `O(n)`,除输出节点外辅助空间 `O(1)`。代价是过程会暂时修改原链表;若输入只读或被并发访问,应改用 `unordered_map<旧地址,新地址>` 的两遍方法,以 `O(n)` 辅助空间换更安全的执行方式。

记住一句 先把副本插到原节点身后建立隐式映射,再复制 random,最后拆链:`O(n)` 时间、输出外 `O(1)` 空间。

30″ 面试开口
可用“原节点后面交织复制节点”的方法,做到 O(n)O(n) 时间、O(1)O(1) 额外辅助空间(不计输出链表)。
交织结构临时建立 old→copy 的 O(1) 映射,因此不需要哈希表,也能正确复制任意 random 边。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

题目中的每个节点有两个指针:

  • next 指向下一个节点;
  • random 可指向链表中任意节点,也可为 nullptr

结论:可用“原节点后面交织复制节点”的方法,做到 O(n)O(n) 时间、O(1)O(1) 额外辅助空间(不计输出链表)。

三步法

假设原链表是:

A -> B -> C
  1. 在每个原节点后插入其副本:
A -> A' -> B -> B' -> C -> C'
  1. 对原节点 x,其副本是 x->next。若 x->random = r,那么副本应指向 r 的副本,即:
x->next->random = x->random ? x->random->next : nullptr;
  1. 将交织链表拆回原链表和复制链表。

C++17 实现

#include <cstddef>

struct Node {
    int val;
    Node* next;
    Node* random;

    explicit Node(int value) : val(value), next(nullptr), random(nullptr) {}
};

Node* copyRandomList(Node* head) {
    if (!head) {
        return nullptr;
    }

    // 1. 交织:x -> x' -> next(x)
    for (Node* cur = head; cur != nullptr; ) {
        Node* copy = new Node(cur->val);
        copy->next = cur->next;
        cur->next = copy;
        cur = copy->next;
    }

    // 2. 建立副本的 random。
    for (Node* cur = head; cur != nullptr; cur = cur->next->next) {
        Node* copy = cur->next;
        copy->random = cur->random ? cur->random->next : nullptr;
    }

    // 3. 拆分,同时恢复原链表。
    Node* copiedHead = head->next;
    for (Node* cur = head; cur != nullptr; ) {
        Node* copy = cur->next;
        Node* nextOriginal = copy->next;

        cur->next = nextOriginal;
        copy->next = nextOriginal ? nextOriginal->next : nullptr;

        cur = nextOriginal;
    }

    return copiedHead;
}

正确性说明

  • 第一步建立一一映射:每个原节点 x 的副本一定是 x->next
  • 原节点的 random 若指向 r,则 r->next 根据上述映射正是 r 的副本,所以第二步得到完全对应的 random 边。
  • 第三步分别连接相邻原节点和相邻副本,不改变第二步建立的 random,并恢复输入链表。

因此,复制链表的值、next 拓扑和 random 拓扑都与原链表同构,同时没有任何复制节点指向原节点,构成真正的深拷贝。

边界与坑

  • 必须处理空链表、random == nullptrrandom 指向自身等情况。
  • 额外空间 O(1)O(1) 不包括返回的新节点本身;输出必然占 O(n)O(n) 空间。
  • 该算法会暂时修改原链表,不适合原链表只读、被其他线程并发访问的场景。此时可用 unordered_map<old*, new*> 做两遍复制,代价是 O(n)O(n) 辅助空间。
  • 若中途 new 抛出异常,上面的简化版本没有回滚已交织部分。生产级代码应使用 RAII、先分配后链接,或选择更容易保证异常安全的哈希表版本。

一块钱一瓶汽水,两个空瓶换一瓶,N 块钱能喝多少瓶?

算法MoEC++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这是一道“资源循环利用”题,最重要的是先讲清规则:一元买一瓶,喝完得到一个空瓶,两个空瓶换一瓶。答案会因是否允许借最后一个空瓶而不同,不能把两种规则混成一个公式。

  1. 不借瓶时,先用 N 元买到 N 瓶并喝完,因此已经喝 N 瓶、手里有 N 个空瓶。每次兑换交出 2 个空瓶,但喝完换来的汽水又得到 1 个,所以一次兑换的净效果是“多喝 1 瓶,空瓶只减少 1 个”。

  2. 当 `N≥1`,空瓶从 N 个逐次减到 1 个便不能再换,一共还能兑换 `N-1` 次,总数为 `N+(N-1)=2N-1`。`N=0` 时既没买到汽水也没有空瓶,答案必须单独是 0,不能机械套公式得到 -1。

  3. 若规则明确允许最后向店家借 1 个空瓶并归还,那么不借版本最后剩的 1 个可与借来的 1 个凑成 2 个。换一瓶、喝完后产生的新空瓶正好还给店家,因此 `N≥1` 时再多喝一瓶,总数为 `2N`;N=0 仍不能凭空启动这个过程。

  4. 模拟时可循环做 `exchanged=empty/2`,再更新 `drunk+=exchanged`、`empty=exchanged+empty%2`,适合扩展到 k 个空瓶换一瓶。还要核对大 N 时 `2N` 是否溢出整数类型,并明确借的是空瓶、不是钱或整瓶汽水。

记住一句 不借瓶:N=0 为 0,N≥1 为 `2N-1`;允许借最后一个空瓶并归还时,N≥1 才是 `2N`。

30″ 面试开口
N=0N=0 时喝 0 瓶;当 N1N\ge1 时总共能喝 2N12N-1 瓶。
每次兑换消耗两个空瓶、喝完返还一个,库存净减少 1;是否允许借最后一个空瓶会改变最终公式。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

不允许借瓶

结论:当 N=0N=0 时喝 0 瓶;当 N1N\ge1 时总共能喝 2N12N-1 瓶。

先花 N 元买 N 瓶,喝完得到 N 个空瓶。每换并喝一瓶:

  • 交出 2 个空瓶;
  • 喝完新汽水又得到 1 个空瓶;
  • 所以空瓶净减少 1 个。

从 N 个空瓶减少到不能再换的 1 个空瓶,恰好还能换 N1N-1 瓶。因此:

N+(N1)=2N1.N+(N-1)=2N-1.

允许最后借一个空瓶

结论:在常见“可以向店家借一个空瓶,换完喝掉后归还空瓶”的规则下,N1N\ge1 时能喝 2N2N 瓶。

不借时最终剩 1 个空瓶。向店家借 1 个空瓶后凑成 2 个,换一瓶汽水;喝完产生的新空瓶归还店家,账正好平掉,因此比不借版本多喝 1 瓶。

这里的“借瓶”必须明确是借空瓶并最终归还。如果题目说可以借钱、借一整瓶汽水,模型会不同;不能在未说明规则时默认可以无限借。

C++17 模拟

模拟写法比直接背公式更容易扩展到“k 个空瓶换一瓶”等变体:

#include <iostream>
#include <limits>
#include <stdexcept>

long long maxSodas(long long money, bool allowBorrowOneEmpty) {
    if (money < 0) {
        throw std::invalid_argument("money must be non-negative");
    }

    // 确保最终答案能由 long long 表示:不借瓶为 2N-1,借瓶为 2N。
    const long long maxMoney = allowBorrowOneEmpty
        ? std::numeric_limits<long long>::max() / 2
        : std::numeric_limits<long long>::max() / 2 + 1;
    if (money > maxMoney) {
        throw std::overflow_error("result does not fit in long long");
    }

    long long drunk = money;
    long long empty = money;

    while (empty >= 2) {
        const long long exchanged = empty / 2;
        drunk += exchanged;
        empty = exchanged + empty % 2;
    }

    // 只有确实买过并喝过汽水、最后剩一个空瓶时,才使用借瓶规则。
    if (allowBorrowOneEmpty && empty == 1) {
        ++drunk;
    }
    return drunk;
}

int main() {
    std::cout << maxSodas(5, false) << '\n'; // 9
    std::cout << maxSodas(5, true) << '\n';  // 10
    std::cout << maxSodas(0, true) << '\n';  // 0
}

时间复杂度为 O(logN)O(\log N),因为每轮空瓶数大约减半;空间复杂度为 O(1)O(1)。若直接使用闭式公式,则时间、空间均为 O(1)O(1)

1~N 个任务,任务 i 耗时 i,需要多少个核并行,才能让执行时间、空泡最短?

算法调度
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题若不先声明假设就没有唯一答案。这里假设 N 个任务彼此独立、时刻 0 同时就绪、任务 i 耗时 i、不可拆分也不可抢占,并忽略调度开销;优化目标按字典序理解:先把全部任务完工时间做到理论最短,再在能达到它的方案中使用最少核,从而把总空泡也压到不可避免的水平。

  1. 最长的任务 N 单独就要运行 N 个时间单位,所以无论有多少核,总完工时间都不可能小于 `T*=N`。所有任务总工作量是 `W=1+…+N=N(N+1)/2`;P 个核若想在 N 时间内做完,容量至少满足 `P·N≥W`。

  2. 由容量下界得到 `P≥ceil(W/N)=ceil((N+1)/2)`。这不只是估算,还能构造出来:让任务 N 单独一组,再把两端任务配对 `{N-1,1}`、`{N-2,2}`……,每对总耗时恰好 N,因此所有组都能在 N 时间内完成。

  3. N 为奇数时共有 `(N+1)/2` 组,每组负载都是 N,没有空泡;N 为偶数时最后会剩任务 `N/2` 单独一组,共 `N/2+1` 个核,这一组有 `N/2` 空泡。该空泡等于总容量减总工作量,已无法再减少。

  4. 所以在上述假设和目标下,最短时间是 `N`,最少核数是 `ceil((N+1)/2)`;N=0 时按无任务处理,时间和核数都为 0。若核数预先固定、任务可拆分、存在依赖或把“核成本”放进目标,问题就会变成另一种调度模型,答案不能照搬。

记住一句 先声明独立、同时就绪、不可拆分和“先最短时间再最少核”;此时 `T*=N`,`P*=ceil((N+1)/2)`。

30″ 面试开口
最短完工时间是: T=N,T^*=N, 达到它所需的最少核数是: P=N+12.P^*=\left\lceil\frac{N+1}{2}\right\rceil.
最长任务给出 T≥N,总工作量给出核数下界;把 i 与 N−i 配对即可构造达到该下界的调度。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

题目为什么不唯一?

若不说明约束和优化目标,这道题没有唯一答案。 至少需要明确:

  • 任务是否相互独立、是否都在时刻 0 就绪;
  • 单个任务能否拆分或抢占;
  • 核数是否有上限、增加核是否有成本;
  • “执行时间最短”和“空泡最短”如何权衡。

例如只最小化总空泡,1 个核顺序执行时空泡为 0,但总时间最长;只说核越多越好,则 N 个核各跑一个任务,完工时间为 N,却产生大量尾部空泡。

下面采用一个合理且可回答的面试假设(并约定 N1N\ge1):

N 个任务相互独立、不可拆分、无调度和通信开销、同时就绪。采用字典序目标:先让完工时间(makespan)达到理论最小,再在所有达到该时间的方案中使用最少核,也就最小化总空泡。

结论

最短完工时间是:

T=N,T^*=N,

达到它所需的最少核数是:

P=N+12.P^*=\left\lceil\frac{N+1}{2}\right\rceil.

下界推导

总工作量为:

W=1+2++N=N(N+1)2.W=1+2+\cdots+N=\frac{N(N+1)}{2}.

对 P 个核,任意调度的完工时间都有两个下界:

TNT\ge N

因为最长的任务 N 自己就要运行 N 时间;同时:

TWPT\ge\left\lceil\frac{W}{P}\right\rceil

因为 P 个核在 T 时间内最多完成 PTPT 工作。

若希望达到绝对下界 T=NT=N,必须满足:

PNW,PN\ge W,

因此:

PWN=N+12.P\ge\left\lceil\frac{W}{N}\right\rceil =\left\lceil\frac{N+1}{2}\right\rceil.

构造达到下界的调度

把任务 N 单独放一组,再从两端配对:

{N}, {N-1, 1}, {N-2, 2}, {N-3, 3}, ...

每个二元组的总耗时都是 N。

  • 若 N 为奇数,所有任务恰好组成 (N+1)/2(N+1)/2 组,每组负载都是 N,完全没有空泡。
  • 若 N 为偶数,最后剩下任务 N/2N/2 单独一组,共 N/2+1N/2+1 组。除最后一组外负载都是 N,总空泡为 N/2N/2。由于此时 PNW=N/2P^*N-W=N/2,这些空泡由工作量决定,已经不可避免。

这既证明了上述核数下界可达,也证明了完工时间和空泡都满足所定义的最优目标。

N=0N=0,没有任务,完工时间和所需核数都按 0 处理,不使用上面的 PP^* 公式。

C++17 构造代码

#include <algorithm>
#include <cassert>
#include <iostream>
#include <vector>

std::vector<std::vector<int>> optimalSchedule(int n) {
    if (n <= 0) {
        return {};
    }

    std::vector<std::vector<int>> cores;
    cores.push_back({n});

    int low = 1;
    int high = n - 1;
    while (low < high) {
        cores.push_back({high, low});
        ++low;
        --high;
    }
    if (low == high) {
        cores.push_back({low});
    }
    return cores;
}

int main() {
    const int n = 6;
    const auto schedule = optimalSchedule(n);

    const int expectedCores = (n + 2) / 2; // ceil((n + 1) / 2)
    assert(static_cast<int>(schedule.size()) == expectedCores);

    for (std::size_t core = 0; core < schedule.size(); ++core) {
        int load = 0;
        std::cout << "core " << core << ':';
        for (int task : schedule[core]) {
            load += task;
            std::cout << ' ' << task;
        }
        assert(load <= n);
        std::cout << "  load=" << load << '\n';
    }
}

构造会恰好访问每个任务一次,时间和保存调度结果的空间都是 O(N)O(N)

如果核数 P 已固定

这就变成相同并行机上的最小化最大负载问题 PCmaxP||C_{\max},一般形式是 NP-hard。面试中常用 LPT(按耗时从大到小,每次放到当前负载最小的核)作为高效近似;其经典最坏界为:

CLPT(4313P)C.C_{\mathrm{LPT}}\le \left(\frac{4}{3}-\frac{1}{3P}\right)C^*.

小规模输入可以对答案做二分,再用回溯检查是否能把任务装入 P 个容量为 T 的桶;大规模系统还必须把调度开销、NUMA、缓存亲和性和任务依赖纳入模型。

LeetCode 2712:使所有字符相等的最小成本(动态规划)

算法
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

这题允许翻转一个前缀或后缀,并按翻转长度付费,目标是让二进制字符串最终全相同。直觉上操作会互相影响,但真正需要处理的只是相邻字符不同的“边界”;把每个边界单独看,便能把动态规划化成一次线性扫描。

  1. 观察位置 i 左右的 `s[i-1]` 和 `s[i]`。若它们相同,这条边界已经没冲突;若不同,最终全 0 或全 1 时它们必须变得相同,因此这条边界的相对关系必须被翻转奇数次,至少要付一次能改变它的操作。

  2. 能单独改变这条边界相对关系的便宜选择只有两类:翻转左侧前缀 `[0,i-1]`,成本 i;或翻转右侧后缀 `[i,n-1]`,成本 `n-i`。选择较短的一侧,因此该不同边界至少贡献 `min(i,n-i)`。

  3. 为什么可以直接相加?一个以边界为端点的前缀或后缀翻转,只改变这条边界两侧是否相同;在操作覆盖区域内部的相邻字符会一起翻转,相对关系不变。于是每个不同边界都可独立按更便宜一侧处理,这些下界也能同时达到。

  4. 最终答案是对所有 `s[i-1] != s[i]` 的位置累加 `min(i,n-i)`,时间 `O(n)`、额外空间 `O(1)`。空串或单字符答案为 0;总成本可能超过 32 位,代码应使用 `long long`,且无需预先指定最终变成全 0 还是全 1。

记住一句 把字符串看成一串边界:每个不同边界支付 `min(左侧长度,右侧长度)`,全部相加就是最优成本。

30″ 面试开口
可以做前后缀 DP,时间 O(n)O(n)、空间 O(n)O(n);进一步观察每个相邻边界相互独立,可将空间优化为 O(1)O(1)
每个 0/1 变化边界最终都要被处理;它的最小贡献是左侧长度与右侧长度中的较小值,线性扫描即可求和。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

题意:给一个长度为 n 的二进制字符串,可以执行:

  1. 翻转前缀 [0, i],成本为 i + 1
  2. 翻转后缀 [i, n - 1],成本为 n - i

求使整个字符串中的字符全部相等的最小成本。

结论:可以做前后缀 DP,时间 O(n)O(n)、空间 O(n)O(n);进一步观察每个相邻边界相互独立,可将空间优化为 O(1)O(1)

DP 状态

定义:

  • prefix[i]:只考虑左侧,把区间 [0, i - 1] 变得与 s[i] 相等的最小成本;
  • suffix[i]:只考虑右侧,把区间 [i + 1, n - 1] 变得与 s[i] 相等的最小成本。

prefix

  • s[i - 1] == s[i],不需要处理新边界,prefix[i] = prefix[i - 1]
  • 若二者不同,需要翻转 [0, i - 1],长度和成本都是 i,因此:
prefix[i]=prefix[i1]+i.\text{prefix}[i] =\text{prefix}[i-1]+i.

所以统一写成:

prefix[i]=prefix[i1]+[si1si]i.\text{prefix}[i]=\text{prefix}[i-1] +[s_{i-1}\ne s_i]\cdot i.

同理,从右向左:

suffix[i]=suffix[i+1]+[sisi+1](ni1).\text{suffix}[i]=\text{suffix}[i+1] +[s_i\ne s_{i+1}]\cdot(n-i-1).

选择位置 i 作为中间基准字符,左边通过前缀操作统一到它,右边通过后缀操作统一到它:

answer=min0i<n(prefix[i]+suffix[i]).\text{answer} =\min_{0\le i<n} \left(\text{prefix}[i]+\text{suffix}[i]\right).

为什么还可以化成边界求和?

考察 s[i - 1]s[i] 之间的边界,1i<n1\le i<n

  • 翻转前缀 [0, i - 1],只有这个边界两侧的相对关系改变,成本 i;
  • 翻转后缀 [i, n - 1],也只有这个边界的相对关系改变,成本 nin-i
  • 其他端点的前缀或后缀操作不会改变该边界两侧的相对关系。

如果原本两字符相同,无需操作该边界;如果不同,要让最终所有字符相同,该边界必须被改变奇数次,最便宜就是选择两种操作中较小的一个。因此:

answer=i=1n1[si1si]min(i,ni)\boxed{ \text{answer} =\sum_{i=1}^{n-1} [s_{i-1}\ne s_i]\min(i,n-i) }

这也解释了 DP 的最优性:每个不相等边界都必须支付至少上述成本,而按离边界更近的一侧翻转可以达到这些下界。

C++17:前后缀 DP

#include <algorithm>
#include <iostream>
#include <limits>
#include <string>
#include <vector>

long long minimumCostDP(const std::string& s) {
    const int n = static_cast<int>(s.size());
    if (n <= 1) {
        return 0;
    }

    std::vector<long long> prefix(n, 0);
    std::vector<long long> suffix(n, 0);

    for (int i = 1; i < n; ++i) {
        prefix[i] = prefix[i - 1];
        if (s[i - 1] != s[i]) {
            prefix[i] += i;
        }
    }

    for (int i = n - 2; i >= 0; --i) {
        suffix[i] = suffix[i + 1];
        if (s[i] != s[i + 1]) {
            suffix[i] += n - i - 1;
        }
    }

    long long answer = std::numeric_limits<long long>::max();
    for (int i = 0; i < n; ++i) {
        answer = std::min(answer, prefix[i] + suffix[i]);
    }
    return answer;
}

int main() {
    std::cout << minimumCostDP("0011") << '\n';   // 2
    std::cout << minimumCostDP("010101") << '\n'; // 9
}

如果只需要结果,可直接按边界公式累加,空间降为 O(1)O(1)

#include <algorithm>
#include <string>

long long minimumCost(const std::string& s) {
    const int n = static_cast<int>(s.size());
    long long answer = 0;
    for (int i = 1; i < n; ++i) {
        if (s[i - 1] != s[i]) {
            answer += std::min(i, n - i);
        }
    }
    return answer;
}

边界与坑:成本累加可能超过 32 位,应使用 long long;题目要的是最终全 0 或全 1 中成本更低的结果,不需要预先指定目标字符。

类似 LeetCode 1383:最多选 maxMachines 台机器,使 sum(speed) * min(reliability) 最大

算法MoEC++
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

每台机器有速度和可靠性,最多选 K 台;团队得分是“速度总和 × 其中最低可靠性”。难点在于最低值会随选人变化。解决办法是先固定一个可靠性门槛,再在所有不低于门槛的机器里尽量拿到最大的速度和。

  1. 先按可靠性从高到低排序。扫描到可靠性为 r 的当前机器时,已经看过的所有机器可靠性都不低于 r,因此可以把 r 当作这一轮枚举的最低可靠性门槛;每个最优团队都有一台机器取得最低可靠性,所以逐轮枚举不会漏掉它。

  2. 在同一门槛下,速度都为正时,当然希望最多保留 K 台且速度和尽可能大。用容量为 K 的小根堆保存已扫描机器的速度:新速度入堆并加入 sum,若堆大小超过 K,就弹出最小速度并从 sum 扣除,留下的是速度最大的至多 K 台。

  3. 每轮用当前 `sum × r` 更新答案。即使当前速度太小而被弹出,这一候选只是按更低门槛保守计算;堆中那组机器的真实最低可靠性会在更早的扫描轮次被枚举,因此全局最大值仍不会漏。也可维护“此前至多 K-1 个速度”并强制包含当前机器,证明更直观。

  4. 排序耗时 `O(n log n)`,每台机器进出小根堆耗时 `O(log K)`,堆空间 `O(K)`;K≤0 或没有机器时答案为 0。乘积可能溢出,应按约束选 `long long`、`__int128` 或大整数;若题目要求取模,要先比较真实大小,最后再取模。

记住一句 可靠性降序枚举门槛,所有候选可靠性都不低于当前值;用容量 K 的小根堆保留最大速度和。

30″ 面试开口
按可靠性从高到低排序,把当前机器的可靠性枚举为团队最小值;同时用小根堆保留此前速度最大的至多 K-1 台机器。
枚举最低可靠性并强制包含当前机器;小根堆只保存此前速度最大的至多 K−1 台,与当前机器合成不超过 K 台的团队。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

假设每台机器的 speedreliability 都是正数,并且最多选择 K 台。团队性能定义为:

performance(S)=(iSspeedi)miniSreliabilityi.\operatorname{performance}(S) =\left(\sum_{i\in S}\operatorname{speed}_i\right) \min_{i\in S}\operatorname{reliability}_i.

结论:按可靠性从高到低排序,把当前机器的可靠性枚举为团队最小值;同时用小根堆保留此前速度最大的至多 K-1 台机器。 排序耗时 O(nlogn)O(n\log n),堆扫描耗时 O(nlogK)O(n\log K),所以总时间复杂度为 O(nlogn)O(n\log n)。堆占 O(K)O(K) 辅助空间;下面按值接收 machines 以免修改调用者数据,因此这份具体实现还会保留一份 O(n)O(n) 的输入副本。

为什么要枚举最小可靠性?

乘积中 min(reliability) 很难直接维护,但任何非空最优团队一定有一台机器取得这个最小值。若当前枚举机器的可靠性为 r,并且按可靠性降序扫描,那么此前机器可靠性都至少为 r。

强制选当前机器后,为让速度和最大,应从此前机器中选速度最大的至多 K-1 台。因为速度为正,多选一台只会增加速度和,不会降低当前的最小可靠性。

小根堆正好支持:

  • 插入一个历史速度:O(logK)O(\log K)
  • 超过 K-1 台时弹出最小速度,从而留下最大的 K-1 个速度。

C++17 实现

#include <algorithm>
#include <cstddef>
#include <functional>
#include <iostream>
#include <limits>
#include <queue>
#include <stdexcept>
#include <utility>
#include <vector>

struct Machine {
    long long speed;
    long long reliability;
};

long long maxPerformance(std::vector<Machine> machines, int maxMachines) {
    if (maxMachines <= 0 || machines.empty()) {
        return 0;
    }

    std::sort(machines.begin(), machines.end(),
              [](const Machine& a, const Machine& b) {
                  return a.reliability > b.reliability;
              });

    // 保存已经扫描过的机器中,速度最大的至多 K-1 个。
    std::priority_queue<long long,
                        std::vector<long long>,
                        std::greater<long long>> fastestPrevious;

    long long previousSpeedSum = 0;
    long long answer = 0;
    constexpr long long kLongLongMax =
        std::numeric_limits<long long>::max();

    for (const Machine& current : machines) {
        if (current.speed <= 0 || current.reliability <= 0) {
            throw std::invalid_argument("speed and reliability must be positive");
        }

        // 当前机器必须入选,并作为当前枚举的最低可靠性机器。
        if (current.speed > kLongLongMax - previousSpeedSum) {
            throw std::overflow_error("speed sum does not fit in long long");
        }
        const long long speedSum = previousSpeedSum + current.speed;
        if (speedSum > kLongLongMax / current.reliability) {
            throw std::overflow_error("performance does not fit in long long");
        }
        const long long candidate = speedSum * current.reliability;
        answer = std::max(answer, candidate);

        // 当前机器在后续迭代中成为“历史候选”。
        fastestPrevious.push(current.speed);
        previousSpeedSum = speedSum;

        if (fastestPrevious.size() >
            static_cast<std::size_t>(maxMachines - 1)) {
            previousSpeedSum -= fastestPrevious.top();
            fastestPrevious.pop();
        }
    }

    return answer;
}

int main() {
    std::vector<Machine> machines{
        {2, 5},
        {10, 4},
        {3, 3},
    };
    std::cout << maxPerformance(machines, 2) << '\n'; // (2 + 10) * 4 = 48
}

正确性说明

取任意最优团队 S,把其中在可靠性降序序列中最后出现的成员记作 x。扫描到 x 时,S 的其他成员都已扫描过,且数量不超过 K-1。堆维护的是所有已扫描机器中速度最大的至多 K-1 台,因此其速度和不小于 S \ {x} 的速度和。当前候选又强制包含 x,最低可靠性为 reliability(x),所以本轮候选性能不小于 S。算法枚举所有 x,因此不会漏掉全局最优值。

边界与坑:

  • 这是“最多 K 台”,不是必须 K 台;在速度均为正的原题约束下,枚举早期自然覆盖人数不足 K 的团队。
  • 原 LeetCode 1383 要对最终答案取 109+710^9+7。必须先比较未取模的真实性能,最后才取模;不能拿取模后的数比较大小。
  • 乘积范围必须核算。上面的通用接口会在速度和或候选乘积超出 long long 前抛出异常,避免 C++ 有符号溢出的未定义行为;若业务需要返回更大精确值,应把和、乘积、答案与返回类型一起改成 __int128(GCC/Clang 扩展)或大整数。原 LeetCode 约束下 long long 足够。
  • 如果允许负速度,“越多越好”和保留最大 K-1 个的证明需要调整,应忽略非正收益机器。

数字字符串,在任意位置插入加号,求所有插法的表达式总和

算法
入门详解 BEGINNER GUIDE

先把这道题用大白话拆开

长度为 n 的数字串有 n-1 个空隙,每处都可选择加号或直接拼接,因此共有 `2^(n-1)` 种表达式,暴力列举很快爆炸。线性 DP 的关键是:追加一个数字时,不只记录所有表达式总和,还要记录每个表达式“最后一个数字段”的总和。

  1. 处理某个前缀后维护三个旧状态:`ways` 是插法数,`total` 是所有表达式完整值之和,`ending` 是这些表达式最后一段数字之和。首位数字 d₀ 初始化为 `ways=1`、`total=ending=d₀`。

  2. 追加数字 d 时,每个旧表达式分成两支:加号分支把 d 作为新段,完整值增加 d;拼接分支把旧末段 last 变成 `10·last+d`,相对旧完整值增加 `9·last+d`。把所有旧表达式一起求和,就能避免逐个枚举。

  3. 必须用旧值同时计算:`newTotal=2·total+9·ending+2·d·ways`,`newEnding=10·ending+2·d·ways`,`newWays=2·ways`。先存下三个 new,再一起覆盖旧状态;若边算边改 `ways` 或 `ending`,后面的式子会混入本轮新值而得到错误答案。

  4. 若按某个模数计算,三个状态每步取模,时间 `O(n)`、额外空间 `O(1)`。若要求精确答案,结果位数随 n 增长,`long long` 很快溢出,应使用大整数;状态转移仍是 n 轮,但朴素大整数逐块运算的总成本可到 `O(n²)` 字操作,峰值空间 `O(n)`。

  5. 空串通常约定答案 0;前导零若按普通十进制数值解释,递推仍成立。若题目禁止出现带前导零的数字段,就必须增加字段起点等状态,不能直接套这组三变量递推。

记住一句 维护 `ways/total/ending`,用旧值同步更新三式,就能把 `2^(n-1)` 种插法压成一次扫描。

30″ 面试开口
用 DP 同时维护“所有表达式的总值”和“所有表达式末尾数字段之和”,只需从左到右扫描一次。
每个旧表达式只分成两条路径;维护 ways、total、ending 三个累计量,就能一次扫描合并所有指数级组合。
完整详解 原理 · 公式 · 工程取舍 · 常见追问

先明确题意

给定只含数字的字符串,在相邻数字之间的每个空隙,都可以选择“插入 +”或“不插入”。题目求的是 全部 2n12^{n-1} 种插法所得表达式值的总和,包括完全不插加号的情况。

例如 "123" 有四种插法:

123       = 123
1 + 23    = 24
12 + 3    = 15
1 + 2 + 3 = 6
总和      = 168

结论:用 DP 同时维护“所有表达式的总值”和“所有表达式末尾数字段之和”,只需从左到右扫描一次。

DP 状态与推导

处理完某个前缀后,定义:

  • ways:这个前缀的所有插法数量;
  • total:所有表达式完整值之和;
  • ending:所有表达式最后一个数字段的数值之和。

现在追加新数字 d。对每个旧表达式,设它为:

fixed+last.\text{fixed}+\text{last}.

有两种扩展方式。

方式一:不插加号。 最后一段从 last 变成 10last+d10\cdot\text{last}+d,新表达式值为:

oldTotalValue+9last+d.\text{oldTotalValue}+9\cdot\text{last}+d.

对所有旧表达式求和:

noPlusTotal=total+9ending+dways.\text{noPlusTotal} =\text{total}+9\cdot\text{ending}+d\cdot\text{ways}.

方式二:插入加号。 新表达式值就是旧表达式值加 d:

plusTotal=total+dways.\text{plusTotal}=\text{total}+d\cdot\text{ways}.

因此:

newTotal=2total+9ending+2dways\boxed{ \text{newTotal} =2\cdot\text{total} +9\cdot\text{ending} +2d\cdot\text{ways} }

新的末尾字段之和包括:

  • 不插加号分支:10ending+dways10\cdot\text{ending}+d\cdot\text{ways}
  • 插加号分支:每个末尾都是 d,共 ways 个。

所以:

newEnding=10ending+2dways\boxed{ \text{newEnding} =10\cdot\text{ending}+2d\cdot\text{ways} }

而每个旧表达式分裂成两个新表达式:

newWays=2ways.\text{newWays}=2\cdot\text{ways}.

初始时只处理第一个数字 d0d_0

ways=1,total=ending=d0.\text{ways}=1,\quad \text{total}=\text{ending}=d_0.

从单个数字贡献角度再验证

设数字 did_i 后面还有 ni1n-i-1 位。若它在某个数字段中处于 10k10^k 位:

  • 0k<ni10\le k<n-i-1 时,它后面连续 k 个间隙不能加号,第 k+1 个间隙必须加号,其余间隙任意,因此出现次数为 2nk22^{n-k-2}
  • k=ni1k=n-i-1 时,该数字段一直延伸到字符串结尾,左侧 i 个间隙任意,因此出现次数为 2i2^i

所以 did_i 的总贡献系数是:

k=0ni210k2nk2+10ni12i.\sum_{k=0}^{n-i-2}10^k2^{n-k-2} +10^{n-i-1}2^i.

逐位求贡献也能得到答案,但前面的 DP 更容易写成一次扫描。

C++17 精确大整数实现

答案随长度指数增长,long long 很快溢出。下面用十进制大整数所需的最小功能(加法、乘小整数)给出一个不依赖第三方库的精确实现:

#include <algorithm>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

class BigUInt {
public:
    explicit BigUInt(std::uint64_t value = 0) {
        do {
            digits_.push_back(static_cast<std::uint32_t>(value % kBase));
            value /= kBase;
        } while (value != 0);
    }

    BigUInt& operator+=(const BigUInt& rhs) {
        digits_.resize(std::max(digits_.size(), rhs.digits_.size()), 0);
        std::uint64_t carry = 0;
        for (std::size_t i = 0; i < digits_.size(); ++i) {
            const std::uint64_t rhsDigit =
                i < rhs.digits_.size() ? rhs.digits_[i] : 0;
            const std::uint64_t sum = digits_[i] + rhsDigit + carry;
            digits_[i] = static_cast<std::uint32_t>(sum % kBase);
            carry = sum / kBase;
        }
        if (carry != 0) {
            digits_.push_back(static_cast<std::uint32_t>(carry));
        }
        return *this;
    }

    BigUInt& operator*=(std::uint32_t multiplier) {
        if (multiplier == 0) {
            // 保持唯一的零表示,避免输出多余的高位零块。
            digits_.assign(1, 0);
            return *this;
        }

        std::uint64_t carry = 0;
        for (std::uint32_t& digit : digits_) {
            const std::uint64_t product =
                static_cast<std::uint64_t>(digit) * multiplier + carry;
            digit = static_cast<std::uint32_t>(product % kBase);
            carry = product / kBase;
        }
        while (carry != 0) {
            digits_.push_back(static_cast<std::uint32_t>(carry % kBase));
            carry /= kBase;
        }
        return *this;
    }

    friend BigUInt operator+(BigUInt lhs, const BigUInt& rhs) {
        lhs += rhs;
        return lhs;
    }

    friend BigUInt operator*(BigUInt value, std::uint32_t multiplier) {
        value *= multiplier;
        return value;
    }

    friend std::ostream& operator<<(std::ostream& out, const BigUInt& value) {
        out << value.digits_.back();
        for (std::size_t i = value.digits_.size() - 1; i-- > 0; ) {
            out << std::setw(9) << std::setfill('0') << value.digits_[i];
        }
        return out;
    }

private:
    static constexpr std::uint64_t kBase = 1'000'000'000ULL;
    std::vector<std::uint32_t> digits_; // 小端,基数为 10^9
};

BigUInt sumAllPlusInsertions(const std::string& digits) {
    if (digits.empty()) {
        return BigUInt(0);
    }
    for (char c : digits) {
        if (c < '0' || c > '9') {
            throw std::invalid_argument("input must contain digits only");
        }
    }

    BigUInt ways(1);
    BigUInt total(static_cast<unsigned>(digits[0] - '0'));
    BigUInt ending = total;

    for (std::size_t i = 1; i < digits.size(); ++i) {
        const int d = digits[i] - '0';

        const BigUInt digitContribution =
            ways * static_cast<std::uint32_t>(2 * d);
        const BigUInt newTotal =
            total * 2 + ending * 9 + digitContribution;
        const BigUInt newEnding =
            ending * 10 + digitContribution;

        total = newTotal;
        ending = newEnding;
        ways *= 2;
    }
    return total;
}

int main() {
    std::cout << sumAllPlusInsertions("123") << '\n'; // 168
    std::cout << sumAllPlusInsertions("105") << '\n'; // 105 + 6 + 15 + 6 = 132
}

若工程环境已有 Boost,也可以直接用 boost::multiprecision::cpp_int 替代上面的 BigUInt。若题目要求对 M 取模,则把三个状态改成定长整数,并让每次乘加都对 M 取模,此时时间复杂度为 O(n)O(n)、额外空间为 O(1)O(1)。上面的精确版本执行 O(n)O(n) 次大整数运算;状态本身有 O(n)O(n) 个二进制位(或 10910^9 进制块的线性数量),因此在当前逐块加法、乘小整数实现下,总时间为 O(n2)O(n^2) 个字运算,峰值空间为 O(n)O(n)

前导零不影响算法,例如 "001" 中的 0010+01 都按通常十进制数值解释;若题目禁止含前导零的数字段,则必须在 DP 中增加“当前字段起点”状态,不能直接使用上述递推。