AI编译器技术剖析(二)-传统编译器

近年来,AI应用程序已经无处不在。比如:智能家居设备由自然语言处理(NLP)和语音识别模型驱动,自动驾驶技术以计算机视觉模型为支柱。通常这些AI模型会部署在云平台、专用计算设备以及物联网传感器的内置微型芯片。

因此,我们在进行AI应用落地时,需要将AI模型从研发阶段转而部署到多种多样的生产环境,同时,也需要相当多的繁重工作。即使对于我们最熟悉的环境(例如:在 GPU 上),部署包含非标准算子的深度学习模型仍然需要大量的工程。为了解决这些繁琐的问题,AI 编译器应运而生,可以说未来十年AI编译器将迎来快速发展的黄金十年。

本系列将分享 AI 编译器的技术原理,本文为该系列第二篇。AI编译器通常会依赖于传统编译器。AI编译器在IR上面对模型进行优化之后,通常会有 lowering 的过程,将优化后的 high-level IR 转换成传统编译器的 low-level IR;然后,依赖传统编译器做最终的机器码生成。因此,了解AI编译器之前,我们先来看下传统编译器架构以及传统编译器的优化方法等。

编译器简介

编译器是指把用高级程序设计语言书写的源程序翻译成等价的机器语言格式目标程序的翻译程序。

从源代码到可执行程序一般经过预处理、编译、链接过程,而编译是编译器的工作,编译分为三个阶段,分别为前端、优化器、后端。

编译器可以将整个程序转换为目标代码(object code),这些目标代码通常存储在文件中。目标代码也被称为二进制代码,在进行链接后可以被机器直接执行。典型的编译型程序语言有C和C++。主流的编译器有 LLVM 和 GCC 等。

编译器与解释器的区别

编译器(Compiler)和解释器(Interpreter)是两种不同的工具都可以将编程语言和脚本语言转换为机器语言。虽然两者都是将高级语言转换成机器码,但是其最大的区别在于:解释器在程序运行时将代码转换成机器码,编译器在程序运行之前将代码转换成机器码。下面从以下5个方面进行对比:

  1. 执行方式:
    • 编译器: 编译器在代码执行前将整个源代码翻译成机器代码或中间代码。这个过程称为编译。编译生成的目标代码可以独立执行,而不需要源代码存在。
    • 解释器: 解释器逐行地解释和执行源代码,无需生成独立的目标代码。解释器一边读取源代码一边执行,不会生成额外的中间文件。
  2. 执行速度:
    • 编译器: 由于在执行前已经生成了目标代码,编译后的程序通常执行速度较快,因为它不需要在运行时进行翻译。
    • 解释器: 解释器每次运行都需要逐行解释源代码,因此执行速度可能较慢。
  3. 跨平台性:
    • 编译器: 生成的目标代码通常与特定硬件架构相关,因此可能需要为不同的平台编译不同的版本。
    • 解释器: 解释器通常能够直接在不同平台上运行源代码,而不需要生成特定平台的目标代码。
  4. 调试和错误处理:
    • 编译器: 在编译阶段发现错误,有时可能难以追踪源代码中的错误。
    • 解释器: 在运行时逐行执行,有助于更容易地发现和调试错误。
  5. 内存占用:
    • 编译器: 生成的目标代码通常独立运行,可能占用较少的内存。
    • 解释器: 由于需要在运行时保持源代码和解释器本身,可能需要更多的内存。

但在实际应用中,有一些语言使用了混合的方式,例如:Java。Java源代码首先被编译成中间代码(字节码),然后由Java虚拟机(JVM)解释执行字节码或者通过即时编译(Just-In-Time Compilation,JIT)转换成本地机器码执行。这种方式综合了编译和解释的优势。

AOT 编译与 JIT 编译

而在编译器中,通过引入解释器的思想,逐渐演化出 JIT 编译(Just-in-Time Complier)和 AOT 编译(Ahead-of-Time Complier)。二者的区别如下:

  • AOT编译在程序运行之前生成目标代码,而JIT编译在程序运行时生成目标代码。
  • AOT编译产生的是完全编译好的可执行文件,而JIT编译通常在运行时进行,动态地将部分代码编译成机器代码。
  • AOT编译适合静态语言,而JIT编译通常与解释器一起用于动态语言或中间代码的执行。

总之,JIT编译适用于需要在运行时动态优化的情况,而AOT编译适用于对程序启动性能有较高要求,且不需要根据运行时信息进行优化的情况。在某些情况下,也可以采用两者结合的方式,例如:Java中的HotSpot虚拟机使用了混合模式。

