R语言进阶 | 变量赋值背后的机制与R语言内存优化
為什么要了解變量賦值?
變量賦值牽涉到對象和變量名,理解對象和變量名之間的區別和聯系將對你有如下幫助:
(1)幫助你更精準預測代碼的行為和內存的使用情況;(2)避免代碼運行過程中不必要的對象復制,從而加快代碼運行的速度;(3)幫助你進一步了解R語言函數式編程的原理。
理解綁定(banding)
x <- c(1, 2, 3)閱讀上面這行代碼,我們自然地理解為:”創建一個名為x的對象,其包括元素值1,2和3“。但實際上這種理解是不準確的,我們可以認為這行代碼背后做了兩件事情:(1)創建一個向量對象,即c(1, 2, 3);(2)將這個對象和變量名x綁定起來。換句話說對象可以有一定的類型但是沒有名字,而變量名可以通過和對象綁定從而指向一定的值。
可以參考下圖進一步理解,左邊方框中的變量名x和右邊的向量對象通過綁定從而聯系起來。右邊對象下方的”0x74b“可以理解為這個向量對象的識別符(identifier),實際上是該對象在內存中的存儲地址。一個對象的存儲地址可以通過R包lobstr的obj_addr()函數進行獲取。
此時如果將變量x賦值給y,那么x和y會指向同一個對象。
y <- x##通過lobstr::obj_addr()查看x和y的存儲地址 obj_addr(x) #> [1] "0x7fe11b31b1e8" obj_addr(y) #> [1] "0x7fe11b31b1e8"符合語法規則的變量名(syntactic names)
R的變量名要求由字母、數字、下劃線、小數點組成, 開頭不能是數字、下劃線、小數點, 中間不能使用空格、減號、井號等特殊符號, 變量名不能與if、NA等保留字相同(可用?Reserved命令查看所有的保留字)。有時為了與其它軟件系統兼容, 需要使用不符合規則的變量名, 這時只要將變量名兩邊用反引號 (``)保護即可。
值得一提的是在用read.csv()讀取文件時,不符合命名規則的變量名會被強制改為符合命名規則的名稱(比如有的基因名稱中的"-“會被改變成”.",造成一定的麻煩),這時候可以通過check.names 參數進行關閉這種強制行為。另外 make.unique()和make.names()也是和變量名相關的函數,感興趣的讀者可繼續了解。
Copy-on-modify機制
以下代碼將x和y同時綁定至同一向量,然后再修改變量y。
x <- c(1, 2, 3) y <- xy[[3]] <- 4 x #> [1] 1 2 3雖然開始x和y指向同一對象(0x74b),但當改變變量y,變量x并沒有改變。x仍然指向原來的向量對象(0x74b),而y則指向了修改后的另一個副本對象(0xcd2),這個對象實際上是通過復制原來的對象(0x74b)并進行對應的修改得來的。即復制行為是通過修改而引發的,故這種行為稱為copy-on-modify。
tracemem()函數
你可以通過base::tracemem函數觀測一個對象是否被復制。如果對一個對象使用這個命令,首先會返回這個對象的存儲地址,之后如果這個對象發生了復制則會輸出對應的地址變化信息,除非使用 base::untracemem()函數取消對這個對象的跟蹤。
x <- c(1, 2, 3) cat(tracemem(x), "\n") #> <0x7f80c0e0ffc8> y <- x y[[3]] <- 4L #> tracemem[0x7f80c0e0ffc8 -> 0x7f80c4427f40]: #提示發生一次復制Function calls
以上講的關于變量的復制規則也適用于函數使用時。
f <- function(a) {a }x <- c(1, 2, 3) cat(tracemem(x), "\n") #> <0x7fe1121693a8>z <- f(x) # 調用函數過程中并沒有發生復制,即變量z和x指向同一對象!untracemem(x)當函數運行時,函數里的a變量將會指向x指向的對象:
從上面的例子可以看出, 函數f以x為實參, 但不修改x的元素, 不會生成x的副本(不發生復制), 返回的值是x指向的對象本身, 再次賦值給z, 也不制作副本, z和x綁定到同一對象(0x74b)。
Lists
l1 <- list(1, 2, 3)對于列表了l1而言,表面上似乎和上面提到的數字向量類似(即l1指向了一個包括三個元素的列表對象)。但實際上列表會更加復雜一點,因為列表存儲的不是值本身而是存儲指向某些值的鏈接。
當改變一個列表時:
l2 <- l1 l2[[3]] <- 4和數字向量一樣,列表同樣遵守 copy-on-modify規則。即原始的列表被保留下不作變化,R會創建一個經過修改的副本。但這里復制其實是淺拷貝(shallow copy),簡單講就是l2和l1并不是完全獨立的兩個對象,l2中未經改變的元素(前兩個元素)還是和l1共享的。
為了觀察兩個列表中元素的共享情況,可以用lobstr::ref()函數輸出每個列表元素的存儲地址,存儲地址相同的元素就是共享的元素。
ref(l1, l2) #> █ [1:0x7fe11166c6d8] <list> #> ├─[2:0x7fe11b6d2078] <dbl> #> ├─[3:0x7fe11b6d2040] <dbl> #> └─[4:0x7fe11b6d2008] <dbl> #> #> █ [5:0x7fe11411cc18] <list> #> ├─[2:0x7fe11b6d2078] #> ├─[3:0x7fe11b6d2040] #> └─[6:0x7fe114130a70] <dbl>Data frames
數據框(data frames)是由多個向量組成,copy-on-modify規則在數據框中也成立,數據框中的每個元素都指向某個對應的向量。
d1 <- data.frame(x = c(1, 5, 6), y = c(2, 4, 3))如果你只修改某一列,那么僅僅這一列會被修改,其他的還是指向原始的對象。
d2 <- d1 d2[, 2] <- d2[, 2] * 2如果你修改某一行,則其實每一列都會被修改,這就意味著每一列都會被復制。
d3 <- d1 d3[1, ] <- d3[1, ] * 3Character vectors
字符串向量和數字向量是不同的。我們常常會用如下圖去理解字符串向量。
x <- c("a", "a", "abc", "d")但實際上對于字符串向量,R常常會使用global string pool,這個pool里面包含所有不重復的(unique)字符串向量元素,每個元素可以被重復指向。這樣的好處顯而易見,可以減少內存使用。
另外可以用ref()函數來查看字符串向量內部的存儲結構。
ref(x, character = TRUE) #記得設置character參數 #> █ [1:0x7fe114251578] <chr> #> ├─[2:0x7fe10ead1648] <string: "a"> #> ├─[2:0x7fe10ead1648] #> ├─[3:0x7fe11b27d670] <string: "abc"> #> └─[4:0x7fe10eda4170] <string: "d">對象大小(Object size)
可以通過lobstr::obj_size()函數來查看一個對象的大小。
obj_size(letters) #> 1,712 B obj_size(ggplot2::diamonds) #> 3,456,344 B因為列表的元素并不是具體值而是指向值的鏈接。所以下面代碼中y變量的大小可能比預計中的要小得多。
x <- runif(1e6) obj_size(x) #> 8,000,048 By <- list(x, x, x) obj_size(y) #> 8,000,128 By的大小比x大80bytes,實際上這80bytes就是具有三個元素的空列表的大小。
obj_size(list(NULL, NULL, NULL)) #> 80 B同樣的,因為R使用global string pool存儲字符串向量的元素,所以下面代碼中即使當字符串的數量增加100倍,但向量的大小并沒有增加100倍。
banana <- "bananas bananas bananas" obj_size(banana) #> 136 B obj_size(rep(banana, 100)) #> 928 B另外一個值得注意特征是:用冒號(:)產生的連續變化的元素組成的字符串向量(如1:3),不管這個向量跨度有多大,所占的大小都是一樣的。因為此時只會存儲首尾兩個元素。
obj_size(1:3) #> 680 B obj_size(1:1e3) #> 680 B obj_size(1:1e6) #> 680 B obj_size(1:1e9) #> 680 BModify-in-place
正如我們在上面所看到的,修改 R 對象時通常會創建一個副本,但有兩個例外的情況:
-
當對象只和一個變量名綁定時會進行特殊優化處理,修改對象時不創建副本;
-
環境(Environments)對象
單綁定
如果一個對象只綁定了一個變量,修改對象不會創建一個副本(注意下面對象修改前后變量指向的地址不變)
v <- c(1, 2, 3) v[[3]] <- 4但是作為編寫代碼的人,在實際應用中其實很難判斷一個對象什么時候會應用該優化的機制,主要原因包括兩點:
-
與python不同,R語言的引用計數只包括 0 1 many。這意味著如果一個對象有兩個綁定,并且一個消失了,那么引用計數不會回到 1。反過來,這意味著 R 有時會在不需要時進行復制。
-
當你調用絕大多數的函數時,它都會對對象進行引用(“primitive” C 編寫的函數例外)。
所以,哪怕是經驗豐富的R語言愛好者也可能很難準備憑借經驗來判斷解釋器是否會創建副本,這里建議如有需要使用tracemem函數進行追蹤調試。
我們來看一個例子,我們實現將一個大數據框的每一列減去其中位數的操作:
x <- data.frame(matrix(runif(5 * 1e4), ncol = 5)) medians <- vapply(x, median, numeric(1))for (i in seq_along(medians)) {x[[i]] <- x[[i]] - medians[[i]] }這個循環運行速度會非常慢,因為涉及到大量的內存分配、副本創建的操作:
cat(tracemem(x), "\n") #> <0x7f80c429e020> for (i in 1:5) {x[[i]] <- x[[i]] - medians[[i]] } #> tracemem[0x7f80c429e020 -> 0x7f80c0c144d8]: #> tracemem[0x7f80c0c144d8 -> 0x7f80c0c14540]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c14540 -> 0x7f80c0c145a8]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c145a8 -> 0x7f80c0c14610]: #> tracemem[0x7f80c0c14610 -> 0x7f80c0c14678]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c14678 -> 0x7f80c0c146e0]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c146e0 -> 0x7f80c0c14748]: #> tracemem[0x7f80c0c14748 -> 0x7f80c0c147b0]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c147b0 -> 0x7f80c0c14818]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c14818 -> 0x7f80c0c14880]: #> tracemem[0x7f80c0c14880 -> 0x7f80c0c148e8]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c148e8 -> 0x7f80c0c14950]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c14950 -> 0x7f80c0c149b8]: #> tracemem[0x7f80c0c149b8 -> 0x7f80c0c14a20]: [[<-.data.frame [[<- #> tracemem[0x7f80c0c14a20 -> 0x7f80c0c14a88]: [[<-.data.frame [[<- untracemem(x)我們驚恐的發現,循環一次竟然觸發了三次開辟內存新建副本的操作!!所以我們需要優化我們的代碼,比如我們將data.frame轉換成list,性能會得到顯著提高:
y <- as.list(x) cat(tracemem(y), "\n") #> <0x7f80c5c3de20>for (i in 1:5) {y[[i]] <- y[[i]] - medians[[i]] } #> tracemem[0x7f80c5c3de20 -> 0x7f80c48de210]:Environments
環境變量(Environments)是R語言里面一種特殊的數據類型,該種數據類型的修改永遠遵守modify in place的原則。我們舉例說明:
e1 <- rlang::env(a = 1, b = 2, c = 3) e2 <- e1如果我們修改其中的屬性,其修改也是modify in place:
e1$c <- 4 e2$c #> [1] 4垃圾回收
我們在之前的推文中給大家介紹過《python垃圾回收機制》,趁著這個機會也給大家分享一下R語言的垃圾回收機制。python的垃圾回收機制主要利用引用計數 和分代回收兩個算法來實現,而R語言則利用tracing GC的方法。這意味著R語言會跟蹤從 global environment 中可訪問的每一個對象,以及從這些對象中可訪問的所有對象(即遞歸搜索列表和環境中的引用)。
每當 R 需要更多內存來創建新對象時,垃圾收集器 (GC) 就會自動運行。從用戶角度來講,基本上無法預測 GC 什么時候會運行。如果你想知道 GC 什么時候運行,調用 `gcinfo(TRUE)`,GC 會在每次運行時向控制臺打印一條消息。用戶可以通過調用 `gc()`來強制進行垃圾收集。在必要的時候,你可以手動調用 `gc()`快速釋放內存給操作系統,以便其他程序可以正常運行,或者統計內存使用情況: gc() #> used (Mb) gc trigger (Mb) limit (Mb) max used (Mb) #> Ncells 884876 47.3 1698228 90.7 NA 1478961 79 #> Vcells 5026893 38.4 17228590 131.5 16384 17226182 132lobstr::mem_used() # 或則使用該函數 #> 89,748,952 B需要注意的是,上面所顯示的內存使用情況可能和操作系統的內存使用情況不一致,主要有以下三個原因:
它包括由 R 創建但不由 R 解釋器創建的對象
R 和操作系統的統計結果都有一定的延遲
內存碎片:R 計算對象占用的內存,但由于刪除的對象可能存在空白。
Reference
Translated from advanced R
總結
以上是生活随笔為你收集整理的R语言进阶 | 变量赋值背后的机制与R语言内存优化的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python垃圾回收 (GC) 机制
- 下一篇: 算法训练 字符串的展开