【AI系统】LLVM IR 基本概念

简介: 本文深入探讨了LLVM的IR(中间表示)概念,解释了其在编译器工作原理中的重要性及应用方式。LLVM IR作为一种适中抽象级别的表示形式,能有效捕捉源代码信息,支持编译器的灵活操作与优化。文章进一步分析了LLVM IR的不同表现形式,包括内存中的编译中间语言、硬盘上的二进制格式和人类可读的文本格式,以及通过具体示例展示了如何使用Clang将C语言程序编译为LLVM IR。此外,还详细解析了LLVM IR的基本语法、条件语句、循环结构和指针操作等内容。

在上一篇文章中,我们已经简要介绍了 LLVM 的基本概念和架构,我们现在将更深入地研究 LLVM 的 IR(中间表示)的概念。

了解 LLVM IR 的重要性是为了能够更好地理解编译器的运作原理,以及在编译过程中 IR 是如何被使用的。LLVM IR 提供了一种抽象程度适中的表示形式,同时能够涵盖绝大多数源代码所包含的信息,这使得编译器能够更为灵活地操作和优化代码。

本文将进一步探究 LLVM IR 的不同表示形式,将有助于我们更好地理解代码在编译器中是如何被处理和转换的。

LLVM IR 概述

编译器常见的作用是将源高级语言的代码编译到某种中间表示(Intermediate Representation,一般称为 IR),然后再将 IR 翻译为目标体系结构(具体硬件比如 MIPS 或 X86)的汇编语言或者硬件指令。

LLVM IR 提供了一种抽象层,使程序员可以更灵活地控制程序的编译和优化过程,同时保留了与硬件无关的特性。通过使用 LLVM IR,开发人员可以更好地理解程序的行为,提高代码的可移植性和性能优化的可能性。

LLVM 基本架构

目前常见的编译器都分为了三个部分,前端(Frontend),优化层(Optimizeation)以及后端(Backend),每一部分都承担了不同的功能:

  • 前端:负责将高级源语言代码转换为 LLVM 的中间表示(IR),为后续的编译阶段打下基础。

  • 优化层:对生成的中间表示 IR 进行深入分析和优化,提升代码的性能和效率。

  • 后端:将优化后的中间表示 IR 转换成目标机器的特定语言,确保代码能够在特定硬件上高效运行。

这种分层的方法不仅提高了编译过程的模块化,还使得编译器能够更灵活地适应不同的编程语言和目标平台。同理,LLVM 也是按照这一结构设计进行架构设计:

编译器

在 LLVM 中不管是前端、优化层、还是后端都有大量的 IR,使得 LLVM 的模块化程度非常高,可以大量的复用一些相同的代码,非常方便的集成到不同的 IDE 和编译器当中。

经过中间表示 IR 这种做法相对于直接将源代码翻译为目标体系结构的好处主要有两个:

  1. 有一些优化技术是目标平台无关的,我们只需要在 IR 上做这些优化,再翻译到不同的汇编,这样就能够在所有支持的体系结构上实现这种优化,这大大的减少了开发的工作量。

  2. 其次,假设我们有 m 种源语言和 n 种目标平台,如果我们直接将源代码翻译为目标平台的代码,那么我们就需要编写 m * n 个不同的编译器。然而,如果我们采用一种 IR 作为中转,先将源语言编译到这种 IR ,再将这种 IR 翻译到不同的目标平台上,那么我们就只需要实现 m + n 个编译器。

值得注意的是,LLVM 并非使用单一的 IR 进行表达,前端传给优化层时传递的是一种抽象语法树(Abstract Syntax Tree,AST)的 IR。因此 IR 是一种抽象表达,没有固定的形态。

编译器

抽象语法树的作用在于牢牢抓住程序的脉络,从而方便编译过程的后续环节(如代码生成)对程序进行解读。AST 就是开发者为语言量身定制的一套模型,基本上语言中的每种结构都与一种 AST 对象相对应。

在中端优化完成之后会传一个 DAG 图的 IR 给后端,DAG 图能够非常有效的去表示硬件的指定的顺序。

