細說 PEP 468: Preserving Keyword Argument Order

AdjWang發表於2020-05-05

細說 PEP 468: Preserving Keyword Argument Order

Python 3.6.0 版本對字典做了優化,新的字典速度更快,佔用記憶體更少,非常神奇。從網上找了資料來看,大部分都指向了 [Python-Dev] More compact dictionaries with faster iteration 這篇文章,裡面概括性地介紹了舊字典和新字典的差別,以及優化的地方,很有創意。

然而我非常好奇這樣的結構是怎麼用C語言實現的,所以去看了原始碼。我分別找到 3.5.9 和 3.6.0 版本的 Python 字典原始碼,對比了一下,發現 Python 裡字典的實現到處都是神操作,令人振奮。於是,一個想法產生了,不如就從原始碼角度,細說一下 PEP 468 對字典的改進,也算是對 [Python-Dev] More compact dictionaries with faster iteration 的補充。

如果上來就對比 3.5.9 和 3.6.0 的程式碼差異,是沒有辦法把事情說清楚的,所以我還得多囉嗦一些,把字典資料結構先完整地分析一下,然後就可以愉快地對比差異了 : )

如無特殊說明,預設參考Python 3.6.0版本。

新特性

在 Python 的新特性變更記錄頁面,可以看到 Python 從 3.6 版本開始,支援有序字典,而且記憶體佔用更少。

Python 3.6.0 beta 1

Release date: 2016-09-12

Core and Builtins

  • ...
  • bpo-27350: dict implementation is changed like PyPy. It is more compact and preserves insertion order. (Concept developed by Raymond Hettinger and patch by Inada Naoki.)
  • ...

dict 資料結構簡述

我本來想拿 Python 3.5.9 的結構來對比一下,不過後來想了想,沒有必要。二者差異不大,不需要把對比搞得很麻煩,我直接介紹 Python 3.6.0 的字典結構,然後直接對比原始碼細節,就已經夠清楚了。再參考 [Python-Dev] More compact dictionaries with faster iteration ,就更清晰了。

結構

涉及到字典物件的結構主要有3個:

  • PyDictObject (./Include/dictobject.h)
  • PyDictKeysObject (./Objects/dict-common.h) 就是標頭檔案裡面的 struct _dictkeysobject
  • PyDictKeyEntry (./Objects/dict-common.h)

image-20200504163755031

下面依次說明一下各個資料的定義:

  • PyDictObject

    字典物件,Python裡面所有字典,不管是我們自己用dict()建立的還是類的__dict__屬性,都是它。

    • PyObject_HEAD

      Python裡所有東西都是物件,而且這些物件都是無型別的,那麼想想這兩個問題:在C語言裡,型別是固定的,而且沒有執行時型別檢查,那麼怎麼樣實現動態呼叫呢?動態呼叫以後怎麼識別型別呢?

      沒錯,就是"看臉",假如每個物件都有一個這樣的PyObject_HEAD,其中包含型別資訊,那麼就可以用指標動態呼叫,然後根據其中的型別資訊動態識別型別了。打個比方,假如你的物件很多很多,TA們的身高體重長相各自都是固定的,你今天約這個,明天約那個,“型別”變了怎麼辦?不礙事呀,用手機“動態呼叫”,聽聲音或者見面“識別型別”,一個道理嘛,哈哈哈哈哈哈哈……

      再多說一句,一定要做好“型別檢查”,如果讓你的物件發現你心裡想的是別人,那就翻車了!這時候程式就出錯崩潰了!

    • ma_used

      當前字典裡的item數量。

    • ma_version_tag

      有一個64位無符號全域性變數pydict_global_version,字典的建立和每次更改,都會給這個全域性變數+1,然後賦值(不是引用)ma_version_tag。所以在不同的時刻,只需要看ma_version_tag變沒變,就知道字典變沒變,不需要檢查字典的內容。這個特性可以優化程式。參考 PEP 509 -- Add a private version to dict

    • PyDictKeysObject *ma_keys

      字典的鍵物件指標,雖然這個物件叫KeysObject,但是裡面也有value,在"combined"模式下value(即 me_value)有效;而在"splitted"模式下me_value無效,改用PyObject **ma_values

      • dk_refcnt

        在"splitted"模式下,一個PyDictKeysObject被很多PyDictObject共用,這個引用計數就起作用了。

      • dk_size

        字典雜湊表空間大小,指實際申請的記憶體空間,類似C++裡vectorcapacity屬性的含義。

        這個值也是陣列dk_indices的大小,必須是 2 的整數次冪,不夠用時再動態擴容。

        儘管好奇心害死貓,不過為什麼必須是 2 的整數次冪?我摘出來幾行程式碼。

        #define DK_MASK(dk) (((dk)->dk_size)-1)
        size_t mask = DK_MASK(k);
        i = (size_t)hash & mask;	// 通過雜湊值計算雜湊表索引
        

        這就明白了,雜湊值的資料型別是size_t,可能導致雜湊表訪問越界,所以要對雜湊表長度取餘數,為了用與操作加速取餘數運算,把dk_size規定為 2 的整數次冪。

      • dk_lookup

        查詢函式,從雜湊表中找出指定元素。共有4個函式。

        /* Function to lookup in the hash table (dk_indices):
        ​ - lookdict(): general-purpose, and may return DKIX_ERROR if (and
        ​ only if) a comparison raises an exception.

        ​ - lookdict_unicode(): specialized to Unicode string keys, comparison of
        ​ which can never raise an exception; that function can never return
        ​ DKIX_ERROR.

        ​ - lookdict_unicode_nodummy(): similar to lookdict_unicode() but further
        ​ specialized for Unicode string keys that cannot be the value.

        ​ - lookdict_split(): Version of lookdict() for split tables. */

        Python 裡大量用到以字串作為key的字典,所以對它做了專門的優化,儘量多用字串作為key吧!

      • dk_usable

        字典裡的可用entry(hash-key-value)數量,為了降低雜湊碰撞,只佔dk_size2/3,由USABLE_FRACTION巨集設定。

        這個值在初始化時也是陣列dk_entriesma_values的大小,不夠用時再動態擴容。

      • dk_nentries

        陣列dk_entriesma_values的已用entry數量。

      • dk_indices

        雜湊索引表陣列,它是一個雜湊表,但是儲存的內容是dk_entries裡元素的索引。

        參考 PEP 468 -- Preserving the order of **kwargs in a function.

      • PyDictKeyEntry dk_entries[dk_usable]

        Python 裡管一個hash-key-value的組合叫一個entry,這個概念會經常出現。注意它和ma_values的區別,dk_entries是一個陣列,儲存區域緊跟在dk_indices後面,而ma_values是一個指標,指向的儲存區域並不在PyDictObject末尾。在分析dictresize()函式的時候,會看到這個特性帶來的影響。

        • me_hash
        • me_key
        • me_value
      • ...(下一個PyDictKeyEntry)

    • PyObject **ma_values

      Python 3.3 引入了新的字典實現方式: splitted dict,這是一個針對類屬性實現的結構,想象這樣的應用場景:一個類,定義好以後屬性名字不變(假設不動態更改),它有很多不同的例項,這些例項屬性值不同,但是屬性名字相同,如果這些__dict__共用一套key,可以節約記憶體。參考 PEP 412 -- Key-Sharing Dictionary

      在這個模式下,多個不同的PyDictObject物件裡面的ma_keys指標指向同一個PyDictKeysObject物件。原來的字典裡的entry(hash-key-value)是一個整體,牽一髮而動全身,現在key合併了,也就意味著entry合併了,所以value也被迫合併了,但是我們不能讓value合併,因為這種模式下不同的PyDictKeysObject物件的key一樣,但是value不一樣,沒有辦法,就只好在entry結構外面新增value陣列,代替被迫合併的entry->value,這個外加的value陣列就分別附加到多個不同的PyDictObject物件後面。這個做法分開了keyvalue,所以取名"splitted"

      /* If ma_values is NULL, the table is "combined": keys and values
      ​ are stored in ma_keys.

      ​ If ma_values is not NULL, the table is splitted:
      ​ keys are stored in ma_keys and values are stored in ma_values */

      PyObject **ma_values;

      此外,“splitted”模式還有2個條件,與類屬性吻合:

      Only string (unicode) keys are allowed.
      All dicts sharing same key must have same insertion order.

