用 NumPy 中的視圖來節(jié)省內(nèi)存
如果您使用 Python 的 NumPy 庫(kù),通常是因?yàn)槟谔幚碚加么罅績(jī)?nèi)存的大型數(shù)組。為了減少內(nèi)存使用,您可能希望盡量減少不必要的重復(fù)項(xiàng)。
NumPy 有一個(gè)內(nèi)置功能,可以在許多常見情況下透明地執(zhí)行此操作:內(nèi)存視圖。而且,此功能還可以防止數(shù)組被垃圾回收,從而導(dǎo)致更高的內(nèi)存使用率。在某些情況下,它可能會(huì)導(dǎo)致錯(cuò)誤,數(shù)據(jù)會(huì)以意想不到的方式發(fā)生變異。
為了避免這些問題,讓我們了解視圖的工作原理以及對(duì)代碼的影響。
預(yù)備知識(shí):Python 列表
在查看 NumPy 數(shù)組和視圖之前,讓我們考慮一個(gè)有點(diǎn)相似的數(shù)據(jù)結(jié)構(gòu):Python 列表。
Python 列表與 NumPy 數(shù)組一樣,是連續(xù)的內(nèi)存塊。當(dāng)你對(duì)一個(gè) Python 列表進(jìn)行切片時(shí),你會(huì)得到一個(gè)完全不同的列表,這意味著你正在分配一塊新的內(nèi)存:
- >>> from psutil import Process
- >>> Process().memory_info().rss
- 12247040
- >>> list1 = [None] * 1_000_000
- >>> Process().memory_info().rss
- 20463616
- >>> list2 = list1[:500_000]
- >>> Process().memory_info().rss
- 24580096
切片列表分配了更多內(nèi)存。由于第二個(gè)列表是一個(gè)獨(dú)立的副本,如果我們改變它,這不會(huì)影響第一個(gè)列表:
- >>> list2[0] = "abc"
- >>> print(list2[0])
- abc
- >>> print(list1[0])
- None
注意,復(fù)制到第二個(gè)列表中的數(shù)據(jù)是指向 Python 對(duì)象的指針,而不是對(duì)象本身的內(nèi)容。因此,即使列表本身不同,底層對(duì)象仍然在兩者之間進(jìn)行共享。
切片時(shí) NumPy 數(shù)組并不進(jìn)行復(fù)制
NumPy 數(shù)組的工作方式不同。因?yàn)榧僭O(shè)您可能正在處理非常大的數(shù)組,所以許多操作不會(huì)復(fù)制數(shù)組,它們只是讓您查看原始數(shù)組指向的同一連續(xù)內(nèi)存塊。
第一個(gè)結(jié)果是切片不會(huì)分配更多內(nèi)存,因?yàn)樗皇窃紨?shù)組的視圖:
- >>> from psutil import Process
- >>> import numpy as np
- >>> arr = np.arange(0, 1_000_000)
- >>> Process().memory_info().rss
- 37810176
- >>> view = arr[:500_000]
- >>> Process().memory_info().rss
- 37810176
視圖對(duì)象看起來像一個(gè) 500,000 長(zhǎng)的 int64 數(shù)組,因此如果它是一個(gè)新數(shù)組,它將分配大約 4MB 的內(nèi)存。但它只是針對(duì)同一個(gè)原始數(shù)組的一個(gè)視圖,所以不需要額外的內(nèi)存。
從技術(shù)上來說,可能會(huì)為視圖對(duì)象本身分配一小部分內(nèi)存,但這可以忽略不計(jì),除非您有很多視圖對(duì)象。在這種情況下,RSS(常駐內(nèi)存)度量中沒有出現(xiàn)新內(nèi)存,因?yàn)?Python 預(yù)先分配了更大的內(nèi)存塊,然后用小的 Python 對(duì)象填充這些塊。
視圖導(dǎo)致內(nèi)存泄漏
使用視圖的后果之一是您可能會(huì)泄漏內(nèi)存,而不是節(jié)省內(nèi)存。這是因?yàn)橐晥D可以防止原始數(shù)組被垃圾回收 - 對(duì)整個(gè)數(shù)組來說。
假設(shè)您已經(jīng)決定只需要使用大數(shù)組的一小部分:
- >>> import numpy as np
- >>> from psutil import Process
- >>> arr = np.arange(0, 100_000_000)
- >>> Process().memory_info().rss
- 830181376
- >>> small_slice = arr[:10]
- >>> del arr
- >>> Process().memory_info().rss
- 830111744
如果這是一個(gè) Python 列表,刪除原始對(duì)象將釋放內(nèi)存。然而,在這種情況下,即使我們沒有對(duì)數(shù)組的直接引用,視圖仍然可以起作用,這意味著內(nèi)存沒有被釋放,即使我們只對(duì)其中的一小部分感興趣。
您實(shí)際上可以通過視圖訪問原始數(shù)組:
- >>> small_slice
- array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
- >>> small_slice.base
- array([0, 1, 2, ..., 99999997, 99999998, 99999999])
結(jié)果,只有當(dāng)我們刪除所有視圖時(shí),原始數(shù)組的內(nèi)存才會(huì)被釋放:
- >>> del small_slice
- >>> Process().memory_info().rss
- 29642752
其他改變
使用視圖的另一個(gè)后果是修改視圖會(huì)改變?cè)紨?shù)組?;叵胍幌拢瑢?duì)于 Python 列表,修改切片結(jié)果不會(huì)修改原始列表,因?yàn)樾聦?duì)象是一個(gè)副本:
- >>> l = [1, 2, 3]
- >>> ll2 = l[:]
- >>> l2[0] = 17
- >>> l2
- [17, 2, 3]
- >>> l
- [1, 2, 3]
使用 NumPy 視圖后,改變視圖確實(shí)改變了原始對(duì)象,它們都指向同一個(gè)內(nèi)存地址:
- >>> arr = np.array([1, 2, 3])
- >>> view = arr[:]
- >>> view[0] = 17
- >>> view
- array([17, 2, 3])
- >>> arr
- array([17, 2, 3])
這個(gè)結(jié)果不是我們想要的!
由于某些 NumPy API 可能會(huì)根據(jù)情況返回視圖或副本,因此更有可能發(fā)生意外變化。例如,某些切片結(jié)果可能不是視圖:
- >>> arr = np.array([1, 2, 3])
- >>> arrarr2 = arr[:]
- >>> arr2.base is arr
- True
- >>> arrarr3 = arr[[True, False, True]]
- >>> arr3.base is arr
- False
改變 arr2 也會(huì)改變 arr,但改變 arr3 不會(huì)改變 arr。
使用 copy() 進(jìn)行顯式復(fù)制
當(dāng)您不想引用原始內(nèi)存時(shí),顯式復(fù)制允許您創(chuàng)建一個(gè)新數(shù)組。這對(duì)于防止改變很有用,并且在您不想將原始數(shù)組保留在內(nèi)存中的情況下也很有用:
- >>> arr = np.arange(0, 100_000_000)
- >>> Process().memory_info().rss
- 829464576
- >>> small_slice = arr[:10].copy()
- >>> del arr
- >>> Process().memory_info().rss
- 29700096
- >>> print(small_slice.base)
- None
在這種情況下,刪除 arr 釋放了內(nèi)存,因?yàn)?small_slice 是副本,而不是視圖。
要點(diǎn):高效安全地使用視圖
鑒于各種 NumPy API 會(huì)自動(dòng)返回視圖,您需要在編寫代碼時(shí)考慮它們:
•在文檔中注意 API 是否會(huì)返回視圖、副本或兩者。
•如果您想從內(nèi)存中清除一個(gè)大數(shù)組,請(qǐng)確保不僅沒有直接引用它,而且沒有引用它的視圖。
•如果你要改變一個(gè)數(shù)組,確保它不會(huì)因?yàn)樗鼘?shí)際上是一個(gè)視圖而意外改變其他一些數(shù)組。
•如果您不需要視圖,請(qǐng)使用 copy() 方法。