Project #1 - Buffer Pool Manager

不要将你的项目发布到公开的 Github 仓库。

概述

本学期,你将构建一个面向磁盘的数据库管理系统(DBMS),名为 BusTub。面向磁盘的架构意味着 DBMS 的主要存储位置在持久化存储中,如硬盘(HDD)或闪存(SSD)。这与内存型 DBMS 不同,后者将数据存储在易失性内存中。

第一个编程项目是实现 DBMS 的缓冲池管理器。缓冲池负责在内存缓冲区和持久化存储之间来回移动物理数据页。它还充当缓存,将频繁使用的页保留在内存中以加快访问速度,并淘汰未使用或冷数据页回存储。

BusTub 中的一个是 8192 字节(8 KB)的数据,这意味着缓冲池以 8 KB 为单位管理数据。由于 BusTub 中的页具有固定大小,缓冲池管理器将这些页存储到固定大小的缓冲区中,称为。页和帧之间的区别有些微妙。是 8 KB 的逻辑(虚拟)数据,可以存储在内存中、磁盘上,或同时存在于两者中。而是一个固定长度的 8 KB 内存块(即指向该内存的指针),用于存储单个页的数据。这里的类比是将(逻辑)页存储在(物理)固定帧内部

除了充当缓存之外,缓冲池管理器还允许 DBMS 支持大于系统可用内存量的数据库。假设一台计算机有 1 GB 的内存(RAM)。如果我们要管理一个 2 GB 的数据库,缓冲池管理器使我们能够与这个数据库交互,而无需将其全部内容放入内存。

缓冲池执行的 I/O 操作被抽象化,与 DBMS 的其他部分隔离。例如,当 DBMS 的某个组件(如执行引擎)使用唯一标识符(page_id_t)向缓冲池管理器请求一个数据页时,该组件不需要知道该页是否已在内存中,或者系统是否需要从磁盘检索它。同样,缓冲池管理器不需要理解这些页的内容,它只需要知道数据存储在哪里。

实现

你的缓冲池实现必须是线程安全的。多个线程将并发访问你的缓冲池的内部数据结构,你必须确保临界区受到锁存器(latches)的保护(在操作系统中称为"锁")。

你必须实现以下存储管理器组件:

这是一个个人项目,必须独立完成(即不允许组队)。

项目规范

记得从 BusTub 仓库拉取最新代码。

对于以下每个组件,我们提供了包含你必须实现的 API 的存根类。你不应修改这些类中预定义函数的签名。如果你修改了签名,我们的评分测试代码将无法工作,你将无法获得该项目的学分。

如果一个类已经包含数据成员,你不应删除它们。例如,BufferPoolManager 类包含 DiskSchedulerArcReplacer 成员,这些是系统其余部分所需功能所必需的。你可以向这些类添加数据成员和辅助函数以正确实现所需的功能。

除非另有说明,你可以在项目中使用任何内置的 C++17 容器。由你来决定使用哪些容器。请注意,这些容器不是线程安全的,你需要使用锁存器来保护对它们的访问。不能使用额外的第三方库(例如 Boost)。

任务 #1 - 自适应替换缓存(ARC)替换策略

该组件负责跟踪缓冲池中的页使用情况,以确定要淘汰出内存并写回磁盘的候选页/帧。

你将在 src/include/buffer/arc_replacer.h 中实现一个名为 ArcReplacer 的类,并在 src/buffer/arc_replacer.cpp 中实现其对应的实现文件。注意 ArcReplacer 是一个独立的类,与任何其他 Replacer无关。你只需要实现 ARC 替换策略,不需要实现 LRU-K、LRU 或 Clock 替换策略(即使有对应的文件)。

ARC 替换策略最初由 IBM 开发,是一种自适应替换策略,会根据观察到的工作负载进行调整。它包含两个跟踪缓存页的列表、两个跟踪最近被淘汰页的列表,以及一个自适应于工作负载的目标大小。由于这种自适应性,ARC 替换策略通常优于 LRU。有关更多详细信息,请参阅原始论文

你需要为本项目实现 ARC 替换策略的一个变体

你需要为 ARC 实现以下方法,如头文件(src/include/buffer/arc_replacer.h )和源文件(src/buffer/arc_replacer.cpp )中所定义:

ARC 替换算法

