關於一個子集判斷函數的性能分析

最近遇到一個需求,需要做基於日誌標籤的實時分發,基本原理是一條日誌傳遞到程序後,系統採集該日誌的標籤屬性,跟後臺的預定義的多個規則標籤集合進行匹配,如果Ok則將規則與該日誌綁定,等待後期的處理。

比如一條業務日誌具有標籤: dns, usa, 192.168.0.1, 規則庫的當前規則示例:

  • 規則1 : dns
  • 規則2: dns, usa
  • 規則3: dns, usa, 192.168.0.2

則前兩個規則分別捕獲該日誌,用於下一步處理。所以這就是一個簡單的匹配問題。針對集合匹配的話,最簡單直接的方式是兩次循環,查詢是否包含子集合。爲簡單起見這裏使用標籤ID,也就是一個int數組表示一組標籤:

func isSubsetWithLoop(originalSet []int, newSet []int) bool {
	sizeOfOriginal := len(originalSet)
	sizeOfNewSet := len(newSet)
	found := 0
	if sizeOfNewSet == 0 || sizeOfOriginal < sizeOfNewSet {
		return false
	}
	for _, i := range newSet {
	inner:
		for _, j := range originalSet {
			// println(i, j)
			if i == j {
				found++
				break inner
			}
		}
	}
	return sizeOfNewSet == found
}

但是考慮到兩次循環程序的執行復雜度基本上O(n*m)的時間複雜度。所以考慮有沒有更快的方式解決問題。主要的思路有兩個:

  • 排序集合一,對於集合二的數據進行二分查找,獲取是否存在集合一中。時間複雜度 基本上O(mlogm + nlogm) +
  • 排序集合一和集合二, 通過位移的方式比較兩個集合是否包含的關係,時間複雜度 基本上是O(mlogm + nlogn)
  • 使用map集合保存集合一的數據,查詢集合二中數據是否存在map中,時間複雜度 基本上是O(m + n)

看上去使用map可以獲得比較好的時間複雜度算法,具體實施上如下面的所示:

func isSubsetWithMap(originalSet []int, newSet []int) bool {
	sizeOfOriginal := len(originalSet)
	sizeOfNewSet := len(newSet)
	if sizeOfNewSet == 0 || sizeOfOriginal < sizeOfNewSet {
		return false
	}
	mapOfArray := make(map[int]bool, sizeOfOriginal)
	for _, v := range originalSet {
		mapOfArray[v] = true
	}
	for _, i := range newSet {
		if !mapOfArray[i] {
			return false
		}
	}
	return true
}
壓力測試

根據上面的代碼編寫對應的壓力測試,看一下性能是否和之前猜想的一致, 下面的代碼生成對應的測試矩陣, 設定初始規則爲3個標籤,而日誌標籤的範圍則從8-1024, 儘管可能實際並不會那麼多個標籤。

func BenchmarkIsSubset(b *testing.B) {
	subsetFuncs := []struct {
		name string
		fun  func(originalSet []int, newSet []int) bool
	}{

		{"map", isSubsetWithMap},
		{"loop", isSubsetWithLoop},
	}

	for _, subsetFuncStruct := range subsetFuncs {
		subset := getRandomArr(3, 100)
		for k := 3.; k <= 10; k++ {
			max := int(math.Pow(2, k))
			arr := shufferArray(getRandomArr(max, max*2))
			b.Run(fmt.Sprintf("%s/%d/%d", subsetFuncStruct.name, len(subset), len(arr)), func(b *testing.B) {
				for i := 0; i < b.N; i++ {
					subsetFuncStruct.fun(arr, subset)
				}
			})
		}
	}
}

實際執行的時候測試結果如下, 可以發現對於map和loop兩種方式基本上都符合線性的增長趨勢,但是明顯loop會更低一些。按道理來說map查詢應該更快一些纔對?

