0%

pmdk组件学习

  • PMDK(Persistent Memory Development Kit)组件学习

PMDK 简介

PMDK 的全称是 Persistent Memory Development Kit,它包含了 Intel 开发的一系列旨在 方便非易失性内存的应用开发的函数库和工具。PMDK 是一个还在快速发展变化的新鲜事物,不过版本的迭代更新等很多信息都可以公开看到。需要注意的是,PMDK 的网站入口 和 Git 仓库的入口不是严格的一一对应关系。

实际上,PMDK的Git仓库中包含了libpmem、libvmem、libpmemblk、libpmemlog、libpmemobj、libpmempool、libvmmalloc 等很多函数库。还有很多函数库和工具分散在其他的一些Git仓库中,例如我们下面要介绍的libpmemkind、libvmemcache、libpmemkv以及支持C/C++、Java、Python等语言的绑定。

PMDK的典型应用场景

  • 把DRAM和PM一起当作内存使用
  • 把DRAM和PM当作两层不同的内存使用(libmemkind)
  • 把PM当作对象缓存使用(libvmemcache)
  • 把PM当作持久性KV存储使用(libpmemkv)
    • 这个库也是我打算以后用来实现数据缓存部分需要的,还需要花一定时间去了解。
  • 把PM当做事务性对象存储使用(libpmemobj)

