字节跳动 AI Infra 实习 一二三面
Q: Cache Line包含哪3个部分?分别的作用?
Cache Line是CPU缓存管理的最小单位(x86-64典型为64字节),每条Cache Line包含三个组成部分:
1. Tag(标签,高位地址):
- 存储该cache line对应的内存地址高位,用于地址匹配。
- 当CPU访问某地址时,将地址分为:Tag | Set Index | Block Offset。
- Cache Controller用Tag比较判断是否命中(hit/miss)。
- Tag位数 = 总地址位数 - Set Index位数 - Block Offset位数。
2. Data(数据块,64字节):
- 实际缓存的内存数据内容,通常64字节(512位)。
- 整行加载/淘汰:即使只需1字节,也会加载整行64字节(利用空间局部性)。
- 为什么是64字节:平衡了总线传输效率(DDR burst长度)和cache容量利用。
3. Flags(标志位/状态位):
- Valid bit:该line是否包含有效数据(冷启动时所有line invalid)。
- Dirty bit:数据是否被修改过(write-back策略下,dirty line被淘汰时需写回下级)。
- MESI协议状态位(多核一致性):
- **M(Modified)**:本核独占且已修改,其他核无效。
- **E(Exclusive)**:本核独占但未修改,其他核无效。
- **S(Shared)**:多核共享,未修改。
- **I(Invalid)**:无效(等于不存在)。
对AI Infra的影响:
- False Sharing:两个线程修改同一cache line内的不同变量→MESI协议频繁invalidate→性能退化。解决:
alignas(64)将变量对齐到不同line。 - Prefetch:CPU硬件预取利用连续访问模式(stride prefetch),这要求数据结构尽量连续排列。
- Cache Line Bouncing:多核频繁写同一全局变量(如atomic counter)→该line在核间乒乓→成为扩展瓶颈。
Q: 多级Cache存在的原因?
核心原因——速度-容量-成本的物理限制不可调和:
| Cache级别 | 容量 | 延迟 | 每GB成本 | 与核心的关系 |
|---|---|---|---|---|
| L1 | 32-64KB | ~1ns (4 cycles) | 极高 | 每核私有 |
| L2 | 256KB-1MB | ~3-10ns | 很高 | 每核私有(多数CPU) |
| L3 | 8-64MB | ~10-30ns | 较高 | 多核共享 |
| DRAM | 64-512GB | ~50-100ns | 中 | 全局 |
| NVMe | TB级 | ~10-100us | 低 | 外设 |
为什么不用一个大而快的Cache:
- 物理限制:SRAM单元大(6个晶体管/bit),要快就必须小(信号传播距离短)。
- 要做到64MB的1ns延迟在物理上不可能(光速传播64MB SRAM需要>1ns)。
- 成本:如果用L1的速度和密度做64MB,芯片面积不够且功耗爆炸。
多级设计的巧妙之处:
- 时间局部性:刚用过的数据很快再用→留在L1(最近最快)。
- 空间局部性:相邻数据可能被用→以cache line为单位加载64字节。
- 层次过滤:90%+访问命中L1(4 cycles),剩余大部分命中L2/L3,极少数才到DRAM。
- 平均访问延迟 ≈ 0.95×4 + 0.04×10 + 0.008×30 + 0.002×100 ≈ 4.9 cycles(而非全用DRAM的100 cycles)。
对GPU编程的启示:
- GPU的L1/L2 Cache更小且无法像CPU那样有效隐藏延迟(靠大量线程切换代替)。
- 显式使用Shared Memory等价于程序员手动管理”L1 Cache”。
Q: Cache Miss之后的流程?
Cache Miss发生后,硬件执行一系列操作来加载数据并恢复执行:
完整流程:
1 | CPU执行load指令 → 地址送入L1 |
各步骤细节:
- 替换策略决定:目标cache set满时,选择一个victim line淘汰(LRU/PLRU/Random)。
- Writeback检查:如果victim line是dirty的(Modified状态),必须先将其写回下级存储。
- 数据传输:按cache line粒度(64字节)加载。DDR4/5的burst传输与此匹配(8×8字节 burst)。
- Critical Word First:大多数CPU先返回请求的那个word让CPU继续执行,同时后台填充完整line。
- 流水线影响:
- 乱序执行CPU:Miss不一定stall——可以继续执行不依赖该数据的后续指令。
- 如果后续指令都依赖该数据:流水线stall直到数据返回(~100 cycles!)。
- 硬件预取:检测到顺序/stride访问模式时,提前发起预取请求,hide miss latency。
对性能的影响(访问1GB数组的场景):
- 全命中L1:4GB/s × 4 cycles = 几乎无开销。
- 全L3命中(~30 cycles):有效带宽降为约L1的7分之一。
- 全DRAM(~100 cycles):有效带宽降为约L1的25分之一。
AI推理中Cache Miss的典型场景:
- 模型权重首次加载:cold cache,全部miss到DRAM→首个推理batch较慢(warmup效应)。
- Embedding Table查找:随机索引→几乎每次都cache miss→用prefetch或GPU offload。
Q: 页表机制带来的开销以及如何缓解?
开销来源——地址翻译的代价:
64位系统使用4级页表(PGD→PUD→PMD→PTE),每级存储在不同物理页中:
1 | 虚拟地址: [PGD索引(9bit)] [PUD索引(9bit)] [PMD索引(9bit)] [PTE索引(9bit)] [页内偏移(12bit)] |
这意味着没有TLB的情况下,一次内存访问需要5次物理内存读取(4次页表+1次数据)——5倍slowdown。
缓解机制:
1. TLB(Translation Lookaside Buffer)—— 最关键:
- 硬件缓存页表翻译结果(虚拟页号→物理页号的映射)。
- L1 DTLB:~64条目,1 cycle访问。
- L2 TLB:
1000-2000条目,10 cycles。 - 命中率通常>99%(利用程序的空间局部性)。
- Miss时才需要做完整page walk(硬件或OS完成)。
2. 大页(Huge Pages):
- 标准页4KB → 1GB虚拟空间需要262144个页表条目。
- 2MB大页 → 同样1GB只需512个条目。TLB覆盖范围扩大512倍。
- 1GB大页 → 1个条目覆盖1GB。
- AI框架中使用大页:
torch.cuda.memory._set_allocator_settings("max_split_size_mb:128")或直接hugetlbfs映射权重文件。
3. Page Walk Cache(部分CPU):
- 缓存中间级页表查询结果(如PGD/PUD级别的翻译)。
- 同一PGD下的不同地址可以跳过PGD级查询。
4. 页表自身的Cache命中:
- 页表数据也是普通内存数据,频繁访问的页表条目会被L1/L2 Cache缓存。
- 连续访问同一区域时,相关的PTE page在data cache中热。
对AI推理的影响:
- 大模型权重mmap加载后,首次访问每4KB会触发page fault + TLB miss + page walk → 首次推理极慢。
- 解决:
madvise(MADV_SEQUENTIAL)提示OS预读、使用大页减少TLB压力、mlockall()防止swap。
Q: 页表的好处?
页表(Page Table)实现虚拟内存机制,提供了以下关键能力:
1. 进程隔离和保护:每进程独立页表→独立地址空间→进程A无法访问进程B的内存。页表条目中的权限位(R/W/X/User/Kernel)实现细粒度保护。
2. 按需分配(Lazy Allocation):malloc(1GB)不立即分配物理内存——只建立虚拟页映射(PTE标记为不存在)。首次访问时触发Page Fault,OS才分配物理页。节省内存且启动快。
3. 物理内存去碎片化:虚拟地址连续但物理地址可以分散。OS可以用任何空闲物理页满足请求,无需连续物理内存。
4. Swap(虚拟内存扩展):物理内存不足时,不活跃的页可以交换到磁盘。恢复访问时Page Fault → OS从磁盘加载回来。透明扩展内存容量。
5. 内存共享:不同进程的不同虚拟页可以映射到同一物理页(共享库、mmap共享内存)。零拷贝进程间通信。
6. Copy-on-Write:fork()后父子进程共享物理页(PTE标记只读),写入时才复制——使fork几乎零开销。
Q: C++重载机制以及如何实现?
函数重载(Function Overloading):同一作用域中可以定义多个同名函数,只要参数列表不同:
1 | void process(int x); // 版本1 |
编译器实现——名称修饰(Name Mangling):
编译器将函数名和参数类型编码为唯一的符号名:
1 | void process(int) → _Z7processi |
链接器看到的是不同符号,不会冲突。
重载决议规则(编译期完成):
1 | 调用 process(3.14f) 时: |
优先级:精确匹配 > 类型提升(char→int, float→double) > 标准转换(int→double) > 用户定义转换 > 省略号匹配。
注意事项:
- 返回值不参与重载决议(不能仅靠返回类型区分)。
const修饰的成员函数和非const版本可以重载。- 模板函数和非模板函数同时匹配时,优先选择非模板(更特化)。
extern "C"禁用name mangling(C没有重载,需要保持符号名不变)。
Q: C++中的原子操作以及引入的原因?
为什么需要原子操作:
1 | // 非原子的 i++ 实际是3条指令: |
std::atomic提供的保证:
1 | std::atomic<int> counter{0}; |
内存序(Memory Order)——控制可见性:
| 内存序 | 保证 | 开销 | 典型用途 |
|---|---|---|---|
| relaxed | 仅保证原子性,不保证顺序 | 最小 | 计数器(不关心顺序) |
| acquire | 本操作之后的读写不会重排到之前 | 中等 | 获取锁 |
| release | 本操作之前的读写不会重排到之后 | 中等 | 释放锁 |
| acq_rel | 同时acquire+release | 较高 | 读-改-写 |
| seq_cst | 全序(所有线程看到相同的操作顺序) | 最高 | 默认,最安全 |
1 | // Lock-free队列的典型模式 |
在AI Infra中的应用:
- CUDA中的
atomicAdd:多线程向同一地址累加(如Reduce的最终阶段)。 - Lock-free数据结构:推理服务的请求队列。
- 引用计数:
shared_ptr的计数器用原子操作实现线程安全的引用计数。
Q: 大模型分布式训练中的并行方式?
六种并行策略从不同维度切分计算/数据/模型,满足不同规模的训练需求:
| 并行方式 | 切分维度 | 通信模式 | 通信频率 | 适用互联 | 典型范围 |
|---|---|---|---|---|---|
| DP/DDP | 数据(batch) | AllReduce梯度 | 每步1次 | 任意 | 跨节点 |
| ZeRO | 数据+分片状态 | AllGather/ReduceScatter | 每步多次 | 中高带宽 | 跨节点 |
| TP | 层内权重 | AllReduce/AllGather | 每层2次 | 高带宽(NVLink) | 节点内 |
| PP | 层间(stage) | P2P(激活值) | 每micro-batch | 中带宽(IB) | 跨节点 |
| SP | 序列维度 | AllGather/ReduceScatter | 每层 | 高带宽 | 节点内 |
| EP | Expert维度 | All-to-All | 每MoE层 | 高带宽 | 节点内/跨节点 |
| CP | 上下文(长序列) | Ring传递KV | 每attention层 | 高带宽 | 节点内 |
典型大模型训练的3D/4D/5D并行配置:
1 | GPT-4级(~1T参数, ~10000 GPU): |
Q: 如何应对模型训练和推理中过高的内存占用?
训练阶段显存组成和优化:
| 组件 | FP32大小(每参数) | 混合精度大小 | 优化方法 |
|---|---|---|---|
| 参数 | 4B | 2B(FP16)+4B(FP32 master) | ZeRO-3分片 |
| 梯度 | 4B | 2B(FP16) | ZeRO-2分片 |
| Adam m | 4B | 4B(FP32) | ZeRO-1分片 |
| Adam v | 4B | 4B(FP32) | ZeRO-1分片 |
| 激活值 | 变化大 | ~= 模型参数量 | Activation Checkpointing |
Activation Checkpointing详解:
- 标准方式:保存每层的激活值用于反向计算,显存需求 ≈ batch_size × seq_len × hidden × layers。
- Checkpointing:只保存每N层的输入,反向时重计算中间激活。显存从O(L)降为O(sqrt(L))。
- 代价:额外~33%前向计算量(需要重新跑一遍被checkpoint的层)。
推理阶段显存优化:
| 方法 | 效果 | 代价 |
|---|---|---|
| W4量化 | 模型体积↓4x | <2% perplexity损失 |
| PagedAttention | KV利用率→100% | 微小kernel开销 |
| KV量化(INT8) | KV显存↓2x | 极小精度损失 |
| Offloading | 有效容量+Nx | 加载延迟 |
| GQA架构 | KV显存↓4-8x | 需要训练时使用 |
Q: C++多态机制,虚函数表中函数地址何时确定?虚表指针何时确定?
虚函数表(vtable)中的函数地址——编译期确定:
编译器在编译每个含虚函数的类时,生成该类的vtable并填入函数地址。这些地址在编译+链接后就是最终值:
1 | // 编译Derived类时生成: |
vtable本身是只读数据段(.rodata),程序加载后不会改变。
虚表指针(vptr)的值——运行时构造时确定:
对象构造过程中,构造函数的隐含第一步是设置vptr:
1 | // Derived d; 的实际执行序列: |
关键推论:
- 基类构造函数中调用虚函数→走的是基类版本(因为此时vptr指向基类vtable)。
- 这就是为什么”不要在构造函数中调用虚函数”——不会得到多态行为。
- 析构时逆序更新vptr:先Derived析构(vptr=Derived_vtable)→再Base析构(vptr=Base_vtable)。
Q: C++中vector如何释放内存?
vector的clear()只调用元素析构函数但不释放底层内存(capacity不变):
1 | std::vector<int> v(1000000); // size=1000000, capacity>=1000000 |
为什么clear()不释放内存���
- 性能考虑:如果clear后还要重新push_back,保留capacity避免重新分配。
- 频繁的分配/释放(malloc/free)有系统调用开销。
- 设计哲学:clear()只保证逻辑为空,内存管理交给程序员决定。
AI框架中的相关实践:
- PyTorch的TensorList:类似vector
,clear后tensor的storage由引用计数管理。 - 显存管理:CUDA的caching allocator不会立即将cudaFree返回给OS,而是缓存以供后续使用。类似vector不释放capacity的理念。
Q: 实现一个支持O(1)获取最小值的栈?
(编程题)
Q: 手撕:棋盘连通性判断(DFS)?
(编程题)
Q: 手撕:拓扑排序?
(编程题)
Q: 手撕:下一个全排列(LeetCode 31)?
(编程题)