原始碼

把 Python 3.5.9 版本和 3.6.0 版本的結構體拿出來對比一下,Python 3.6.0 加了很多在 Python 3.5.9 裡面沒有的註釋,非常優秀的行為!!不過這裡只保留了不同部分的註釋。

/* ./Objects/dict-common.h */
/* Python 3.5.9 */
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];
};

/* Python 3.6.0 */
struct _dictkeysobject {
    Py_ssize_t dk_refcnt;
    Py_ssize_t dk_size;
    dict_lookup_func dk_lookup;
    Py_ssize_t dk_usable;
    
    /* Number of used entries in dk_entries. */
    Py_ssize_t dk_nentries;

    /* Actual hash table of dk_size entries. It holds indices in dk_entries,
       or DKIX_EMPTY(-1) or DKIX_DUMMY(-2).

       Indices must be: 0 <= indice < USABLE_FRACTION(dk_size).

       The size in bytes of an indice depends on dk_size:

       - 1 byte if dk_size <= 0xff (char*)
       - 2 bytes if dk_size <= 0xffff (int16_t*)
       - 4 bytes if dk_size <= 0xffffffff (int32_t*)
       - 8 bytes otherwise (int64_t*)

       Dynamically sized, 8 is minimum. */
    union {
        int8_t as_1[8];
        int16_t as_2[4];
        int32_t as_4[2];
#if SIZEOF_VOID_P > 4
        int64_t as_8[1];
#endif
    } dk_indices;

    /* "PyDictKeyEntry dk_entries[dk_usable];" array follows:
       see the DK_ENTRIES() macro */
};

通過註釋可以知道這些新新增的變數的用途,不過在結構體裡面,dk_entries的定義註釋掉了,這是怎麼回事呢?根據註釋的指引,找到DK_ENTRIES一探究竟。

/* Python 3.6.0 */
/* ./Objects/dictobject.c */
#define DK_SIZE(dk) ((dk)->dk_size)
#if SIZEOF_VOID_P > 4
#define DK_IXSIZE(dk)                          \
    (DK_SIZE(dk) <= 0xff ?                     \
        1 : DK_SIZE(dk) <= 0xffff ?            \
            2 : DK_SIZE(dk) <= 0xffffffff ?    \
                4 : sizeof(int64_t))
#else
#define DK_IXSIZE(dk)                          \
    (DK_SIZE(dk) <= 0xff ?                     \
        1 : DK_SIZE(dk) <= 0xffff ?            \
            2 : sizeof(int32_t))
#endif
#define DK_ENTRIES(dk) \
    ((PyDictKeyEntry*)(&(dk)->dk_indices.as_1[DK_SIZE(dk) * DK_IXSIZE(dk)]))
  • DK_SIZE取得dk_size,也就是陣列dk_indices的元素數量
  • DK_IXSIZE根據dk_size設定當前dk_indices每個元素佔用的位元組數
  • DK_ENTRIES根據dk物件(PyDictKeysObject物件)取得dk_entries陣列首地址

