1. 程式人生 > 其它 >CMU15-445 Lab2 B+Tree全記錄

CMU15-445 Lab2 B+Tree全記錄

寫在前面

最近在學CMU15-445。趁著實習的間隙,晚上,還有周末,看看視訊,寫寫lab。
CMU15-445的lab與MIT6.824的lab風格很不一樣。前者定義好了函式原型,提示更多,但是禁錮了思維,發揮空間變小了。後者只提供了最基礎的介面,在程式碼架構上的可發揮性更高。
由於函式原型都給好了,我以為這個lab會簡單很多。結果沒成想,寫著寫著,發現B+樹這個lab給我整不會了。花了足足一個月,才把lab寫完,目前程式碼在gradescope上已經通過。通關截圖:

在做這個lab的過程中,學到了不少東西。在這裡簡單總結下。

整體架構

lab的整體架構很清晰。先實現internal_page、leaf_page這兩個資料結構,作為B+樹的內部節點和葉子節點。然後實現B+樹的插入、刪除,最後支援併發。
難點主要有四處:

  1. 內部節點和葉子節點的實現沒有專門的測試。需要自己寫測試。
  2. 插入和刪除的細節需要仔細思考。
  3. 併發部分的具體實現方式。
  4. debug

內部節點與葉子節點

在這一部分中,我們要實現一系列的小函式。整個過程比較繁瑣,但難度不大。
在我最初的版本中,所有的搜尋都是線性時間複雜度的。這樣做實現簡單,可以確保正確性。但是在後續效能調優中,發現此處的效能瓶頸很嚴重。於是改為了二分搜尋。這是後話。
在完成所有的小函式後,我自己手寫了一組測試,用來檢測內部節點的正確性。(葉子節點比較簡單,就沒有寫測試。)
本來以為,這些簡單的小函式,我是絕不可能出錯的。但是通過測試,還是發現了兩個小bug。
心得是:永遠不要過於相信自己。用測試結果說話。

插入與刪除

插入與刪除的部分需要仔細思考。
對於內部節點,在插入刪除時需要考慮middle key。這裡以刪除時redistribute為例:

插入與刪除時,分裂以及合併的具體實現會影響內部節點、葉子節點的相關函式實現。
具體來說,出於效能考慮,我做了以下設計:

  1. 分裂時,新建一個右節點,將需要分裂的節點的後一半移動到新建的右節點中,剩下的一半保持不動。這樣與新建一個左節點相比,減少了將剩下一半前移的開銷。
  2. 合併時,預設將右節點合併到左節點。這樣與將左節點合併到右節點相比,減少了將右節點全部節點後移的開銷。

在理清思路後,插入與刪除部分就順理成章地完成了。

併發

併發是難度最大的點。
難點如下:

  1. 怎樣實現latch crabbing過程中的加鎖和解鎖。
  2. 怎樣實現節點的刪除,不要讓刪除與加鎖解鎖相沖突。
  3. 如何對根節點相關的資訊加鎖,以及如何及時釋放根節點的鎖。

latch crabbing

latch crabbing的思想很簡單。但在實現時比較複雜。
讀取的情況比較簡單:加鎖過程中,我們跟蹤當前節點和它的父節點(用兩個指標實現),對它們進行加解鎖。
插入/刪除的情況相比而言更復雜:我們不僅要考慮父節點,還要考慮所有的祖先節點。

---下面這一段是記錄給自己看的,可以略過---

在實現過程中,我首先考慮的實現方式是這樣的:(後面捨棄了)
FindLeafPage函式中,先對所有需要加鎖的祖先進行加鎖。但並不記錄下這些祖先。在Remove/Insert函式中,每當要分裂/合併/重分佈時,都通過GetParentId獲取父節點(“順藤摸瓜”)。當然,這時父節點已經在FindLeafPage中被latch住了,因此不用再獲取鎖。當使用完父節點後,解鎖之。
這樣做的優點在於,可以在祖先使用完畢後,立即及時釋放祖先的鎖。
但是採用這種方式,正確性是有問題的:若Remove時僅進行Redistribute,那麼上溯將在Redistribute後停止。但是這一層上面可能還有已經加鎖的祖先節點。我們將無法對它們進行解鎖。這可以通過額外的醜陋機制加以解決。
最重要的時:正確性之外,程式碼實現變得非常醜陋--解鎖分散在程式碼的各個位置,還要考慮大量解鎖的corner case,實現和維護難度極大。

在痛苦地掙扎了一週後,我決定放棄這種思路。改為如下實現:

---結束---

最終的實現如下:
FindLeafPage函式中,記錄下latch crabbing過程中Fetch並加鎖的祖先(例如:用一個佇列),在

  1. FindLeafPage中發現安全的節點後
  2. 整個Insert/Remove函式最後