DAG(Directed Acyclic Graph,有向无环图)是图论中的一种数据结构,它是由顶点和有向边组成的图,其中顶点之间的边是有方向的,并且图中不存在任何环路(即不存在从某个顶点出发经过若干条边之后又回到该顶点的路径)。

在计算机科学中,DAG 图常常用于描述任务之间的依赖关系,例如在编译器和数据流分析中。DAG 图具有拓扑排序的特性,可以方便地对图中的节点进行排序,以确保按照依赖关系正确地执行任务。

编译的不同阶段会产生不同的数据结构和中间表达,如前端的抽象语法树(AST)、优化层的 DAG 图、后端的机器码等。后端优化时 DAG 图可能又转为普通的 IR 进行优化,最后再生产机器码。

LLVM IR 表示形式

LLVM IR 具有三种表示形式,这三种中间格式是完全等价的:

  • 在内存中的编译中间语言(无法通过文件的形式得到的指令类等)

  • 在硬盘上存储的二进制中间语言(格式为.bc)

  • 人类可读的代码语言(格式为.ll)

接下来我们就看一下具体的 .ll 文件格式。

LLVM IR 示例与语法

示例程序

我们编写一个简单的 C 语言程序,并将其编译为 LLVM IR。

test.c 文件内容如下:

#include <stdio.h>

void test(int a, int b)
{
   
    int c = a + b;
}

int main(void)
{
   
    int a = 10;
    int b = 20;
    test(a, b);
    return 0;
}

接下来我们使用 Clang 编译器将 C 语言源文件 test.c 编译成 LLVM 格式的中间代码。具体参数的含义如下:

  • clang:Clang 编译器
  • -S:生成汇编代码而非目标文件
  • -emit-llvm:生成 LLVM IR 中间代码
  • .\test.c:要编译的 C 语言源文件
clang -S -emit-llvm .\test.c

在 LLVM IR 中,所生成的 .ll 文件的基本语法为:

  1. 指令以分号 ; 开头表示注释
  2. 全局表示以 @ 开头,局部变量以 % 开头
  3. 使用 define 关键字定义函数,在本例中定义了两个函数:@test@main
  4. alloca 指令用于在堆栈上分配内存,类似于 C 语言中的变量声明
  5. store 指令用于将值存储到指定地址
  6. load 指令用于加载指定地址的值
  7. add 指令用于对两个操作数进行加法运算
  8. i32 32 位 4 个字节的意思
  9. align 字节对齐
  10. ret 指令用于从函数返回

编译完成后,生成的 test.ll 文件内容如下:

; ModuleID = '.\test.c'
source_filename = ".\\test.c"
target datalayout = "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-w64-windows-gnu"

; Function Attrs: noinline nounwind optnone uwtable
define dso_local void @test(i32 noundef %0, i32 noundef %1) #0 { ;定义全局函数@test(a,b)
  %3 = alloca i32, align 4 ; 局部变量 c
  %4 = alloca i32, align 4 ; 局部变量 d
  %5 = alloca i32, align 4 ; 局部变量 e
  store i32 %0, ptr %3, align 4 ; %0 赋值给%3 c=a
  store i32 %1, ptr %4, align 4 ; %1 赋值给%4 d=b
  %6 = load i32, ptr %3, align 4 ; 读取%3,值给%6 就是参数 a
  %7 = load i32, ptr %4, align 4 ; 读取%4,值给%7 就是参数 b
  %8 = add nsw i32 %6, %7
  store i32 %8, ptr %5, align 4 ; 参数 %9 赋值给%5 e 就是转换前函数写的 int c 变量
  ret void
}

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca i32, align 4
  %3 = alloca i32, align 4
  store i32 0, ptr %1, align 4
  store i32 10, ptr %2, align 4
  store i32 20, ptr %3, align 4
  %4 = load i32, ptr %2, align 4
  %5 = load i32, ptr %3, align 4
  call void @test(i32 noundef %4, i32 noundef %5)
  ret i32 0
}

attributes #0 = { noinline nounwind optnone uwtable "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }

!llvm.module.flags = !{!0, !1, !2, !3}
!llvm.ident = !{!4}

