Lecture 13: Domain-Specific Programming Systems and AI-Driven Performance Optimization(日期:2025-11-06, Thursday)

目录 · ← l12 · l14 →

Lecture 13: Domain-Specific Programming Systems and AI-Driven Performance Optimization(日期:2025-11-06, Thursday)

概述:本讲聚焦”如何提高性能优化工作的生产力”。核心想法有三条:(1) 提高抽象层次(Domain-Specific Languages,DSL),把”算法”与”调度”分离,代表作是图像处理 DSL —— Halide;(2) 智能搜索(automatic search / autotuning),让编译器在巨大的调度空间中自动寻找高性能实现;(3) 新兴的第三条路 —— 利用现代 LLM 的代码生成与问题求解能力,构建”AI 智能体”来自动写 kernel、profile、反思、迭代优化。课程最终指出:性能优化既需要专家知识又枯燥费时,是自动化(DSL 自动调度 + LLM 智能体)最有价值的应用场景之一。

注意:本讲内容与 Assignment 4(Trainium2 上的 Fused Conv+MaxPool kernel,11 月 13 日截止)以及 Assignment 5(”写出世界上最快的 CUDA kernel”,12 月 4 日截止)的优化思路直接相关——你在作业中学到的 profile→定位瓶颈→改写 kernel 的循环,正是本讲”AI 智能体优化循环”中人类要做的同一件事。


一、核心概念与定义

1. Performance / Productivity / Generality 三角(理想并行语言)

  • 定义:任何并行编程语言都在三个维度上权衡:Performance(能否榨干硬件性能)、Productivity(写起来是否省力)、Generality(能否表达各种不同问题)。不存在三者兼得的”理想并行编程语言”(slide 中借用 Pat Hanrahan 的经典设计)。
  • 现实类比:就像”好吃、便宜、快”不可能同时满足的餐厅三难(不可能三角)。C++/ISPC/CUDA 性能强、通用性强,但生产力极低;Python 生产力高但性能差。
  • 图示
            Performance(性能)
                /\
               /  \
              /    \
             / 理想语言 \
            /   (不存在)\
           /______________\
     Productivity       Generality
     (生产力)          (通用性)
    

2. DSL(Domain-Specific Language,领域特定语言)

  • 定义:针对某一特定领域、表达能力受限(restricted expressiveness)的编程语言;通常是高层、声明式(declarative)且确定性的(deterministic)。
  • 现实类比:餐馆菜单而不是通用菜谱大全。菜单只列本店能做的菜(受限表达力),点菜就是”声明我要什么”(declarative),厨房怎么做(调度)由后厨决定。
  • 要点:限制表达力正是换取性能与生产力的手段——系统因为知道”你只会做这几种菜”,才能为每一种菜准备最优做法。

3. Domain-Specific Programming System(领域特定编程系统)

  • 定义:围绕 DSL 构建的完整系统。核心思想是提高表达程序的抽象层次,目标是:(a) 快速为某台目标机器写出高性能程序;(b) 一份程序在不同机器上都能高效运行。做法是引入针对应用领域的高层编程原语(primitives)。
  • 现实类比:快递公司分拣系统。你只需写”把包裹送到 X 城市”(高层原语),分拣系统(领域知识)自动决定用哪条运输线路、哪种车辆(算法选择)、如何合并拼车(并行化策略),甚至物流网络本身(硬件)也为这种抽象优化。
  • 关键句(slide 8):优化不止是”把软件高效映射到硬件”——硬件平台本身也可以针对这些抽象来优化。代价是:通用性/完备性的损失(loss of generality/completeness)。

4. Halide(图像处理 DSL)

  • 定义:一个嵌入 C++ 的、用于描述图像处理操作序列的简单 DSL(Jonathan Ragan-Kelley、Andrew Adams 等人,SIGGRAPH 2012 / PLDI 2013)。已在 Google 手机相机管线(HDR+、人像模式等)、Instagram、Adobe 等生产环境中使用。
  • 现实类比:写图像处理就像写”烹饪配方”:你只管描述”最终菜品长什么样、每步用什么原料”,至于用什么锅、多大火、先切后炒还是先炒后切(循环顺序、向量化、多核并行)是另一层的事情。
  • 核心抽象:Halide Func(函数)把整数坐标映射到值(如像素颜色);Halide expression 是无副作用(side-effect free)的表达式,描述如何用其他函数的值计算某点上的值。程序本质是一张 DAG(数据流图)。

5. Algorithm / Schedule 分离(算法与调度分离)

  • 定义:Halide 把程序拆成两层——algorithm(”做什么”:声明式描述每个输出像素如何由输入计算而来)与 schedule(”怎么做”:循环顺序、分块 tiling、向量化 vectorize、多核并行 parallel、中间量在哪里计算 compute_at)。程序员用一组调度原语给出高层”草图”,由 Halide 编译器机械地生成底层平台特定代码(pthreads、AVX intrinsics 等)。
  • 现实类比:电影导演 vs 摄影组。导演(程序员)决定”这场戏拍什么内容、机位大致怎么摆”(algorithm + 高层 schedule),摄影、灯光、场务(编译器)负责把细节落地。
  • 要点:Halide 的哲学是——程序员负责算法,并对如何高效调度有直觉;系统(编译器)不”聪明”,只负责机械地把调度草图翻译成目标机器上的具体机制

