0%

Agent 基本范式

一句话定位:Agent 的基本范式是感知 → 规划 → 执行 → 观察的闭环,而 ReAct 是这个闭环在 LLM 上的具体实现——让模型把”思考”和”行动”显式交替写出来,使决策过程可追踪、可干预。

1. 感知-规划-执行-观察闭环

  • 感知(Perceive):接收当前任务、历史上下文、工具返回的观察结果,构成”当前状态”。
  • 规划(Plan):基于当前状态决定下一步做什么(可能是直接回答,也可能是调用某个工具)。
  • 执行(Act):真正调用工具/执行动作。
  • 观察(Observe):拿到执行结果,反馈回感知环节,进入下一轮循环,直到任务完成或达到终止条件。

这个循环本质上和经典 AI 里的 Agent 定义(感知环境、决策、作用于环境)一致,只是”环境”换成了”工具 + 上下文”。

2. ReAct:让”思考”显式化

  • 传统做法是模型直接输出最终答案(Act-only)或先长篇推理再给答案(Reason-only),两者都不利于纠错与追踪。
  • ReAct(Reasoning + Acting):让模型交替输出 Thought(推理这一步该做什么)→ Action(调用哪个工具、参数是什么)→ Observation(工具返回结果)→ 下一轮 Thought……直到给出 Final Answer
  • 好处:
    • 可追踪:每一步”为什么这么做”都写在 Thought 里,出错时能定位是哪一步推理错了还是工具调用错了。
    • 可干预:可以在任意 Thought/Action 之间插入人工审核(对应 7.7 的 Human-in-the-loop)。
    • 减少幻觉:把”凭空编答案”变成”基于真实 Observation 逐步推进”,降低模型编造事实的概率。

3. 面试落点

  • 能画出这个闭环图,并指出 ReAct 相比”直接输出答案”多了哪些环节、解决了什么问题(可追踪性、可干预性)。
  • 可迁移话术:这个闭环和大数据里的”任务调度器观察任务状态 → 决定下一步调度动作 → 执行 → 再观察”的思路是同构的,只是决策主体从规则/调度算法换成了 LLM。

参考:论文《ReAct: Synergizing Reasoning and Acting in Language Models》(Yao et al., 2022)

CUDA Kernel 代码阅读能力

一句话定位:不要求会写复杂 Kernel,但要能读懂三类最基础的代码——索引计算、边界判断、访存模式,这三点决定了一段 Kernel 是否正确、是否高效。

1. 全局索引计算:定位”我是谁”

每个线程要先知道自己该处理数据的哪一部分,标准写法:

1
int idx = blockIdx.x * blockDim.x + threadIdx.x;
  • blockIdx.x:当前线程所在的 Block 编号;blockDim.x:每个 Block 里的线程数;threadIdx.x:线程在 Block 内的局部编号。
  • 这行代码把”块内局部编号”转换成”全局唯一编号”,与大数据里”分区编号 × 分区大小 + 分区内偏移量 = 全局偏移”的思路完全一致。

2. 边界判断:防止越界访问

线程总数通常是 Block/Thread 配置向上取整的结果,会超过实际数据长度,因此几乎每个 Kernel 都要有:

1
2
3
if (idx < n) {
// 实际计算逻辑
}

漏掉这行是最常见的越界访问 Bug 来源(读到脏数据或直接 crash)。

3. 三段典型代码要读懂

  • 向量加法(最简单):每个线程处理一个元素,c[idx] = a[idx] + b[idx],重点看索引计算与边界判断。
  • 朴素矩阵乘:每个线程计算结果矩阵的一个元素,需要一个内层循环遍历公共维度;重点看二维索引 row = blockIdx.y*blockDim.y+threadIdx.ycol = blockIdx.x*blockDim.x+threadIdx.x 的推导,以及这种朴素写法的访存是否高效。
  • Reduction(归约,如求和):多个线程协作把一组数据归约成一个值,典型做法是先在共享内存里做树形归约(每轮线程数减半),再跨 Block 归约;重点看共享内存的读写同步(__syncthreads())与树形归约的下标规律。

4. 访存是否合并(Coalesced Access)

  • 合并访问:同一个 Warp(32 个线程)里的线程访问连续的显存地址,硬件可以合并成一次内存事务,效率最高。
  • 非合并访问:线程访问地址跳跃或不连续(如按列访问按行存储的矩阵),会拆成多次内存事务,显存带宽利用率骤降。
  • 判断方法:看索引表达式里线程编号(threadIdx.x)是否直接映射到内存地址的最低维、连续位置。