编译器架构

传统的编译器通常分为三个部分:前端(Front End),优化器(Optimizer)和后端(Back End)。

  • Front End:主要负责词法、语法和语义分析,将源代码转化为抽象语法树,即将程序划分为基本的组成部分,检查代码的语法、语义,然后生成中间代码。
  • Optimizer:优化器则是在前端的基础上,对得到的中间代码进行优化(如:常量折叠、去掉冗余代码、子表达式消除等工作),使代码更加高效。
  • Back End:后端则是将已经优化的中间代码,针对具体的硬件生成目标机器的机器代码,转换成为包括代码优化器和代码生成器。

主流编译框架

编译器套件 GCC

GCC是一个完整的编译器套件,支持多种语言和硬件架构,具有悠久的历史。GCC 原名为 GNU C 语言编译器,因为它原本只能处理 C语言。后来 GCC 快速演进,变得可处理 C++、Fortran、Pascal、Objective-C、Java, 以及 Ada 等其他语言。GCC支持的主要处理器架构:ARM、x86、x86-64、MIPS、PowerPC等。

GCC的源代码文件数量庞大,目录结构复杂,总体结构理解有一定的难度,但从代码功能和逻辑结构上来讲,这些代码大致可以分为下图所示的几个部分。

图分为上下两个部分,上半部分表示GCC的源代码内容,下半部分表示将GCC源代码编译生成编译器程序。

图上半部分根据源代码的功能将GCC源代码分为4大部分:

  1. 高级语言相关代码(High-Level-Language Specific Code)

在GCC的源代码中,对于GCC能够编译的每一种编程语言都有其相应的处理代码,这些代码主要集中在{GCC_SOURCE}/ {Language}目录下。其中,{Language}代表了编程语言的名称,这部分代码主要完成高级编程语言的词法、语法分析等功能,从而生成该语言对应的抽象语法树(AST,Abstract Syntax TREE),并完成其规范化(Genericize)操作。

  1. 与编程语言和目标机器无关的通用代码(Language & Machine Independent Generic Code)

这部分代码主要包括{GCC_SOURCE}/目录下的代码,用于完成 GIMPLE 和 RTL 的生成,以及数量庞大的基于GIMPLE和RTL的处理及编译优化工作。

  1. 机器描述代码(Machine Descriptions Code)

一般来说,对于GCC支持的每一种名称为target的目标机器,在GCC的代码中均有一个名称为 {GCC_SOURCE}/config/{Target}的子目录,用来存放与该目标机器相关的机器描述代码及其相应的头文件和c文件等。

(4)与目标机器相关的生成器代码(Machine Dependent Generator Code)

这部分代码比较难以理解。为了生成目标机器上编译器程序cc1,GCC提供的源代码在设计阶段是不完整的,其中:缺少的部分主要包括目标机器相关的RTL构造及目标代码生成等部分的源代码。由于这一部分源代码是与目标机器相关的,在GCC设计源代码时是难以确定的,因此,GCC采用了这样一种解决的思路,就是通过一些生成器(Generator)代码,这些代码能够根据目标机器的机器描述文件,提取目标机器的信息,从而自动地生成关于目标机器上RTL构造及目标代码生成的源代码,并将这些源代码与GCC原有的其他代码结合在一起编译,从而生成与目标机器相关的编译器程序。与目标机器相关的生成器代码的文件名称一般为{GCC_SOURCE}/gen*.[ch],其主要的功能就是根据机器描述文件生成与目标机器相关的部分源代码。

因此,最终参与编译,生成目标机器编译器的源代码主要包括了语言相关的代码语言及机器无关的通用代码 以及根据机器描述文件由机器相关代码生成器所生成的代码这三部分。

图下半部分给出了根据上述GCC的源代码所生成的目标机器上编译器程序cc1(gcc程序所调用的编译器)的主要工作流程。

从整体上看,目标机器上编译器cc1的功能就是将用户输入的高级程序代码最终编译成目标机器上的汇编代码,其中,经历了前端的词法分析、语法分析、语义分析中间的GIMPLE生成、GIMPLE优化 ,以及后端的RTL生成、RTL优化、代码生成等几个步骤。

