使用 PMDK 实施面向持久内存的容错算法 - MapReduce 示例

签署人: Eduardo Berrocal Garcia De Carellan IDZSupport KS

已发布:04/10/2018   最后更新时间:10/24/2017

获取示例代码

概述

在本文中,我将使用 libpmemobj 的 C++ 绑定(持久内存开发工具包 (PMDK)的核心库),展示面向持久内存 (PMEM) 的知名 MapReduce (MR) 算法的示例实施。本示例旨在展示 PMDK 如何促进持久内存感知型 MR 的实施,它强调使用多个线程和 PMEM 感知同步,通过事务和并发实现数据一致性。另外,我还将展示 PMEM 的自然容错能力,方法是在中途停止程序并从停止的位置重新启动,而不需要任何检查点/重启机制。最后,我将通过改变 map 和 reduce 工作线程的线程数量,展示灵敏度性能分析。

MapReduce 是什么?

MR 是Google* 在 2004 年推出的一种编程模型,它使用函数式编程概念,设计灵感来自于 Lisp* 等语言的mapreduce 基元,可让程序员更轻松地在由数千台机器组成的群集上运行大规模并行计算。

由于所有函数的数据都是相互独立的,即所有输入数据都是按值传递的,这种编程模型已经成为一种处理数据一致性和同步问题的有效解决方案。并行化可以通过并行运行多个函数实例来自然实现。MR 模型可被描述为函数式编程模型的子集,在这个模型中,所有计算仅使用两个函数进行编码:Mapreduce

Map Reduce 流程图
图 1MapReduce 概述。此图是上面提到的 Google 文章的图 1 修订版。

关于 MR 工作原理的高度概述可以在图 1 中找到。输入由一组文件组成,这些文件被分为预定义大小的数据块(通常在 16-64 MB 之间)。每个数据块会被馈送到一个 map 任务,后者将创建被分组、排序并馈送到 reduce 任务的键值对。Reduce 任务可以直接将其结果写入至输出文件,或将它们传递给其他 reduce 任务以便进一步归约。

MR 计算的典型例子是字数统计。输入块分成几行。每行会被馈送到一个 map 任务,后者将为找到的每个词输出一个新的键值对,如下所示:{key : 'word', value : '1'}.Reduce 任务添加同一个键的所有值,并创建一个包含更新值的新键值对。如果我们最后只有一个 reduce 任务,那么输出文件的每个词将只包含一个键值条目,其值为总数。

使用 PMEM 的 MapReduce

MR 模型实现 FT 的方式是将其中间结果存储在驻留于本地文件系统的文件中。这一文件系统通常位于与节点(任务生成数据运行)相连的传统机械硬盘 (HDD) 或固态盘 (SSD) 的顶部。

这种方法的第一个问题在于,这些硬盘和易失性 RAM (VRAM) 内存之间的带宽存在多个数量级的差异。PMEM 技术能够以非常接近 VRAM 的速度运行,从而显著缩小这一差距。鉴于这一点,您可以将本地文件系统安装在 PMEM 设备上,以这种方法作为从机械硬盘或固态盘切换到 PMEM 的首个解决方案。虽然这肯定有帮助,但软件在设计时仍然需要考虑易失性内存和持久内存。例如,数据在持久存储(如逗号分隔值 (CSV) 文件和结构化查询语言 (SQL) 表)中的表现形式与 VRAM(二叉树和堆等)不同。在这里,使用 libpmemobj 库直接针对 PMEM 进行编程可以大幅简化开发!

通过直接针对 PMEM 进行编程,实现 FT 只需指定哪些数据结构应该是永久性的。为提高性能,仍然可以使用传统 VRAM,但需要以透明方式(相对于 VRAM 使用处理器缓存的方式相同)或作为临时缓冲区。另外,为了确保永久数据结构在写入操作过程发生故障时不会被损坏,必须建立事务机制。

设计决策

本节将介绍为使示例具有 PMEM 感知能力而作出的设计决策。

数据结构

这个特定示例专为仅在一个计算机节点上运行而设计,只有一台 PMEM 设备与其相连。工作线程被实施为线程。

