编译 Tensorflow 踩坑

前段时间发现了 Tensorflow 里的一处小 Bug,现在有空正好提一个 PR。Bug 很快就修好了,不过之后进行本地编译时我却踩了不少坑。现在记录一下。 一、各种版本傻傻分不清 在开始编译之前,需要介绍一下相关的 Nvidia GPU 依赖项。 Nvidia 有不同架构的各型显卡。为了区分硬件上的区别,Nvidia 使用计算能力(Compute Capability)加以区分。计算能力版本分为两部分 x.y。大版本号表示计算架构(如 Pascal、Volta、Ampere 等等)上的变化,之间不可兼容;小版本号则表示同一架构内部的差别,更高版本可以兼容更低版本。 GPU 驱动(GPU Driver)为操作系统提供硬件驱动。其版本可以通过 nvidia-smi --query-gpu=driver_version --format=csv 找到。同一版本的驱动支持一系列不同计算能力、不同架构的显卡。 CUDA 驱动(CUDA Driver)在 GPU 驱动之上提供了 CUDA 接口。与 GPU 驱动属于内核态设备驱动不同,CUDA 驱动是一用户态的动态链接库(DSO)。CUDA 驱动的版本一般应当随着 GPU 驱动版本的更新而更新。 CUDA Toolkit 提供了构建 CUDA 程序所需的编译器、运行时和库。构建后的 CUDA 应用程序依赖于 CUDA 驱动所提供的接口。又由于 CUDA 是向后兼容(Backward Compatibility)的,所以旧的 CUDA 应用程序可以运行在新的 CUDA 驱动上;换句话说,要运行某一 CUDA 程序,需要高于特定版本的 CUDA 驱动。 向后兼容中的 Backward 指的是与时间上在前的进行兼容。这似乎是中英文导致的思维差异。 二、构建配置 在构建 Tensorflow 时添加 CUDA 支持后,Tensorflow 需要满足 CUDA Toolkit 和 CUDA 驱动之间的兼容性要求。如前所述,为了更强的兼容性,我们希望 CUDA Toolkit 的版本较低。但更高的 Tensorflow 版本又会需要更高版本的 CUDA 特性。因此在编译时需要在这两方面进行权衡。...

一月 20, 2025 · 3 分钟 · 602 字 · Wokron

讨论一下环境变量

这里所说的环境变量并不仅仅指 Shell 中的变量,而是每一个进程各自拥有的,能够通过系统 API 获取的变量。当然,Shell 通常会提供环境变量的操作方法,我们也经常通过在 Shell 中管理环境变量。但是 Shell 中的变量和环境变量实际上并不完全相同,在使用 Shell 时可能会混淆这两种概念,这里便稍微分辨一下。 一、环境变量 从程序的角度来看,环境变量很简单。环境变量是每个进程各自拥有的键值对集合。进程可以从其环境变量中读取已有变量,修改已有变量或创建新的变量。当进程创建其子进程时,子进程会继承父进程的环境变量,但子进程的环境变量的修改并不会影响父进程的环境变量。 为了方便,这里以 python 为例。 进程可以读取已有环境变量: # inherit.py import os print(os.environ["HOME"]) 也可以写入或创建环境变量: os.environ["MY_VAR"] = "my_value" 子进程会继承父进程的环境变量: # env.py import os import sys assert len(sys.argv) == 2 father_proc = bool(int(sys.argv[1])) print_my_var = lambda: print( f"process={'father' if father_proc else 'child'}, " f"MY_VAR={os.environ.get('MY_VAR', None)}" ) print_my_var() if father_proc: os.environ["MY_VAR"] = "1" print_my_var() os.system(f"{sys.executable} {sys.argv[0]} 0") print_my_var() else: os.environ["MY_VAR"] = "2" print_my_var() $ python inherit....

十一月 2, 2024 · 2 分钟 · 300 字 · Wokron

在 Conda 环境中安装 CUDA

最近想学一下 CUDA。这里记录一下配置环境的过程。 创建环境。这里只是用 Conda 实现环境隔离,不需要 python。 $ conda create -n cuda-dev $ conda activate cuda-dev 使用 nvidia-smi 查看 CUDA 版本。注意这里的版本为驱动所支持的最高 CUDA 版本。而非后面安装的 CUDA 运行时版本。在安装时应当保证 CUDA 运行时版本小于等于驱动版本。 $ nvidia-smi +---------------------------------------------------------------------------------------+ | NVIDIA-SMI 535.183.01 Driver Version: 535.183.01 CUDA Version: 12.2 | |-----------------------------------------+----------------------+----------------------+ 安装特定版本 CUDA。 $ conda install cuda -c nvidia/label/cuda-11.8.0 查看 nvcc 版本。保证驱动支持当前 CUDA 版本。 $ nvcc -V nvcc: NVIDIA (R) Cuda compiler driver Copyright (c) 2005-2022 NVIDIA Corporation Built on Wed_Sep_21_10:33:58_PDT_2022 Cuda compilation tools, release 11....

十月 14, 2024 · 1 分钟 · 88 字 · Wokron

