深入理解 Python 虛擬機器:字典(dict)的最佳化

一無是處的研究僧發表於2023-03-26

深入理解 Python 虛擬機器:字典(dict)的最佳化

在前面的文章當中我們討論的是 python3 當中早期的內嵌資料結構字典的實現,在本篇文章當中主要介紹在後續對於字典的記憶體最佳化。

字典最佳化

在前面的文章當中我們介紹的字典的資料結構主要如下所示:

typedef struct {
    PyObject_HEAD
    Py_ssize_t ma_used;
    PyDictKeysObject *ma_keys;
    PyObject **ma_values;
} PyDictObject;

struct _dictkeysobject {
    Py_ssize_t dk_refcnt;
    Py_ssize_t dk_size;
    dict_lookup_func dk_lookup;
    Py_ssize_t dk_usable;
    PyDictKeyEntry dk_entries[1];
};

typedef struct {
    /* Cached hash code of me_key. */
    Py_hash_t me_hash;
    PyObject *me_key;
    PyObject *me_value; /* This field is only meaningful for combined tables */
} PyDictKeyEntry;

用圖示的方式表示如下圖所示:

所有的鍵值對都儲存在 dk_entries 陣列當中,比如對於 "Hello" "World" 這個鍵值對儲存過程如下所示,如果 "Hello" 的雜湊值等於 8 ,那麼計算出來物件在 dk_entries 陣列當中的下標位 0 。

在前面的文章當中我們談到了,在 cpython 當中 dk_entries 陣列當中的一個物件佔用 24 位元組的記憶體空間,在 cpython 當中的負載因子是 \(\frac{2}{3}\) 。而一個 entry 的大小是 24 個位元組,如果 dk_entries 的長度是 1024 的話,那麼大概有 1024 / 3 * 24 = 8K 的記憶體空間是浪費的。為了解決這個問題,在新版的 cpython 當中採取了一個策略用於減少記憶體的使用。具體的設計如下圖所示:

在新的字典當中 cpython 對於 dk_entries 來說如果正常的雜湊表的長度為 8 的話,因為負載因子是 \(\frac{2}{3}\) 真正給 dk_entries 分配的長度是 5 = 8 / 3,那麼現在有一個問題就是如何根據不同的雜湊值進行物件的儲存。dk_indices 就是這個作用的,他的長度和真正的雜湊表的長度是一樣的,dk_indices 是一個整型陣列這個陣列儲存的是要儲存物件在 dk_entries 當中的下標,比如在上面的例子當中 dk_indices[7] = 0,就表示雜湊值求餘數之後的值等於 7,0 表示物件在 dk_entries 當中的下標。

現在我們再插入一個資料 "World" "Hello" 鍵值對,假設 "World" 的雜湊值等於 8,那麼對雜湊值求餘數之後等於 0 ,那麼 dk_indices[0] 就是儲存物件在 dk_entries 陣列當中的下標的,圖中對應的下標為 1 (因為 dk_entries 陣列當中的每個資料都要使用,因此直接遞增即可,下一個物件來的話就儲存在 dk_entries 陣列的第 3 個(下標為 2)位置)。

記憶體分析

首先我們先來分析一下陣列 dk_indices 的資料型別,在 cpython 的內部實現當中並沒有一刀切的直接將這個陣列當中的資料型別設定成 int 型別。

dk_indices 陣列主要有以下幾個型別:

  • 當雜湊表長度小於 0xff 時,dk_indices 的資料型別為 int8_t ,即一個元素值佔一個位元組。
  • 當雜湊表長度小於 0xffff 時,dk_indices 的資料型別為 int16_t ,即一個元素值佔 2 一個位元組。
  • 當雜湊表長度小於 0xffffffff 時,dk_indices 的資料型別為 int32_t ,即一個元素值佔 4 個位元組。
  • 當雜湊表長度大於 0xffffffff 時,dk_indices 的資料型別為 int64_t ,即一個元素值佔 8 個位元組。

與這個相關的程式碼如下所示:

/* lookup indices.  returns DKIX_EMPTY, DKIX_DUMMY, or ix >=0 */
static inline Py_ssize_t
dictkeys_get_index(const PyDictKeysObject *keys, Py_ssize_t i)
{
    Py_ssize_t s = DK_SIZE(keys);
    Py_ssize_t ix;

    if (s <= 0xff) {
        const int8_t *indices = (const int8_t*)(keys->dk_indices);
        ix = indices[i];
    }
    else if (s <= 0xffff) {
        const int16_t *indices = (const int16_t*)(keys->dk_indices);
        ix = indices[i];
    }
#if SIZEOF_VOID_P > 4
    else if (s > 0xffffffff) {
        const int64_t *indices = (const int64_t*)(keys->dk_indices);
        ix = indices[i];
    }
#endif
    else {
        const int32_t *indices = (const int32_t*)(keys->dk_indices);
        ix = indices[i];
    }
    assert(ix >= DKIX_DUMMY);
    return ix;
}

現在來分析一下相關的記憶體使用情況:

雜湊表長度 能夠儲存的鍵值對數目 老版本 新版本 節約記憶體量(位元組)
256 256 * 2 / 3 = 170 24 * 256 = 6144 1 * 256 + 24 * 170 = 4336 1808
65536 65536 * 2 / 3 = 43690 24 * 65536 = 1572864 2 * 65536 + 24 * 43690 = 1179632 393232

從上面的表格我們可以看到雜湊表的長度越大我們節約的記憶體就越大,最佳化的效果就越明顯。

總結

在本篇文章當中主要介紹了在 python3 當中對於字典的最佳化操作,主要是透過一個記憶體佔用量比較小的陣列去儲存鍵值對在真實儲存鍵值對當中的下標實現的,這個方法對於節約記憶體的效果是非常明顯的。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章