bitcount優化之路

問題:git

使用Go實現bitcount函數,統計一個uint64型數值中被設置爲 1 的比特位的數量。github

方案一:golang

最容易想到的實現就是每次右移一位,檢測最後一位是不是1,這樣完成挨個比特檢測後,就能夠得出結果。web

func bitCount1(n uint64)int8{
   var count int8

   var i uint

   for i < 64 {

      if ( (n >> i) & 1) != 0{
         count += 1
      }

      i += 1
   }

   return count
}

var BitCount = bitCount1

實現一個測試函數和一個基準測試函數測試正確性和性能:算法

測試環境:segmentfault

型號名稱: MacBook Pro
處理器名稱: Intel Core i7
處理器速度: 2.5 GHz
處理器數目: 1
核總數: 4
L2 緩存(每一個核): 256 KB
L3 緩存: 6 MB
超線程技術: 已啓用
內存: 16 GB數組

// main_test.go

package main

import "testing"

var tests = []struct{
   input uint64
   want int8
}{
   { 7118255637391829670 , 34 },
   { 7064722311543391783 , 25 },
   { 4608963400064623015 , 34 },
   { 14640564048961355682 , 39 },
   { 8527726038136987990 , 27 },
   { 9253052485357177493 , 29 },
   { 8999835155724014433 , 28 },
   { 14841333124033177794 , 35 },
   { 1220369398144154468 , 33 },
   { 15451339541988045209 , 33 },
   { 2516280747705128559 , 28 },
   { 4938673901915240208 , 29 },
   { 410238832127885933 , 29 },
   { 1332323607442058439 , 33 },
   { 15877566392368361617 , 30 },
   { 3880651382986322995 , 35 },
   { 3639402890245875445 , 30 },
   { 16428413304724738456 , 39 },
   { 14754380477986223775 , 37 },
   { 2517156707207435586 , 29 },
   { 15317696849870933326 , 30 },
   { 6013290537376992905 , 35 },
   { 17378274584566732685 , 29 },
   { 5420397259425817882 , 31 },
   { 11286722219793612146 , 35 },
   { 8183954261149622513 , 30 },
   { 17190026713975474863 , 41 },
   { 379948598362354167 , 34 },
   { 3606292518508638567 , 31 },
   { 10997458781072603457 , 33 },
   { 7601699521132896572 , 31 },
   { 16795555978365209258 , 34 },
   { 9555709025715093094 , 35 },
   { 2957346674371128176 , 29 },
   { 6297615394333342337 , 36 },
   { 15800332447329707343 , 31 },
   { 10989482291558635871 , 36 },
   { 10116688196032604814 , 29 },
   { 13017684861263524258 , 29 },
   { 9721224553709591475 , 35 },
   { 7710983100732971068 , 28 },
   { 11089894095639460077 , 38 },
   { 938751439326355368 , 34 },
   { 8732591979705398236 , 33 },
   { 5679915963518233779 , 36 },
   { 16532909388555451248 , 33 },
   { 13248011246533683006 , 31 },
   { 1317996811516389703 , 30 },
   { 4318476060009242000 , 33 },
   { 3082899072464871007 , 34 },
}

func TestBitCount(t *testing.T){

   for _, test := range tests{

      if got := BitCount(test.input); got != test.want{
         t.Errorf("BitCount(%q) = %v", test.input, got)
      }

   }
}

func BenchmarkBitCount(b *testing.B) {
   var input uint64 = 5679915963518233779

   for i := 0; i < b.N; i ++{
      BitCount(input)
   }
}

命令行執行 go test -bench=. , 輸出以下:
7f4dfe03bc5098135150bc314220a1643c6a8f276e1aa156e2269e94111da843
平均一次執行時間 91.2ns緩存

for循環固定執行了64次,能夠稍做優化,由於輸入數值的第64個比特不必定是1,只要檢測到最高位的那個1就能夠結束了。函數

func bitCount11(n uint64) int8{
   var count int8

   for n != 0 {

      if ( n & 1) != 0{
         count += 1
      }

      n = n >> 1
   }

   return count
}

var BitCount = bitCount11

跑一下測試,
圖片描述
性能並無改觀,甚至更糟糕了一點。。。 不過在數值較小的狀況下,執行時間的確短不少,好比輸入是 1 的狀況下:
圖片描述post

上一版的實現對應是 41.5ns
圖片描述

方案二:

方案一里的實現受限於最高位值位1的比特所在的位數,有沒有辦法只關注值爲1的比特的數量呢?熟悉位操做的話,很容易想到 n = n &(n-1)能夠將 n 的最低一個值爲1的比特位置爲0, 好比 14 & 13 = (0b1110) & (0b1101) = 0b1100 。 這樣數值n中有M個值爲1的比特位,循環檢測的次數就是M次了。

func bitCount2(n uint64)int8{
   var count int8

   for n != 0{
      n = n & ( n - 1 )
      count += 1
   }

   return count
}

跑一下測試:
圖片描述
時間降到原來1/3的水平了,提高仍是很給力。

若是輸入n是1的話,測試結果:
圖片描述

和初版裏優化過的結果旗鼓至關。

方案三:

上兩個版本的結果都受數值n中值爲1的比特位數量M的影響,有不有常數時間的算法呢?空間換時間的思路很容易想到查表,將一個字節(8位)的全部可能值和對應的值爲1的比特位數M預先寫入表中,而後將n分紅8個字節去查表,將結果相加就好了。