5. 面试落点

  • 产出物:能口头讲清一段给定 CUDA Kernel 的执行逻辑(每个线程做什么)与访存模式(是否合并访问)。
  • 可迁移话术:矩阵乘的朴素实现(未做 Tiling/共享内存复用)对应大数据里”没有做本地聚合就直接 Shuffle”的低效模式;Reduction 的树形归约思路与 Spark 的 treeReduce(相对于普通 reduce 减少 Driver 端压力)是同一个思想的不同实现层。

参考:《CUDA C++ Programming Guide》;NVIDIA CUDA Samples 官方仓库

Rust 所有权机制

一句话定位:Rust 用编译期的所有权 + 借用检查替代了 C++ 手工内存管理与 GC 语言的运行时开销,在没有运行时垃圾回收的前提下,静态地消除了数据竞争与悬垂指针这两类最常见的内存安全问题。

1. 所有权(Ownership)三条基本规则

  • 每个值都有唯一的”所有者”(owner)变量。
  • 同一时刻只能有一个所有者;赋值/传参默认是**移动(move)**而不是拷贝,原变量随即失效。
  • 所有者离开作用域时,值被自动释放(析构),这一点与 C++ 的 RAII(6.1)思路一致,只是 Rust 把”移动语义默认化”并在编译期强制检查。

2. 借用(Borrowing):不转移所有权的引用

  • 不可变借用 &T:可以同时存在多个,只能读。
  • 可变借用 &mut T:同一时刻只能存在一个,且与任何不可变借用互斥。
  • 编译器在编译期静态检查这套规则(借用检查器,borrow checker),一旦违反(比如同时存在可变借用和不可变借用)直接编译不通过,而不是留到运行时才崩溃或产生数据竞争。

3. 与 C++/GC 语言的对比(核心面试话术)

维度 C++ Rust GC 语言(Java/Python)
内存安全检查时机 无强制检查,靠开发者自律 + RAII 约定 编译期静态检查 运行期(GC 回收 + 部分运行时异常)
数据竞争 可能发生,靠 mutex 手工避免 编译期消除(Send/Sync trait) 可能发生,靠锁/并发工具手工避免
运行时开销 无额外开销 无额外开销(零成本抽象) 有 GC 开销

核心话术:Rust 把 C++ 里”程序员自己保证不出错”的部分,变成了”编译器强制证明不会出错”,代价是学习曲线更陡、部分合法但难以证明安全的代码需要 unsafe 逃生舱。

4. 面试落点

  • 能讲清”所有权/借用检查解决了什么问题”:悬垂指针(借用检查保证引用不会比数据活得更久)、数据竞争(可变/不可变借用互斥规则)。
  • 若岗位倾向 Rust(如高性能数据处理算子),可以说明这套机制特别适合”高并发 + 高性能 + 不想为内存安全牺牲性能”的场景,是 C++ 之外的另一个选择。

参考:《The Rust Programming Language》(Rust 官方教程 “The Book”)

C++ 并发原语

一句话定位:C++ 并发的核心是”用合适粒度的同步原语保护共享状态”——临界区大、逻辑复杂用 mutex,单个计数/标志位这类简单操作用 atomic,海量短任务用线程池摊薄线程创建开销。

1. 线程池:任务队列 + 工作线程

  • 结构:固定数量的工作线程 + 一个(或多个)任务队列;生产者把任务塞进队列,工作线程循环取任务执行,避免为每个任务单独创建/销毁线程的开销。
  • 进阶设计——工作窃取(work-stealing):每个线程有自己的本地队列,空闲时去”偷”其他线程队列尾部的任务,减少单一全局队列的锁竞争,是高性能线程池(如 Rayon、TBB)的常见做法。

2. mutex vs atomic:按临界区大小选型

  • mutex:加锁期间独占访问一段代码(临界区可以任意复杂,包含多个变量、多条语句);代价是加锁/解锁本身有开销,且存在死锁风险。
  • atomic:针对单个变量的读-改-写做无锁的原子操作(如计数器自增、CAS 比较交换),底层靠 CPU 原子指令实现,没有锁的调度开销,但只适合简单场景,无法保护”多个变量联合更新”的复杂逻辑。
  • 选型原则:临界区越简单越优先 atomic;一旦涉及多个变量的一致性或复杂逻辑,直接上 mutex,不要用多个 atomic 拼凑复杂同步(容易引入微妙的竞态)。

