Tutorial: Breadth-First Search

In this project we'll implement a parallel version of Breadth-First Search in Ligra. Given a graph, $G = (V,E)$, and a source node $s$, a Breadth-First Search processes the graph level by level, starting from $s$. The output of the algorithm is an array $A$ s.t. $\forall v \in V, A[v]$ holds the parent of $v$ in the breadth-first tree rooted at $s$.

Writing generic graph algorithms #

One of the main goals of Ligra is to abstract away implementation specific details about how the graph is represented (Is the graph compressed/uncompressed? Is it directed/undirected?). The standard way of implementing an algorithm in Ligra is to implement a method, Compute, which has the following signature:

template <class vertex>
void Compute(graph<vertex>& G, commandLine P);

To get started, cd into the tutorial/ directory and open up the file BFS.C. We will examine the code piece by piece.

#include "ligra.h"

template <class vertex>
void Compute(graph<vertex>& G, commandLine P) {
  long start = P.getOptionLongValue("-r",0);
  long n = GA.n;
  //creates Parents array, initialized to all -1, except for start
  long* Parents = new long[n];
  parallel_for(long i=0;i<n;i++) Parents[i] = -1;
  Parents[start] = start;
}

So far, we have set up the variables and data necessary for the algorithm. We use parallel_for to initialize memory in parallel. This statement is just a macro that is replaced by either a parallel loop if we are using OpenMP or Cilk, or a standard for loop if OpenMP or CILK is unavailable. Note that all memory we allocate in the body of Compute must be explicitly freed in order to prevent memory leaks.

Representing a frontier #

We can express a frontier generated by the BFS with the vertexSubset datatype in Ligra, which lets us represent a subset of the vertices. Each level of the traversal will be represented by a vertexSubset. The initial vertexSubset, is just a set containing our source node can be constructed the following constructor

vertexSubset Frontier(n, source); // creates initial frontier

Traversing a frontier #

Now, we need to describe the logic to produce the next frontier given the current frontier. Enter edgeMap. edgeMap allows us to process the out edges of a vertexSubset, and produce a new vertexSubset. Please see the api for more details.

The behavior of edgeMap is intended to be customized by providing a user-defined structure. edgeMap expects a parameter of some template class F that has the following fields:

struct F {
  F(...) { ... }
  inline bool update (long s, long d) {
    // logic for how to process the edge (s,d)
  }

  inline bool updateAtomic (long s, long d) {
    // logic for how to process the edge (s,d)
  }

  inline bool cond (long d) {
    // return 1 if update should be applied on and edge (s,d) 
    // return 0 otherwise
  }
};

There is a subtle, but important distinction between update and updateAtomic that we will discuss later. For now, we will make sure that all update logic is atomic. Atomicity is important because if the vertex has more than one in-edge from the current frontier, then multiple calls to update can happen in parallel which may result in an incorrect result.

For our BFS, let's implement a struct BFS_F where

  • updateAtomic: atomically update Parents array. We can implement this using a compare and swap operation. A simple, implementation independent version is provided by the framework as CAS in ligra/utils.h.
  • cond: avoid revisiting previously visited vertices.
struct BFS_F {
  long* Parents;
  BFS_F(long* _Parents) : Parents(_Parents) { }
  inline bool updateAtomic (long s, long d) { // atomically update
    return (CAS(&Parents[d],(long)-1,s));
  }
  inline bool update (long s, long d) { // defer to updateAtomic
    return updateAtomic(s, d);
  }
  //cond function checks if vertex has been visited yet
  inline bool cond (long d) { return (Parents[d] == -1); }
};

Add this code before the definition of Compute.

Notice that while BFS_F will correctly produce the next frontier, the tree computed by the BFS is still non-deterministic! We will discuss how to fix this in a later section.

Implementing traversal logic #

All we need to do to finish up the BFS is to actually call edgeMap, and add a termination condition that signifies when the traversal is finished. Given a current frontier, our condition should just apply the edgeMap while the current frontier is non-empty. In code:

while(!Frontier.isEmpty()){ //loop until frontier is empty
  vertexSubset output = edgeMap(GA, Frontier, BFS_F(Parents));
  Frontier.del();
  Frontier = output; //set new frontier
}

The final algorithm #

The last step is to just free any memory allocated in the body of Compute. Our finished BFS algorithm should look as follows:

#include "ligra.h"

struct BFS_F {
  long* Parents;
  BFS_F(long* _Parents) : Parents(_Parents) {}
  inline bool updateAtomic(long s, long d) { // atomically update
    return (CAS(&Parents[d],(long)-1,s));
  }
  inline bool update(long s, long d){ // defer to updateAtomic
    return updateAtomic(s, d);
  }
  //cond function checks if vertex has been visited yet
  inline bool cond(long d) { return (Parents[d] == -1); }
};

template <class vertex>
void Compute(graph<vertex>& GA, commandLine P) {
  long start = P.getOptionLongValue("-r",0);
  long n = GA.n;
  //creates Parents array, initialized to all -1, except for start
  long* Parents = new long[n];
  parallel_for(long i=0;i<n;i++) Parents[i] = -1;
  Parents[start] = start;
  vertexSubset Frontier(n,start); //creates initial frontier
  while(!Frontier.isEmpty()){ //loop until frontier is empty
    vertexSubset output = edgeMap(GA, Frontier, BFS_F(Parents));
    Frontier.del();
    Frontier = output; //set new frontier
  }
  Frontier.del();
  free(Parents);
}

Compilation #

Compile the application by running make, which will produce a binary called BFS.

Testing #

Let's try running our program on one of the test-inputs provided by ligra in the inputs/ directory. Note that the -s flag tells Ligra that the graph is symmetric (undirected) and the -r flag indicates the source vertex.

./BFS -s -r 1 ../inputs/rMatGraph_J_5_100
Running time : 0.000234
Running time : 0.000359
Running time : 0.000243

Great! We've successfully implemented a shared memory breadth-first search using Ligra. In the remaining tutorials, we will consider more complicated graph algorithms and introduce the remaining parts of the Ligra API.