6. 调度原语(Scheduling Primitives)

  • 定义:Halide 提供的描述 N 维域迭代方式的指令,可同时指定迭代顺序并行化方式(多线程、SIMD 向量化)。常用组合:
    • tile(x, y, xi, yi, W, H):把外层 (x,y) 循环按 W×H 分块,产生内部 (xi,yi) 循环;
    • vectorize(xi, 8):把 xi 循环用 8 宽 SIMD 指令实现;
    • parallel(y):把 y 循环用线程并行;
    • compute_at(out, x) / compute_root():决定中间 Func 在哪个循环层级计算(影响局部性与中间存储大小)。
  • 现实类比:装修时给工人下”施工指令”——”每个房间(tile)内,先铺地板再刷墙(循环顺序),地板用 8 块并行铺(vectorize),不同房间分给不同工人(parallel)”。

7. 算术强度与局部性(Arithmetic Intensity & Locality)

  • 定义:3x3 box blur 直接实现的总工作量是 9×WIDTH×HEIGHT(N×N 滤波器为 N²×W×H);利用可分离性(separable filter)改成两遍 1D 滤波后为 6×W×H(N×N 为 2N×W×H),算术强度降低 2 倍(每像素的乘加次数变少)。局部性分析关注:每个数据被复用几次、是否重复加载、缓存行是否被充分利用。
  • 现实类比:买食材做 9 道菜——”直接法”每道菜都单独去菜市场买齐 9 种配料(重复加载);”两遍法”先集中采购一批、再做中间处理,食材(数据)被反复利用。
  • 公式
    • 直接 2D 滤波:work = N² × WIDTH × HEIGHT
    • 两遍可分离滤波:work = 2N × WIDTH × HEIGHT
    • 分块版(CHUNK_SIZE=16):work = (34/16) × 3 × WIDTH × HEIGHT ≈ 6.4 × W × H,随 CHUNK_SIZE 增大趋近理想值 6×W×H。

8. 自动调度 / 自动调优(Autoscheduler / Autotuning)

  • 定义:把”找好调度”建模为在调度空间中进行序列化决策的搜索问题:从 DAG 末端开始,对每个节点 N 依次决定 (1) N 在现有循环嵌套中的位置(即 compute_at 到哪一层);(2) N 的 tile 大小。然后用搜索算法(greedy search、beam search)在数十万乃至上百万个候选调度中寻找代价最小的一个。
  • 现实类比:装修公司报价系统——对”地板、墙、吊顶”每道工序,系统枚举”在哪个房间做、用什么尺寸”的所有组合,用一个快速估价模型挑出最便宜的方案。
  • 关键数据(slide 41):代价估计用一个简单 MLP(多层感知机),每个调度只需几十微秒(1.4M 个调度在 166 秒内测完);该 MLP 在大量随机生成的 Halide 程序上训练(把程序编译执行得到真实代价作为标签),实际输出 27 个系数,代入一个手工设计的代价模型。

9. LLM 智能体优化循环(LLM Agent Optimization Loop)

  • 定义:把”性能优化工程师”的工作流程自动化:给定起点代码(如 PyTorch)+ 提示词(”你是 CS149 性能优化工程师,请把它改写成高性能 CUDA”)→ LLM 生成 kernel → 执行/Profile(正确性 Y/N、耗时、SM 利用率、DRAM 利用率、L2 命中率等统计)→ 把 profile 统计反馈给 LLM 让它反思瓶颈原因并修改代码 → 循环直到达标。这就是”通过反思进行试错”(trial and error via reflection)。
  • 现实类比:带教实习生——导师(profile 工具)每轮给出”这版哪里慢”的诊断,实习生(LLM)据此改下一版,而不是一次性写出完美代码。
  • 图示
    PyTorch 起点代码 + 提示词("你是 CS149 性能优化工程师…")
          │
          ▼
     ┌─────────┐    CUDA 代码    ┌──────────────────┐
     │   LLM   │ ──────────────► │  Execute / Profile │
     └─────────┘                 │  正确性 Y/N、耗时 32ms│
          ▲                      │  SM util 42%、DRAM 89%│
          │                      │  L2 命中率 68%        │
          └── 反思 + 修改代码 ◄── └──────────────────┘
          ("分析是什么拖慢了程序,然后基于你的分析修改代码")
    

10. KernelBench 与 DNN DSL(Triton / CUTLASS-CuTe / TileLang)

  • 定义KernelBench 是一个包含数百个 PyTorch kernel 的基准测试集,LLM 智能体的目标是自动产出又快又正确的 CUDA kernel。另外,为 DNN 编写的 DSL(Triton、CUTLASS/CuTe、TileLang)也能帮助自动化:LLM 拼装的是高层高性能原语而不是手写底层 CUDA,因此更不容易出错/幻觉;挑战在于”用得少的语言训练数据少,LLM 容易写错,但会随着时间改善”。
  • 现实类比:给装修工人提供标准化预制件(DSL 原语)而不是让他现场砌砖(底层 CUDA)——出错率低、速度快,前提是工人熟悉这些预制件的规格。