在这些处理过程中,GCC也分别使用几种不同的中间表示(Intermediate Representation,IR)形式,包括:AST、GIMPLE、RTL等。这些处理步骤与上半部分的代码具有一定的对应关系,例如:词法、语法分析以及AST的规范化过程对应上半部分的"高级语言相关代码";GIMPLE生成、GIMPLE优化及RTL优化部分则对应上半部分的"与编程语言和目标机器无关的代码";RTL生成以及最终的汇编代码生成部分则由上半部分的"与目标机器相关的生成器代码"根据上半部分的"机器描述"生成。

编译器基础设施 LLVM

LLVM 是一种编译器基础设施,提供灵活的框架和强大的代码优化;同时,也支持多种语言和硬件。以 C++ 写成,包含一系列模块化的编译器组件和工具链,用来开发编译器前端和后端。它与其他编译器的主要不同在于内部采用的架构。

由于 GCC 编译器在设计的时候没有做好层次划分,导致很多数据在前端和后端耦合在了一起,所以GCC支持一种新的编程语言或新的目标架构特别困难。有了GCC的前车之鉴,LLVM进行了如下图所示的三阶段设计:前端,优化组件和后端。

  • 前端组件解析程序源代码,检查语法错误,生成一个基于语言特性的AST来表示输入代码,并将其转换为LLVM IR;
  • 优化器作用是中间代码(IR)优化,比如:去除无用的变量或者无用的计算,来提高代码运行效率;
  • 后端则生成目标机器码。

它统一了 LLVM IR(中端),如果需要开发新的处理器架构后端,那么只需要开发后端即可。除了后端模块化,连前端也实现了模块化,开发者可以自定义前端规则,开发一门自己的编程语言。该编译器框架被Apple、FaceBook、Google广泛应用,甚至Apple为该编译器框架开发了Clang。

编译器前端工具 Clang

Clang 本质上是 LLVM 衍生出来的前端项目,由Apple开发。作为 LLVM 项目的一部分,直接支持了 C、C++和Objective-C和 Objective-C++ 编程语言,作为LLV 前端将其转化为LLVM IR,之后再从IR转换为后端机器码。

与 GCC 相比,Clang 特性如下:

  • 速度快:通过编译 OS X 上几乎包含了所有 C 头文件的 carbon.h 的测试,包括:预处理 (Preprocess),语法 (lex),解析 (parse),语义分析 (Semantic Analysis),抽象语法树生成 (Abstract Syntax Tree) 的时间,Clang 比 GCC 快2倍多。
  • 内存占用小:Clang 内存占用是源码的 130%,Apple GCC 则超过 10 倍。
  • 诊断信息可读性强:其中错误的语法不但有源码提示,还会在错误的调用和相关上下文的下方有~~~~~^的提示,相比之下 GCC 的提示很天书。
  • 兼容性好:Clang 从一开始就被设计为一个 API,允许它被源代码分析工具和 IDE 集成。GCC 被构建成一个单一的静态编译器,这使得它非常难以被作为 API 并集成到其他工具中。
  • Clang 有静态分析,GCC 没有。
  • Clang 使用 BSD 许可证,GCC 使用 GPL 许可证。

GCC 与 Clang/LLVM 差异

GCC 与 Clang/LLVM 具体差异如下表所示:

类别 GCC Clang/LLVM
许可证 GNU GPL Apache 2.0
代码模块化 一体化架构 模块化
支持平台 *inx, Windows (MinGW) *inx, Natively in Windows
符合的语言标准 C++20 已通过验证, 符合 C++17 符合 C++17,正在申请 C++20 标准
代码生成 高效,有很多编译器选项可以使用 高效,LLVM 后端使用了 SSA 表单
语言独立类型系统 没有 有(LLVM的设计初衷)
构建工具 Make CMake
解析器 最早采用 Bison LR,现在改为递归下解析器 手写的递归下降解析器
链接器 LD lld
调试器 GDB LLDB

总之,GCC 是一个功能强大的编译器集合,支持多种编程语言,广泛应用于各种开源项目和商业软件。LLVM 是一个灵活的编译器基础设施,提供了通用的编译器工具和库,被用于构建自定义编译器。Clang 是基于 LLVM 开发的主要支持 C、C++、Objective-C 和 Objective-C++ 编程语言的编译器前端,具有快速的编译速度和低内存占用,Clang 的底层框架 LLVM 具有足够的可扩展性,可以支持 Julia 和 Swift 等较新的语言。

编译器常用的优化方法

编译器在优化代码时采用多种技术,以提高程序的性能和效率。以下是一些常见的编译器优化技术:

常量传播:在编译期时,能够直接计算出结果(这个结果往往是常量)的变量,将被编译器由直接计算出的结果常量来替换这个变量。