ARC 算法包含以下部分。我们从两个列表开始:MRU(最近最少使用)列表跟踪最近恰好被访问一次的帧及其对应的页,而 MFU(最常使用)列表跟踪最近被访问超过一次的帧及其对应的页。我们还有两个幽灵列表:MRU 幽灵列表和 MFU 幽灵列表。这些列表跟踪不再在缓冲池中但最近被淘汰的页。最后,我们还有一个 MRU 列表的目标大小,它会适应工作负载的变化,初始值为 0。请注意,MRU 列表的实际大小可能与目标不同,可能更小或更大,这只是我们的目标大小。

在使用 ARC 替换器时,通常涉及五个关于大小的概念,需要加以区分:

此外,请确保你理解此处帧和页之间的关系,这样你可能会理解为什么需要同时跟踪页 ID 和帧 ID:

当对一个帧及其对应的页执行 RecordAccess 时,有四种情况,恰好会发生其中一种:

  1. 页已存在于 MRU/MFU 中:这是实际缓存命中的情况。将页移动到 MFU 的前端。
  2. 页已存在于 MRU 幽灵列表中:这是实际缓存未命中但我们在幽灵列表中命中的情况。在这种情况下,我们将其视为伪命中并调整目标大小。如果 MRU 幽灵列表的大小大于或等于 MFU 幽灵列表的大小,将 MRU 目标大小增加 1。否则增加 MFU 幽灵大小 / MRU 幽灵大小(向下取整)。不要将目标大小增加到超过 replacer_size。然后将页移动到 MFU 的前端。这样做的理由是,如果 MRU 列表稍大一些,那么 DBMS 可能已经缓存命中。
  3. 页已存在于 MFU 幽灵列表中:与前一种情况类似,这是实际缓存未命中但我们在幽灵列表中命中的情况。如果 MFU 幽灵列表的大小大于或等于 MRU 幽灵列表的大小,将 MRU 目标大小减少 1。否则将 MRU 目标大小减少 MRU 幽灵大小 / MFU 幽灵大小(向下取整)。不要将目标大小减少到低于 0。然后将页移动到 MFU 的前端。这样做的理由是,如果 MFU 列表稍大一些,DBMS 可能已经缓存命中。
  4. 页不在替换器中:这是实际缓存未命中且幽灵列表也未命中的情况。此时应发生以下情况之一。
    1. 如果 MRU 大小 + MRU 幽灵大小 = 替换器大小:移除 MRU 幽灵列表中的最后一个元素,然后将页添加到 MRU 的前端。
    2. 否则 MRU 大小 + MRU 幽灵大小应小于替换器大小(如果你正确操作,它不应更大)。在这种情况下
      • 如果 MRU 大小 + MRU 幽灵大小 + MFU 大小 + MFU 幽灵大小 = 2 * 替换器大小:移除 MFU 幽灵列表中的最后一个元素,然后将页添加到 MRU 的前端。
      • 否则直接将页添加到 MRU 的前端。

试着思考为什么在情况 4(a) 和 4(b) 中,幽灵列表中必定有元素。

实现

当你实现此算法时,重要的是理解页何时应进入 MRU,何时应进入 MFU。思考每种情况下采取给定操作的原因以及它的目的也会有所帮助,而不是将英文直译为 C++ 代码。如果 MRU 列表大小小于目标大小,我们尝试从 MFU 列表中淘汰。如果 MRU 列表大小大于或等于目标大小,我们尝试从 MRU 列表中淘汰。在任何一种情况下,如果从预期的一侧无法淘汰(该列表中没有可淘汰的内容),尝试从另一个列表淘汰。如果仍然没有可淘汰的内容,淘汰失败并返回 std::nullopt