於是,dk_indices.as_1[DK_SIZE(dk) * DK_IXSIZE(dk)]就會定位到dk_indices後面的第一個地址,也就是dk_indices剛好越界的地方。什麼?越界?對,因為後面緊跟著的就是dk_entries對應的空間,DK_ENTRIES巨集取得的這個地址就是dk_entries陣列的首地址。多麼有趣的玩法 : )

為什麼要搞這麼麻煩呢?像 Python 3.5.9 裡面那樣直接定義dk_entries不好嗎?我想這大概是因為dk_indices也是動態的。如果直接定義dk_entries,那它的首地址相對結構體而言就是固定的,當dk_indices陣列長度動態變化的時候,使用&dk->dk_entries[0]這樣的語句就會得到錯誤的地址。具體的記憶體分佈還需要看new_keys_object()函式。

為什麼小

接著上面的內容,分析new_keys_object()函式,從這裡可以看到PyDictKeysObject物件的記憶體分佈。我在關鍵位置加了註釋,省略一些不影響理解流程的程式碼。

/* Python 3.6.0 */
/* ./Objects/dictobject.c */
...
/* Get the size of a structure member in bytes */
#define Py_MEMBER_SIZE(type, member) sizeof(((type *)0)->member)
...
static PyDictKeysObject *new_keys_object(Py_ssize_t size)
{
    PyDictKeysObject *dk;
    Py_ssize_t es, usable;
    
    assert(size >= PyDict_MINSIZE);
    assert(IS_POWER_OF_2(size));
    
    // dk_indices 有 2/3 能用(usable), 1/3 不使用
    // PyDictKeyEntry dk_entries[dk_usable] 只申請 usable 部分記憶體
    usable = USABLE_FRACTION(size);     // 2/3
    if (size <= 0xff) {
        es = 1;     // 位元組數
    }
    else if (size <= 0xffff) {
        ...
    }

    // 為 PyDictKeysObject *dk 申請記憶體
    // 使用快取池
    if (size == PyDict_MINSIZE && numfreekeys > 0) {
        dk = keys_free_list[--numfreekeys];
    }
    else {
        dk = PyObject_MALLOC(// Py_MEMBER_SIZE 得到 dk_indices 之前的大小
                             sizeof(PyDictKeysObject)
                             - Py_MEMBER_SIZE(PyDictKeysObject, dk_indices)
                             // 位元組數 * dk_indices元素數量
                             + es * size
                             // PyDictKeyEntry dk_entries[dk_usable]
                             // 這部分內容沒有在 struct _dictkeysobject 結構體裡定義,但是實際申請了空間
                             // 因為 dk_indices 長度也是可變的,所以使用 DK_ENTRIES 巨集來操作 dk_entries
                             // 為了節約空間,只申請 usable 部分,所以 dk_indices 比 dk_entries 長
                             + sizeof(PyDictKeyEntry) * usable);
        ...
    }
    DK_DEBUG_INCREF dk->dk_refcnt = 1;
    dk->dk_size = size;
    dk->dk_usable = usable;
    dk->dk_lookup = lookdict_unicode_nodummy;
    dk->dk_nentries = 0;
    // dk_indices 初始化為0xFF 對應 #define DKIX_EMPTY (-1)
    memset(&dk->dk_indices.as_1[0], 0xff, es * size);
    // dk_entries 初始化為 0
    // DK_ENTRIES 巨集用於定位 dk_entries,相當於 &dk->dk_entries[0]
    memset(DK_ENTRIES(dk), 0, sizeof(PyDictKeyEntry) * usable);
    return dk;
}

PyObject_MALLOC申請到的記憶體,就是這個字典的PyDictKeysObject物件,這個結構體記憶體可以分為3部分:

  1. dk_indices之前的部分:sizeof(PyDictKeysObject) - Py_MEMBER_SIZE(PyDictKeysObject, dk_indices)

    用標頭檔案定義的結構體大小減去dk_indices的大小,就是dk_indices之前的部分,包含dk_refcnt, dk_size, dk_lookup, dk_usable, dk_nentries

  2. dk_indiceses * size

    位元組數 * dk_indices元素數量。

  3. dk_entriessizeof(PyDictKeyEntry) * usable)

    從這裡可以看到,dk_entries的長度不是size,只申請 usable 部分。

再對比一下 Python 3.5.9 的dk = PyMem_MALLOC(...)記憶體申請和dk_entries定址,就可以明白二者巨大的差異。

/* Python 3.5.9 */
/* ./Objects/dictobject.c */
static PyDictKeysObject *new_keys_object(Py_ssize_t size)
{
    PyDictKeysObject *dk;
    Py_ssize_t i;
    PyDictKeyEntry *ep0;
    ...
    dk = PyMem_MALLOC(sizeof(PyDictKeysObject) +
                      // 結構體裡面 PyDictKeyEntry dk_entries[1] 加上這裡 size-1,共size個
                      sizeof(PyDictKeyEntry) * (size-1));
    ...
    ep0 = &dk->dk_entries[0];
    /* Hash value of slot 0 is used by popitem, so it must be initialized */
    ep0->me_hash = 0;
    for (i = 0; i < size; i++) {
        ep0[i].me_key = NULL;
        ep0[i].me_value = NULL;
    }
    dk->dk_lookup = lookdict_unicode_nodummy;
    return dk;
}

對比一下這 2 個PyObject_MALLOC()函式申請的記憶體空間,就知道為什麼新的字典佔用記憶體更少了。

