跳到主要内容

NVIDIA / HPC 岗面经(牛客网汇总)

来源:牛客网 AI深度学习推理加速HPC类岗位-Nvidia面经


目录


C++ 基础与工程

1. 为什么我们做 C++ 项目的时候,需要写头文件?

答:

头文件(.h/.hpp)的主要作用是:

  • 接口声明:暴露类的定义、函数签名、宏定义给使用者
  • 编译分离:实现编译期类型检查,支持分别编译
  • 避免重复定义:配合 include guard 或 pragma once
  • 代码组织:将实现细节隐藏在 .cpp 中,提高封装性

2. 讲出 static 关键字的一种应用场景

答:

应用场景说明
函数内 static 变量保持状态跨调用,如计数器
文件作用域 static限制符号只在当前文件可见,避免命名冲突
类内 static 成员所有对象共享,如单例模式
类内 static 方法无需对象实例即可调用

典型例子 - 单例模式:

class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // C++11 线程安全
return instance;
}
private:
Singleton() = default;
};

3. 单例模式如何实现?请写出或描述典型实现方式,并说明线程安全问题

答:

Meyers' Singleton(推荐,C++11 起线程安全):

class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // 线程安全初始化
return instance;
}

// 禁止拷贝
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;

private:
Singleton() = default;
~Singleton() = default;
};

双检查锁(DCL,C++11 前):

class Singleton {
public:
static Singleton* getInstance() {
if (instance_ == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mutex_);
if (instance_ == nullptr) { // 第二次检查
instance_ = new Singleton();
}
}
return instance_;
}

private:
static std::atomic<Singleton*> instance_;
static std::mutex mutex_;
};

线程安全说明:

  • C++11 起,函数内 static 变量初始化是线程安全的(由编译器保证)
  • DCL 需要配合 memory barrier,否则可能看到未构造完成的对象

4. C++ 中有哪几种类型转换?各自适用场景

答:

转换类型适用场景安全性
static_cast相关类型转换(如 int→float、基类→派生类指针)编译期检查
dynamic_cast多态类型向下转换运行时检查,失败返回 nullptr
const_cast添加/移除 const/volatile可能未定义行为
reinterpret_cast底层位重新解释(如指针↔整数)最危险

推理场景例子:

// static_cast: 精度转换
float f = static_cast<float>(int_val);

// reinterpret_cast: CUDA 指针转换
void* d_ptr;
cudaMalloc(&d_ptr, size);
float* f_ptr = reinterpret_cast<float*>(d_ptr);

5. 拷贝构造函数中浅拷贝和深拷贝的区别?在什么场景下必须做深拷贝?

答:

特性浅拷贝深拷贝
行为复制指针值,共享资源分配新内存,复制内容
问题双重释放、悬空指针内存开销增加
触发条件默认拷贝构造自定义拷贝构造

必须深拷贝的场景:

  • 类包含指针成员(如动态数组)
  • 资源需要独立管理(如文件句柄、GPU 显存指针)
class Tensor {
float* data_;
size_t size_;
public:
// 深拷贝
Tensor(const Tensor& other) : size_(other.size_) {
data_ = new float[size_];
std::memcpy(data_, other.data_, size_ * sizeof(float));
}

~Tensor() { delete[] data_; }
};

6. 一个类要去访问另一个类的 private 数据成员,该如何设计?

答:

方法适用场景
friend 友元紧密耦合的类,如迭代器访问容器
公共接口(getter)一般情况,保持封装
内部嵌套类天然的访问权限

推荐优先使用公共接口,保持封装性。


量化 Quantization

1. 说说你知道的针对大模型/LLM的量化技术或方案

答:

方法类型特点
GPTQPTQ逐层权重重建,适合离线量化
AWQPTQ保护显著权重,精度友好
SmoothQuantPTQ平滑激活异常值,适合 INT8
LLM.int8()PTQ分离异常值处理
FP8/FP4低精度硬件原生支持,动态范围好

2. SmoothQuant 为什么可以缓解 INT8 LLM 精度下降的问题?

答:

核心问题: LLM 激活值中存在大量 Outliers(异常极大值),导致朴素 INT8 量化时大部分值被压缩到很小范围,精度损失严重。

SmoothQuant 思想:

原始: Y = X · W
平滑: Y = (X · s) · (W / s) = X' · W'

其中 s 是 per-channel 的缩放因子,把激活的难度转移到权重上

为什么有效:

  • 权重分布通常比激活更平滑,对量化更友好
  • 通过数学等价变换,不改变输出结果
  • 让激活和权重都落入适合 INT8 量化的范围

3. bfloat16 和 fp16 在相同内存占用下,主要优缺点分别是什么?

答:

特性FP16BF16
指数位5 bit8 bit
尾数位10 bit7 bit
动态范围较小 (±65504)较大 (±3.4e38)
精度较高较低

优缺点:

FP16:

  • ✅ 精度更高
  • ❌ 容易 overflow/underflow,需要 Loss Scaling

BF16:

  • ✅ 动态范围与 FP32 相同,数值稳定性好
  • ❌ 精度略低,极少数任务难收敛

