字节跳动 AI Infra 一面 (2)
Q: CUDA编程一般怎么优化?
CUDA优化的核心思路:根据kernel的bound类型选择对应策略,通过Profiling驱动迭代优化。
1. 访存优化(Memory-bound kernel的首要任务):
合并全局内存访问(Coalesced Access):
1 | // 好:相邻线程访问相邻地址 → 一次128字节事务 |
Warp内32线程访问128字节对齐的连续地址时,硬件合并为1次内存事务。非合并访问可能产生32次独立事务,带宽利用率降至1/32。
共享内存缓存:
1 | __shared__ float tile[BLOCK_SIZE][BLOCK_SIZE + 1]; // +1消除bank conflict |
Global memory延迟400 cycles,Shared memory延迟20 cycles。数据复用N次时,总延迟从N×400降为400+N×20。
向量化读写:
1 | float4 vec = *reinterpret_cast<float4*>(&input[idx]); // 128位单次传输 |
要求地址16字节对齐。FP16情况下用half2/half4。
避免Bank Conflict:
1 | // 32个bank,每bank连续4字节 |
2. 计算优化(Compute-bound kernel的重点):
利用Tensor Core:
1 | // WMMA API (16×16×16 FP16矩阵乘) |
减少Warp Divergence:
1 | // 差:同warp线程走不同分支 |
循环展开:
1 | #pragma unroll 4 // 展开4次,暴露ILP |
展开后编译器可以交错不同迭代的指令,隐藏流水线延迟(指令级并行ILP)。
快速数学函数:__expf()/__rsqrtf()等用硬件SFU单元,比标准函数快5-10x(精度损失<2 ULP)。
3. 并行度优化:
提高Occupancy:
- 减少每线程寄存器用量:
__launch_bounds__(256, 2)限制编译器。 - 合理划分shared memory:不是越多越好(过多则block数受限)。
- Block大小选128或256(通常最优)。
注意:高Occupancy不是目标——如果增加Occupancy需要spill寄存器到local memory(400 cycle延迟),反而更慢。真正的目标是最大化性能,Occupancy只是手段之一。
4. 延迟隐藏:
Double Buffering:
1 | // 阶段1: 加载buffer_0 |
5. 算子融合:
- 合并多个小kernel减少:launch开销(~5us)×N次 + 中间tensor HBM写入/读取。
- 典型收益:3个小op融合后加速2-5x(对memory-bound操作)。
6. Profiling驱动:
- 不要靠猜测优化——先Profile确定瓶颈,再针对性优化。
- Nsight Compute的Roofline图一目了然地告诉你”离峰值还有多远、在哪个方向”。
Q: 卷积如何优化?
卷积优化的核心是将其转化为高效的矩阵运算或利用数学恒等变换减少计算量:
方法对比:
| 方法 | 原理 | 适用场景 | 加速比(vs naive) | 缺陷 |
|---|---|---|---|---|
| Im2col+GEMM | 展开为矩阵乘 | 通用 | 10-50x | 额外内存(9x for 3×3) |
| Winograd | 减少乘法次数 | 3×3, stride=1 | 比GEMM再快2-4x | 数值精度略差 |
| FFT | 频域卷积 | kernel>7×7 | 大kernel快 | 小kernel开销大 |
| 隐式GEMM | 不显式展开 | 通用(cuDNN默认) | 接近GEMM性能 | 索引计算复杂 |
| Direct | 直接滑窗 | 特殊形状 | 低 | 难以充分优化 |
Im2col + GEMM详解:
1 | 输入: [N, C_in, H, W] kernel: [C_out, C_in, kH, kW] |
优势:直接调用cuBLAS的高度优化GEMM(利用Tensor Core)。
代价:3×3 conv展开后输入增大9倍——但对于中等特征图这通常可接受。
Winograd F(m,r) 变换:
- F(2,3):2×2输出tile + 3×3 kernel → 在4×4输入tile上计算。
- 乘法次数:从3×3=9次/输出降为4×4/4=4次/输出(减少56%)。
- F(4,3):进一步减少到4×4/(16-6)=2.25次/输出。
- 为什么cuDNN 3×3 conv默认选Winograd:在有限的精度损失下获得30-50%额外加速。
- 限制:stride>1或dilation>1时不适用;r越大数值稳定性越差。
数据Layout对性能的影响:
- NCHW(PyTorch默认):channel连续,适合CPU SIMD(按channel向量化)。
- NHWC(TensorFlow/cuDNN推荐):HWC连续,Tensor Core需要16字节对齐的连续channel数据。
- A100上NHWC比NCHW快10-30%(Tensor Core更高效利用)。
cuDNN自动选择最优算法:
1 | cudnnFindConvolutionForwardAlgorithm(handle, ..., &algo); |
cuDNN会根据输入shape/kernel/stride自动选择Winograd/ImplicitGEMM/FFT等。实际部署中通常让cuDNN auto-tune(首次慢一点,后续用缓存)。
Q: 共享内存的作用和使用?
共享内存(Shared Memory)是SM上的高速可编程SRAM,是CUDA优化中最关键的资源:
物理特性(A100):
| 特性 | 数值 | 对比 |
|---|---|---|
| 容量/SM | 192KB(可配,与L1 Cache共享) | HBM: 80GB |
| 延迟 | ~20 cycles | HBM: ~400 cycles |
| 带宽 | ~19 TB/s/SM(理论) | HBM: 2 TB/s(全GPU) |
| Bank数 | 32 banks | - |
| Bank宽度 | 4 bytes/bank | - |
| 访问方式 | 程序员显式管理 | HBM通过L1/L2 Cache |
典型使用模式:
1 | __shared__ float smem[TILE_SIZE][TILE_SIZE + 1]; // +1 padding避免bank conflict |
Bank Conflict详解:
1 | 32个bank,每bank连续4字节: |
动态分配:
1 | extern __shared__ float dynamic_smem[]; // 声明动态共享内存 |
Shared Memory vs L1 Cache:
- A100上两者共享192KB SRAM,可配置比例(如164KB shared + 28KB L1)。
cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, size)配置超过默认上限的shared memory。
使用注意事项:
- Shared memory用量直接影响每SM可驻留的Block数(进而影响Occupancy)。
- 一定要
__syncthreads()确保写入可见(否则读到旧数据)。 - 不要在条件分支中放
__syncthreads()(可能死锁——部分线程不执行sync)。
Q: C的malloc和C++的new有什么区别?
全面对比:
| 维度 | malloc/free | new/delete |
|---|---|---|
| 本质 | C标准库函数 | C++运算符(可重载) |
| 头文件 | <stdlib.h> |
无需(语言内置) |
| 类型安全 | 返回void*,需手动强转 | 返回正确类型指针 |
| 大小指定 | 需要sizeof |
自动计算 |
| 构造/析构 | 不调用 | 自动调用 |
| 失败行为 | 返回NULL | 抛std::bad_alloc |
| 重载 | 不可 | 可重载operator new |
| 数组 | 手动计算 | new[]/delete[] |
| 配对 | malloc↔free |
new↔delete |
底层实现(Linux):
1 | new (或 malloc) 申请内存 |
两者最终都调用OS系统调用(brk/mmap),但new额外做了:
- 调用
operator new(size)分配内存(默认实现就是malloc)。 - 在分配的内存上调用构造函数(placement new语义)。
- 失败时调用new_handler或抛异常(而非返回NULL)。
为什么混用会出问题:
1 | // 危险!malloc分配的不会调用构造函数 |
AI框架中的内存管理:
- PyTorch的CachingAllocator:基于cudaMalloc但缓存释放的block(避免频繁系统调用)。
- 自定义operator new:可以用来实现内存池、对齐分配、统计追踪。
aligned_alloc/posix_memalign:当需要特定对齐时使用。
Q: C++四种强制类型转换?
四种cast的语义和安全级别:
| Cast | 安全性 | 运行时检查 | 典型用途 |
|---|---|---|---|
| static_cast | 中 | 无 | 数值转换、向上转型 |
| dynamic_cast | 高 | 有(RTTI) | 安全向下转型 |
| const_cast | 低 | 无 | 去除const |
| reinterpret_cast | 最低 | 无 | 按位重解释 |
static_cast:
1 | double d = 3.14; |
编译期完成,不产生运行时代码。如果向下转型错误(bp实际不是Derived),行为未定义。
dynamic_cast:
1 | Base* bp = get_object(); |
需要RTTI(虚函数表中的type_info),有运行时开销(遍历继承链)。引用形式失败抛std::bad_cast。
const_cast:
1 | const int* cp = &value; |
唯一能去除const/volatile的cast。常见于调用不接受const参数的旧API。
reinterpret_cast:
1 | float f = 3.14f; |
最危险——只是告诉编译器”把这些字节当作另一种类型看待”。不做任何转换或检查。
AI框架中的使用场景:
reinterpret_cast:CUDA kernel中的向量化读写(float4*)、类型punning查看内存布局。static_cast:精度转换(FP32→FP16)、enum→int。dynamic_cast:在算子dispatch中检查具体子类类型(如检查TensorImpl的具体device type)。
Q: 深拷贝和浅拷贝的区别?
核心区别在于是否复制指针指向的资源:
1 | class MyArray { |
浅拷贝的致命问题:
1 | MyArray a(100); // a.data → [heap block A] |
何时必须深拷贝(Rule of Three/Five):
- 类管理动态资源(new/malloc/文件句柄/GPU显存等)时。
- 如果定义了析构函数(释放资源),通常也需要定义拷贝构造和拷贝赋值。
- C++11后扩展为Rule of Five(加上移动构造和移动赋值)。
PyTorch中的例子:
1 | # 浅拷贝(共享底层storage) |
Tensor的storage是引用计数管理的——多个Tensor可以共享同一个Storage(不同offset/stride)。只有clone()会创建独立的storage。
Q: 智能指针的种类和实现原理?
三种智能指针各自解决不同的所有权语义问题:
unique_ptr(独占所有权):
1 | // 实现核心:禁止拷贝,只允许移动 |
- 大小:与裸指针相同(8字节),零开销抽象。
- 适用:函数返回动态对象、独占资源管理(如文件句柄、CUDA stream)。
shared_ptr(共享所有权):
1 | // 实现核心:引用计数(在堆上的控制块中) |
- 大小:16字节(ptr + ctrl指针)。
- 开销:拷贝/析构时原子操作(~10ns in x86)、堆上control block。
- 适用:多处共享同一对象(如PyTorch的Storage在多个Tensor间共享)。
weak_ptr(观察不拥有):
1 | // 不增加强引用计数,只增加弱引用计数 |
- 解决循环引用问题:A→B且B→A时,用weak_ptr打破环。
- 适用:缓存、观察者模式、打破shared_ptr循环。
make_shared优于new + shared_ptr:
1 | auto p = make_shared<T>(args); // 一次分配(对象和控制块在一块内存中) |
make_shared更高效(减少内存分配次数、更好的cache局部性)。
Q: 如何防止内存泄漏?
系统化的防泄漏策略(从设计到检测):
1. RAII设计原则(最根本):
1 | // 资源的获取即初始化,释放绑定在析构函数 |
2. 智能指针使用规范:
1 | // 独占: unique_ptr(首选,零开销) |
3. 容器自动管理:
1 | std::vector<Tensor> activations; // vector析构时自动析构所有元素 |
4. 检测工具:
| 工具 | 用途 | 开销 | 使用方式 |
|---|---|---|---|
| AddressSanitizer | 越界/UAF/泄漏 | 2x运行时 | -fsanitize=address |
| LeakSanitizer | 专注泄漏检测 | 极小 | -fsanitize=leak(默认含在ASan中) |
| Valgrind | 全面内存检查 | 10-50x | valgrind --leak-check=full ./app |
| CUDA-memcheck | GPU内存错误 | 中等 | compute-sanitizer ./app |
5. GPU显存泄漏防范(AI框架特有):
1 | # PyTorch中常见泄漏场景 |
Q: GDB的基本使用?
GDB是Linux下最强大的C/C++调试工具,AI Infra开发中常用于调试CUDA host代码、内存问题、多线程问题:
核心命令分类:
断点和执行:
1 | (gdb) b main.cpp:42 # 在文件第42行设断点 |
检查状态:
1 | (gdb) p variable # 打印变量值 |
内存调试:
1 | (gdb) x/16xb ptr # 以hex byte格式查看ptr开始的16字节 |
多线程调试:
1 | (gdb) info threads # 列出所有线程 |
实用技巧:
1 | # 崩溃后分析core dump |
AI Infra调试场景:
- Segfault:通常是越界访问或空指针。用
bt定位,然后p检查指针。 - CUDA错误:host端看到
cudaErrorIllegalAddress——可能kernel越界。用compute-sanitizer代替GDB。 - 死锁:
thread apply all bt看哪些线程在等锁,分析锁顺序。
Q: 手撕:图的最短连通路径长度?
(编程题)