採用Tagged Pointer的字串

發表於2015-09-20

Tagged Pointer是一個能夠提升效能、節省記憶體的有趣的技術。在OS X 10.10中,NSString就採用了這項技術,現在讓我們來看看該技術的實現過程。本話題由Ken Ferry提出。

回顧

物件在記憶體中是對齊的,它們的地址總是指標大小的整數倍,通常為16的倍數。物件指標是一個64位的整數,而為了對齊,一些位將永遠是零。

Tagged Pointer利用了這一現狀,它使物件指標中非零位有了特殊的含義。在蘋果的64位Objective-C實現中,若物件指標的最低有效位為1(即奇數),則該指標為Tagged Pointer。這種指標不通過解引用isa來獲取其所屬類,而是通過接下來三位的一個類表的索引。該索引是用來查詢所屬類是採用Tagged Pointer的哪個類。剩下的60位則留給類來使用。

Tagged Pointer有一個簡單的應用,那就是NSNumber。它使用60位來儲存數值。最低位置1。剩下3位為NSNumber的標誌。在這個例子中,就可以儲存任何所需記憶體小於60位的數值。

從外部看,Tagged Pointer很像一個物件。它能夠響應訊息,因為objc_msgSend可以識別Tagged Pointer。假設你呼叫integerValue,它將從那60位中提取數值並返回。這樣,每訪問一個物件,就省下了一次真正物件的記憶體分配,省下了一次間接取值的時間。同時引用計數可以是空指令,因為沒有記憶體需要釋放。對於常用的類,這將是一個巨大的效能提升。

NSString似乎並不適合Tagged Pointer,因為它的長度即可變,又可遠遠超過60位。然而,Tagged Pointer是可以與普通類共存的,即對一些值使用Tagged Pointer,另一些則使用一般的指標。例如,對於NSNumber,大於2^60-1的整數就不能採用Tagged Pointer來儲存,而需要在記憶體中分配一個NSNumber的物件來儲存。只要建立物件的程式碼編寫正確,就沒有問題。

NSString也是如此。對於那些所需記憶體小於60位的字串,它可以建立一個Tagged Pointer。其餘的則被放置在真正的NSString物件裡。這使得常用的短字串的效能得到明顯的提升。實際程式碼就是如此嗎?似乎Apple是這麼認為的,因為他們這麼做了並實現了它。

可能的實現方法

在看Apple的實現之前,讓我們花點時間想想我們自己會如何實現這種字串。最初想法很簡單:置最低位為1,剩下的3位作為類的標誌,60位為真正的資料。如何使用這60位是一個大問題。我們想要最大限度地利用這60位。

一個Cocoa字串在概念上是一系列的Unicode字元。一共有1,112,064個有效的Unicode字元,所以需要21位代表一個字元。這意味著我們可以放兩個字元在這60位裡,浪費掉了18位。我們可以用一些額外的位來儲存長度。所以一個採用Tagged Pointer的字串可以是零個、一個或兩個字元。然而被限制為只有兩個字元的字串似乎並沒什麼用。

NSString API實際上是基於UTF-16的實現,而不是直接基於Unicode。UTF-16用16位的序列值來表示Unicode。最常見的基本多文種平面(Basic Multilingual Plane,BMP)字元需要16位,字元編碼超過65,535的則需要兩個。我們可以放三個16位進60位,剩下12位。再借用一些表示長度的位,這將允許我們表示0-3個UTF-16字元。這將允許三個BMP字元,且其中一個字元可以超出BMP的範圍。被限制為三個字元的字串的使用仍然有限。

大多數APP裡的字串是ASCII。即使APP本地化到非ASCII語言,字串也遠遠不止用於顯示UI。它們用於URL元件、副檔名、物件鍵、屬性列表值等等。UTF-8編碼是一種ASCII相容的編碼,它將每一個ASCII字元編碼為一個位元組,用四位元組編碼其他Unicode字元。我們可以在60位裡放七個位元組,剩下的4位表示長度。這樣這種字串可以儲存七個ASCII字元,或者少一些的非ASCII字元,這取決於這些字元是什麼。

如果我們要優化ASCII,我們不妨放棄對Unicode的完整支援。畢竟包含非ASCII字元的字串可以使用真正的NSString物件。ASCII是一個七位編碼,如果我們給每個字元只分配7位會發生什麼?讓我們儲存八個ASCII字元在這60位裡,再用剩下的4位儲存長度。這聽起來很有用。在一個APP裡可能有大量的字串是純ASCII並且只包含8個字元或更少。

接著往下想,完整的ASCII裡有很多不常用的東西。比如一堆控制字元和不常用的符號。字母和數字才是最常使用的。我們能不能把編碼縮短到6位?

