BFS
In this tutorial we'll implement a parallel version of Breadth-First Search in GBBS. Given a graph, , and a source node , a Breadth-First Search processes the graph level by level, starting from . The output of the algorithm is an array s.t. holds the parent of in the breadth-first tree rooted at .
#
Getting StartedIn what follows we will reimplement the BFS algorithm shown in the file above and go through all of the steps to create a new benchmark in GBBS.
To get started, cd into the benchmarks/BFS
directory and create a
new directory BFS_tutorial
. Create two new files in this directory,
BFS.cc
and BFS.h
.
We will start with the interesting algorithmic code in BFS.h
.
Let's start by creating a stub for the BFS function:
One of the goals of GBBS is to abstract away implementation specific details about how the graph is represented. Towards this goal, the algorithm implementations in GBBS typically take the graph type as a template argument, as shown above. This approach lets us run the same code on the graph whether it is compressed or uncompressed, and directed or undirected.
The uintE
type name is an integer type used to represent vertex-ids
in GBBS (the analogous type for edge-ids is uintT
although we won't
use it in this implementation).
The BFS function's signature indicates that it takes as input a graph, and a source vertex-id and returns a sequence of integers representing the parent of each vertex in the BFS tree.
Next, modify the BFS implementation to the following:
Here, we set W
to the weight-type in the provided graph. For
unweighted graphs, this is the type of an empty struct (with no data),
and otherwise it is the graph's weight type, e.g., int32
or float
.
Next, we set the Parents
array to a sequence containing (G.n
)
elements where the i'th element is some suitable null value, in this
case the maximum unsigned uintE
, UINT_E_MAX
. Note that the
initialization of this sequence will happen in parallel when we use a
parallel runtime.
We also set the parent of the source vertex to itself.
So far, we have set up the variables and data necessary for the algorithm, and we're ready to start the interesting parts of the BFS.
#
Representing a FrontierWe can express a frontier generated by the BFS with the vertexSubset
datatype (from the Ligra interface), 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
#
Traversing a FrontierNow, we need to describe the logic to produce the next frontier given the current
frontier. Enter edgeMap
. edgeMap
(from the Ligra interface)
allows us to process the out edges of a vertexSubset
, and produce a
new vertexSubset
.
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:
There is an 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 updateParents
array. We can implement this using a compare and swap operation, which is provided in thepbbslib
library aspbbslib::atomic_compare_and_swap
.cond
: avoid revisiting previously visited vertices by checking ifParents[v] == UINT_E_MAX
.
Add this code before the definition of BFS
.
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 LogicAll 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:
#
The Final AlgorithmOur finished BFS algorithm should look as follows:
At this point we have created a BFS implementation
#
Wrapping up: BFS.cc and Build-files.Lastly, let's see what code we need to add in order to create a binary to
run BFS on graph inputs. Place the following code in BFS.cc
.
The main points of interest in this code are the generate_main
function,
which is a macro defined by GBBS (see gbbs/benchmark.h
) that first loads
the graph supplied as input by the user, and calls gbbs::BFS_runner
. The
false
flag passed to this macro indicates that this application does not
modify the input graph. The macro expects a runner function that takes as
input a graph and a commandLine
object which supplies the command-line
arguments provided by the user.
Lastly, if you are using Bazel add a file called BUILD
with the following
contents to this directory:
This BUILD file contains two build rules, which tells Bazel how to build
the desired outputs, such as executable binaries (cc_binary
) and
libraries (cc_library
). Build rules are also called targets, and point to
a specific set of source files and dependencies. A target can also point to
other targets, as seen in the dependencies for cc_binary
.
You can read more about Bazel here.
#
CompilationUsing Bazel you can compile the application the BFS application as follows:
To use make
you can copy the makefile from
benchmarks/BFS/NondeterministicBFS/makefile
, and run make
.
#
TestingLet's try running our program on one of the test-inputs provided by Ligra in the inputs/
directory.
The flags above indicate the following:
- The
-s
flag indicates that the graph is symmetric. - The
-rounds
flag indicates the number of times to run the benchmark. - The
-src
flag indicates the source vertex.
Great! We've successfully implemented a shared memory breadth-first search in GBBS. You should now have all of the pieces needed to implement new benchmarks in GBBS.