contributed by < toastcheng
>
linux2021
透過 xs
使用 union
的方式可以看出 xs
總共佔用 16 bytes (128 bits) 的記憶體空間,因為不同情境可能為 char data[16]
或另外兩種 struct
。
xs_data
回傳字串的開頭從程式的分支邏輯對應 xs
的三種 layout,可以看出依照不同情境,字串的開頭可能分別在:
x->data
指向的位置x->ptr
後 4 個 byte 的位置LARGE_STRING_LEN
(256),會利用前 x->ptr
指向位置的前 4 個 byte 存放 reference count,因此真正存放資料的位置在 x->ptr + 4
。與 xs_allocate_data
的邏輯相呼應,其依照字串長度做不同記憶體配置。x->ptr
指向的位置LARGE_STRING_LEN
,則一樣分配在 heap,並由 x->ptr
指向其位址,與 large string 差別在於不另外紀錄 reference count。有趣的是 x->data
及 x->ptr
應是在相同的位置,這個部分應該可以改善,可見 改進方案。
xs_size
回傳字串的長度從這個函式可以看出字串的長度如何儲存在 xs
,如果是 short string,其內容完全儲存在 stack,可以由 space_left
知道還有多少空間,因此將 15 - x->space_left
就能得到大小;而 medium string、large string 因為是動態配置,所以其大小另外存在 x->size
中。
xs_capacity
如果為 short string,容量固定為 15,而 medium string 及 large string 則透過 capacity
紀錄,而 capacity
則是在 xs_new
中決定的,會分配一個比字串長度還大的 power of 2 為其值。
xs_*_refcnt
對長字串的 reference count 做操作的函式,可以發現唯獨 xs_set_refcnt
並沒有確保字串為長字串,可見改進方案。
ilog2
取對數透過 __builtin_clz
的使用,找到最高位非 0 bit 的位置(從右算起),根據對數的定義該位置 - 1 便是 n
的對數的整數部分。
xs_allocate_data
配置記憶體xs_allocate_data
針對 medium、large string 做記憶體配置,在此判斷如果長度大於 LARGE_STRING_LEN
就要多配置 4 個 bytes 以供紀錄 reference count。
xs_new
xs_new
初始化一個 xs
,針對是否是 short string 決定是否要利用 xs_allocate_data
動態配置記憶體,在 xs_allocate_data
中會再針對 medium string 或 large string 做不同的配置。
xs_grow
增加 xs
所配置的記憶體空間至 len
,並更新 capacity
,並且處理字串種類的變化,但只涵蓋從 short string 成長為 medium string 的情況,仔細觀察也會發現若是成長為 large string,並沒有相對應的處理,改進可見改進方案。
xs_cow_lazy_copy
這個函式意義上應是將字串 data
的內容複製到 x
,先考慮 reference count > 1 的情況:
data
的內容更改為 x
,勢必無法繼續共用原本的記憶體空間,因此在更改之前呼叫 xs_dec_refcnt
(x
不再引用到這個記憶體空間)xs_allocate_data
重新分配記憶體空間memcpy
進行複製而如果 reference count <= 1,
在最初的檢查 x
會直接回傳 false 並且完全沒有執行到 memcpy
,亦即沒有真的進行複製。對於這樣的行為思考許久,為什麼不必複製?
搜尋使用的情境發現呼叫 xs_cow_lazy_copy
的方式都是對相同的內容進行複製,共有兩處。
xs_concat
:
以及 xs_trim
:
或許這兩處使用 xs_cow_lazy_copy
是因為它們將要改動原本的內容,因此先複製到別的位置。但 xs_cow_lazy_copy
較符合預期的行為應該還是要複製 reference count <= 1 的內容。
xs_concat
回傳一個 prefix + string + suffix
的字串。
xs_trim
將 x
開頭及結尾含有 trimset
內的字元的部分截去。
程式碼中可以看到 xs
所代表的字串有分為短中長三種,但這三種並不是一一對應 xs
的三種排列,短字串對應的是:
長字串則是:
可以發現 char data[16];
是多餘的,其功能可以由 filler
代替,因為它們指向相同的記憶體位址,雖然 filler
的大小為 15 個 bytes,但當字串長度包含 \0
為 16 時,space_left
為 0、後面 4 個 flag 也因為是 short string 而為 0,因此 filler
後的最後一個 byte 也會是 \0
。
但這樣設計要成立需要確保編譯器沒有為了避免 unaligned memory access 做 padding。
根據 ISO/IEC 9899:201x 6.7.2.1 p.113
If enough space remains, a bit-field that immediately follows another bit-field in a structure shall be packed into adjacent bits of the same unit
以 1 word = 4 byte 來說,filler
佔據了 3 個 word 又 3 bytes,最後一個 word 恰好可以讓剩下的 bit fields 補齊,因此沒有 padding 發生。
做一個實驗來看看每個 bitfield 的位置是否不必加額外的 pack 也不會有多餘的 padding:
但很顯然 bit field 無法被 address of:
因為 bit field 的單位是 bit,比指標指向的單位 (byte) 還要細。
在 ISO/IEC 9899:201x 6.7.2.1 p.112 提到:
The unary & (address-of) operator cannot be applied to a bit-field object; thus, there are no pointers to
or arrays of bit-field objects.
所以改由觀察整個 struct 所佔據的記憶體空間,使用 sizeof
來看是否大於 16:
預期輸出:
結果顯示確實沒有額外的 padding。
參考資料
Unaligned Memory Accesses
xs_set_refcnt
checkingxs_set_refcnt
並沒有防止使用者對 short string、middle string 指派 reference count。
xs_allocate_data
考慮將已分配為 large string 重新分配成 medium string 的長度,應把 is_large_string
這個 flag 設為 0。
xs_grow
邏輯錯誤xs_grow
有許多明顯的問題:
is_ptr
才在後續判斷原本是否為 short string (利用 xs_is_ptr
)用一個簡單的測試就能看到程式的問題:
預期輸出:
修改程式碼如下:
預期輸出:
參照 5. 整合 string interning 的部分,將 xs_cow_lazy_copy
改寫,主要需要考量若 x
為 large string,那複製 data
到 x
時,必須一併處理 reference count,在複製前,應將 reference count 減一,然後再為 x
配置新的記憶體空間。
考量到在 string interning 中的實作,會對比字串的 hash 是否跟 pool 中存在的 string 相同,若有則進一步比對,若確定與現存的 string 重複,則使用同一段記憶體空間。
但在 xs_cow_lazy_copy
的使用情境不同,即便字串相同,但可能即將被 concat
或 trim
等方式修改內容,所以會希望它被存在不同的記憶體空間,即使內容重複,因此實作 xs_interning_nontrack
,將記憶體配置,而不透過 string interning 紀錄其值,也不會計算 hash。
而在 concat
及 trim
完成對字串的更動之後,加上這一個操作,將該記憶體位置加入 string interning 的紀錄。
add_interning_address
的實作如下,跟 add_interning
雷同,差別在於它直接接受一個位於 heap
的字串位址,不必再分配記憶體,而 add_interning
則是需要自行配置記憶體,再將該記憶體位址回傳給呼叫的函式,該函式需要負責將位址指派到 xs_data(xs)
。
應考慮到 locality of reference,善用 GDB 追蹤和分析,確認字串的確符合預期地配置於 stack 或 heap;
// TODO 補完分析和實驗
xs
空間效率針對 1000 個 short+medium 的字串做初始化:
xs
std::string
嘗試用簡易的方式來追蹤程式中的 large string,加入 intern.[ch]
來為 xs
擴充。
大部分的 struct 為 cstr
的簡化:
實作 add_interning
,該函式主要會被 xs_new
呼叫,若是使用者新增了一個 large string,便會透過此函式分配記憶體,並計算 hash,放進 pool 中。
修改過後的 xs_new
,直接負責處理不同長度字串的邏輯,而 xs_allocate_data
也簡化為處理 middle string 的記憶體分配:
large string 的記憶體分配以及 string interning 紀錄則改由 xs_interning
來負責:
作為測試,新增三個長度超過 LARGE_STRING_LEN
的相同字串,並用 xs_data
(效果同 string->ptr + 4
)並用 %p
觀察是否有共用同一段記憶體位址。
預期輸出:
詳細實作可見 GitHub(TODO: 優化實作)。
CoW 的設計應該會有兩個情境:
觀察 sk_buff
,為代表 socket buffer 的 struct,有對應的函式 skb_copy
、skb_clone
:
skb_copy
註解提到:
Make a copy of both an &sk_buff and its data. This is used when the caller wishes to modify the data and needs a private copy of the data to alter.
很明顯地這是 CoW 的情境,在預期會修改內容的情況複製,並將 reference count 設為 1。
skb_clone
註解提到:
The new one is not owned by a socket. Both copies share the same packet data but not structure. The new buffer has a reference count of 1.
透過 skb_clone
複製的 socket buffer 會跟原本的 sk_buffer
共享 packet data (unsigned char *data
),並將 reference count 設為 1,可見 __skb_clone
,在 sk_buff
中 reference count 紀錄在 refcount_t users
中:
這和純粹的共享資料有點不同,可以注意到 skb_clone
依然將 reference count 設為 1,照 string interning 的邏輯,如果要重複使用相同的字串,應該要將 reference count 加一。
那增加 reference count 的情境應該是本來預期的共享資料,因此用 refcount_inc\(&[a-zA-Z]+->users\)
搜尋,但並沒有特定的函式負責管理,而是每個函式在接收到 sk_buff
的指標副本後負責增減 reference count,各自管理。
skb_unshare
另外還有 skb_unshare
這樣的函式,先用 skb_cloned
來判斷拿到的 skb
是否為透過 skb_clone
得到的,如果是就重新呼叫 skb_copy
斷開兩者共享的 packet data。