3. 内存序(存在感知即可)

  • atomic 操作可以指定内存序(memory_order_relaxed/acquire/release/seq_cst),控制编译器/CPU 对指令重排的限制程度;默认 seq_cst 最安全但开销最大。
  • 面试层面只需知道:内存序是为了在多核 CPU 的指令重排/缓存可见性问题上做正确性与性能的权衡,不要求手写 relaxed 场景的代码。

4. 面试落点

  • 能讲清”什么时候用 mutex,什么时候用 atomic”的判断依据(临界区复杂度)。
  • 可迁移话术:这和 Java 里 synchronized(大临界区)vs AtomicInteger(简单计数)的选型逻辑完全一致,是同一套并发设计哲学在不同语言里的体现。

参考:《C++ Concurrency in Action》(Anthony Williams)

性能基线(Benchmark)建设方法论

一句话定位:没有可信基线,所有”优化了 30%”都是自说自话。基线的全部价值在于可复现可比较

1. 五条硬规则

1 固定输入分布

  • 输入的长度分布、batch 组成、请求到达模式必须固定并记录(LLM 场景尤其关键:输入/输出长度直接决定吞吐);
  • 用固定数据集或固定随机种子生成,避免每次测的其实是不同负载。

2 固定环境

  • 固定硬件(GPU 型号、卡数、拓扑)、驱动/CUDA/框架版本、并发数、功耗与时钟模式(必要时锁频,避免降频与 boost 波动);
  • 独占机器测量,避免邻居进程干扰。环境变了就不能与旧数据直接比。

3 区分预热与稳定态

  • 前若干次迭代包含 CUDA 上下文初始化、显存分配、autotune、cache 预热,耗时显著偏高;
  • 必须丢弃预热轮,只统计稳定态;同时明确报告预热轮数(同 5.1 的 profiler schedule)。

4 报告 P50 / P99 而非仅均值

  • 均值会掩盖长尾。一个 P99 严重恶化的方案在生产上是不可接受的,哪怕均值更好;
  • 延迟必须给分位数(P50/P90/P99),吞吐给稳定态均值 + 波动范围。这也是 4.7 四维评估中”稳定性”维度的数据来源。

5 明确吞吐与延迟的取舍点

  • 两者天然对立:增大 batch / 加长聚合窗口 → 吞吐升、延迟升(见 3.6 Dynamic Batching);
  • 因此不能只报单点,应给出吞吐-延迟曲线,并标明业务 SLA 约束下(如 P99 < 2s)的最大可用吞吐——这才是有决策价值的数字。

2. 报告应包含的最小信息集

模型与精度 / 硬件与版本 / 输入输出长度分布 / 并发与 batch 设置 / 预热与测量轮数 / P50-P99 延迟 / 稳定态吞吐 / 显存占用峰值。

参考:MLPerf Benchmark 方法论

Profiling 驱动优化的标准动作

一句话定位:不凭感觉优化。这套六步动作在 Spark 与 GPU 上完全同构,是我最强的可迁移叙事——工具会换,动作不换。

1. 六步标准动作

1 采集基线

先建立可复现的量化基线(固定输入与环境、区分预热、报 P50/P99,见 5.4)。没有基线就无法证明收益。

2 定位 Top 耗时算子 / Stage

用工具按耗时排序找热点:GPU 侧用 PyTorch Profiler 算子排序 + Nsight 时间线(5.1/5.2),大数据侧用 Spark UI 的 Stage/Task 分布。必须先判定瓶颈类别(数据供给 / 调度同步 / 通信 / 计算,见 3.3),否则方向就错了。

3 提出假设与预期收益

明确写下:”我认为瓶颈是 X,做 Y 之后,指标 Z 应改善约 N%。”

  • 预估收益要基于占比(Amdahl 定律):占比 10% 的算子优化到 0,整体最多快 10%;
  • 这一步能过滤掉大量”看起来很酷但收益极小”的优化。

4 单变量验证

一次只改一个变量,改完立刻复测。多变量同时改会导致无法归因(哪项起了作用、哪项起了反作用都不知道)。

5 前后对比数据留档

记录改动内容、配置差异、前后指标(含 P99 与稳定性)、结论。留档的意义:

  • 避免重复踩坑与反复回退;
  • 形成可对外复述的证据链(面试与汇报都靠这个)。

6 沉淀为工具 / 模板

把一次性的排查过程固化成脚本、看板、检查清单或对比表模板(如 4.7 的四维选型表)。一次优化 → 一份可复用资产,这是平台化思维的体现。

2. 为什么这是最强的可迁移叙事

同一套动作在两个领域的实例化:

步骤 Spark 场景 GPU 场景
采集基线 固定数据量与集群配置跑基准作业 固定输入分布与环境测吞吐/延迟
定位热点 Spark UI 看 Stage/Task 耗时与 Shuffle 量 Nsight 看 Kernel 时间线与空隙
提假设 “倾斜 key 导致长尾,加盐后 Stage 时间降 X%” “数据供给不足导致锯齿,调 workers 后利用率升 X%”
单变量验证 只改分区数/只开 AQE 只改 num_workers / 只开 FlashAttention
留档 前后 Stage 耗时对比 前后 P50/P99 与利用率对比
沉淀 倾斜探测脚本、调优清单 Profiling 模板、选型对比表

结论话术:我迁移的不是框架 API,而是”先量化、再定位、再单变量验证”的工程方法论——这正是性能优化岗位真正需要的能力。

参考:NVIDIA《GPU Performance Analysis and Optimization》博客系列

大数据工具 → GPU 工具的概念映射

一句话定位:这是我的表达优势——Spark/Flink 的性能分析范式与 GPU 的性能分析范式在结构上完全同构,把映射关系讲清楚,就能证明既有经验可以直接迁移。

1. 核心映射表

大数据侧(Spark / Flink UI) GPU 侧(Nsight / PyTorch Profiler) 共同本质
Stage / Task 耗时分布 Kernel 时间线与耗时排序 按执行单元找耗时热点
Shuffle 读写量 显存带宽 / H2D-D2H 拷贝量 数据搬运量是主要成本
长尾 Task(木桶效应) 长尾 Kernel / 静态 Batching 木桶效应 整批完成时间受最慢单元支配
Executor 内存溢出(OOM) 显存 OOM 单执行单元内存超限
Stage 之间的同步屏障 Kernel 同步点 / Stream 同步 同步破坏流水、造成空转
数据倾斜(key 分布不均) batch 内序列长度不均 负载不均导致资源空闲
小文件过多 → 调度开销 大量极短 Kernel → 启动开销 单元过细,管理成本压倒计算

2. 逐条展开(面试可直接说)

  • Stage/Task 耗时分布 ↔ Kernel 时间线:两边的第一动作都是”看执行单元的耗时排序找热点”,而不是凭经验猜。
  • Shuffle 读写量 ↔ 显存带宽/拷贝量:Spark 优化的主线是减少 Shuffle 的数据量(列裁剪、map 端预聚合),GPU 优化的主线是减少 HBM 往返(算子融合、FlashAttention)——都是”访存/搬运”而非”算力”是瓶颈
  • 长尾 Task ↔ 木桶效应 batch:Spark 里一个倾斜 key 拖慢整个 Stage;LLM 推理里一个超长序列拖慢整个静态 batch。解法也同构:细化调度粒度(AQE 拆分倾斜分区 ↔ Continuous Batching 迭代级出队)。
  • Executor OOM ↔ 显存 OOM:排查思路一致——先分清是”真的用满”还是”分配器/缓存占着不还”(Spark 统一内存的存储挤占执行 ↔ PyTorch caching allocator 的 reserved vs allocated,见 3.2)。

3. 方法论层面的同构

工具不同,但动作序列完全一样:量化基线 → 看分布找热点 → 提假设 → 单变量验证 → 留档对比(详见 5.5)。这也是为什么”从大数据转 GPU 性能优化”是一条成立的路径。

参考:Spark 官方文档 Web UI 章节;Flink 官方文档 Web UI 章节

PyTorch Profiler

一句话定位:框架层的性能显微镜——把一次前向/反向拆成算子级明细,回答”时间花在哪个算子、是 CPU 还是 GPU、显存怎么涨的”。

1. 能采集什么

  • CPU / GPU 时间线:每个算子在 CPU 侧(Python/调度)与 GPU 侧(Kernel 执行)分别耗时多少,可导出 Chrome trace 可视化查看;
  • 算子耗时排序:按 self/total time 对算子聚合排序,直接给出 Top-N 热点;
  • 显存占用趋势:记录分配/释放曲线与峰值,配合 OOM 排查(3.2);
  • Kernel 与 Python 栈关联with_stack=True 可把某个 GPU Kernel 回溯到具体的 Python 代码行——这是它相对 Nsight(5.2)的最大优势:框架语义清晰、能定位到自己写的那行代码

2. 关键指标

  • GPU Kernel 占比:GPU Kernel 累计时间 / 总墙钟时间。占比低意味着大量时间耗在等待、拷贝或 Python 开销上 → 先解决供给与调度(见 3.3);
  • 算子 Top-N 耗时:优化必须从最贵的算子入手(遵循 Amdahl 定律),避免优化了占比 1% 的算子却期待整体提速。