实现细节由你决定。你可以使用内置的 STL 容器。你可以假设不会为这些数据结构的内存不足(但你不能对任务 #3 中的缓冲池做同样的假设,你遇到可用帧不足的情况)。你必须确保你的实现是线程安全的。

你可能会注意到有一个测试用于测试你的 RecordAccess 实现的性能。如果你的实现在测试中失败/超时,试着思考是什么导致 RecordAccess 变慢以及如何修复它。提醒一下,你可以修改头文件中提供的数据结构和成员变量,也可以添加额外的数据结构来加速操作。

如果你想了解更多关于 ARC 替换算法的信息,请参阅这篇论文。本项目不要求你完全实现原始算法。我们也欢迎你思考我们要求你做的超出原始算法所能实现的内容。

任务 #2 - 磁盘调度器

该组件负责调度 DiskManager 上的读写操作。你将在 src/include/storage/disk/disk_scheduler.h 中实现一个名为 DiskScheduler 的类,并在 src/storage/disk/disk_scheduler.cpp 中实现其对应的实现文件。

磁盘调度器可以被其他组件(在本例中是任务 #3 中的 BufferPoolManager)用来排队磁盘请求,这些请求由 DiskRequest 结构体表示(已在 src/include/storage/disk/disk_scheduler.h 中定义)。磁盘调度器将维护一个后台工作线程,负责处理已调度的请求。

磁盘调度器将使用共享队列(通道)来调度和处理 DiskRequest。一个线程将请求添加到队列中,磁盘调度器的后台工作线程将处理排队的请求。我们在 src/include/common/channel.h 中提供了一个 Channel 类,用于促进线程之间数据的安全共享,但如果你觉得有必要,可以使用自己的实现。

DiskScheduler 的构造函数和析构函数已经实现,分别负责创建和加入后台工作线程。你只需要实现以下方法,如头文件(src/include/storage/disk/disk_scheduler.h )和源文件(src/storage/disk/disk_scheduler.cpp )中所定义:

我们提到 DiskRequest 的一个字段是 std::promise。如果你不熟悉 C++ 的 promise 和 future,可以在这里查看文档。就本项目而言,它们本质上提供了一种回调机制,让线程知道其调度的请求何时完成。要查看它们的使用示例,请查看 disk_scheduler_test.cpp

同样,实现细节由你决定。你必须确保你的实现是线程安全的。

磁盘管理器

包含 DiskManager 类的头文件位于(src/include/storage/disk/disk_manager.h )。它从磁盘读取页数据并向磁盘写入数据。你的磁盘调度器在处理读或写请求时将使用 DiskManager::ReadPage()DiskManager::WritePage()

任务 #3 - 缓冲池管理器

最后,你必须实现缓冲池管理器(BufferPoolManager)!回顾本页开头,BufferPoolManager 负责使用 DiskScheduler 从磁盘获取数据库页并将它们存储在内存中。BufferPoolManager 还可以在明确指示或需要淘汰页以为新页腾出空间时,调度将脏页写回磁盘。

你的 BufferPoolManager 实现将使用你在本作业前几步中创建的 ArcReplacerDiskScheduler 类。ArcReplacer 将跟踪页的访问时间,以便在需要为新页腾出空间时决定淘汰哪个帧。DiskScheduler 将在 DiskManager 上调度对磁盘的读写操作。

我们提供了一个名为 FrameHeader 的辅助类,用于管理内存中的帧。所有对页数据的访问都应通过 FrameHeaderFrameHeader 有一个名为 GetData 的方法,返回指向其帧内存的原始指针,DiskScheduler / DiskManager 将使用此指针将磁盘上物理页的内容复制到内存中。

提醒一下,缓冲池管理器不需要理解这些页的内容。BufferPoolManager 关于页所知道的信息只有页 ID(page_id_t)和它们存储在内的 FrameHeader。此外,BufferPoolManager 将重用相同的 FrameHeader 对象来存储数据,因为数据在磁盘和内存之间来回移动。换句话说,所有 FrameHeader 在系统的整个生命周期中将存储许多不同的页。

并发

在实现多线程缓冲池管理器时,我们必须注意同步数据访问。这意味着我们不希望在缓冲池的不同帧中存在同一页的多个副本。如果我们允许这样做,就会遇到以下场景:

因此,我们在内存中只保留一个页的 1 个版本,以防止数据同步竞争。此外,为了防止在线程访问页时淘汰它,我们维护存储该页的帧的引用计数/固定计数。最后,为了跟踪哪些页存储在哪些帧中,我们还使用哈希表维护一个页表,将页 ID 映射到帧。

帧的固定计数是可以访问该页数据的线程数。只要帧的固定计数大于 0(意味着至少有 1 个线程正在访问该页的数据),缓冲池管理器就不允许淘汰正在存储的页。你可以使用 FrameHeader 类中的原子字段 pin_count_ 来维护固定计数。请注意,pin_count_ArcReplacer::SetEvictable 是分开的,因此你需要确保它们正确同步。你还需要在认为必要时更新 FrameHeaderis_dirty_ 标志。如果你在淘汰页时此标志被设置,你将需要采取相应措施来维护内存和磁盘之间的数据同步。

最后但同样重要的是,你必须实现 ReadPageGuardWritePageGuard。这些类是提供对底层页的线程安全读/写访问的 RAII 对象。更多信息请参见下面的实现部分。你可能需要与 BufferPoolManagerCheckedReadPageCheckedWritePage 方法协同实现。但是,如果你想确保页守卫实现正确,可以选择先实现 BufferPoolManager::GetPinCount,然后拼凑出能通过页守卫测试的代码。

实现

你需要实现以下页守卫方法,如头文件(src/include/storage/page/page_guard.h )和源文件(src/storage/page/page_guard.cpp )中所定义:

不需要BufferPoolManager 方法之前实现这些方法。你应该同时进行开发。

这些方法实现了页守卫的移动语义和 RAII。如果你不熟悉这些概念,请通过在线学习资料进行了解。有很多优秀的资源(包括文章、微软教程、YouTube 视频)深入解释了这些内容。在没有扎实理解 RAII 和移动语义工作原理的情况下,你不应该尝试实现这些方法。

这里可能会有很多代码重复(即两个守卫除了少数几行外应该完全相同)。如果你想基于自己创建的类来派生这些类,欢迎这样做。只需确保不改变任何接口和方法签名!

你还需要实现以下 BufferPoolManager 方法,如头文件(src/include/buffer/buffer_pool_manager.h )和源文件(src/buffer/buffer_pool_manager.cpp )中所定义:

所有这些方法在源文件中都有详细的文档注释。确保完整阅读所有注释!它们包含许多有用的提示。

你不需要让缓冲池管理器非常高效。对于所有公共的 BufferPoolManager 方法,从头到尾持有缓冲池锁存器应该就够了(除了需要提前释放以防止死锁的情况)。但是,你确实需要确保缓冲池管理器具有合理的性能,否则在后续项目中会出现问题。你可以将自己的基准测试结果(QPS.1 和 QPS.2)与其他同学进行比较,看看你的实现是否太慢。

请参阅源文件(src/storage/page/page_guard.cpp src/buffer/buffer_pool_manager.cpp )以获取更为详细的规范和文档。

排行榜任务(可选)

在本项目的排行榜挑战中,我们将使用特殊的存储后端对你的缓冲池管理器进行基准测试。

针对排行榜进行优化是可选的(即完成所有之前的任务后,你可以获得满分)。 但是,你的解决方案必须在排行榜测试中以正确的结果完成,且不能出现死锁和段错误。

排行榜测试使用 release 配置文件编译:

mkdir cmake-build-relwithdebinfo
cd cmake-build-relwithdebinfo
cmake .. -DCMAKE_BUILD_TYPE=RelWithDebInfo
make -j `nproc` bpm-bench
# The command below is just for illustrating how the bpm-bench test works.
# We are NOT running tests with the same parameter for the leaderboard test.
./bin/bustub-bpm-bench --duration 5000 --latency 1

我们强烈建议你在针对排行榜测试进行优化之前先备份代码,这样如果这些优化在后续项目中出现问题,你总是可以回退。

在排行榜测试中,我们将有多个线程访问磁盘上的页。基准测试中有两种类型的线程运行:

  1. 扫描线程。每个扫描线程将顺序读取磁盘上的页。将有 8 个扫描线程。
  2. 获取线程。每个获取线程将使用 zipfian 分布随机选择并更新一个页。 将有 8 个获取线程。

我们将在内存存储后端上运行基准测试三次,每次 30 秒。第一次和第二次将直接使用不同的缓冲池和替换器设置运行。 第三次,我们将为每次随机读/写操作添加 1 毫秒的延迟,为顺序/局部读/写操作添加 0.1 毫秒的延迟。更多信息请参见 DiskManagerUnlimitedMemory 类。

最终得分计算为扫描和获取操作的加权 QPS,分别包含和不包含延迟:

scan_qps_large / 1000 + get_qps_large / 1000 + scan_qps_small / 1000 + get_qps_small / 1000 + scan_qps_1ms + get_qps_1ms

推荐优化

  1. 更好的替换器算法。鉴于获取负载是倾斜的(即某些页比其他页更频繁地被访问), 你可以设计你的 ARC 替换器将页访问类型考虑在内,以减少页缺失。考虑如何排队 多个请求并预取数据。
  2. 并行 I/O 操作。不是在磁盘调度器中一次处理一个请求,你可以同时向磁盘管理器发出多个请求。 这个优化在现代存储设备中非常有用,因为对磁盘的并发访问可以更好地利用磁盘带宽。你应该处理同一页的多个操作在队列中的情况, 这些请求的最终结果应该如同按顺序处理一样。在单线程中,它们应该具有写后读一致性。
  3. 要在磁盘调度器中实现真正的并行,你还需要允许缓冲池管理器同时处理多个 ReadPageWritePage 请求以及同时淘汰多个页。你可能需要在缓冲池管理器中引入条件变量来管理空闲页。

排行榜政策

说明

请参阅 Project #0 说明了解如何创建你的私有仓库和搭建开发环境。

测试

你可以使用我们的测试框架来测试本作业的各个组件。我们使用 GTest 进行单元测试。有三个独立的文件包含每个组件的测试:

你可以从命令行单独编译和运行每个测试:

$ make arc_replacer_test -j `nproc`
$ ./test/arc_replacer_test

你也可以运行 make check-tests 来运行所有测试用例。请注意,某些测试被禁用了,因为你还没有实现后续项目。你可以在 GTest 中通过添加 DISABLED_ 前缀来禁用测试。

重要提示:这些测试只是我们用来评估和评分你项目的所有测试的一个子集。你应该自己编写额外的测试用例来检查实现的完整功能。

格式化

你的代码必须遵循 Google C++ 风格指南。我们使用 Clang 自动检查你的源代码质量。如果你的提交未通过这些检查,你的项目成绩将为

执行以下命令检查你的语法。format 目标将自动修正你的代码。check-lintcheck-clang-tidy-p1 目标将打印错误并指导你如何修复以符合我们的风格指南。

$ make format
$ make check-clang-tidy-p1

内存泄漏

在本项目中,我们使用 LLVM Address Sanitizer (ASAN) 和 Leak Sanitizer (LSAN) 来检查内存错误。要启用 ASAN 和 LSAN,请在调试模式下配置 CMake 并正常运行测试。如果存在内存错误,你将看到内存错误报告。请注意,macOS 仅支持地址检测器而不支持泄漏检测器

在某些情况下,地址检测器可能会影响调试器的可用性。在这种情况下,你可能需要通过配置 CMake 项目来禁用所有检测器:

$ cmake -DCMAKE_BUILD_TYPE=Debug -DBUSTUB_SANITIZER= ..

开发提示

你可以在调试模式下使用 BUSTUB_ASSERT 进行断言。请注意,BUSTUB_ASSERT 中的语句在发布模式下不会被执行。 如果你需要在所有情况下进行断言,请使用 BUSTUB_ENSURE 代替。

请在 Piazza 上发布你关于本项目的所有问题。不要直接通过电子邮件向助教提问。

如果你遇到问题,我们鼓励你使用图形化调试器来调试项目。

如果你遇到编译问题,运行 make clean 并不能完全重置编译过程。你需要删除构建目录并重新运行 cmake ..,然后再运行 make

评分标准

每个项目提交将根据以下标准进行评分:

  1. 提交是否成功执行所有测试用例并产生正确答案?
  2. 提交是否在没有内存泄漏的情况下执行?
  3. 提交是否遵循代码格式化和风格政策?

请注意,我们将使用额外的测试用例来评分你的提交,这些测试用例比我们提供的示例测试用例更复杂。

迟交政策

请参阅课程大纲中的迟交政策

提交

完成作业后,你可以将实现提交到 Gradescope:

build/ 目录中运行 make submit-p1 将在项目根目录下生成一个名为 project1-submission.zipzip 压缩包,你可以将其提交到 Gradescope。

你可以尽可能多次提交答案并获得即时反馈。

Gradescope 和自动评分器说明

  1. 如果你在 Gradescope 上超时,很可能是因为你的代码中存在死锁或代码太慢无法在 60 秒内运行。如果你的代码太慢,可能是因为你的 ArcReplacer 不够高效。
  2. 如果你在提交中打印太多日志,自动评分器将无法工作。
  3. 如果自动评分器无法正常工作,请确保你的格式化命令有效,并且你提交的是正确的文件。
  4. 排行榜基准测试分数将通过压力测试你的 buffer_pool_manager 实现来计算。

CMU 学生应使用 Piazza 上公布的 Gradescope 课程代码。

协作政策

警告:本项目的所有代码必须是你自己的。你不能从其他学生或网上找到的其他来源复制源代码。抄袭不会被容忍。有关更多信息,请参阅 CMU 的学术诚信政策