Redis底層詳解(七) HyperLogLog 基數估計

 

一、HyperLogLog 概述

         HyperLogLog 算法一種概率算法,用來實現大數據下的基數估計,即無法精確計算集合的基數,存在一定偏差。具體的算法實現可以參見我寫的另一篇文章:夜深人靜寫算法(十四)- 基數估計 (Cardinality Estimation)
         Redis 對這個算法進行了一些改進。主要有以下幾點:
         a) 採用 64 位哈希函數,這樣基數數值就不用侷限在 10^9 了,當基數在 2^32 附近的時候,不需要額外進行修正;
         b) 採用 16384 個 6比特的寄存器(桶)提高計算的精確度,每個集合的內存總數僅有 12K (16384 * 6 / 8 / 1024 = 12);
         c) 存儲採用 Redis 自帶的 sds 字符串(Redis底層詳解(二) 字符串),並提供了兩種存儲方式:密集型存儲 和 稀疏型存儲;
         1、算法概述
         改進後算法的簡介如下:
         給定一個集合 S,集合中的元素可以是 整數、指針、字符串 等等任意對象。給定一個 64 位的哈希函數 H,能夠將這些對象進行映射,映射後的值是一個 64 位的比特串(每位是 0 或 1),比特串的低 14 位用來表示寄存器(也稱爲桶)編號,剩下 50 位用來尋找“最長 0 尾綴”。一共 16384 (2 的 14次冪) 個寄存器,用來存儲 “最長 0 尾綴”(原算法用的是前綴,Redis爲了方便計算採用尾綴的形式,其實是一樣的)。然後計算這些 “最長 0 尾綴” 的調和平均數得到一個初始估值 E。然後根據 E 的相對大小再進行二次估值 得到最後的集合基數。其中 E 的計算公式如下:

         m 即寄存器的數量,M[i] 代表第 i 個寄存器中的  “最長 0 尾綴” 的值,C 在 m 確定的情況下是個常量,E 就是調和平均數。
         2、存儲優化
         由於當集合元素較少時,大多數的寄存器的值爲 0,所以在存儲時,可以採用 Run Length Encoding 進行壓縮存儲,這種壓縮後的存儲方式就是稀疏型存儲(HLL_SPARSE),對應的,未壓縮前的存儲方式就是密集型存儲(HLL_DENSE)。兩種存儲方式對應的核心算法是一致的,只不過在持久化時採用不同的存儲方式,介於篇幅的關係,本文只介紹密集型存儲。

二、HyperLogLog 結構

          1、HyperLogLog 頭

          無論是密集型存儲還是稀疏型存儲,都有一個頭結構 hllhdr,定義如下:

struct hllhdr {
    char magic[4];      
    uint8_t encoding;   
    uint8_t notused[3]; 
    uint8_t card[8];    
    uint8_t registers[]; 
};

#define HLL_HDR_SIZE sizeof(struct hllhdr)
#define HLL_DENSE_SIZE (HLL_HDR_SIZE+((HLL_REGISTERS*HLL_BITS+7)/8))
#define HLL_DENSE 0
#define HLL_SPARSE 1

        magic[4] 爲四個 “魔法” (固定不變的意思)字符,即 “HYLL”,代表這是一個 HyperLogLog 結構,區分於普通的 sds 字符串;
        encoding 代表存儲的編碼方式,總共兩種:密集型編碼存儲 HLL_DENSE、稀疏型編碼存儲 HLL_SPARSE;
        notused[3] 爲三個暫時不用到的保留字節,值爲0;
        card[8] 以小端序存儲了一個 64 位的整數,代表上次計算出來的近似集合基數。這個是很有用的,因爲每次在執行集合插入的過程中,有很大概率是不會改變原有的數據結構的,這樣這個近似的基數值就可以直接拿過來用而不需要重新計算。
        registers[] 存儲了寄存器,根據 encoding 來決定存儲的編碼方式。寄存器的作用會在下文中詳述。以下是 HyperLogLog 的內存結構的示意圖(可以理解成整個 HyperLogLog 結構就是一個以 "HYLL" 爲首的 sds 字符串):

        2、HyperLogLog 寄存器

        寄存器(register)又名 桶 (bucket),每個寄存器用來存儲分配給它的元素在進行哈希後的 “最長 0 尾綴” 的值,HyperLogLog 正是利用所有寄存器的值來計算調和平均數,最終得到集合的基數的初始估計值。一些寄存器常量定義如下:

#define HLL_P 14
#define HLL_REGISTERS (1<<HLL_P)
#define HLL_P_MASK (HLL_REGISTERS-1)

#define HLL_BITS 6
#define HLL_REGISTER_MAX ((1<<HLL_BITS)-1)

        HLL_P 的值越大,計算得到的基數的誤差越小。HLL_REGISTERS 是寄存器的總數量,值爲 16384 。HLL_P_MASK 用來將取模運算轉化成 位與,下面會講到。

三、HyperLogLog 算法詳解

        1、最長 0 尾綴
        在分析 HyperLogLog 算法之前,先來看下之前提到的 “最長 0 尾綴” 的實現方式,hllPatLen 的作用是將傳入的字符串 ele,通過 MurmurHash64 哈希算法得到一個 64 比特的串,然後將這個串的低 14 位作爲寄存器編號,高 50 用來計算 “0” 尾綴。那麼顧名思義,“最長 0 尾綴” 就是在寄存器編號相同的情況下,“0” 尾綴的最大值。實現如下:

int hllPatLen(unsigned char *ele, size_t elesize, long *regp) {
    uint64_t hash, bit, index;
    int count;
    hash = MurmurHash64A(ele,elesize,0xadc83b19ULL);      /* a */
    index = hash & HLL_P_MASK;                            /* b */
    hash |= ((uint64_t)1<<63);                            /* c */
    bit = HLL_REGISTERS;
    count = 1;
    while((hash & bit) == 0) {
        count++;
        bit <<= 1;
    }
    *regp = (int) index;
    return count;
}

       a、MurmurHash64A 是一個哈希算法:MurmurHash2。通過傳入的字符串計算出一個 64 位的整數 (其中 0xadc83b19ULL 是哈希計算時的種子 seed)。Redis 中的這個函數修改了一下,支持大端序和小端序兩種版本。
       b、hash & HLL_P_MASK 的含義是 hash % HLL_REGISTERS,作用是通過取模找到對應的寄存器。用 & 有兩個好處:位運算速度遠快於取模;取模可能產生負數,而 & 不會。寄存器編號會存儲在 regp 並通過指針的方式返回出去。
       c、從 hash 這個 64 位整數的低位開始第 14 位 (即 HLL_P,0 - based)開始往高位一位一位的找,找到第一個值爲 1 的位,中間如果有 k 個 0,則 “0” 尾綴的值 count = k + 1。爲了不在 while 時產生死循環,將 hash 的最高位 (第 63 位) 用位或設置爲 1。
       如下圖所示,分別表示兩個 hash 值的 64 個比特位,內存從上往下遞增,每個字節的右側爲比特位的低位(LSB),左側爲高位(MSB)。第一個 hash 從 14 位開始遍歷 4 次找到第一個 1,所以“0” 尾綴的值爲4;第二個 hash 的 14 位已經是 1 了,所以“0” 尾綴的值爲 1 。

       那麼,所有的集合元素計算完畢,每個寄存器的值(最長0尾綴的值)也就得到了。

       2、密集型存儲
       密集型存儲相對於稀疏型存儲來說比較簡單,採用緊湊型的編碼方式,每 HLL_BITS (默認爲 6)個比特位存儲一個寄存器,所以寄存器能夠存儲的最大值爲 63 (二進制表示爲 6 個 1),即 HLL_REGISTER_MAX 宏定義。
       但是計算機存儲的基本單元是字節,一個字節 8 個比特位,所以爲了節約內存,某些寄存器是跨字節存儲的。
       如下圖所示,紅色代表 0 號寄存器的 6個比特位,橙色代表 1 號寄存器,黃色代表 2 號,以此類推。6 和 8 的最小公倍數爲 24,所以 3個字節一個週期。每 3 個字節存儲 4 個寄存器,總共 16384 個寄存器,佔用字節數爲 16384 / 4 * 3 = 12288 = 12K。

       3、寄存器值獲取
       寄存器的獲取通過宏 HLL_DENSE_GET_REGISTER 實現。源碼實現如下:

#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \
    unsigned long _fb = regnum*HLL_BITS&7; \
    unsigned long _fb8 = 8 - _fb; \
    unsigned long b0 = _p[_byte]; \
    unsigned long b1 = _p[_byte+1]; \
    target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
} while(0)

       target 用於存儲寄存器的值,當返回值用;p 是所有寄存器內存首地址;regnum 表示第幾個寄存器(0-based)。這個宏定義的含義就是獲取第 regnum 個寄存器存的值並且返回到 target 中。讓我們來看下這些位運算的含義。
       首先,由於寄存器的長度(6個比特位)小於 1 個字節的比特位長度,所以每個寄存器最多跨越兩個字節,我們定義內存地址小的叫低字節,地址大的叫高字節。所以低字節的高 k 位 和 高字節的低 (6 - k) 位組成了一個完整的寄存器。如圖所示,淺藍色部分是一個完整的寄存器地址,它由低字節的高 2 位和高字節的低 4 位組成。

       _byte 用來計算第 regnum 個寄存器的低字節相對於 p 的字節偏移量。那麼 p[_byte] 和 p[_byte+1] 就分別代表了上文提到的低字節和高字節的值。_fb 的 “位與 7 ” 相當於 “模 8 ”,再用 8 去減後得到 _fb8,這裏的  _fb8 就是上圖中得到的 k 。最後 target 的值分四步獲得:
        (1) 低字節的值右移 ( 8 - k ) 位,得到 x;
        (2) 高字節的值左移 k,得到 y;
        (3) x 位或 y,得到 z;
        (4) z 位與 HLL_REGISTER_MAX,截掉無用高位,得到最終值 target;
        舉例說明,當 regnum = 5 時,則需要取到藍色寄存器的值,k 的值爲 8 - 5 * 6 % 8 = 2。計算 target 的過程如下:

        4、寄存器值設置
        寄存器的設置通過宏 HLL_DENSE_SET_REGISTER 實現。源碼實現如下:

#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \
    unsigned long _fb = regnum*HLL_BITS&7; \
    unsigned long _fb8 = 8 - _fb; \
    unsigned long _v = val; \
    _p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \
    _p[_byte] |= _v << _fb; \
    _p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \
    _p[_byte+1] |= _v >> _fb8; \
} while(0)

        這個宏定義的含義就是將第 regnum 個寄存器存的值設置爲 val 。和獲取一樣,同樣需要分成高低兩字節進行設置。假設低字節的高 k 位 和 高字節的低 (6 - k) 位組成了一個完整的寄存器。 那麼只要截取 val 的低 k 位 和 高 (6 - k) 位分別放到寄存器的對應位置就行了。
        對於寄存器的低字節操作如下:
        (1) HLL_REGISTER_MAX 左移 (8 - k) 位 , 然後取反得到 x ,使得 x 的高 k 位等於 0;
        (2) 低字節位與上 x,目的是清空低字節的高 k 位的值;
        (3) val 左移 (8 - k) 位得到 y ;
        (4) 低字節 位或 y ,將 val 的低 k 位填充到 低字節的高 k 位上;
        舉例說明,當 regnum = 5 時,則需要設置藍色寄存器的值,k 的值爲 8 - 5 * 6 % 8 = 2。低字節的計算過程如下:

        對於寄存器的高字節操作如下:
        (1) HLL_REGISTER_MAX 右移 k 位 , 然後取反得到 x ,使得 x 的低 (6 - k) 位等於 0;
        (2) 高字節位與上 x,目的是清空高字節的低 (6 - k) 位的值;
        (3) val 右移 k 位得到 y ;
        (4) 高字節 位或 y ,將 val 的高 (6 - k) 位填充到 高字節的低 (6 - k) 位上;
         舉例說明,同樣當 regnum = 5 時,則需要設置藍色寄存器的值,k 的值爲 8 - 5 * 6 % 8 = 2。高字節的計算過程如下:

        結合以上兩種情況,我們就將 val 的值設置到藍色寄存器上了,如圖所示(val 前兩個比特位爲虛線是因爲 val 的值不會超過 2 的 6 次 減 1,即 63):

        5、集合添加元素
        集合增加元素調用 hllAdd 接口,這對應的是更新 "最長 0 尾綴" 的過程,實現如下:

int hllAdd(robj *o, unsigned char *ele, size_t elesize) {
    struct hllhdr *hdr = o->ptr;
    switch(hdr->encoding) {
        case HLL_DENSE: return hllDenseAdd(hdr->registers,ele,elesize);
        case HLL_SPARSE: return hllSparseAdd(o,ele,elesize);
        default: return -1;
    }
}

        根據存儲方式選擇不同的接口,密集型存儲調用 hllDenseAdd,稀疏型存儲調用 hllSparseAdd。稀疏型存儲的實現相對較複雜,這裏以密集型存儲實現爲例來說明實現機理。添加元素的過程的並非真正的元素添加,只有當傳入的字符串算出來的 "最長 0 尾綴" 比對應寄存器中存的值大的時候才做更新,否則保持原樣。主要用到了上文提到的兩個宏定義:HLL_DENSE_GET_REGISTER 和  HLL_DENSE_SET_REGISTER。源碼在 hyperloglog.c 中,實現如下:

int hllDenseAdd(uint8_t *registers, unsigned char *ele, size_t elesize) {
    uint8_t oldcount, count;
    long index;
    count = hllPatLen(ele,elesize,&index);
    HLL_DENSE_GET_REGISTER(oldcount,registers,index);
    if (count > oldcount) {
        HLL_DENSE_SET_REGISTER(registers,index,count);
        return 1;
    } else {
        return 0;
    }
}

        6、集合元素統計
        統計就是利用 16384 個寄存器計算調和平均數的過程,調用 hllCount 接口完成統計工作,實現如下:

uint64_t hllCount(struct hllhdr *hdr, int *invalid) {
    double m = HLL_REGISTERS;
    double E, alpha = 0.7213/(1+1.079/m);
    int j, ez;
    ...                                                               /* a */
    if (hdr->encoding == HLL_DENSE) {                                 /* b */
        E = hllDenseSum(hdr->registers,PE,&ez);
    } else if (hdr->encoding == HLL_SPARSE) {
        E = hllSparseSum(hdr->registers,
           sdslen((sds)hdr)-HLL_HDR_SIZE,PE,&ez,invalid);
    } else if (hdr->encoding == HLL_RAW) {
        E = hllRawSum(hdr->registers,PE,&ez);
    } else {
        serverPanic("Unknown HyperLogLog encoding in hllCount()");
    }
    E = (1/E)*alpha*m*m;                                              /* c */

    if (E < m*2.5 && ez != 0) {
        E = m*log(m/ez);                                              /* d */
    } else if (m == 16384 && E < 72000) {
        double bias = 5.9119*1.0e-18*(E*E*E*E)                        /* e */
                      -1.4253*1.0e-12*(E*E*E)+
                      1.2940*1.0e-7*(E*E)
                      -5.2921*1.0e-3*E+
                      83.3216;
        E -= E*(bias/100);
    }
    return (uint64_t) E;
}

        a) 初始化計算 PE 的過程,PE[i] = 2 ^ (-i),爲了避免重複計算,節省時間,所以預處理在數組 PE[] 中;
        b) hllDenseSum、hllSparseSum 分別表示在不同存儲方式下計算調和平均數的分母(下文作詳細講解)。hllRawSum 是原生的存儲方式,用1個字節代表一個寄存器,不對外開放,用於加速計算;
        c) 這個公式是本文開始提到的初始估值的計算公式;
        d) 數據量比較小時採用線性估值(Linear Counting);
        e) 誤差修正;
        hllDenseSum 求的是如下式子,其中 r 爲寄存器數組,max 是寄存器數量:

先來看代碼如何實現的,同樣在 hyperloglog.c 中: 

double hllDenseSum(uint8_t *registers, double *PE, int *ezp) {
    double E = 0;
    int j, ez = 0;
    if (HLL_REGISTERS == 16384 && HLL_BITS == 6) {
        uint8_t *r = registers;
        unsigned long r0, r1, r2, r3, r4, r5, r6, r7, r8, r9,
                      r10, r11, r12, r13, r14, r15;
        for (j = 0; j < 1024; j++) {
            r0 = r[0] & 63; if (r0 == 0) ez++;
            r1 = (r[0] >> 6 | r[1] << 2) & 63; if (r1 == 0) ez++;
                      ....... 此處省略,節約篇幅
            r14 = (r[10] >> 4 | r[11] << 4) & 63; if (r14 == 0) ez++;
            r15 = (r[11] >> 2) & 63; if (r15 == 0) ez++;
            E += (PE[r0] + PE[r1]) + (PE[r2] + PE[r3]) + (PE[r4] + PE[r5]) +
                 (PE[r6] + PE[r7]) + (PE[r8] + PE[r9]) + (PE[r10] + PE[r11]) +
                 (PE[r12] + PE[r13]) + (PE[r14] + PE[r15]);
            r += 12;
        }
    } else {
        for (j = 0; j < HLL_REGISTERS; j++) {
            unsigned long reg;
            HLL_DENSE_GET_REGISTER(reg,registers,j);
            if (reg == 0) ez++;
            else E += PE[reg];
        }
        E += ez;
    }
    *ezp = ez;
    return E;
}

        計算密集型存儲,各個寄存器的相反數作爲指數,且底數爲 2 的冪的和。其中 PE[i] = 2 ^ (-i),爲了避免重複計算,節省時間,所以預處理在數組 PE[] 中。*ezp 記錄了值爲 0 的寄存器數量。爲了提高效率,當 HLL_REGISTERS 等於 16384 且 HLL_BITS 等於 6 時,這種屬於默認情況,所以爲了提高效率寫了一堆位運算進行優化。如果已經理解寄存器的存儲結構,那麼這段代碼很容易理解,它的執行效果和 else 語句裏執行 HLL_REGISTERS 次 for 的效果是一樣的,都是爲了求 E = Sum(r, max)。

四、HyperLogLog 常用命令

        1、pfadd 向集合中增加元素

pfadd key element [element ...]

127.0.0.1:6370> pfadd hyperloglog_key1 1 2 3 4 5 6
(integer) 1
127.0.0.1:6370> pfadd hyperloglog_key2 0 3 4 5 
(integer) 1

        2、pfcount 統計集合中元素個數

pfcount key [key ...]

127.0.0.1:6370> pfcount hyperloglog_key1
(integer) 6
127.0.0.1:6370> pfcount hyperloglog_key2
(integer) 4
127.0.0.1:6370> pfcount hyperloglog_key1 hyperloglog_key2
(integer) 7

        3、pfmerge 對集合作合併操作

pfmerge destkey sourcekey [sourcekey ...]

127.0.0.1:6370[1]> pfmerge hyperloglog_keymerge hyperloglog_key1 hyperloglog_key2
OK
127.0.0.1:6370[1]> pfcount hyperloglog_keymerge
(integer) 7

        由於 HyperLogLog 結構並沒有存儲實際的集合元素,所以無法輸出集合的成員。

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