Antoine Grondin
writingaboutrss···
← writing
March 22, 2014english·한국어·français

Someone's Wrong! Membership testing in practical cases§

Antoine Grondin

Obviously, when someone's wrong on the internet, you've gotta do something about it. In this case, an individual on StackOverflow mentioned that using maps for membership testing was slower than simply iterating over an array, for n small enough.

Their argument was that the cost of hashing the value was higher than doing a lookup in an array, for most practical sizes. In their words, practical size meant until over a million values.

Now you might be wondering why I'm not directly linking to the comment in question. The reason is that I can't find it anymore. I only remember the comment troubled me enough to make me shout "Someone's wrong on the internet!" (xkcd 386, "Duty Calls").

But I had doubts; their argument could have made sense. I mean, maybe the cost of hashing is greater than iterating and comparing for n smaller than something. I had a gut feeling it was crap, but I'm not pretentious enough to affirm it was crap before actually verifying it was crap.

All in all, finding the comment (and telling the wrongdoer they're wrong!) doesn't matter. What matters is The Truth. In this post, we explore the truth using the Go Programming Language, the greatest language of all (no hyperbole here).

TL;DR§

Obviously this individual was wrong. Verified for n>1, membership testing on a map is always faster than on a slice. This means, in all cases you should not use a slice instead of a map.

<insert fancy graph here>

update: the tests that follow assume sets of string types. The same tests with int types reveal that slices are slightly faster until n \approx 30.

Problem§

Membership testing consists of asking a datastructure whether it contains a value or not. Of the many ways to implement this, two are discussed here:

Map§

Use a map[value]bool, then check if a value is in the map:

func isMapMember(m map[string]bool, key string) bool {
  _, ok := m[key]
  return ok
}

Slice§

Use a []value, then iterate over all the values to check if one of them is in the slice:

func isSliceMember(s []string, key string) bool {
  for _, entry := range s {
    if key == entry {
      return true
    }
  }
  return false
}

Question§

Which one of them is fastest?

Hypothesis§

My hypothesis is that using a slice will be faster. I like trying to prove I'm wrong.

Prediction§

If my hypothesis is indeed right, there will be a n for which using a slice will be faster than using a map. This will mean the individual was right. For many use cases, membership testing will be done on sets that contain a few values.

Testing§

I can think of 3 4 dimensions that might affect the results.

  1. n, the size of the set being tested. The claim here is that for n small enough, a slice will be faster.
  2. valSize, the size of the individual values stored in the set. As valSize increase, it is possible that the structures will perform differently than with smaller valSize.
  3. Whether or not the entry is in the set. It could be that map are faster at determining non-membership. Or slices. Who knows!
  4. update: the type of the values held in the set.

Methodology§

Taking into consideration the above dimensions, we will benchmark the two methods of testing for membership.

  • For a set of n unique entries, take a random entry that is in the set. Measure how much time it takes to assert that this entry is a member of the set.
  • For a set of n unique entries, take a random entry that is not in the set. Measure how much time it takes to assert that this entry is not a member of the set.

The first step is to create a set of n unique entries, each randomly generated strings of length valSize. The actual benchmarking code separates those in two funcs, so to run one type of benchmark at a time.

func makeRandomSet(n, valSize int) (map[string]bool, []string) {
  mapset := make(map[string]bool, n)
  sliceset := make([]string, 0, n)

  for len(mapset) != n {
    str := makeRandomString(valSize)
    if _, ok := mapset[str]; !ok {
      mapset[str] = true
      sliceset = append(sliceset, str)
    }
  }
  return mapset, sliceset
}

Then, we have two paths: asserting membership, asserting non-membership.

Membership§

Take a sample from the set:

func sampleInSlice(sliceset []string) {
  randIdx := rand.Intn(len(sliceset))
  return sliceset[randIdx]
}

Sampling in the map is done the same way, but you first need to extract the map keys into a slice. For benchmarking purposes, this function actually returns a slice of unique samples, and then measure how much time it takes to assert the membership of each.

We will use this helper to benchmark maps:

func benchMapMembers(b *testing.B, size int, keySize int) {
  m := makeRandomMap(size, keySize)
  samples := sampleFromMap(m, b.N)

  var member string
  var ok bool
  b.ResetTimer()
  for i := 0; i < b.N; i++ {
    member = samples[i]
    ok = isMapMember(m, member)
  }
  _ = ok
}

And this one to benchmark slices:

func benchSliceMembers(b *testing.B, size int, keySize int) {
  s := makeRandomSlice(size, keySize)
  samples := sampleFromSlice(s, b.N)

  var member string
  var ok bool
  b.ResetTimer()
  for i := 0; i < b.N; i++ {
    member = samples[i]
    ok = isSliceMember(s, member)
  }
  _ = ok
}

Non-membership§

Find entries that aren't in the set:

func sampleNotInMap(mapset map[string]bool, valSize int) (sample string) {
  for {
    str := makeRandomString(valSize)
    if !mapset[str] {
      return str
    }
  }
}

Sampling in the slice is done the same way. Then we also create benchmark helpers that are quite similar to the membership helpers.

For maps:

func benchMapNotMembers(b *testing.B, size int, keySize int) {
  m := makeRandomMap(size, keySize)
  samples := sampleNotInMap(m, b.N, keySize)

  var member string
  var ok bool
  b.ResetTimer()
  for i := 0; i < b.N; i++ {
    member = samples[i]
    ok = isMapMember(m, member)
  }
  _ = ok
}

For slices:

func benchSliceMembers(b *testing.B, size int, keySize int) {
  s := makeRandomSlice(size, keySize)
  samples := sampleFromSlice(s, b.N)

  var member string
  var ok bool
  b.ResetTimer()
  for i := 0; i < b.N; i++ {
    member = samples[i]
    ok = isSliceMember(s, member)
  }
  _ = ok
}

Faceoff!§

We run the membership helpers using a series of benchmarks, with n in (2,3,4,5,6,7,8,9,10, 100, 1000, 10000, 100000, 1000000) and valSize in (10, 100).

func BenchmarkMap_2key_10bytes(b *testing.B)        { benchMapMembers(b, 2, 10) }
func BenchmarkMap_3key_10bytes(b *testing.B)        { benchMapMembers(b, 3, 10) }
...
func BenchmarkMap_1000000key_100bytes(b *testing.B) { benchMapMembers(b, 1000000, 100) }

func BenchmarkSlice_2key_10bytes(b *testing.B)        { benchSliceMembers(b, 2, 10) }
func BenchmarkSlice_3key_10bytes(b *testing.B)        { benchSliceMembers(b, 3, 10) }
...
func BenchmarkSlice_1000000key_100bytes(b *testing.B) { benchSliceMembers(b, 1000000, 100) }

Testing for non-membership is much more expensive than testing for membership, since we need to create random strings that aren't in the original set. This is very time consuming to setup, so I've limited this part of the test to a few values, and assume that the behavior of non-membership will be consistent with the membership ones. This is an acceptable assumption given that the experiment I did measure suggest that they are indeed.

Also, Go will kill benchmarks running for more than 600s (10min). The above combinations run is just short of 600s (584s).

func BenchmarkMapNot_10key_10bytes(b *testing.B)      { benchMapNotMembers(b, 10, 10) }
func BenchmarkMapNot_1000key_10bytes(b *testing.B)    { benchMapNotMembers(b, 1000, 10) }
func BenchmarkMapNot_1000000key_10bytes(b *testing.B) { benchMapNotMembers(b, 1000000, 10) }

func BenchmarkSliceNot_10key_10bytes(b *testing.B)      { benchSliceNotMembers(b, 10, 10) }
func BenchmarkSliceNot_10000key_10bytes(b *testing.B)   { benchSliceNotMembers(b, 10000, 10) }
func BenchmarkSliceNot_1000000key_10bytes(b *testing.B) { benchSliceNotMembers(b, 1000000, 10) }

Results§

The code to run the benchmarks yourself is on github. The results of running this benchmark on my Mac follow.

Membership results§

nvalSizeMeasurements (slice)Measurements (map)ns/op (slice)ns/op (map)
21010000000010000000017.014.4
31010000000010000000023.618.8
41010000000010000000028.622.5
5105000000010000000033.526.3
6105000000010000000040.830.1
710500000005000000045.433.5
810500000005000000052.436.0
910500000005000000054.236.6
1010500000005000000060.438.1
1001050000005000000049239.3
10001050000050000000482939.3
100001050000500000004817248.4
1000001050005000000048077377.0
100000010500200000004853658149
210010000000010000000016.912.4
310010000000010000000023.915.9
410010000000010000000029.418.0
51005000000010000000036.220.8
61005000000010000000042.022.6
71005000000010000000045.424.6
81005000000010000000050.926.8
9100500000005000000056.567.7
10100500000005000000061.368.0
10010050000005000000051868.8
100010050000050000000494070.2
1000010050000200000004939681.0
100000100500010000000608481172
1000000100200100000006361138199

Non-membership results§

nvalSizeMeasurements (slice)Measurements (map)ns/op (slice)ns/op (map)
1010200000005000000097.736.5
100001020000500000008505336.9
100000010200200000009101328123

The results are clear, my hypothesis was wrong. For n > 1, membership testing is always faster using a map.

So clearly, someone was wrong on the Internet!!!!§

Update§

The above was written considering string as the type held by the set. It turns out that for sets of int, slices are slightly faster than maps until n \approx 30.

Integer membership§

nMeasurements (slice)Measurements (map)ns/op (slice)ns/op (map)
22000000001000000009.0211.3
310000000010000000011.114.0
410000000010000000012.316.0
510000000010000000013.216.4
610000000010000000013.717.4
710000000010000000014.519.4
810000000010000000015.120.5
91000000005000000016.029.9
101000000005000000016.729.9
201000000005000000024.629.8
30500000005000000031.128.5
40500000005000000035.331.6
50500000005000000039.530.7
100500000005000000056.230.6
100050000005000000034029.8
1000050000050000000321232.6
10000050000500000003105140.4
100000050005000000033163074.7

Integer non-membership§

nMeasurements (slice)Measurements (map)ns/op (slice)ns/op (map)
1010000000010000000018.025.4
10000500000100000000622025.5
100000050002000000071800680.7

Someone was not so clearly wrong on the Internet!!!!§

tamuning - seoul