How Does Ligra Work?
This is a written version of my presentation for R244 Large-scale data processing and optimisation reading club, slides are available here
If you have 4 Intel CPUs with 10 cores each and 256 GB of RAM laying around and you want to perform PageRank in seconds this might just be the lightweight graph processing framework for you!
Ligra demonstrates that if we restrict the topology of our hardware, we can get very fast processing. Sometimes even an order of magnitude faster computation than the state of the art algorithms at the time of publishing (2013); Ligra performs Bellman-Ford 1B vertex binary tree in 2s while Pregel takes 20s and 300 commodity PCs.
Contributions of the paper:
- A simple interface for implementing fast graph algorithms.
- A fast framework for parallel processing of large graphs (assuming they fit into memory and the memory is shared between your parallel workers).
- Aesthetically pleasing results (at the time of publishing).
Interface
The interface of Ligra is beautifully simple, because you only need to keep track of 4 things:
vertexSubset
size
edgeMap
vertexMap
vertexSubset
Ligra represents a subset of your graph in 2 ways: sparse and dense.
Sparse vertexSubset
is a list of indices (of the original graph) in any order, such as [2, 4, 3]
.
Dense vertexSubset
is a boolean list of size equal to the original graph, with 1s at the indices that belong to the subset. For example, [0, 0, 1, 1, 1, 0, 0, 0]
is the same vertexSubset
as the sparse representation above, assuming the original graph has 8 vertices.
size
Size is just a function that takes a vertexSubset
and returns the number of vertices the subset has.
edgeMap
edgeMap
is the powerhouse of the framework, allowing us to implement all the highly optimised graph algorithms.
edgeMap
applies a function \(F\) over all outgoing edges from each vertex in \(U\), such that the target vertex satisfies the condition \(C\). In practice, whenever I say vertex, I mean an index of a vertex, so it’s just an int
. Consequently, \(C\) is just a function defined by you that accepts an int
and returns whether or not a vertex denoted by that int
should be processed. Likewise, \(F\) accepts two int
s representing source and destination, where source is in the vertexSubgraph
\(U\). After processing the edge \(F\) returns a boolean, telling edgeMap
whether to add the destination to the vertexSubset
returned by the edgeMap
.
vertexMap
vertexMap
simply iterates in parallel over all vertices in the vertexSubset
and applies a function defined by you. It is significantly (computationally) simpler than the edgeMap
since we don’t care about the edge relationships when performing this map.
Worked Example
Let’s see a simple example to better understand the interface!
Here’s an animation of parallelized breadth-first search.
In the animation above, at each step, a set of frontier nodes is selected and for each frontier node, we find its unseen connections. We can parallelize the process of finding unseen nodes with no overhead by utilizing the shared memory between the parallel workers.
In Ligra it would look like this:
# parents[i] stores the index of i's parent, -1 if unexplored
parents = [-1, ... , -1]
# update returns True if destination should be added to the set of nodes
# returned by the edgeMap and false otherwise
def update(source: int, destination: int):
if (parents[destination] == -1):
# because this write is executed in parallel another thread might
# write to parents before, that doesn't affect the correctness.
parents[destination] = source
return True
else:
return False
def cond(destination: int):
return parents[destination] == -1
def BFS(G: Graph, root: int):
parents[root] = root
frontier = [root]
while (len(frontier) != 0):
frontier = edgeMap(G, frontier, update, cond)
Implementation
The graphs are indexed as adjacency lists, in case of directed graphs, two adjacency lists are stored: one mapping vertices to their out edges (out-edge index) and another mapping vertices to their in-edges (see picture bellow).
Undirected graphs require only a single adjacency list.
Unfortunately the paper doesn’t state the indexation time required for graph processing.
Evaluation
At the time of publishing, Ligra outperformed state of the art distributed graph processing frameworks. Ligra’s performance speaks more to the merit of shared-memory configuration rather than to the framework itself since competing frameworks require significant effort in coordinating distributed memory.
Comparison
GPS uses 30 instances with 4 virtual cores and 7.5GB RAM each. Ligra outperforms it on Yahoo PageRank taking 20s (v. 104s) per iteration.
PowerGraph uses 8 instances with 64 cores. Ligra outperforms it on Twitter PageRank taking 2.91s (v. 3.6s) per iteration.
Google’s Pregel uses 300 commodity PCs. Ligra outperforms Pregel on Bellman-Ford on 1B vertex binary tree processing the entire graph in 2s (v. 20s).
Near Linear Parallelization
The plots from the paper (bellow) demonstrate close to linear parallelization as the number of threads used is increased.
My only criticism of the evaluation is the fact that the performance has only been demonstrated on a single configuration; authors themselves admit that they were unable to replicate the results on a more powerful AMD machine. Consequently, it is difficult to infer whether the parallelization curves demonstrated above would look the same were it performed on CPUs with varying number of cores.
Improvements Since 2013
One of the strongest merits of the framework is the fact that since it’s initial implementation in 2013 it continues to be extended to this day (the latest paper using the framework has been published this year). Here I list the work that has been made on top of Ligra.
2015 Ligra+ extends Ligra to compressed graphs.
2017 Julienne enables efficient implementation of bucketing algorithms for graphs.
2019 Aspen enables writes to graph