胡未滅,鬢已秋,淚空流
此生誰料
心在天山
身老滄州
——訴衷情
完整代碼見:
SnowLegend-star/CMU15445-2023fall: Having Conquered the Loftiest Peak, We Stand But a Step Away from Victory in This Stage. With unwavering determination, we press onward, for destiny favors those brave enough to forge ahead, cutting through the thorns and overcoming every obstacle that dares to stand in their way.
Task #1 - Read/Write Page Guards
實驗說明分析:
????????在緩沖池管理器中,FetchPage 和 NewPage 函數返回已經鎖定的頁面指針。這個鎖定機制確保頁面在沒有任何讀取和寫入時不會被驅逐。為了表示頁面在內存中不再需要,程序員必須手動調用 UnpinPage。
????????然而,如果程序員忘記調用 UnpinPage,頁面將永遠不會被驅逐出緩沖池。由于緩沖池實際上只有較少的幀,這將導致更多的頁面在磁盤和內存之間交換。這樣不僅會影響性能,而且這個 bug 很難被發現。
????????您將實現 BasicPageGuard 類,它存儲指向 BufferPoolManager 和 Page 對象的指針。一個頁面保護器確保一旦超出作用域,就會調用 UnpinPage 來解除鎖定頁面。請注意,它仍然應該提供一個供程序員手動解除鎖定頁面的方法。
????????由于 BasicPageGuard 隱藏了底層的頁面指針,它還可以提供只讀/寫數據的 API,這些 API 在編譯時進行檢查,確保正確設置 is_dirty 標志。
????????在這個和后續項目中,多個線程將會讀取和寫入相同的頁面,因此需要使用讀寫鎖來確保數據的正確性。請注意,在 Page 類中,已經為此目的提供了相關的鎖方法。類似于解除鎖定頁面,程序員可能會忘記在使用后解除鎖定。為了解決這個問題,您將實現 ReadPageGuard 和 WritePageGuard,它們會在作用域結束時自動解除頁面的鎖定。
????????由于在2024-fall的project1分析過了類似的,故實現這塊不在贅述。說實話我個人還是更喜歡2023f版的,這個版本的對PageGuard進行拆分得恰到好處。先是給出BasicPageGuard讓我們對PageGuard的作用有個具體的理解,再Basic的基礎上可以進一步升級為ReadPageGuard(下面簡寫為RPG)和WritePageGuard(簡寫為WPG)。這一套流程看下來2024f版對PageGuard的拆分程度太高了,上來就要求實現RPG和WPG,理解起來的難度要大不少。
????????接下來具體分析關于如何使用PageGuard。我相信很多人可能會對As和AsMut的用法有所疑惑。但無論是RPG還是WPG,其As與AsMut都是調用BasicPG的函數。我們只要理解透徹下面四個函數那對于PageGuard的使用也就是手到擒來了。
????????可以發現,GetData()與As()是配套的,而GetDataMut()和AsMut()也是配套的。而且GetDataMut這個函數中,修改了is_dirty_位,這說明一旦我們選擇調用AsMut,那最好是真的對page的內容進行了修改,自然也只有WPG才能調用AsMut的。
????????那什么時候調用RPG與WPG的情況也就呼之欲出了。只要需要修改page時,才需要調用WPG。如果只是讀取page,那調用RPG就可以了。但是!如果你懶得思考要不要修改page那直接調用WPG也不是不行,就是會多造成disk IO降低吞吐率。如果不清楚是否需要修改page,保險起見可以直接調用WPG。
1. auto GetData() -> const char * { return page_->GetData(); }2. 3. template <class T>4. auto As() -> const T * {5. return reinterpret_cast<const T *>(GetData());6. }7. 8. auto GetDataMut() -> char * {9. is_dirty_ = true;
10. return page_->GetData();
11. }
12.
13. template <class T>
14. auto AsMut() -> T * {
15. return reinterpret_cast<T *>(GetDataMut());
16. }
17.
Task #2 - Extendible Hash Table Pages
實驗說明分析
哈希表頭頁面(Hash Table Header Page)
頭頁面位于基于磁盤的可擴展哈希表的第一層,并且每個哈希表只有一個頭頁面。它存儲指向目錄頁面的邏輯子指針(作為頁面ID)。可以將其視為一個靜態的第一層目錄頁面。頭頁面具有以下字段:
變量名 | 大小 | 描述 |
directory_page_ids_ | 2048 | 一個目錄頁面ID的數組 |
max_depth_ | 4 | 頭頁面可以處理的最大深度 |
請注意,盡管頁面的大小是物理限制,您應該使用 max_depth_ 來確定 directory_page_ids_ 數組的上限大小。
您必須通過僅修改其頭文件(src/include/storage/page/extendible_htable_header_page.h)和相應的源文件(src/storage/page/extendible_htable_header_page.cpp)來實現可擴展哈希表的頭頁面。
哈希表目錄頁面(Hash Table Directory Page)
目錄頁面位于基于磁盤的可擴展哈希表的第二層。每個目錄頁面存儲指向桶頁面的邏輯子指針(作為頁面ID),并包含用于處理桶映射以及動態擴展和收縮目錄的元數據。目錄頁面具有以下字段:
變量名 | 大小 | 描述 |
max_depth_ | 4 | 頭頁面可以處理的最大深度 |
global_depth_ | 4 | 當前目錄的全局深度 |
local_depths_ | 512 | 一個桶頁面本地深度的數組 |
bucket_page_ids_ | 2048 | 一個桶頁面ID的數組 |
請注意,盡管頁面的大小是物理限制,您應該使用 max_depth_ 來確定 bucket_page_ids_ 數組的上限大小。
您必須通過僅修改其頭文件(src/include/storage/page/extendible_htable_directory_page.h)和相應的源文件(src/storage/page/extendible_htable_directory_page.cpp)來實現可擴展哈希表的目
錄頁面。
哈希表桶頁面(Hash Table Bucket Page)
桶頁面位于基于磁盤的可擴展哈希表的第三層。它們實際上存儲鍵值對。桶頁面具有以下字段:
變量名 | 大小 | 描述 |
size_ | 4 | 桶中當前存儲的鍵值對的數量 |
max_size_ | 4 | 桶可以存儲的最大鍵值對數量 |
array_ | 小于或等于4088 | 存儲鍵值對的數組 |
請注意,盡管頁面的大小是物理限制,您應該使用 max_size_ 來確定 array_ 數組的鍵值對的上限大小。
您必須通過僅修改其頭文件(src/include/storage/page/extendible_htable_bucket_page.h)和相應的源文件(src/storage/page/extendible_htable_bucket_page.cpp)來實現可擴展哈希表的桶頁面。
重要說明:
每個可擴展哈希表的頭/目錄/桶頁面對應于通過緩沖池獲取的內存頁面的內容(即 data_ 部分)。每當您讀取或寫入頁面時,必須先從緩沖池中獲取頁面(使用其唯一的 page_id),然后將其重新解釋為相應的類型,在讀取或寫入后解鎖該頁面。我們強烈建議您利用在任務1中實現的 PageGuard API 來實現這一目標。
舉例說明可擴展哈希的插入流程
接下來直接舉個例子來解釋可擴展哈希表的具體實現過程:
初始Global_Depth=0,每個Bucket的Local_Depth=0,Bucket的max_size_=2。假設我們需要插入0 1 2 3 4 5 6 7 8 。
咳咳,又到了展示我精湛畫圖功力的時候了。Show time!
①插入0,1。由于初始的Bucket空間大小足夠,可以直接插入到Bucket0中。紫色筆記表示depth,左側表示Directory,右側表示Buckets。
③接下來插入2,發現B0已經滿了,無法直接插入。
- 所以B0先嘗試local_depth++來進行桶分裂,但是由于此時local_depth已經等于global_detph了,所以loc_d自增失敗,需要glo_d先自增。此時glo_d=1
- Loc_d++,即loc_d=1,自增成功可以進行桶分裂。原桶與新桶(brother)的loc_d都是1
- 將原Bucket的元素進行重映射,即遍歷B0的所有元素,進行key&loc_depth_mask將元素分配到原桶和新桶中。
如下圖。0&1=0,1&1=1。所以0依然放在原桶中,1重新放在下標為1的新桶中。
- 重新嘗試插入2。通過2&global_depth_mask即10&1=0來把2放入B0中。
③插入3。通過3&global_depth_mask即11&1=1來把3放入B1中。
④插入4。4&global_depth_mask即100&1=0,發現B0已經滿了,故準備進行桶分裂。
- 所以B0先嘗試local_depth++來進行桶分裂,但是由于此時local_depth已經等于global_detph了,所以loc_d自增失敗,需要glo_d先自增。此時glo_d=2
- Loc_d++,即loc_d=2,自增成功可以進行桶分裂。原桶與新桶(brother)的loc_d都是2
- 將原Bucket的元素進行重映射,即遍歷B0的所有元素,進行key&loc_depth_mask將元素分配到原桶和新桶中。
如下圖。0&11=00,10&11=10。所以0依然放在原桶B0中,2重新放在下標為2的新桶B10中。
- 重新插入4。4&global_depth_mask即100&11=00,所以把4插入到B0中
⑤插入5,5& global_depth_mask即101&11=01,發現B1這個桶已經滿了,準備桶分裂
- Glo_d > loc_d,loc_d++成功,故桶B1可以直接分裂,得到新桶B11
- 將原桶B1的元素進行重映射(key&local_depth_mask)分配到B01和B11中
- 重新嘗試插入5,5& global_depth_mask即101&11=01,插入B01成功。如果插入失敗繼續嘗試桶分裂。
⑥插入6,6& global_depth_mask即110&11=10,成功插入到B10中
⑦插入7,7& global_depth_mask即111&11=11,成功插入到B11中
⑧插入8,8& global_depth_mask即1000&11=00,發現桶B00已經滿了,嘗試進行桶分裂
- 所以B00先嘗試local_depth++來進行桶分裂,但是由于此時local_depth已經等于global_detph了,所以loc_d自增失敗,需要glo_d先自增。此時glo_d=3
- Loc_d++,即loc_d=2,自增成功可以進行桶分裂。原桶與新桶(brother)的loc_d都是3
- 將原Bucket的元素進行重映射,即遍歷B0的所有元素,進行key&loc_depth_mask將元素分配到原桶和新桶中。
- 重新嘗試插入8,8& global_depth_mask即1000&111=000插入成功。如果插入失敗繼續嘗試桶分裂。
最后可擴展哈希表如下:
其實這個例子中,應該用page_id來標記唯一的桶,為了方便理解我就直接用下標來標識每個Bucket。
通過上述例子,我們可以總結可擴展哈希表的插入流程
- 嘗試插入key。進行key&global_depth_mask從而找到對應的Bucket下標。當前Bucket不滿,直接插入成功,return。如果失敗進行步驟2
- Bucket滿了。
- 當前global_depth > local_depth,此Bucket進行local_depth++成功,可以直接進行桶分裂。進行步驟3
- 當前global_depth =local_depth,此Bucket進行local_depth++失敗,不可以可以直接進行桶分裂。進行Global_depth++,所以此Bucket可以進行local_depth++,桶分裂成功。進行步驟3
- 重映射。桶分裂成功后,遍歷原Bucket,對其中的每個元素elem進行elem&local_depth_mask,從而將原Bucket的元素分配到原桶和新桶中。重新進行步驟1
實現過程分析
我最近發現了個方法論,把實驗需求看了幾遍之后,把lab涉及到的頭文件,在這里是header_page.h、directory_page.h和bucket_page.h重新歸納一遍。只是硬看的話在實現函數的過程中很容易前面忘了,后面忘了,全忘了~簡稱忘3
一言蔽之,這個過程就是將插入的key進行各種轉換,最終找到相應bucket。我直接將lab給出的圖例進行了一番改造,下圖就是一個key進行映射的過程。
為了更好地理解lab要求,在粗略瀏覽一番代碼后,我們應該弄清楚以下幾個問題:
Q:在Header部分,核心函數HashToDirectoryIndex的目的是什么呢?
A:提取key的最高幾位,從而嘗試進入對應的Directory。
Q:在Directory部分,如何獲得Global_Depth?
A:作為task2的核心部分,大部分功能都是在directory_page.cpp進行實現的。在init()直接將Global_Depth和每個Bucket的Local_Depth都初始化為0就行。
Q:在Directory部分,HashToBucketIndex的目的是什么呢?
A:提取key的最低幾位,從而嘗試進入對應的bucket。這里用page其實更合適,因為每個bucket本質都是一張page。
Q:搞清楚bucket_index和page_id的區別?
A:bucket_index并不是我以為的
、
、
這種,而僅僅是指directory中的數組下標。而page_id才是真正的bucket序列號,用來區分唯一的bucket。
Q:GetSplitImageIndex()這個函數的作用是什么?
A:這個函數感覺沒描述清楚,通俗來說就是獲得當前bucekt的兄弟bucket。將當前bucket在Directory的下標最高有效位取反,就可以得到bro。例如,B0的兄弟是B1,B101的兄弟是B001。因為B101和B001都是從B01分裂而來的,所以他倆互為bro。
值得一提的是,task2并不需要實現這個函數。這個函數在task3才需要用到。
理解上述幾個問題后,可以先嘗試粗糙地將函數功能實現,然后對著測試樣例一步步改進細化。
?
?Task #3 - Extendible Hashing Implementation
實現了task2后,對可擴展哈希表的工作流程就已經大體了解得差不多了,現在就是具體實施對(key, value)的處理流程。為了找好著手點,我們可以自行模擬插入三個元素時,hash_table的工作流程。
就GetValue()、Insert()和Remove()三個核心操作而言,有一套共同的前期流程:
1、先從bufferpool中獲得Header Page。至于是以ReadGuadPage還是以WriteGuadPage獲得就取決于這次操作是否會修改Header Page了。得到key的最高幾位后,在Header Page中找到這個key對應的Directory 頁表項。如果存在,進入步驟2。
????????如果沒找到對應的Directory,return false或者從bufferpool中申請一個新的page來存放新的Directory Page,即調用InsertToNewDirectory。
2、找到對應Directory的頁表項后,以Page Guad的方式從bufferpool中獲取這個Directory Page。然后得到key的最低幾位后,在Directory Page中找到這個key對應的bucket頁表項。如果存在,進入步驟3。
3、同樣是以Page Guad的方式從bufferpool中獲取這個Bucket Page,然后就可以進行具體的操作了。
4、至于桶分裂、桶合并等操作就需要自己具體實現了。
對于桶合并的問題,分為三種情況:
- 當前bucket并沒有bro,即它的local_depth=0
- 當前bucket有bro,但是bro和它指向同一個bucket page,這種連體嬰就不需要繼續合并了
- 當前bucket有bro,且bro和它指向不同的bucket page,這種情況才可以正常合并
Q:什么時候需要合并呢?
A:當前bucket和bro有一方為空的時候進行合并。
OK,然后就是對著測試樣例一步步修正自己的代碼了。在提交到gradescope時,果不其然我遇到了兩個bug。
遇到的bug
1、合并時機沒領悟通透,像下面這種情況是需要繼續合并的
2、沒有及時釋放頁面的鎖
????????說實話這個問題過于隱晦了。測試樣例將bufferpool_size設置為3。而訪問Header Page需要占據一個frame,訪問對應的Directory Page需要占據第二個frame,訪問對應的Bucket Page需要占據第三個frame。
????????這個時候,如果需要訪問當前bucket的bro,就需要從當前bufferpool中逐出一個page。但是由于我沒有及時釋放Header Page,就導致前面三個page賴在bufferpool不走了,也就無法訪問新的page,造成訪問地址錯誤。
????????其實我感覺設置bufferpool_size=3是真的過于刻意了,就是盯著只有Header Page只有1個才想出來的餿主意。如果最高級的是root page,而且Header Page不止1個呢,那估計就會設置個bufferpool_size=4來卡一手人了。
????????總而言之,個人感覺Extendible Hash Index思維難度并不高,代碼量也適中。給我的感覺是構建一個b+樹demo工作量就能與之一戰了。
最后放一張通過的圖