11. LLM 智能体的四种自我改进思路(Idea 1–4)

  • 定义(slides 50–54):
    • Idea 1:基于经验微调(fine-tune)——用大量同类任务的经验微调一个专用 LLM,需要大量任务样本与微调大模型的能力。
    • Idea 2:维护”优秀例题”数据库——智能体不断积累”优质 kernel 解决方案库”(如用 Thunderkittens 或 CuTe 写的高质量方案),新问题来时检索最相关的例题辅助生成;库里不仅存方案,还存一系列优化决策的序列
    • Idea 3:从经验中优化提示词(prompt optimization)——不直接给相关例子,而是由一个 prompt optimizer 观察优化循环的历史轨迹(trajectories),总结出重要事实与原则,更新给 LLM 的提示词。
    • Idea 4:穷举搜索 + LLM 智能体结合——把 Halide 式自动调优的穷举搜索技术与上述 agentic 思路结合;优化成本极高,但能取得一些最好的结果。
  • 现实类比:实习生成长三阶段——(1) 送去集训班(fine-tune);(2) 建立自己的”错题本/优秀作业本”并考前翻看(retrieval DB);(3) 把老师批改意见总结成自己的”工作守则”(prompt optimization)。

12. “正确的表示”(The Right Representation)原则

  • 定义(slide 28):任何系统的设计核心是为任务选择正确的表示。好的表示应满足:(a) 生产力——体现人思考问题的自然方式;(b) 让系统能提供服务——如正确性/资源界/类型检查等保证,以及性能服务(并行化、向量化、专用硬件利用)。Halide 的贡献在于:任务从”表达图像处理计算”变成了”为特定 Halide 程序生成高效实现”——正是因为算法/调度两种表示被分开,系统才能插手优化。
  • 现实类比:用坐标纸画工程图(结构化表示)才能让 CAD 自动算面积、做有限元分析;随手素描(自由表示)画得再像,软件也帮不上忙。

13. 调度搜索空间与代价模型(Cost Model)

  • 定义(slides 40–41):调度空间由”每个节点的 compute_at 层级 × tile 尺寸”的所有组合构成,可达数十万以上候选。搜索需要代价估计:本讲采用 AI 方法——一个简单 MLP 对”程序 + 调度”打分,单次只需几十微秒(1.4M 个调度 166 秒测完);MLP 在随机生成的 Halide 程序库上训练(把这些程序真实编译执行得到代价作为标签),实际输出 27 个系数,代入手工设计的代价公式得到最终估计值。
  • 现实类比:买房时用”估价模型”快速给几十万套房打分,而不是每套都实地看房(实地看房 = 真实编译执行,太贵)。

14. 可移植性与硬件协同设计(Portability & HW Co-Design)

  • 定义(slide 8):领域特定编程系统的两大目标之一是”写一份程序,在不同机器上高效运行“(write one program, run it efficiently on different machines)。更进一步:优化不止是软件到硬件的映射——硬件平台本身也可以针对这些抽象进行优化(the hardware platform itself can be optimized to the abstractions)。代价是通用性/完备性的损失。
  • 现实类比:标准集装箱(抽象原语)既让货主只写一次”装箱单”,也让码头、轮船、卡车(硬件)全部按集装箱规格设计——物流全链条都因统一抽象而高效,但散货(通用性)就没法用这套系统。

二、代码示例与详细解说(本讲重点)

示例 1:朴素的 3×3 box blur(直接 2D 滤波)——”性能不好的起点”

代码(C)

int WIDTH = 1024;
int HEIGHT = 1024;
float input[(WIDTH+2) * (HEIGHT+2)];   // 输入图像,四周各留 1 像素边界
float output[WIDTH * HEIGHT];
float weights[] = {1.f/9, 1.f/9, 1.f/9,
                   1.f/9, 1.f/9, 1.f/9,
                   1.f/9, 1.f/9, 1.f/9};

for (int j=0; j<HEIGHT; j++) {
  for (int i=0; i<WIDTH; i++) {
    float tmp = 0.f;
    for (int jj=0; jj<3; jj++)
      for (int ii=0; ii<3; ii++)
        tmp += input[(j+jj)*(WIDTH+2) + (i+ii)] * weights[jj*3 + ii];
    output[j*WIDTH + i] = tmp;
  }
}

【代码做了什么?】

  • 这是 CS149 幻灯片上的原始”朴素版”:对每个输出像素 (i,j),累加其 3×3 邻域 (jj,ii) 与 3×3 权重模板 weights 的逐元素乘积,得到模糊后的像素值。
  • 计算总量是 9 × WIDTH × HEIGHT 次乘加:对 N×N 滤波器推广为 N² × WIDTH × HEIGHT
  • 输入数组刻意做成 (WIDTH+2) × (HEIGHT+2),即四周有一圈 padding,这样边界像素的 3×3 邻域也能无分支地访问。