分析完記憶體佈局, PEP 468 的改進就非常清晰了,現在可以對照 PEP 468 提供的資料確認一下,如果有一種豁然開朗的感覺,那就對了;如果沒有,可能是茂密的頭髮阻礙了你變強,建議剃光。跟隨 PEP 468 說明連結找到 [Python-Dev] More compact dictionaries with faster iteration ,裡面描述的第一個entries陣列對應 3.5.9 版本的dk_entries;後面的indicesentries對應 3.6.0 版本的dk_indicesdk_entries陣列,跟上面的程式碼對上了。

The current memory layout for dictionaries is unnecessarily inefficient. It has a sparse table of 24-byte entries containing the hash value, key pointer, and value pointer.

Instead, the 24-byte entries should be stored in a dense table referenced by a sparse table of indices.

For example, the dictionary:

d = {'timmy': 'red', 'barry': 'green', 'guido': 'blue'}

is currently stored as:

entries = [['--', '--', '--'],
[-8522787127447073495, 'barry', 'green'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
[-9092791511155847987, 'timmy', 'red'],
['--', '--', '--'],
[-6480567542315338377, 'guido', 'blue']]

Instead, the data should be organized as follows:

indices = [None, 1, None, None, None, 0, None, 2]
entries = [[-9092791511155847987, 'timmy', 'red'],
[-8522787127447073495, 'barry', 'green'],
[-6480567542315338377, 'guido', 'blue']]

Only the data layout needs to change. The hash table algorithms would stay the same. All of the current optimizations would be kept, including key-sharing dicts and custom lookup functions for string-only dicts. There is no change to the hash functions, the table search order, or collision statistics.

看完原始碼,對這個說明的理解就更加深刻了吧,嘿嘿 : )

不過,到這裡還沒完,new_keys_object()函式只是建立了PyDictKeysObject物件,最終目標應該是PyDictObject,建立PyDictObject物件的函式是PyDict_New()

/* Python 3.6.0 */
/* ./Objects/dictobject.c */
PyObject *
PyDict_New(void)
{
    PyDictKeysObject *keys = new_keys_object(PyDict_MINSIZE);
    if (keys == NULL)
        return NULL;
    return new_dict(keys, NULL);	// combined 模式下 values 是 NULL
}

new_keys_object()看過了,接著看看new_dict(),仍然省略掉部分型別檢查和異常檢查程式碼。

/* Python 3.6.0 */
/* ./Objects/dictobject.c */
static PyObject *
new_dict(PyDictKeysObject *keys, PyObject **values)
{
    PyDictObject *mp;
    assert(keys != NULL);
    if (numfree) {
        // 快取池
        mp = free_list[--numfree];
        ...
        _Py_NewReference((PyObject *)mp);
    }
    else {
        mp = PyObject_GC_New(PyDictObject, &PyDict_Type);
        ...
    }
    mp->ma_keys = keys;			// 傳遞 new_keys_object 函式生成的 PyDictKeysObject 物件
    mp->ma_values = values;		// combined 模式下 values 是 NULL
    mp->ma_used = 0;			// 初始化的字典沒有元素
    mp->ma_version_tag = DICT_NEXT_VERSION();	// 版本號,參考上面資料結構裡的說明
    assert(_PyDict_CheckConsistency(mp));
    return (PyObject *)mp;
}

到這裡,一個 dict 物件就算正式建立完成了,我們在 Python 裡也可以開始愉快地玩耍了。不過注意,這裡建立出來的字典是“combined”模式的。“splitted”模式的字典在“combined”模式基礎上還初始化了ma_values,我這裡就懶得詳細介紹了。

為什麼有序

通過前面分析的資料結構,我們知道,字典元素儲存在dk_entries陣列中。當一個資料結構有序,指的是它裡面元素的順序與插入順序相同。元素插入雜湊表的索引是雜湊函式算出來的,應該是無序的,這就是之前的字典元素無序的原因。而 Python 3.6.0 引入了dk_indices陣列,專門記錄雜湊表資訊,那麼元素插入的順序資訊就得以保留在dk_entries陣列中。為了滿足好奇心,下面分析一下插入函式。

/* Python 3.6.0 */
/* ./Objects/dictobject.c */
/*
Internal routine to insert a new item into the table.
Used both by the internal resize routine and by the public insert routine.
Returns -1 if an error occurred, or 0 on success.
*/
static int
insertdict(PyDictObject *mp, PyObject *key, Py_hash_t hash, PyObject *value)
{
    PyObject *old_value;
    PyObject **value_addr;
    PyDictKeyEntry *ep, *ep0;
    Py_ssize_t hashpos, ix;
	...
    ix = mp->ma_keys->dk_lookup(mp, key, hash, &value_addr, &hashpos);
    ...
    Py_INCREF(value);
    MAINTAIN_TRACKING(mp, key, value);
	...
	/* 插入新值 */
    if (ix == DKIX_EMPTY) {
        /* Insert into new slot. */
        /* dk_entries 陣列填滿的時候給字典擴容 */
        if (mp->ma_keys->dk_usable <= 0) {
            /* Need to resize. */
            if (insertion_resize(mp) < 0) {
                Py_DECREF(value);
                return -1;
            }
            find_empty_slot(mp, key, hash, &value_addr, &hashpos);
        }
        ep0 = DK_ENTRIES(mp->ma_keys);
        ep = &ep0[mp->ma_keys->dk_nentries];	// 每次插入位置在最後
        dk_set_index(mp->ma_keys, hashpos, mp->ma_keys->dk_nentries);
        Py_INCREF(key);
        ep->me_key = key;
        ep->me_hash = hash;
        if (mp->ma_values) {
            assert (mp->ma_values[mp->ma_keys->dk_nentries] == NULL);
            mp->ma_values[mp->ma_keys->dk_nentries] = value;
        }
        else {
            ep->me_value = value;
        }
        mp->ma_used++;
        mp->ma_version_tag = DICT_NEXT_VERSION();
        mp->ma_keys->dk_usable--;
        mp->ma_keys->dk_nentries++;
        assert(mp->ma_keys->dk_usable >= 0);
        assert(_PyDict_CheckConsistency(mp));
        return 0;
    }

    assert(value_addr != NULL);
	/* 替換舊值 */
    old_value = *value_addr;
    if (old_value != NULL) {
        *value_addr = value;
        mp->ma_version_tag = DICT_NEXT_VERSION();
        assert(_PyDict_CheckConsistency(mp));

        Py_DECREF(old_value); /* which **CAN** re-enter (see issue #22653) */
        return 0;
    }

    /* pending state */
    assert(_PyDict_HasSplitTable(mp));
    assert(ix == mp->ma_used);
    *value_addr = value;
    mp->ma_used++;
    mp->ma_version_tag = DICT_NEXT_VERSION();
    assert(_PyDict_CheckConsistency(mp));
    return 0;
}