编译技术教程:附录 - 项目测试

系列导航: 《编译技术教程:前言》 《编译技术教程:词法分析》 《编译技术教程:抽象语法树》 《编译技术教程:语法分析》 《编译技术教程:符号表与类型系统》 《编译技术教程:语义分析》 《编译技术教程:附录 - 项目测试》 项目的规模越大,越难以通过直接观察发现隐藏的代码缺陷。在实验中,各位同学需要实现的是一个万行级别的编译器项目。在这一情景下若不对自己的开发过程进行规范,及时进行项目测试,必然会严重影响代码质量,进而影响各位同学的实验进展。 因此在本附录中,我们将结合 tolangc 项目,提供一个较为简单,但具体可行的项目测试示例,以供同学们参考。 一、单元测试 (1)单元测试的结构 单元测试样例用于对程序中的最小功能模块进行测试,能够验证功能模块内部的正确性。一般来说,一个单元测试对应一个类或一个函数。对于编译器这一架构得到了充分研究的程序类型,一般来说我们可以选择为各个编译阶段设计对应的单元测试。例如在 tolangc 中,我们为 Lexer、Parser、Visitor 等等分别编写了单元测试。 一个单元测试样例是对某一功能进行的一次测试。不同的样例之间应当尽可能相互独立、互不影响。这也意味着单元测试的执行顺序不应当影响测试的结果。 需要注意的是,一些单元测试框架可能会提供设定样例执行顺序的功能,例如在某一样例成功之后执行等等。但是此处的执行顺序实际上反映的是功能模块之间的依赖关系,意为 “由于 A 模块依赖于 B 模块,所以如果 B 模块的测试出错,那么 A 模块的测试便没有意义”,而并不意味着单元测试之间可以相互关联。 由于上述特点,单元测试实际上应当看作一一个不同的可执行程序。但和整个项目所组成的程序不同,单个功能模块往往无法单独运行,且不像程序那样有着明确的输出。这就需要我们为单元测试构建测试所需的环境,并对模块的运行结果进行检验。这组成了单元测试所需要遵循的三个阶段:构造、操作和检验。 CMake 中提供的 “测试” 实际上就是这样的可执行程序。 构造指设定模块的运行环境,例如参数、依赖的对象、数据库中数据等等。构造在每个单元测试中都要进行,绝不能依赖其他单元测试的运行结果。运行环境当中需要重点关注的是可以被所有单元测试访问的单例和全局变量,以及可以实现数据持久化的文件和数据库。如果存在单元测试之间相互影响的风险,则需要在单元测试结束后进行重置操作。 操作指功能模块的运行。当构造完成运行环境之后,我们希望功能模块能够按照预期方式正确运行。 最后,检验指将功能模块的运行结果和预期结果进行比对。一般来说,测试框架会提供一系列用于比较结果的接口。具体的接口则视框架而定。这一部分的关键实际在于如何获取功能模块的运行结果。如果运行结果相对简单,如某个数值、单个数组等等,那么检验过程便相对简单。但如果运行结果的数据结构较为复杂,或运行结果位置分散,那么检验起来便较为困难。这时可以选择定义相应的信息提取函数,例如定义 to_string 函数将复杂数据结构转换为字符串。在 tolangc 中,对于语法树我们便采用了这样的处理方式。 (2)Mock 如果我们发现难以编写某个类或函数的单元测试代码,那可能意味着我们的类或函数需要进行进一步的拆分。难以测试可能是代码中的副作用或具体类之间的耦合导致的。对于类之间的耦合,我们需要遵循依赖倒置原则,用抽象的接口替代具体的实现。 例如,在实现 Lexer 时,我们没有将 ifstream 作为参数,而是将其父类 istream 作为参数。尽管对于 tolangc 来说,源代码总是从文件中读入的。 class Lexer { Lexer(std::istream &in) : _input(in) {} // ... } 这样做将使 Lexer 类更加可测试。若对 Lexer 类进行测试,我们不必创建真正的源文件。因为 istream 存在子类 istringstream。该类创建时需传入一字符串,而当从流中读取时,读到的便是传入的字符串的内容。...

九月 10, 2024 · 3 分钟 · 544 字 · Wokron

编译技术教程:语义分析

系列导航: 《编译技术教程:前言》 《编译技术教程:词法分析》 《编译技术教程:抽象语法树》 《编译技术教程:语法分析》 《编译技术教程:符号表与类型系统》 《编译技术教程:语义分析》 《编译技术教程:附录 - 项目测试》 在之前的阶段中,我们编写了词法分析和语法分析程序,实现了源代码到抽象语法树的转换,这意味着我们就此得到了结构化的语法信息;另外我们还定义了符号表,从而能够将某一处的语义存储并关联到程序的其他部分;最后我们还定义了中间代码的数据结构,让我们能够方便地构建并输出中间代码。而在这些之后,我们需要一个阶段将这些内容全部连接起来,这就是语义分析阶段。 根据语言的不同,语义分析阶段的目标也可能很不相同。例如 C 的语义分析阶段肯定与 SQL 的语义分析差别巨大。不过总体来说,语义分析阶段的思路还是类似的,那就是遍历语法树、维护符号表并构建中间代码(或者类似的翻译操作)。 一、遍历语法树 树的遍历各位同学当然不陌生。但是怎么样遍历才更好却依旧是一个值得思考的问题。假设我们现在用 Node 类表示树的节点: struct Node { int val; std::vector<Node*> children; } 那么我们很容易想到两种遍历的方式。第一种便是在 Node 中增加一个方法 visit。 struct Node { int val; std::vector<Node> children; void visit() { // do something, like `std::cout << val << std::endl;` for (auto child : children) { child.visit(); } } }; 而第二种方式则是将 Node 作为参数。为了避免使用全局变量,我们需要创建一个新的类 Visitor。 struct Visitor { void visit(Node &node) { // do something, like `std::cout << val << std::endl;` for (auto child : children) { visit(child); } } }; 那么就这两种方法来说,哪种更加适合抽象语法树的遍历呢?答案是第二种使用 Visitor 的方法。因为语义分析的关键是跨节点的语义信息传递。采取第一种方法时,信息被隔离在了每一个节点之中,想要传播只能作为 visit 函数的参数和返回值,沿着节点的遍历顺序进行传递。而对于第二种方法,类中所有方法都共享一个类作用域,这意味着信息不仅能沿着遍历顺序传递,还能跨越语法树的分支传播到需要的地方,而不需要经过层层调用。这种功能十分重要,因为实现上下文关系的关键,符号,就是跨越语法树分支存在的。...

九月 10, 2024 · 7 分钟 · 1474 字 · Wokron

编译技术教程:符号表与类型系统

系列导航: 《编译技术教程:前言》 《编译技术教程:词法分析》 《编译技术教程:抽象语法树》 《编译技术教程:语法分析》 《编译技术教程:符号表与类型系统》 《编译技术教程:语义分析》 《编译技术教程:附录 - 项目测试》 在语法分析部分,我们将单词序列转化为结构化的抽象语法树。然而抽象语法树依旧无法表达完整的程序语义。因为抽象语法树的定义基于上下文无关文法,而无法反映程序执行时的上下文环境。因此为了记录程序中的上下文相关信息,为后续语义分析阶段提供建议,我们需要定义新的数据结构以维护上下文信息。在程序设计语言中,最为关键的上下文信息即符号信息,因此本章节我们着重讨论符号表。 一、符号表 符号表是编译过程中的一个重要结构,主要用于记录各个符号的标识以及相应的信息,例如名称、作用域、类型、大小、维度、存储地址、行数等各项信息。其目的是在编译过程中遇到对应符号时即可快速查询到相关信息。接下来我们也将提供符号表的实现思路以供参考。要注意,符号表的设计与使用将贯穿后续的全部实验流程,请同学们三思而后行! 二、符号表的创建 在不区分作用域的情况下,符号表仅由需记录符号与符号信息的简单映射。但如果语言支持嵌套作用域,那么我们就需要表示各作用域符号表间的嵌套关系。在这种情况下,对于一张符号表来说,通常具有如图的结构:一个指向外层作用域符号表的指针 pre,表主体(符号名与对应信息),若干指向内层作用域符号表的指针 next。此外,在编译的过程中,有一个指向当前作用域符号表的指针 cur。 符号表的生成主要有以下几个操作: 初始时,创建一个全局变量符号表,也是最外层作用域符号表,cur 指向该符号表。 编译时,遇到变量声明语句,解析出需要的信息(一般包括类型、维度、大小等),填入 cur 指向的符号表。 编译时,进入新的作用域(block),生成新的符号表,设置 cur 指向的符号表的 next 指针指向新符号表,新符号表的 pre 设置为 cur,然后将 cur 指向新符号表,后续会在新符号表上填入信息。 编译时,离开作用域(block),通过 cur 指向的符号表的 pre 指针回溯至外层符号表,并对应修改 cur 指针。 不难发现,这样生成的符号表具有树状结构。当然,由于两个无嵌套关系的作用域中的符号并无任何联系,所以我们也可以按栈式结构来组织符号表,即进入新的作用域,就压入新的符号表,离开作用域时,弹出当前符号表。 在 tolangc 中,我们定义了一个栈式符号表。符号表类 SymbolTable 对应某一作用域的符号表。每一个符号表拥有一个 _father 字段,指向更外层作用域的符号表。另有 _symbols 字段存储当前作用域的符号。以符号名称为键,以符号信息(这里为 Symbol 类对象)作为值。 class SymbolTable : public std::enable_shared_from_this<SymbolTable> { // ... private: std::unordered_map<std::string, std::shared_ptr<Symbol>> _symbols; std::shared_ptr<SymbolTable> _father; }; 当进入新的作用域时,我们调用 push_scope 函数,创建新的符号表,并将该符号表的 _father 字段设置为当前符号表。...

九月 10, 2024 · 2 分钟 · 349 字 · Wokron, 北航编译技术课程组