int x;This declaration allocates a region of memory starting at some address
addr. Assuming that the size of an integer is four bytes on our machine, the region spans [addr, addr+4), i.e., the four bytes at addr, addr+1, addr+2, and addr+3. The address of x (&x) points to the beginning of this region, i.e., &x and addr are the same value. Such a region of memory is called an object.
Similarly, a dynamic allocation using the new construct:
p = new X;allocates a new memory object spanning
[addr, addr+sizeof(X)) and returns a pointer p to the beginning of this region, i.e., p and addr are the same value.
An object need not be a contiguous region of memory. e.g., if X is a compound type (a class or a struct), p may point to the root node of a data structure that itself contains pointers to other regions; collectively, all these regions may be considered a single object. Thus, an object is a region, or a set of regions, of memory.
x represent an object of four bytes spanning [addr, addr+4), and let p point to a single byte within this region, say addr+1:
int x; char *p = (char *)&x + 1;Here,
&x points to the object, and the object itself is x, which represents a region of four bytes. Similarly, p points to an object, and the object itself is *p, which represents a region of one byte (the size is determined by the type of p).
In this example, *p and x alias, because the regions of memory represented by them overlap (i.e., their intersection is non-empty). This definition applies to both contiguous and non-contiguous objects.
Aliasing: Two or more names for the same region of memory. In the example above, the byte at addr+1 has two names: one is derived from the variable x (e.g., *((char *)&x + 1)), and the other is *p. *p and x alias iff the memory regions named by them overlap.
Terminology subtlety: we talk about aliasing of objects, not pointers. If pointers p1 and p2 point to the same object, we say that *p1 and *p2 alias; we do not say that p1 and p2 alias. Similarly, in the example above, we say that *p aliases with x; we do not say that p aliases with &x. Notice that p1 is just an address, whereas *p1 represents a region of memory: it encodes both the start address and the size of the region (determined by the type of p1), and, for a non-contiguous object, all the regions that constitute that object. Two objects alias if their regions overlap. It is a common mistake to talk about aliasing of pointers; whenever we say aliasing, we are talking about aliasing of objects.
x = new X; x.f = 42; y = x.f; assert(y == 42);A standard dataflow analysis can confirm that the assertion always holds, i.e.,
y is always equal to 42.
Now consider another program where p is computed through some complicated function of x (or of &x), such that *p may alias with *x:
x = new X; p = ...; // some complicated computation x.f = 42; p.f = 0; y = x.f; assert(y == 42); // ?Is it okay to assert
y == 42? No: if *p aliases with *x, then y is 0; otherwise, y is 42. We say that p.f and x.f may alias. Unless the compiler can establish that *p and *x do not alias, it must make the conservative assumption that they may alias. e.g., while constant propagation can replace all subsequent uses of y with 42 in the first program, it cannot do so in the second program.
Thus, because of aliasing, many compiler analyses that would otherwise be straightforward become more complicated. For this reason, it is important for a compiler to perform alias analysis to identify, in a best-effort way, as many non-aliasing relationships as possible. e.g., a robust compiler should be able to determine that *p never aliases with *x, if that is indeed true for the program. This falls under the umbrella of pointer analysis in a compiler.
Now consider a third program, where p is directly assigned x:
x = new X; p = x; x.f = 42; p.f = 0; y = x.f; assert(y == 0);Is it okay to assert
y == 0? Yes: here, we are sure that p and x point to the same object, i.e., *p and *x definitely alias (in fact, they fully overlap and represent the same object). Thus, the write to p.f is also a write to x.f, and reading x.f into y is guaranteed to yield 0. For the compiler to reason that y == 0 (e.g., for constant propagation), it needs to know that p.f and x.f definitely alias. We say that p.f and x.f must alias.
*p and *x) potentially alias?Soundness:
*x and *y clearly do not alias, because the language semantics ensure that the two allocations do not overlap:
x = new X; y = new X;But an imprecise may-alias analysis may include the pair
(*x, *y) in its may-alias set; this is sound but imprecise. An unsound may-alias analysis would incorrectly claim that two objects do not alias even though they may alias at runtime.*x and *y must alias:
x = new X; y = x;But an imprecise must-alias analysis may not include the pair
(*x, *y) in its must-alias set; this is sound but imprecise. An unsound must-alias analysis would include wrong entries in the must-alias set.In a compiler, we never compromise soundness, because an unsound analysis may cause the compiler to perform incorrect optimizations. Since the problem is undecidable, we cannot hope for a fully precise analysis, but we want as high a precision as possible:
memcpy: The C library function memcpy(dst, src, n) copies n bytes from [src, src+n) to [dst, dst+n). A basic implementation is:
void *memcpy(char *dst, char *src, size_t n) {
for (size_t i = 0; i < n; i++) {
dst[i] = src[i];
}
return dst;
}
memcpy is one of the most commonly used and performance-critical functions in the software stack. Real implementations are typically hundreds of lines of code, involving vectorization (e.g., copying four or eight bytes at a time) and specialized code for special cases. But if the regions [src, src+n) and [dst, dst+n) overlap (alias), such optimizations may become incorrect: e.g., copying a chunk of bytes in one go may read bytes that should have already been overwritten through dst. To avoid a slow implementation just because of this corner case, the C library designers specify that the source and destination regions must not overlap: it is undefined behaviour to call memcpy with overlapping regions. This example shows how aliasing pervades the systems stack, not just within a compiler but also in library design.
Accumulating an array:
void accumulate(int *a, int n, int *sum) {
for (int i = 0; i < n; i++) {
*sum += a[i];
}
}
This loop can be made much faster than the naive implementation, e.g., by register-allocating *sum (and writing it back to memory only at the very end), and by vectorizing the loop (reading eight values of a at once and summing them using vector instructions). But these optimizations are legal only if *sum cannot alias with any a[i]. In the absence of other information, the compiler is forced to make the conservative assumption that *sum may alias with a[i], in which case all these optimizations are illegal. This shows how much difference good may-alias information can make to compiler optimization.
x and y do not alias: by the language semantics, two distinct declarations are given distinct (non-overlapping) regions of memory.x = new ... and y = new ..., *x and *y do not alias, i.e., (*x, *y) does not belong to the may-alias set.p1 may point to one of the objects o1, o2, ..., ok1, and p2 may point to one of the objects q1, q2, ..., qk2. This information is computed by a points-to analysis.
For example, consider the statements:
p = &x; x = &y;From the first statement, we know that
p may point to the object x; from the second, that x may point to y. Thus, the points-to relation is p --> x --> y. In other words, the objects in the points-to sets may themselves appear on the left-hand side of the points-to relation (e.g., x is both pointed to, and may point to other objects). Also, the points-to sets of different pointers may overlap (have a non-empty intersection).
Approximating may-alias using points-to sets: May-alias information is among the most important information that a compiler needs to perform transformations (as seen in the memcpy and accumulate examples). A typical approach in compilers is to first compute, for each pointer, the set of all objects that it may point to. Let pointsTo(p) denote this set. Then:
*p1 and *p2 may alias iff pointsTo(p1) ∩ pointsTo(p2) ≠ {}
This is a sound approximation of may-alias information, assuming that the points-to sets are sound (i.e., pointsTo(p) includes all the objects that p may potentially point to at runtime). The only pairs left out of the may-alias set are those whose points-to sets are disjoint, i.e., the pointers point to distinct objects; such pairs definitely cannot alias.
k nodes:
struct LNode {
int v;
LNode *next;
};
LNode *createList(int k) {
LNode *head = nullptr;
for (int i = 0; i < k; i++) {
LNode *n = new LNode;
n->v = i;
n->next = head;
head = n;
}
return head;
}
The number of times the allocation new LNode executes depends on the number of loop iterations, which in turn depends on the parameter k. In general, a program may have arbitrary logic preceding an allocation, and it is undecidable to determine how many times a loop executes; hence it is undecidable to determine the total number of objects that a program may create at runtime.
The actual (runtime) points-to graph for this program is:
head --> ok --next--> ok-1 --next--> ... --next--> o1 --next--> nulli.e.,
head points to the most recently allocated object, whose next pointer points to the second most recently allocated object, and so on, until the oldest object whose next pointer is null. The number of objects in this graph is unknown at compile time. This picture can be drawn at runtime, but at compile time we do not know its size or shape. A compile-time analysis needs to reason about a bounded number of objects.
In a points-to graph over abstract objects, an edge from A to B means that a value stored in (some runtime object represented by) A may be a pointer to (some runtime object represented by) B. An edge from a variable p to A means that p may point to an object represented by A. The fewer the edges (while remaining sound), the more precise the analysis.
n and finite numbers k1, k2, ..., kn, at any point in the program execution:
i in 1..n, abstract object Ai represents the last ki runtime objects created before the ∑j=1i-1 kj objects represented by A1, ..., Ai-1. e.g., A1 represents the last k1 allocations; A2 represents the k2 allocations before those; and so on.Alast represents all runtime objects created before the last ∑j=1n kj allocations.
program execution timeline --->
| ...... Alast ...... | kn allocs: An | ... | k2 allocs: A2 | k1 allocs: A1 | <-- now
Each of A1, ..., An represents a finite number of runtime objects, but Alast represents a potentially unbounded number of runtime objects.
Example: For createList, let n = 2 and k1 = k2 = 1, i.e., we track the most recently allocated object (A1), the second most recently allocated object (A2), and all the objects allocated before these two (Alast). Since the set of abstract objects is finite and known at compile time, we can draw the complete points-to graph. The best answer that an analysis could produce at the end of the program is:
head --> A1 --next--> A2 --next--> Alast --next--> Alasti.e.,
head points to the most recently allocated object, which points to the second most recently allocated object, which points to an object in Alast, which (through its next pointer) may point to another object in Alast.
Abstraction makes the problem tractable but loses information. e.g., the self-edge on Alast suggests that an object in Alast may point to itself, whereas in the original program, no list node points to itself. Because Alast represents multiple runtime objects, the edge from Alast to Alast conflates "points to another object in Alast" with "points to itself".
Weakness of the recency abstraction: Now suppose that the program also has a function createTree that, depending on some parameter, creates a tree with zero, one, two, or more nodes:
main() {
head = createList(...);
root = createTree(...);
}
At compile time, we cannot predict how many allocations createTree makes. So, at the end of main:
createTree made no allocations, head points to A1.createTree made exactly one allocation, head points to A2.createTree made more than one allocation, head points to Alast.head may point to A1, A2, or Alast. Perhaps the analysis can determine that the last node allocated by createTree is the root, so that root points to A1 (if it points to anything at all). Similarly, the edges between A1, A2, and Alast become imprecise: A1, A2, and Alast may each represent list nodes or tree nodes. This points-to graph is much less satisfactory: the structure of the data structures is lost, and it seems that head (and each abstract object) may point almost anywhere; e.g., *head and *root may alias, as both may point to A1.
In general, if it is not possible to determine how many allocations happen in a program fragment, we must conservatively assume that any number of allocations may happen in that fragment, and this causes spurious edges in the points-to graph.
LNode are represented by the abstract object LNode, and all objects of type TNode (tree nodes allocated by createTree) are represented by the abstract object TNode. The points-to graph for main becomes:
head --> LNode --next--> LNode root --> TNode --left/right--> TNodeThis is much better than the recency abstraction:
head never points to a TNode; root never points to an LNode; an LNode never points to a TNode, and vice versa. In particular, *head and *root do not alias.
Weakness of the type abstraction: Consider the following program (the statements are labeled by their PC, which we use later). Assume that Z is a type derived from Y (so that z, declared of type Y*, may point to either):
1: x = new X; 2: y = new Y; 3: x.f = y; 4: z = new Y; 5: z.g = x; 6: z.f = y; 7: z = new Z;In this discussion, we ignore the order of statements (i.e., the analysis is flow-insensitive): we keep adding edges until a fixed point is reached. We build the points-to graph under the type abstraction, where the abstract objects are
X, Y, and Z:
x --> X and y --> Y.x.f = y): an object in X may contain (in field f) a pointer to an object in Y: X --> Y. (The field names are not shown in the points-to graph.)z --> Y. Imprecision already: both y and z point to Y, so *y and *z may alias, whereas the program never assigns the pointer held in y to z.z --> Z.z.g = x): whatever z points to (Y or Z) may contain a pointer to whatever x points to (X): Y --> X and Z --> X. The edge Y --> X is spurious: it suggests that X may be reachable through y, whereas in the program it is reachable only through z.z.f = y): Y --> Y and Z --> Y. Again, the self-edge on Y is spurious.x --> X X --> Y y --> Y Y --> X, Y z --> Y, Z Z --> X, YMany spurious edges are added due to the type abstraction.
Y, but they are different allocation sites, and so give rise to different abstract objects (2 and 4). For the same program:
x --> 1 and y --> 2.x.f = y): 1 --> 2.z --> 4. This is where the precision improves: under the type abstraction, y and z both pointed to Y; now y points to 2 and z points to 4, so *y and *z cannot alias.z.g = x): 4 --> 1.z.f = y): 4 --> 2.z --> 7. Now that z may also point to 7, statements 5 and 6 must be reconsidered (we iterate to a fixed point, as in a dataflow analysis): 7 --> 1 and 7 --> 2.x --> 1 1 --> 2 y --> 2 4 --> 1, 2 z --> 4, 7 7 --> 1, 2We will see an algorithm to compute such points-to graphs automatically later; here, we are primarily interested in the different abstractions and their trade-offs. The number of edges in the two graphs cannot be directly compared (the number of abstract objects differs), but fewer aliasing relationships are possible in the allocation-site graph than in the type-abstraction graph: e.g.,
*y and *z cannot alias under the allocation-site abstraction, but may alias under the type abstraction.
The list and tree example: With the allocation-site abstraction, there is one allocation site in createList (say, site 1, allocating LNode) and one in createTree (say, site 2, allocating TNode):
head --> 1 --next--> 1 root --> 2 --left/right--> 2This allows us to rule out many points-to and aliasing relationships, and works well for this example, unlike the recency abstraction.
Weakness of the allocation-site abstraction: Suppose createList is called twice:
main() {
head = createList(...);
head2 = createList(...);
root = createTree(...);
}
Both lists are allocated at the same allocation site (site 1), so:
head --> 1 --next--> 1 head2 --> 1 root --> 2 --left/right--> 2This is still better than the recency abstraction, but it is imprecise: it implies that
*head may alias with *head2, whereas the code shows that this is not the case. We will see ways to reduce such imprecision later.
A1, ..., An and an allocation-site abstraction creates abstract objects B1, ..., Bm, we can use their Cartesian product (n × m abstract objects) to potentially obtain more precision than either abstraction alone. Any abstraction is acceptable as long as the total number of abstract objects is finite. However, there is a trade-off: Cartesian products can quickly make the number of abstract objects exponential; more abstract objects may give better precision, but at the cost of a higher running time of the points-to analysis.