【并行机制解说】

  • 这段代码尚未做任何并行优化:没有多线程、没有 SIMD、也没有调整循环顺序。它是本讲性能讨论的”基准起点”。
  • 从并行角度观察:输出像素彼此独立(每个 output 元素只依赖 input 的 3×3 邻域),因此天然可并行——这正是 Halide 用声明式表达式out(x,y) = f(in 邻域))能够表达、并由调度器自动生成并行循环的原因。
  • 对应概念:算术强度 / 局部性 / 提升抽象层次——人类直接手写并行版本(如 slide 22 的优化 C++)虽然能快 10 倍,但代码变得”看不懂、只适用于 SSE、只适用 CPU”;Halide 想解决的就是这个问题。

示例 2:可分离两遍滤波 + 分块(chunked)——”手工优化之旅”

代码(C)(对应 slide 20 的 chunked version 2,CHUNK_SIZE=16):

int WIDTH = 1024;
int HEIGHT = 1024;
int CHUNK_SIZE = 16;
float input[(WIDTH+2) * (HEIGHT+2)];
float tmp_buf[WIDTH * (CHUNK_SIZE+2)];   // 中间缓冲:只放 CHUNK_SIZE+2 行
float output[WIDTH * HEIGHT];
float weights[] = {1.f/3, 1.f/3, 1.f/3};

for (int j=0; j<HEIGHT; j += CHUNK_SIZE) {          // 外层:按 CHUNK 行处理
  // 第一步:水平模糊,产出本 chunk 需要的 CHUNK_SIZE+2 行 tmp_buf
  for (int j2=0; j2<CHUNK_SIZE+2; j2++)
    for (int i=0; i<WIDTH; i++) {
      float tmp = 0.f;
      for (int ii=0; ii<3; ii++)
        tmp += input[(j+j2)*(WIDTH+2) + i+ii] * weights[ii];
      tmp_buf[j2*WIDTH + i] = tmp;
    }
  // 第二步:垂直模糊,由 tmp_buf 的 3 行生成 CHUNK_SIZE 行输出
  for (int j2=0; j2<CHUNK_SIZE; j2++)
    for (int i=0; i<WIDTH; i++) {
      float tmp = 0.f;
      for (int jj=0; jj<3; jj++)
        tmp += tmp_buf[(j2+jj)*WIDTH + i] * weights[jj];
      output[(j+j2)*WIDTH + i] = tmp;
    }
}

【代码做了什么?】

  • 利用 3×3 box 滤波的可分离性(separable):2D 模板 = 水平 1D 模板 ⊗ 垂直 1D 模板,于是先做水平 1D 模糊(读 input 写 tmp_buf),再做垂直 1D 模糊(读 tmp_buf 写 output)。
  • 直接两遍法总工作量是 6 × W×H(每像素 6 次乘加),比直接 2D 法的 9 × W×H 少 1/3;代价是需要 W × (H+2) 的中间存储,且多了对 tmp_buf 的读写流量。
  • 分块版把中间缓冲压缩到 W × (CHUNK_SIZE+2)(只保留产出当前 chunk 输出所需的最少行数),让整块 tmp_buf 能装进缓存,捕获”生产者-消费者局部性”。
  • 工作量核算:每个 chunk 第一步 18×3×WIDTH(水平模糊 CHUNK_SIZE+2=18 行)、第二步 16×3×WIDTH;摊到整幅图是 (34/16)×3×W×H = 6.4×W×H随着 CHUNK_SIZE 增大趋近理想值 6×W×H(因为重叠的边界行占比变小)。

【并行机制解说】

  • 这一步的”并行”其实发生在缓存层级(cache-level parallelism of the memory system):tmp_buf 的所有读写都命中缓存,避免把中间结果写回内存再读回——这是利用局部性减少片外通信,与多核/向量化正交。
  • 但 slide 21 明确指出”还没做完”:循环尚未为多核并行化、未用 SIMD、未做循环展开等基本优化。手工把这些全做上(slide 22 的优化 C++)需要:SSE 向量 intrinsics、256×32 分块迭代顺序最大化命中率、按行切分图像多核执行、把两遍融合使 tmp 数据直接从缓存读取——最终比两遍朴素版快约 10 倍,但代码可读性极差且不可移植。
  • 对应概念:局部性 / 算术强度 / “手工优化 = 低生产力”——这正是 Halide 想用”调度”这一层抽象自动化掉的工作。

示例 3:Halide——算法与调度分离

代码(C++,嵌入 Halide DSL)(对应 slide 23/29 的完整版本,含 bright 与查表):

#include "Halide.h"
using namespace Halide;