BenchmarkIsSubset/map/3/8-4         	10000000	       233 ns/op
BenchmarkIsSubset/map/3/16-4        	 2000000	       737 ns/op
BenchmarkIsSubset/map/3/32-4        	 1000000	      1467 ns/op
BenchmarkIsSubset/map/3/64-4        	  500000	      2951 ns/op
BenchmarkIsSubset/map/3/128-4       	  200000	      5636 ns/op
BenchmarkIsSubset/map/3/256-4       	  200000	     11243 ns/op
BenchmarkIsSubset/map/3/512-4       	  100000	     22432 ns/op
BenchmarkIsSubset/map/3/1024-4      	   30000	     44178 ns/op

BenchmarkIsSubset/loop/3/8-4                          	50000000	        35.5 ns/op
BenchmarkIsSubset/loop/3/16-4                         	30000000	        61.3 ns/op
BenchmarkIsSubset/loop/3/32-4                         	20000000	       118 ns/op
BenchmarkIsSubset/loop/3/64-4                         	10000000	       195 ns/op
BenchmarkIsSubset/loop/3/128-4                        	 5000000	       283 ns/op
BenchmarkIsSubset/loop/3/256-4                        	 2000000	       616 ns/op
BenchmarkIsSubset/loop/3/512-4                        	 2000000	       723 ns/op
BenchmarkIsSubset/loop/3/1024-4                       	 1000000	      1615 ns/op
性能分析pporf

使用go tool工具中的pporf工具可以對於程序執行的時間和內存進行分析,因爲內存並不是我們主要考慮的問題,我們僅對於CPU時間進行分析。

再次執行測試並生成對應的測試性能文件:

go test -cpuprofile cpu.prof -bench . -benchtime 2s
go tool pprof cpu.prof

上面的操作會進入命令行模式,執行top10, top20分別返回花費最大的命令執行。儘管isSubsetWithLoop排名第一,但是由於實際上執行的次數也要高於isSubsetWithMap, 所以按照之前的測試看不出單一的函數執行的性能比較。

    flat  flat%   sum%        cum   cum%
    23.57s 42.07% 42.07%     23.57s 42.07%  github.com/zhangmingkai4315/workspace/01-subset.isSubsetWithLoop
    13.63s 24.33% 66.40%     13.63s 24.33%  runtime.pthread_cond_signal
     7.94s 14.17% 80.58%     11.85s 21.15%  runtime.mapassign_fast64
     2.60s  4.64% 85.22%      2.60s  4.64%  runtime.aeshash64
     1.46s  2.61% 87.83%     15.27s 27.26%  github.com/zhangmingkai4315/workspace/01-subset.isSubsetWithMap
     1.38s  2.46% 90.29%     40.22s 71.80%  github.com/zhangmingkai4315/workspace/01-subset.BenchmarkIsSubset.func1
     1.08s  1.93% 92.22%      1.08s  1.93%  runtime.pthread_cond_wait
     0.56s     1% 93.22%      0.56s     1%  runtime.add

修改測試文件:

func BenchmarkIsSubset(b *testing.B) {
	arr := shufferArray(getRandomArr(20, 100))
	subset := getRandomArr(5, 100)
	for i := 0; i < b.N; i++ {
		isSubsetWithMap(arr, subset)
		isSubsetWithLoop(arr, subset)
	}
}

重新執行上面的流程:

      flat  flat%   sum%        cum   cum%
     1.08s 29.59% 29.59%      1.08s 29.59%  runtime.pthread_cond_signal
     0.94s 25.75% 55.34%      1.54s 42.19%  runtime.mapassign_fast64
     0.33s  9.04% 64.38%      0.33s  9.04%  runtime.aeshash64
     0.25s  6.85% 71.23%      2.25s 61.64%  github.com/zhangmingkai4315/workspace/01-subset.isSubsetWithMap
     0.19s  5.21% 76.44%      0.19s  5.21%  github.com/zhangmingkai4315/workspace/01-subset.isSubsetWithLoop
     0.12s  3.29% 79.73%      0.12s  3.29%  runtime.memclrNoHeapPointers
     0.07s  1.92% 81.64%      0.07s  1.92%  runtime.add
     0.07s  1.92% 83.56%      0.08s  2.19%  runtime.overLoadFactor
     0.06s  1.64% 85.21%      0.06s  1.64%  runtime.pthread_cond_wait
     0.05s  1.37% 86.58%      0.05s  1.37%  runtime.bucketShift
     0.04s  1.10% 87.67%      0.06s  1.64%  runtime.mapaccess1_fast64

