home

Learning Golang By Benchmarking Set Implementations

15 Jul 2011

As my twitter followers probably know, the last couple days I've been having fun learning Google's Go language. My goal is to build something in the FlockDB universe, but for now I'm just getting familiar with things. For those who aren't familiar with it, Go is Google's attempt to build a modern system programming language. A modern day C, if you will.

Go has some pretty neat features. And it has some annoying limitations. I'll blog about those when I get more familiar with it. One thing that I can definitively say is that it is both young and there appears to be a strong will to keep it slim and focused. The result is a comparatively lacking framework (or set of built-in packages as they are called). For example, there's no built-in Set.

Please keep in mind that I don't really know what I'm doing.

My goal tonight was to benchmark various Set implementation, in the hope that I'd not only find a good implementation but also familiarize myself with Go. I want to be very clear: I have specific requirements for my Set. I'm therefore focusing on testing against my use-case. What is my use-case? Basically I want to populate a collection with integers and explicitly remove all duplicates on demand. In other words, I only care about unique values when I care about unique values - the rest of the time, duplicates are ok. Ordering isn't important. Here's an example of how I might use it:

func Load(users []User) ([]int) {
  ids := new(MySpecialCollection)
  for user := range users {
    for id := range.user.friendIds {
      ids.Add(id)
    }
  }
  ids.RemoveDuplicates()
  return ids;
}

So, I want to get all the unique ids of all the user's friends. Ids are removed once at the end and then the "set" is returned.

We'll begin by defining an interface:

type Set interface {
  Add(value int)
  Contains(value int) (bool)
  Length() (int)
  RemoveDuplicates()
}