int main() {
    Var x, y, xi, yi;
    Func blurx, blury, bright, out;
    Buffer<uint8_t> in    = load_image("myimage.jpg");   // 输入照片
    Buffer<uint8_t> lookup = load_image("s_curve.jpg");  // 255 像素 1D 查找表

    // ---- 算法描述(algorithm:声明"做什么",无任何循环)----
    blurx(x,y) = 1/3.f * (in(x-1,y) + in(x,y) + in(x+1,y));      // 水平 1D 模糊
    blury(x,y) = 1/3.f * (blurx(x,y-1) + blurx(x,y) + blurx(x,y+1)); // 垂直 1D 模糊
    bright(x,y) = min(blury(x,y) * 1.25f, 255);                  // 提亮 25% 并截断
    out(x,y) = lookup(bright(x,y));                              // 查表对比度增强

    // ---- 调度描述(schedule:声明"怎么做")----
    out.tile(x, y, xi, yi, 256, 32)   // 2D 分块 256×32
       .vectorize(xi, 8)              // 内部 xi 循环 8 宽 SIMD
       .parallel(y);                  // 外部 y 循环多线程
    blurx.compute_at(out, x)          // blurx 在每个 tile 内按需计算
         .vectorize(x, 8);            // 内部 x 循环 8 宽 SIMD

    // 在 1024×1024 域上执行整条流水线
    Buffer<uint8_t> result = out.realize(1024, 1024);
    return 0;
}

【代码做了什么?】

  • 算法部分:blurx(x,y) 是”坐标 (x,y) 处的水平模糊值 = 输入图像同一行三个相邻像素的加权平均”;blury 在 blurx 上再做垂直 1D 模糊;bright 提亮并 clamp 到 255;out 用 bright 的值查 s_curve 查找表。四个 Func 形成一张 DAG:in → blurx → blury → bright → out(外加 lookup)。
  • 每个 Halide 表达式都是无副作用的纯函数定义:它只说明”要算某点的值需要哪些其他点的值”,不规定迭代顺序、不规定哪些中间值要存下来——迭代整个域是隐式的(realize 触发)。
  • 调度部分:out.tile(x, y, xi, yi, 256, 32) 生成外层 (x,y) 与内层 (xi,yi) 的两级循环;.vectorize(xi,8) 把 256 宽的内层 xi 循环改成 8 宽 SIMD(编译器自动处理 256+2 不能被 8 整除的边界条件);.parallel(y) 用线程并行化外层 y;blurx.compute_at(out, x) 表示 blurx 在每个输出 tile 内部按需计算(只需分配 258×34 左右的小缓冲),vectorize(x,8) 让 blurx 的计算也向量化。

【并行机制解说】

  • 并行如何实现?全部由编译器完成:程序员只给了 4 行高层”调度草图”,Halide 编译器机械地把它展开成等价的并行循环嵌套(pthreads + AVX intrinsics + 边界条件处理)——这正是 slide 35 所说”系统(编译器)不聪明,它提供的是把调度细节机械落地到目标机器机制(pthreads、AVX 等)的服务”。
  • 数据共享/同步点:多线程并行的是外层 tile 循环,每个线程处理不同的 y-tile,输出像素互不重叠,因此线程间无数据竞争;共享的是只读的 input/lookup。blurx 是每个线程私有的小块缓冲(生产者-消费者局部性在单线程内由缓存捕获)。
  • 对应概念:Algorithm/Schedule 分离、调度原语、DSL 提升抽象层次。Halide 的代价是通用性受限:只支持规则 N-D 域上的前馈流水线(加上 reduction 与固定深度递归的特别支持),且要求编译器能推断所有依赖(slide 36)——”受限表达力”换来的是自动化的可能性。

示例 4:自动调度 = 序列化决策 + 搜索(autoscheduler 伪代码)

代码(伪代码)(对应 slide 39–41 的调度搜索建模):

// 输入:Halide 程序 DAG(节点 = Func),如 in → blurx → blury → out
// 输出:一个完整的 schedule(每个节点的 compute_at 位置 + tile 大小)

function AUTOSCHEDULE(dag):
    schedule = EMPTY_SCHEDULE
    // 从 DAG 末端(输出节点)开始,逆向依次调度每个节点
    for node in REVERSE_TOPOLOGICAL_ORDER(dag):
        candidates = []
        for loop_level in ALL_LOOP_LEVELS(schedule):        // 决策 1:compute_at 放哪层
            for (tw, th) in ALL_TILE_SIZES:                 // 决策 2:tile 尺寸
                s = schedule + PLACE(node, loop_level, tw, th)
                cost = COST_MODEL(dag, s)                   // MLP 估计:几十微秒/次
                candidates.append((cost, s))
        schedule = BEAM_SEARCH_PICK(candidates)             // 保留 beam 宽度的最优分支
    return schedule

function COST_MODEL(dag, s):
    // 用训练好的 MLP 估算:(1) 该 schedule 的循环开销、缓存局部性、
    //                        (2) 向量化/并行的收益……
    // MLP 不直接输出代价,而是输出 27 个系数,代入手工设计的代价公式
    coeffs[27] = MLP_ENCODE(dag, s)
    return HANDCRAFTED_COST(coeffs)      // 估计吞吐量/像素每秒