3. 使用要点

  • 一定要跳过预热步(前几个 iteration 含 CUDA 上下文初始化、autotune、缓存分配器预热),用 schedule 设置 wait/warmup/active 阶段,只统计稳定态(同 5.4);
  • 采样本身有开销,不要在采样状态下评估绝对性能;
  • 定位到热点算子后,再决定动作:算子融合、换实现(如启用 FlashAttention,3.7)、调 batch、或量化(4)。

4. 与大数据工具的对应

PyTorch Profiler 的”算子耗时排序” ≈ Spark UI 的”Stage/Task 耗时明细”——都是先按耗时排序找热点,再针对热点提假设(详见 5.3 映射表)。

参考:PyTorch 官方 Profiler 教程

Nsight Systems

一句话定位:系统级时间线剖析工具——不只看算子,而是把 CPU 线程、CUDA API、Kernel 执行、内存拷贝、多流并发画在同一条时间轴上,用来发现”GPU 为什么在空转”。

1. 能看什么

  • Kernel 执行:每个 Kernel 的起止时间、持续时长、所在 Stream;
  • H2D / D2H 拷贝:Host↔Device 数据传输的时机与耗时,是否与计算重叠(未重叠说明缺少 pinned memory 或异步拷贝,见 2.9);
  • CUDA Stream 并发:多流是否真正并行,还是被隐式同步串行化;
  • 同步点cudaDeviceSynchronize.item().cpu()、打印张量等操作造成的阻塞;这些同步点会打断流水,是常见性能杀手。

2. 核心判据:Kernel 之间的空隙就是 GPU 空转

这是使用 Nsight 最重要的一条:

  • 时间线上若 Kernel 密集连续 → GPU 被喂满,应转向优化 Kernel 本身(换实现、量化);
  • Kernel 之间存在明显空隙(gap) → GPU 在等待,这些空隙就是首要优化目标。按成因分别处理:
    • 空隙伴随 CPU 侧繁忙 → 数据供给/前处理瓶颈(调 DataLoader,2.9);
    • 空隙伴随 H2D 拷贝 → 拷贝未与计算重叠(用 pinned memory + non_blocking);
    • 空隙前有同步 API → 去掉不必要的同步(如训练循环里的 loss.item() 每步都同步);
    • 大量极短 Kernel 密集排列且间隔均匀 → Kernel 启动开销占主导,用算子融合 / CUDA Graph 合并。

3. 与 PyTorch Profiler 的分工

  • PyTorch Profiler(5.1):框架语义强,能回溯到 Python 代码行,适合定位”哪个算子贵”;
  • Nsight Systems:系统视角,适合定位”为什么 GPU 闲着“(调度、同步、拷贝、并发问题)。
  • 实践顺序:先用 Nsight 看整体时间线判定瓶颈类别(3.3),再用 PyTorch Profiler 深挖具体算子。

参考:NVIDIA Nsight Systems 用户指南

C++ 内存管理

一句话定位:C++ 没有 GC,靠 RAII(资源获取即初始化)把内存/文件/锁等资源的生命周期绑定到对象的构造/析构上,编译器保证析构一定被调用,从而在没有垃圾回收的前提下做到”自动释放”。

1. RAII:核心思想

  • 资源在构造函数里获取,在析构函数里释放;只要对象离开作用域(正常返回、异常抛出、提前 return),析构函数都会被调用
  • 好处:把”何时释放”这个容易遗漏的问题,转成”对象生命周期管理”这个编译器能强制保证的问题,天然异常安全。

2. 智能指针:RAII 在动态内存上的应用

  • unique_ptr:独占所有权,不可拷贝只能移动(move);开销几乎等于裸指针,是默认首选。
  • shared_ptr:引用计数式共享所有权,计数归零时自动释放;代价是原子计数的线程安全开销,且存在循环引用会导致内存永远无法释放的陷阱。
  • weak_ptr:不增加引用计数的”观察者”引用,专门用来打破 shared_ptr 之间的循环引用(如父子节点互相持有对方的场景)。

3. 面试落点

  • 能讲清”为什么 C++ 不需要 GC 也能自动管理内存”——本质是编译器保证的析构调用时机确定,而 GC 语言的回收时机不确定。
  • 能识别循环引用场景(如双向链表、观察者模式)并知道用 weak_ptr 破除。
  • 可迁移话术:这套”生命周期绑定资源释放”的思路和 Java 的 try-with-resources、Python 的 with 语句是同一类模式,只是 C++ 把它做成了语言默认行为。

参考:《Effective Modern C++》(Scott Meyers)