使用场景:

  • 训练: BF16 更稳定,FP16 需配合 Loss Scaling
  • 推理: 两者皆可,BF16 更适合对数值敏感的场景

3.5 LLM.int8() 的原理是什么?

答:

核心思想: 混合精度分解,将 outlier(异常大值)单独用 FP16 处理。

  1. 按列检测激活中的 outlier(绝对值 > 阈值,如 6.0)
  2. Outlier 列用 FP16 精度计算
  3. 非 outlier 列用 INT8 量化计算
  4. 两部分结果相加
X @ W = X_outlier @ W_outlier (FP16) + X_normal @ W_normal (INT8)
  • ✅ 几乎零精度损失的 INT8 推理
  • ❌ 需要动态检测 outlier,增加实现复杂度;混合精度会降低 Tensor Core 利用率

3.6 AWQ(Activation-aware Weight Quantization)的原理?

答:

核心思想: 不是所有权重都同等重要,保护"重要"权重。

  1. 通过校准数据统计每个权重通道对应的激活幅度
  2. 激活幅度大的通道 → 对应权重更重要 → 量化时给更大的 scale
  3. 等价变换:W_q = Quantize(W * s) / s,s 基于激活幅度搜索最优值
  • ✅ Weight-only 量化(W4A16),适合极致压缩权重、省显存
  • ❌ 只量化权重,激活仍是 FP16

3.7 GPTQ 的原理?

答:

核心思想: 基于二阶信息(Hessian 近似)的逐层权重量化。

  1. 逐层处理:对每一层的权重矩阵做量化
  2. 逐列量化:量化一列后,用 Hessian 信息补偿其他列的误差
  3. 目标:最小化量化后的输出误差 $||WX - \hat{W}X||^2$
  4. 利用 OBS(Optimal Brain Surgeon)的近似方法
  • ✅ 量化精度高(尤其 4-bit),一次量化多次推理
  • ❌ 量化过程较慢(需逐层优化),需要校准数据

3.8 量化方案对比

方法类型量化对象精度适用场景
LLM.int8()PTQW8A8(mixed)零损失推理,但速度一般
SmoothQuantPTQW8A8INT8 全量化,追求吞吐
AWQPTQW4A16较高极致压缩权重,省显存
GPTQPTQW4A16 / W3A16较高离线量化,追求低 bit 精度
FP8PTQ/QATW8A8 (FP8)Hopper 原生支持,部署自然

4. 在实际工程中,量化如何平衡「精度」和「速度/显存」?

答:

评估指标设计:

维度指标
精度Perplexity、下游任务准确率、人工评估
速度Tokens/s、Latency (TTFT/ITL)
显存峰值显存、并发能力
成本$/1M tokens

Trade-off 策略:

  1. 分层量化: Attention 层用 FP16,FFN 层用 INT8
  2. 混合精度: Weight 用 INT4,Activation 用 FP16
  3. 自适应量化: 根据 layer 敏感度选择精度

实验设计:

  • 先在小数据集验证精度损失可接受
  • 再在全量数据评估端到端性能
  • A/B 测试对比用户感知质量

CUDA 与 GPU 优化

1. 讲讲 shared memory bank conflict 的发生场景?出现 bank conflict 会有什么后果?

答:

Bank Conflict 发生场景:

// 假设 bank 宽度为 4 bytes,32 个 banks
__shared__ float shared[256];

// 情况 1: 无 conflict - 每个线程访问不同 bank
float x = shared[threadIdx.x]; // stride=1

// 情况 2: 2-way conflict - 相邻线程访问同一 bank 不同地址
float x = shared[threadIdx.x * 2]; // stride=2

// 情况 3: 32-way conflict - 所有线程访问同一 bank
float x = shared[threadIdx.x * 32]; // stride=32

后果:

  • 同一 warp 内访问同一 bank 不同地址时,访问被串行化
  • 32-way conflict 会导致 32 倍延迟!

解决方法:

  1. 改变访问模式: 使用 stride 避免冲突
  2. Padding: shared[256][33] 而非 [256][32]
  3. 广播访问: 所有线程访问同一地址是广播,无 conflict

2. CUDA 里面如何分配 GPU 显存?为什么相关 API 里参数是二级指针?

答:

分配方式:

// 方式 1: cudaMalloc - 设备内存
float* d_ptr;
cudaMalloc(&d_ptr, size); // 二级指针

// 方式 2: cudaMallocHost - 锁页主机内存
float* h_ptr;
cudaMallocHost(&h_ptr, size);

// 方式 3: cudaMallocManaged - 统一内存
float* u_ptr;
cudaMallocManaged(&u_ptr, size);

为什么是二级指针?

// cudaMalloc 内部需要修改调用者的指针值
// 一级指针无法修改调用者的指针(传值)
// 二级指针可以修改指针本身的值(传址)

cudaError_t cudaMalloc(void** devPtr, size_t size) {
void* ptr = internal_alloc(size); // 内部分配
*devPtr = ptr; // 通过二级指针修改外部指针
return cudaSuccess;
}

2.5 GPU 线程层次结构:Grid / Block / Thread / Warp

答:

Grid(网格)
├── Block 0(线程块 / CTA)
│ ├── Warp 0: Thread 0~31
│ ├── Warp 1: Thread 32~63
│ └── ...
├── Block 1
│ └── ...
└── ...
概念说明
ThreadGPU 最小执行单元,每个 thread 执行相同的 kernel 代码
Warp32 个连续 thread 组成,SIMT 执行的基本单位,warp 内 thread 执行相同指令
Block(CTA)多个 warp 组成,共享 Shared Memory,在一个 SM 上执行,block 内可同步(__syncthreads()
Grid所有 block 的集合,对应一次 kernel launch

高频追问:

  • Warp divergence:同一 warp 内 thread 走不同 if/else 分支 → GPU 串行执行两个分支,浪费算力
  • 一个 block 最多 1024 个 thread,实际要根据 register 和 shared memory 用量考虑 occupancy
  • 一个 block 完整运行在一个 SM 上,一个 SM 可以同时运行多个 block

2.6 GPU 存储层次结构:L0/L1/L2/Global Memory、SRAM/HBM

答:

┌────────────────────────────────────────────┐
│ Global Memory (HBM) │ ← 容量大(80GB),带宽 3.35TB/s,延迟 ~400 cycles
│ ┌──────────────────────────────────────┐ │
│ │ L2 Cache (SRAM) │ │ ← 全 GPU 共享,50MB (H100)
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Per-SM: L1 Cache / │ │ │ ← 每个 SM 私有
│ │ │ Shared Memory (SRAM) │ │ │ ← 可配置划分,延迟 ~20-30 cycles
│ │ │ ┌──────────────────────────┐ │ │ │
│ │ │ │ Register File │ │ │ │ ← 每个 thread 私有,延迟 ~1 cycle
│ │ │ │ L0 Instruction Cache │ │ │ │ ← Warp scheduler 指令缓存
│ │ │ └──────────────────────────┘ │ │ │
│ │ └────────────────────────────────┘ │ │
│ └──────────────────────────────────────┘ │
└────────────────────────────────────────────┘
存储层级位置容量延迟作用域
RegisterSM 内每 SM 256KB~1 cycleThread 私有
L0 Instruction CacheSM 内极低Warp scheduler 用
L1 / Shared MemorySM 内(SRAM)~192-256KB/SM~20-30 cyclesBlock 内共享(Shared Memory)或自动缓存(L1)
L2 Cache全芯片(SRAM)~50MB (H100)~200 cycles全 GPU 共享
Global Memory (HBM)芯片外80GB (H100)~400 cycles全 GPU + Host 可访问

SRAM vs HBM 核心区别:

  • SRAM(L1/Shared/L2):on-chip,速度极快(数十 TB/s),容量极小(KB~MB)
  • HBM(Global Memory):off-chip,速度相对慢(几 TB/s),容量大(数十 GB)
  • FlashAttention 的核心就是让 attention 中间结果留在 SRAM 中,避免写回 HBM

2.7 Tensor Core

答:

Tensor Core 是 GPU 中专门用于矩阵乘加(MMA)的硬件单元,每个时钟周期可完成一个小矩阵乘法。

架构支持精度说明
Volta (V100)FP16首次引入 Tensor Core
Ampere (A100)FP16, BF16, TF32, INT8, INT4扩展精度支持
Hopper (H100)增加 FP8990 TFLOPS (BF16) vs 67 TFLOPS (FP32 CUDA Core)
Blackwell (B200)FP4, FP6 等更多低精度支持

为什么 Tensor Core 对 decode 帮助有限? 因为 decode(batch=1)做的是 GEMV(矩阵×向量),规模太小无法喂满 Tensor Core。Tensor Core 在大规模 GEMM(如 prefill)中收益更大。

2.8 TMA(Tensor Memory Accelerator)

答:

TMA 是 Hopper 架构引入的硬件单元,在 HBM 和 Shared Memory 之间高效搬运多维张量。

核心能力:

  • 支持多维张量的异步拷贝(不阻塞 SM 计算)
  • 自动处理地址计算、边界检查、padding
  • 支持 multicast(一次读取分发给多个 SM)
传统方式:Thread 手动计算地址 → 发起 load → 等待 → SM 大量时间浪费在地址计算和等待上
TMA 方式:TMA 单元接收描述符 → 自动搬运整块数据到 Shared Memory → 计算与搬运完全 overlap

FlashAttention-3 利用 TMA 实现更高效的 tiling,让 SM 专注于计算。

3. 为了优化 CUDA 程序的访存效率,你可以想到哪些手段?

答:

优化手段说明
Coalesced Memory Access确保线程按顺序访问连续地址,合并内存事务
Shared Memory利用片上高速缓存,减少全局内存访问
缓存重用设计算法提高数据局部性
对齐访问确保访问地址对齐到 128 bytes
向量化加载使用 float4/int4 等宽向量指令
零拷贝使用 Unified Memory 或 Zero Copy

4. 为了优化 CUDA 程序的计算效率,你又可以想到哪些?

答:

优化手段说明
Occupancy提高 SM 占用率,隐藏延迟
指令级并行 (ILP)一个线程执行多条独立指令
Kernel Fusion合并小 kernel,减少 launch overhead
减少分支发散避免 warp 内线程走不同分支
使用 Tensor Core对矩阵运算使用 WMMA/mma.sync
循环展开减少循环控制开销

大模型理论与推理优化

1. 说出你知道的典型 encoder-only / decoder-only / encoder-decoder 结构的模型,各自优缺点

答:

架构代表模型Attention 类型典型任务
Encoder-onlyBERT, RoBERTa, ALBERT, DeBERTa双向(Bidirectional)全注意力文本分类、NER、句子相似度
Decoder-onlyGPT 系列, LLaMA, Qwen, DeepSeek因果(Causal)单向注意力文本生成、对话、代码补全
Encoder-DecoderT5, BART, mBART, Flan-T5Encoder 双向 + Decoder 因果 + Cross Attention翻译、摘要、问答

Encoder-only 优缺点:

  • ✅ 双向上下文建模能力强,适合理解类任务;模型相对较小,推理快
  • ❌ 不擅长生成任务(没有自回归机制);在生成式 AI 浪潮中逐渐被取代

Decoder-only 优缺点:

  • ✅ 天然适合自回归生成,scaling law 表现最好;涌现能力(ICL, CoT)主要在此架构上观察到
  • ❌ 单向注意力在纯理解任务上不如双向模型;长序列 KV Cache 显存线性增长

Encoder-Decoder 优缺点:

  • ✅ 输入输出可以有不同长度和结构,天然适合 seq2seq 任务;Encoder 做理解、Decoder 做生成,分工明确
  • ❌ 架构更复杂,参数效率不如 decoder-only;scaling 方面不如 decoder-only 有优势

为什么 Decoder-only 成了主流:

  1. Scaling law 在 decoder-only + next-token prediction 上表现最好
  2. 统一的自回归范式可以同时处理理解和生成
  3. 工程上 KV Cache、continuous batching 等优化都是针对 decoder-only 设计
维度Encoder-onlyDecoder-onlyEncoder-Decoder
生成能力
理解能力中等(大模型后追上来了)
Scaling 表现一般最好一般
KV Cache不需要需要Encoder KV + Decoder KV
推理优化生态较少最成熟中等
当前主流地位衰退绝对主流衰退

2. 随着序列长度增加,encoder-only 和 decoder-only 模型的计算量与访存量变化趋势

答:

模型类型计算复杂度访存趋势
Encoder-onlyO(n²) - 一次性算完随序列增长,但无 KV Cache 累积
Decoder-onlyO(n²) 每步,总 O(n³)KV Cache 线性增长,带宽瓶颈

Decoder-only 的特殊性:

Step 1: 计算 token 1 的 KV
Step 2: 计算 token 2 的 KV,读取 token 1 的 KV
Step 3: 计算 token 3 的 KV,读取 token 1,2 的 KV
...
Step n: 读取 n-1 个 KV

总计算量: O(n³)
KV Cache: O(n) 存储,但 O(n) 带宽每步

3. 说说你知道的大模型训练或推理的常见优化手段

答:

类别方法
并行策略流水线并行、张量并行、序列并行
显存优化量化、KV Cache 优化、Activation Checkpointing
计算优化FlashAttention、算子融合、CUDA Graph
调度优化Continuous Batching、PD 分离、Speculative Decoding

4. 一般会对哪些大模型中的算子做「算子融合」?举几个典型例子

答:

典型融合场景:

融合算子融合内容收益
RMSNorm1/x² → multiply → scale减少 kernel launch,中间结果存 SRAM
GeLU/SiLULinear → Activation避免写回 HBM
QKV Projection三个 Linear 合并减少启动开销
AttentionQK^T → Softmax → V 合并FlashAttention 核心

5. 请讲讲 FlashAttention 的原理?为什么它能极大提升速度并节省显存?

答:

核心思想:IO-Aware 计算

传统 Attention:
HBM: Q, K, V (大矩阵)
↓ 加载到 SRAM
SRAM: 计算 S = QK^T
↓ 写回 HBM
HBM: S (大矩阵)
↓ 加载到 SRAM
SRAM: 计算 P = softmax(S)
↓ 写回 HBM
HBM: P (大矩阵)
... 大量 HBM 读写

FlashAttention:
HBM: Q, K, V
↓ 分块加载
SRAM: 每次处理小块,在 SRAM 内完成所有计算
只输出最终结果到 HBM
使用 Online Softmax 技巧

收益来源:

  1. 减少 HBM 访问 - 从 O(N²d) 降到 O(N²d²/M)(M 为 SRAM 大小),不需要将 N×N 注意力矩阵写回 HBM
  2. 节省显存 - 不需要存储中间注意力矩阵
  3. 速度提升 - HBM 带宽不再是瓶颈

5.5 FlashAttention v1 → v2 → v3 的演进

答:

FlashAttention v2 改进(在 v1 基础上):

  • 更好的并行化:外层循环改为遍历 Q blocks(v1 是遍历 K/V blocks),每个 Q block 结果在 SRAM 中累积完后一次性写回,减少 HBM 读写
  • 减少非矩阵乘 FLOPs:优化 softmax 统计量更新,减少 warp 间同步
  • 更好的 work partitioning:在 warp 之间更均匀分配工作
  • 速度比 v1 快约 2x,达到理论 FLOPS 的 50-73%

FlashAttention v3 改进(面向 Hopper 架构):

  • 利用 TMA:用 Tensor Memory Accelerator 做异步数据搬运,解放 SM
  • Warp Specialization:不同 warp 分别负责数据加载和计算,流水线化
  • FP8 支持:原生支持 FP8 精度
  • Ping-pong scheduling:双缓冲调度,让 TMA 加载和 WGMMA 计算完全重叠
  • 在 H100 上比 v2 快约 1.5-2x
维度FA v1FA v2FA v3
核心思想Tiling + Online Softmax更好的并行化Hopper 硬件特性利用
目标硬件A100A100/H100H100 (Hopper)
数据搬运Thread 手动加载Thread 手动加载TMA 异步搬运
FP8 支持
相对速度1x~2x~3-4x

总结:v1→v2 是算法层面改进(更好的并行化),v2→v3 是硬件层面利用(TMA、Warp Specialization)。

5.6 什么是 FlashDecoding?

答:

问题: FlashAttention 并行维度是 batch × head × Q_block。Decode 阶段 Q 只有 1 个 token,如果 batch 小、head 少,并行度不足以喂满 GPU。

FlashDecoding 做法:KV sequence 维度 上增加并行。

传统 decode attention: 一个 thread block 串行遍历整个 KV 序列

FlashDecoding: 将 K/V 序列切成多个分片 → 每个分片由不同 thread block 并行处理
→ 每个分片独立计算 partial softmax 和 partial output
→ 最后一次 reduction 合并(用 online softmax 技巧保证数值正确)
  • 长序列 decode 速度提升 8x+
  • 特别适合 long context + small batch 场景

6. 请讲讲 PagedAttention 的原理?为什么它能极大提升推理速度?与 FlashAttention 的本质区别?

答:

PagedAttention 核心:

传统 KV Cache 分配:
请求1: [████████████████] 预分配 2048
请求2: [████████████████] 预分配 2048
实际只用 512 实际只用 1024
浪费 75% 浪费 50%

PagedAttention:
┌─────────────────────────────────────┐
│ 请求1: [Block0][Block1][空闲] │
│ 请求2: [Block3][空闲][Block4] │
│ 物理: [B0][B3][B1][B4][空闲] │
└─────────────────────────────────────┘
按需分配 Block,消除碎片

提升速度的原因:

  1. 更高显存利用率 - 可以服务更多并发请求
  2. 支持 Continuous Batching - 动态插入新请求
  3. 减少显存碎片 - 高效利用每一字节

与 FlashAttention 的区别:

特性FlashAttentionPagedAttention
优化目标单步 Attention 计算效率KV Cache 显存管理
技术层次Kernel 层优化系统/调度层优化
解决的问题Attention 计算中的 HBM 瓶颈KV Cache 分配碎片
关系互补,可叠加使用互补,可叠加使用

GPU 硬件与编程模型补充

H100 相比 A100 有哪些改进

答:

维度A100 (Ampere)H100 (Hopper)改进倍数
Tensor Core第 3 代(FP16/BF16/TF32/INT8)第 4 代(新增 FP8~3x FLOPS
BF16 峰值算力312 TFLOPS990 TFLOPS~3.2x
FP8 峰值算力不支持1979 TFLOPS全新
NVLink第 3 代,600 GB/s第 4 代,900 GB/s1.5x
HBMHBM2e,2.0 TB/s,80GBHBM3,3.35 TB/s,80GB1.67x
L2 Cache40 MB50 MB1.25x
SM 数量1081321.22x

Hopper 架构关键新特性:

  1. Transformer Engine:硬件级自动 FP8 混合精度,自动选择 FP8/FP16 粒度
  2. Thread Block Clusters:多个 Block 可组成 Cluster,共享跨 SM 的数据(Distributed Shared Memory)
  3. TMA(Tensor Memory Accelerator):硬件异步搬运多维张量,解放 SM 做计算
  4. DPX 指令:加速动态规划类算法
  5. Confidential Computing:硬件级 AI 安全保护

面试重点: H100 对 LLM 最重要的改进是 FP8 Tensor Core + TMA + 更高 NVLink 带宽,使得 FlashAttention-3 和 FP8 训练成为可能。

Triton 和 CUDA 区别

答:

维度CUDATriton
开发者NVIDIAOpenAI
编程语言C/C++ + CUDA 扩展Python
抽象级别低(手动管理线程、共享内存、同步)高(自动 tiling、自动共享内存管理)
控制粒度Thread 级别Block (tile) 级别
内存管理手动(shared memory load/store)自动(编译器优化 tiling 和缓存)
编译目标PTX → SASSMLIR → PTX → SASS
学习曲线陡峭平缓
性能上限最高(手工极致优化)接近 CUDA(通常 80-95%)
调试难度中等
生态最成熟,文档/工具丰富快速发展,PyTorch 生态集成

Triton 核心思想:

# Triton: 以 block/tile 为粒度编程
@triton.jit
def add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr):
pid = tl.program_id(0) # 获取 block ID
offsets = pid * BLOCK + tl.arange(0, BLOCK) # 自动向量化
mask = offsets < n
x = tl.load(x_ptr + offsets, mask=mask) # 自动合并访存
y = tl.load(y_ptr + offsets, mask=mask)
tl.store(out_ptr + offsets, x + y, mask=mask)

选择建议:

  • 写自定义 kernel(Flash Attention 变体、融合算子):优先 Triton(开发效率高)
  • 极致性能优化(最后 5-10%):CUDA
  • 需要 Warp 级控制(warp shuffle、inline PTX):只能 CUDA

BLOCK SIZE 选择原则

答:

BLOCK SIZE(每个 Block 的线程数)直接影响 GPU 利用率,通常选择 64、128 或 256

权衡因素:

因素BLOCK SIZE 过小BLOCK SIZE 过大
OccupancyWave 内 block 多,但每 block 线程少每 block 用 register/shared memory 多,SM 能放的 block 少
Register 压力高(可能溢出到 local memory)
Shared Memory用量少用量大,限制并发 block 数
Warp 数量少,难以隐藏延迟多,更好隐藏 memory latency
计算粒度Tile 小,数据复用低Tile 大,数据复用好

实践指南:

1. 确保 BLOCK SIZE 是 Warp 大小(32) 的倍数
2. 查看 SM 资源限制:
- Register: 每 SM 65536 个,每 thread 使用 N 个 → 最多 65536/N 个 thread
- Shared Memory: 每 SM 48-164KB
3. 使用 CUDA Occupancy Calculator 计算最优值

常见选择:
- GEMM kernel: 128 或 256(需要大 tile 做数据复用)
- Elementwise kernel: 256 或 512(简单计算,追求高占用率)
- Reduction kernel: 256(平衡并行度和同步开销)

CUDA Tile 编程模型

答:

Tiling 是 GPU 编程的核心思想:将大规模计算分解为适合 SRAM 的小块(tile),减少对全局内存的访问。

基本模式:

全局内存 (HBM)          共享内存 (SRAM)          寄存器
┌──────────┐ ┌──────────┐ ┌──────┐
│ A[M×K] │ ──load──→ │ As[T×T] │ ──load──→ │ 计算 │
│ B[K×N] │ │ Bs[T×T] │ │ 结果 │
│ C[M×N] │ ←─store── │ Cs[T×T] │ ←─store── │ │
└──────────┘ └──────────┘ └──────┘

GEMM Tiling 经典实现(C = A × B):

// 每个 Block 计算 C 的一个 TILE_M × TILE_N 子块
__global__ void gemm_tiled(float* A, float* B, float* C, int M, int N, int K) {
__shared__ float As[TILE_M][TILE_K]; // A 的 tile 缓存
__shared__ float Bs[TILE_K][TILE_N]; // B 的 tile 缓存
float acc = 0.0f; // 寄存器累加

for (int t = 0; t < K; t += TILE_K) {
// Step 1: 从全局内存加载 tile 到共享内存
As[ty][tx] = A[row * K + (t + tx)];
Bs[ty][tx] = B[(t + ty) * N + col];
__syncthreads(); // 等待所有线程加载完成

// Step 2: 在共享内存中计算(数据复用)
for (int k = 0; k < TILE_K; k++)
acc += As[ty][k] * Bs[k][tx];
__syncthreads(); // 等待计算完成再加载下一个 tile
}

// Step 3: 写回全局内存
C[row * N + col] = acc;
}

为什么 Tiling 有效:

  • 全局内存访问 A 的同一行被 TILE_N 个线程复用 → 减少 TILE_N 倍访存
  • 共享内存带宽 ~19 TB/s vs HBM ~3.35 TB/s → 快 ~5-6 倍
  • FlashAttention 本质就是 Attention 的 Tiling 实现

高级 Tiling 技巧:

  1. 双缓冲(Double Buffering):加载下一个 tile 同时计算当前 tile
  2. 寄存器分块(Register Tiling):每个线程计算多个输出元素
  3. Warp 级 Tiling:利用 Tensor Core 的 wmma 指令

介绍下 Ray 框架

答:

Ray 是 UC Berkeley RISELab(现 Anyscale 公司)开发的分布式计算框架,核心特点是统一的分布式 API

核心组件:

组件功能典型用途
Ray CoreTask(无状态函数)+ Actor(有状态对象)的分布式抽象通用分布式计算
Ray Train分布式训练多 GPU/多节点训练
Ray Serve模型推理服务在线 serving
Ray Data分布式数据处理数据预处理流水线
RLlib分布式强化学习RL 训练

为什么在 LLM 领域重要:

传统 RL 训练:
手动管理 Actor/Critic/Reward/Reference 4 个模型的分布式调度 → 工程量巨大

Ray 方案(如 OpenRLHF):
@ray.remote(num_gpus=4)
class ActorWorker:
def generate(self, prompts): ...
def train_step(self, batch): ...

@ray.remote(num_gpus=2)
class RewardWorker:
def score(self, responses): ...

# Ray 自动处理调度、通信、容错
futures = [actor.generate.remote(batch) for batch in batches]
results = ray.get(futures)

Ray 的核心优势:

  1. 动态资源调度:按需分配 GPU,训练和推理资源可动态切换
  2. 容错恢复:Worker 失败自动重启
  3. 灵活编排:不同模型可以用不同并行策略
  4. Python 原生:API 简洁,学习成本低
  5. 生态丰富:与 PyTorch、vLLM、DeepSpeed 等集成

在 RLHF 中的应用: OpenRLHF、veRL 等框架使用 Ray 编排 PPO/GRPO 训练中的多模型协作,Actor 生成、Reward 打分、Critic 评估可以在不同 GPU 组上并行执行。


面试金句

"FlashAttention 解决的是 Attention 计算中的 HBM 瓶颈,通过分块计算减少显存访问;PagedAttention 解决的是 KV Cache 显存分配碎片问题,通过虚拟内存分页机制提高利用率。两者互补,可叠加使用。"

"SmoothQuant 的核心思想是通过数学等价变换,将激活中的异常值平滑到权重上,让两者都落入适合 INT8 量化的范围。"

"BF16 和 FP16 内存占用相同,但 BF16 有更大的动态范围(与 FP32 相同),数值稳定性更好,训练时不需要 Loss Scaling。"

CUDA 编程进阶面试题

什么是 Coalesced Memory Access(合并访存)

答:

GPU 访问 HBM 不是按单个字节读的,而是按 128 字节的 cache line 为单位读取。

Coalesced Access(合并访存): 同一个 warp 的 32 个 thread 访问连续内存地址,硬件合并成一次或几次 cache line 读取。带宽利用率接近 100%。

Non-Coalesced Access(非合并访存): 32 个 thread 访问分散地址,每个请求落在不同 cache line,搬了大量数据但只用其中一小部分。带宽利用率可能低到 3%。

// ✅ Coalesced:相邻 thread 读相邻地址
float val = A[threadIdx.x]; // thread 0 读 A[0], thread 1 读 A[1]...
// 32 个 thread 的请求合并成 1 次 cache line 事务

// ❌ Non-Coalesced:相邻 thread 读间隔地址
float val = A[threadIdx.x * stride]; // thread 0 读 A[0], thread 1 读 A[1024]...
// 32 次独立 cache line 事务,搬 32×128=4096 字节,只用 128 字节,浪费 97%

性能影响: coalesced vs non-coalesced 可以差 10-30 倍。这就是为什么 MatMul 要用 tiling + shared memory——先按合并模式从 HBM 搬到 shared memory,shared memory 里随便怎么跳都很快。

什么是 Warp Divergence

答:

GPU 执行单位是 warp(32 个连续 thread),同一 warp 必须在同一时刻执行同一条指令(SIMT)。

当 warp 内 thread 走不同 if/else 分支时: 硬件只能串行执行两条路径——先让走 if 的 thread 执行(其余闲等),再让走 else 的 thread 执行(其余闲等)。

// ❌ Warp Divergence:同一 warp 内一半走 if,一半走 else
if (threadIdx.x % 2 == 0) {
C[i] = A[i] + B[i]; // 16 个 thread 执行,16 个闲等
} else {
C[i] = A[i] - B[i]; // 16 个 thread 执行,16 个闲等
}
// 执行时间 = 两条路径之和,效率减半

// ✅ 无 Divergence:分支以 warp 为单位对齐
if (threadIdx.x / 32 == 0) {
// warp 0 全部走这里
} else {
// warp 1 全部走这里 → 不同 warp 走不同路径完全没问题
}

向量加法的边界检查 if (i < N) 影响很小: 只有最后一个 block 的最后一个 warp 可能 divergence,其他 warp 全部满足条件。

Block 内 Reduction 的完整实现

答:

Reduction(求和/求 max)是几乎所有 kernel 的基础(RMSNorm 求平方和、Softmax 求 max 和 sum 都依赖它)。256 个 thread 怎么高效地把 256 个数加成 1 个数:

两阶段方案:Warp Shuffle + Shared Memory

阶段一:Warp 内 Shuffle(8 个 warp 各自内部归约)
256 个 thread → 8 个 warp,每个 warp 32 个 thread
Warp Shuffle 过程(以一个 warp 为例):
32 个值
→ 第1轮:跟距离16的 thread 交换并相加 → 16个有效值
→ 第2轮:跟距离8的交换并相加 → 8个
→ 第3轮:距离4 → 4个
→ 第4轮:距离2 → 2个
→ 第5轮:距离1 → 1个
5 轮完成,warp 内 32 个数归约成 1 个 partial sum

8 个 warp 得到 8 个 partial sum

阶段二:跨 Warp 归约(通过 Shared Memory)
8 个 partial sum 写入 shared memory
→ 1 个 warp 读出 8 个值
→ 再做一轮 warp shuffle → 最终结果
→ thread 0 广播给所有 thread

为什么不全用 Shared Memory: warp shuffle 在寄存器间直接交换,不需要 __syncthreads() 同步,延迟更低。

为什么不全用 Warp Shuffle: shuffle 只能在 32 个 thread 的 warp 内部工作,跨 warp 必须经过 shared memory。

SM(Streaming Multiprocessor)和 Occupancy

答:

SM 是 GPU 的基本计算单元,每个 SM 包含 CUDA Core、Tensor Core、寄存器文件、Shared Memory、Warp Scheduler。

GPUSM 数量每 SM 寄存器每 SM Shared Memory
A100108256KB164KB(可配置)
H100132256KB228KB(可配置)

SM 和 Block 的关系:

  • 一个 Block 被分配到一个 SM 上执行,不会迁移
  • 一个 SM 可以同时运行多个 Block(取决于资源)
  • 不同 Block 之间不能通过 Shared Memory 通信(可能在不同 SM 上)

Occupancy = SM 上实际活跃 warp 数 / SM 最大支持 warp 数

Occupancy 低的原因:
1. Block 用了太多寄存器 → SM 放不下多个 Block → warp 少 → 无法隐藏延迟
2. Block 用了太多 Shared Memory → 同理
3. Block 的 thread 数太少 → warp 少

Occupancy 不一定越高越好:
- 有时低 occupancy 但每个 thread 用更多寄存器可以减少 memory spill
- 关键看整体吞吐,不是单一指标
- 但通常 occupancy > 50% 是比较健康的

CUDA 向量加法 Kernel(入门必会)

答:

// Kernel 函数
__global__ void vector_add(float* A, float* B, float* C, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x; // 全局索引
if (i < N) { // 边界检查
C[i] = A[i] + B[i];
}
}

// 主机端调用
int block_size = 256;
int grid_size = (N + block_size - 1) / block_size; // 向上取整
vector_add<<<grid_size, block_size>>>(A, B, C, N);

关键概念:

  • blockIdx.x * blockDim.x + threadIdx.x:全局唯一编号
  • 最后一个 block 有多余 thread(如 N=100000, 391×256=100096),多出 96 个 thread 通过 if (i < N) 跳过

所有复杂 kernel 都是在这个模式上扩展: RMSNorm 加 reduction、Softmax 加三趟扫描、MatMul 加 tiling。

Softmax Kernel 的三趟扫描

答:

手写 Softmax kernel 需要三趟扫描,每趟是一次 block 内 reduction:

第一趟:求 max(数值稳定性)
扫描整行,找最大值 m = max(x)
→ block 内 reduction(warp shuffle + shared memory)

第二趟:求 exp 之和
再扫描一遍,算 sum = Σ exp(x_i - m)
→ block 内 reduction

第三趟:归一化
再扫描一遍,算 softmax(x_i) = exp(x_i - m) / sum
→ 逐元素计算,无需 reduction

为什么不能合并: 第二趟需要第一趟的全局 max,第三趟需要第二趟的全局 sum。每趟都依赖上一趟的全局结果。

Online Softmax 的改进: 通过维护 running max + running sum 加修正系数 exp(old_max - new_max),把三趟压缩成一趟。FlashAttention 正是靠这个实现分块流式计算。

MatMul 的 Tiling + Shared Memory 详解

答:

没有 Shared Memory 的问题:

计算 C 的一个 32×32 小块时,A 的同一行被 32 个 thread 重复读(因为它们算 C 同一行的不同列),大量冗余 HBM 读取。

Tiling + Shared Memory 的做法:

for each tile along K dimension:
// Step 1: 所有 thread 协作,把 A 和 B 各一个 tile 从 HBM 搬到 Shared Memory
As[ty][tx] = A[row * K + (t + tx)];
Bs[ty][tx] = B[(t + ty) * N + col];
__syncthreads();

// Step 2: 在 Shared Memory 内做计算(数据被多个 thread 复用)
for (int k = 0; k < TILE_K; k++)
acc += As[ty][k] * Bs[k][tx];
__syncthreads();

// Step 3: 写回 HBM
C[row * N + col] = acc;

为什么有效:

  • A 的 tile 从 HBM 只读 1 次到 Shared Memory,然后被 32 个 thread 复用 32 次
  • HBM 读取量下降一个数量级
  • 本质:用高带宽的片上存储(19 TB/s)换低带宽的 HBM 访问(2-3 TB/s)

面试金句

"H100 相比 A100 的核心提升:FP8 Tensor Core(算力 2x)、第 4 代 NVLink(900GB/s)、HBM3(3.35TB/s)、TMA 异步搬运,使 FlashAttention-3 和 FP8 训练成为可能。"

"Triton 是 Python 级的 GPU 编程语言,以 Block/Tile 为粒度编程,自动处理 tiling 和内存管理,开发效率远高于 CUDA,性能可达 CUDA 的 80-95%。"

"CUDA Tile 编程的核心:将大计算分解为适合 SRAM 的小块,从全局内存加载 → 共享内存计算 → 写回全局内存,通过数据复用减少访存次数。"

"Coalesced access 是同一 warp 的 32 个 thread 访问连续内存地址,硬件合并成少量 cache line 事务。Non-coalesced 地址分散,性能差距 10-30 倍。"

"Block 内 reduction 标准做法:warp 内用 shuffle 归约(寄存器级,无需同步),跨 warp 用 shared memory 中转。两阶段结合兼顾速度和通用性。"