好的,正在等待你的连接。太好了,谢谢。Zoom 里的人能听到我说话吗?顺便确认一下……我不知道。是的,听到了。好的,听起来不错。大家好,我是 Alex Rinda。接下来我将介绍我们过去几年在“使用程序神经代理进行编程”方面所做的一些工作。
这项工作与我实验室的几位同事合作完成,包括 Yiding、Zach Inkner 以及我的导师 Mike Carbon。我将从展示一些示例或过去几年文献中的多篇论文开始,这些论文看似涉及不同领域且方法论各异。但我要透露一个秘密:尽管表面看起来各不相同,这些论文在底层都利用了同一个核心概念。
这个核心概念就是“程序代理”(surrogate of a program),即对给定程序行为的建模。通常,这种代理通过机器学习模型(如神经网络架构)来实现。其做法是训练该模型,使其对于给定的程序输入,能够大致预测出原程序的输出结果。
那么,为什么会出现这种现象?为何过去几年里有大约二十篇论文都使用程序代理呢?根本原因在于,代理可以简化提升复杂程序速度和准确性的过程。例如,Asmadi 等人利用代理将一组程序的运行速度提高了约 2.3 倍;Kutskowski 等人利用代理将一组物理仿真的准确性提高了约两到三倍。
此外,在我们之前的工作中,我们利用代理寻找仿真参数,以降低相对于专家设定参数的误差。由此可见,程序代理具有广泛的应用潜力和显著优势。但这引出了一个关键问题:为什么会有二十篇不同的论文讨论人们如何对程序代理进行编程?
这似乎意味着每当有人产生新想法,就会写一篇论文。从编程语言研究的背景来看,这显得缺乏系统性。我们本应有清晰的方法论来理解如何利用代理及其具体操作方式。然而,目前这一领域仍像“狂野西部”,人们各自为政,方法多为临时性的。
因此,过去几年我们主要致力于两个方向:一是开发基于代理的设计模式分类法(taxonomy),旨在理解人们将代理应用于编程任务的不同方式及其潜在收益;二是探索开发这些代理的实际编程语言方法论,特别是针对非机器学习专家或特定程序领域专家的指导方法。
在接下来的演讲中,我将首先介绍我们识别出的三种基于代理的设计模式:电路编译、电路适应和电路优化。随后,我将讨论我们在系统化构建和理解如何对程序进行代理编程方面的相关工作。
让我们从分类法中的案例研究开始,具体考察 LLVM MCA 这一程序。LLVM MCA 是包含在 LLVM 编译器基础设施中的一个 CPU 模拟器,是一个约一万行代码的 C++ 程序。我将简要介绍其输入输出规范及工作原理:它接收一个 x86 基本块(无跳转或循环的指令序列)以及一组 CPU 参数作为输入。
这些参数描述了所建模 CPU 的物理特性。LLVM MCA 的输出是该基本块的吞吐量,即假设在循环中重复执行且考虑到内存和缓存行为等假设时,执行该基本块所需的 CPU 时钟周期数。指令在内部经过四个阶段处理,其速度受模拟模型中物理参数可用性的控制。
这些阶段和资源的行为由必须设置的约一万一千个参数决定,以针对任何特定的目标 CPU 架构。此外,LLVM MCA 还有一个特点:与经典程序严格的正确性规范不同,像 LLVM MCA 这样的模拟器通常具有更宽松的“正确性”定义。
其中存在相应的准确率指标。在此前的工作中,我们开发了 Beehive 系统,这是一种针对真实 CPU 的时序评估方法,其假设与 LLVM MCA(机器代码分析器)所基于的假设相对应。此外,我们还收集了一个来自真实世界程序的基本块数据集,以实际验证这一准确性。当我们使用平均绝对百分比误差将两者进行对比时,发现 LLVM MCA 与真实 CPU 模拟结果相比存在约百分之二十五的误差,其数量级差异显著。
这就是我们正在处理的项目。接下来我将向大家介绍几种使用程序代理(surrogates)进行编程的不同设计模式,首先我要讲的是“代理编译”(Surrogate Compilation)。在这种模式下,你的任务是加速 LLVM 和 MCA。基本上,你可以想象自己是 LLVM 和 MCA 的开发者之一。你的老板走进来说:“嘿,这太慢了,我们需要它更快。”这可能是为了更快地搜索候选优化方案,或者类似的原因。
这可能会成为编译器的瓶颈。所以,当老板说我们要加速时,你做的第一件事可能是直接应用所有的编译器优化手册。从 O0 到 O3,你可以将速度提升大约一个数量级。假设这仍然不够快,你想要更快的速度,对吧?那么作为一名热心的 MTS 开发者,此时你会怎么做呢?
你可以想象做几件事。一是假设你是一位专家级的性能工程师,可以手动优化模型的部分内容以加快执行速度。你也可以更进一步,改变模拟模型的语义,以便以更高效的方式近似其进行的模拟。但无论哪种方式,这两种方法都要求你既是 LLVM MC 的专家,也是性能工程方面的专家。
另一种选择是应用基于代理的设计模式——即“代理编译”。通过这种方式,你为给定的 LLVM MCA 开发一个替代品(surrogate),并基本上将其部署给用户,以取代原始的 LLVM MCA。通过这样做,实际上在完全相同的硬件上,我们获得了一个执行速度比大约百分之五十更快的替代品。
相比之下,经过编译器优化的 LLVM MCA 的执行速度较慢,但代价是……这里确实存在额外的误差。因为你仍然只是在近似 LLVM MCA,但你通过这样做获得了非常显著的速度提升。让我稍微概括一下这种设计模式。“代理编译”是一种你开发程序替代品,然后将其部署给用户以取代原始程序的模式。
你想要这样做的主要原因本质上是:你的替代品执行起来比原始程序更快。这可能是因为它的软件实现经过了更优化的处理。也就是说,人们花费了大量时间试图优化 PyTorch、TensorFlow、ONNX 等工具。相比之下,人们花在优化 LLVM MCA 或你试图为其开发替代品的任何其他随机程序上的时间要少得多。
这也可能是因为你在不同的硬件上执行操作,对吧?你可以轻松地在 GPU、TPU、FPGA 等硬件上运行神经网络。要将现有程序移植到这些硬件上要困难得多。最后,你的替代品可能具有不同的算法复杂度。例如,假设你的模拟模型在输入的某个函数上是多项式或指数级的,而你的替代品可以是一个具有线性时间复杂度的神经网络。
你只需让神经网络学习最佳近似值即可,对吧?好的,所以这些都是在同一颗 CPU 上进行的。我们实际上是在 CPU 上执行神经网络。我想,这大概是某种随机的 Intel Aeon CPU,或者是 GCP(Google Cloud Platform)中的某款处理器。但总之,这个替代品是在 CPU 上执行的。如果你使用的是 GPU,你可能还能进一步提高吞吐量。
但为了比较的公平性,我们没有这样做。我要说的是,我们确实进行了大量的超参数搜索,以找到模型的规模,使我们能够在误差小于百分之十的情况下获得这种执行速度。这是此处产生的另一个额外成本。所以我猜还有一个最后的问题:你是针对模拟器的输出进行训练的,而不是使用来自 CPU 的原始数据吗?是的。
这只是为了提供你模拟器的一个更快版本,所以如果你的模拟器本身是错误的……这就是为什么这里的误差是百分之二十七。与真实基准(ground truth)相比,这是百分之二十七的误差,因为相对于模拟器而言,你还有额外的百分之十误差。是的,我稍后会谈谈仅在 CPU 上进行训练的问题。
好的,等等,有个问题。这与行为克隆(behavior cloning)有何不同?我假设是这样……嗯,我不熟悉“行为克隆”这个术语。呃,等等。哦,抱歉。我认为行为克隆是指你基本上收集证据、从最终用户那里收集演示数据,然后使用这些数据训练模型并完美拟合这些演示。
是的,那么我要说的是,在这次演讲的前半部分中,至少我所谈论的内容类型并非如此。也就是说,这不像我们想出了一个如何让程序变快的绝妙主意。它更像是文献中我们看到的一些设计模式的概括化。所以对于你所描述的行为克隆之类的事情,如果你将用户视为一个程序,并且知道这可能是一种使其变快的方法。
那么我认为那将是“电路编译”(circuit compilation)的一个实例。我想说的是,正如我所说的,这不是我们想出来的东西。你们在下面看到的这些论文都是来自文献的示例。实际上,我想那篇“Smiles a Day”论文是这方面的经典之作。但总之,我们要在这里做的是提供一种通用的语言和框架,以便理解你可以用程序替代品做什么的各种不同事物。
当然,听起来不错。谢谢。所以,如果我理解正确的话,你正在使用这个模型来优化代码,与手动优化相比的好处是它可能能够推广到特定情况之外。那么神经网络和电路之间的比较方式有哪些?是的,所以……
进行电路编译的原因,通常是为了加速原始仿真模型的执行。以 LLVM MCA 为例,你可以使用此类仿真模型来优化编译器内部循环中的候选优化方案。假设有一组语义等价的优化选项,你想选择执行速度最快的那一个。如果你认为仿真模型是当前的性能瓶颈,这种方法就能让你在有限的编译时间内尝试更多的候选优化方案。
虽然 LLVM 本身速度尚可,问题不那么突出,但我们曾与 ARM 等行业的专家交流过。他们拥有周期精确的仿真器,运行完整的代码实际仿真非常缓慢。如果你只需要对模型行为进行粗略模拟,就可以训练一个原始仿真模型的替代模型(Surrogate),从而获得更快的运行速度。
这样做的核心目的是能够更快地迭代调用该仿真模型的外部循环,我稍后会详细展开。接下来我想介绍几个设计模式,并确认是否回答了你的问题。电路优化适合旨在加速原始程序的场景;但如果目标是提高准确性而非速度呢?例如,希望 LLVM MCA 能更准确地模拟实际 CPU。
遗憾的是,编译器没有直接提供“提高精度”的标志位。你只能深入修改 LLVM 代码以调整仿真模型,使其更接近真实情况(Ground Truth)。这需要你成为 LLVM MCA 和 CPU 实际行为方面的深度专家,极具挑战性。另一种极端做法是彻底抛弃 LLVM MCA,从头开始训练神经网络。
虽然使用足够好的网络架构确实能获得比 LLVM MCA 更高的精度,但神经网络极度依赖数据。为了达到或超越 LLVM MCA 的准确性,你需要海量的训练数据。因此,另一种替代方案是应用基于代理的设计模式——“代理适配”(Surrogate Adaptation)。
在代理适配中,第一步与代理编译相同:为 LLVM MCA 开发一个替代模型。第二步则是利用来自实际 CPU 的真实数据,对该代理进行微调。你可以将其理解为使用梯度下降来调整 LLVM MCA仿真模型的语义,使其更准确地模拟真实情况。
我们来看这种方法的效果:图表纵轴为误差(越低越好),横轴为训练所需的真实数据点数量。对于 LLVM MCA 本身,误差不随数据量变化,恒定为约百分之二十五。从头训练神经网络虽然能降低误差,但需要上万条数据才能勉强持平,更不用说超越了。
代理适配则不同:初始阶段通过模仿 LLVM MCA,在没有真实数据时误差与之相近。仅需约百条数据,即可将误差降至更低。在无限数据的极限情况下,从头训练与代理适配最终会收敛至相似水平,因此该方法在小数据量场景下表现优异。
概括而言,“代理适配”是一种开发程序替代模型,并继续在下游任务数据上训练的模式。其关键优势在于:相比从头训练,它能以更少的数据量改变程序语义以更好地匹配下游任务。此前我们展示了如何用代理加速或提高精度,但 Omar 提到了可解释性问题。
进行代理适配后,我们失去了对仿真模型预测结果进行推理的能力。一方面,适应步骤改变了模型的语义;另一方面,神经网络本身难以解释。因此,或许更好的策略是:在保持准确性的同时,通过优化 CPU 参数来实现,而非黑盒式的神经网络。
设置这些 CPU 参数的传统方法是由专家根据英特尔手册文档或实测芯片数据来设定。这正是 LLVM MCA 开发者一直采用的方式,但误差约为百分之二十五且实施难度极大。
另一种做法是,我们可以放弃让参数等于其真实值(ground truth)的想法。实际上有充分的理由相信,使用这两种方法时,参数的真实值并不一定能带来良好的模拟精度。相反,你可以定义一个优化问题:对于一组基本块的数据集,我希望找到那些能让这些基本块的模拟最准确的参数,无论它们是否就是真实值。这是一个相当明确的优化问题,你可以想象将其投入求解器中处理。这里的关键挑战主要有两点:首先,LLVM 是一个拥有万行代码的 C++ 程序,包含 1.1 万个参数。理想情况下我们希望优化这些参数,但由于它是 C++ 编写的,模拟模型本身不可微分。因此,你基本上必须采用某种零阶、无梯度的方法来优化其参数。
另一个问题是它有 1.1 万个参数,这对于零阶方法来说太多了,因为这类方法无法扩展到如此大的参数量。因此,我们更希望应用类似梯度下降的方法,这可以通过第三种基于代理的设计模式——即“代理优化”来实现。在代理优化中,第一步与往常一样,构建原始程序的代理模型。虽然这里有一个技术区别:我们将 CPU 参数也作为输入包含在代理中,但本质与之前的步骤相同。
第二步是优化:你使用训练好的代理模型并冻结其权重,给定 CPU 参数的初始猜测值,然后通过反向传播通过基于神经网络的代理模型来利用梯度下降优化这些 CPU 参数。关键点在于,由于你的代理是神经网络,它是可微分的,因此你可以使用梯度下降来优化这些参数,这比直接处理原始程序能更好地扩展到这种规模的参数空间。
一旦你优化了这些参数,就可以丢弃代理模型并将这些参数重新植入原始程序中。这样你就找到了一些能让 LLVM MCA 相比真实值具有更高模拟精度的参数。那么,这种设计模式效果如何呢?在几年前的一篇论文中,我们在几种不同的 CPU 架构上评估了这一点。图中纵轴表示误差,横轴是不同的 CPU 架构。使用 LLVM MCA 中默认的专家集参数,整体误差在百分之二十五以上;而通过黑盒零阶搜索技术获得的参数,甚至无法找到任何能降低误差的参数。
相比之下,使用代理优化,我们不仅能找到匹配真实值的参数,还能将误差降低到低于原始专家集参数的水平。此外需要注意的是,这里的初始化是随机的,独立于专家集参数。因此,如果你从一个好的猜测值开始初始化,甚至可能获得更低的误差。最后让我总结一下这种设计模式:代理优化是一种构建程序代理、针对该代理优化程序的输入参数,然后将这些优化后的输入参数重新植入原始程序的模式。
这样做的关键原因是:代理能让你比直接针对原始程序优化参数更快地进行优化。无论是因为代理是可微分的(允许使用梯度下降),还是仅仅因为执行代理比执行原始程序更快,你都可以更快地进行搜索。回到之前的幻灯片,在本演讲的第一部分,我介绍了三种不同的设计模式:代理编译、代理适应和代理优化。你会发现每种模式中都有一个共同的步骤:构建原始程序的代理模型。
在我们提出这种分类法后,我们团队一直在思考一个问题:对于所有这些设计模式中的每一个,你实际上该如何去构建它?换句话说,如果路人给你一个程序说“给我这个程序的代理”,你需要采取哪些步骤来训练它?这是一个很大的开放性问题。纵观所有论文和文献,大家只是各自采用对他们有效的临时方案。因此我们认为在这方面有很多潜在的工作可以做,试图系统化这一过程。
这里真正有趣的地方在于:与许多其他机器学习任务不同(别人给你数据让你训练神经网络来模仿),在这里你实际上可以访问真实函数。你可以获得试图用来训练神经网络去模仿的那个程序本身。因此,本演讲后半部分的核心论点是:我们应该能够利用源代码,通过查看程序和程序分析来推导关于该程序的某些事实,并利用这些事实来指导代理的构建。
在这个方向或目标下,我们列出了一系列潜在的设计问题或方法论问题,即开发代理程序时需要问自己的问题。在接下来的半程演讲中,我将重点关注其中两个:第一个问题是“使用什么训练数据?”当你训练一个程序的代理时,你必须决定使用什么样的数据。这是一个开放性问题:什么应该用来代表程序,同时又允许你训练一个神经网络?这是我们在这篇论文中探索的问题。我目前有一份该论文的预印本可供查阅。
实际上,如果有人感兴趣,请通过邮件联系我之类的。我很乐意分享这个内容。这是 Turco 的论文:《用于训练程序神经替代模型的数据采样》(Data Sampling for Training Neural Surrogates of Programs)。好的,所以如果你正在训练一个程序的替代模型(surrogate),并且试图确定使用什么数据,你一开始可能有一些见解。首先,对于任何给定的程序,你可以将其视为由一组不同的行为组成。
这些差异可能源于几个不同的因素。一种情况是,你可以拥有不同的命令行标志(flags)。例如,你可以通过命令行开关来开启或关闭程序中的不同行为。你也可以想象这源于边界条件:对于输入空间的某些区域,模拟中可能会出现一种类型的行为;而在边界处,则可能出现不同类型的行为。
你可以将这种情况想象为拥有一个具有多个不同阶段的程序,每个阶段都必须独立执行。如果你对这个程序的洞察是:在训练程序替代模型时,需要对每一个不同的区域有一定的覆盖率概念。那么你可以从几个不同角度或方法来尝试实现这一点。
第一种比较朴素的做法是,直接说我们要从每个不同的区域中均匀采样(uniformly sample)。也就是说,我们希望覆盖所有内容。如果你再深入思考一下,你可能会说:实际上,我们不想进行均匀采样,而是希望每个区域的覆盖比例大致与其出现的频率或我们对这些出现频率的重视程度成正比。
因此,你可以想象对程序进行插桩(instrumenting),并收集一些工作负载数据,以计算在程序的 actual usage 中,每个输入区域出现的频率。然而,你将面临的挑战是:你会发现程序中某些区域的复杂度显著高于其他区域。
这里有一个简单的例子:如果你插桩一个程序,以观察人们传递了哪些命令行标志。也许有一半的时间他们会先传递帮助(help)标志,然后才会真正执行程序。如果你只是简单地这样做,你会说:用于训练替代模型的一半数据都来自这个帮助标志,这很可能是多余的(overkill),因为它的输出相当恒定。你确实不需要从那里获取那么多样本。
因此,我们的见解是:不仅应该使用程序不同区域的频率来指导采样量,还应该能够利用不同区域的学习复杂度(sample complexity)来进行引导。这里的“学习复杂度”是指为了将给定函数的替代模型学习到某个目标误差阈值所需的样本数量。
直觉上,路径越频繁,你就应该从该区域采样越多;复杂度越高,你也应该采样越多。我将在接下来的演讲中详细介绍我们如何形式化这一概念。总之,我们在本文中提出了两项贡献:
第一点正如我所说的,这是一种基于函数输入空间各区域的频率及其相对复杂度来分配样本的方法。第二部分(我认为这是最精彩的部分)是:正如我提到的,在开发程序电路时,我们拥有程序的源代码。因此,我们已经开发了这种程序分析技术:对于某些类别的程序(至少),它实际上允许我们计算该程序输入空间每个区域的学习复杂度。
我们可以将此与第一个贡献相结合,以确定训练程序替代模型的最佳分布概念。我有一个问题:请问“程序区域的学习复杂度”是什么意思?基本上,如果你拥有某个程序区域的学习复杂度,它会告诉你需要多少个输入样本才能将该区域的替代模型学习到给定的误差阈值。
例如,假设你说:“好吧,我需要将这个学到 10% 的误差范围内。”如果你拥有学习复杂度,它会告诉你类似这样的话:那么你需要从该区域获取五千个样本才能将其学到 10% 的误差范围内。我将在演讲的这一部分稍后使其更加具体化。太棒了,好的,谢谢。
这些区域来自哪里?这些区域——你可以想象它们来源于 if 语句之类的结构,对吧?在我们的工作中,它 literally(字面上)来自 if 语句。所以程序的每条不同路径就是一个不同的区域。我认为这是本文的一个相当大的局限性,因为你可以很容易地想象到 if 语句数量的指数级爆炸。
因此,我认为在未来的工作中,最好有一种更通用的概念:例如,可以将不同的事物组合成一个单一的区域,或者在适当的时候将其拆分。是的,目前只是 if 语句将你的程序划分为不同的区域。你有没有关于为什么这样划分的正式特征描述?
我的意思是,我想说的是:程序的复杂度也需要被找到。你是什么意思?你是说这些区域的复杂度不需要被定义吗?是的,两者都有。这其实是我们思考了一段时间但最终从论文中删除的内容。你可以想象增加一个规范,比如关于最优的划分或最优的分层(stratification)。
是的。也就是说,将程序划分为不同区域的最佳方式。如果你有能力计算每个区域的复杂度,那么你可以说:实际上……因为当然存在训练成本。例如,如果你必须从两个区域分别采样数据,而不是将它们组合在一起,这样做会有额外的成本。所以你可以……
形式化方法中确实存在将程序划分为不同区域的最佳方式这一概念。此处为了展示简洁,我们并未采用这种方式,但这绝对是可以做的,而且最终可能也是应该做的。比如,如果你尝试将本文的贡献应用于真正的超大规模程序,你关注的就不会是简单的 if 语句,而是更高层级的不同区域等概念。理想情况下,你可以通过对程序的分析来驱动这种划分,以确定哪些部分最适合组合在一起。
在此之前,我们尚未讨论架构,即神经网络的结构。是的,我将介绍一些理论机器学习结果,以确立样本复杂度的基础。我们使用的这些样本复杂度界限是针对两层 ReLU 网络证明的,因此我们在评估中使用了这种结构。在实践中,你希望使用更好的模型,且初步评估显示这些界限确实可推广到其他类型的网络。但在本次评估中,我们仅使用了非常简单的两层神经网络。
之前提到的 LLVM MCA 案例使用的都是 Transformer,这部分需要澄清:我们最初研究的是 Transformer,因为这是处理序列任务的简便方法;而这一部分使用两层 ReLU 网络,是因为界限是在此基础上证明的。我可能没时间展开讲 Transformer 架构如何更好地适配给定程序,但之前的内容都是关于 Transformer 的,只有这部分涉及 ReLU 网络。
接下来通过案例研究使内容更具体,我们将使用一个三维渲染器程序作为新案例。该程序生成场景截图中的动画:有水车、烟囱冒烟,从夜晚过渡到白天等。这类图形程序是应用代理模型的常见场景,因为可以显著加速渲染、进行风格迁移等有趣操作。此外,图形程序具有不错的可微性,且百分之一的误差人类通常难以察觉。
我将展示该程序中极小的代码片段,即着色器中的几行代码。这段程序根据太阳在天空的位置计算给定材质的亮度:Y 轴为零时太阳在地平线,为一时在正午,为负时为夜晚。同时考虑材质的发射属性(反射率),如煤块不反光而镜子高反光。观察着色器可立即看到程序的不同区域:当太阳位置小于零时为夜间,存在特定的程序执行路径。
还有黄昏区和白天区。对于每个区域,可以看出夜间相对简单,仅返回发射参数乘以 0.1(无环境光);黄昏期有少量环境光,仍是各变量的线性函数;而白天区则复杂得多,涉及反射阳光等。在输出随两个输入变化的图中可见,此处是二次函数关系。这提供了不同区域复杂性的直觉:夜间最简单,白天最难。
这种复杂性差异在数据中得到了验证。我们展示了在不同路径样本数下,针对各不同路径训练的代理模型示例:X 轴为样本数,Y 轴为训练误差。对于任意给定的路径样本数,夜间路径产生的误差最低,白天路径最高。这符合直觉:白天路径看起来最复杂,其样本复杂度确实最高。
我们可以结合频率和复杂性来看:假设太阳位置均匀分布,夜间路径占约一半时间,黄昏区 10%,白天区 40%。同时考虑各路径的复杂性:达到相同目标误差阈值,黄昏和白天路径所需的样本量比夜间多约 50%,而白天路径则需五倍于夜间的样本。结合频率和复杂性,我们可以得出基于复杂性的各路径采样比例。
大家可以看到,对于夜间路径,我们希望相对于其复杂度对其进行欠采样。当然,更准确地说,是相对于其频率进行欠采样,因为它非常简单。而对于日间路径,我们希望相对于其频率进行过采样,因为它的复杂度显著更高。因此,当我们实际在这些不同的候选分布上训练该程序的代理模型时——例如仅在频率基础上进行训练,还是基于这种复杂度采样比率进行训练——我们可以看到,使用更好的采样分布实际上能够降低该程序代理模型的误差。
这是一个对数图。虽然图中的部分看起来相当接近,但在这个区域中,使用基于复杂度的采样方法带来了约 27% 的改进。随后数据趋于收敛,因为这是一个非常简单的程序。在整个区域中,与仅基于频率采样相比,使用复杂度采样带来了约 15% 的改进。好的,我时间不多,正在思考要深入讲解多少内容。我将快速过一遍这些内容,如果大家希望我稍后详细讨论,请告诉我。
现在我要谈谈如何实际计算这些基于复杂度的采样比率。其背后的形式化框架假设神经网络本质上是 PAC 学习者(可近似学习的)。这意味着对于任何给定的目标误差阈值,训练神经网络达到该阈值所需的样本数量是有限的。具体而言,所需样本的数量源自理论机器学习文献中的结果,那里已经证明了神经网络的各种样本复杂度界限。
这里的关键点是:训练到目标误差 epsilon 所需的样本数量是数据复杂度的函数,或者说是该函数 f 的复杂度。这符合直觉:事物越复杂,学习它所需的样本就越多。因此,我们的总体目标基本上是找到从路径的每个区域采样的样本数量,以最小化代理模型的整体误差期望。
我们发现,采样次数与数据复杂度成正比,而不是与该区域的频率成正比。这与我们的直觉相符:我们希望从更频繁出现的事物中采样更多样本,也从更复杂的事物中采样更多样本。好的,这就是我们所做的。是的,我已经展示了如何获取这些采样比率,假设有人直接告诉你 zeta(i) 是什么。
另一个问题是:如何实际获得这个复杂度?即如何获取 zeta(i)?这同样基于理论机器学习文献中的结果。为了实际做到这一点,我们要做出的核心假设是:每个函数 f 都是解析的(analytic),这意味着它可以表示为幂级数。为了定义复杂度,我们要定义这个工具函数 tilde(f),即取每个系数的绝对值后的幂级数表示。
然后我们定义 zeta(f) 为 tilde(f) 在一点处的导数加上 tilde(f) 在零点的值,再取平方。我确信这完全晦涩难懂。老实说,对我来说也还有点晦涩。你可以去查看证明过程,得出的结果就是这样:好吧,那这意味着什么?其直觉是:越“波动”的事物会有更高的 tilde 值。
你可以想象,比如正弦波就有相当高的 tilt(波动性)。其他事物可能并非字面意义上的“波动”,但如果需要 ReLU 网络进行大量微小调整才能准确建模,它们往往也会有较高的 tilt。这就是这里发生的直觉解释。另一个值得注意的有趣事实是:这个 tilde 函数具有组合性,或者至少其上界是具有组合性的。
如果一个函数是另外两个函数的和,那么其 tilde 值被这两个函数 tilde 值的总和所界定。乘法也是如此,函数的复合运算亦然。因此,这本质上为我们提供了一种代数结构,使我们能够建立程序分析的基础,以计算给定区域的复杂度。
这正是我们所做的。我们在 Churico 语言中实现了这一点,在该语言中,程序被这些 if 语句分割成的每个不同区域本身就是一个解析函数。因此你可以使用这里的任何运算符。对于 Turiko(注:此处字幕可能指代特定语言或工具名,依原文发音翻译),当然它有标准的类似 IMP 的语义。
但我们还有额外的分析来计算 Turiko 程序的 tilde 值。这里只展示了一小部分规则。本质上,你采用这种非标准解释来计算 tilde,然后采用相当标准的正向模式自动微分(AD)来解释计算 tilde 的导数。我们要证明的是,通过使用这种 tilde 关系,你能够获得至少该程序区域真实复杂度的上界。
好的,回到我们的渲染器示例中。我已经展示了如何计算区域的复杂度,以及如何将该结果与区域频率结合使用,以确定从每个区域采样多少样本以训练该程序的代理模型。那么这对于实际的程序效果如何呢?
以下是我们在渲染器中学习的代理模型的源程序。这是该渲染器程序中核心着色器的约 50 行代码片段,实际上是用 Turko 语言编写的。这个着色器总共有九种不同的路径。也就是说,它仍然是之前的夜间、黄昏和日间路径的乘积。此外还有……
例如,在水、烟和固体的输出渲染中分别有不同的路径。因此,这些路径的交叉组合最终在渲染中生成了九种不同的分析性路径。我们在四个不同的数据集上评估了这种方法,以便观察频率的影响程度。为此我们收集了一个包含四种场景(如白天、夜晚及俯视视角)的数据集。分析显示,每种路径的计算复杂度在5800到6800之间变化。通过结合各数据集中路径的频率与复杂度,我们得出了最优采样方案及基于复杂度的采样比例。
使用这些采样比例相比基线方法,我们显著降低了代理模型的误差。仅基于路径频率训练的基线相比,采用复杂度采样比例使误差降低了约17%;而相对于路径均匀分布的基线,误差则降低了44%。当然这些数字可能缺乏直观性,因此我们提供了渲染结果的可视化对比。左侧为基准真值渲染图,使用复杂度引导比例采样的代理模型结果与其基本一致。
按频率采样时白天效果尚可,但整体色调偏紫,与真值不符;而均匀分布的代理模型颜色偏差极大,渲染效果很差。有人问及如果训练数据中某一类样本占比大十倍会发生什么?我们实际上测试了不同的采样预算(如1000和5000个样本),结果表明无论分布如何,只要增加无限多的采样量,误差最终都会趋近于零。理论上,使用极差分布但数据无限多时的效果优于正确分布但有限的数据。
关于新方法的样本复杂度优势,即达到相同误差所需采样量的减少程度,目前尚未精确计算。因为确定具体倍数需要进行复杂的插值分析,这比较繁琐。不过我们可以离线绘制误差随样本数量变化的曲线来估算这一差异。关于代码行数作为复杂度代理的问题,虽然它可能是一个合理的启发式指标,但我们的分析仅计算复杂度的上界。
由于该上界可能不够紧密导致结果次优,实践中引入代码行数等额外启发式方法是有道理的。然而我们尚未深入研究如何将代码行数整合到复杂度启发式中。总结这部分内容,在训练程序代理模型时,我们拥有源代码和语义信息。我认为在训练这些电路或代理时应充分利用这些信息来指导方法论,从而优化采样和评估过程。
获得更好的程序电路结构。因此,我们可以将这一点与我们在理论和经验上对神经网络的了解相结合,利用这些方法为给定程序生成尽可能高质量的电路。时间所剩无几,我原本还想讲另一个话题,但我也可以选择不讲这部分而提前结束。大家可以根据意愿选择是现在提问还是稍后讨论,以便留出时间回答问题。
刚才提到的论文主要探讨如何利用程序分析以及我们已知的关于程序的客观事实,来获取更优质的训练数据以训练代理模型。此前 Omar 曾询问如何决定使用何种架构,这确实是一个关键的设计问题。若要构建良好的程序代理,必须选择最能对该程序进行建模的架构。虽然存在关于架构搜索的研究,但我们接下来的内容更为具体:我们设计了一种名为 Renamer 的 Transformer 架构。
该架构旨在对变量重命名保持不变性,这相当于在架构中内嵌了特定的归纳偏置。我认为探索如何在能够访问实际目标函数程序的情况下进行架构搜索,是一项具有潜力的有趣工作。归纳偏置本质上是将关于学习任务的假设内嵌到神经网络中,例如卷积层对输入平移的不变性。
人们青睐归纳偏置主要有两个原因:首先,它能提供行为保证;其次,通过缩小优化过程的搜索空间,往往能获得更高的准确率。本演讲的核心观点是:在开发程序代理时,应充分利用所有可用的关于学习任务的先验知识来指导模型构建。
我们在观察中发现,许多程序代理任务本质上是带有重命名不变性的序列处理任务。例如,对于不同的候选程序输入(如文本 A 或 C),原始程序的预测结果应当一致。然而,神经网络通常不具备这一特性,它们会将不同的输入嵌入为不同向量从而产生截然不同的预测。
这可能导致部署的代理模型缺乏所需的不变性认证,从而影响准确性。为此,我们做出了两项贡献:首先形式化了序列处理任务中的重命名不变性概念;其次开发了 Renamer,这是一种本身具备重命名不变性的 Transformer 架构。使用该架构有助于提升准确率并提供相应的认证保证。
接下来我将介绍 Renamer 的工作原理,首先回顾标准 Transformer 架构的两个部分:将输入标记转换为向量表示的嵌入层,以及计算节点间关系的注意力层。为了强制实现重命名不变性属性,我们在 Transformer 的第一层进行了两项修改:首先屏蔽掉具体的变量名称。
具体做法是将字符串 X、Y、Z 等统一替换为单词“variable”。这一操作已经实现了重命名不变性,因为无论将 x 重命名为 a 还是其他名称,模型都会给出相同的预测结果。当然……
这实际上并不是我们想要的,对吧?我的意思是,我们要的是重命名不变性(renaming invariance),但我们真正想要的是:在保持 Transformer 表示任何重命名不变函数能力的同时,实现这种不变性。因此,为了恢复这一能力,我们在 Reamer 中引入了第二步,称之为“重命名不变注意力”(renaming invariant attention)。本质上,你可以将其视为一个打破对称性的步骤。
我们做的是:对于输出中的某个节点,让它去关注输入中最初具有相同变量名的所有节点。你可以看到这里的 X 只关注那些原本是 X 的东西,Y 只关注原本为 Y 的东西,依此类推。在此之后,Reamer 的第一层中,网络其余部分只是标准的密集注意力机制。
本质上,这让我们能够消除歧义,打破了不同变量之间的对称性。这个节点可以知道它与那个节点相同,也能知道它与其他节点不同;但无论你将 X 重命名为 A 还是其他名称,你得到的表示都是一样的。因此,这恢复了 Transformer 原本具有的完整代表能力(至少在学习这些重命名不变函数方面)。
那么效果如何呢?我们来评估一下。是的,这里的箭头代表注意力操作中的边。通常你的注意力机制是所有的节点都互相关注,而我们基本上删除了其中的一些边。你可以简单地在注意力图中屏蔽掉某些特定的边。
你指的是新注意力层的复杂度,还是屏蔽这些边需要多长时间?是后者。这取决于具体的计算方式。对于训练阶段,我们可以预先完成所有操作;我们尚未评估其耗时。
推理成本可能并不低,如果在推理阶段执行此操作会产生显著开销,那么如果目的是加速程序,这种方法可能行不通。因为额外的成本可能导致其过慢而不实用。但在实践中耗时并不长,只需查看程序并识别哪些词相同即可快速完成。
好的,明白了。我认为对我而言最大的问题是:这是离线还是在线处理?谢谢确认。是的,Z 被嵌入吗?没有。Z 和 Y 拥有相同的嵌入向量。它们只是被嵌入,就像……这篇论文由 Zach 领导,他是一位非常优秀的欧洲研究者。
我认为其实现至少等同于将输入字符串中的每个变量重命名为“variable”这个字符串本身。因此,Z 在此处的嵌入与 Y 完全相同。你基本上是使用索引作为嵌入吗?不完全是。我们确实将其与使用索引的方法进行了对比。
如果使用索引,那就是 0、1、2……你是指用变量在序列中出现的顺序来替换每个变量吗?不是。这两个变量的内容嵌入实际上是完全相同的。没有区别……如果你只看文本,它们的内容输出或第一层中嵌入函数的向量输出是完全一样的。
因此,在第一层中,你根本无法区分 X 和 Y。不仅无法区分,对于 Transformer 来说,这些在第一层中可能全都是 X。因此你需要第二层来进行这种消歧义处理,因为最初它们看起来完全相同。
好的。我们在 LLVM MCA 上评估了这一点,这也是我们讲座前半部分研究的案例研究。我要提到之前提到的重命名变体所带来的额外复杂性。这里有一个重命名 x86 基本块的例子,你可以看到 RAX 被重命名为 RBX。
更复杂的是:如果你这样做,EAX 实际上与 RAX 引用相同的数据。因此,如果将 RAX 重命名为 RBX,你也必须同时将 EAX 重命名为 EBX,并保持一般的一致性。我们的形式化将变量扩展到了两种引用同一底层实体的不同变量类型,且必须始终一致地重命名它们。
那么效果如何呢?我们将其与几个基线进行了对比:一个是普通的 Transformer(即在此任务上应用大型 LLM 风格的 Transformer);另一个是增强型 Transformer,即在训练集中添加了大量基本块重命名的变体。
我们还将其与规范化 Transformer 进行了对比,即所有训练和测试输入都被重命名为某种规范形式(例如:看到的第一个寄存器始终是 RAX,第二个是 RBX 等)。我们发现 Reamer 架构的表现显著优于所有这些基线,在不同模型规模下提升了约 25% 到 50%。
我个人认为,它优于规范化基线这一结果非常迷人。我们有一些假设和额外数据来解释原因,但这可以留到线下讨论。
总之,通过观察程序并检查其行为,我们可以构建具有更好属性的替代模型(surrogates),这些属性可能与程序本身的归纳偏置相同。这种方法既能让我们获得具有特定属性的神经网络的认证版本……
对于程序而言,如果我们拥有其代理模型且不希望放弃原程序的某些特性,我们可以验证该代理是否具有相同的属性。此外,借助这种归纳偏置,我们还能获得更高的准确性。在本次演讲中,我介绍了使用程序神经代理进行编程这一通用方法的不同方面。我提出了替代编译、替代适配和替代优化的分类体系,并展示了通过数据采样方法以及变量重命名与变体方法来理解如何创建程序代理的两个步骤。
我认为这里有许多有趣的工作值得继续深入,潜在的应用领域也非常广泛。在科学计算中,无论是物理、化学还是其他相关领域的研究人员都在使用或开发程序代理。因此,我非常感兴趣的是看是否存在我们此前未涵盖的新案例来丰富这一分类体系,或者该框架是否能很好地描述现有情况。当然,在系统化这种编程方法论的未来方向上也有许多有趣的工作值得探索。
我认为这是一种令人兴奋的、用于编写程序和处理复杂系统的新兴潜在方式,我很期待看到它最终的发展走向。谢谢大家,很高兴回答任何问题。我可能超时了,对此表示歉意。不过我有一个问题:在第一种情况下进行替代优化时,如果你将优化后的参数直接放回原始程序中会发生什么?或者,如果保留带有这些优化参数的神经网络呢?
你可以这样做。我们曾对此进行过一些思考,虽然我不确定为什么要这么做,但这确实是可行的。我的推测是,如果这样做效果更好、更快或更准确,那么如果不进行替代优化步骤,效果可能会更好。在这个特定案例中,我试图回忆具体的数值。我认为我们发现的是,将其直接放回原始程序中的准确性更高。
我想原因可能在于这起到了一种正则化的作用。如果你观察这张图表,它是针对代理优化CPU参数以匹配预测吞吐量的过程。这在视觉上看起来非常类似于对抗训练流程,即尝试优化某些输入以使神经网络产生某种意想不到的输出。我们发现CPU参数在代理看来比实际情况好得多,例如代理认为这些参数仅导致1%左右的误差。
然而,当我们将其插回LLVM时,实际误差约为25%,或者说是23%左右。虽然确实有所降低,但我记不清确切的误差值了,大概是在30%左右。如果我们将其保留在代理中,会有一定的变化,这很有趣。我不完全理解这一现象,我可能记错了这些数字,所以请不要引用我的具体数据。网络在编程方面表现良好却又不准确,这确实很奇怪。
但我想说的是,只有当解决方案是准确的时,它才是有用的。我同意这一推理逻辑。实际上我也可能是错的,因为我尝试了上百万次实验,记不清所有具体的数值。不过我同意你的观点。我们还曾想过尝试插入原始的默认CPU参数,看看电路是否能准确预测这些情况。需要指出的是,替代优化通常很特殊,因为找到的参数与真实参数的基准值没有任何关系。
这些参数的名称类似于CPU的分发宽度或加法指令的延迟等。你可以手动尝试将它们设置为等于分发器,但LLVM MCA使用该参数的方式与CPU的分发器处理方式不同。因为LLVM MCA只是一个接收输入并执行操作的程序,它可能存在bug,比如将分发宽度解释为其倒数。那么优化后的惩罚值与真实基准相差多少呢?
它们相当不同。一年前发布了一篇关于Diff Tune方法的“拆解”论文,非常有趣且发人深省。研究发现Diff Tune学习的许多参数其实很荒谬,例如它认为大多数指令的执行延迟为零,这显然不符合事实。然而LLVM MCA会对许多基本块的实际时间进行高估。
Noam指出,通过将参数降低至零来减少这种高估是可行的。虽然这听起来毫无意义,但我们真正想要做的可能是为LLVM MCA添加一个偏置项。但由于它缺乏这种能力,只能通过改变CPU参数来发挥作用,因此它会找到一些隐蔽的、对抗性的微小参数来实现这一目标。这确实是一种奇怪的现象。
最终,那篇拆解论文发现,如果将所有参数设置为零或在此基础上进行一些搜索,你找到的参数实际上能带来比之前更高的准确性。
DiffTune 找到了结果。是的,情况确实如此。我的意思是,我们找到的参数在某种程度上并非最优,原因有几个。首先, surrogate 神经网络并不完全准确。其次,如果你还记得的话,原始的仿真模型是不可微的,因此在根本上会出现一些陡峭的变化(cliffs)以及各种非凸性。因此,当你使用梯度下降法优化这些参数时,你无法保证找到的是全局最优的参数集。
不过,我要说一个有趣的结论是:我们确实找到了一些参数,乍一看你会觉得“哇,这些参数太糟糕了”,它们与真实情况(ground truth)毫无关系。然而,实际上使用这些参数反而能得到更好的仿真效果。通过观察这些参数,你可以想出更好地设置这些参数的方法,基本上就是放弃使用真实数据的概念,转而进行这种类型的搜索。
我是该(Alex)。没有问题。我感觉可能……好吧,谢谢,感谢你,Alex。谢谢。