Python3中對Dict的內存優化

衆所周知,python3.6這個版本對dict的實現是作了較大優化的,特別是在內存使用率方面,所以我以爲有必要研究一下最新的dict的源碼實現。html

先後斷斷續續看了大概一週多一點,主要在研究dict和建立實例對象那部分的代碼,在此將所得記錄下來。python

值得一提的事,新版的dict使用的算法仍是同樣的,好比說hash值計算、衝突解決策略(open addressing)等。所以這一部分也不是我關注的重點,我關注的主要是在新的dict如何下降內存使用這方面。算法

btw,本文的分析是基於python的3.6.1這個版本。數組

 

話很少說,先看 PyDictObject 結構的定義:緩存

 1 typedef struct _dictkeysobject PyDictKeysObject;
 2 
 3 /* The ma_values pointer is NULL for a combined table
 4  * or points to an array of PyObject* for a split table
 5  */
 6 typedef struct {
 7     PyObject_HEAD
 8 
 9     /* Number of items in the dictionary */
10     Py_ssize_t ma_used;
11 
12     /* Dictionary version: globally unique, value change each time
13        the dictionary is modified */
14     uint64_t ma_version_tag;
15 
16     PyDictKeysObject *ma_keys;
17 
18     /* If ma_values is NULL, the table is "combined": keys and values
19        are stored in ma_keys.
20 
21        If ma_values is not NULL, the table is splitted:
22        keys are stored in ma_keys and values are stored in ma_values */
23     PyObject **ma_values;
24 } PyDictObject;

 

說下新增的 PyDictKeysObject 這個對象,其定義以下:app

 1 /* See dictobject.c for actual layout of DictKeysObject */
 2 struct _dictkeysobject {
 3     Py_ssize_t dk_refcnt;
 4 
 5     /* Size of the hash table (dk_indices). It must be a power of 2. */
 6     Py_ssize_t dk_size;
 7 
 8     /* Function to lookup in the hash table (dk_indices):
 9 
10        - lookdict(): general-purpose, and may return DKIX_ERROR if (and
11          only if) a comparison raises an exception.
12 
13        - lookdict_unicode(): specialized to Unicode string keys, comparison of
14          which can never raise an exception; that function can never return
15          DKIX_ERROR.
16 
17        - lookdict_unicode_nodummy(): similar to lookdict_unicode() but further
18          specialized for Unicode string keys that cannot be the <dummy> value.
19 
20        - lookdict_split(): Version of lookdict() for split tables. */
21     dict_lookup_func dk_lookup;
22 
23     /* Number of usable entries in dk_entries. */
24     Py_ssize_t dk_usable;
25 
26     /* Number of used entries in dk_entries. */
27     Py_ssize_t dk_nentries;
28 
29     /* Actual hash table of dk_size entries. It holds indices in dk_entries,
30        or DKIX_EMPTY(-1) or DKIX_DUMMY(-2).
31 
32        Indices must be: 0 <= indice < USABLE_FRACTION(dk_size).
33 
34        The size in bytes of an indice depends on dk_size:
35 
36        - 1 byte if dk_size <= 0xff (char*)
37        - 2 bytes if dk_size <= 0xffff (int16_t*)
38        - 4 bytes if dk_size <= 0xffffffff (int32_t*)
39        - 8 bytes otherwise (int64_t*)
40 
41        Dynamically sized, 8 is minimum. */
42     union {
43         int8_t as_1[8];
44         int16_t as_2[4];
45         int32_t as_4[2];
46 #if SIZEOF_VOID_P > 4
47         int64_t as_8[1];
48 #endif
49     } dk_indices;
50 
51     /* "PyDictKeyEntry dk_entries[dk_usable];" array follows:
52        see the DK_ENTRIES() macro */
53 };

新版的dict在內存佈局上和舊版有了很大的差別,其中一點就是分離存儲了key和value。設計思路能夠看看這個:More compact dictionaries with faster iteration函數

還有一點須要說明的是,新版的dict有兩種形式,分別是 combined 和 split。其中後者主要用在優化對象存儲屬性的tp_dict上,這個在後面討論。佈局