6位可以儲存64個不同的值。ASCII裡有26個字母,算上大寫小寫則有52個,再加上數字0-9則多達62個。如果說有兩個地方需要節省,那就是空間和時間。可能有很多隻包含這些字元的字串。每6位1個位元組,我們可以在60位裡儲存十個字元!等等!我們沒有剩餘空間儲存長度。所以要麼我們儲存9個字元加長度,要麼在那64個不同值裡刪除一個(我認為可以刪除空格),然後對於那些小於10個字元的字串使用零作為結束符。

如果是5位呢?這不是完全荒謬的。可能有很多隻存在小寫字元的字串。例如,5位可以儲存32個不同的值。算上整個小寫字母,也還有6個額外的值,你可以再分配一些更常見的大寫字母、符號、數字或組合。如果你發現其中的一些情況更常見,你甚至可以刪除一些不太常見的小寫字母,例如q。如果我們省下儲存長度的空間,5位編碼我們可以儲存十一個字元,如果我們借一個符號位並使用一個結束符則可以儲存十二個字元。

接著往下想,作為一個合理的編碼,5位已經儘可能的短了。你可以用一個可變長度的編碼,如霍夫曼編碼。常見的,這將允許字母e比起字母q有更短的編碼。這將可能允許最短1位來編碼一個字元,在一些極端的情況下假如你的字串全部都是e。這樣也將導致更復雜的空間開銷,編碼也可能更慢。

Apple採用了哪一種方法?讓我們找出答案。

運用 Tagged String

這裡有一段程式碼,它建立了一個這種字串並輸出它的指標。

mutableCopy/copy是必要的。原因有兩個。首先,儘管像@”a”這樣的字串可以儲存為一個Tagged Pointer,但是字串常量卻從不儲存為Tagged Pointer。字串常量必須在不同的作業系統版本下保持二進位制相容,而Tagged Pointer的內部細節是沒有保證的。其能使用的前提是Tagged Pointer在執行時總是由Apple的程式碼生成,如果編譯器把它們嵌入二進位制裡,那麼前提就被打破了(字串常量就是這樣)。因此我們需要copy常量字串來獲取Tagged Pointer。

mutableCopy是必要的,因為NSString太聰明,而且也知道一個不可變字串的副本是一個毫無意義的操作,所以它會返回原字串的當作“copy”。字串常量是不可變的,所以[a copy]結果只是a。一個可變數的副本強迫它產生真正副本,這樣一個可變數副本的不可變的副本足以讓系統給我們產生一個採用Tagged Pointer的字串。

注意不要在你自己的程式碼裡依賴這些細節!這是NSString的當前情況,它隨時可能改變。如果你的程式碼某種程度上依賴於此,那麼程式碼最終將失效。幸運的是,只有非正常的程式碼才會這樣。所有正常、合理的程式碼都沒有問題,傻傻的不知道任何Tagged Pointer而幸福著吧。

以下是上面程式碼在我電腦上的輸出。

    0x10ba41038 0x6115 NSTaggedPointerString

首先你可以看到原始指標,一個真正的物件指標。副本是第二個值,非常清楚,這是一個奇數,這意味著它不是一個有效的物件指標。這也是一個較小的數,在未對映且不可對映的4GB零頁的64位Mac地址空間的開頭裡,這使它更加不可能是一個物件指標。

我們從這個0x6115中可以推斷出什麼?我們知道,Tagged Pointer的最低4位是其機制本身的一部分。最低半位元組5的二進位制是0101。最低位表示它是一個Tagged Pointer。接下來的3位表示其所屬類。010,表明字串類在類表中的索引為2。這些資訊並不是很有用。

開頭的61是有啟發性的。61在十六進位制里正好是小寫字母a的ASCII編碼,這正是字串的值。看來是直接的ASCII編碼。方便!

類名告訴了我們這個類的用途,並是一個很好的去考慮其真正的程式碼實現的入手點。我們會很快談到它,但是先讓我們再做一些外部檢查。

以下是一個迴圈,構建了許多形如abcdef……的字串,並一個接一個輸出,直到停止產生Tagged Pointer。

第一個輸出:

0x0000000000006115 a NSTaggedPointerString

上面我們看到的這個匹配值。請注意,我輸出了包含所有前導零得完整指標,這樣能更清楚與後續輸出值比較。讓我們再看看第二個輸出:

    0x0000000000626125 ab NSTaggedPointerString

正如我們所想的那樣,最低的四位並沒有改變。即那個5將保持不變,表明這是一個NSTaggedPointerString型別的Tagged Pointer。

前面的61沒變,並加入了62。62顯然是b的ASCII編碼。所以我們可以看到,這是一個八位的ASCII編碼。5之前的值從1變到2,表明這可能是長度。後續的輸出證實了這一點:

    0x0000000063626135 abc NSTaggedPointerString

    0x0000006463626145 abcd NSTaggedPointerString

    0x0000656463626155 abcde NSTaggedPointerString

    0x0066656463626165 abcdef NSTaggedPointerString

    0x6766656463626175 abcdefg NSTaggedPointerString