【代码做了什么?】

  • 外层循环按逆拓扑序(从输出往前)逐个 Func 决定两件事:它应该 compute_at 到当前循环嵌套的哪一层、以及它的 tile 尺寸。每做一个局部决策,就用代价模型给”当前部分完成的调度”打分(slide 40 图中每个节点旁的数字就是该部分调度的估计代价)。
  • 搜索策略用 greedy search / beam search:每次保留代价最低的若干候选分支继续扩展,而不是枚举全部(全部可能多达几十万个以上)。
  • 代价模型是 AI 驱动的:一个简单 MLP 用几十微秒即可给一个调度打分——slide 41 报告 1.4M 个调度 166 秒测完;MLP 在大量随机生成并真实编译执行的 Halide 程序上训练,输出 27 个系数交给手工设计的代价公式。

【并行机制解说】

  • 这个”并行”是元层面的并行搜索:不是程序运行时的并行,而是”调度空间”中的并行搜索与加速。MLP 之所以能快到几十微秒/次,是因为它把”编译+执行+测量”这一昂贵流程替换为一次前向推理。
  • 结果(slide 42–43):autoscheduler 生成的调度与已知最好的手工调度相当(在 CPU 上做图像处理,想手工写出更好的调度相当困难);且能大幅节省专家时间——图中人类专家(Dillon、Andrew)花几十分钟手工调参达到的吞吐量,自动调度器很快就能达到甚至超越。
  • 对应概念:自动调优 / 智能搜索。课程强调:Halide 调度抽象之所以能自动化,正是因为高层的调度原语让”所有可能调度”这个空间可以被干净地枚举(slide 44)——反观 C++,”搜索所有可能的 C++ 程序排列”根本无从谈起。

示例 5:LLM 智能体优化循环(KernelBench 场景)

代码(伪代码)(对应 slide 46 的 “trial and error via reflection”):

PROMPT_INIT = "You are a performance optimization engineer in CS149.
               Please rewrite the following PyTorch code as high performance
               code in CUDA. Keep in mind the code optimization principles
               we discussed in class…"

PROMPT_REFLECT = "You are an optimization engineer in CS149. Given the input
                  code and the profiling statistics produced by running the
                  code on an H100 GPU, reflect on what might be slowing the
                  program down. Then, given the code and your reflection,
                  make an edit to the code to address the reason for the
                  slowdown that you identified."

kernel = LLM_GENERATE(pytorch_code, PROMPT_INIT)
for round in 1..MAX_ROUNDS:
    result = EXECUTE_AND_PROFILE(kernel)        // 在 H100 上编译运行
    stats = { correct: result.passes,           // 正确性 Y/N
              time_ms: result.time,             // 耗时:32 ms
              sm_util: result.sm_util,          // SM 利用率:42%
              dram_util: result.dram_util,      // DRAM 利用率:89%
              l2_hit: result.l2_hit_rate }      // L2 命中率:68%
    if stats.correct and stats.time_ms < TARGET: break
    kernel = LLM_EDIT(kernel, stats, PROMPT_REFLECT)   // 反思 + 修改
return kernel   // 最终:又快又正确的 CUDA kernel

【代码做了什么?】

  • 第一阶段:给 LLM 起点代码(PyTorch)与角色提示词,生成第一版 CUDA kernel。提示词刻意让 LLM “扮演 CS149 性能优化工程师”,并提醒它运用课堂上学过的优化原则(局部性、算术强度、向量化、占用率等)。
  • 第二阶段(循环):把 kernel 放到真实硬件(如 H100)上执行并 profile,得到正确性、耗时、SM 利用率、DRAM 利用率、L2 缓存命中率等统计;把这些统计原样喂给 LLM,让它反思“什么在拖慢程序”,然后基于反思修改代码;循环直到正确且达标。
  • 评价基准:KernelBench——一个含数百个 PyTorch kernel 的基准集,目标就是让 LLM 智能体自动产出快且正确的 CUDA kernel。

【并行机制解说】

  • 这里的”并行/加速”体现为闭环自动化:人类专家的”写代码→跑→profile→分析→改”循环被 LLM + 工具执行。它依赖两个前提:可执行、可测(profile 工具给出可解释的硬件计数器),以及 LLM 能把”统计数字 ↔ 代码结构”联系起来(如 DRAM 利用率高但 SM 利用率低 → 访存受限 → 需要改善数据复用/合并访问)。
  • 与之互补的是 DNN 领域 DSL(slide 48):让 LLM 拼装 Triton / CUTLASS-CuTe / TileLang 的高层原语,而不是手写裸 CUDA——正确性失误/幻觉更少;但冷门语言训练数据少,LLM 容易写错(预计随时间改善)。
  • 对应概念:LLM 智能体优化循环、KernelBench、AI 驱动优化。课程还列出四条进化路线(slide 50–54):fine-tune 专用 LLM、建立例题数据库做检索增强、用经验优化提示词、以及把穷举自动调优与 LLM agentic 结合(成本极高但效果最好)。

示例 6:遗传算法式自动调优(OpenTuner 风格)——搜参数 vs 搜调度

代码(Python 伪代码,OpenTuner/遗传算法风格)(补充”智能搜索”一类的经典形态):

import random