對於舊版的hash table,其每一個slot存儲的是一個 PyDictKeyEntry 對象(PyDictKeyEntry是一個三元組,包含了hash、key、value),這樣帶來的問題就是,多佔用了一些非必要的內存。對於狀態爲EMPTY的slot,實際可能存儲爲(0,NULL,NULL)這種形式,但其實這些數據都是冗餘的。優化

所以新版的hash table對此做出了優化,slot(也便是 dk_indices) 存儲的再也不是一個 PyDictKeyEntry,而是一個數組的index,這個數組存儲了具體且必要的 PyDictKeyEntry對象 。對於那些EMPTY、DUMMY狀態的這類slot,只須要用個負數(區分大於0的index)表示便可。ui

實際上,優化還不止於此。實際上還會根據須要索引 PyDictKeyEntry 對象的數量,動態的決定是用什麼類型的變量來表示index。例如,若是所存儲的 PyDictKeyEntry 數量不超過127,那麼實際上用長度爲一個字節的帶符號整數(char)存儲index便可。須要說明的是,index的值是有可能爲負的(EMPTY、DUMMY、ERROR),所以須要用帶符號的整數存儲。具體能夠看 new_keys_object 這個函數,這個函數在建立 dict 的時候會被調用:

 1 PyObject *
 2 PyDict_New(void)
 3 {
 4     PyDictKeysObject *keys = new_keys_object(PyDict_MINSIZE);
 5     if (keys == NULL)
 6         return NULL;
 7     return new_dict(keys, NULL);
 8 }
 9 
10 static PyDictKeysObject *new_keys_object(Py_ssize_t size)
11 {
12     PyDictKeysObject *dk;
13     Py_ssize_t es, usable;
14 
15     assert(size >= PyDict_MINSIZE);
16     assert(IS_POWER_OF_2(size));
17 
18     usable = USABLE_FRACTION(size);
19     if (size <= 0xff) {
20         es = 1;
21     }
22     else if (size <= 0xffff) {
23         es = 2;
24     }
25 #if SIZEOF_VOID_P > 4
26     else if (size <= 0xffffffff) {
27         es = 4;
28     }
29 #endif
30     else {
31         es = sizeof(Py_ssize_t);
32     }
33 
34     if (size == PyDict_MINSIZE && numfreekeys > 0) {
35         dk = keys_free_list[--numfreekeys];
36     }
37     else {
38         dk = PyObject_MALLOC(sizeof(PyDictKeysObject)
39                              - Py_MEMBER_SIZE(PyDictKeysObject, dk_indices)
40                              + es * size
41                              + sizeof(PyDictKeyEntry) * usable);
42         if (dk == NULL) {
43             PyErr_NoMemory();
44             return NULL;
45         }
46     }
47     DK_DEBUG_INCREF dk->dk_refcnt = 1;
48     dk->dk_size = size;
49     dk->dk_usable = usable;
50     dk->dk_lookup = lookdict_unicode_nodummy;
51     dk->dk_nentries = 0;
52     memset(&dk->dk_indices.as_1[0], 0xff, es * size);
53     memset(DK_ENTRIES(dk), 0, sizeof(PyDictKeyEntry) * usable);
54     return dk;
55 }

 有幾點須要說明一下:

(1)受限於裝填因子,所以給定一個hash table 的 size 就能肯定出最多可容納多少個有效對象(上圖代碼18行),所以存儲的 PyDictKeyEntry 對象的數組的長度是能夠在一開始便肯定下來的。PyDictKeysObject 對象上的 dk_usable 表示hash table還能存儲多少個對象,其值小於等於0的時候,再插入元素須要執行 rehash 操做。

(2)傳入的size的值必須是2的冪,所以若是 size <= 0xff(255) 成立,則說明 size <= 128,所以用1個字節長度來表示index足矣。

(3)CPython的代碼處處存在着緩存策略,keys_free_list 也是如此,目的是減小實際執行malloc的次數。

(4)當申請內存時,在計算一個 PyDictKeysObject 對象實際須要的內存時,須要減去 dk_indices 成員默認的大小,默認大小是8字節。這部份內存是根據size動態肯定下來的。

 

如今來講說以前說起的split形式的dict。這種字典的key是共享的,有一個引用計數器 dk_refcnt 來維護當前被引用的個數。而之因此設計出split形式的字典,是由於觀察到了python虛擬機中,會有大量key相同而value不一樣的字典的存在。而這個特定的狀況就是實例對象上存儲屬性的 tp_dict 字典!