有没有好的项目推荐?

  • 看了一堆材料之后发现自己还是没有什么想法,主要是什么知识都不知道,终于找到了阿里的一个数据库比赛,专门针对可持续性内存进行的一个kv数据库的实现,于是想到可以先学习一下他们的设计思路,再做出自己的创新。

  • 这里专门附上比赛链接,为了进一步了解如何进行PM的编程,我开始学习大赛的官方学习文档:

    PMEM编程指南

    小白是个普通大学生,但是他有幸得到了一块AEP,他决定把之前写的学生管理系统搬到AEP上来

    首先我们假设有如下的结构体

    1
    2
    3
    struct Student {
    char name[16];
    };

    那么接下来我们想要构造一个新的学生,如果介质是普通的内存,我们自然会写出这样的代码

    1
    2
    3
    4
    5
    struct Student* new_student(char* name) {
    struct Student* stu = (struct Student*)malloc(sizeof(struct Student));
    strcpy(stu->name, name);
    return stu;
    }

    但是如果在持久型存储上,上面的代码结果是不确定的。因为程序会crash,机器也可能掉电关机,这些导致程序非正常退出的因素都可能导致上述的数据写入出现不一致的状态。如果程序在调用strcpy前失败,我们还可以通过学生名为空发现这不是一个合理的学生(假定之前整块AEP为空),但是如果strcpy执行中失败,Alicebobby同学可能在重启之后成为了Alice同学。

    为了解决这个问题,我们会想到添加一些字段来进行校验。这是改进后的结构体。

    1
    2
    3
    4
    struct Student {
    char name[16];
    uint16_t length;
    };

    这时我们会这样构造对象

    1
    2
    3
    4
    5
    6
    7
    struct Student* new_student(char* name) {
    struct Student* stu = (struct Student*)malloc(sizeof(struct Student));
    uint16_t len = strlen(name);
    stu->length = len;
    strcpy(stu->name, name);
    return stu;
    }

    我们就可以在重启后通过对比length与实际写入的长度来确认这个对象的构造是否正确(假定语句总是顺序执行的)。但是,cpu的缓存机制也可能产生特殊的影响。

    我们继续假定有这样的结构体与更新操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct Students{
    struct Student* student[MAX_STUDENT_CNT];
    uint32_t student_cnt;
    };

    void push_back(struct Students* students, char* name) {
    struct Student* stu = new_student(name);
    student[student] = stu;
    student_cnt++;
    }

    首先,数据开始的时候被存储在cpu的多级cache中,在通常的策略下,cpu并不会在每次有更新时就将脏数据写回,这在进行持久化内存编程时产生了很大的影响,在不使用特殊手段的情况下,我们很难预测cpu将哪一部分cache写回了。因此,理论上如果此时系统掉电,那么将会出现cache中的数据丢失的现象,对应到这个场景,则是我们可能得到这样的数据

    1
    2
    3
    4
    students:
    student: {NULL, NULL,NULL...},
    student_cnt: 1,
    };

    我们可以注意到,虽然我们的student字段是最先赋值的,但是cpu将这部分所属的cache选择在最后写回,实际写入的学生数和记录中的学生数对不上了。这是我们所不想见到的。

    为了回避这些问题,我们需要了解这样几个指令,分别是flush系的CLFLUSH、CLFLUSHOPT和CLWB,以及SFENCE。

    CLFLUSH会命令cpu将对应cacheline逐出,强制性的写回介质,这在一定程度上可以解决我们的问题,但是这是一个同步指令,将会阻塞流水线,损失了一定的运行速度,于是Intel添加了新的指令CLFLUSHOPT和CLWB,这是两个异步的指令。尽管都能写回介质,区别在前者会清空cacheline,后者则会保留,这使得在大部分场景下CLWB可能有更高的性能。

    但是事情没有那么简单,异步的代价是我们对于cache下刷的顺序依旧不可预测,对应到这个场景就是name依然可能先于length下刷,于是我们需要使用SFENCE提供保证,SFENCE强制sfence指令前的写操作必须在sfence指令后的写操作前完成。

    除此之外,8byte的写入操作本身也是原子的。

    cache的问题解决的差不多了。但是除了cache,编译器的优化策略与cpu的乱序执行也可能产生类似的效果。

    因此Intel提供了PMDK,在PMDK中的libpmem中提供了这样一些API,用以处理所有可能产生影响的情况。

    1
    2
    3
    void pmem_flush(const void *addr, size_t len);
    void pmem_drain(void);
    void pmem_persist(const void *addr, size_t len);

    pmem_flush即是之前所说的flush系指令的封装,只不过libpmem会在装载时获取相关信息自动选择最优的指令,而pmem_drain则是对sfence的封装。至于pmem_persist,仅仅只是连续调用了pmem_flushpmem_drain。考虑到pmem_drain可能会阻塞一些操作,更好的做法是对数据结构里互不相干的几个字段分别flush,最后一并调用pmem_drain,以将阻塞带来的问题降到最低。

    用上面几个API来补全这个例子,就是

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void push_back(struct Students* students, char* name) {
    struct Student* stu = new_student(name); // you also need to make this function persist
    student[student] = stu;
    pmem_persist(&student[student], sizeof(struct Student*));
    // or
    // pmem_flush(&student[student], sizeof(struct Student*));
    // pmem_drain();
    student_cnt++;
    pmem_persist(&student_cnt, sizeof(student_cnt));
    // the same
    // pmem_flush(&student_cnt, sizeof(student_cnt));
    // pmem_drain();
    }

    再举一个例子好了,我们可能会想要以链表形式组织数据,那么

    1
    2
    3
    4
    5
    6
    7
    8
    struct StudentNode {
    struct Student* stu;
    struct StudentNode* next;
    };
    void insert(struct StudentNode** head, struct StudentNode* new_stu) {
    new_stu->next = *head;
    *head = new_stu;
    }

    看出问题了吗?同样的,head很有可能先于next写回AEP,在这种情况下发生断电我们就会丢失整条链表的大部分信息。因此,我们也需要手动处理持久化。

    1
    2
    3
    4
    5
    6
    void insert(struct StudentNode** head, struct StudentNode* new_stu) {
    new_stu->next = *head;
    pmem_persist(&new_stu->next, sizeof(struct StudentNode*));
    *head = new_stu;
    pmem_persist(*head, sizeof(struct StudentNode*));
    }

    最后则是libpmem里的几个API

    1
    2
    3
    4
    5
    6
    void *pmem_memmove_persist(void *pmemdest, const void *src, size_t len);
    void *pmem_memcpy_persist(void *pmemdest, const void *src, size_t len);
    void *pmem_memset_persist(void *pmemdest, int c, size_t len);
    void *pmem_memmove_nodrain(void *pmemdest, const void *src, size_t len);
    void *pmem_memcpy_nodrain(void *pmemdest, const void *src, size_t len);
    void *pmem_memset_nodrain(void *pmemdest, int c, size_t len);

    语义上讲,这些函数都相当于调用glibc的同名函数再补上pmem_flushpmem_drain(可选),只不过no_drain提供了更细的粒度,让我们有能力对多个不相干的数据分别写入,最后一并调用pmem_drain

  • 每个参赛队伍的引擎设计思路

  • 参考