曦望 AI 编译器开发一面:C++ 多态、CRTP 与 GPU 存储层级
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 哪些排序算法印象较深?
- 快速排序的基本思想是什么?把递归快速排序改成循环需要注意什么?
- 如何理解 C++ 多态?
- 函数重载与运行期多态有什么区别?
- 不同模板实参实例化出的函数/类是什么关系?
- 两个模板实例中的相同逻辑如何复用?
- 模板父类如何调用子类成员?
- CRTP 静态多态与虚函数动态多态如何选择?
- 快手实习主要做网络、服务还是客户端?
- AEC 如何区分回声、人声和噪声?
- 毕业后想从事什么方向?做过哪些 AI 项目?
- RAG 的相关性如何计算?关键词相关性如何计算?最终相关性分数的值域是什么?
- 你知道自己投的岗位是什么吗?AI 编译器岗位大致做什么?
- CPU 与 GPU 架构的主要区别是什么?GPU 有哪些缓存和存储层级?
- 对 LLVM、MLIR 有什么了解?了解哪些 AI/GPU DSL?
- 对 Triton、TileLang 的编程抽象有什么理解?
- 公司和团队当前主要业务是什么?
- DSL 适配自研硬件的挑战或瓶颈是什么?
- 算子、编译器 Pass 和硬件特性之间如何划分优化职责?
- 面试官对本场表现有什么建议?
《参考解析》
C++ 的多态体系要分清「编译期」与「运行期」两条线。 运行期多态靠虚函数:对象头里有虚表指针,调用经虚表间接跳转(有间接寻址与无法内联的代价),配合 override/final 与 dynamic_cast/typeid(需要 RTTI);多继承与虚继承会引入 thunk 和额外的指针开销。函数重载属于编译期:同名不同参在编译期就确定(名字修饰 name mangling),与返回值无关,是静态分派,和虚函数的动态分派不是一回事。模板也是编译期多态:每个实参组合实例化出互不相同的类型/函数(vector<int> 与 vector<float> 是两个完全独立的类),它们之间没有继承关系、不能互相赋值;相同逻辑的复用靠把公共部分抽到基类模板、用 if constexpr 分支、或用标签分派/traits 特化(也可以把公共实现放到非模板的辅助函数里,减少代码膨胀)。模板父类调用子类成员(CRTP 的写法):template<class D> struct Base { void run(){ static_cast<D*>(this)->impl(); } };,即把派生类作为模板参数传入,基类通过 static_cast 向下转换调用——这是「编译期多态」的标准手法,没有虚表调用开销、可内联,代价是代码膨胀与错误信息晦涩。选择判据:需要运行期决定类型(插件、跨模块接口、异构容器)用虚函数;性能敏感且类型在编译期已知(数值计算、模板库、序列化、Eigen/CRTP mixin)用 CRTP。补充实践:热路径上避免虚调用可用 std::variant + visit、函数指针表或去虚拟化,但可读性优先,除非有实测数据。
GPU 与 CPU 架构的差异,一句话是「延迟优化 vs 吞吐优化」。 CPU 核少而强,有乱序执行、分支预测、大容量多级缓存(L1/L2/L3)、深度流水,目标是降低单线程延迟;GPU 有成千上万个简单核心,按 SIMT 组织成 warp(32 线程)执行同一指令,靠大量线程切换隐藏访存延迟,目标是吞吐。存储层级(从快到慢/小到大):寄存器 → 共享内存/ L1(per-SM,可配置划分,通过 __shared__ 显式使用,带宽高无 bank 冲突时接近寄存器)→ L2(全芯片共享)→ 全局显存(HBM,带宽高但延迟几百周期)→ 常量内存/纹理内存(有专用缓存与广播优化)。编程要点:合并访存(coalesced access,一个 warp 内的线程访问连续地址)、避免 bank conflict、用共享内存做 tiling 复用数据、提高占用率(occupancy)来隐藏延迟、以及注意 warp divergence(分支发散会串行化)。CPU 与 GPU 之间还有 PCIe/NVLink 的传输瓶颈,所以编译器要尽量减少 host-device 拷贝与 kernel 启动次数(算子融合正是为此)。
LLVM/MLIR 与 AI DSL 的理解,要落到「多层 IR + 可扩展方言」上。 LLVM 是编译器基础设施:前端产出 LLVM IR(SSA 形式、类型化、有 LLVM IR 的验证器),中端做与目标无关的优化(内联、常量折叠、循环优化、向量化),后端做指令选择、寄存器分配、调度并生成机器码;它的价值是「一次编写优化、多目标复用」。MLIR 在此之上引入多层方言(dialect)+ 可扩展操作,允许在高层(如 linalg、tensor、affine、scf)表达张量计算与循环结构,并逐步 lower 到 GPU 方言(gpu、nvgpu)与 LLVM IR;关键是**渐进式降级(progressive lowering)**与每一层都有验证与变换,这正是 AI 编译器需要的(从图级融合到循环级 tiling 到硬件指令)。Triton 的抽象是「块级编程(block-level)」:用户写的是对 tile 的运算(tl.load/tl.dot/tl.store),编译器自动负责线程映射、共享内存分配、流水与向量化,优点是开发效率高(不用手写 CUDA),局限是控制力不如手写 CUDA(特殊布局、异步拷贝、精细调度受限)。TileLang 类似思路但更强调用 tile 与 layout 显式描述,兼顾可控性与可移植性(面向算子开发场景)。DSL 适配自研硬件的瓶颈通常在三处:缺少硬件特性的表达能力(新指令/异步拷贝/tensor core 的 layout 无法在高层 DSL 里表达)、降级路径的映射质量(自动 tiling/调度达不到手写性能)、以及生态(算子库、调试与性能分析工具的缺失);对应的做法是扩展方言与 intrinsic、写针对自研硬件的 pass、并建立性能回归基准做持续对比。优化职责的划分可以答成三层:硬件特性决定「能做什么」(必须由 DSL/IR 暴露,否则上层无从使用);编译器 Pass 负责「自动做对的变换」(融合、tiling、向量化、内存布局、流水);算子层负责「算法级选择」(用什么算法、精度、切分策略)。三者要有清晰的接口与性能基线,否则优化会互相打架(比如算子层写死了 tiling,编译器就无法再优化)。
RAG 相关性打分那组问题的要点:关键词与语义两路分数不可直接比较。 关键词相关性常用 BM25(TF 饱和 + IDF + 长度归一化),值域上不封顶(取决于 IDF 与词频,一般几十以内但不是固定区间);语义相似度用向量余弦/内积,余弦值域 [−1, 1](归一化向量内积同),实践中常映射到 [0, 1]。要做最终融合就必须归一化:min-max 或 z-score 归一化到同一区间后加权求和,或用 RRF(只用名次,天然免疫量纲问题,见另一篇面经的详细说明),或训练一个重排模型把多路特征作为输入直接输出分数(cross-encoder 的分数通常过 sigmoid 到 [0,1] 表示相关性概率)。回答时最好说明「我最终用的是哪个区间、阈值怎么定(用评测集选阈值而不是拍脑袋)」,这比记住某个具体数值更能体现工程素养。