在插入函式中,第一個重點關注物件應該是ix = mp->ma_keys->dk_lookup(mp, key, hash, &value_addr, &hashpos)這句程式碼。dk_lookup是一個函式指標,指向四大搜尋函式的其中一個,這裡有必要說明一下各引數和返回值:

  • 引數

    1. PyDictObject *mp (已知引數)

      字典物件,在該物件中查詢。

    2. PyObject *key (已知引數)

      entry裡的key,代表key物件的引用,用於第一次判定,如果引用相同就找到了;如果不同再判斷hash

    3. Py_hash_t hash (已知引數)

      entry裡的hash,用於第二次判定,如果雜湊值相同就找到了;如果不同就代表沒找到。

    4. PyObject ***value_addr (未知引數,用指標返回資料)

      如果找到元素,則value_addr返回對應的me_value的指標;如果沒找到,*value_addrNULL

    5. Py_ssize_t *hashpos (未知引數,用指標返回資料)

      hashpos返回元素在雜湊表中的位置。

  • 返回值

    • Py_ssize_t ix

      返回元素在dk_entries陣列中的索引。如果不是有效元素,ix可能是DKIX_EMPTY, DKIX_DUMMY, DKIX_ERROR中的一個,分別代表dk_entries陣列中的 新空位,刪除舊值留下的空位,錯誤。

瞭解了各個引數的作用,就可以繼續愉快地看程式碼了。然後就看到了這一句ep = &ep0[mp->ma_keys->dk_nentries],根據它下面的程式碼可以知道,這個ep就是新元素插入的地方,代表一個PyDictKeyEntry物件指標,而mp->ma_keys->dk_nentries指向的位置,就是dk_entries陣列的末尾。也就是說,每次的新元素插入字典,都會依次放到dk_entries陣列裡,保持了插入順序。那麼雜湊函式計算出來的插入位置呢?答案就在dk_set_index(mp->ma_keys, hashpos, mp->ma_keys->dk_nentries)函式裡。

/* Python 3.6.0 */
/* ./Objects/dictobject.c */
/* write to indices. */
static inline void
dk_set_index(PyDictKeysObject *keys, Py_ssize_t i, Py_ssize_t ix)
{
    Py_ssize_t s = DK_SIZE(keys);

    assert(ix >= DKIX_DUMMY);

    if (s <= 0xff) {
        int8_t *indices = keys->dk_indices.as_1;
        assert(ix <= 0x7f);
        indices[i] = (char)ix;	// 填充 dk_indices 陣列
    }
    else if (s <= 0xffff) {
        ...
    }
}

可以看到,雜湊函式計算出來的插入位置儲存到了dk_indices陣列裡,而對應插入位置儲存的資訊就是這個元素在dk_entries陣列裡的索引。
如果沒看明白,就再回顧一下 [Python-Dev] More compact dictionaries with faster iteration 中的描述。

For example, the dictionary:

d = {'timmy': 'red', 'barry': 'green', 'guido': 'blue'}

...

Instead, the data should be organized as follows:

indices = [None, 1, None, None, None, 0, None, 2]
entries = [[-9092791511155847987, 'timmy', 'red'],
[-8522787127447073495, 'barry', 'green'],
[-6480567542315338377, 'guido', 'blue']]

是時候了,現在拿出 Python 3.5.9 的程式碼對比一下,只對比 Empty 狀態的 slot 插入程式碼即可。

/* Python 3.5.9 */
/* ./Objects/dictobject.c */
/*
Internal routine to insert a new item into the table.
Used both by the internal resize routine and by the public insert routine.
Returns -1 if an error occurred, or 0 on success.
*/
static int
insertdict(PyDictObject *mp, PyObject *key, Py_hash_t hash, PyObject *value)
{
    PyObject *old_value;
    PyObject **value_addr;
    PyDictKeyEntry *ep;
    assert(key != dummy);

    Py_INCREF(key);
    Py_INCREF(value);
    ...
    ep = mp->ma_keys->dk_lookup(mp, key, hash, &value_addr);
    ...
    old_value = *value_addr;
    /* Active 狀態 */
    if (old_value != NULL) {
        ...
    }
    else {
        /* Empty 狀態 */
        if (ep->me_key == NULL) {
            if (mp->ma_keys->dk_usable <= 0) {
                /* Need to resize. */
                ...
            }
            mp->ma_used++;
            *value_addr = value;	// 直接向 dk_entries 陣列插入元素
            mp->ma_keys->dk_usable--;
            assert(mp->ma_keys->dk_usable >= 0);
            ep->me_key = key;
            ep->me_hash = hash;
            assert(ep->me_key != NULL && ep->me_key != dummy);
        }
        /* Dummy 狀態 */
        else {
            ...
        }
    }
    return 0;
    ...
}