!0 = !{i32 1, !"wchar_size", i32 2}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"uwtable", i32 2}
!3 = !{i32 1, !"MaxTLSAlign", i32 65536}
!4 = !{!"(built by Brecht Sanders, r4) clang version 17.0.6"}

以上程序中包含了两个函数:@test@main@test 函数接受两个整型参数并计算它们的和,将结果存储在一个局部变量中。@main 函数分配三个整型变量的内存空间,然后分别赋予初始值,并调用 @test 函数进行计算。最后 @main 函数返回整数值 0。

程序的完整执行流程如下:

  1. @main 函数中,首先分配三个整型变量的内存空间 %1,%2,%3,分别存储 0,10,20
  2. 接下来加载 %2 和 %3 的值,将 10 和 20 作为参数调用 @test 函数
  3. @test 函数中,分别将传入的参数 %0 和 %1 存储至本地变量 %3 和 %4 中
  4. 然后加载 %3 和 %4 的值,进行加法操作,并将结果存储至 %5 中
  5. 最后,程序返回整数值 0

LLVM IR 的代码和 C 语言编译生成的代码在功能实现上具有完全相同的特性。.ll 文件作为 LLVM IR 的一种中间语言,可以通过 LLVM 编译器将其转换为机器码,从而实现计算机程序的执行。

基本语法

除了上述示例代码中涉及到的基本语法外,LLVM IR 作为中间语言也同样有着条件语句、循环体和对指针操作的语法规则。

  1. 条件语句

    例如以下 C 语言代码:

    #include <stdio.h>
    int main()
    {
    int a = 10;
    if(a%2 == 0)
        return 0;
    else 
        return 1;
    }
    

    在经过编译后的 .ll 文件的内容如下所示:

    define i32 @main() #0 {
    entry:
     %retval = alloca i32, align 4
     %a = alloca i32, align 4
     store i32 0, i32* %retval, align 4
     store i32 10, i32* %a, align 4
     %0 = load i32, i32* %a, align 4
     %rem = srem i32 %0, 2
     %cmp = icmp eq i32 %rem, 0
     br i1 %cmp, label %if.then, label %if.else
    
    if.then:                                          ; preds = %entry
    store i32 0, i32* %retval, align 4
    br label %return
    
    if.else:                                          ; preds = %entry
    store i32 1, i32* %retval, align 4
    br label %return
    
    return:                                           ; preds = %if.else, %if.then
    %1 = load i32, i32* %retval, align 4
    ret i32 %1
    }
    

    icmp 指令是根据比较规则,比较两个操作数,将比较的结果以布尔值或者布尔值向量返回,且对于操作数的限定是操作数为整数或整数值向量、指针或指针向量。其中,eq 是比较规则,%rem 和 0 是操作数,i32 是操作数类型,比较 %rem 与 0 的值是否相等,将比较的结果存放到 %cmp 中。

    br 指令有两种形式,分别对应于条件分支和无条件分支。该指令的条件分支在形式上接受一个“i1”值和两个“label”值,用于将控制流传输到当前函数中的不同基本块,上面这条指令是条件分支,类似于 c 中的三目条件运算符 < expression ?Statement:statement>;无条件分支的话就是不用判断,直接跳转到指定的分支,类似于 c 中 goto ,比如说这个就是无条件分支 br label %return。br i1 %cmp, label %if.then, label %if.else 指令的意思是,i1 类型的变量 %cmp 的值如果为真,执行 if.then 否则执行 if.else

  2. 循环体

    例如以下 C 程序代码:

    #include <stdio.h>
    
    int main()
    {
      int a = 0, b = 1;
      while(a < 5)
      {
          a++;
          b *= a;
      }
      return b;
    }
    

    在经过编译后的 .ll 文件的内容如下所示:

    define i32 @main() #0 {
    entry:
     %retval = alloca i32, align 4
     %a = alloca i32, align 4
     %b = alloca i32, align 4
     store i32 0, i32* %retval, align 4
     store i32 0, i32* %a, align 4
     store i32 1, i32* %b, align 4
     br label %while.cond
    
    while.cond:                                       ; preds = %while.body, %entry
     %0 = load i32, i32* %a, align 4
     %cmp = icmp slt i32 %0, 5
     br i1 %cmp, label %while.body, label %while.end
    
    while.body:                                       ; preds = %while.cond
     %1 = load i32, i32* %a, align 4
     %inc = add nsw i32 %1, 1
     store i32 %inc, i32* %a, align 4
     %2 = load i32, i32* %a, align 4
     %3 = load i32, i32* %b, align 4
     %mul = mul nsw i32 %3, %2
     store i32 %mul, i32* %b, align 4
     br label %while.cond
    
    while.end:                                        ; preds = %while.cond
     %4 = load i32, i32* %b, align 4
     ret i32 %4
    }
    

    对比 if 语句可以发现,while 中几乎没有新的指令出现,所以说所谓的 while 循环,也就是“跳转+分支”这一结构。同理,for 循环也可以由“跳转+分支”这一结构构成。

  3. 指针

    例如以下 C 程序代码:

    int main(){
     int i = 10;
     int* pi = &i;
     printf("i 的值为:%d",i);
     printf("*pi 的值为:%d",*pi);
     printf("&i 的地址值为:",%d);
     printf("pi 的地址值为:",%d);
    }
    

    在经过编译后的 .ll 文件的内容如下所示:

@.str = private unnamed_addr constant [16 x i8] c"i\E7\9A\84\E5\80\BC\E4\B8\BA\EF\BC\9A%d\00", align 1
@.str.1 = private unnamed_addr constant [18 x i8] c"*pi\E7\9A\84\E5\80\BC\E4\B8\BA\EF\BC\9A%d\00", align 1
@.str.2 = private unnamed_addr constant [23 x i8] c"&i\E7\9A\84\E5\9C\B0\E5\9D\80\E5\80\BC\E4\B8\BA\EF\BC\9A%p\00", align 1
@.str.3 = private unnamed_addr constant [23 x i8] c"pi\E7\9A\84\E5\9C\B0\E5\9D\80\E5\80\BC\E4\B8\BA\EF\BC\9A%p\00", align 1

define i32 @main(){
entry:
  %i = alloca i32, align 4
  %pi = alloca i32*, align 8
  store i32 10, i32* %i, align 4
  store i32* %i, i32** %pi, align 8

  %0 = load i32, i32* %i, align 4
  %call = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([16 x i8], [16 x i8]* @.str, i32 0, i32 0), i32 %0)
  %1 = load i32, i32* %i, align 4
  %call1 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([18 x i8], [18 x i8]* @.str.1, i32 0, i32 0), i32 %1)

  %call2 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([23 x i8], [23 x i8]* @.str.2, i32 0, i32 0), i32* %i)
  %2 = load i32*, i32** %pi, align 8
  %call3 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([23 x i8], [23 x i8]* @.str.3, i32 0, i32 0), i32* %2)
  ret i32 0
}

declare i32 @printf(i8*, ...)

