【MIT6.172】学习笔记(1)
前言
本文为MIT6.172的笔记,每篇文章最后对该篇文章的内容会做一简要总结。
本系列假设读者对计算机组成原理,操作系统已经有清晰的认识。
注意,这篇博客更倾向于作者的个人笔记,质量不太高,并且可能有事实上的错误(尽量避免)。如果发现,请随意批评指出。
Lecture 1 初步介绍&矩阵乘法
软件设计:有哪些数学更重要?兼容性,正确性,清晰,可debug,模块化,移植性,可维护性,功能性,可靠性,健壮性,可测试性,可用性。
那么性能是什么?性能是用于“兑换”以上性质的“通货”。
功耗墙:限制了cpu频率的进一步增长。后来转向多核设计。多核设计引入了更复杂的存储器体系结构(Shared memory, Distributed memory)
摩尔定律:每过半年,晶体管的数目就要翻一番。(为什么近代开始失效?)
以矩阵乘法为例:哪些因素会影响代码的执行?如何优化?
①常规的矩阵乘法,速度为:Python<Java<C:
- Python为解释执行的语言
- 解释器:灵活但速度缓慢,支持动态代码修改。工作原理为:读取一行代码,解释,执行,更新状态,如此循环
- Java先由编译为字节码(平台架构无关),再由解释器解释为对应平台的机器码
- 即时编译(JIT compilation):会将部分高频执行的字节码保存为机器码,加速执行,恢复因为解释而丢失的性能。
- C则是直接解释为对应的机器码
② 缓存缺失:i,j,k的不同顺序会有影响,原因不再赘述。需要关注数据结构在内存中的排布。
③编译优化
针对C:-O0到-O3的优化程度越来越高(但实际性能则不一定),另外-Os是优化程序空间大小,-Og是为了debug。
④并行化循环
利用多核心,对循环进行并行化。记住,优先并行化外层循环,因为并行化操作本身也需要开销,并行化操作次数越少越好。(但不是所有代码都易于并行化)
⑤块划分
采用合适的划分方式,使得每块的访存次数尽量少:
如图,每个核心负责算矩阵乘法某一小方块的内容即可。至于块大小如何划分,需要做实验才能知道。(每个核心负责算一块的内容)。在代码中,ih和jh相当于把一小块的base定下来了,k也相当于定了个base。
⑥基于两级Cache的划分
下图是现代处理器的常见架构。
需要测试的参数包括s和t。
⑦分治
idea:矩阵乘法可以分解为更小的矩阵乘法(分块矩阵)
需要强调的是这个矩阵是个方阵,如果需要支持其它矩阵,则需要进行修改。如果n不是2的幂,还需要仔细考虑边界问题(这个程序的assert就是用于判断是不是2的幂)。(作者的想法是,考虑满足r,c为2的幂的矩阵乘法的写法,然后把所有矩阵递归地分解为这样的矩阵。。。)
其中:
- cilk_spawn表示并行化进行,cilk_sync表示同步,注意THRESHOLD判断条件。
- 再注意mm_dac函数的入参:是原矩阵的大小,但n代表当前矩阵的大小。
- 再注意那个宏展开:r是分块矩阵的行,c是分块矩阵的列,宏展开的结果是指针的偏移量,其实是子矩阵的base。详细地说,一个“单位”的大小为n/2,(r * (n_[A-C])) + c
代表有多少个这样的单位。这样就好理解了。
- 再回到mm_base函数,为什么要传入n_C,n_A,n_B,是为了方便计算偏移量。
1 | C[i][j] -> C[i * n_C + j] |
⑧编译器向量化优化
需要开启-O2以上的优化。显示指定包括:
- -mavx
:启用avx
- -mavx2
:启用avx2
- mfma
:启动乘法-加法向量指令
- -march=<string>
:使用指定架构上的任何指令
- -march=native
:使用本机架构的所有指令
另外需要提到的是,使用某台架构的机器编译另外一种架构的编译行为叫做交叉编译。
⑨手动向量化优化(内置向量化)
使用内置函数 。
⑩baseline:Intel MKL(Math Kernel Library )
Lecture 2 Bentley工作量优化准则
工作量:对于给定输入,一个程序所执行的所有操作,
优化工作的角度:算法(很明显),利用计算机硬件的特性:ILP,cache,向量化,预测和分支预测,等等。
- 数据结构
- 打包和编码:将一些信息编码,使用更少的bit位进行表示。经典例子如一个int保存了32个flag,缺点是需要解码,增加编码困难。
- 数据增广:使用额外的数据保存额外的信息,比如单向链表,可以保存尾指针,实现插入的常数时间插入。
- 预计算:俗称打表
- 编译期优化:还是打表
- 缓存:将计算过的结果保存起来,以便于下一次用到。典型应用是记忆化搜索。
- 稀疏性:某些数据结构,有大量无用数据,如稀疏矩阵,此时可以考虑用其它数据结构保存稀疏矩阵,以减少空间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16typedef struct{
int n, nnz;
int * rows;
int * cols;
double* vals;
} spm;
// 矩阵乘以向量。。。
void spmv(spm *A, double* x, double* y){
for (in i = 0; i < A->n; i++){
y[i] = 0;
for (int k = A->rows[i]; k < A->rows[i+1]; k++){
int j = A->cols[k];
y[i] += A->vals[k] * x[j];
}
}
}
- 代码逻辑
- 常数折叠与传播:写过编译器的都知道这个方法,如果变量是常数,后面的表达式也是常数变量的话,那么编译期就可以算出这个结果。
- 共同表达式消除:
b = a - c, d = a - c => b - a - c, d = b
- 代数恒等式:开方的开销是比平方大的,那么不妨把开方化成平方?
- 短路运算:如果在循环中间已知结果,那么可以提早退出。
- 条件排序:将最可能能得出整个表达式结果的条件排到前面
- 创建快速路径:一个函数首先算出一些平凡常见的状况,避免开销更大的判断方法
- 混合测试条件:其实就是用真值表,结合switch-case语句进行使用
- 循环
- 循环无关代码外提:不依赖于循环变量的语句全部扔到循环外面去
- 哨兵值:如果循环内有判断语句,不妨考虑在数据结构里面嵌入哨兵值,减少判断语句的使用。
- 循环展开:分为循环全展开和循环部分展开
- 循环聚合:将循环范围相同的循环结合在一起
- 消除多余的迭代:
- 函数
- 函数内联:减少函数调用开销
- 尾递归:将递归语句放到return(最后)的位置,那么编译器会做优化,直接覆盖当前栈帧,而不是新开一个栈帧。
- 粗粒度递归:当问题规模减小到一定程度时,如果继续递归下去,调用开销大于解决这个问题的开销时,就转为直接解决这个问题,而不是继续递归。
- 其它建议
- 避免过早的优化,先写正确的代码,保证正确性,使用回归测试
- 编译器帮你干了以上很多工作,但是是否启用了,还是要查看编译器代码。
- TODO:介绍cpp的restrict和volatile关键字。。。
Lecture 3: bit hacks
这一章就介绍了各种奇奇怪怪的bit operation,目的是:在编译器不自动优化时,自己手动优化(但绝大多数情况下编译器比你优化的好)。这里记录一下有意思的。
无中间变量的交换
1
2
3x = x ^ y;
y = x ^ y;
x = x ^ y;
但是不利于挖掘ILP寻找较小值
r = y ^( (x ^ y) & -(x < y) );
lowbit操作
r = x & (-x)
八皇后问题
记录状态可以用bit位操作,而不需要数组获取一个数的二进制的1的数量
1
2for(r = 0; x!=0; r++)
x &= x - 1
1
2
3
4static const int count[256] =
{0, 1, 1, 2, 1, ..., 8}
for (int r = 0; x != 0; x >>= 8)
r += count[x & 0xff]
还可以使用编译器内置函数(但移植性可能很差!)
int __builtin_popcount (unsigned int x)
Lecture 4: 汇编语言和计算机体系结构
编译的四个步骤:
反汇编:objdump -S fib
(前提是编译的时候带了-g
选项)
ISA:定义了语法和语义。包括:寄存器,指令,数据类型,访存模式。
x86-64指令集
x86-64寄存器介绍
x86-64有如下寄存器:
其中通用寄存器,根据所使用的位宽不同,同一个寄存器有不同名字:一个寄存器根据使用的byte不同可能有多个名称,主要改动为寄存器前缀,但八位会改变后缀。[e**, r**, **, *h/*l]
,
部分寄存器可能采用其它名称,改变的是后缀。[**d, **w, **b]
这些后缀的含义见下一节。
x86-64指令介绍
x86-64的指令,大致可以分为:数据移动,算数和位运算,以及跳转指令。其中指令后缀暗示数据类型。如subq
表示64位整数。如果没有后缀,则会从操作数推断。
下面是数据类型对应的后缀,可以稍微归纳一下,浮点数比较特殊:依次为s,d(ouble),t
。其他的数据类型为b(yte), w(ord), l(ong)/d(ouble), q
(毕竟本质都是整数)。特殊一点的是d
,在浮点数和整数都出现过。
下面是一些特殊指令的介绍:
- 关于mov
指令,有两种需要关注的后缀:标识扩展方式的后缀和标识数据位宽的后缀。如movzbl
的z
表示零扩展,bl
表示从8bit移动到32bit;movslq
的s
表示符号扩展,lq
表示从32bit移动到64bit。
- 需要注意一个问题:32位扩展到64位采用的是隐式零扩展,和8位和16位的结果不同(是什么?待补)。
- 关于j
指令:通常按照RFLAGS保存d的内容来判断s是否跳转,而RFLAGS的值由cmp
显式改变(其他指令的执行结果也会改变RFLAGS寄存器的值)。RFLAGS
的内容如下:在正常情况下,只需要关注RFLAGS的四个域:CF,ZF,SF和OF。其中:
- CF表示上一个ALU运算是否进位
- ZF表示上一个运算结果是否是0
- SF表示上一个运算结果的正负值,正0负1
- OF表示上一个运算结果是否溢出。
- 对于j
的判断条件后缀,如下表所示。如ja
,jae
等等。另外,思考一下ge
的判断条件为什么是SF=OF
?考虑不溢出的情况,那么ge意味着所算出的结果一定是正数,那么SF=OF=0
;考虑溢出的情况(INT_MAX-INT_MIN),那么算出来的结果本身为正数,但是因为溢出变成了负数,那么SF=OF=1
,也成立;(INT_MIN-INT_MAX),那么结果为负数,下溢变成正数,那么SF=0, OF=1
,也成立。
- 最后注意j
的操作数,可以是label
,也可以是直接地址,也可以是间接地址:
1 | jmp .L1 |
### x86指令的寻址模式
- 直接访存模式(Direct Addressing Mode)
- 立即数:使用声明的立即数:
movq $172, %rdi
- 寄存器:使用指定存储器存储的数据:
movq %rcx, %rdi
- 内存访问:使用常数指定内存地址,使用对应内存地址所存储的数据,
movq 0x172, %rdi
- 间接访存模式(Indirect Addressing Mode)
- 寄存器间接寻址(Register indirect):使用寄存器所存的值作为内存地址:
movq (%rax), %rdi
- 寄存器基址寻址(Register indexed):使用寄存器所存的值作为基地址,加上offset进行访存:
movq 172(%rax), %rdi
- 指令计数器相对寻址(Instruction-pointer relative):使用
%rip
(类似于MIPS的PC,只不过x86是CISCCISC,指令是变长的,所以估计没有程序计数器这个概念)的值作为基地址。
movq 172(%rip), %rdi
- 基地址放缩位移寻址(Base Indexed Scale Displacement)
需要注意的是地址的计算方式,以及两个寄存器是通用寄存器(GPR)。第一个GPR存base,第二个GPRc存Index。以及scale默认是1,可取1,2,4,8。
tricks
1 | # 1 |
浮点数与向量体系结构
浮点数指令集
x86支持float和double,x87加入了long double。SSE和AVX指令也包括了向量指令。编译器更喜欢使用SSE,因为SSE更容易编译和优化。对于SSE指令,使用两个字母的后缀来标识数据类型:[sp][sd]
例:
1 | movsd (%rcx,%rsi,8), %xmm1 |
向量体系结构
令k为向量位宽,每个向量寄存器保存k个整数或者浮点数。每个单元拥有k个向量车道,每个车道包含标量计算单元。每个车道使用相同的指令(也就是SIMD的体现了)。
对于向量体系结构,有以下特点:
- 内存排布可能需要对齐
- 向量寄存器只能“按位操作”,即一个向量寄存器的第i个元素只能和另外一个向量寄存器的第i个元素进行操作
- 某些体系结构支持车道交叉(cross-lane)操作,如插入或提取一个向量的子集,洗牌,分散和集中操作。
下面是x86-64支持的标准:
- SSE:整数,单精度浮点数和双精度浮点数向量操作
- AVX:单精度浮点数和双精度浮点数向量操作
- AVX2:加入了整数向量操作
- AVX3(AVX-512):扩大向量寄存器宽度到512,加入了新操作(如popcount,为1的个数)
SSE和AVX*的比较:
- SSE使用xmm寄存器,长度为128bit,最多同时操作两个操作数
- AVX使用ymm寄存器,长度为256bit,最多接受三个操作数:vaddpd %ymm0, %ymm1, %ymm2
,其中%ymm2
为目标寄存器。
最后,前缀也有两个进行表示:[v ][p ]
,其中第一个前缀,带v
则用avx,否则是sse;第二个前缀,带p
则是整数,否则为浮点数。(别忘了前面的后缀)
另外,向量寄存器也有类似于通用寄存器的别名:ymm的低128位为xmm,ymm总共有256位。
计算机体系结构:概述
首先介绍了经典五级流水线,这个应该都熟悉,下面看看Intel Haswell Microarchitecture的架构,大概有14-19级的流水线,先看看了解一下。
从计算机体系结构上进行提升,主要有两种手段:
- 开发ILP:如向量化,指令级并行,多核操作
- 利用局部性原理:如使用cache
超标量处理(实现多发射)
使用流水线,但是需要关注三种数据依赖类型:真依赖(RAW),反依赖(WAR)和输出依赖(WAW)。但由于部分操作时延较长,增加了流水线的设计复杂度,如下图所示。
下图是Intel Haswell的功能单元设计:
从流水线如何过渡到超标量?答案是进行多发射,保证每个功能单元都在执行任务(理想状况!)这里展示Intel Haswell的取指和解码阶段的架构:
其采用了微指令体系结构,作用是将一条复杂指令分解为更多简单的指令。其包括一个复杂指令的解码器,每周期发射四条微指令。
乱序执行
流水线中一个重要的问题是数据依赖问题,对于RAW来说,常见的方案是数据前推(By passing)。对于一个指令流,我们可以建立一个指令依赖图。如下图所示
而对于顺序发射,WAR和WAW似乎都不是问题,但如果是乱序执行呢?可以使用寄存器重命名技术!
那么,乱序执行下,WAR和WAW可以通过重命名技术解决,而RAW造成的性能损失,在乱序发射的场景下,性能损失得到减少。下面将具体研究动态寄存器重命名技术。
在体系结构中,会使用更多的寄存器保存因为WAR和WAW而多出来的值。维护这个关系的表叫做重命名表(renaming table),其中维护ISA规定的寄存器号到物理寄存器号的映射。而物理寄存器由Freelist进行维护。
关于寄存器重命名及动态排序的算法,推荐直接查找ppt看,图示较多,这里用我的语言介绍一下算法步骤:
上图为重排序缓冲(reorder buffer, ROB),用于跟踪指令间的依赖关系。每个指令拥有tag。source,dest以及重命名表的Data域可能取tag,也可能取物理寄存器。下面分两种情况讨论:
- 当一个新的指令进入buffer时,首先检查source,根据重命名表判断是否来自未执行完的指令,并在ROB记录对应的tag,然后检查Dest,在重命名表中更新对应寄存器的tag。
- 当一个指令执行完毕后,从ROB删除该项,并在重命名表中更新Data,如果tag和该指令的tag相同,则更新到对应的物理寄存器,并将ROB的对应Source项也进行更改(从tag改到物理寄存器)。
总之,在乱序执行场景下,数据依赖关系可以通过图进行建模,而且只有RAW会影响性能,WAW和WAR几乎不影响性能。
分支预测
这一小节重点考虑控制冒险的问题。
在IF阶段就需要知道分支判断的结果,但是需要在EXE阶段才能计算得到结果。因此有一些技巧:
- 投机执行(Speculative Execution):总是假定不执行,预测错误则撤销执行的部分带来的影响。缺点是流水线越深,分支预测错误带来的惩罚越严重。
- 动态分支预测:分为局部动态分支预测和全局动态分支预测。局部是根据单条指令来判断,全局是根据所有指令的历史情况判断。
Lecture 5:从C语言到汇编
为什么要了解汇编语言?因为汇编语言可以展示编译器做的优化,而且某些bug来源于编译器(开了O3独有的),而且可以手动修改汇编,使得其运行更加快。下图是基于LLVM的编译器的编译流程:
生成LLVM的中间表示,然后通过优化器生成优化后的IR,最后生成汇编代码。首先看看IR的中间表示长什么样(使用clang编译器,使用-emit-llvm
可以产生中间表示。
LLVM中间表示
LLVM IR和汇编的区别:更小的指令集,寄存器是无限的,没有FLAG计数器和条件码,没有显式栈帧指针
- LLVM IR寄存器:用
%<name>
表示,数量无限,相对于LLVM IR函数是局部的(即不同函数的寄存器编号可以相同) - LLVM IR指令:
- 产生结果的指令:
%<name> = <opcode> <operand list>
- 其他指令:
<opcode> <operand list>
- operand可以是寄存器,常数和基本块
- 指令列表如下:
- 产生结果的指令:
- LLVM IR数据类型
- 整数:
i<number>
。例:3bit整数:i3 - 浮点数:float,double
- 数组:
[<number> x <type>]
- 结构体:
{<type> ...}
- 向量:
< <number> x <type> >
- 指针:
<type>*
- 标签
- 整数:
从C语言到LLVM IR
基本块转IR
数组结构常保存在内存中。而LLVM IR中的getelementptr
函数用于从指针和下标计算内存地址。
函数转IR
从C语言到LLVM IR的函数,形参列表一一对应。并且按顺序自动命名为%0, %1, %2...
基本块:一系列的指令,只有单个入口和单个出口。最后一条指令可以是跳转语句。
控制流图:基于跳转情况,可以建立CFG,其中每个节点是一个基本块。
条件语句转IR
条件跳转语句(if)会转成br <type> <reg>, label <reg>, label <reg>
,其中第一个参数用于判断,为真则跳转到第一个label,否则为第二个label。比较语句会转成icmp
指令。条件跳转语句总会创建菱形CFG。
其中pred记录了前驱基本块。
无条件跳转语句则直接转换成br label <reg>
,直接跳转。
循环转IR
循环包含循环体和循环控制两个部分。如下图所示。在CFG中会创建一个自循环部分。对于C语言的for循环,包含一个循环归纳变量(loop induction variable),包含初始化,条件和自增的部分。
注意归纳变量自增的时候,寄存器会替换,原因是循环自增重定义了变量(需要满足SSA,下文提到)
LLVM IR维护静态单赋值(SSA, Static Single Assignment)的性质,即一个寄存器最多只能由一条指令赋值。这有利于进行数据流分析。但如果出现控制流的汇合,需要使用phi函数来维护这一性质:
1 | %9 = phi i64 [%14, %8], [ 0, %6 ] |
这个phi函数表示如果控制流从基本块8回来,则采用%14的值;从基本块6回来,则采用0。
IR属性
LLVM IR会为一些操作维护额外属性。这些属性的来源可能是源代码,也有可能是编译器分析。
最后的总结:LLVM IR的特点是:所有计算出的值都会存入寄存器中,每个寄存器最多只有一条IR赋值,这个性质称为Static Single Assignment(SSA),同时IR会被建模为CFG,进行进一步的优化。
从LLVM IR到x86汇编
这一步需要考虑三个问题:
- 选择恰当的汇编指令
- 合理分配通用寄存器
- 处理函数调用
x86-64的函数调用约定
程序在虚拟空间中被划分为几个段,其中data段是初始化过的值,bss段是未初始化的值,默认初始化为0.
汇编代码包括两种指令:引用部分(section)的指令和操作部分(section)的指令。其中:
- 段指令将汇编代码文件组织为几个部分,包括.bss
,.data
,.text
等。
- 存储指令将相应的值存储在指定位置,如:
- x: .space 20
:在x处划分20字节的空位
- y: .long 172
:在y处存储172L
- z: .asciz "6.172"
:在z处存储"6.172\0"
- .align 8
:下面的内容按照八字节对齐。
- 域和链接指令:用于控制链接
- .globl fib
使符号fib对其他目标文件可见。
对于调用栈,有这些数据保存在栈上:返回地址,寄存器状态和函数参数,以及不能存入寄存器的局部参数。
x86-64的函数调用约定:
- 每个函数拥有自己的帧,其中%rbp
保存栈帧顶,%rsp
保存栈帧底。
- 使用call
和ret
实现调用栈和退栈,指令指针寄存器%rip
用于处理返回地址。其中call
将%rip
压栈,ret
则进行弹栈操作。
- 保存其他寄存器的内容:将寄存器分为caller
和callee
,前者需要调用者保存,后者由被调用者保存。其中callee-saved registers
为%rbx, %rbp, %r12-%r15
,其他的均为调用者保存寄存器。而%xmm0-%xmm7
通常用来传递浮点数参数。
原课程以Fibonacci为例再介绍了一下,略。愿意看的看原ppt即可。
Lecture 9 编译器能够做什么,不能做什么
首先看一下编译器的图景,可以发现LLVM相当于编译器中端,向下屏蔽了高级语言类型,向上屏蔽了平台架构。
如何优化?编译器通过多遍变换,生成优化后的LLVM IR。下面是Clang/LLVM生成优化报告的参数:
- -Rpass=<string>
:生成关于指定优化方法的成功结果
- -Rpass-missed=<string>
:失败结果
- -Rpass-analysis=<string>
:前两者之和
例:clang -O3 test.c -Rpass=.* -Rpass-analysis=.*
编译器优化
关于编译器能干的事罗列如下:
### 标量优化
上面这幅图需要注意的点:LLVM IR函数参数,将struct展开了(vec_t是两个double组成的结构体),第一个优化点是略去内存读取操作,直接在寄存器进行操作。注意%15
和%20
,实际上%14
和%19
都可以换成%2
,并消除死代码,获得优化效果。
结构体优化
结构体很难处理,因为往往操作的是结构体的其中某一个域。
观察上面这一块代码,最后%13
需要什么数?%12
实际是什么?%0
,前面对内存进行了相当多的存入存出操作,最后发现就是%0
,那么,可以进行替换,并进行死代码消除。
总之,编译器尽量减少内存的读入读出操作,尽可能在寄存器内完成操作。
函数调用优化
①进行函数内联
但需要注意的是,不是所有函数都可以进行内联,如递归调用(除了尾递归调用可以优化),不能将定义在其他编译后的单元的函数进行内联(除非一起编译),同时,函数内联会导致代码大小增大。
编译器是否能判断函数是否内联呢?很遗憾的是并不知道,只能根据函数大小进行猜测。可以利用__attribute__((always_inline))
对函数进行标记,令相应函数进行内联。使用__attribute__((no_inline))
取消内联。同时,可以使用链接时优化(link-time optimization (LTO) )进行整体编译的优化。
循环优化
无关代码外提(code hoisting),或无关代码移动(loop-invariant code motion, LICM),目的在于将不依赖于循环归纳变量(loop induction variable)的语句提到循环外。
如图,里面两个框起来的语句只依赖于外层变量,因此可以考虑外提这些语句。对应的源代码如下。
但是编译器无法发掘什么问题呢?上个图的函数作用是计算力的大小,编译器是无法发现两个物体的相互作用力,大小相同,方向相反这个特点的。(背景是N体运动模拟问题)
失败的优化——样例学习
样例1 :向量的线性运算
观察这段代码,这个分支是什么呢?其实是判断两个数组是不是一个东西,然后根据结果跑不同的指令。可以看到,编译器产生了两个版本的指令,因为存在内存别名(memory aliasing)的问题!可以使用restrict
关键字修饰入参,表明这两个地址一定不是同一个地址,从而消除内存别名的问题。同时,const
修饰符表明只读的属性。
样例2:归一化
将每次计算都能得到相同结果的,但计算代价大的成分扔出循环。但编译器为什么没有优化?因为编译器不知道传入的参数,内容是否会被修改!因此,在norm
函数声明的最前面可以加上__attribute__((const))
来声明不会有修改,从而达到优化目的。
样例3:显式循环展开
如图,由于循环次数不确定,无法进行向量化。为什么?因为传入参数是size_t
,这是个无符号整数!注意由于无符号整数的溢出是个Undefined Behavior,编译器的行为不确定(可能优化可能没有优化)。修改方法是改用有符号整数。
链接时优化
主要是单个编译单元优化程度不够,可以进行链接时优化,clang编译器开启-flto
将源代码编译成LLVM IR形式,带上-fuse-ld=gold
将LLVM IR代码链接在一起。
总结
本篇文章对第1节课到第5节课,以及第9节课的内容进行了总结,其中:
- 第1节课
- 以矩阵乘法为例,介绍了一系列优化的效果
- 第2节课
- 介绍了一些优化技巧,并且这些技巧大多数可以被编译器识别,但仍然需要了解
- 第3节课
- 关于位操作的一些骚操作
- 第4节课
- 介绍了x86 ISA,其中需要掌握的内容有:
- 所拥有的寄存器,作用,以及寄存
- 器别名的设计,寄存器的前缀,后缀的含义
- 常见指令,以及常见指令的后缀
- x86的条件RFLAGS寄存器的相关设计(ZF,OF...)
- x86的寻址模式
- 向量指令集 SSE,AVX,AVX2和AVX3的特点
- 指令集的前缀(
[v ][p ]
)后缀([sp][sd]
)
- 之后介绍了计算机体系结构相关的内容,主要是从五级流水线到现代处理器流水线特征的过渡,包括:
- 向量体系结构
- 超标量处理
- 乱序执行:数据冒险的处理,以及针对WAR和WAW的寄存器重命名技术
- 分支预测:投机执行和动态分支预测
- 介绍了x86 ISA,其中需要掌握的内容有:
- 第5节课
- 对LLVM IR进行了初步介绍,需要知道
- 基本块的定义
- SSA,phi函数
- CFG
- 进程的虚拟地址空间组织方式,有哪些段以及作用(.text,.bss,.data...)
- 汇编代码文件的组织,汇编代码的一些特殊指令
- x86-64的函数调用约定
- 对LLVM IR进行了初步介绍,需要知道
- 第9节课
- 了解编译器的优化问题,包括
- 编译器输出优化报告
- 通过减少访存次数,尽量在寄存器中进行操作,同时消除死代码
- 函数内联,哪些函数不能函数内联,以及优缺点
- 影响编译器优化的因素,包括:
- 寄存器别名问题,C语言的关键字
restrict
- 不确定的内存修改:对相关函数加上
__attribute__((const))
修饰符,表明相应函数不会对传入参数的内容进行修改。 - undefined behavior行为导致优化的不确定性:如无符号整数导致的溢出。
- 寄存器别名问题,C语言的关键字
- 链接时优化
- 了解编译器的优化问题,包括
- Title: 【MIT6.172】学习笔记(1)
- Author: spiritTrance
- Created at: 2024-02-09 13:48:00
- Updated at: 2024-08-14 12:01:56
- Link: https://spirittrance.github.io/2024/02/09/MIT6.172_ch1/
- License: This work is licensed under CC BY-NC-SA 4.0.