常量折叠:在编译期间,如果有可能,多个变量的计算可以最终替换为一个变量的计算,通常是多个变量的多级冗余计算被替换为一个变量的一级计算。

复写传播: 编译器用一个变量替换两个或多个相同的变量。其目标是将变量的值复制到其使用的地方,以减少不必要的变量赋值。

针对以下伪代码:

ini 复制代码
x = 5
y = x + 3
z = y - 2
w = x * 2
print(w)

我们可以看到变量 x 的值在第一行被赋值为 5,然后在第二行和第四行分别被使用。在复写传播中,我们可以将变量 x 的值直接复制到使用它的地方。

优化后的代码可能是这样的:

ini 复制代码
x = 5
y = 5 + 3
z = y - 2
w = 5 * 2
print(w)

在这个优化后的代码中,我们用 5 替代了 x 的使用处,因为我们知道在这个上下文中,x 的值就是 5。这样做的目的是减少对变量的不必要引用,提高代码的执行效率。

需要注意的是,复写传播并非总是安全的。在某些情况下,变量可能在使用之前被修改,因此进行复写传播可能导致错误的结果 。编译器需要进行数据流分析以确定何时可以安全地进行复写传播。在上面的示例中,因为 x 的值在使用之前没有被修改,所以复写传播是安全的。

公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在的E中的变量都没有发生变化,那么E的此次出现就成为了公共子表达式,因此,编译器可判断其不需要再次进行计算浪费性能。

死代码消除:消除不会影响程序执行结果的无用代码,例如:变量自己给自己赋值、未使用的变量或不可达代码块(return之后的语句)。

数组范围检查消除 :如果开发语言是Java这种动态类型安全型的,那在访问数组时,比如:array[]时,Java不会像C/C++那样只是纯粹的裸指针访问,而是会在运行时访问数组元素前进行一次是否越界检查,这将会带来许多开销,如果即时编译器能根据数据流分析出变量的取值范围在[0,array.length]之间,那么在循环期间就可以把数组的上下边界检查消除,以减少不必要的性能损耗。

函数调用优化:包括尾递归优化、函数内联、内联缓存等,以减少函数调用开销。

  • 尾递归优化:这种优化是对递归函数的一种特殊情况的处理,称为尾递归。尾递归函数是在函数的最后一步直接调用自身,并且没有其他操作。旨在将尾递归函数的调用转换为迭代形式,以避免递归调用栈的堆栈溢出。
  1. 尾递归函数的一般形式

一个典型的尾递归函数的一般形式如下:

arduino 复制代码
int tail_recursive_factorial(int n, int accumulator) {
    if (n == 0) {
        return accumulator;
    } else {
        return tail_recursive_factorial(n - 1, n * accumulator);
    }
}

在这个示例中,tail_recursive_factorial 是一个尾递归函数,因为递归调用是函数的最后一步操作。

  1. 编译器尾递归优化的步骤

尾递归优化通常包括以下步骤:

  • 尾调用优化: 将尾递归调用转换为迭代形式。编译器会检测尾递归调用,并将其优化为迭代,以避免递归调用栈的增长。这可能包括对栈帧的复用,而不是创建新的栈帧。
  • 变量复用: 将尾递归调用中使用的变量与当前栈帧中的变量复用,而不是创建新的变量。这有助于减小栈帧的大小。
  1. 示例

对上述阶乘的尾递归函数进行尾递归优化后的形式可能如下:

sql 复制代码
int iterative_factorial(int n) {
    int result = 1;
    while (n > 0) {
        result *= n;
        n--;
    }
    return result;
}

在这个示例中,尾递归函数被转换为一个迭代形式,避免了递归调用栈的增长。

  1. 适用条件

尾递归优化适用于满足以下条件的尾递归函数:

  • 函数的最后一步操作是一个递归调用。
  • 递归调用是函数的最后一步操作,没有其他操作。
  1. 编程语言和编译器支持

不是所有编程语言和编译器都对尾递归进行自动优化。一些函数式编程语言(如:Scheme、Clojure)和支持函数式编程的编程语言(如:Erlang、Haskell)通常对尾递归提供了更好的支持。在使用其他语言时,可以查看编译器文档以了解是否支持尾递归优化,或者手动使用迭代形式来实现避免递归调用栈溢出。

  • 内联缓存:用于缓存先前的方法查找结果,从而避免重复的查找过程。

针对一个简单的JavaScript代码片段:

sql 复制代码
function add(a, b) {
    return a + b;
}

let result = add(3, 4);

add 函数被调用,接受两个参数并返回它们的和。在动态语言中,方法调用通常涉及动态的方法查找,因为类型信息可能在运行时才能确定。

内联缓存可以用于优化这样的方法调用。以下是一个示例,使用内联缓存来缓存先前的方法查找结果:

csharp 复制代码
// 内联缓存
function add(a, b) {
    if (!add.cache) {
        add.cache = new Map();
    }

    const cacheKey = `${typeof a}-${typeof b}`;

    if (add.cache.has(cacheKey)) {
        console.log('Cache hit!');
        return add.cache.get(cacheKey);
    }

    console.log('Cache miss!');
    const result = a + b;
    add.cache.set(cacheKey, result);
    return result;
}

let result1 = add(3, 4); // 第一次调用,触发缓存 miss
let result2 = add(3, 4); // 第二次调用,触发缓存 hit

console.log(result1); // 输出结果
console.log(result2); // 输出相同的结果,因为使用了缓存

在这个示例中,add 函数通过一个缓存来存储先前的方法查找结果。在每次方法调用时,先检查缓存中是否已经有了相同参数的计算结果。如果是,直接返回缓存的结果,否则进行实际的计算并将结果存入缓存。

  • 方法内联:将比较简短的函数或者方法代码直接粘贴到其调用者中,以减少函数调用时的开销,比较重要且常用,很容易理解,就比如C++的inline关键字一样,只不过inline是开发者的手动方法内联,而编译器在分析代码和数据流之后,也有可能做出自动inline的优化。

循环优化(Loop Optimization):包括循环展开、循环定界、循环变量分析等,以提高循环的执行效率。

  • 循环定界:通过将循环的迭代次数在编译时确定为一个常量,以减小循环的开销和提高性能。
  • 循环变量分析:关注循环变量的属性,如循环变量是否是递增的、是否在循环内被修改等,旨在更好地理解和优化循环结构。
  • 循环展开(Loop Unrolling): 将循环体中的代码复制多次,减少循环控制开销,提高指令级并行性,有助于寄存器分配和其他优化。

针对以下伪代码中的简单循环:

ini 复制代码
for (int i = 0; i < 4; i++) {
    array[i] = array[i] * 2;
}

我们可以看到循环迭代了四次,每次将数组中的元素乘以2。通过循环展开,我们可以将循环体的代码复制多次,减小循环控制开销。

优化后的代码可能是这样的:

ini 复制代码
for (int i = 0; i < 4; i += 2) {
    array[i] = array[i] * 2;
    array[i + 1] = array[i + 1] * 2;
}

// 处理循环剩余的最后一个元素
array[3] = array[3] * 2;

在这个优化后的代码中,循环展开为两次,每次处理两个元素。这样可以减小循环控制的开销,提高执行效率。需要注意的是,循环展开可能会增加代码的大小,因此在实际应用中需要权衡代码大小和性能提升。

循环展开通常与其他循环优化技术一起使用,例如:循环定界、循环变量分析等,以获得更好的性能。

总结

本文简要介绍了传统编译器架构、主流编译器框架以及传统编译器的常见优化方法等。

码字不易,如果觉得有帮助,欢迎点赞收藏加关注。

参考文档

相关推荐
Mintopia17 小时前
🤖 2025 年的人类还需要 “Prompt 工程师” 吗?
人工智能·llm·aigc
Mintopia17 小时前
意图驱动编程(Intent-Driven Programming)
人工智能·llm·aigc
想用offer打牌18 小时前
逃出结构化思维:从向量,向量数据库到RAG
后端·架构·llm
想用offer打牌18 小时前
Reasoning + Acting: ReAct范式与ReAct Agent
人工智能·后端·llm
爱可生开源社区19 小时前
在数据库迁移中,如何让 AI 真正“可用、可信、可落地”?
数据库·sql·llm
Elwin Wong1 天前
关于熵的一些概念及其计算
人工智能·大模型·llm
hzp6661 天前
新兴存储全景与未来架构走向
大数据·大模型·llm·aigc·数据存储
EdisonZhou1 天前
MAF快速入门(8)条件路由工作流
llm·aigc·agent·.net core
暴风鱼划水1 天前
大型语言模型(入门篇)B
人工智能·语言模型·大模型·llm
xhxxx1 天前
别再让 AI 自由发挥了!用 LangChain + Zod 强制它输出合法 JSON
前端·langchain·llm