大概就到這裡了。Tagged Pointer已滿,下一次迭代將分配一個真正的NSString物件並終止迴圈。對嗎?錯了!

    0x0022038a01169585 abcdefgh NSTaggedPointerString

    0x0880e28045a54195 abcdefghi NSTaggedPointerString

    0x00007fd275800030 abcdefghij __NSCFString

迴圈還經過兩次迭代之後才停止。資料部分繼續增長,其餘部分變成亂碼。發生了什麼?讓我們看看其具體實現。

反編譯

NSTaggedPointer類在CoreFoundation框架裡,似它乎應該在Foundation框架裡,但是最近很多核心Objective-C類已經搬到CoreFoundation裡了,Apple正在慢慢放棄讓CoreFoundation成功一個獨立的實體。

讓我們先看看 -[NSTaggedPointerString length] 的實現:

    push       rbp

    mov        rbp, rsp

    shr        rdi, 0x4

    and        rdi, 0xf

    mov        rax, rdi

    pop        rbp

    ret

用Hopper進行反編譯

簡而言之,為了得到長度,提取4-7位並返回。這證實了我們之前的想法。

另一個NSString的原始方法是characterAtIndex:。我將跳過冗長的反編譯,以下是Hopper的反編譯輸出,已經相當可讀了:

讓我們整理一下。前三行只是Hopper告訴我們哪些暫存器獲取哪些引數。讓我們用_cmd替換rsi,用self替換rdi。因為arg2實際上就是index,所以讓我們用index替換r13。讓我們去掉所有__stack_chk,因為它只是用於加強安全性,和方法的具體實現無關。這樣整理後變成:

在第一個if語句之前有這一行:

這正是之前看到的提取長度的程式碼。讓我們用length替換r12:

在if語句的內部,第一行把self右移了8位。這8位記錄的是:Tagged Pointer標記和字串長度。其餘部分,就是我們認為的真正的資料。讓我們用stringData替換rbx使程式碼更加清晰。下一行似乎是把某種查詢表賦給rcx,所以讓我們用table替換rcx。最後,length的副本被賦給rdx。看來將被用作某種遊標,讓我們用cursor替換rdx。現在我們的程式碼長這樣:

這幾乎是所有的變數。剩下一個原始暫存器名:rbp。這實際上是幀指標,所以編譯器直接從幀指標做一些索引。加一個0xffffffffffffffbf常數是二進位制補碼中“一切都是一個無符號整數(everything is ultimately an unsigned integer)”減去65的方法。然後,它減去64。這在堆疊上可能都是相同的區域性變數。鑑於按位元組索引,這可能是一個放在堆疊種的緩衝。奇怪的是,其實有一個方法能夠直接讀取緩衝區而無需專門編寫。發生了什麼?

原來Hopper忘了反編譯在if外的else的部分。整合在一起變成了這樣:

   mov        rax, rdi

    shr        rax, 0x8

    mov        qword [ss:rbp+var_40], rax

var_40在Hopper的反編譯中表示的偏移量64。(40是64的十六進位制版本)讓我們呼叫這個指向位置緩衝區的指標。這個錯失部分的C版本看起來是這樣:

*(uint64_t *)buffer = self >> 8

讓我們繼續並插入這句程式碼,並用buffer替換rbp,這使得程式碼更加可讀。另外新增一個緩衝區的宣告來提醒我們:

好多了。雖然有些瘋狂的指標操作語句有點難讀,但是他們只是陣列索引。讓我們解決這個問題:

現在我們已經取得了一些進展。

我們可以看到根據不同的長度分為三種情況。長度值小於8走錯失的else分支,只是取值,移位,放到緩衝區。這是純ASCII的情況。在這裡,index是用來索引self的值並提取指定的位元組,然後返回給呼叫者。既然ASCII編碼在ASCII範圍內匹配Unicode編碼,也就無需額外的操作來讀出正確的值。我們之前猜測純ASCII的字串是以這種方式儲存,這裡證實了這種猜測。

如果長度是8或者更長呢?如果長度是8或者更長但比10(0xa)小,程式碼進入一個迴圈。這個迴圈取低6位的stringData,當作一個表的索引,然後將該值複製到緩衝區。然後把stringData右移6位並迴圈,直到它遍歷整個字串。這是六位編碼,先把六位編碼對映到ASCII字元再儲存在表中。在緩衝區建立臨時字串,然後在索引操作結束時從中提取所要求的字元。