We only really care about Add and RemoveDuplicates. However, the Contains and Length methods will help us test our implementation (kinda pointless to see which runs faster if we don't also test that they actually work!)

The first implementation uses a map (Go's built-in hashtable type). This is our baseline because it's the simplest most obvious solution. Here's the implementation:

type HashSet struct {
  data map[int]bool
}

func (this *HashSet) Add(value int) {
  this.data[value] = true
}

func (this *HashSet) Contains(value int) (exists bool) {
  _, exists = this.data[value]
  return
}

func (this *HashSet) Length() (int) {
  return len(this.data)
}

func (this *HashSet) RemoveDuplicates() {}

func NewSet() (Set) {
  return &HashSet{make(map[int] bool)}
}

If this is your first time looking at Go, you might be a little lost. First we define our type called HashSet and define a private member data (it's private because the name starts with a lowercase letter) which is a map. One really cool thing about Go is that you don't have to explicitly implement an interface. Simply implement its methods and it can safely be cast to it (it's awesome!) Methods are defined as functions with a receiver (this *HashSet). All of the implementations are pretty straighforward. RemoveDuplicates does nothing because duplicates are never added (that's just a basic property of a hash/map).

Two things about the above code worth exploring. First, the Contains method is somewhat funky. In Go, methods can return multiple values. Getting a value from a map (this.data[value]) returns two values. The actual value at the key, and true/false on whether the value existed. We discard the actual value by assigning it to the magic variable _ and assign whether the value existed or not to the exists variable - which is actually a named returned parameter. The other thing is the NewSet function. Functions like this is how you do a constructor. I think it kinda sucks. Within our "constructor" we actually initialize our map using the special make. make is something else which I think sucks about Go. Three built-in types need to be instantiated with make, all the other types are instantiated with new (or the &TYPE{} syntax we are using here, which is syntactical sugar for new and is similar to C#'s constructor initializers).

We'll create two tests to make sure our implementation works:

import(
  "testing"
  "rand"
)

func TestCanAddUniqueValues(t *testing.T) {
  values := []int{5,4,3,9,1,2,8}
  set := NewSet()
  for _,v := range values {
    set.Add(v)
  }
  set.RemoveDuplicates()
  Assert(t, set.Length() == len(values), "Wrong length")
  for _,v := range values {
    Assert(t, set.Contains(v), "Missing value")
  }
}

func TestUniqueRemovesDuplicates(t *testing.T) {
  values := []int{5,4,3,5,2,1,2,3,4,5,1,2,2}
  uniques := []int{5,4,3,2,1}
  set := NewSet()
  for _,v := range values {
    set.Add(v)
  }
  set.RemoveDuplicates()
  Assert(t, set.Length() == len(uniques), "Wrong length")
  for _,v := range uniques {
    Assert(t, set.Contains(v), "Missing value")
  }
}
func Assert(t *testing.T, condition bool, message string) {
  if (!condition) {
    t.Fatal(message)
  }
}

Testing in Go isn't particularly pretty. But, at least we'll be able to make sure that whenever RemoveDuplicates is called on our set, that duplicates are, in fact, removed.

Now comes the interesting part. Go has built-in benchmarking capabilities, and it's pretty neat. Rather than define a Test function, we define a Benchmark method, like so:

func BenchmarkSmallSetWithFewCollisions(b *testing.B){
  for i := 0; i < b.N; i++ {
    set := NewSet()
    for j := 0; j < 100; j++ {
      set.Add(rand.Int() %700)
      }
    }
  }
}

If you look at the first for loop, you'll notice that we are looping b.N times. b is the parameter which is passed into our function. The way it works is that the engine will loop through our code as many times as it needs to until it feels comfortable that it has an accurate measurement for the cost of a single iteration. This'll become more clear briefly.

My goal was to test the performance of a small, medium and large set (100, 5000 and 100000 items) and for each of those, with a small, medium and large number of duplicates. The above code will generate a set of 100 values, where a given value can be between 0 and 699 - so duplicates are pretty rare. Here's what all 9 benchmarks look like:

func benchmark(b *testing.B, size int, fill int) {
  for i := 0; i < b.N; i++ {
    set := NewSet()
    for j := 0; j < size; j++ {
      set.Add(rand.Int() % fill)
    }
  }
}

func BenchmarkSmallSetWithFewCollisions(b *testing.B){
  benchmark(b, 100, 700)
}
func BenchmarkSmallSetWithMoreCollisions(b *testing.B){
 benchmark(b, 100, 100)
}
func BenchmarkSmallSetWithManyCollisions(b *testing.B){
 benchmark(b, 100, 25)
}
func BenchmarkMediumSetWithFewCollisions(b *testing.B){
  benchmark(b, 5000, 35000)
}
func BenchmarkMediumSetWithMoreCollisions(b *testing.B){
 benchmark(b, 5000, 5000)
}
func BenchmarkMediumSetWithManyCollisions(b *testing.B){
 benchmark(b, 5000, 1250)
}
func BenchmarkLargeSetWithFewCollisions(b *testing.B){
  benchmark(b, 100000, 700000)
}
func BenchmarkLargeSetWithMoreCollisions(b *testing.B){
 benchmark(b, 100000, 100000)
}
func BenchmarkLargeSetWithManyCollisions(b *testing.B){
 benchmark(b, 100000, 25000)
}

And, here's the output from running this against our simple HashSet implementation:

set.BenchmarkSmallSetWithFewCollisions		50000		52600 ns/op
set.BenchmarkSmallSetWithMoreCollisions		50000		42991 ns/op
set.BenchmarkSmallSetWithManyCollisions		50000		31406 ns/op
set.BenchmarkMediumSetWithFewCollisions		1000		2663531 ns/op
set.BenchmarkMediumSetWithMoreCollisions	1000		1703337 ns/op
set.BenchmarkMediumSetWithManyCollisions	1000		1411807 ns/op
set.BenchmarkLargeSetWithFewCollisions		50			54807860 ns/op
set.BenchmarkLargeSetWithMoreCollisions		50			48436380 ns/op
set.BenchmarkLargeSetWithManyCollisions		50			34149020 ns/op

The first column is the benchmark that was run. The 2nd column is how many iterations it ran (or the value of b.N). The last column is the time it took (in nanoseconds) per loop. From the above, we can tell that the more collisions there are, the faster the code (I'd guess that's because it has to insert less values into the map).

For my second implementation, I decided to use an array and scan the array for a duplicate before adding it. The interesting method here is Add and Contains:

func (this *RealtimeArraySet) Add(value int) {
  if this.Contains(value) == false {
    this.data = append(this.data, value)
  }
}
func (this *RealtimeArraySet) Contains(value int) (exists bool) {
  for _, v := range this.data {
    if v == value {
      return true
    }
  }
  return false
}

And, here are the results:

set.BenchmarkSmallSetWithFewCollisions		100000		22390 ns/op
set.BenchmarkSmallSetWithMoreCollisions		100000		19421 ns/op
set.BenchmarkSmallSetWithManyCollisions		100000		17037 ns/op
set.BenchmarkMediumSetWithFewCollisions		100			19248020 ns/op
set.BenchmarkMediumSetWithMoreCollisions	100			12210990 ns/op
set.BenchmarkMediumSetWithManyCollisions	500			5158366 ns/op
set.BenchmarkLargeSetWithFewCollisions		1			7482466000 ns/op
set.BenchmarkLargeSetWithMoreCollisions		1			4660109000 ns/op
set.BenchmarkLargeSetWithManyCollisions		1			1785185000 ns/op

For small sets, this is much faster. For large sets, it's much slower. This isn't really a surprise. Doing a linear search in the array just can't scale with large sets.

The last implementation I did was to allow duplicates to get added to the array, but to remove them when explicitly told to:

func (this *OnDemandArraySet) Add(value int) {
  //the append method will automatically grow our array if needed
  this.data = append(this.data, value)
}
func (this *OnDemandArraySet) RemoveDuplicates() {
  length := len(this.data) - 1
  for i := 0; i < length; i++ {
    for j := i + 1; j <= length; j++ {
      if (this.data[i] == this.data[j]) {
        this.data[j] = this.data[length]
        this.data = this.data[0:length]
        length--
        j--
      }
    }
  }
}

We remove duplicates by comparing every value to every other value. This leans heavily on Go slices. You can think of a Go slice as a movable window above an array. Slices are really efficient. When we find a duplicate, we replace it with the last element and shrink by 1. So, if we have: [1,2,3,2,5,6] it'll become [1,2,3,6,5] (the 6 overwrites the duplicate 2 and the slice is shrank). Again, all of this is efficient - we aren't creating copies of array, just playing with pointers. The results:

set.BenchmarkSmallSetWithFewCollisions		200000		12362 ns/op
set.BenchmarkSmallSetWithMoreCollisions		200000		12425 ns/op
set.BenchmarkSmallSetWithManyCollisions		200000		12430 ns/op
set.BenchmarkMediumSetWithFewCollisions		5000		682792 ns/op
set.BenchmarkMediumSetWithMoreCollisions	2000		720831 ns/op
set.BenchmarkMediumSetWithManyCollisions	2000		706491 ns/op
set.BenchmarkLargeSetWithFewCollisions		100			12645380 ns/op
set.BenchmarkLargeSetWithMoreCollisions		100			12682960 ns/op
set.BenchmarkLargeSetWithManyCollisions		100			12863000 ns/op

Under all circumstances, this is a clear winner. In the simplest case it's 4.25x faster than the map and 1.8x faster than the original array implementation. In the most complex case it's 2.6x faster than the map and 138x faster than the original array implementation. (I did play with pre-allocating the underlying structures with different sizes, it didn't change anything.)

Beyond raw speed, we can also measure how much memory each approach takes. For this, I simply enabled Go's memoryprofile and looped through each set a fixed number of times. To be honest, I'm not positive what all the values mean, but I extracted the three which seemed the most relevant:

HashSet
Alloc = 1227039448
TotalAlloc = 2249302288
HeapInuse = 1241645056

RealTimeArraySet
Alloc = 1033523080
TotalAlloc = 1033574400
HeapInuse = 1047220224

OnDemandArraySet
Alloc = 3631414928
TotalAlloc = 3631466248
HeapInuse = 3659673600

No huge surprise here. Since the fastest implementation doesn't discard duplicates until we tell it to, it takes the most amount of memory. The RealTimeArraySet takes the least amount of memory because duplicates are discarded, and there's little overhead to the underlying structure (an array). I'm quite surprised at how close the memory footprint of the map is to the array.

The source code for this is available at https://github.com/karlseguin/golang-set-fun.

blog comments powered by Disqus