对指针的操作就是指针的指针,开辟一块指针类型的内存,里面放个指针`%pi = alloca i32*, align 8

如果您想了解更多AI知识,与AI专业人士交流,请立即访问昇腾社区官方网站https://www.hiascend.com/ 或者深入研读《AI系统:原理与架构》一书,这里汇聚了海量的AI学习资源和实践课程,为您的AI技术成长提供强劲动力。不仅如此,您还有机会投身于全国昇腾AI创新大赛和昇腾AI开发者创享日等盛事,发现AI世界的无限奥秘~

目录
相关文章
|
5天前
|
人工智能 JavaScript 前端开发
多角色AI代理的一次尝试- AI代码助手
本文介绍了一个多角色AI代理系统,用于自动化代码开发过程。系统包括用户接口、需求分析、代码结构设计、代码生成、代码审查和代码执行等角色,通过协调工作实现从需求到代码生成与测试的全流程自动化。使用了qwen2.5 7b模型,展示了AI在软件开发中的潜力。
|
2天前
|
设计模式 IDE API
C# 一分钟浅谈:GraphQL 客户端调用
本文介绍了如何在C#中调用GraphQL API,涵盖基本步骤、常见问题及解决方案。首先,通过安装`GraphQL.Client`库并创建客户端实例,连接到GraphQL服务器。接着,展示了如何编写查询和突变,以及处理查询语法错误、变量类型不匹配等常见问题。最后,通过具体案例(如管理用户和订单)演示了如何在实际项目中应用这些技术,帮助开发者更高效地利用GraphQL。
53 38
C# 一分钟浅谈:GraphQL 客户端调用
|
14小时前
|
人工智能 自然语言处理 前端开发
【AI系统】LLVM 前端和优化层
本文介绍了 LLVM 编译器的核心概念——LLVM IR,并详细讲解了 LLVM 的前端 Clang 如何将 C、C++ 等高级语言代码转换为 LLVM IR。文章还探讨了编译过程中的词法分析、语法分析和语义分析三个关键步骤,以及 LLVM 优化层的 Pass 机制,包括分析 Pass 和转换 Pass 的作用及依赖关系。
11 3
|
3天前
|
机器学习/深度学习 人工智能 芯片
【AI系统】谷歌 TPU v3 POD 形态
TPU v3 是 TPU v2 的增强版,主要改进包括:MXU 数量翻倍至 4 个,时钟频率提升 30%,内存带宽扩大 30%,容量翻倍,芯片间带宽增加 30%,可连接节点数增至 4 倍。TPU v3 通过采用水冷系统,不仅提高了功率,还优化了温度管理,显著提升了计算能力和能效。TPU v3 Pod 由 1024 个 TPU v3 组成,算力达 100 PFLOPS,适用于大规模神经网络训练。
12 2
|
15小时前
|
人工智能 前端开发 编译器
【AI系统】LLVM 架构设计和原理
本文介绍了LLVM的诞生背景及其与GCC的区别,重点阐述了LLVM的架构特点,包括其组件独立性、中间表示(IR)的优势及整体架构。通过Clang+LLVM的实际编译案例,展示了从C代码到可执行文件的全过程,突显了LLVM在编译器领域的创新与优势。
13 3
|
14小时前
|
存储 监控 算法
|
14小时前
|
存储 C语言 索引
R 语言教程 之 R 数据类型 5
R语言中的数据类型包括逻辑型等,逻辑型主要用于向量的逻辑运算。通过`c()`创建向量,使用`&gt;`、`&`等运算符进行条件判断,`which()`函数可筛选符合条件的元素索引。`all()`和`any()`分别用于检测向量是否全为真或含真值。
9 4
|
3天前
|
SQL 监控 安全
Flask 框架防止 SQL 注入攻击的方法
通过综合运用以上多种措施,Flask 框架可以有效地降低 SQL 注入攻击的风险,保障应用的安全稳定运行。同时,持续的安全评估和改进也是确保应用长期安全的重要环节。
33 14
|
13小时前
|
机器学习/深度学习 人工智能 前端开发
【AI系统】AI 编译器基本架构
本文承接前文关于AI编译器发展的三个阶段,深入探讨通用AI编译器架构。文章首先回顾现有AI编译器架构,如PyTorch的转换流程及优化策略,然后介绍理想化的通用AI编译器架构,涵盖从前端接收多框架模型输入到后端生成特定硬件代码的全过程。重点解析了编译器的中间表达IR、前端与后端优化技术,以及现有AI编译器全栈产品的层次结构,为读者提供了全面的技术概览。
7 2
|
13小时前
|
机器学习/深度学习 人工智能 编译器
【AI系统】AI 编译器历史阶段
本文概述了AI编译器的发展历程,从朴素AI编译器、专用AI编译器到未来的通用AI编译器,详细介绍了各阶段的技术特点与优化目标。AI编译器旨在优化AI和机器学习应用,通过多层IR设计、面向神经网络的深度优化及对DSA芯片的支持,实现高性能计算。随着技术的进步,通用AI编译器将实现计算图与算子的统一表达、自动化优化及模块化设计,推动AI技术的广泛应用和发展。
7 2