SFS
-
关于artifact目前已集成到SVF中,对应FlowSensitive指针分析。
-
其它参考blog。
-
SVF其它参考文档。
这篇blog中会提到point-to set, point-to graph,point-to information等概念,基本上都是指同一个东西。可表示为1个map,key为top-level varaible或者address-taken variable(后面会介绍概念),value为address-taken variable集合。表示程序中变量的指向集合。
1.Introduction
-
Flow-Insensitive指针分析特点:(1).忽略语句顺序、(2).所有program point共享一个solution。具体规则参考。
-
Flow-Sensitive:(1).考虑control flow、(2).每个program point有一个solution。
传统IFDS-based解法是沿着ICFG遍历,对于大型程序,CFG中可能有数10万个node,每个node需要维护2个point-to graph: 对应incoming information和outgoing information。每个point-to graph可能有数10万个指针变量,每个指针可能指向数千个对象,因此效率非常低下。
Solution: staged pointer-to analysis (简称SFS)
-
1.auxiliary pointer analysis(后面简称AUX): 保守计算def-use信息。
-
2.primary analysis(简称PA): 利用AUX计算的def-use信息构建sparse value flow graph(SVFG),在SVFG上进行flow-sensitive分析。
Challenges:
-
1.AUX必须在精度和性能之间取得良好的平衡:如果结果太不精确,那么第1步生产的SVFG的稀疏性就会受到影响;如果AUX性能开销大,则PA将永远无法执行。
-
2.PA必须能够同时处理AUX的保守的def-use信息,同时保持流敏感指针分析的精度。
-
3.PA必须有效管理由AUX计算的大量def-use链。
Contributions:
-
1.提出了SFS方法。
-
2.引入了access equivalence的概念,它将 def-use 链划分为等价类,从而允许 SFS 有效地处理大量的 def-use 信息。
-
3.用一个flow-, context-insensitive的inclusion-based (Andersen style)的AUX进行evaluation。benchmark包括16 open-source程序,能够在14min内分析1.9M Loc的程序。
-
对于小型程序(那些少于100K LOC的程序),SFS依然很高效,但与baseline相比没有提供任何性能优势。
-
对于中型程序(具有100K 到 400K LOC的程序),SFS比baseline快5.5倍。
-
对于大型程序(具有超过 800K LOC的程序),SFS可以完成分析,而baseline则不能。
-
2.Background
2.1.Flow-Sensitive Pointer Analysis
传统Flow-Sensitive Analysis沿着CFG(ICFG)用transfer function进行遍历,直到达到fix point。transfer function为:
I N k = ∪ x ∈ p r e d ( k ) O U T x O U T k = G E N k ∪ ( I N k − K I L L k ) IN_k = \mathop{\cup}\limits_{x \in pred(k)} OUT_x \\ OUT_k = GEN_k \cup (IN_k − KILL_k) INk=x∈pred(k)∪OUTxOUTk=GENk∪(INk−KILLk)
k k k 为一个CFG node, x x x 为一个前驱node, I N IN IN 代表incoming pointer information, O U T OUT OUT 代表outgoing pointer information, G E N GEN GEN 和 K I L L KILL KILL 分别表示新生成的和覆盖的pointer information。
-
对于赋值语句 k : x = y k: x = y k:x=y, K I L L k = { x → _ } KILL_k = \{x \rightarrow \_ \} KILLk={x→_},这是strong update。
-
对于 k : ∗ x = y k: *x = y k:∗x=y,情况就比较复杂。
-
如果 x x x 一定指向内存区域 z z z,那么 K I L L k = { z → _ } KILL_k = \{z \rightarrow \_ \} KILLk={z→_},依旧可以进行strong update。
-
如果 x x x 可能指向好几块内存区域,那么 K I L L k = { } KILL_k = \{\} KILLk={},此时进行的是weak update。这是种保守策略。
-
flow-sensitive的分析关键步骤就是kill,也就是strong update,flow-insensitive(Andersen)的做法则是weak update(不断进行增量分析,会引入很多误报)。
任何指针分析的一个重要方面是堆建模,它将概念上无限大小的堆抽象为一组有限的内存位置。作者采用常见的做法,将每个静态内存分配点(memory allocation site)视为不同的抽象内存位置(在程序执行期间可能映射到多个具体内存位置)。
2.2.SSA
在静态单赋值(SSA)形式中,每个变量在程序中只被定义一次。SSA形式非常适合执行稀疏分析,因为它明确表示了def-use信息,并允许数据流信息直接从变量定义传递到其相应的使用点。
由于C/C++存在通过指针访问的address-taken variable,将程序转换为SSA形式变得更加复杂,这些间接定义和使用只能通过指针分析来发现。由于由此产生的指针信息是保守的,每个间接定义和使用实际上都是一个可能的定义或使用。为此作者采用了 [2] 中Memory SSA,用 χ \chi χ (CHI) 和 μ \mu μ (MU) 来表示address-taken variable的defs和uses。
-
对于
store
指令*x = y
。Memory SSA会为其添加标注 v = χ ( v ) v = \chi(v) v=χ(v), v v v 表示可能会被该store
指令define的变量。 -
对于
load
指令x = *y
。Memory SSA会为其添加标注 μ ( v ) \mu(v) μ(v), v v v 表示该load
指令访问的变量。
在转换为Memory SSA形式时,每个 χ \chi χ 函数被视为给定变量的def和use,而每个 μ \mu μ 函数被视为给定变量的use。
为了避免处理 load
和 store
的复杂性,一些现代编译器(如GCC和LLVM)使用一种被称为partial SSA的变体。其思路是将变量分为两类。一类包含从不被指针引用的top-level variable,因此它们的def和use可以被轻松确定,而不需要指针信息。对于这些变量,可以使用任何构建SSA形式的算法将它们转换为SSA。第二类包含那些可以被指针引用的变量(address-taken variable),为了避免上述的复杂性,这些变量不会被转换为SSA形式。
相关文档:stackoverflow, handout09
2.3.LLVM IR
在LLVM中,top-level variable保存在一个概念上无限的虚拟寄存器集合中,这些寄存器保持SSA形式。address-taken variable则保存在内存中而不是寄存器中,并且它们不处于SSA形式。top-level variable通过 alloca
(内存分配)和 copy
指令进行修改。address-taken variable通过 load
和 store
指令访问,这些指令使用top-level variable作为参数。address-taken variable在IR中从不被直接引用,而是通过这些 load
和 store
指令间接引用。LLVM指令使用三地址格式,因此每条指令最多只有一级指针解引用(具有多级间接引用的源语句通过引入临时变量简化为这种形式)。
下图为paper中的示例代码,左边是非SSA形式的代码,右边是转换后的partial SSA形式的IR(不是实际编译出来的)。(注意: 这个程序只能又来进行说明,实际上源代码中所有被声明的变量都会存在内存中,包括示例中的 a
, b
, c
, d
,因此都属于address-taken variable,top-level variable由于没有实际分配内存只存在于编译后的IR的中间变量中)
左边源代码中存在 a
、b
、c
和 d
4个address-taken variable,w
, x
, y
, z
4个top-level variable。示例右边的IR中address-taken variable仅通过 load
和 store
访问(包括 c = 0
被编译成 store 0 y1
,通过 y
进行访问),top-level variable则都是SSA形式(y = &c
和 y = &d
分别成了 y1 = ALLOCc
和 y2 = ALLOCd
)。
3.Related Work
这里主要说下SFS和semi-sparse analysis [ 3 ] ^{[3]} [3] 区别,semi-sparse analysis对top-level variable执行稀疏分析,同时对address-taken variable使用迭代数据流分析(听起来是IFDS-based方法)。相比之下,SFS是完全稀疏的,并且对所有变量都使用SSA形式。最终的分析比semi-sparse analysis在可扩展性上提高了一个数量级。
4.Staging The Analysis
作者这里选择了一个flow-, context-insensitive inclusion-based (Andersen style)指针分析作为AUX,只要AUX是sound的,SFS也将是sound的,并且它的精度至少与传统的流敏感指针分析相当。AUX的精度主要影响主要分析的稀疏性(从而影响其性能)。
这一部分主要介绍基于AUX构造sparse value flow graph(def-use graph)的过程。
4.1.Sparse Flow-Sensitive Pointer Analysis
这一步主要用到的数据结构就是def-use graph (DUG),最大的挑战即是为address-taken variable计算def-use graph,下图为一个假想的代码片段对应的CFG(应该不是完整代码片段),右侧是AUX计算出的每个top-level variable对应的point-to set。CFG中只包含 store
和 load
操作。
首先,SFS需要将该CFG转化成Memory SSA形式,即插入
χ
\chi
χ 和
μ
\mu
μ 函数。以 *p = w
和 s = *z
为例。p
指向 {a}
,因此插入
a
1
=
χ
(
a
0
)
a_1 = \chi(a_0)
a1=χ(a0)。z
指向 {a, b, c, d}
,因此插入
μ
(
a
2
)
,
μ
(
b
2
)
,
μ
(
c
0
)
,
μ
(
d
2
)
\mu(a_2), \mu(b_2), \mu(c_0), \mu(d_2)
μ(a2),μ(b2),μ(c0),μ(d2)。下图则是完整的Memory SSA形式的CFG。
由AUX计算的def-use信息相对于SFS将要计算出的计算出精确的flow-sensitive def-use信息来说更加保守,因此对基于AUX计算出的Memory SSA中当遇到带有标注
v
m
=
χ
(
v
n
)
v_m = \chi(v_n)
vm=χ(vn) 的 store
命令 *x = y
(也就是AUX计算出 x
指向 v
),需要考虑以下三种可能性:
-
1.SFS计算后
x
可能不指向v
。在这种情况下, v m v_m vm 应该是 v n v_n vn 的一个copy,并且不包含y
的任何信息。也就是 p t s ( v m ) ⊇ p t s ( v n ) pts(v_m) \supseteq pts(v_n) pts(vm)⊇pts(vn)。 -
2.SFS计算后
x
可能只指向v
1个address-taken variable。在这种情况下,分析算法可以对v
的points-to information 进行strong update。换句话说, v m v_m vm 应该是y
的copy,并且不包含 v n v_n vn 的任何信息。也就是 p t s ( v m ) ⊇ p t s ( y ) pts(v_m) \supseteq pts(y) pts(vm)⊇pts(y)。 -
3.SFS计算后
x
除了v
还会指向别的address-taken variable。在这种情况下,分析算法必须对v
的pointer-to information进行weak update。换句话说, v m v_m vm 应该包含来自 v n v_n vn 和y
的指向信息。也就是 p t s ( v m ) ⊇ p t s ( v n ) ; p t s ( v m ) ⊇ p t s ( y ) pts(v_m) \supseteq pts(v_n) \; ; \; pts(v_m) \supseteq pts(y) pts(vm)⊇pts(vn);pts(vm)⊇pts(y)。
构造后的def-use graph如下图所示,每条边都标注了address-taken variable,这里简单说明下:
-
p = w
连接到*r = y
的边表示 a 1 = χ ( a 0 ) a_1 = \chi(a_0) a1=χ(a0) 到 a 2 = χ ( a 1 ) a_2 = \chi(a_1) a2=χ(a1) 的def-use关系。 -
*q = x
连接到u = *v
的两条边分别表示 e 1 = χ ( e 0 ) e_1 = \chi(e_0) e1=χ(e0) 到 μ ( e 1 ) \mu(e_1) μ(e1) 和 f 1 = χ ( f 0 ) f_1 = \chi(f_0) f1=χ(f0) 到 μ ( f 1 ) \mu(f_1) μ(f1) 的def-use关系。
4.2.Access Equivalence
可以看到前面的DUG中有非常多的边,这是由于每个 load/store
可能访问上千个address-taken variable。边太多了显然会影响性能。因此这里作者提出访问等价(access equivalence)的概念。
两个address-taken variable如果每次都被同时 load/store
,那么就是访问等价。为了计算访问等价关系,作者用到了一个map
A
E
AE
AE,key为address-taken variable,value为指令集合,表示访问了该address-taken variable的所有指令。如果
A
E
(
x
)
=
A
E
(
y
)
AE(x) = AE(y)
AE(x)=AE(y),那么
x
x
x 和
y
y
y 访问等价。
上面示例中的访问等价组包括:{a}, {b, d}, {c}, {e, f }
。压缩边集合的DUG如下图所示
4.3.Interprocedural Analysis
有两种方式将这一步的def-use analysis扩展为跨函数分析:
方式一:为每个函数单独计def-use graph,保守地将call instruction视为对callee function中所有被定义变量的def点以及所有被使用变量的use点。此方法可能会使得callee中很多 store/load
指令会连接到callsite,因此的缺点是边的数量可能太多影响稀疏性。不过Pinpoint貌似解决了这个问题并采用这个思路。
方式二:也是作者选择的方式,是将整个程序作为一个整体来计算def-use graph,直接跨函数连接变量def和use。其中一个重要考量是间接调用。一些跨越多个函数的def-use chain可能依赖于间接调用分析。给定的分析并没有解决这个问题——它假设def-use chain只依赖于指令所使用的指针的point-to set,而没有考虑到函数指针的额外依赖关系。换句话说,如果AUX计算的调用图对流敏感指针分析计算的调用图进行了过拟合,那么这种技术可能会失去精度。
间接调用问题有两种解决方案。(1).简单地假设AUX计算了一个精确的调用图,即与流敏感指针分析计算的调用图相同,这也是作者采用的方法。(2).每个跨函数的依赖于间接调用的def-use edge会用 <function pointer,target function>
对进行标注。只有当目标函数已被计算为函数指针的指向集的一部分时,指针信息才会跨越这个def-use edge传播。
5.Final Algorithm
5.1.Main Part
完整的算法为:
第一步进行pre-analysis,构造sparse value-flow graph (def-use graph),包括:
-
1.用AUX进行指针分析,用指针分析的结果求解间接调用并构造ICFG。对于function call,为callsite实参和每个callee形参添加
copy
边。返回值和callsite间添加copy
边。 -
2.为top-level variables进行SSA转换,这一步理论上LLVM可以完成。
-
3.进行访问等价分析,对于每个partition (等价组)
P
,对P
中任意变量的store
标注为 P = χ ( P ) P = \chi(P) P=χ(P),load
标注为 μ ( P ) \mu(P) μ(P),随后构造完整的Memory SSA。 -
4.为每个pointer-related instruction以及 Φ \Phi Φ 函数构造DUG node并进一步添加edge:
-
对于
alloc
,copy
,load
结点 N N N,对于每个使用了 N N N 的top-level variable node M M M,添加边 N → M N \rightarrow M N→M。 -
对于
store
结点 N N N,标注了 P n = χ ( P m ) P_n = \chi(P_m) Pn=χ(Pm),那么对于使用 P n P_n Pn 的每个node M M M,添加边 N → P M N \stackrel{P}{\rightarrow} M N→PM。 -
对于 Φ \Phi Φ 结点 N N N,其定义了 P n P_n Pn,那么对于使用 P n P_n Pn 的每个node M M M 添加边 N → M N \rightarrow M N→M。
-
可以看到存在两种边,没有标记的和标记了address-taken variable的,带标记的边从 store
指令出发,指向其use,这里的use点由于使用了address-taken variable一定是 store
或者 load
。因此带标记的边一定是从 store
到 load/store
。
def-use graph构造好后就进行flow-sensitive pointer analysis,主算法如下:
-
w o r k l i s t worklist worklist 初始化为所有
alloc
node。 -
P G PG PG 为所有top-level variable的point-to set, P t o p ( v ) P_top(v) Ptop(v) 为top-level variable v v v 的point-to set,相当于 P G PG PG 的子集。这里 P G PG PG 相当于一个flow-insensitive Andersen算法的结果。
-
每一个
load
node都包含一个point-to graph I N k IN_k INk 保存该node所有可能访问的address-taken variables。 P k ( v ) P_k(v) Pk(v) 表示 I N k IN_k INk 中的address-taken variable v v v 对应的point-to set。 -
每一个
store
node包含2个point-to graph保存该node所有可能define的address-taken site。 I N k IN_k INk 保存incoming information, O U T k OUT_k OUTk 保存outgoing information。 P k ( v ) P_k(v) Pk(v) 为 I N k IN_k INk 中的address-taken variable v v v 对应的point-to set。 p a r t ( v ) part(v) part(v) 为 v v v 对应的访问等价组。
可以看到相比flow-insensitive,这里既有全局point-to set
P
G
PG
PG,又有每个program的point set
I
N
k
IN_k
INk 和
O
U
T
k
OUT_k
OUTk (只在 store
中用到)。
5.2.Comparsion with Andersen-style
这里贴上Andersen算法作为对比 (不考虑field-sensitivity):
-
相同之处在于,SFS在处理
alloc
,copy
时方式几乎一样。都会更新 P t o p ( x ) P_{top}(x) Ptop(x)。 -
不同之处:
-
首先Andersen的worklist算法没有考虑
store
,copy
等指令执行的顺序。不过仅仅是这个不同那么算法收敛的时候全局point-to set P G PG PG 起始是一样的。 -
SFS除了 P G PG PG 外还用 I N IN IN 和 O U T OUT OUT 保存流敏感指针分析结果(只保存address-taken variable), I N k IN_k INk 和 O U T k OUT_k OUTk 保存指令 k k k 的incoming和outgoing信息( k k k 只能是
store
或者load
)。 -
处理
load
指令x = *y
和store
指令*x = y
时,Andersen算法会添加copy
边而不是直接更新 P G PG PG。SFS处理load
指令 k k k 时访问了 I N k IN_k INk 并更新了 P G PG PG,处理store
指令 k k k 时会应用transfer function更新 O U T k OUT_k OUTk,并更新DUG后继load/store/Phi
node的 I N IN IN 集合,将成功更新的node加入worklist。
-
5.3.指针分析结果及使用
SFS的指针分析结果包括 P G PG PG (针对top-level variable) 和 I N IN IN/ O U T OUT OUT (针对address-taken variable)。从源代码的角度来说,因为所有源代码中声明的变量都是address-taken variable,top-level variable都是LLVM IR中间生成变量,因此最重要的结果就是 I N IN IN/ O U T OUT OUT。
6.Evaluation
作者拿SFS和semi-sparse analysis(SSO) [ 3 ] ^{[3]} [3] 进行对比,指标为时间和内存开销,benchmark信息如下表所示
运行时间和内存开销如下表所示,SFS的时间开销包括3部分: AUX指针分析、DUG构造、flow-sensitive指针分析。可以看到SFS相比SSO优势主要体现在分析大型和中等大小的程序上。
其中分析时间即使对于大小相近的benchmark可能也有很大差异。一些较小的benchmark测试花费的时间比较大的benchmark要长得多。一个benchmark测试的分析时间取决于许多因素,而不仅仅是程序大小原始大小:所涉及的point-to set的大小;def-use graph的特性,这决定了指针信息传播的广度;worklist算法与pointer analysis的交互方式;等等。如果不知道这些信息(只能通过指针分析过程来收集),就很难预测分析时间。