我们首先需要一个允许我们将工作分配给 map 和 reduce 工作线程的数据结构。这可以通过任务来实现。将任务分配给工作线程的方式有两种:(1) 推送方式(从主信息推送到工作线程) (2) 拉动方式(工作线程从主信息获取)。在本例中我们选择第二种方式,以便使用持久任务列表简化实施并使用 PMEM 互斥体在工作线程之间进行协调。


图 2:根数据结构。

PMEM 池中的第一个对象始终是根。这一对象作为连接程序中创建的所有其他对象的主要锚点。就我而言,我有四个对象。前两个对象是 pmem::obj 版本的 C++ 标准互斥体和条件变量。我们不能使用标准对象,因为 libpmemobj 需要在发生崩溃时重置它们(否则可能会发生永久性死锁)。如欲了解更多信息,请参见 以下关于通过 libpmemobj 进行同步的文章。第三个对象是输入数据,它被存储为一维 持久字符串。第四个对象是我们的任务列表。您可能已经注意到,变量 tlist没有被声明为持久指针。其原因在于,tlist 在第一次创建后从未被修改(即被重写),所以在事务处理期间无需跟踪该内存范围。另一方面,map 和 reduce 任务的头变量被声明为持久指针,因为在程序执行期间,它们的值实际上会发生变化(通过添加新任务)。

接下来我们来看看 list_entry 类:


图 3:ListEntry 类。

  • 变量 next 是链表中下一个条目的持久指针。
  • status 标记可以取值 TASK_ST_NEW(任务为新任务,并且可以线程可以立即开始处理)、TASK_ST_BUSY (有些线程目前正在处理这一任务)、TASK_ST_REDUCED(这个任务有归约的结果,但尚未与其他归约任务相结合) 或 TASK_ST_DONE (任务最终完成)。
  • task_type 标记可以取值 TASK_TYPE_NOTYPETASK_TYPE_MAPTASK_TYPE_REDUCE
  • start_byte 在输入数据字符串中保存数据块的起始字节。仅与 map 任务相关。
  • n_lines 保存数据块中的行数。仅与 map 任务相关。
  • kv 是键值对列表的指针。这一列表仅与 reduce 任务相关。
  • kv_sizekv 列表的大小(以元素为单位)。
  • 最后,alloc_byteskv 列表的大小(以字节为单位)。

kv 之所以是 char[]的持久指针,是出于性能方面的考虑。最初,我将这个列表实施为 kv_tuple 对的链表。然而,由于大量分配(有时每个任务每线程数十万个)非常小的对象(平均在 30-40 字节之间),并且由于这些分配由 libpmemobj 同步以保护元数据的完整性,所以我的代码无法扩展到 8 个线程之外。该变更允许每条线程在为单个任务存储所有键值对时仅执行一次大分配。

您可能也注意到,对于kv_tuple中的密钥,我没有使用前面提到的persistent_string类。其原因在于,persistent_string是针对可以随时间变化的持久字符串变量设计的,因此会为每个新字符串创建两个持久指针:一个用于对象本身,一个用于原始字符串。在这个特定示例中,不需要 persistent_string 的功能。键值元组在建造过程中进行批量分配和设置,且在销毁之前不会更改。这会减少库在事务处理期间需要了解的 PMEM 对象数量,从而有效减少开销。

不过,以这种方式分配键值元组有点棘手。

struct kv_tuple { size_t value; char key[]; };

在创建键值元组的持久列表之前,我们需要计算它的大小(以字节为单位)。我们之所以可以这样,是因为主要的计算和排序工作都是先在 VRAM 上完成的,因此能够提前知道总大小。一旦我们完成了这一任务,我们便可以通过一次调用分配所有 PMEM:

void list_entry::allocate_kv (pmem::obj::pool_base &pop, size_t bytes) { pmem::obj::transaction::exec_tx (pop, [&] { kv = pmem::obj::make_persistent<char[]> (bytes); alloc_bytes = bytes; }); }

然后我们将数据复制到我们新创建的 PMEM 对象:

void list_entry::add_to_kv (pmem::obj::pool_base &pop, std::vector<std::string> &keys, std::vector<size_t> &values) { pmem::obj::transaction::exec_tx (pop, [&] { struct kv_tuple *kvt; size_t offset = 0; for (size_t i = 0; i < keys.size (); i++) { kvt = (struct kv_tuple *)&(kv[offset]); kvt->value = values[i]; strcpy (kvt->key, keys[i].c_str ()); offset += sizeof (struct kv_tuple) + strlen (kvt->key) + 1; } kv_size = keys.size (); }); }

除了强制池对象 pop(这个对象不能存储在持久内存中,因为它是在每个程序调用时新创建的)之外,该函数的输入都是两个易失性向量,包含由 map 或 reduce 任务生成的键值对。由于每个 kv_tuple 的大小不是常量(取决于其密钥的长度),所以kv通过字节偏移(for loop 内的第一个和最后一个语句)迭代。

同步

以下伪代码表示工作线程的高级逻辑:

  1. 等到有新的任务可用。
    • map 工作线程每次只处理一个任务。
    • 如果可能,reduce 工作线程尝试处理两个任务(并将它们合并起来)。如果处理不了,那么只处理一个任务。
  2. 处理任务并设为TASK_ST_DONE(或者 TASK_ST_REDUCED ,如果是处理单个任务的 reduce 工作线程)。
  3. 将结果存储在状态为 TASK_ST_NEW的新建任务中(最后一个任务有整个计算的结果,并直接创建为 TASK_ST_DONE)。
  4. 完成计算后(所有任务都为 TASK_ST_DONE),退出。
  5. 转至 (1)。


接下来我们来看一下 map 工作线程的步骤 (1):

void pm_mapreduce::ret_available_map_task (pmem::obj::persistent_ptr<list_entry> &tsk, bool &all_done) { auto proot = pop.get_root (); auto task_list = &(proot->tlist); /* LOCKED TRANSACTION */ pmem::obj::transaction::exec_tx ( pop, [&] { all_done = false; if ((task_list->ret_map (tsk)) != 0) { tsk = nullptr; all_done = task_list->all_map_done (); } else tsk->set_status (pop, TASK_ST_BUSY); }, proot->pmutex); }

这段代码中最重要的部分位于事务内部(在以pmem::obj::transaction::exec_tx开始的数据块内)。这一事务需要被锁定,因为每个任务只能由一个工作线程执行(意识到我在事务结束时使用来自根对象的持久互斥体)。调用task_list->ret_map()方法检查是否有新的 map 任务。如果有,我们将其状态设为 TASK_ST_BUSY,防止其被其他工作线程获取。如果没有新任务,则调用task_list->all_map_done()检查是否已完成所有 map 任务,在这种情况下线程将退出(在此代码片段中未显示)。

从这段代码中学到的另一个重要教训是,每次在锁定区域内修改数据结构时,该区域应与事务同时结束。如果线程更改了锁定区域内的数据结构,然后很快发生故障(但未完成事务),则在锁定时完成的所有更改都会回滚。与此同时,另一个线程可能已经获取该锁,并可能正在故障线程所作变更的基础上进行其他更改,从而最终破坏数据结构。

避免这种情况的一种方法是将持久互斥体传递给事务,从而锁定整个事务(如上面的代码片段所示)。然而,有些情况是不可行的(因为整个事务实际上是序列化的)。在这些情况下,我们可以通过将同步写入置于嵌套锁定事务中,将同步写入留到事务结束。尽管嵌套事务在默认情况下是扁平化的,这意味着我们最后拥有的仅仅是最外层的事务。嵌套事务的锁只从嵌套事务开始的位置锁定最外层的事务。这可以在以下代码片段中看到:

..... auto proot = pop.get_root (); auto task_list = &(proot->tlist); pmem::obj::transaction::exec_tx (pop, [&] { ....../* 这部分事务可以由所有线程同时执行*。*/ ...... pmem::obj::transaction::exec_tx ( pop, [&] { /* this nested transaction adds the lock to the outer one.* This part of the transaction is executed by only one * thread at a time */ task_list->insert (pop, new_red_tsk); proot->cond.notify_one (); tsk->set_status (pop, TASK_ST_DONE); }, proot->pmutex); /* end of nested transaction */ }); /* end of outer transaction */