清空佇列,把所有的祖先都解鎖並Unpin。(使用佇列的原因是,可以先釋放最上層的祖先)
乍一看,似乎按照第二條的做法,並不能及時地在使用完祖先後,立即釋放祖先的鎖。而是要等到整個修改操作完成後,才能釋放祖先的鎖。
但要注意的是:Insert/Remove函式是從樹的底層向樹的上層遞迴的。在遞迴的最後,才會接觸到最上層的祖先。在這之前,提前解鎖下層的節點並不能帶來什麼好處。由於最上層的祖先仍然被鎖住,即使下層的節點被解鎖,其他執行緒也無法訪問到它們。
因此,採用佇列記錄的方案,並不會對解鎖的及時性產生影響。

採用這種方案,解鎖變得異常整潔:在InsertRemove函式及其呼叫的所有函式中,我們都不用考慮與鎖相關的事情。
心得:程式碼可維護性很重要。當代碼邏輯過於複雜時,要考慮使用一些資料結構等,簡化程式碼邏輯。

節點刪除

如果我們在Remove函式及其呼叫的子函式中,直接解鎖unpin,並呼叫buffer_pool_manager_->DeletePage刪除頁,會與解鎖的流程衝突。這是因為,被刪除的頁可能會在加鎖佇列中。當Remove函式執行到最後時,會再次試圖解鎖已經被刪除的頁。
為了解決這個問題,我引入一個unordered_map,記錄所有要被刪除的節點。在需要刪除節點時,僅將節點加入map中。在Remove函式最後解鎖所有祖先後,再真正刪除map中記錄的所有節點。
在解鎖後再刪除節點並不會引起併發問題。這是因為要被刪除的節點已經與B+樹斷開了所有的連線,其他的執行緒已經不再能夠訪問到它們了。

對根節點加鎖

b_plus_tree類中,有一個成員變數root_page_id,它記錄了B+樹根節點的page id。任何一個執行緒在對B+樹進行任何操作前,都需要讀取root_page_id;插入和刪除時,有些情況下需要修改root_page_id。因此這個變數需要用鎖保護。
在作業要求中,老師建議使用std::mutex進行保護。因此我引入了root_latch鎖。
在我最初的版本中,對root_latch的加解鎖方案是這樣的:

  1. GetValue/Insert/Remove函式開始時,對root_latch加鎖
  2. 在獲取了根節點的讀鎖後,釋放root_latch
  3. 在釋放了根節點的寫鎖後,釋放root_latch

前兩條都是合理的。但是第三條對效能有一定影響:當根節點的內容需要修改時,我們會獲取根節點的寫鎖。但並不是所有需要修改根節點的情況下,都需要修改root_page_id。例如:根節點的子節點分裂,需要在根節點中新增新項,但並不會導致根節點分裂。
因此在後續版本中,為了優化效能,在FindLeafPageComplex中添加了一段程式碼。將第三條修改為:在獲取根節點的寫鎖後,檢查其Size。若根節點“安全”,則釋放寫鎖。

其他優化

在課上Andy提到,latch crabbing有樂觀加鎖的版本。具體而言,在插入/刪除時,並不是直接一路向下新增寫鎖。而是先一路新增讀鎖,在遇到葉子節點時新增寫鎖。若葉子節點“安全”,則直接進行插入/刪除操作。若葉子節點不“安全”,則解除葉子節點的寫鎖,重頭再來,一路向下新增寫鎖。
在完成上面的部分之後,我心血來潮,想要把插入/刪除的併發控制改成樂觀的。
改成樂觀控制並不難。要想支援這個功能,需要讓每個內部節點,都能夠判斷其子節點是否為葉子節點,從而判斷對其加讀鎖還是寫鎖。因此我在內部節點類中添加了一個成員變數is_child_leaf,並在分裂時維護這個變數。
在完成樂觀控制後,我發現這並不會提高效能。在gradescope上的leaderboard中,樂觀版本的執行時間和悲觀版本一樣。
心得:過早的優化是萬惡之源

debug

在完成上述內容後,我開始對程式碼進行測試。使用的測試程式碼是15-445學習群中獲得的,gradescope的測試程式碼。
在測試過程中,併發插入總能通過。但是在併發刪除與混合測試中,我一直遭遇兩種錯誤:

  1. 鎖相關的報錯:pthread_mutex_lock.c:62: __pthread_mutex_lock: Assertion mutex->__data.__owner == 0' failed`
  2. 在呼叫buffer_pool_manager_->DeletePage刪除節點時,有節點的Pin Count不為0。在大多數錯誤情況下,這些節點的Pin Count為1。少數情況下,Pin Count為2。

這兩個錯誤困擾了我一週多的時間。在此期間,我對程式碼進行了多次review,發現了第一個錯誤的原因:
Remove函式中,需要訪問當前節點的sibling時,必須要對sibling加鎖。這是因為,sibling節點可能在上一次插入/刪除操作中,是被加鎖的最古老的祖先。在執行本次刪除操作時,上一次插入/刪除操作還沒有完成,sibling仍在被使用。