可以看到*value_addr = value這句程式碼填充了dk_entries,但是這裡資訊是不夠的,value_addr來自搜尋函式,於是我找到通用搜尋函式lookdict,來看下它裡面獲取插入位置的關鍵程式碼。

/* Python 3.5.9 */
/* ./Objects/dictobject.c */
static PyDictKeyEntry *
lookdict(PyDictObject *mp, PyObject *key,
         Py_hash_t hash, PyObject ***value_addr)
{
    ...
    mask = DK_MASK(mp->ma_keys);
    ep0 = &mp->ma_keys->dk_entries[0];
    i = (size_t)hash & mask;	// 靠雜湊值找到插入位置
    ep = &ep0[i];	// 直接按照位置插入到 dk_entries 陣列中
    if (ep->me_key == NULL || ep->me_key == key) {
        *value_addr = &ep->me_value;	// 用指標返回 me_value 作為插入地址
        return ep;
    }
    ...
}

可以清晰地看到,雜湊函式計算出來的位置是直接對應到dk_entries陣列中的,元素也直接放進去,沒有dk_indices陣列。因為雜湊值不是連續的,所以我們依次插入到dk_entries陣列裡的元素也就不連續了。
如果又沒看明白,就再回顧一下 [Python-Dev] More compact dictionaries with faster iteration 中的描述。

For example, the dictionary:

d = {'timmy': 'red', 'barry': 'green', 'guido': 'blue'}

is currently stored as:

entries = [['--', '--', '--'],
[-8522787127447073495, 'barry', 'green'],
['--', '--', '--'],
['--', '--', '--'],
['--', '--', '--'],
[-9092791511155847987, 'timmy', 'red'],
['--', '--', '--'],
[-6480567542315338377, 'guido', 'blue']]

為什麼快

迭代變快的原因源自dk_entries陣列的密集化,迭代時遍歷的數量少。Python 3.5.9 和 3.6.0 版本程式碼的寫法差異不大,所以這裡只摘取一段dictresize()的資料複製程式碼對比。對dictresize()函式的具體分析放在附錄裡。

/* ./Objects/dictobject.c */
/* Python 3.5.9 */
static int
dictresize(PyDictObject *mp, Py_ssize_t minused)
{
    ...
    /* Main loop */
    for (i = 0; i < oldsize; i++) {
        PyDictKeyEntry *ep = &oldkeys->dk_entries[i];
        if (ep->me_value != NULL) {
            assert(ep->me_key != dummy);
            insertdict_clean(mp, ep->me_key, ep->me_hash, ep->me_value);
        }
    }
    mp->ma_keys->dk_usable -= mp->ma_used;
    ...
}
/* Python 3.6.0 */
static int
dictresize(PyDictObject *mp, Py_ssize_t minsize)
{
    ...
    /* Main loop */
    for (i = 0; i < oldkeys->dk_nentries; i++) {
        PyDictKeyEntry *ep = &ep0[i];
        if (ep->me_value != NULL) {
            insertdict_clean(mp, ep->me_key, ep->me_hash, ep->me_value);
        }
    }
    mp->ma_keys->dk_usable -= mp->ma_used;
    ...
}

現在知道是哪些程式碼節省了時間嗎?就是所有for (i = 0; i < oldkeys->dk_nentries; i++){...}程式碼塊。在 Python 3.5.9 中,它們對應for (i = 0; i < oldsize; i++){...},其中的oldsize等於oldkeys->dk_size,只看程式碼的寫法,沒有什麼區別,但是根據USABLE_FRACTION的設定,dk_nentries只佔dk_size2/3,所以新的字典迭代次數少了1/3。在dict_items()函式中的迭代操作速度變快也是同樣的原因。

現在再來看看 [Python-Dev] More compact dictionaries with faster iteration 裡面的這幾段話:

In addition to space savings, the new memory layout makes iteration faster. Currently, keys(), values, and items() loop over the sparse table, skipping-over free slots in the hash table. Now, keys/values/items can loop directly over the dense table, using fewer memory accesses.

Another benefit is that resizing is faster and touches fewer pieces of memory. Currently, every hash/key/value entry is moved or copied during a resize. In the new layout, only the indices are updated. For the most part, the hash/key/value entries never move (except for an occasional swap to fill a hole left by a deletion).

With the reduced memory footprint, we can also expect better cache utilization.

原始碼觀後感

Python 的字典實現就是一套 tradeoff 的藝術,有太多的東西值得深思:

  • 使用空間佔申請空間的比重
  • 雜湊函式和探測函式的選用
  • 初始化需要申請的最小空間
  • 字典擴容時擴到多少
  • 元素分佈對 CPU 快取的影響

目前 Python 裡的各個引數都是通過大量測試得到的,考慮的場景很全面。然而,tradeoff 的藝術,也包括針對特定應用場景優化,如果能根據實際業務場景優化 Python 引數,效能還是可以提高的。

此外,幾個效能優化的點:

  • 快取池只快取小物件,大容量的字典的建立和擴容每次都要重新申請記憶體。多小算小呢?

    #define PyDict_MINSIZE 8

    8 allows dicts with no more than 5 active entries.

  • 鑑於lookdict_unicode()函式的存在,儘量用字串作為key

參考./Objects/dictnotes.txt./Objects/dictobject.c裡的部分註釋。

參考資料

附錄

dict 擴容原始碼分析

擴容操作發生在元素插入的時候,當mp->ma_keys->dk_usable <= 0的時候,就對字典擴容,新容量使用GROWTH_RATE巨集計算。dictresize()函式處理"combined"和"splitted"兩種情況,需要分開看。

