Sparse set in Go
June 17, 2017
Go Cgo Sparse Set Data structures
This post is inspired by https://research.swtch.com/sparse
Complete code: https://github.com/ideahitme/go-and-learn/blob/master/golang/cgo/sparse.go
Implementing data structures in Go is easy due to the presence of slices and maps which allows data structures like queue, stack programmed in just few lines of code. Implementing sets should not be too difficult either, but let’s take a closer look at two different ways of doing it. First is the most commonly seen implementation via map and the other will utilize an interesting data structure called sparse set. The latter, however, will require us to do some C code invocation for additional optimization.
I would like to point out that the word set in this text means a data structure which stores unordered set of unique integers and is different from C++ std::set.
Let’s begin by defining an interface for our set implementation:
type Set interface {
Insert(uint32) // Insert new element if not present
Find(uint32) bool // Check if element is in the set
Iterate() []uint32 // Provide a way to iterate over inserted elements
Clear() // Clear the set
}
(we omit Remove operation at least in the scope of this post)
MapSet
The most common way to implement a set is to use a hash map, one of the possible implementations is described below:
type MapSet map[uint32]bool
func (ms MapSet) Insert(x uint32) {
ms[x] = true
}
func (ms MapSet) Find(x uint32) bool{
return ms[x]
}
func (ms MapSet) Iterate() []uint32 {
result := make([]uint32, 0, len(ms))
for x := range ms {
result = append(result, x)
}
return result
}
func (ms MapSet) Clear() {
for x := range ms {
delete(ms, x)
}
}
...
// usage
set := MapSet{}
set.Insert(1)
set.Find(1) //true
The MapSet implementation is completely fine and it is probably the simplest and cleanest solution in most of the cases. However it has two downsides, both Clear() and Iterate() methods require O(n) operations for the keys of the map to be extracted. That’s where sparse set comes to rescue.
Sparse Set
The idea behind sparse set is to maintain two arrays, namely dense and sparse:
densearray stores elements in the order of their arrivalsparseis an array which elements point to the location where given element is indense
To understand it better, let’s consider the following example:
In the beginning our sparse set has no elements. And now we want to insert 4 into it:
Before:
dense: []
sparse: []
After:
dense:[4]
sparse:[_,_,_,_,0]
Now let’s insert 2,
Before:
dense:[4]
sparse:[_,_,_,_,0]
After:
dense:[4, 2]
sparse:[_,_,1,_,0]
So dense simply stores the elements as they arrive into the set. While sparse stores the indexes where this element exist in dense. So if we want to find if element 2 is present in the set, all we have to do is check sparse[2], which will give us the index of dense where 2 is present. Therefore, for any x in the set, the following holds true dense[sparse[x]] == x. So far so good.
Let’s look at one of the naive Go implementations:
type NaiveSparseSet struct {
dense []uint32
sparse []int
}
func (ns *NaiveSparseSet) Insert(x uint32) {
ns.sparse[x] = len(ns.dense)
ns.dense = append(ns.dense, x)
}
Apparently there is a problem, if we call (&NaiveSparseSet{}).Insert(1) the code will panic with index out of bound runtime error. To fix this problem we can ask the user to provide us with the maximum element value that can be stored in the set:
func NewNaiveSparseSet(maxValue uint32) *NaiveSparseSet {
sparse := make([]int, maxValue)
return &NaiveSparseSet{
sparse: sparse,
}
}
So with this little trick we can now implement Set interface quite simply:
func (ns *NaiveSparseSet) Insert(x uint32) {
if ns.Find(x) {
return
}
ns.sparse[x] = len(ns.dense)
ns.dense = append(ns.dense, x)
}
func (ns *NaiveSparseSet) Find(x uint32) bool {
return ns.sparse[x] < len(ns.dense) && ns.dense[ns.sparse[x]] == x
}
func (ns *NaiveSparseSet) Iterate() []uint32 {
return ns.dense
}
func (ns *NaiveSparseSet) Clear() {
ns.dense = nil
}
It all works and all our operations are constant-time operations, but we added new problems:
- Initializing
sparsenow takesO(max-value)amount of operations - Consistently calling
Clearwill cause the underlying arrays to be generated and continuously garbage collected, which is not really necessary as will be shown further
Optimized Sparse Set
Let’s first solve the GC problem with underlying array of ns.dense. Instead of detaching the slice header, we can simply say that our set has no elements by adding a flag which indicates how many elements we actually have in the set:
type SparseSet struct {
size int32
...
Therefore, now Clear and Insert can be simply rewritten as:
func (s *SparseSet) Clear() {
s.size = 0
}
func (s *SparseSet) Insert(x uint32) {
if s.Find(x) {
return
}
if int32(len(s.dense)) == s.size { //grow only when necessary
s.dense = append(s.dense, x)
} else {
s.dense[s.size] = x
}
s.sparse[x] = s.size
s.size++
}
Now to the dummy initialization problem. The following line caused the sparse slice to be allocated and initialized.
sparse := make([]int, maxValue)
While the former is required for us to use the memory, we need to understand if initialization phase is really necessary. To do that let’s check where we actually need to read the sparse data:
func (s *SparseSet) Find(x uint32) bool {
return s.sparse[x] < s.size && s.dense[s.sparse[x]] == x
}
So if we had some garbage stored in the s.sparse[x] it would not really affect the correctness of the program, because it would mean that either s.sparse[x] points out of the s.dense boundary, or the value in s.dense[s.sparse[x]] is not actually x.
So how do we allocate a memory without initializing ? Obviously malloc() !
One of the great features of Go is the ability to invoke C code (more details here https://github.com/golang/go/wiki/cgo) and all we have to do is to import the C library:
/*
#include <stdlib.h>
*/
import "C"
So now our sparse allocation would look like:
func NewSparseSet(maxValue uint32) (*SparseSet, error) {
//allocate a new array via malloc
sparse := C.malloc(C.size_t(maxValue * 4))
if sparse == nil {
return nil, fmt.Errorf("failed to allocate memory")
}
return &SparseSet{sparse: unsafe.Pointer(sparse)}, nil
}
It is important to understand that when we call C code, we are interacting with C types and malloc(1000) returns *void type (pointer to anything) so we have to be precise with the size. Since we are expecting to store uint32 (4 bytes) we allocate 4 * maxValue amount of memory. Furthermore, we need to be able to do pointer arithmetics, therefore conversion to unsafe.Pointer is needed as well (more on unsafe package https://github.com/ideahitme/go-and-learn/tree/master/golang/unsafe).
This is how we can perform pointer arithmetics to navigate over the s.sparce (type unsafe.Pointer):
// at(i) returns pointer to sparse array in position i
func (s *SparseSet) at(shift uint32) unsafe.Pointer {
return unsafe.Pointer(uintptr(s.sparse) + uintptr(4*shift))
}
So the rest of our code is as simple as:
func (s *SparseSet) Clear() {
s.size = 0
}
func (s *SparseSet) Insert(x uint32) {
if s.Find(x) {
return //already in the set
}
if int32(len(s.dense)) == s.size { //grow only when necessary
s.dense = append(s.dense, x)
} else {
s.dense[s.size] = x
}
mem := (*int32)(s.at(x))
*mem = s.size
s.size++
}
func (s *SparseSet) Find(x uint32) bool {
mem := (*int32)(s.at(x))
return *mem < s.size && s.dense[*mem] == x
}
func (s *SparseSet) Iterate() []uint32 {
return s.dense
}
All methods now are constant time operations, we do not need to initialize the sparce and we can avoid constant creation of n.dense underlying arrays. But we forgot one little thing, didn’t we ? Of course, we forgot that malloc() and free() are very good friends.
It is important to keep in mind that Go runtime has no idea of the memory allocation performed in the C code, therefore it will never garbage collect s.sparce. Fortunately, this is quite easy to solve as well:
func (s *SparseSet) Free() {
C.free(s.sparse)
}
...
func main() {
s, err := NewSparseSet(100000)
if err != nil {
log.Fatal(err)
}
defer s.Free() //clean up
s.Insert(100)
fmt.Printf("is %d in the set: %t\n", 100, s.Find(100))
fmt.Printf("is %d in the set: %t\n", 99, s.Find(99))
s.Insert(99)
fmt.Printf("is %d in the set: %t\n", 99, s.Find(99))
// is 100 in the set: true
// is 99 in the set: false
// is 99 in the set: true
}
Complete code for all implementations can be found in https://github.com/ideahitme/go-and-learn/blob/master/golang/cgo/sparse.go
Conclusion
Use map to implement set when:
- Not operating on large data sets
- Iteration is not really needed
- Set does not need to be cleared
MapSet is the cleanest and recommended way of implementing sets and should be used in 99% of the cases.
Use naive sparse set implementation when:
- Not operating on large data sets
- Iteration is needed
- Clear is not called often
NaiveSparseSet is has some advantages over its optimized version due to the abscense of direct C code invocation and it is type safety guarantees.
Use optimized sparse set implementation when:
- Max value is not too large
- Iteration is needed
- Clear is called often
SparseSet is a great example of cases when we do not need to operate on initialized arrays, we can take advantage of reducing time and resource utilizations for initialization stage and work with “dirty” data.
What’s next ?
In the next post I will do some benchmarking and compare these implementations on various data sets and utilize some of the great profiling capabilities of Go. We will also try to find a way to circumvent the need for the max-value, i.e. the maximum number which can be stored in the sparse set.