所以split形式的dict主要是出於對優化實例對象上存儲屬性這種狀況考慮的。設計思路這裏有所說起:PEP 412 -- Key-Sharing Dictionary

咱們都知道,python使用dict來存儲對象的屬性。考慮一個這樣的場景:

(1)一個類會建立出不少個對象。

(2)這些對象的屬性,能在一開始就肯定下來,而且後續不會增長刪除。

若是能知足上述兩個條件,那麼其實咱們可使用一種更高效、更省內存的方式,來存儲對象的屬性。方法就是,屬於一個類的全部對象共享同一份屬性字典的key,而value以數組的方式存儲在每一個對象的身上。優化的好處是顯而易見的,原來須要爲每個對象維持一份屬性key,而如今只需爲全部對象維持一份便可,而且屬性的值(value)也以更加緊湊的方式組織在內存中。新版的dict的設計使得實現這種共享key的策略變得更簡單!

看看具體的代碼:

 1 int
 2 _PyObjectDict_SetItem(PyTypeObject *tp, PyObject **dictptr,
 3                       PyObject *key, PyObject *value)
 4 {
 5     PyObject *dict;
 6     int res;
 7     PyDictKeysObject *cached;
 8 
 9     assert(dictptr != NULL);
10     if ((tp->tp_flags & Py_TPFLAGS_HEAPTYPE) && (cached = CACHED_KEYS(tp))) {
11         assert(dictptr != NULL);
12         dict = *dictptr;
13         if (dict == NULL) {
14             DK_INCREF(cached);
15             dict = new_dict_with_shared_keys(cached); // importance!!!
16             if (dict == NULL)
17                 return -1;
18             *dictptr = dict;
19         }
20         if (value == NULL) {
21             res = PyDict_DelItem(dict, key);
22             // Since key sharing dict doesn't allow deletion, PyDict_DelItem()
23             // always converts dict to combined form.
24             if ((cached = CACHED_KEYS(tp)) != NULL) {
25                 CACHED_KEYS(tp) = NULL;
26                 DK_DECREF(cached);
27             }
28         }
29         else {
30             int was_shared = (cached == ((PyDictObject *)dict)->ma_keys);
31             res = PyDict_SetItem(dict, key, value);
32             if (was_shared &&
33                     (cached = CACHED_KEYS(tp)) != NULL &&
34                     cached != ((PyDictObject *)dict)->ma_keys) {
35                 /* PyDict_SetItem() may call dictresize and convert split table
36                  * into combined table.  In such case, convert it to split
37                  * table again and update type's shared key only when this is
38                  * the only dict sharing key with the type.
39                  *
40                  * This is to allow using shared key in class like this:
41                  *
42                  *     class C:
43                  *         def __init__(self):
44                  *             # one dict resize happens
45                  *             self.a, self.b, self.c = 1, 2, 3
46                  *             self.d, self.e, self.f = 4, 5, 6
47                  *     a = C()
48                  */
49                 if (cached->dk_refcnt == 1) {
50                     CACHED_KEYS(tp) = make_keys_shared(dict);
51                 }
52                 else {
53                     CACHED_KEYS(tp) = NULL;
54                 }
55                 DK_DECREF(cached);
56                 if (CACHED_KEYS(tp) == NULL && PyErr_Occurred())
57                     return -1;
58             }
59         }
60     } else {
61         dict = *dictptr;
62         if (dict == NULL) {
63             dict = PyDict_New();
64             if (dict == NULL)
65                 return -1;
66             *dictptr = dict;
67         }
68         if (value == NULL) {
69             res = PyDict_DelItem(dict, key);
70         } else {
71             res = PyDict_SetItem(dict, key, value);
72         }
73     }
74     return res;
75 }
當咱們在類的 __init__ 方法中經過 self.a = v 初始化一個對象的屬性時,最終會調用到函數_PyObjectDict_SetItem。此函數會初始化對象的tp_dict,也便是對象的屬性字典。從上述的第15行代碼能夠看出,在特定狀況下,會將對象的屬性字典初始化爲共享key的split式字典。所以也驗證了以前的分析。
相關文章
相關標籤/搜索