/* Python 3.6.0 */
/* ./Objects/dictobject.c */
/* GROWTH_RATE. Growth rate upon hitting maximum load.
 * Currently set to used*2 + capacity/2.
 * This means that dicts double in size when growing without deletions,
 * but have more head room when the number of deletions is on a par with the
 * number of insertions.
 * Raising this to used*4 doubles memory consumption depending on the size of
 * the dictionary, but results in half the number of resizes, less effort to
 * resize.
 * GROWTH_RATE was set to used*4 up to version 3.2.
 * GROWTH_RATE was set to used*2 in version 3.3.0
 */
#define GROWTH_RATE(d) (((d)->ma_used*2)+((d)->ma_keys->dk_size>>1))

/*
Restructure the table by allocating a new table and reinserting all
items again.  When entries have been deleted, the new table may
actually be smaller than the old one.
If a table is split (its keys and hashes are shared, its values are not),
then the values are temporarily copied into the table, it is resized as
a combined table, then the me_value slots in the old table are NULLed out.
After resizing a table is always combined,
but can be resplit by make_keys_shared().
*/
static int
dictresize(PyDictObject *mp, Py_ssize_t minsize)
{
    Py_ssize_t i, newsize;
    PyDictKeysObject *oldkeys;
    PyObject **oldvalues;
    PyDictKeyEntry *ep0;

    /* Find the smallest table size > minused. */
    /* 1. 計算新大小 */
    for (newsize = PyDict_MINSIZE;
         newsize < minsize && newsize > 0;
         newsize <<= 1)
        ;
    if (newsize <= 0) {
        PyErr_NoMemory();
        return -1;
    }
    /* 2. 申請新的 PyDictKeysObject 物件 */
    oldkeys = mp->ma_keys;
    oldvalues = mp->ma_values;
    /* Allocate a new table. */
    mp->ma_keys = new_keys_object(newsize);
    if (mp->ma_keys == NULL) {
        mp->ma_keys = oldkeys;
        return -1;
    }
    // New table must be large enough.
    assert(mp->ma_keys->dk_usable >= mp->ma_used);
    if (oldkeys->dk_lookup == lookdict)
        mp->ma_keys->dk_lookup = lookdict;
    /* 3. 元素搬遷 */
    mp->ma_values = NULL;
    ep0 = DK_ENTRIES(oldkeys);
    /* Main loop below assumes we can transfer refcount to new keys
     * and that value is stored in me_value.
     * Increment ref-counts and copy values here to compensate
     * This (resizing a split table) should be relatively rare */
    if (oldvalues != NULL) {
        /* 3.1 splitted table 轉換成 combined table */
        for (i = 0; i < oldkeys->dk_nentries; i++) {
            if (oldvalues[i] != NULL) {
                Py_INCREF(ep0[i].me_key);	// 要複製key,而原來的key也要用,所以增加引用計數
                ep0[i].me_value = oldvalues[i];
            }
        }
    }
    /* Main loop */
    for (i = 0; i < oldkeys->dk_nentries; i++) {
        PyDictKeyEntry *ep = &ep0[i];
        if (ep->me_value != NULL) {
            insertdict_clean(mp, ep->me_key, ep->me_hash, ep->me_value);
        }
    }
    mp->ma_keys->dk_usable -= mp->ma_used;
    /* 4. 清理舊值 */
    if (oldvalues != NULL) {
        /* NULL out me_value slot in oldkeys, in case it was shared */
        for (i = 0; i < oldkeys->dk_nentries; i++)
            ep0[i].me_value = NULL;
        DK_DECREF(oldkeys);
        if (oldvalues != empty_values) {
            free_values(oldvalues);
        }
    }
    else {
        assert(oldkeys->dk_lookup != lookdict_split);
        assert(oldkeys->dk_refcnt == 1);
        DK_DEBUG_DECREF PyObject_FREE(oldkeys);
    }
    return 0;
}

在分析函式內容前,先看下函式前面的說明:

Restructure the table by allocating a new table and reinserting all items again. When entries have been deleted, the new table may actually be smaller than the old one.
If a table is split (its keys and hashes are shared, its values are not), then the values are temporarily copied into the table, it is resized as a combined table, then the me_value slots in the old table are NULLed out. After resizing a table is always combined, but can be resplit by make_keys_shared().

這段說明告訴我們 2 件重要的事情:

  1. 新的字典可能比舊的小,因為舊字典可能存在一些刪除的entry。(字典刪除元素後,為了保持探測序列不斷開,元素狀態轉為dummy,建立新字典的時候去掉了這些dummy狀態的元素)

    儘管如此,為了偷懶,我仍然把這個操作稱為“擴容”。

  2. “splitted”模式的字典經過擴容會永遠變成"combined"模式,可以用make_keys_shared()函式重新調整為"splitted"模式。擴容操作會把原來的分離的values拷貝到entry裡。