這樣通過相同次數的執行我們可以比較出不同函數的性能, 可以看到對於isSubsetWithMap函數執行高於Loop的執行(至少在測試子集爲5個,源集合爲20個的情況下)。上面的表格顯示可能生成map結構和分配數據的效率導致整體map的性能低於loop。我們可以在交互窗口中輸入list命令查看具體的每一個函數的執行情況:

(pprof) list github.com/zhangmingkai4315/workspace/01-subset.isSubsetWithMap
Total: 3.65s subset.isSubsetWithMap in /main.go
     250ms      2.25s (flat, cum) 61.64% of Total
  			 .          .     31:		}
         .          .     32:	}
         .          .     33:	return sizeOfNewSet == found
         .          .     34:}
         .          .     35:
      10ms       10ms     36:func isSubsetWithMap(originalSet []int, newSet []int) bool {
         .          .     37:	sizeOfOriginal := len(originalSet)
         .          .     38:	sizeOfNewSet := len(newSet)
         .          .     39:	if sizeOfNewSet == 0 || sizeOfOriginal < sizeOfNewSet {
         .          .     40:		return false
         .          .     41:	}
      10ms      410ms     42:	mapOfArray := make(map[int]bool, sizeOfOriginal)
     110ms      110ms     43:	for _, v := range originalSet {
     120ms      1.66s     44:		mapOfArray[v] = true
         .          .     45:	}
         .          .     46:	for _, i := range newSet {
         .       60ms     47:		if !mapOfArray[i] {
         .          .     48:			return false
         .          .     49:		}
         .          .     50:	}
         .          .     51:	return true
         .          .     52:}

對比 loop版本的代碼執行情況, 基本上沒有太複雜的邏輯和執行花費比較高的代碼。看來不能完全的依賴於O(n)來判斷一個函數是否高效率的依據。

     190ms      190ms (flat, cum)  5.21% of Total
         .          .     15:
         .          .     16:func isSubsetWithLoop(originalSet []int, newSet []int) bool {
         .          .     17:	sizeOfOriginal := len(originalSet)
         .          .     18:	sizeOfNewSet := len(newSet)
         .          .     19:	found := 0
      10ms       10ms     20:	if sizeOfNewSet == 0 || sizeOfOriginal < sizeOfNewSet {
         .          .     21:		return false
         .          .     22:	}
      10ms       10ms     23:	for _, i := range newSet {
         .          .     24:	inner:
     160ms      160ms     25:		for _, j := range originalSet {
         .          .     26:			// println(i, j)
      10ms       10ms     27:			if i == j {
         .          .     28:				found++
         .          .     29:				break inner
         .          .     30:			}
         .          .     31:		}
         .          .     32:	}

同時在交互終端中執行web可以在瀏覽器打開自動生成的的svg文件,查看流程圖。

假如不需要臨時生成map,而是提前生成完成,每次都直接進行比對的話,其實map的版本還是相對較快的。對於代碼不確定性能的時候,還是要多謝一些測試來檢查性能。

另外原始代碼還實現了sort版本的兩個函數,放在github上,大家感興趣的話可以查看, 對於上述的代碼或者文章描述有任何疑問,歡迎大家交流討論。

具體的代碼可以參考查看Github Gist

另外我將該文章同時放在個人的Blog上,歡迎大家訪問:https://zhangmingkai.cn

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章