# 可调参数空间(以 CUDA kernel 为例):每个参数 = kernel 的一个变体维度
PARAM_NAMES = ["block_size_x", "block_size_y", "unroll_factor", "use_shared_mem", "vector_width"]
PARAM_SPACE = {
    "block_size_x":   [32, 64, 128, 256],
    "block_size_y":   [1, 2, 4, 8],
    "unroll_factor":  [1, 2, 4, 8],
    "use_shared_mem": [True, False],
    "vector_width":   [1, 2, 4],
}

def random_config():
    return {p: random.choice(v) for p, v in PARAM_SPACE.items()}

def evaluate(config):
    """把参数代进 kernel 模板、编译、在目标机器上运行并计时(真实测量)"""
    kernel_src = generate_kernel_from_template(config)   # 模板实例化
    compile_and_load(kernel_src)
    return measure_time_ms()                             # 越小越好

def mutate(cfg):
    cfg = dict(cfg)
    p = random.choice(PARAM_NAMES)
    cfg[p] = random.choice(PARAM_SPACE[p])               # 随机改一个参数
    return cfg

def crossover(a, b):
    return {p: (a[p] if random.random() < 0.5 else b[p]) for p in PARAM_NAMES}

def genetic_autotune(pop_size=16, generations=50):
    population = [random_config() for _ in range(pop_size)]
    for gen in range(generations):
        # 评估整代(可并行:每个个体独立编译+运行)
        scored = sorted([(evaluate(c), c) for c in population])
        best_time, best_cfg = scored[0]
        print(f"gen {gen}: best = {best_time} ms  {best_cfg}")
        # 精英保留 + 交叉 + 变异,生成下一代
        elites   = [c for _, c in scored[:pop_size // 4]]
        children = [crossover(random.choice(elites), random.choice(elites))
                    for _ in range(pop_size // 2)]
        children += [mutate(random.choice(elites)) for _ in range(pop_size - len(elites) - len(children))]
        population = elites + children
    return best_time, best_cfg

best_time, best_cfg = genetic_autotune()
print("best:", best_time, best_cfg)

【代码做了什么?】

  • 把 kernel 优化建模为参数搜索:每个”配置”是 block 尺寸、循环展开、共享内存、向量宽度等参数的组合;evaluate 负责把配置实例化成真实代码、编译并在目标机器上实测计时(真实硬件反馈,不是模型估计)。
  • 遗传算法流程:随机初始化种群 → 每代按实测性能排序 → 精英保留(top 1/4)→ 交叉(两个精英各取一半参数)→ 变异(随机改一个参数)→ 生成下一代;跑若干代收敛到最优参数。
  • 这是 OpenTuner 等经典 autotuner 的思想骨架,也是 slide 54 “Idea 4” 中”exhaustive search based techniques (like the Halide autotune)”的另一形态。

【并行机制解说】

  • 并行的两个层面:(1) 评估并行——种群中每个个体的”编译+运行+计时”互相独立,可多核/多机并行执行(与 Halide autoscheduler 用 MLP 加速代价估计形成对比:这里用真实测量,代价高但最准确);(2) 程序运行时的并行——最终找到的配置本身决定了 kernel 如何并行(block 尺寸、SIMD 宽度等)。
  • 与 Halide autoscheduler 的区别与联系:Halide 在结构化的调度空间(compute_at 层级 × tile 尺寸)上搜索,用 MLP 代价模型快速打分(几十微秒/个,可测 140 万个);遗传调优在参数空间上搜索,用真实硬件测量(每次要编译+运行,慢但准)。二者都体现”智能搜索“这一本讲主题,也预示 slide 54 的结论:把穷举/搜索类方法与 LLM agentic 方法结合,代价极高但效果最好。
  • 对应概念:autotuning / 智能搜索 / 代价模型 vs 实测

三、关键要点

  1. 性能优化是稀缺技能且枯燥:能写高性能 C++/ISPC/CUDA 的程序员很难找(”Proof by assignments 1–4”),而且换一台机器、换一个略有不同的问题就要重来一遍——这是自动化(DSL + 自动调度 + LLM 智能体)的最佳战场。
  2. 提高抽象层次是第一步:DSL 用”受限表达力”换取”系统可以利用领域知识做高性能实现 + 硬件可针对抽象优化”,代价是通用性/完备性损失。Halide 是教科书级案例:算法/调度分离后,程序员只需写”算法 + 4 行调度草图”。
  3. 调度可以且应该被搜索:Halide 的调度原语不仅提高专家生产力,更重要的是把”所有可能调度”变成了可枚举的空间,从而让贪婪/束搜索 + MLP 代价模型实现自动调度,效果可媲美人类专家(Adams 2019)。
  4. LLM 智能体正在改变优化范式:通过”生成 → 执行/profile → 反思 → 修改”的闭环,LLM 能自动把 PyTorch 改写为高性能 CUDA(KernelBench);用高层 DNN DSL 拼装原语可大幅降低幻觉风险。
  5. 开放问题:LLM 智能体能否成为”优秀的 CS149 学生”?代价是多少 token?而真正产生价值的到底是 DSL 设计还是 LLM 智能体,是值得争论的问题(slide 55)。

四、常见陷阱与注意事项

  1. 过早写底层优化代码:一上来就写 SSE/AVX intrinsics 的代码(slide 22 那种”快 10 倍但没人看得懂”),可读性、可移植性极差。应先确认瓶颈(profile),再考虑用 DSL/高层抽象表达,最后才落到平台特定优化。
  2. 忽视算术强度与局部性就谈并行:直接 2D 滤波(9×W×H)比两遍可分离(6×W×H)多 50% 工作量;中间缓冲放不进缓存时,两遍法会产生大量非固有的内存流量(tmp_buf 读写是实现伪影,不是计算本身需要的)。分块(chunking)是捕获 producer-consumer 局部性的关键。
  3. 把”算法”和”调度”混为一谈:在 Halide 中改 schedule 不会改变算法结果(都是声明式的、确定性的),但会剧烈改变性能;反之,用命令式循环手写时,算法与调度纠缠在一起,任何一处重排都可能引入 bug。
  4. 误以为 DSL 是万能的:Halide 只支持规则 N-D 域、前馈流水线(+ reduction、固定深度递归),且要求依赖全部可推断;超出领域,抽象反而成为束缚(通用性损失)。
  5. 对 LLM 生成代码盲目信任:LLM 输出必须经过”执行 + profile + 正确性检查”的闭环验证;在冷门语言(如新 DSL)上幻觉率更高;检索到的”例题解决方案”也要注意适配当前问题,而不是照抄。

五、思考题(带答案)

Q1:为什么”两遍可分离滤波 + 分块”能把工作量从 9×W×H 降到接近 6×W×H,而且分块大小越大越接近理想值? A1:可分离性把 2D 模板(9 次乘加/像素)拆成两个 1D 模板(3+3=6 次乘加/像素),所以计算量降到 6×W×H。分块只影响中间缓冲的重叠行:CHUNK_SIZE=16 时每 chunk 需算 18 行水平模糊(多出的 2 行是与相邻 chunk 重叠的边界行),摊到每 chunk 是 18+16=34 行的工作量对应 16 行输出,即 (34/16)×3×W×H ≈ 6.4×W×H;CHUNK_SIZE 越大,重叠行占比 2/CHUNK_SIZE 越小,工作量趋近 6×W×H。同时分块让 tmp_buf 装进缓存,消除了两遍法中读写中间结果的片外流量。

Q2:Halide 的 schedule 为什么能被”自动搜索”?换成一个任意 C++ 程序,同样的思路为什么行不通? A2:Halide 的调度原语(tile/vectorize/parallel/compute_at)构成了一套小而完备的决策空间——每个 Func 的 compute_at 层级与 tile 大小都是有限枚举,整个调度空间是”干净”的,可以建模成序列化决策并用贪婪/束搜索遍历(每个候选用 MLP 代价模型打分)。任意 C++ 程序的”等价变换”空间(循环重排、分块、融合、向量化、展开的组合)没有这样的规范表达,无法枚举,也就无法系统搜索——这正是 slide 44 的论点:”Consider searching over all possible permutations of a C++ program”。

Q3:LLM 智能体优化循环中,profile 统计(SM 利用率、DRAM 利用率、L2 命中率)为什么是关键?如果只有”正确/错误”二元反馈,会发生什么? A3:profile 统计提供了可操作的中间信号,让”反思”有据可依——例如 SM 利用率 42% 但 DRAM 利用率 89%,说明 kernel 是访存受限(memory-bound),应该减少冗余访存、改善数据复用或合并访问;L2 命中率低则提示改善局部性。若只有二元反馈,LLM 只能盲目试错(运气成分大、收敛慢),因为”错了”并不告诉它错在哪个优化维度。这正是”trial and error via reflection”中 reflection 的价值所在。

Q4(开放题):课程最后提出的争论——”真正带来成功的价值,到底在 DSL 设计,还是在 LLM 智能体?”你如何从三者的关系论证? A4(要点):(1) DSL(如 Halide、Triton、CuTe)提供了干净、可枚举、语义清晰的中间表示——没有算法/调度分离,就没有可搜索的调度空间,LLM 也无从”拼装高层原语”(slide 48 明说 LLM 拼装 DSL 原语比裸写 CUDA 幻觉更少);(2) 但 DSL 本身不解决”选择哪个调度”——那是智能搜索(autoscheduler)与 LLM 反思要做的;(3) 因此更合理的观点是二者互补:DSL 定义搜索/生成的空间,智能体(搜索算法或 LLM)在其中导航;LLM 的独特价值在于能利用自然语言知识(优化原则、历史经验、例题库)跨问题迁移,而搜索的价值在于穷举与可验证。这也是 slide 54 “Idea 4”(穷举搜索 + LLM agentic 结合)效果最好的原因。


本讲笔记基于 Stanford CS149 Fall 2025 Lecture 13 幻灯片(raw/aiperfoptimization.txt)撰写;Halide 示例参考 Ragan-Kelley/Adams 的 SIGGRAPH 2012 / PLDI 2013 工作,自动调度参考 Adams et al., SIGGRAPH 2019 “Learning to Optimize Halide with Tree Search and Random Programs”。