func bitCount31(n uint64)int8{

   table := [256]int8{
      0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
      1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
      1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
      2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
      1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
      2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
      2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
      3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
      1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
      2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
      2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
      3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
      2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
      3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
      3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
      4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8,
   };

   var count int8

   for n != 0 {
      count += table[n & 0xff]
      n = n >> 8
   }

   return count
}

測試結果以下:
圖片描述

11.6ns ! 只有方案二里的1/3了!

不過在n爲1的時候,表現稍差,達到了9.9ns 。

圖片描述

將表的大小改成16個(即對 4 bit 建表),能夠獲得另外一個有趣的結果:

func bitCount32(n uint64)int8{

   table := [16]int8{0, 1, 1, 2, 1, 2, 2, 3,  1, 2, 2, 3, 2, 3, 3, 4}

   var count int8 = 0

   for n != 0 {
      count += table[n & 0xf]
      n = n >> 4
   }

   return count
}

圖片描述
當輸入n爲1的時候,只須要3.06ns,表現要好不少:
圖片描述

實際上,當輸入從0x0增加到0xffffffffffffffff的過程當中(每次左移4位,並將最低4位置爲0xf),前者耗時由8.86ns 線性增加到11.6ns, 後者耗時由2.41ns線性增加到11.5ns,所以用4bit建表效果更好。

方案四:

用分治的思路,一樣能夠常數時間內得出結果。將n的比特位依次按2一組,4一組,8一組,16一組,32個一組,64個一組,計算出1的位數便可。 以 0b1111爲例,兩個一組獲得: 1 1,1 1,將組內的每一個位的值相加,獲得這個組1的個數 :10,10,而後按4個一組,將組內的值相加,獲得 0b10+0b10 = 0b100,對應的十進制值是 4,就是0b1111的1的個數。(這個算法叫 variable-precision SWAR算法,更詳細的介紹能夠看文末的參考連接)

func bitCount41(n uint64)int8 {
   n = (n & 0x5555555555555555) + ((n >> 1) & 0x5555555555555555)
   n = (n & 0x3333333333333333) + ((n >> 2) & 0x3333333333333333)
   n = (n & 0x0f0f0f0f0f0f0f0f) + ((n >> 4) & 0x0f0f0f0f0f0f0f0f)
   n = (n & 0x00ff00ff00ff00ff) + ((n >> 8) & 0x00ff00ff00ff00ff)
   n = (n & 0x0000ffff0000ffff) + ((n >> 16) & 0x0000ffff0000ffff)
   n = (n & 0x00000000ffffffff) + ((n >> 32) & 0x00000000ffffffff)

   return int8(n & 0x7f)
}

只要5.23ns,比查表的實現下降了一半。並且當n爲0或者0xffffffffffffffff,結果穩定在 5.23ns 左右。

圖片描述

uint64最多64個1,64對應的二進制值不會超出一個字節,針對沒必要要的計算,咱們再作一下優化:

func bitCount42(n uint64)int8 {
   n = n - ((n >> 1) & 0x5555555555555555)
   n = (n & 0x3333333333333333) + ((n >> 2) & 0x3333333333333333)
   n = (n & 0x0f0f0f0f0f0f0f0f) + ((n >> 4) & 0x0f0f0f0f0f0f0f0f)
   n = n + (n >> 8)
   n = n + (n >> 16)
   n = n + (n >> 32)
   return int8(n & 0x7f)
}

耗時降到了3.91ns ! 降到接近查表法的 1/3了,降到了初版實現的 3.91/91.2 = 4.3%

圖片描述

附:

查表法的實現,能夠把table放在函數外(做爲全局變量),這樣基準測試數據更好看。table放在函數內的話,每次調用會在棧裏面分配空間放table數組的值,這個過程會比較耗時。table做爲全局變量的話,按4bit建表和按8bit建表的測試結果正好反過來,當n爲0時,兩者耗時都是 2ns,當n = 0xffffffffffffffff,前者耗時仍是約11.5ns,後者耗時約 7ns。當 n = 0xffffff(24位全1)時,後者耗時約4ns,和方案4的耗時至關。所以當輸入大部分分佈在0xffffff之內時,選擇8bit建表的查表法,時間性能更優。

能夠藉助pprof分析一下二者在代碼層面的耗時。

命令行裏依次執行:

$ go test -c main_test.go main.go

$ ./main.test -test.bench=. -test.cpuprofile=cpu-profile.prof

$ go tool pprof main.test cpu-profile.prof

接着在pprof的輸入:weblist bitCount

table做爲函數的局部變量:
圖片描述

table做爲全局變量:
圖片描述

當n爲0時,耗時:
圖片描述
當n爲0xffffffffffffffff時,耗時:
圖片描述

總結:

在上述實現的迭代優化過程當中,主要思路是下降循環內代碼塊的執行次數,從固定的64降到取決於最高一個1的位置,再降到比特值爲1的比特位的數量,最後經過查表或者分治下降到常數次數操做(按8bit建表時最多查8次,分治固定6次),查表須要執行一次函數棧內分配數組空間和賦值的過程,會耗時較多;而分治耗時低且表現穩定,是生產環境實現中最常採用的算法。
圖片描述

參考:

variable-precision SWAR算法:

https://ivanzz1001.github.io/...
https://segmentfault.com/a/11...

golang的 pprof 使用:

https://blog.golang.org/profi...

CPU分支預測模型:

https://zhuanlan.zhihu.com/p/...

相關文章
相關標籤/搜索