面向流敏感的对象版本化 i指针分析 i
分析
莫哈马德·巴巴尔∗†,隋玉磊∗,陈世平†
∗悉尼科技大学,澳大利亚
†CSIRO数据61部门,澳大利亚
摘要
流敏感的指向分析比其流不敏感版本提供了更高的精度。传统上在控制流图上进行的流敏感分析会带来较高的分析开销。为了提高性能,分阶段流敏感分析(SFS)在预先计算的定义‐使用(值流)图上进行,其中变量的指向集通过定义‐使用链进行稀疏传播,而不是在控制流图中的控制流上传播。SFS实现了不同对象的指向集传播的稀疏性(多对象稀疏性),但仍存在同一对象的指向集在指令间冗余传播的问题(单对象稀疏性)。一个对象的指向集常常被重复复制,导致冗余的传播和存储,尤其是在现实世界的内存密集型程序中。
我们注意到,一种简单的图预标记扩展可以在预分析阶段识别出大部分此类冗余。通过该预分析,值流图中的多个节点(指令)可以共享单个内存对象的指向集,而不是每个节点都为该单一对象维护独立的指向集。我们提出了面向流敏感指针分析的对象版本化,这是一种更精细的单对象稀疏技术,在保持相同精度的同时,避免了在传播和存储指向集过程中存在的大量冗余。我们在15个开源程序上进行的实验表明,与SFS相比,我们的方法运行速度最高提升26.22×倍(平均提升5.31×倍),内存使用最多减少5.46×(平均减少2.11×)。
索引术语
指向分析,流敏感性,稀疏值流图
一、引言
指向分析是一种基础的静态分析,用于支持许多其他程序分析,例如编译器优化[6],[15],漏洞检测[17],[28],程序验证[8],和程序切片[27]。它是一种数据流分析,采用集合并交运算符,旨在静态地确定指针在运行时可能指向的对象。由于是静态分析,一个可靠的指向分析只能给出可能的运行时指针关系的过近似。这种过近似可以通过各种精度维度来提高精确性,但通常会以牺牲性能为代价。其中一个维度是流敏感性,在该维度中会考虑控制流。
与将指令视为无序的流不敏感指向分析不同,流敏感指向分析需要在编码了程序控制流的数据结构上执行分析。为此,可以使用程序的控制流图。由于指针的指向集可能随着程序执行顺序中每条指令而变化,因此我们需要维护不同的指向集在每条指令处对每个变量而言都是如此。然而,在许多现代C/C++指向分析所基于的部分SSA形式[14],中,所谓的顶层变量只有一个定义,因此可以为这类变量使用全局指向集。不幸的是,被取地址的变量(即通过加载和存储指令间接修改的变量)仍然需要在每个程序点维护独立的指向集。
分阶段流敏感分析(SFS)[11]摒弃了在每个程序点为每个变量维护一个指向集的概念。相反,它通过使用一种安全但不精确(因而代价更低)的指向分析,构建一个过近似的定义‐使用图(或稀疏值流图(SVFG)),并在该图上进行流敏感分析。与在控制流图上将所有指向集传播到所有程序点不同,只需根据过近似的定义‐使用关系,在SVFG上维护和传播一部分对象指向集即可。
SFS旨在使不同对象的指向集传播变得稀疏(多对象稀疏性),但它在相同对象的指向集在指令之间的传播上存在过度传播的问题(单对象稀疏性)。我们注意到,多个指令通常使用同一对象的相同指向集,但SFS始终假定这些指令各自在计算和维护独立的指向集,从而在传播和存储指向集时浪费了时间和空间。简而言之,如果一个对象o在某条指令处的指向集未发生变化,则可以从另一条指令中重用该指向集(类似于写时复制[2])。我们需要确定在哪些指令处对象o的指向集是相同的。因此,
我们的目标是通过对可能使用o的每条指令中的对象o进行可靠的“版本化”,实现更细粒度的单对象稀疏性,并补充现有的多对象稀疏性,从而使共享o版本的指令能够安全地共享该对象的指向集。
我们引入融合标记(meld labelling),一种用于有向图的预标记扩展方法,以实现对象版本化。融合标记通过传播标签来扩展预标记,使得每个节点的标签是其入边邻居节点标签的“合并”。该合并过程持续进行,直到达到不动点。在每条可能写入对象o的存储指令处,我们为o分配一个不同的标签(即不同版本)。然后,在此预标记基础上执行融合标记(但我们确保预标记的节点永不改变)。结果是,所有具有相同标签(版本)的节点对于o都依赖于
表 I 分析域和类LLVM指令集。
| 分析域 | 类LLVM指令集 |
|---|---|
| ∈ L 指令标签 | ALLOC p= allocˆo |
| x, y, z ∈ S 栈变量 | PHI p= φ(q, r) |
| g ∈ G 全局变量 | MEMPHI o= φ(a, b) |
| p, q, r ∈ P= S ∪ G顶层变量 | CAST p=(t) q oˆ ∈ O 基本抽象对象 字段 p=&q→ fk |
| ˆo.fk ∈ F 抽象字段对象 | LOAD p= ∗q |
| o, a, b ∈ A= O ∪ F被取地址的对象 | STORE ∗p= q v ∈ V= P ∪ A变量 CALL p= q(r1, . . . , rn) |
| SN ⊂ A 单例对象 | FUNENTRY fun(r1, . . . , rn) |
| κ, ε ∈ K 标签/版本 | 函数退出 retf unp |
图1. C代码及其IR和SVFG。
相同的修改集(通过存储)指向o的指向集。如果两条指令依赖于相同的修改集来修改o,则在那两条指令处o的指向集必须相同,因此可以共享。由于对象现在是带版本的,我们可以执行具有更细粒度单对象稀疏性的流敏感指针分析,这比SFS更高效,同时保持精度。
总之,这篇论文描述了:
- 一种新的带版本的对象方法,用于具有更细粒度单对象稀疏性的流敏感指针分析,能够在保持与SFS相同精度的同时提高效率。
- 融合标记,一种用于有向图的快速简单的预标记扩展,我们用它来确定同一对象在不同指令处的等价的指向集。
- 使用15个开源程序进行的评估。与SFS相比,我们的实验表明,我们的方法运行速度快达26.22×倍(平均快5.31×倍),内存使用最多减少5.46×(平均减少2.11×)。
II. 程序表示
本节介绍我们指向分析中使用的分析域、类LLVM指令集以及SVFG。
A. 域和中间表示
遵循先前针对C和C++[3],[10],[11],[13],[16],的指向分析,我们在不失一般性的情况下,基于类似LLVM的[14]指令集进行分析。表I描述了我们分析的目标。
所有变量V的集合由两个集合组成:A= O ∪ F,表示所有可能的抽象对象及其字段,即被取地址的变量;以及P= S ∪ G,表示所有栈和全局指针,即顶层指针。顶层指针是显式的,而被取地址的变量是隐式的,并通过顶层指针在STORE和加载指令中被间接访问。所有指令都标有来自L的标签。SN是表示恰好一个真实对象的所有抽象对象的集合。我们将此类对象称为单例[16]。
K包含我们执行融合标记操作的所有标签或版本的集合,ε是K中的恒等。融合标记在第四节中讨论。
在转换为部分SSA形式后,[24],目标程序由10种类型的指令组成。其中8种构成函数体:ALLOC(p= alloco,在栈上、全局或堆上分配一个对象),PHI(p= φ(q, r),在控制流的汇合点选择顶层指针的值),MEMPHI(o= φ(a, b),在控制流的汇合点选择被取地址对象的值),CAST(p=(t) q,将指针类型转换为另一种类型),FIELD(p=& q → fk,获取指向聚合对象字段的指针),LOAD(p= ∗q,读取对象的值),STORE(∗p= q,写入对象的值),以及CALL(p= q(r1,…, rn),使用指定参数调用函数)。其余两种指令用于连接调用和返回与其目标。每个函数都有一个唯一的FUNENTRY指令(fun(r1,…, rn)),其中包含函数的参数,以及一个FUNEXIT指令(retf unp)。LLVM的UnifyFunctionExitNodes确保所有函数都只有一个FUNEXIT指令。图1展示了一段过程内C代码及其对应的中间表示示例。翻译过程中需要引入一些临时变量(x∗),以将C语句拆分为更简单的指令。箭头和灰色注释将在下一节中讨论。
B. 值流图
需要一种辅助分析来构建SVFG,以便在其上执行流敏感分析。为此,SFS[11]使用了流不敏感包含性指向分析(也称为Andersen风格的指向分析[1])。
Andersen分析已被广泛研究,性能相对较好,并且精度足够高,能够生成可接受的SVFG。SVFG的节点很容易确定,因为它们就是程序的指令。边分为两种类型:直接边和间接边,其中直接边无需借助辅助分析即可推断,而间接边则需要预先计算的指向结果。直接边表示顶层指针的值流(P),并且可以很容易地确定,因为在部分SSA形式中,顶层指针只能通过名称直接访问,不能通过其他指针间接访问。间接边表示被取地址的对象的值流(A),并依赖于辅助分析。
III. 动机示例
图2给出了一个动机示例,用于说明我们方法的核心思想。图2a展示了一个从真实程序的SVFG(来自GNU Coreutils1)中提取的SVFG片段,其中移除了一些无关的边和节点;图2b展示了流敏感分析所需的指向集和传播约束(SFS以及我们的方法)。为了可读性,省略了直接边(它们与我们的目的无关),因此所有边均为间接边,并用对象o进行标签标注。双线框表示STORE节点,可能定义对象(即将其他对象放入其指向集中),其余节点为LOAD节点,可能使用对象(在本例中为o)。标签表示指令标签,便于引用。作为示例,我们假设pt(p)={o}, pt(q)={a}以及pt(r)={b}(根据当前流敏感分析的状态),因此这两个STORE节点可能定义o。根据图2b,o在1,之后且在2和3之前的结果指向集为{a},而在2之后且在4和5之前的结果指向集为{a, b}。C和Y函数以及版本(κ)的使用由我们的方法引入,并在本节后续部分进行描述。
SFS
在SFS中,每个SVFG节点维护一个输入集和一个输出集,分别表示每条指令执行前和执行后对象的指向集。存储指令会更新节点输出集中对象的指向集,以实现向前传播。当一条间接边被标记为对象o时,其指向集从源节点的输出集传播到目标节点的输入集。因此,该指向集
1GNU的true会引入并调用GNU Coreutils套件中通用的函数。因此,尽管看似简单,它仍需要具备过程间指向分析的能力,这与更简单的true实现不同。
某个对象在节点的输入集中的集合是该对象在其入邻居的输出集的所有指向集的并集。我们将o在的输入集中的指向集记为pt|(o),将o在的输出集中的指向集记为pt|(o)。由于顶层指针在部分SSA形式中仅定义一次,因此每个指针只需一个全局指向集(如pt(p))。
应用SFS
在该示例中,SFS需要为每个节点的输入集中的o维护一个指向集,并在两个STORE节点的输出集中也进行维护。这导致了冗余存储和传播,因为其中一些指向集可能是等价的,可以共享,而不是重复存储相同的指向集并重复传播以形成相同的指向集(图2b的第2列显示了SFS在流敏感解析过程中维护的指向集以及所需的约束)。例如,2和3的输入集中o的指向集与1的输出集中o的指向集是等价的,因为它们的输入集仅由1的输出集传播而来。类似地,4和5的输入集也是等价的,因为它们均由1和2的输出集的并集构成,因此pt|4(o) = pt|5(o)。
我们的方法
我们不再从每个节点存储的IN或OUT集中检索对象o的指向集,而是将o拆分为不同的版本,使得对象o的版本κ的指向集(ptκ(o))是全局的,并由多个SVFG节点共享。我们称指令“消耗”了o的版本C(o),并“生成”了o的版本Y(o)。一条指令所消耗的对象版本可用于访问该指令之前对象的指向集,而其生成的版本可用于访问该指令之后对象的指向集。因此,例如,存储指令向o存储时,会操作ptY(o)(o);而加载指令从o读取时,会从ptC(o)(o)读取。流敏感分析在存在边o→−′时,将指向集从ptY(o)(o)传播到ptC′(o)(o),而不是从pt|(o)传播到pt|′(o)。版本仅仅是标签或标识符,其重要性体现在它们之间的相互关系上。通过快速的预分析,我们可以确保以下几点:
C(o) = C′(o) ⇒ pt|(o) = pt|′(o) (1)
C(o) = Y′(o) ⇒ pt|(o) = pt′|(o) (2)
Y(o) = Y′(o) ⇒ pt|(o) = pt′|(o) (3)
结果是,对于每个对象,节点将具有一个被消耗的版本和一个被产生的版本,可用于访问对象的指向集,而不是通过IN和OUT集合进行访问(我们完全摒弃了这种方式)。重要的是,访问同一对象版本的节点可以共享该对象的指向集。
应用我们的方法
图2a中展示了我们示例中节点的被消耗和产生的版本。o在2和3的输入集中的指向集是通过1的输出集中o的指向集传播而生成的,因此它们是等价的,所以C2(o) = C3(o) = Y1(o) = κ1,以及4和5具有类似的关系(κ1κ2是与κ1和κ2不同的另一个版本,且操作符是如第IV‐B节所述。由于多个节点可能共享版本,因此我们可以存储更少的指向集。同时,由于需要生成的指向集减少,我们产生的传播约束也更少。在图2b中展示了所需指向集数量和生成的传播约束数量的改进情况,其中我们方法的相关数据列于第3列。我们不再存储6个指向集,而是仅存储3个,并将生成的传播约束数量从6个减少到2个。因此,我们的方法通过存储更少的指向集节省了空间,并通过减少传播次数节省了时间。
IV. 方法
本节首先阐述现有流敏感指向分析及我们分析的基本思想。然后,我们详细说明我们方法的三个主要部分,即融合标记、利用融合标记对对象进行版本化,以及使用版本化对象执行更细粒度的稀疏分析。
我们频繁使用各种符号表示,其中一些在本节中引入。以下列出了一些较常见的符号表示,以便参考。
pt(p) p的指向集。ptκ(o) o的版本κ的指向集。κ的指向集。pt|(o)指令之前立即执行时o的指向集。pt|(o)指令之后立即执行时o的指向集。pta(o)根据辅助分析得到的o的指向集。δ()返回指令是否为SVFG中的δ节点。κ1κ2版本κ1和κ2的合并。C(o)指令所消耗的o的版本。Y(o)指令所生成的o的版本。
A. 上下文敏感的指向分析
传统的基于数据流的流敏感指针分析通过求解迭代数据流问题,计算最大不动点解,以作为所有路径交汇问题的上近似。该分析在过程间控制流图(ICFG)上进行,通过维护和计算每个语句之前(pt|(v))和之后(pt|(v))每一变量v的指向集来实现。我们用IN和OUT表示位于|和|处的所有指向集。
对于ICFG中的每个节点,流敏感分析会迭代计算以下内容,直到达到固定点:
IN = ⋃
′ ∈ pred()
OUT′ (4)
OUT = Gen ∪(IN − Kill) (5)
其中pred()表示ICFG中的前驱节点,而Gen和Kill表示在分析语句后生成和消除的指向关系。
分阶段流敏感分析(SFS)[11]是传统基于数据流分析的一种优化版本。SFS并非在ICFG上求解数据流事实,而是在定义‐使用图或稀疏值流图(SVFG)[11],[23]上进行操作,该图保守地捕获了程序的定义‐使用链。具体而言,一条边→−v′,,其中v∈V,从语句指向语句′,表示一个潜在的针对v的定义‐使用链,其定义位于,使用位于′。这种表示是稀疏的,因为和′之间的中间程序点被省略了。借助SVFG,SFS可以使用以下方程执行指向分析,
INv = ⋃
′∈spred(,v)
OUTv′ (6)
OUTv = Genv ∪(INv − Killv) (7)
其中spred(,v)表示在SVFG中变量v∈V到具有值流的指令集合。每个数据流集合(例如INv和OUTv)都由变量v在处定义或使用的位置进行限定。与在ICFG上的传统流敏感分析相比,SFS通过在SVFG上计算和维护指向集,能够分离指向集的传播,从而实现多对象稀疏性。
我们的方法旨在减少每个v的INv和OUTv数量,使得每个v的版本κ仅计算并全局维护一个指向解(注意,这里我们只关心抽象内存对象的版本,而不关心仅定义一次的顶层指针)。例如,ptκ(o)(即版本为κ的对象o的指向集)是全局存储的,而不是在每个程序点存储。该方法通过基于meld‐标签的对象版本化,避免了单个对象内的冗余指向传播(单对象稀疏性)。
因此,我们有,
INv:κ = ⋃
′ ∈spred(,v)
OUTv:κ′′ (8)
OUTv:κ′ = Genv ∪(INv:κ − Killv) (9)
其中OUTvκ:表示检索全局维护的具有版本κ=Y(v)的对象v的指向集,INvκ:和κ=C(v)同理。
B. 融合标记
融合标记是一种在有向图上的预标记扩展,其中每个节点都被标记为其输入边源端标签的“合并”结果。鉴于标签域为K,为了实现融合标记,我们定义融合算子:K2 → K。该融合算子可以是任何关于K满足交换性、结合性、幂等性并具有单位元的运算。换句话说,给定κ1、κ2、κ3、ε∈K且ε为单位元,
κ1κ2 = κ2κ1 (Commutativity)
κ1(κ2κ3) = (κ1κ2)κ3 (Associativity)
κ1κ1 = κ1 (Idempotence)
κ1ε = κ1 (Identity)
集合并运算符∪以及许多编程语言中常见的按位或运算符,在标签表示为集合或位集时,是合适的融合算子示例。
初始图根据融合标记的目的选择的某个条件,用K中的标签对节点进行预标记,但ε除外。不属于该预标记子集的节点则被赋予恒等ε标签。融合标记过程很简单:重复将每个节点的标签与其入邻居的标签进行融合,直到所有应被标记的节点都被标记(即达到不动点)。这一点在图3的[MELD]M规则中得到了例证,其中n和n′是节点,κn和κn′分别是n和n′的标签。
预标记的节点以及可通过任何预标记节点通过传递可达的节点,在融合标记过程结束时将被赋予某个非‐ε标签。所有其他节点将以ε作为标签结束。最终结果是,节点根据其通过传递可达的预标签的合并情况被划分为不同的等价类。那些以ε结束的节点属于它们自己的类:即无法通过任何预标记节点到达的节点。
图4展示了一个预标记图的示例及其在融合标记后的状态。在此实例中,标签域K由模式组成,具体为K={| |,||,||,||,||,||,||,||}其中是恒等元素。节点被预标记为、和,其余节点则标记为恒等元素。融合算子用于合并这些模式。根据融合算子的以下情况子集(尽管其他子集也足够),并已知是交换的、结合的且幂等的,且是恒等元素,可推导出所有情况。
= = = =
在图4中,尽管节点5和8(以及类似地,节点4和7)具有不同的入邻居,但它们最终完成时的相同的标签,因为它们的入邻居标签的合并结果相同。因此,节点上标签的等价性并非源于共享入邻居,而是源于共享到达它们的标签集合(来自预标记)。
1) 复杂度
在最坏情况下,融合标记需要O(|E|P)时间,其中P是使用非恒等标签预标记的节点数量,E是边的集合。这是因为图中已有的每个标签都可能需要沿着每条边传播(无论是作为合并操作的一部分还是单独传播)。在空间方面,它始终需要O(|N|)空间,其中N是节点集合,因为每个节点都需要存储一个标签。
C. 使用融合标记进行对象版本化
SFS在指令之间传播指向集,使得在每条指令处都有一个(或两个)指向集,用于表示该指令可能使用或定义的每个对象。然而,如果两条指令依赖于对某个对象的指向集进行完全相同的修改,则它们可以共享所使用的指向集。在这种情况下,我们说这两条指令(即SVFG节点)消耗了该对象的同一版本。与消耗对象版本相对应的是,我们说一条指令生成的版本即是该指令所定义的对象的版本(如果它不定义对象,则生成它所消耗的版本)。
一个对象的版本表示该对象指向集的一种状态,对象指向集的任何变化都需要产生一个新版本。在某一程序点,对象o的指向集可以通过两种方式发生变化:(1)通过一条STORE指令∗p=q,当p指向o时;(2)通过在特定指令之前,在不同程序点处o的指向集进行合并(即,当某个对象在输入集中的指向集是多个输出集中对应指向集的并集时)。所有指令仅消费一个对象的一个版本,并生成一个版本。确定一个对象的各个版本、每条指令可能消费的版本以及可能生成的版本,都需要指向信息;由于流敏感的指向信息显然在流敏感分析完成之前是不可用的,因此我们使用辅助分析的指针信息(在本例中为Andersen分析)。这可能会导致我们得到比实际所需更多的版本,因为在使用更精确的指向信息进行版本化时,其中两个版本可能可以合并为单个版本(根据流敏感分析,这两个版本具有等价的指向集),但这种过度近似是安全的,并且性能仍然良好,如第五节中评估所示。
我们为每条指令赋予一个C(用于消耗)函数,定义为,
定义1: C: A → K其中C(o)是o所消耗的版本。
以及一个Y(用于yield)的函数,定义为,
定义2: Y: A → K其中Y(o)是o所生成的版本。
总体而言,对象版本化允许多个指令在依赖的情况下访问相同的o指向集通过对o进行相同的修改(通过存储和控制流合并),我们可以通过放弃IN和OUT集合来节省传播和存储指向集所需的时间和空间,因为许多指令可能会消费/产生同一对象的相同版本(该版本由单个指向集表示)。
上一节讨论的融合标记根据其所扩展的预标记对节点之间的依赖关系进行编码,使得如果节点具有相同的标签,则它们依赖于相同的预标记节点。通过标记修改对象指向集的节点(存在一些注意事项),我们可以使用融合标记来版本化对象。由于基于包含的指向分析所使用的集合并运算符满足融合算子的要求,因此融合标记可被视为利用标签/版本表示指向集、并依赖辅助分析提供的不精确指向信息而非流敏感信息,对实际指向分析的传播过程进行的一种轻量级模拟。第一步是恰当地描述我们希望进行融合标记的图,并对其进行预标记。在用于带版本分阶段流敏感指针分析(VSFS)的融合标记上下文中,我们的分析中,术语“标签”和“版本”是同义的。
1) 预标记
在任意给定节点,可能会使用或定义多个对象。因此,我们的目标不是在每个节点上生成单个版本,而是在每个节点上为每个(关注的)对象生成一个版本。此外,我们希望在每个节点上为每个对象生成两个版本:一个用于消费,一个用于产生,因为某些节点可能不会传播(产生)与其使用(消费)相同的版本。由于我们唯一可用的指向信息来自不精确的辅助分析结果,因此我们的目标是进行版本化,以表示流敏感分析的最坏情况(不现实地不会比辅助分析更精确)。
在执行过程中,假设一条STORE指令可能指向o,它可能会向前传播一个与传入的不同的指向集o,因为它可能会修改o的指向集。一条STORE指令∗p=q是否修改一个对象的指向集,取决于p是否指向o,以及q的指向集是否包含o的指向集中不存在的元素。在分析之前,这些信息对我们来说是不可知的,因此我们保守地假设,在流敏感求解过程中,如果STORE指令修改了其所指向对象的指向集,则其生成的版本与所消耗的版本不同。产生多余的版本是安全的,就目前而言,可以认为SFS在每个节点的IN和OUT中,对每个对象都有一个唯一的被消耗和被产生的版本。由于STORE指令生成的版本是一个新版本,不一定依赖于任何其他版本,因此预标记应在STORE节点处进行。对于每条STORE指令,,我们需要将其生成的版本作为预标签提供(即设置Y(o))。
STORE节点被赋予预标签(κ1和κ2),以产生o,所有其他被消耗或产生的版本均设置为恒等。
如果我们使用(Andersen的)辅助分析的结果来执行调用图解析[11], ,这就足够了。
然而,我们使用流敏感指针分析的结果本身进行即时调用图解析,这种方法更加精确且性能更优,因此某些节点缺少传入的边,这可能会影响版本化。换句话说,版本之间的某些依赖关系(例如C(o)是其自身与某个Y′(o)的合并)直到执行流敏感分析时才能确定。为了弥补这一点,任何此类节点(称为δnode)都会为它可能向前传播的每个对象消耗一个唯一的版本(来自预标记)。根据辅助分析,这些节点包括所有可能成为间接调用目标的函数入口指令以及所有执行间接调用的调用指令(即间接调用的返回目标)。这是一种安全的过度近似,因为在实际情况下,我们要么需要引入一个新版本(我们已经这样做),要么重用一个版本(这会提升性能),而版本重用只有在具备流敏感指向信息时才可能实现。
为方便起见,我们定义一个δ函数来对此进行编码,即定义3:δ:L → B,使得
δ() = true ⇔ ∃′ ∈ L. ∃o ∈ O. ′ o →− ∧ P(′ o →− )
其中P(′ o →− )表示由于流敏感分析过程中进行的即时调用图解析,可能导致边′ o →− 。
其他每个节点的被消耗和被产生的版本均设置为恒等ε。预标记的速度非常快,其耗时可以忽略不计,因为它仅对SVFG进行线性扫描,并为相对较少的节点将C或Y设置为新版本。图6中的推理规则展示了在所有C和Y已被设为恒等ε的图上执行此操作的过程。[STORE]P规则确保存储指令为其可能定义的每个对象生成一个新版本(由辅助分析确定),而[OTF‐CG]P规则确保δ节点同样为它们最终可能产生的每个对象消耗一个新版本(如果需要的话)。
为o返回一个新版本,而pta(p)是根据辅助分析得到的p的指向集。)
2) 使用融合标记进行版本化
此时,我们已得到一个SVFG,其中被消耗和被产生的版本仅设置在少量节点(预标签)上。在对已预标记的SVFG进行融合标记时,我们需要考虑到边是用被取地址的对象进行标记的。我们只需沿着标有被取地址的对象的边传播对象的版本
[STORE]P : ∗p= q o ∈ pta(p) ε= Y(o) Y(o)= nv(o)
[OTF-CG]P δ() →−o ′ ε= C(o) C(o)= nv(o)
因为如果不存在从 →−o ′到′的边,则′不会受到在处对o的任何定义的影响。
例2 :在图7中,κ1= Y1(a)仅沿着带有a标签的边传播到2。类似地,3和b也发生相同情况。
每个节点可能具有每个对象的两个版本(被消耗的版本和被产生的版本),这一点也需要考虑。因此,我们通过引入节点“内部”和“外部”的传播来进行融合标记传播。当某个节点生成其所消耗的内容时,就会发生内部传播,这适用于所有非STORE节点,因为其他类型的节点永远不会为对象o传播与其接收到的不同指向集。在这种情况下,对于这样的节点, Y(o) = C(o)对所有o ∈ A成立。当存在边o →− ′时,我们执行外部传播。在这种情况下,我们将Y(o)融合到C′(o)中(即′消耗所产生的内容,并且也可能消耗其他版本),但当δ(′)时例外,因为此时C′(o)将是预标签,而预标签不会被更改。我们明确避免修改预标记阶段中设定的内容,因为在那些位置上唯一版本是特别选定的,我们需要保持这一点。更正式地说,图8中的推理规则将为每个节点提供在该节点处使用的对象的被消耗版本和被产生的版本。
[EXTERNAL] V o →− ′ ¬δ(′) C′(o) = C′(o) Y(o)
[INTERNAL] V ¬: ∗p= Y(o) = C(o)
[EXTERNAL]V规则将节点的入邻居所生成的版本进行传播,并将其与该节点的被消耗版本进行融合。此规则类似于第IV‐B节中融合标记原始定义中的[MELD]M。它排除了δ节点,因为这些节点在预标记阶段已被设置了相关的被消耗版本。[INTERNAL]V规则确保任何生成了什么的节点
它消耗的(即非STORE节点)将其被产生的版本设置为被消耗的版本。 例3 :在图9中,我们再次回顾了来自图2的动机示例,该示例已在例2中进行了预标记。o的版本从Y1(o)外部传播到C2(o)、C3(o)、C4(o)和C5(o),并从Y2(o)传播到C4(o)和C5(o)。当多个版本被传播到另一个节点时,会发生合并,如在节点4和5(κ1κ2)处o的被消耗版本所示。最后,在3, 4,和5处发生内部传播,因为它们不是STORE节点,并且会产出其所消耗的内容。例如,Y3(o) = C3(o)。
D. 使用对象版本控制的流敏感指针分析
此时,每个可能访问取地址对象的指向集的指令都会为该对象标注两个版本:它消耗的版本和它生成的版本。我们使用这些版本来选择针对每个关注对象应访问的指向集,而不是从IN/OUT集中访问对象的指向集,以避免在每个程序点维护冗余的指向集。图10中的推理规则修改了SFS[11],使其使用版本而非IN/OUT集。我们使用符号表示ptκ(o)来引用o的κ版本的指向集,并将我们新提出的流敏感指针分析方法称为带版本的分阶段流敏感指针分析(VSFS)。
[LOAD]F和[STORE]F规则与其原始对应规则完全相同,区别在于它们不是从输入集或输出集中访问o的指向集,而是分别使用在,处o的指向集的被消耗版本和产生的版本。对于加载指令p=∗q,[LOAD]F规则将q所指向对象的指向集(被消耗版本)添加到p的指向集中;对于存储指令∗p=q,[STORE]F规则将q的指向集添加到p所指向对象的指向集(产生的版本)中。[SU/WU]F规则将对象在处的被消耗版本的指向集传播到同一指令处对象的产生版本的指向集,而不是从的输入集传播到输出集。它通过与kill函数以与标准方法相同的方式交互来执行强更新;如果一个对象是
单元素,则该对象指向集的被消耗版本会被kill函数清除,且不会传播到该对象的产生版本。最后,[A‐PROP]F规则在节点之间传播指向集。对于′ →−o ,,不是从 ′的输入集或输出集(取决于′是指令类型)传播到 ,的输入集以处理 o,而是将 ′处被产生的 o版本的指向集包含进 处被消耗的 o版本的指向集中。由于许多节点可能消耗并产生相同的版本,因此相较于SFS,传播发生的频率大大降低,并且通过存储更少的指向集节省了空间。[调用]F和[RET]F规则分别将实际参数的值复制给形式参数,以及将返回值复制给指针。使用指针 q作为被调用函数,可实现即时调用图解析。如灰色部分所示,如果指令标注有 χ/μ,则在边尚不存在时生成新的边。
分析的其余部分与SFS的工作方式完全相同。[ADDR]F规则将新分配的对象插入到左侧指针的指向集中。[φ]F和[CAST]F规则将右侧指针或多个指针的指向集添加到左侧指针的指向集中。[FIELD*]F规则通过将字段对象(另一个对象中的偏移量)插入到左侧指针的指向集中,从而为分析提供字段敏感性。我们使用[FIELD‐ADD]F规则以避免从字段对象创建新的字段对象,因为使用像ˆo.fi+j这样的对象比像ˆo.fi.fj这样的对象更易于推理和实现分析。
E. 正确性
本节讨论我们的方法如何产生与SFS相同的结果。直观上,我们希望每个 o的编号对应的全局指向集小于 IN/OUT集中的 o的指向集(或在理论上不切实际的最坏情况下编号相同)。当我们的方法能够合理地确定多个指向集在SFS中等价时,会将其视为一个指向集。
在预标记阶段引入的对象版本数量是我们将为该对象存储的最小指向集数量,因此我们尽量减少引入的版本数。对于每个导致yields的STORE,我们引入一个新版本,因为我们无法确定在没有流敏感分析的情况下,STORE处被消耗的对象版本与其产生的版本是否具有相同的指向集。换句话说,我们不知道STORE指令实际上会做什么。此外,为了正确处理即时调用图构建,我们还为 δ nodes引入一个新版本以供consumed。这同样是因为在执行流敏感分析之前,我们不知道 δ nodes是否会收到新的间接边(以及来自何处)。
然后在融合标记阶段,我们在节点之间和内部传播版本。融合算子专门设计用于模拟基于包含的指向分析中使用的集合并运算符(约束 pt(p) ⊆ pt(q) 转换为 pt(q) = pt(q) ∪ pt(p))。与融合算子类似,∪ 是交换的({o1} ∪ {o2} = {o2} ∪ {o1} = {o1, o2})、结合的(({o1} ∪ {o2}) ∪ {o3} = {o1} ∪ ({o2} ∪ {o3}) = {o1, o2, o3})、幂等的({o} ∪ {o} = {o}),并且具有恒等元,即空集 ∅({o} ∪ ∅ = {o})。因此,当我们执行融合标记中,我们执行了一种极轻量级的IN/OUT集传播SFS版本,使用版本来表示指向集,并依赖辅助分析提供的信息。因此,我们不再在内部从OUT集合向IN集合、再从IN集合向OUT集合传播对象的指向集,而是改为在内部将产生的版本传播给被消耗的版本,反之亦然。与预标记阶段类似,我们可能会引入多余版本。例如,如果 被消耗的版本为κ1 κ2, pt|(o),实际上可能由至少两个指向集的并集构成。然而,若 κ2所表示的指向集是 κ1所表示的指向集的子集,则 κ1可能足以供 消耗。但在流敏感分析完成前我们无法确定这一点,因此为保证安全性,我们按最坏情况假设。
由于我们的预分析在需要流敏感指针分析结果时采取最坏情况假设(悲观的预标记和多余版本),它可靠地确保我们的分析仅在多个指向集在SFS中本应相同时才将其视为一个,因此产生的结果与SFS相同(公式1、2和3)。
V. 评估
本节描述了我们对所提出方法(VSFS)与SFS在性能和内存使用方面的比较实验。开源指针分析框架SVF[23]包含了原始论文[11],第五节中所述的SFS实现,我们在同一框架下实现了新的版本化分析以进行对比。用于构建SVFG的辅助分析是通过波传播(wavepropagation)增强的Andersen分析[19]。在版本化阶段,我们使用LLVM的SparseBitVector数据结构及其上的按位操作符,分别用于标签/版本和融合算子。在版本化阶段,作为一项优化,类似于SFS,如果一个节点消耗其自身生成的内容,则仅存储一次该版本,而不显式执行[内部]V规则。为了降低分析成本,在版本化阶段之后,我们使用无符号整数来表示版本,将等效的SparseBitVector转换为相同的整数,并显式地存储所有被消耗和被产生的版本以简化处理(尽管这会带来轻微的时间和内存开销)。
我们使用15个开源程序作为基准测试来评估和比较我们的分析,如表II所列。这些基准测试中,ninja、astyle和hyriseConsole是用C++编写的,其余的是用C编写的。我们通过整个程序LLVM(WLLVM)2使用Clang 10(O3标志)编译这些基准测试,以生成位码文件。对于每个基准测试,表II中列出的代码行数(LOC)是通过统计LLVM调试信息中列出的每个源文件的代码行数得到的(不包括系统头文件)。最后,我们去除调试信息以更清楚地了解位码大小。
我们在每个基准测试上分别运行Andersen分析(也由SVF提供)、SFS和VSFS各5次,并在表III中记录以秒为单位的平均运行时间和以千兆字节为单位的内存使用量(最大驻留集大小)。每次运行最多给予12小时完成时间(没有基准测试耗尽该时间)和120GB内存(lynx在此内存限制下无法被SFS分析)。对于SFS和VSFS,我们省略了所需的辅助分析、内存SSA构建和SVFG构建的运行时间,仅显示分析主阶段所用的时间(对于VSFS还包括版本生成时间)。内存使用统计数据未作此区分,包含所有内容。时间使用C的clock函数测量,内存使用GNU的time程序测量。底部一行的平均值使用几何平均数计算,不存在的数据(特别是SFS对lynx的运行时间)被忽略。所有实验均
表II 用于评估VSFS的基准测试。
对于每个基准测试,我们列出了代码行数(LOC)、在进行O3优化并去除调试信息后的比特码大小(KIB)、SVFG中的节点数量、直接边(D.EDGE)和间接边(I.EDGE)数量、顶层变量和被取地址的变量数量,以及简要描述。
| 基准测试 | LOC | Size(KiB) | SVFG节点数量 | 直接边数量 | 间接边数量 | TOP‐LEVEL | ADDRESS‐TAKEN | 描述 |
|---|---|---|---|---|---|---|---|---|
| du | 27704 | 376 | 27192 | 16229 | 367638 | 35314 | 1705 | 磁盘使用量(GNU) |
| ninja | 8702 | 576 | 46776 | 31594 | 392071 | 46075 | 2681 | 构建系统 |
| bake | 20548 | 580 | 78426 | 34038 | 1833144 | 40668 | 3097 | 构建系统 |
| dpkg | 21934 | 612 | 77793 | 32117 | 379719 | 38843 | 2884 | 包管理器 |
| nano | 27564 | 828 | 84637 | 42463 | 1911821 | 72663 | 2637 | 文本编辑器 |
| i3 | 22895 | 1016 | 100009 | 54532 | 268501 | 67716 | 3237 | 窗口管理器 |
| psql | 47444 | 1120 | 92421 | 62659 | 495139 | 67310 | 3513 | PostgreSQL前端 |
| janet | 56500 | 1172 | 128838 | 54625 | 2491254 | 99753 | 3154 | Janet编译器 |
| astyle | 16715 | 1560 | 156322 | 86563 | 8616092 | 125992 | 8712 | 代码格式化工具 |
| tmux | 48205 | 1656 | 154683 | 101518 | 8302942 | 123889 | 6009 | 终端多路复用器 |
| mruby | 50807 | 1732 | 118992 | 101613 | 1145376 | 149346 | 2381 | Ruby解释器 |
| mutt | 64046 | 1820 | 297971 | 106211 | 11044405 | 147712 | 6939 | 终端邮件客户端 |
| bash | 102319 | 2200 | 309236 | 100660 | 29697360 | 187307 | 6217 | UNIX shell |
| lynx | 138182 | 3800 | 554940 | 172969 | 35091128 | 232829 | 7802 | 终端网页浏览器 |
| hyriseConsole | 37300 | 9524 | 627834 | 437230 | 6377348 | 607274 | 37339 | Hyrise DB 前端 |
在一台配备2.60 GHz的Intel Xeon Gold 6132处理器和128 GB内存的64位Ubuntu 18.04.2 LTS系统上进行。
总体而言,我们发现由于VSFS在时间和内存方面均有所改进,并且VSFS针对了IN和OUT集合的过度传播和存储问题,因此SFS的相当一部分开销都花费在了这些集合的传播和存储上。
A. 时间
我们发现,我们的分析性能始终优于SFS。版本控制过程的开销始终很低。对于VSFS较容易分析的基准测试,版本生成时间可能占总运行时间的较大比例(例如mruby和bake),但在我们的测试中,这从未导致整体分析速度慢于SFS。随着基准测试的分析时间变长,版本生成时间变得越来越微不足道。例如,VSFS的主阶段分析lynx几乎耗时三个半小时,而版本化操作仅需不到一分钟。
像bake(最极端的情况,性能提升达a26.22×)、astyle、hyriseConsole、ninja和janet等基准测试从减少的传播需求中获益显著,且版本化时间可以忽略不计。dpkg和bash的加速效果较小,分别为1.74×和1.46×。像dpkg这样的程序实际上并不是我们分析的主要目标,因为它们可以很容易地被SFS分析;而bash仍有一些改进,且没有出现性能退化。其余的基准测试获得了2.43×到6.27×范围内的加速比。性能提升的几何平均数为5.31×,我们认为这表明了成功,特别是考虑到该数据包含了未显示显著性能提升的基准测试(dpkg和bash)。如果我们排除所有SFS分析耗时少于30秒的基准测试(这些并非本工作的目标),即du、dpkg、i3、psql和mruby,则得到的几何平均加速比为6.74×。lynx的时间数据缺失,因为它在分析的早期阶段(似乎是)耗尽了内存。
B. 内存使用
我们发现,与SFS相比,我们的分析方法在除dpkg、i3和mruby之外的所有基准测试中均改善了内存使用,在这些情况下,其内存使用量与SFS大致相同。在这些实例中,分析规模较小,因此版本化带来的较低内存开销占总内存使用的相当大比例。另一方面,SFS在分析lynx时耗尽了内存,意味着其需要超过120GB的内存,而VSFS使用的内存少于22GB。在最佳情况下,对于我们的基准测试,我们的方法将内存减少了5.46×或更多,并在分析lynx、bash和astyle时提供了显著改进。总体而言,我们的分析实现了至少2.11×的(几何)平均改进,意味着VSFS的内存使用量平均仅为SFS的一半,同时性能也得到提升。
由版本化带来的内存开销增长速度远慢于主阶段分析,与时间开销相似。我们相信,通过设计专门针对版本化的数据结构,而非使用现成的(LLVM的SparseBitVector)数据结构,或许可以进一步降低开销,后者可能采用完全不同的融合算子。
VI. 相关工作
为了提高性能,流敏感的指向分析的稀疏性随着时间的推移得到了改进。早期的方法[4],[12],[16]侧重于通过删除与分析无关的节点来减少ICFG,而不是引入新的数据结构来进行分析。哈德柯普和林[10]提出了一种半稀疏分析方法,该方法利用部分SSA形式[24]对顶层指针进行稀疏分析,同时对被取地址的对象仍以之前的方式进行分析。
表III Andersen分析、SFS(仅主阶段时间)和VSFS的运行时间(秒)和内存使用量(最大常驻集大小,千兆字节)。
VSFS的时间统计分为三部分:版本化对象的时间、使用版本进行分析的时间,以及这两段时间的总和(用于比较)。最后两列表示我们的方法相比SFS快(或慢)多少倍,以及我们的方法相比SFS内存使用量的减少(或增加)。OOM表示基准测试因耗尽内存资源而无法完成。
| 基准测试 | Andersen的 Time | SFS Time | SFS Mem. | VSFS Time | VSFS Mem. | 版本化 | 主阶段 | 总时间 | Mem. | 时间差异 | Mem. 差异 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| du | 1.15 | 21.43 | 2.24 | 0.27 | 3.94 | 4.20 | 1.52 | 5.10× | 1.48× | ||
| ninja | 1.15 | 60.97 | 2.37 | 0.24 | 4.01 | 4.25 | 1.47 | 14.35× | 1.61× | ||
| bake | 1.06 | 60.90 | 3.12 | 0.92 | 1.40 | 2.32 | 1.69 | 26.22× | 1.84× | ||
| dpkg | 0.60 | 3.34 | 1.41 | 0.37 | 1.55 | 1.92 | 1.41 | 1.74× | 1.00× | ||
| nano | 2.91 | 74.90 | 5.59 | 1.57 | 16.69 | 18.26 | 2.16 | 4.10× | 2.58× | ||
| i3 | 1.14 | 3.28 | 1.55 | 0.32 | 0.81 | 1.13 | 1.52 | 2.90× | 1.02× | ||
| psql | 0.99 | 8.04 | 1.82 | 0.38 | 0.90 | 1.28 | 1.56 | 6.27× | 1.17× | ||
| janet | 2.93 | 116.76 | 7.09 | 1.35 | 8.32 | 9.67 | 2.34 | 12.07× | 3.03× | ||
| astyle | 20.18 | 12437.38 | 85.41 | 7.76 | 1199.55 | 1207.31 | 19.59 | 10.30× | 4.36× | ||
| tmux | 22.78 | 483.14 | 12.75 | 14.50 | 167.25 | 181.75 | 7.79 | 2.66× | 1.64× | ||
| mruby | 7.65 | 16.78 | 2.75 | 1.97 | 3.45 | 5.41 | 2.62 | 3.10× | 1.05× | ||
| mutt | 13.74 | 981.53 | 26.59 | 15.84 | 388.72 | 404.56 | 8.13 | 2.43× | 3.27× | ||
| bash | 19.54 | 2160.97 | 77.70 | 24.50 | 1458.23 | 1482.73 | 15.44 | 1.46× | 5.03× | ||
| lynx | 57.18 | OOM | OOM | 58.80 | 11947.68 | 12006.48 | 21.96 | – | ≥5.46× | ||
| hyriseConsole | 18.18 | 701.93 | 18.42 | 4.21 | 39.95 | 44.16 | 6.85 | 15.89× | 2.69× | ||
| 平均 | 5.31× | ≥2.11× |
最近的一些研究采用了分阶段分析的思想,例如对顶层变量执行稀疏的定义‐使用分析,或通过对所有变量构建内存SSA形式来执行稀疏分析。这是一种多对象稀疏性的形式,因为所有对象的指向集不再基于控制流一起传播,而是根据稀疏值流以对象到对象的方式进行传播。我们在该工作的基础上进一步改进单对象稀疏性,即在一个对象的指向集向不同程序点传播过程中的稀疏性。我们的方法高效且有效地确定单个对象在不同程序点的重复指向集,并将其视为一个集合。哈德柯普和林在其关于半稀疏分析的研究中也引入了“指向图等价”的概念,然而,尽管他们的方法与我们的相似,但在精度上有所不足,因为他们保守地确定整个IN和OUT,无法像我们的单对象稀疏性那样充分实现对象指向集的合并机会。他们的预分析是在稀疏求值图上进行的,而我们的预分析则在SVFG上进行。我们尚未将本方法与此优化进行比较,因为我们不知道该优化的现代实现。
在他们的工作第3.4节中,[16], Lhot´ak和Chung在ICFG上稀疏地分配指令标签,使得在分析过程中可以确定被取地址对象的指向集不会发生变化的地方(非STORE指令和控制流的非合并点)跳过传播。我们的工作有所不同:他们在ICFG上执行分析,且其稀疏标签分配并非基于每个对象,而是在每次标签分配时针对所有对象。此外,他们的方法总是为不同的合并点分配独立的标签,而我们的方法有时能够判断出合并点产生的指向集正在被合并后可以重用。它们的标签分配比我们的版本化更快,但我们的版本化更有效(且仍然具有良好的性能),这对于较大的程序至关重要,因为约束求解的增长速度远快于标签分配或版本化。
我们的分析是离线变量替换[20]的一个实例,因为在主阶段指针分析之前,我们先将多个等价变量(在本例中为变量/位置对)进行合并。变量替换及类似技术此前已成功应用[9],[10],[16],[22]。
第七节 结论
本文提出了一种基于对象版本化的流敏感指向分析方法,相较于最先进的分阶段流敏感指向分析(SFS),在时间和空间上均有改进。我们使用一种预标记扩展方法——meld标记,对对象进行版本化,使得SVFG节点在许多情况下可以共享同一对象的指向集。相比SFS,我们实现了更细粒度的单对象稀疏性,获得了平均5.31×倍(最高达26.22×倍)的平均加速比,以及超过2.11×倍(最高达5.46×倍)的平均内存减少。

被折叠的 条评论
为什么被折叠?



