Project1 Buffer Pool
先放个通关记录~
特别鸣谢:
某不愿透露姓名的友人hhj
During the semester, you will build a disk-oriented storage manager for the BusTub DBMS.
注:DBMS(Database Management System),比如说Oracle数据库
The first programming project is to implement a buffer pool in your storage manager.The buffer pool is responsible for moving physical pages back and forth from main memory to disk.也就是负责物理页从磁盘的读写
It allows a DBMS to support databases that are larger than the amount of memory available to the system. 是的,这其实就跟内存换入换出差不多。我们现在是不是要在用户态实现这个功能?我记得xv6似乎是没有这个机制的。有点小期待呀。不过这部分感觉说不定和xv6的磁盘管理(使用双向链表管理buffer cache),及其的改进版本(lab:locking 使用哈希+双向链表)比较类似。
注:xv6确实没有内存换入换出机制,其是固定大小的内存空间。但xv6的文件系统有采用LRU算法的buffer cache(怪不得有什么数据库型的文件系统,这两个确实有点像)。
The buffer pool’s operations are transparent to other parts in the system. For example, the system asks the buffer pool for a page using its unique identifier (
page_id_t
) and it does not know whether that page is already in memory or whether the system has to retrieve it from disk.Your implementation will need to be thread-safe.
总结
由于这几天时间比较零散+事情比较多,因此完成的总时间数不一定值得参考:26h(乐)
本次实验要说简单也还算简单。大概就是实现一个database与磁盘交换页的buffer pool,机制类似于内存换入换出。而实现这个buffer pool,首先得实现换入换出算法,也即task1的LRU-K算法。再然后就是在我们的LRU-K算法的基础上,实现真正的buffer pool(真正指:真正地存储以及读写磁盘、向外暴露接口),也即BufferPoolManager
。最后,我们会实现类似于lock_guard
这样结构的PageGuard
,用于自动释放页引用和读写锁。最后的最后,我们会对实现的buffer pool进行性能优化,优化方向包括细粒度化锁以实现并行IO、针对特定应用场景调整LRU-K策略等。
这四者都是相互联系相互递进的,我认为每一个task都设计得非常不错,写完了之后对它所涉及的知识点都有了更深刻的理解。我认为其中最优美的一点就是LRU-K算法与buffer pool的解耦,这个设计让我十分地赞叹。
最后,再对我的完成情况进行一个评价。本次实验确实内容不是很难【除了性能调优部分,这个我是真不懂QAQ】,毕竟它指导书以及代码注释都给了详细的步骤参考,我之所以做了那么久一是因为我有不好的习惯,就是没认真看指导书和提示就开始按着自己的理解写,然后写完就直接开始debug开始交了;二是因为这几天学业的破事太多、竞赛也逐步开始了,因而战线拉得太长,总耗时就太多了。
因而,吸取经验,我之后coding完了之后,再照着指导书仔仔细细地过一遍自己的代码。同时,15445这个实验我也决定先暂时搁置,毕竟接下来这两个月应该会在竞赛和学业两头转,实在不能抽出很大段时间继续写了。
就酱。
Task1 LRU-K
This component is responsible for tracking page usage in the buffer pool.
The LRU-K algorithm evicts a frame whose backward k-distance is maximum of all frames in the replacer. LRU-K 算法驱逐一个帧,其backward k-distance是替换器中所有帧的最大值。
Backward k-distance is computed as the difference in time between current timestamp and the timestamp of kth previous access.
backward k-distance
=现在的时间戳 - 之前第k次访问时的时间戳A frame with fewer than k historical accesses is given +inf as its backward k-distance. 不足k次访问的帧的backward k-distance应该设置为inf(对应上图左边那个访问记录队列吧)
**When multiple frames have +inf backward k-distance, the replacer evicts the frame with the earliest overall timestamp (i.e., the frame whose least-recent recorded access is the overall least recent access, overall, out of all frames).**如果有多个inf的结点,按照LRU规则淘汰(也即上图左边那个历史记录队列采取LRU规则)
The maximum size for the
LRUKReplacer
is the same as the size of the buffer pool since it contains placeholders for all of the frames in theBufferPoolManager
. However, at any given moment, not all the frames in the replacer are considered to be evictable. The size ofLRUKReplacer
is represented by the number of evictable frames. TheLRUKReplacer
is initialized to have no frames in it. Then, only when a frame is marked as evictable, replacer’s size will increase. size为可驱逐的frame数而非所有frame数。
正确思路
本次实验要我们实现的是一个LRU-K算法的页面置换器。
LRU-K算法是对LRU算法和LFU算法的折中优化,平衡了LFU和LRU的性能和开销的同时,也解决了缓存污染问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。具体来说,它维护了一个backward k-distance
,其计算方法:
- 如果已经被访问过k次:
backward k-distance
=current_timestamp_
- 倒数第k次访问的时间戳 - 如果还没被访问过k次:
backward k-distance
= +inf
页面驱逐规则:
驱逐
backward k-distance
最大的页。也即情况2总是优先会比情况1被驱逐;每次优先驱逐previous k次访问最早的页面。
当有多个页值为+inf,则采取FIFO规则进行驱逐。
故而,在具体实现中,为了便于管理,我将此拆分为两个队列:
数据第一次被访问,加入到访问历史列表;
如果数据在访问历史列表里后没有达到K次访问,则按照一定规则(FIFO,LRU)淘汰;
当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列删除,将数据移到缓存队列中,并缓存此数据,缓存队列重新按照时间排序;
缓存数据队列中被再次访问后,重新排序;
需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第K次访问离现在最久”的数据。
每个页面结构持有一个时间戳队列即可:
感想
刚coding完
task1的内容就是实现对一堆frame_id
的LRU-K算法管理,挺简单的(也可能是测试用例少我错误没排查出来2333)
我并没有用默认给的模板的unorder_map
,也没有用默认给的模板思路(但原理以及最终效果是差不多的,就是没用它的方法),而是选择类似像上面这张图一样,分成两个队列实现,一个队列visit_record_
存储那些访问次数<k的数据,另一个队列cache_data_
存储那些访问次数>=k的顺序,每次优先淘汰visit_record_
中的数据,两个队列都采用LRU的方式管理。与此同时,我觉得LRU管理时间戳只用记录最新访问的就行,所以将历史访问时间戳队列改成了只有一个变量。
终于通过online-test
参考:
FIFO和LRU这里面的实例非常直观地说明了两种算法的差异,可以跟着手推感受一下
pro1这个用的是我上面的那个想法,是错的。但是评论很值得参考:
pro1这个评论的“偷测试用例”xswl,虽然这次没用,但以后说不定能用上:
正确思路
……简单个屁!!
算法上,上面错误的算法确实很简单;而正确的算法也确实很简单。那么难的是什么呢?我觉得难的还是搞清楚它要我们实现的究竟是上面东西。
结合指导书这段话:
The LRU-K algorithm evicts a frame whose backward k-distance is maximum of all frames in the replacer. 每次驱逐
backward k-distance
最大的那么
backward k-distance
是什么?Backward k-distance is computed as the difference in time between current timestamp and the timestamp of kth previous access.
backward k-distance
=current_timestamp_
- 倒数第k次访问的时间戳A frame with fewer than k historical accesses is given +inf as its backward k-distance. 没有达到k次访问的,
backward k-distance
为+inf。也就是说,每次优先从历史访问队列清除元素。【**When multiple frames have +inf backward k-distance, the replacer evicts the frame with the earliest overall timestamp (i.e., the frame whose least-recent recorded access is the overall least recent access, overall, out of all frames).**】当历史访问队列有多个元素,就驱逐英文描述那样的frame。
我们可以发现,它这个对于两个队列的LRU,并非我们原来算法那样,对于每个frame,针对其最新的访问时间戳,也即history_.back()
,进行LRU淘汰;而是,针对其倒数第k新的访问记录,也即history_.front() && history_.size()<=k
,进行LRU淘汰。
其中,由于历史访问队列的记录少于k个,因而其事实上从k-distance算法退化为了FIFO算法。【感受一下这一点的优美:FIFO实际上是k-distance的特例】
我们上面的算法比较的是history_.back()
,所以可以省略时间戳队列为一个变量,然后将两个队列使用FILO的形式组织起来。正确算法就不能这么简单了,要按front排序的话,实现开销可能更大,所以下面就采用了map形式来实现logn的查找。
关于LRU的翻译
这里一个点我其实还是很疑惑的,完全想不通。
就是,对缓存队列实现k-distance算法没毛病,这段话已经写得很清楚了。
Backward k-distance is computed as the difference in time between current timestamp and the timestamp of kth previous access.
backward k-distance
=current_timestamp_
- 倒数第k次访问的时间戳
但是,为什么历史访问队列要用FIFO呢?是我英语不好吗,这段话不是实现纯正LRU的意思吗:
【**When multiple frames have +inf backward k-distance, the replacer evicts the frame with the earliest overall timestamp (i.e., the frame whose least-recent recorded access is the overall least recent access, overall, out of all frames).**】当历史访问队列有多个元素,就驱逐英文描述那样的frame。
我翻译一下:
当多个frame有+inf这个 backward k-distance
的时候,replacer需要驱逐拥有全部(overall)frame中最早的timestamp的frame。(也就是说,frame,它的最近访问记录是所有frame里面最早的)
这样确实看起来就是要用LRU。
但其实,是我英语不好。咨询了场外热心人士hhj之后,我才修订出了如下版本:
当多个frame有+inf这个 backward k-distance
的时候,replacer需要驱逐拥有全部(overall)frame中最早的timestamp的frame。(也就是说选择一个frame,这个frame的最不近的访问记录,是所有frame中最近最少访问的)【也即这个frame的history的front是所有frame中最早的,也即使用FIFO算法】
可见,正确解法确实是没问题的,就是理解上很困难。要是可以配个实例就好了QAQ
所以说,所谓LRU(Least Recently Used)的直译还是最不近使用,也即最近最少使用。里面这个least不是用来修饰recent表示recent程度深的,相反它表示的是recent的程度浅。英语不好的惨痛教训啊。
最后一下子交了这么多次才过。绷不住了。
Task2 BufferPoolManager
The
BufferPoolManager
is responsible for fetching database pages from theDiskManager
and storing them in memory.从DiskManager中取出页,然后存入内存。也就是说,我们的Buffer Pool是磁盘到内存的映射,我们在Task1实现了内存部分的管理数据结构?
The
BufferPoolManager
can also write dirty pages out to disk when it is either explicitly instructed to do so or when it needs to evict a page to make space for a new page.也要负责dirty页的写回You will also not need to implement the code that actually reads and writes data to disk (this is called the
DiskManager
in our implementation). We will provide that functionality.DiskManager
已给出All in-memory pages in the system are represented by
Page
objects. EachPage
object contains a block of memory that theDiskManager
will use as a location to copy the contents of a physical page that it reads from disk.Page
是可复用的内存页容器The
Page
object’s identifer (page_id
) keeps track of what physical page it contains; if aPage
object does not contain a physical page, then itspage_id
must be set toINVALID_PAGE_ID
.也就是说,
page_id
表示的是实际的物理页号;frame_id
表示的是你的Page容器的序号,同时也是LRU的对象。你需要一个类似<fid, pid>
这样的map来记录这二者的映射。具体是通过:
1
2
3
4 /** Array of buffer pool pages. */
Page *pages_; // <fid, pid>
/** Page table for keeping track of buffer pool pages. */
std::unordered_map<page_id_t, frame_id_t> page_table_; // <pid, fid>Each
Page
object also maintains a counter for the number of threads that have “pinned” that page. YourBufferPoolManager
is not allowed to free aPage
that is pinned. 有引用计数机制Each
Page
object also keeps track of whether it is dirty or not. It is your job to record whether a page was modified before it is unpinned. YourBufferPoolManager
must write the contents of a dirtyPage
back to disk before that object can be reused.需要track dirty,并且这是你要干的;要写回,这也是你要干的Your
BufferPoolManager
implementation will use theLRUKReplacer
class that you created in the previous steps of this assignment. TheLRUKReplacer
will keep track of whenPage
objects are accessed so that it can decide which one to evict when it must free a frame to make room for copying a new physical page from disk. When mappingpage_id
toframe_id
in theBufferPoolManager
, again be warned that STL containers are not thread-safe.
感想
刚coding完
task2说得比较复杂,实现的函数较多,实际coding细节也比较繁琐,但debug倒是很轻松。
主要内容就是实现BufferPoolManager
,在task1实现的LRU-K算法的基础上,写具体的内存换入换出的接口逻辑。
再次回顾我们整个project1的目的:实现一个从磁盘到内存的buffer。task1只是实现了一个内存页换入换出的LRU-K算法部分,task2则基于算法部分,实现了具体与上层交互的像样的逻辑。
我认为这其中一个亮点就是,它非常完美地将LRU-K算法和具体的上层逻辑进行了解耦。LRU-K只需关注如何将这一堆freme_id
组织起来组织好,而无需关心具体内存页存放在哪,以及对应frame淘汰之后内存页又何去何从,因为这些逻辑都会由上层实现;而上层逻辑也无需关心具体的淘汰页算法【LRU-K/LRU/LFU,只需替换replacer_就可以替换换入换出策略】,而只需打好evictable标记,并且在调用evict方法之后做好后处理(如内存释放等等等)即可。
这其中有一个小细节也值得借鉴,即从page_id_
到frame_id_
的转化。frame_id_
有界,比较方便LRU-K算法实现,并且进行了LRU-K算法的容量控制,同时由于算法和上层逻辑的容量相同,故而也是pages_
的索引号;而page_id_
不能有界,因为实际上访问到的物理页不可能只共享pool_size_
个序列号。故而在这样解耦实现的基础上,二者缺一不可。
还有frame_id_
的复用,它是采用了类似我们日常生活中取号那样,要用号时从队列头取,不用号时塞回队列尾就行,这种方式我觉得还挺有意思。
其他部分虽然步骤繁杂,但理解难度不高,而且它提示得也很保姆了,所以不多bb。
通过online-test
确实算简单了,我主要倒在没有认真看它的需求,这应该是语文问题(绷
一个是FetchPage
这里:
如果所求物理页存在于buffer pool,直接返回+record access即可,不用再写回+读入。因为它的提示这边:
这个是句号。也就是说后面那些写回啊read啊,是没找到时才做的,不是并列关系。
这也很合理,毕竟你找到所需页就说明不用从磁盘读入,也即找到所需页=直接返回即可。
另一个是UnpinPage
这里:
不应该写is_dirty_ = is_dirty
,因为它的提示这边:
可见参数is_dirty
为true是需要设置为dirty,为false的话没有别的意义,保持原来值就行。
还有一个就是,在Page
类中声明了friend:
故而BufferPoolManager
可以直接访问Page
的私有成员变量,而无需手动为Page
添加Getter/Setter方法。
Task3 Page Guard
这是要写我们在上面用的那个PageGuard?这让我想起了Lab0的ValueGuard
。
1 | template <class T> |
不过其实这两个是不一样的。本次要实现的Page Guard的语义更类似lock_guard
。
我们需要手动调用
UnpinPage
,但这中就跟new/delete、malloc/free一样都要靠人脑来记住,不大安全。You will implement
BasicPageGuard
which store the pointers toBufferPoolManager
andPage
objects. A page guard ensures thatUnpinPage
is called on the correspondingPage
object as soon as it goes out of scope. 【也许这需要在析构函数中实现?】Note that it should still expose a method for a programmer to manually unpin the page.仍然需要提供UnPin方法。As
BasicPageGuard
hides the underlyingPage
pointer, it can also provide read-only/write data APIs that provide compile-time checks to ensure that theis_dirty
flag is set correctly for each use case.这个思想很值得学习。In the future projects, multiple threads will be reading and writing from the same pages, thus reader-writer latches are required to ensure the correctness of the data. Note that in the
Page
class, there are relevant latching methods for this purpose. Similar to unpinning of a page, a programmer can forget to unlatch a page after use. To mitigate the problem, you will implementReadPageGuard
andWritePageGuard
which automatically unlatch the pages as soon as they go out of scope.
感想
怎么说,其实只用仔细看相关文档和它的要求就不难,但你懂的我的尿性就是不细看文档,所以这里我也用gdb调了蛮久才过的。正确思路没什么好说的,直接记录下我觉得比较有意义的错误吧。
错误集锦
析构函数的调用
在这个用例中,退出“}”会调用两次析构函数。
奇怪的死锁
debug过程
我在coding的过程中,遇到了一个很神奇的死锁现象。
在这里page->WLatch();
这句会死锁,而且还是在第一次调用FetchWritePage()
时死锁的:
1 | WritePageGuard(BufferPoolManager *bpm, Page *page) : guard_(bpm, page) { |
但是添加了一句page->WUnlatch();
:
1 | WritePageGuard(BufferPoolManager *bpm, Page *page) : guard_(bpm, page) { |
它就不会死锁了。
这很奇怪,到底是发生了什么?我用GDB调了半天,在RWLatch.WLock()
处打了断点,也没发现在这之前有调用过lock()。于是我就去看了下std::shared_mutex
的官方文档(当然,这中间想了很久也不知道怎么办):
我就怀疑是不是我哪里写错了,所以就干了这种undefined的事,然后就导致死锁了。于是我写了个测试程序:
发现,当在调用WLock
(也即std::shared_mutex::lock()
)之前,如果多调了一次XUnlock
(也即std::shared_mutex::unlock()
或者std::shared_mutex::unlock_shared()
),就会卡住。
这说明确实发生了不匹配问题。于是我就在Page
中添加了两个成员变量用来记录上锁和解锁的次数,并且在gurad test中打印了出来,结果发现:
确实发生了不匹配问题,是在这里:
之后用gdb调下就发现错误了,不赘述了。
另外的想法
在出现死锁问题时,我是想着,会不会是测试程序中,对同一页获取了一次ReadGuardPage
对象之后,再对同一页获取Read/WriteGuardPage
导致的呢?于是我就开始思考如何防范这个流程,最后写下了这样的代码:
1 | auto BufferPoolManager::FetchPageRead(page_id_t page_id) -> ReadPageGuard { |
但很遗憾的是,我发现是无法区分当前进程持有write还是read锁的。也许有别的办法但我没想起来。
总之,我认为这段代码还是很有参考价值的,姑且放着先。
Task4 性能调优
参考:
CMU 15-445 2023 P1 优化攻略 [rank#3] 写得非常细致,思路很清晰
我的实现有一些并发小问题,详见lab2的并发部分~
lru-k的算法优化是自己想的,并行IO的优化思路全部来自 CMU 15-445 Project 1 (Spring 2023) 优化记录,我只是把这位大佬的思路自己实现了一遍。感觉还是太菜了,面对这种实际场景毫无还手之力一点思路没有QAQ但正是如此,这个细粒度化锁的小task才值得学习。
放上优化前后性能对比:
Better replacer algorithm
In the leaderboard test, we will have multiple threads accessing the pages on the disk. There are two types of threads running in the benchmark:在具体的benchtest中,可以分为两类线程。
- Scan threads. Each scan thread will update all pages on the disk sequentially. There will be 8 scan threads.
- Get threads. Each get thread will randomly select a page for access using the zipfian distribution. There will be 8 get threads.
Given that get workload is skewed(有偏向性的)(i.e., some pages are more frequently accessed than others), you can design your LRU-k replacer to take page access type into consideration, so as to reduce page miss.
解决方法
我们可以回想起当初选择LRU-K而不选择LRU算法的原因:缓存污染。
缓存污染:
LRU因为只需要一次访问就能成为最新鲜的数据,当出现很多偶发数据时,这些偶发的数据也会被当作最新鲜的,从而成为缓存。但其实这些偶发数据以后并不会是被经常访问的。
而在这里也是同理。我们的benchtest中,scan线程是顺序地访问磁盘上所有页,而get线程是遵从zip分布地访问,显然get线程的access记录比scan线程的有价值的多,并且scan线程的数据是很容易污染get线程的。
所以,我的解决方法是,如果某个页被第一次访问,且该访问方式为SCAN,则RecordAccess进入历史访问队列;如果某个页不是被第一次访问,且访问方式为SCAN,则不做任何处理。不用修改UnpinPage的处理方式。
Parallel I/O operations
Instead of holding a global lock when accessing the disk manager【不要在访问
disk_manager_
的时候使用bpm的全局锁latch_
】, you can issue multiple requests to the disk manager at the same time. This optimization will be very useful in modern storage devices, where concurrent access to the disk can make better use of the disk bandwidth.
解决方法
详细的解决方法大佬这边已经说得很清楚了,接下来我就对其总体的做法进行一点总结,加上一些个人理解。
我刚看到这个需求的时候是这么做的:
1 | if (pages_[fid].IsDirty()) { |
也即在原来代码的基础上做简单的改动,每次执行到涉及磁盘读写的地方,就暂时地开一下锁。但其实这样是不行的,当多个线程访问bpm,线程A在这里开锁执行Write,线程B正好得到锁,然后对pages_[fid]
执行比如说ResetMemory操作,这样就寄了。
所以,在磁盘读写的时候,我们仍然需要使用锁保护,只不过我们需要选择粒度更细的锁。这时我们就可以想到在page_guard
里常用的page自带的锁。在这里用page锁,既能够锁保护,又符合语义,看起来非常完美:
1 | pages_[fid].WLatch(); |
但由于我们在returnpage_guard
的时候会获取锁,因而在这样的情况下,会发生死锁:
1 | auto reader_guard_1 = bpm->FetchPageRead(page_id_temp); |
在这里我们首先获取
reader_guard_1
,持有了该 page 的读锁,并允许其他线程读;但在获取reader_guard_2
时,FetchPage
会在释放 bpm 写锁前,请求该 page 的写锁;但由于reader_guard_1
已经申请了该 page 的读锁,就会造成死锁,与预期结果不符。
因而,我们就可以选择在bpm内部,单独为pages_数组的每一页都维护一个锁,在每个对page页属性进行读写的地方进行锁定:
1 | std::shared_mutex latch_; |
然后对代码进行重排序,尽量分离bpm内部成员和page内部成员属性的修改:(以FetchPage
为例)
1 | auto BufferPoolManager::FetchPage(page_id_t page_id, [[maybe_unused]] AccessType access_type) -> Page * { |
其他地方也是一样。就不多赘述了。
一个小地方
当外界需要对页进行读写时,需要使用page自带的锁;而当bpm内部需要对页进行读写时,则使用的是bpm内部自带的页锁。
这句话说完,相信危险性已经显而易见了:我们使用了两把不同的锁维护了同一个变量!而且可能会有两个线程分别持有这两个锁,对这个变量并发更新!
但其实,在当前这个场景,这么做是没问题的。
外界实质上只能对page的data字段进行读写。因而,有上述危险的,实质上就只有bpm中会对data字段进行改变的地方,也即bpm::NewPage()
、bpm::FetchPage()
、bpm::DeletePage()
这三个地方。
而在前两个地方,我们会使用到的page都是闲置/已经被释放的页,因而外界不可能,也即不可能有别的线程,会持有page的锁并且对其修改;同样的,在第三个地方,我们会使用的page也是pincount==0的页,仅有当前线程在对其进行读写。
因而,综上所述,这样做是并发安全的。