如果長度是10或者更長呢?程式碼幾乎相同,除了它是五位迴圈一次,而不是六位。這是一個更緊湊的編碼,使字串能儲存11字元,但使用一個只有32個值的編碼表。這將使用六位編碼表的前半個表作為編碼表。

因此我們可以看到採用Tagged Pointer的字串的結構是:

1:如果長度介於0到7,直接用八位編碼儲存字串。

2:如果長度是8或9,用六位編碼儲存字串,使用編碼表“eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX”。

3:如果長度是10或11,用五位編碼儲存字串,使用編碼表“eilotrm.apdnsIc ufkMShjTRxgC4013

讓我們與之前輸出的資料對比一下:

0x0000000000006115 a NSTaggedPointerString

    0x0000000000626125 ab NSTaggedPointerString

    0x0000000063626135 abc NSTaggedPointerString

    0x0000006463626145 abcd NSTaggedPointerString

    0x0000656463626155 abcde NSTaggedPointerString

    0x0066656463626165 abcdef NSTaggedPointerString

    0x6766656463626175 abcdefg NSTaggedPointerString

    0x0022038a01169585 abcdefgh NSTaggedPointerString

    0x0880e28045a54195 abcdefghi NSTaggedPointerString

    0x00007fbad9512010 abcdefghij __NSCFString

二進位制0x0022038a01169585去掉末尾8位再分割成一個個6位的塊變成:

    001000 100000 001110 001010 000000 010001 011010 010101

用這些索引去編碼表中獲取值,我們可以看到,這確實拼出“abcdefgh”。

同樣,二進位制of0x0880e28045a54195去掉末尾8位再分割成一個個6位的塊變成:

    001000 100000 001110 001010 000000 010001 011010 010101 000001

我們可以看到前面是相同的,不過末尾加上i。

但接下來就離奇了。接下來,它本應該用五位編碼返回兩個字串,然而它卻開始生成長度為10的物件。到底發生了什麼?

五位編碼表是非常有限了,但不包括字母b!在神聖的五位編碼表裡,那個字母肯定不是常見到足以留下。讓我們從c開始再試試。以下是輸出:

    0x0000000000006315 c NSTaggedPointerString

    0x0000000000646325 cd NSTaggedPointerString

    0x0000000065646335 cde NSTaggedPointerString

    0x0000006665646345 cdef NSTaggedPointerString

    0x0000676665646355 cdefg NSTaggedPointerString

    0x0068676665646365 cdefgh NSTaggedPointerString

    0x6968676665646375 cdefghi NSTaggedPointerString

    0x0038a01169505685 cdefghij NSTaggedPointerString

    0x0e28045a54159295 cdefghijk NSTaggedPointerString

    0x01ca047550da42a5 cdefghijkl NSTaggedPointerString

    0x39408eaa1b4846b5 cdefghijklm NSTaggedPointerString

    0x00007fbd6a511760 cdefghijklmn __NSCFString

我們現在有長度為11的採用Tagged Pointer的字串。最後兩個字串的二進位制是:

    01110 01010 00000 10001 11010 10101 00001 10110 10010 00010

    01110 01010 00000 10001 11010 10101 00001 10110 10010 00010 00110

正如我們所想的那樣。

建立採用Tagged Pointer的字串

完整的六位編碼表是:

    eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX

一個顯然的問題是:為什麼這個指令這麼奇怪?

因為這個表同時用於六位編碼和五位編碼,它不完全按字母順序排列是有意義的。最常使用的字元應該是上半部分,而較少使用的字元應該在下半部分。這可以確保儘可能多的長字串可以使用五位編碼。

然而,這種分為兩個部分的分割,每個部分內的順序並不重要。每個部分內本身是可以按照字母表順序排列的,然而實際上沒有這樣。

表中前幾個字母與字母出現在英語裡的頻率相似。最常見的英文字母是E,然後是T,A,O,I,N,S。E作為表的開頭是正確的,其他的則靠近開頭。表似乎是按使用頻率排序。與英語的差異可能是因為Cocoa APP中的短字串並不是一個從英語散文中隨機選擇的單詞,而是更專業的語言。

我推測Apple最初想使用一個更漂亮的變長編碼,可能基於霍夫曼編碼。但是太困難,或者不值得,或者他們時間不夠了,所以他們退而求其次推出一個如上所述的不那麼雄心勃勃的版本,字串使用定長的八位,六位,或五位編碼。奇怪的表是當前版本的一個殘留物,也是一個起點,如果他們決定在未來去採用變長編碼。這是純粹的猜測,它看起來更像是我會做的事。

結論

Tagged Pointer是一個很棒的技術,能把它運用在字串上很不尋常。顯然Apple花了很多心思在這上面,他們必須要看到一個顯著的好處。看他們如何把這些技術融合在一起,看他們如何在非常有限的空間裡面儘可能的儲存資訊,實在有趣。

相關文章