我把這個函式分成了 4 個步驟:

  1. 計算新大小

    程式重新計算了字典大小,可是引數Py_ssize_t minsize不是字典大小嗎?為什麼要重新計算?

    minsize顧名思義,指定了呼叫者期望的字典大小,不過字典大小必須是 2 的整數次冪,所以重新算了下。翻看new_keys_object()函式,也會發現函式開頭有這麼一句: assert(IS_POWER_OF_2(size)),這是硬性要求,其原因已經在介紹資料結構的時候說過了,參考dk_size的說明。

  2. 申請新的 PyDictKeysObject 物件

    "combined"模式下,需要擴容的部分是PyDictKeysObject物件裡面的dk_indicesdk_entries,程式並沒有直接擴容這部分,因為dk_indicesdk_entries不是指標,它們佔用了PyDictKeysObject這個結構體後面連續的記憶體區域,所以直接重新申請了新的PyDictKeysObject物件。

    "splitted"模式下,本來還需要額外擴容ma_values,不過因為擴容使字典轉換為"combined"模式,所以實際上不需要擴容ma_values,直接申請新的PyDictKeysObject物件,把ma_values轉移到dk_entries裡面,再把ma_values指向NULL就好。

    好奇心又來了,為什麼不把dk_indicesdk_entries設定成指標,指向獨立的陣列呢?那樣不就可以用realloc之類的函式擴容陣列了嗎?同時也不用重新申請PyDictKeysObject物件,也不用手動複製陣列元素了。

    這個問題在網上和原始碼裡都沒找到答案,我就自己瞎猜一下吧。假如我現在換用指標,這兩個陣列在結構體外部申請獨立的空間,那麼會面臨 2 個問題:

    1. 程式碼分散。本來只有一個PyDictKeysObject物件,現在又多了 2 個外部陣列,程式碼裡除了新增相應的記憶體管理程式碼,還需要在每個函式裡檢測*dk_indices指標和*dk_entries指標是否為空;

    2. 頻繁的記憶體申請釋放帶來效能問題。現在的快取池在PyDictKeysObject物件釋放的時候把物件加入快取,並不立即銷燬,原來的dk_indicesdk_entries都是結構體內部的陣列,可以跟著結構體一起快取,而換成指標的話就不行了。要解決這個問題,就要給外部陣列單獨加快取池,這樣又導致了程式碼分散的問題。

    也不能說哪種方法就一定好或者一定差,這是一個 tradeoff 的問題,時間,空間,可維護性,不可兼得。

    魚與熊掌不可兼得。 --《魚我所欲也》

    Newton's third law. You got to leave something behind. --《Interstellar》

  3. 元素搬遷

    1. splitted table 轉換成 combined table

      這一步把ma_values轉移到dk_entries裡面的me_value,這樣後面就可以按照"combined"模式操作這個 splitted table 了,操作完後,再把dk_entries裡面的me_value還原。注意Py_INCREF(ep0[i].me_key)操作,即給每個key增加引用計數,為什麼要這麼做呢?原因還得到insertdict_clean()函式裡去找。

      /* Python 3.6.0 */
      /* ./Objects/dictobject.c */
      /*
      Internal routine used by dictresize() to insert an item which is
      known to be absent from the dict.  This routine also assumes that
      the dict contains no deleted entries.  Besides the performance benefit,
      using insertdict() in dictresize() is dangerous (SF bug #1456209).
      Note that no refcounts are changed by this routine; if needed, the caller
      is responsible for incref'ing `key` and `value`.
      Neither mp->ma_used nor k->dk_usable are modified by this routine; the caller
      must set them correctly
      */
      static void
      insertdict_clean(PyDictObject *mp, PyObject *key, Py_hash_t hash,
                       PyObject *value)
      {
          size_t i;
          PyDictKeysObject *k = mp->ma_keys;
          size_t mask = (size_t)DK_SIZE(k)-1;
          PyDictKeyEntry *ep0 = DK_ENTRIES(mp->ma_keys);
          PyDictKeyEntry *ep;
          ...
          i = hash & mask;
          /* 探測處理雜湊碰撞 */
          for (size_t perturb = hash; dk_get_index(k, i) != DKIX_EMPTY;) {
              perturb >>= PERTURB_SHIFT;
              i = mask & ((i << 2) + i + perturb + 1);
          }
          /* 修改 PyDictKeysObject 物件引數 */
          ep = &ep0[k->dk_nentries];	// 定位到 dk_entries 陣列
          assert(ep->me_value == NULL);
          dk_set_index(k, i, k->dk_nentries);	// 填充 dk_indices 陣列
          k->dk_nentries++;
          /* 填充 dk_entries 陣列 */
          ep->me_key = key;
          ep->me_hash = hash;
          ep->me_value = value;
      }
      

      這個函式比insertdict()函式更快,它的來歷要參考 issue1456209 。留意函式前面說明註釋裡的這句話:

      Note that no refcounts are changed by this routine; if needed, the caller is responsible for incref'ing key and value.

      這個函式只是複製了值,並不改變任何引用計數。看到這裡就明白了,舊的PyDictKeysObject物件裡面的key複製到新申請的PyDictKeysObject物件裡去的時候,引用計數應該加一。

      那麼問題又來了,為什麼value的引用計數沒有增加呢?別忘了現在正在操作 split table, 舊的PyDictKeysObject物件是很多PyDictObject共用的,所以key也是共用的,為了不影響別的PyDictObject物件,需要把key複製到新PyDictKeysObject物件裡;而oldvalues = mp->ma_valuesPyDictObject物件裡,是私有的,移動到新PyDictKeysObject物件裡即可,不需保留原值,所以不需要修改引用計數。

      為了便於理解,打個比方:我抄李華的作業,抄完以後,李華的作業要還給李華,他也要交,於是作業的引用計數增加了我的一份,這就是複製key的情況。而我轉念一想,抄得太像會被老師發現,所以自己又重新改抄了部分內容,之前抄的扔了就行,所以雖然我寫了 2 遍作業,但是最終只上交 1 份,作業的引用計數不變,這就是移動value的情況。

      為了更加便於理解,再說簡單一點:key是複製,引用計數+1;value是移動,引用計數+0

  4. 清理舊值

    "combined"模式下,直接釋放舊的PyDictKeysObject物件;

    "splitted"模式下,需要還原舊的PyDictKeysObject物件裡的dk_entries裡的me_valueNULL,原因參考 3.1 裡面的第一句話。最後釋放ma_values陣列。

相關文章