但是第二個問題遲遲得不到解決。
首先,我進行了大量的單執行緒測試,確保單執行緒的Remove並不會發生任何問題。那麼可以確認問題是出在多執行緒上。
接下來,我進行了併發測試,添加了大量log。甚至採用了從6.824助教那裡學來的方法:對log加顏色。(不過加顏色真心好用!)
在這個過程中,我逐漸對問題進行定位。發現錯誤總是發生在如下場景:一個執行緒多次對同一個葉子節點執行刪除操作,直至該節點不再安全,需要執行合併,需要刪除該節點。在該執行緒執行刪除操作時,發現Pin Count不為0。
看起來,有另一個執行緒也正在訪問這個葉子節點。這就很奇怪了。要想訪問某個節點,必須對其進行加鎖。既然正在執行刪除的執行緒可以修改葉子節點,那麼其它執行緒必然沒有獲取到寫鎖,因此不能訪問葉子節點。
接下來,我又對程式碼中負責對葉子節點加鎖的部分進行了嚴密的檢查,但並沒有發現問題。可以認為,加鎖的邏輯是正確的。錯誤隱藏的比我想象的更深。
那麼我能做的,就只有再多跑測試,多打log,直到找到一次能夠揭示問題原因的測試結果為止了!
幸運的是,一個下午過後,這樣的測試結果出現了。
我發現,在執行刪除操作之間,有另一個執行緒,對需要被刪除的葉子節點進行了Fetch、加鎖、解鎖,但並沒有unpin
也就是說,問題的根源在於,解鎖與Unpin不是原子的
要想解決這個問題,方案有兩個:

  1. 讓解鎖與Unpin變成原子的。這需要引入一把新的鎖。
  2. 在刪除時,若發現Pin Count不為0,則sleep一段時間。等待另一個執行緒unpin。若甦醒後發現Pin Count仍不為0,則不斷迴圈。

方案2比較簡單,因此我選擇了方案2。在這之後,問題迎刃而解,測試通過。

效能調優

將程式碼提交到gradescope上。發現無法通過memory safety測試。經查詢,確認這是由於程式碼太慢。
那麼工作的重點就轉移到效能調優上了。
首先我引入了一個解鎖優化,即“對根節點加鎖”這一節中,對第三條的優化。但是並未對程式碼速度產生什麼影響。這樣一來,似乎程式碼太慢不是由於多執行緒鎖爭用導致的。

那麼我們就必須弄清楚,效能問題到底是由於單執行緒太慢,還是由於併發鎖衝突導致的。

首先觀察測試花費的時間:

  1. 對於單執行緒插入測試,插入1000個記錄,用時34ms。
  2. 對於多執行緒混合測試Mixtest1,兩個執行緒(一個插入,一個刪除),分別插入/刪除1000個記錄,迴圈100次,用時4367ms。
  3. 對於多執行緒混合測試Mixtest1,十個執行緒(五個插入,五個刪除),分別插入/刪除1000個記錄,迴圈100次,用時15622ms。

觀察1和2,436734*100大約在同一個數量級。考慮到兩執行緒必然會發生一些鎖衝突,可以認為兩個執行緒的衝突並不嚴重。
觀察2和3,執行緒數量變為五倍,用時變為3倍多。考慮到Mixtest1中插入的記錄數量不多(1000個。與之相比,節點的MaxSize有200多。),樹較淺(應該只有兩層),這個衝突情況可以接受。

那麼導致超時的主要原因,應該是單執行緒太慢。

恰好前段時間在The Missing Semester of Your CS Education中,瞭解到了“火焰圖”這個工具,感覺很酷炫,這次正好拿來嘗試一下。
火焰圖的github連結
火焰圖與perf搭配使用,可以分析各個函式呼叫所使用的時間。火焰圖是互動式的svg圖,使用很直觀,也很方便。
但需要注意,這種方式只能分析on-cpu time,也就是說,執行緒等待鎖的時間是無法被計入的。
不過沒關係,我們正是要分析單執行緒的執行情況的。

作圖如下:(其實這裡應該對單執行緒測試作圖。但我當時只對併發混合測試MixTest1做了圖。其實不太嚴謹,但問題解決了就好。)

圖裡面有兩個MixTest1相關的部分。我不知道是為什麼。但這不影響我們的分析。

放大來看:

Remove:

### Insert:

可以很明顯地看到,Remove中佔大頭的是LookUp函式。Insert中佔大頭的是KeyIndex函式。
這個時候我回想起來,我的這兩個函式都是線性時間複雜度的。這裡一個節點中記錄的個數在200多。如果把它們改成二分查詢,最多隻需要8次查詢(log2(200)),應該可以大大提速。

在改為二分查詢之後,成功地通過了gradescope的所有測試,用時顯示為5.16,排34位,還不錯!

寫在最後

這次lab做了超級超級久,中間一度想過放棄。但是很慶幸,自己最後還是堅持了下來。通過這次實驗,我第一次寫了測試程式碼,第一次嘗試帶顏色的log,第一次用火焰圖進行了效能分析。收穫頗豐!
(不要問我為什麼6.824的lab4還沒有更新。咕咕咕!