字节跳动 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
2
3
4
5
6
7
8
9
10
11
CPU执行load指令 → 地址送入L1
↓ L1 miss
查询L2 (通过L1→L2 bus)
↓ L2 miss
查询L3 (通过ring bus/mesh)
↓ L3 miss(完全cache miss!)
Memory Controller发起DRAM请求
↓ ~50-100ns后数据返回
逐级填充: DRAM → L3 → L2 → L1

CPU重新执行该load指令

各步骤细节

  1. 替换策略决定:目标cache set满时,选择一个victim line淘汰(LRU/PLRU/Random)。
  2. Writeback检查:如果victim line是dirty的(Modified状态),必须先将其写回下级存储。
  3. 数据传输:按cache line粒度(64字节)加载。DDR4/5的burst传输与此匹配(8×8字节 burst)。
  4. Critical Word First:大多数CPU先返回请求的那个word让CPU继续执行,同时后台填充完整line。
  5. 流水线影响
    • 乱序执行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
2
3
4
5
6
7
8
虚拟地址: [PGD索引(9bit)] [PUD索引(9bit)] [PMD索引(9bit)] [PTE索引(9bit)] [页内偏移(12bit)]

翻译过程:
1. 读PGD[index] → 获得PUD页的物理地址 (1次内存访问)
2. 读PUD[index] → 获得PMD页的物理地址 (1次内存访问)
3. 读PMD[index] → 获得PTE页的物理地址 (1次内存访问)
4. 读PTE[index] → 获得最终物理页号 (1次内存访问)
总计: 4次内存访问 × ~100ns = ~400ns!(而实际数据访问本身只需100ns)

这意味着没有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
2
3
4
void process(int x);           // 版本1
void process(double x); // 版本2
void process(int x, int y); // 版本3
void process(const string& s); // 版本4

编译器实现——名称修饰(Name Mangling)

编译器将函数名和参数类型编码为唯一的符号名:

1
2
3
4
void process(int)         → _Z7processi
void process(double) → _Z7processd
void process(int, int) → _Z7processii
void process(const string&) → _Z7processRKSs

链接器看到的是不同符号,不会冲突。

重载决议规则(编译期完成)

1
2
3
4
5
调用 process(3.14f) 时:
1. 精确匹配: 无 (float参数,没有process(float))
2. 类型提升: float→double → 匹配 process(double) ✓
3. 隐式转换: float→int → 也可匹配 process(int)
4. 歧义!编译错误(两个同等优先的匹配)

优先级:精确匹配 > 类型提升(char→int, float→double) > 标准转换(int→double) > 用户定义转换 > 省略号匹配。

注意事项

  • 返回值不参与重载决议(不能仅靠返回类型区分)。
  • const修饰的成员函数和非const版本可以重载。
  • 模板函数和非模板函数同时匹配时,优先选择非模板(更特化)。
  • extern "C"禁用name mangling(C没有重载,需要保持符号名不变)。

Q: C++中的原子操作以及引入的原因?

为什么需要原子操作

1
2
3
4
5
// 非原子的 i++ 实际是3条指令:
// 1. LOAD i → register
// 2. ADD 1 → register ← 另一线程可能在此间修改i!
// 3. STORE register → i
// 两线程同时i++可能只增加1(而非2):经典的竞态条件

std::atomic提供的保证

1
2
3
4
5
6
7
std::atomic<int> counter{0};

// 原子操作:硬件保证不被打断
counter.fetch_add(1); // 原子+1(x86上编译为 lock xadd 指令)
counter.store(42); // 原子写
int val = counter.load(); // 原子读
bool ok = counter.compare_exchange_strong(expected, desired); // CAS

内存序(Memory Order)——控制可见性

内存序 保证 开销 典型用途
relaxed 仅保证原子性,不保证顺序 最小 计数器(不关心顺序)
acquire 本操作之后的读写不会重排到之前 中等 获取锁
release 本操作之前的读写不会重排到之后 中等 释放锁
acq_rel 同时acquire+release 较高 读-改-写
seq_cst 全序(所有线程看到相同的操作顺序) 最高 默认,最安全
1
2
3
4
5
6
7
8
9
10
11
12
// Lock-free队列的典型模式
void produce(int value) {
data[idx] = value; // 先写数据
ready.store(true, std::memory_order_release); // 然后标记就绪
// release保证: data写入对看到ready=true的线程可见
}

void consume() {
while (!ready.load(std::memory_order_acquire)) {} // 等待就绪
// acquire保证: 看到ready=true后,data的写入对本线程可见
int val = data[idx]; // 安全读取
}

在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
2
3
4
5
6
GPT-4级(~1T参数, ~10000 GPU):
TP=8 (节点内NVLink)
PP=16 (跨16组节点)
DP=64 (64个数据并行副本)
EP=8 (MoE expert分布)
总GPU = 8×16×64 = 8192

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
2
3
4
5
6
// 编译Derived类时生成:
Derived_vtable[] = {
&Derived::foo, // [0] override的函数用Derived的地址
&Base::bar, // [1] 未override的函数继承Base的地址
&Derived::baz // [2] Derived新增的虚函数
};

vtable本身是只读数据段(.rodata),程序加载后不会改变。

虚表指针(vptr)的值——运行时构造时确定

对象构造过程中,构造函数的隐含第一步是设置vptr:

1
2
3
4
5
6
7
8
9
10
// Derived d; 的实际执行序列:
1. 分配内存
2. Base::Base() {
this->vptr = &Base_vtable; // 先设为Base的vtable
// Base的构造体...
}
3. Derived::Derived() {
this->vptr = &Derived_vtable; // 覆盖为Derived的vtable
// Derived的构造体...
}

关键推论

  • 基类构造函数中调用虚函数→走的是基类版本(因为此时vptr指向基类vtable)。
  • 这就是为什么”不要在构造函数中调用虚函数”——不会得到多态行为。
  • 析构时逆序更新vptr:先Derived析构(vptr=Derived_vtable)→再Base析构(vptr=Base_vtable)。

Q: C++中vector如何释放内存?

vector的clear()只调用元素析构函数但不释放底层内存(capacity不变):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::vector<int> v(1000000);  // size=1000000, capacity>=1000000
v.clear(); // size=0, capacity仍为1000000! 内存未释放

// 方法1: swap技法(C++11前的标准做法)
std::vector<int>().swap(v); // 空vector与v交换
// v现在: size=0, capacity=0(内存已释放)
// 临时对象析构:释放原来的1000000个元素的内存

// 方法2: shrink_to_fit (C++11)
v.shrink_to_fit(); // 请求释放多余capacity(非强制但多数实现会执行)
// 注意: 标准只说"non-binding request",实现可以忽略

// 方法3: 让vector离开作用域
{
std::vector<int> v(1000000);
} // v析构,内存自动释放

// 方法4: 赋值空vector
v = std::vector<int>(); // 移动赋值,旧内存随旧storage释放

为什么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)?

(编程题)