对于 reduce 工作线程来说,情况 (1) 更复杂,所以我不会在这里将其全部重现。不过,有一部分值得讨论:

void pm_mapreduce::ret_available_red_task ( pmem::obj::persistent_ptr<list_entry> (&tsk)[2], bool &only_one_left, bool &all_done) { auto proot = pop.get_root (); auto task_list = &(proot->tlist); /* locked region */ std::unique_lock<pmem::obj::mutex> guard (proot->pmutex); proot->cond.wait ( proot->pmutex, [&] { /* conditional wait */ .....}); ..... guard.unlock ();

map 工作线程和 reduce 工作线程之间的主要区别在于,reduce 工作线程进行有条件等待。map 任务在计算开始之前立即创建。因此,map 工作线程无需等待创建新任务。另一方面,reduce 工作线程将进行有条件等待,直到其他工作线程创建新 reduce 任务。reduce 工作线程被唤醒时(另一个工作线程proot->cond.notify_one()在创建新任务并插入列表后运行),布尔函数(传递给 proot->cond.wait())将运行,以检查工作线程是否应继续。reduce 工作线程将在以下两种情况下继续:(a) 至少有一个任务可用 (b) 所有任务最终完成(线程将退出)。

容错

本文中介绍的示例代码可以从 GitHub*下载。这一代码将通过从一般 PMEM MapReduce 类继承并完成完成虚拟函数 map()reduce()来实现 PMEM 版本的 wordcount 程序:

class pm_wordcount : public pm_mapreduce { public: /* constructor */ pm_wordcount (int argc, char *argv[]) : pm_mapreduce (argc, argv) {} /* map */ virtual void map (const string line, vector<string> &keys, vector<size_t> &values) { size_t i = 0; while (true) { string buf; while (i < line.length () && (isalpha (line[i]) || isdigit (line[i]))) { buf += line[i++]; } if (buf.length () > 0) { keys.push_back (buf); values.push_back (1); } if (i == line.length ()) break; i++; } } /* reduce */ virtual void reduce (const string key, const vector<size_t> &valuesin, vector<size_t> &valuesout) { size_t total = 0; for (vector<size_t>::const_iterator it = valuesin.begin (); it != valuesin.end (); ++it) { total += *it; } valuesout.push_back (total); } };

构建指令

若要编译 mapreduce 代码示例,只需从 pmem/pmdk-examples GitHub 库的根目录键入make mapreduce即可。如欲了解更多信息,请阅读 mapreduce 示例README 文件。

运行示例的说明

编译后,您可以运行无参数的程序来获取使用帮助:

$ ./wordcount USE: ./wordcount pmem-file <print | run | write -o=output_file | load -d=input_dir> [- m=num_map_workers] [-nr=num_reduce_workers] command help: print -> Prints mapreduce job progress run -> Runs mapreduce job load -> Loads input data for a new mapreduce job write -> Write job solution to output file command not valid

若要了解 FT 的工作方式,请使用一些示例数据运行代码。就我而言,我使用所有维基百科摘要 (文件大小为 5GB,因此可能需要很长时间才能加载到浏览器;您可以通过右击 > 另存为来下载)。运行 MR 之前的第一个步骤是将输入数据加载到 PMEM:

$ ./wordcount /mnt/mem/PMEMFILE load -d=/home/.../INPUT_WIKIABSTRACT/ Loading input data $

现在我们可以运行该程序(在这种情况下,我对 map 工作线程使用两个线程,对 reduce 工作线程使用两个线程)。取得一些进度之后,我们将按Ctrl-C,终止该任务:

$ ./wordcount /mnt/mem/PMEMFILE run -nm=2 -nr=2 Running job ^C% map 15% reduce $

我们可以用 print命令查看进度:

$ ./wordcount /mnt/mem/PMEMFILE print Printing job progress 16% map 15% reduce $

到目前为止,我们的进度已保存!如果我们再次使用 run 命令,计算将从我们中断的位置重新开始(16% map 和 15% reduce):

$ ./wordcount /mnt/mem/PMEMFILE run -nm=2 -nr=2 Running job 16% map 15% reduce

计算完成后,我们可以将结果(命令 write)转储到常规文件并读取结果:

$ ./wordcount /mnt/mem/PMEMFILE write -o=outputfile.txt Writing results of finished job $ tail -n 10 outputfile.txt zzeddin 1 zzet 14 zzeti 1 zzettin 4 zzi 2 zziya 2 zzuli 1 zzy 1 zzz 2 zzzz 1 $

性能

所用系统拥有 28 核英特尔® 至强® 铂金 8180 处理器 CPU(224 个线程)和 768 GB 的英特尔® 双倍数据速率 4(英特尔® DDR 4)内存。若要模拟安装在 /mnt/mem 的 PMEM 设备,使用 512 GB 的内存。使用的操作系统是内核版本为 4.9.49 的 CentOS Linux* 7.3。所用的输入数据还是 所有维基百科摘要 (5 GB)。在实验中,我为 map 分配一半线程,为 reduce 任务分配一半线程。

图 4:使用我们的 PMEM-MR 示例对所有维基百科摘要 (5 GB) 中的词进行计数所用的时间。

可以看到,我们的示例可以一直扩展到 16 个线程(大约将完成时间减半)。在 32 个线程中仍然有所改进,但只有 25%。在 64 线程中,我们达到了这个特定示例的可扩展性限制。这是因为随着更多线程用于相同的数据,同步部分在总执行时间中占据较大比例。

总结

在本文中,我们使用 PMDK 库 libpmemobj 的 C++ 绑定展示了知名 MR 算法的示例实施。我展示了如何使用 PMEM 互斥体和条件变量,通过事务和并发来实现数据一致性。另外,我还展示了 PMDK 如何促进 FT 程序的创建,支持编程人员直接针对 PMEM 进行编码(也就是定义哪些数据结构应该被持久化)。最后,我进行了灵敏度性能分析,展示了将更多线程添加到执行时的可扩展性。

关于作者

Eduardo Berrocal 于 2017 年 7 月加入英特尔,担任云软件工程师。他拥有伊利诺斯州芝加哥伊利诺理工学院 (IIT) 的计算机科学博士学位。他的博士研究方向主要为(但不限于)数据分析和面向高性能计算的容错。他曾担任过贝尔实验室(诺基亚)的暑期实习生、阿贡国家实验室的研究助理,芝加哥大学的科学程序员和 web 开发人员以及西班牙 CESVIMA 实验室的实习生。

资源

  1. MapReduce: 简化大型集群数据处理,Jeffrey Dean 和 Sanjay Ghemawat,https://static.googleusercontent.com/media/research.google.com/en//archive/mapreduce-osdi04.pdf
  2. 使用骨架函数进行并行编程,J. Darlington 等人,西澳大利亚大学计算机科学系http://pubs.doc.ic.ac.uk/parallel-skeleton/parallel-skeleton.pdf
  3. 持久内存编程,pmemobjfs - 基于 FUSE 的简单 libpmemobj, 2015 年 9 月 29 日, http://pmem.io/2015/09/29/pmemobjfs.html
  4. 持久内存编程libpmemobj 的 C++ 绑定(第 7 部分) - 同步原语, 2016 年 5 月 31 日, http://pmem.io/2016/05/31/cpp-08.html
  5. 持久内存编程使用 libpmemobj C ++ 绑定建模字符串,2017 年 1 月 23 日,http://pmem.io/2017/01/23/cpp-strings.html
  6. 链接到 GitHub 中的示例代码*
  7. https://dumps.wikimedia.org/enwiki/latest/enwiki-latest-abstract.xml
  8. Pmem.io 持久内存编程如何模拟持久内存,2016 年 2 月 22 日,http://pmem.io/2016/02/22/pm-emulation.html

产品和性能信息

1

性能因用途、配置和其他因素而异。请访问 www.Intel.com/PerformanceIndex 了解更多信息。