Leveraging special graph shapes in Graphs

Let the compiler do the work

In a previous post, we pushed the boundaries of the Graphs.jl abstraction to see how conforming the algorithms are to the declared interface, noticing some implied assumptions that were not stated. This has led to the development of VertexSafeGraphs.jl and soon to some work on Graphs.jl itself.

Another way to push the abstraction came out of the JuliaNantes workshop: leveraging some special structure of graphs to optimize some specific operations. A good parallel can be established be with the LinearAlgebra package from Julia Base, which defines special matrices such as Diagonal and Symmetric and Adjoint, implementing the AbstractMatrix interface but without storing all the entries.

A basic example

Suppose you have a path graph or chain, this means any vertex is connected to its predecessor and successor only, except the first and last vertices. Such graph can be represented by a Graphs.SimpleGraph:

import Graphs
const LG = Graphs

g = LG.path_graph(10)

for v in 1:9
    @assert LG.has_edge(g, v, v+1) # should not explode
end

This is all fine, but we are encoding in an adjacency list some structure that we are aware of from the beginning. If you are used to thinking in such way, “knowing it from the beginning” can be a hint that it can be encoded in terms of types and made zero-cost abstractions. The real only runtime information of a path graph (which is not available before receiving the actual graph) is its size $n$. The only thing to do is implement the handful of methods from the Graphs interface.

struct PathGraph{T <: Integer} <: LG.AbstractGraph{T}
    nv::Int
end

LG.edgetype(::PathGraph) = LG.Edge{Int}
LG.is_directed(::Type{<:PathGraph}) = false
LG.nv(g::PathGraph) = g.nv
LG.ne(g::PathGraph) = LG.nv(g) - 1
LG.vertices(g::PathGraph) = 1:LG.nv(g)

LG.edges(g::PathGraph) = [LG.Edge(i, i+1) for i in 1:LG.nv(g)-1]

LG.has_vertex(g::PathGraph, v) = 1 <= v <= LG.nv(g)

function LG.outneighbors(g::PathGraph, v)
    LG.has_vertex(g, v) || return Int[]
    LG.nv(g) > 1 || return Int[]
    if v == 1
        return [2]
    end
    if v == LG.nv(g)
        return [LG.nv(g)-1]
    end
    return [v-1, v+1]
end

LG.inneighbors(g::PathGraph, v) = outneighbors(g, v)

function LG.has_edge(g::PathGraph, v1, v2)
    if !has_vertex(g, v1) || !has_vertex(g, v2)
        return false
    end
    return abs(v1-v2) == 1
end

A more striking example

PathGraph may leave you skeptical as to the necessity of such machinery, and you are right. A more interesting example might be complete graphs. Again for these, the only required piece of information is the number of vertices, which is a lot lighter than storing all the possible edges. We can make a parallel with FillArrays.jl, implicitly representing the entries of a matrix.

Use cases

The question of when to use a special-encoded graph is quite open. This type can be used with all functions assuming a graph-like behaviour, but is immutable, it is therefore not the most useful when you construct these special graphs as a starting point for an algorithm mutating them.

Performance

As of now, simple benchmarks will show that the construction of special graphs is cheaper than the creation of the adjacency lists for Graphs.SimpleGraph. Actually using them for “global” algorithms is another story:

function f(G, nv)
    g = G(nv)
    pr = pagerank(g)
    km = kruskal_mst(g)
    return (g, pr, km)
end

Trying to benchmark this function on PathGraph shows it is way worse than the corresponding SimpleGraph structure, the CompleteGraph implementation is about the same order of allocations and runtime as its list-y counterpart.

The suspect for the lack of speedup is the edges operation, optimized with a custom edge iterator in Graphs and returning a heap-allocated Array in SpecialGraphs for now. Taking performance seriously will requiring tackling this before anything else. Other opportunities for optimization may include returning StaticArrays and re-implementing optional methods such as Graphs.adjacency_matrix using specialized matrix types.

Conclusion and further reading

The work on these graph structures is happening in SpecialGraphs.jl, feel free to file issues and submit pull requests. Also check out the matrix-based graph prototype in this post.

Mathieu Besançon
Mathieu Besançon
Researcher in mathematical optimization

Mathematical optimization, scientific programming and related.