在C ++中为远程内存创建富有表现力的智能指针

哈Ha!

今天,我们发布了一份有趣的研究翻译,有关使用C ++处理内存和指针。 该材料有点学术性,但显然加洛维兹威廉姆斯的读者会感兴趣。

跟随广告!

在研究生院,我从事分布式数据结构的构建。 因此,在创建干净整洁的代码的工作中,代表远程指针的抽象非常重要。 在本文中,我将解释为什么需要智能指针,告诉我如何在C ++中为我的库编写远程指针对象,并确保它们的工作方式与常规C ++指针完全相同。 这是使用远程链接对象完成的。 此外,我将解释这种抽象在什么情况下会失败,原因很简单,原因是我自己的指针(到目前为止)无法应付普通指针可以完成的任务。 我希望本文能引起参与高级抽象开发的读者的兴趣。

低级API


当使用分布式计算机或网络硬件时,您通常具有通过C API读取和写入内存的权限,其中一种示例是用于单向通信的MPI API。 该API使用的功能是打开直接访问以从位于分布式群集中的其他节点的内存中进行读取和写入。 这是略微简化的外观。

void remote_read(void* dst, int target_node, int offset, int size); void remote_write(void* src, int target_node, int offset, int size); 

在目标节点的共享内存段中指示的偏移量下remote_read目标节点remote_read一定数量的字节, remote_write写入一定数量的字节。

这些API很棒,因为它们使我们能够访问重要的原语,这些原语对我们实现在计算机集群上运行的程序很有用。 它们也非常好,因为它们的工作速度非常快,并且可以准确反映硬件级别提供的功能:远程直接内存访问(RDMA)。 现代的超级计算机网络,例如Cray AriesMellanox EDR ,使我们能够计算出读/写延迟不会超过1-2μs。 由于网卡(NIC)可以直接读写RAM,而无需等待远程CPU唤醒并响应您的网络请求,因此可以实现此指示器。

但是,此类API就应用程序编程而言并不是很好。 即使在如上所述的简单API的情况下,意外删除数据也不会花费任何成本,因为对于存储在内存中的每个特定对象没有单独的名称,只有一个大的连续缓冲区。 另外,该接口是无类型的,也就是说,您被剥夺了另一项切实的帮助:当编译器发誓时,如果您在错误的位置写下了错误类型的值。 您的代码只会证明是错误的,而错误将具有最神秘和灾难性的性质。 这种情况甚至更加复杂,因为实际上这些API稍微复杂一些,并且在使用它们时很可能会错误地重新排列两个或多个参数。

删除的指针


指针是创建高级编程工具时所需的重要且必要的抽象级别。 直接使用指针有时很困难,并且您可能会犯很多错误,但是指针是代码的基本构建块。 数据结构甚至C ++链接通常在内部使用指针。

如果我们假定我们将具有与上述API类似的API,则内存中的唯一位置将由两个“坐标”指示:(1) 等级或进程ID,以及(2)对该进程占用的具有此等级的进程所占用的远程内存共享部分的偏移量。 您不能在那里停下来做一个完整的结构。

  template <typename T> struct remote_ptr { size_t rank_; size_t offset_; }; 

在此阶段,已经可以设计一种用于读写远程指针的API,并且该API将比我们最初使用的API更安全。

  template <typename T> T rget(const remote_ptr<T> src) { T rv; remote_read(&rv, src.rank_, src.offset_, sizeof(T)); return rv; } template <typename T> void rput(remote_ptr<T> dst, const T& src) { remote_write(&src, dst.rank_, dst.offset_, sizeof(T)); } 

块传输看起来非常相似,为简洁起见,在此我将其省略。 现在,为了读取和写入值,您可以编写以下代码:

  remote_ptr<int> ptr = ...; int rval = rget(ptr); rval++; rput(ptr, rval); 

它已经比原始API更好,因为在这里我们使用类型化的对象。 现在,写入或读取错误类型的值或仅写入对象的一部分并不容易。

指针算术


指针算术是最重要的技术,它使程序员可以管理内存中的值集合。 如果我们正在编写一个用于内存中分布式工作的程序,那么大概我们将使用大量值进行操作。
将删除的指针增加或减少一个是什么意思? 最简单的选择是将删除的指针的算术视为普通指针的算术:p +1仅指向原始等级的共享段中p之后的下一个sizeof(T)对齐的内存。

尽管这不是远程指针算法的唯一可能定义,但最近已被最积极地采用,并且以这种方式使用的远程指针包含在UPC ++DASH和BCL之类的库中。 但是,在高性能计算(HPC)专家社区中留下了丰富的遗产的统一并行C (UPC)语言包含了指针算法的更详细定义[1]。

以这种方式实现指针算法很简单,并且只涉及更改指针偏移量。

  template <typename T> remote_ptr<T> remote_ptr<T>::operator+(std::ptrdiff_t diff) { size_t new_offset = offset_ + sizeof(T)*diff; return remote_ptr<T>{rank_, new_offset}; } 

在这种情况下,我们就有机会访问分布式内存中的数据阵列。 因此,我们可以实现SPMD程序中的每个进程将对其远程指针指向的数组中的变量执行写或读操作[2]。

 void write_array(remote_ptr<int> ptr, size_t len) { if (my_rank() < len) { rput(ptr + my_rank(), my_rank()); } } 

也很容易实现其他运算符,从而为在普通指针算术中执行的全套算术运算提供支持。

选择nullptr


对于常规指针, nullptr值为NULL ,这通常意味着将#define减小为0x0,因为不太可能使用内存中的此部分。 在我们的带有远程指针的方案中,我们可以选择一个特定的指针值作为nullptr ,从而使该位置在内存中未被使用,或者包括一个特殊的布尔成员,该成员将指示指针是否为空。 尽管事实上不使用内存中的某个特定位置并不是最好的方法,但我们还将考虑到,仅添加一个布尔值时,从大多数编译器的角度来看,远程指针的大小将加倍,并从128位增加到256位以保持对齐。 这是特别不希望的。 在我的库中,我选择{0, 0} (即,偏移量为0,等级为0)作为值nullptr

nullptr选择其他选项也可能会同样起作用。 另外,在某些编程环境(例如UPC)中,实现了窄指针,每个指针都适合64位。 因此,它们可以与交换一起用于原子比较操作。 当使用窄指针时,您必须做出妥协:偏移量标识符或等级标识符的大小不能超过32位,这限制了可伸缩性。

删除的链接


在Python之类的语言中,根据您是读取对象还是写入对象,括号语句可作为调用__setitem____getitem__语法糖。 在C ++中, operator[]不能区分对象属于哪个值类别 ,以及返回的值是否将立即处于读或写状态。 为了解决此问题,C ++数据结构返回指向容器中包含的内存的链接,这些链接可以被写入或读取。 std::vectoroperator[]实现可能看起来像这样。

  T& operator[](size_t idx) { return data_[idx]; } 

这里最重要的事实是,我们返回类型为T&的实体(这是您可以编写的原始C ++链接),而不是类型T的实体(仅表示源数据的值)。

在我们的例子中,我们不能返回原始的C ++链接,因为我们所指的是位于另一个节点上且未在虚拟地址空间中表示的内存。 是的,我们可以创建自己的自定义参考对象。
链接是一个对象,它充当指针的包装器,它执行两个重要的功能:可以将其转换为类型T的值,也可以将其分配给类型T的值T 因此,在使用远程引用的情况下,我们只需要实现一个隐式转换运算符即可读取该值,还需要使一个赋值运算符写入该值。

 template <typename T> struct remote_ref { remote_ptr<T> ptr_; operator T() const { return rget(ptr_); } remote_ref& operator=(const T& value) { rput(ptr_, value); return *this; } }; 

因此,我们可以使用新的强大功能来丰富我们的远程指针,在这种情况下,可以像普通指针一样完全取消对它的引用。

 template <typename T> remote_ref<T> remote_ptr<T>::operator*() { return remote_ref<T>{*this}; } template <typename T> remote_ref<T> remote_ptr<T>::operator[](ptrdiff_t idx) { return remote_ref<T>{*this + idx}; } 

因此,现在我们恢复了整个图片,显示了如何正常使用远程指针。 我们可以重写上面的简单程序。

 void write_array(remote_ptr<int> ptr, size_t len) { if (my_rank() < len) { ptr[my_rank()] = my_rank(); } } 

当然,我们的新指针API允许我们编写更复杂的程序,例如,基于树执行并行约简的函数[3]。 使用我们的远程指针类的实现比使用上述C API通常获得的实现更安全,更干净。

运行时产生的成本(或缺少成本!)


但是,使用这种高级抽象将花费什么呢? 每次访问内存时,我们都调用解引用方法,返回包装指针的中间对象,然后调用影响中间对象的转换运算符或赋值运算符。 在运行时会花多少钱?

事实证明,如果您仔细地指定指针和引用类,则在运行时进行此抽象将没有任何开销-现代C ++编译器通过主动嵌入来处理这些中间对象和方法调用。 为了评估这种抽象将使我们付出什么,我们可以编译一个简单的示例程序,并检查程序集将如何运行以查看在运行时将存在哪些对象和方法。 在此处描述的示例中,使用基于树的归约与远程指针和引用的类进行编译,现代编译器将基于树的归约简化为几个remote_readremote_write [4]。 没有调用任何类方法,在运行时不存在引用对象。

与数据结构库的交互


经验丰富的C ++程序员记得标准的C ++模板库指出:STL容器必须支持自定义C ++分配器 。 分配器允许您分配内存,然后可以使用我们创建的指针类型来引用此内存。 这是否意味着您可以简单地创建一个“远程分配器”并将其连接以使用STL容器将数据存储在远程内存中?

不幸的是,没有。 据推测,出于性能原因,C ++标准不再需要支持自定义引用类型,并且在C ++标准库的大多数实现中,它们实际上均不受支持。 因此,例如,如果您使用来自GCC的libstdc ++,则可以诉诸自定义指针,但是同时您只能使用普通的C ++链接,这不允许您在远程内存中使用STL容器。 一些高级C ++模板库(例如,使用自定义指针类型和引用类型的Agency)包含来自STL的某些数据结构的自己实现,这些实现实际上允许您使用远程引用类型。 在这种情况下,程序员可以通过创造性的方式来创建分配器,指针和链接的类型,从而获得更大的自由度,此外,还可以获得可以自动与它们一起使用的数据结构的集合。

广泛的背景


在本文中,我们解决了许多更广泛但尚未解决的问题。

  • 内存分配 。 现在我们可以引用远程内存中的对象,我们如何保留或分配此类远程内存?
  • 支持对象 。 这种类型比int复杂的对象在远程存储器中的存储情况如何? 是否可以为复杂类型提供整洁的支持? 可以同时支持简单类型而不浪费序列化资源吗?
  • 设计分布式数据结构 。 现在有了这些抽象,可以用它们构建什么数据结构和应用程序? 数据分发应使用什么抽象?

注意事项


[1]在UPC中,指针具有一个阶段,该阶段确定指针加1后将定向到哪个等级。 由于阶段的原因,可以将分布式数组封装在指针中,并且它们中的分布模式可能非常不同。 这些功能非常强大,但对于新手来说似乎很神奇。 尽管某些UPC Ace确实更喜欢这种方法,但更合理的面向对象的方法是先编写一个简单的远程指针类,然后确保根据为此目的专门设计的数据结构分配数据。

[2] HPC中的大多数应用程序都是以SPMD样式编写的,此名称表示“一个程序,不同的数据”。 SPMD API提供了一个函数或变量my_rank() ,该变量或变量my_rank()告诉执行该程序的进程唯一的等级或ID,然后可以基于该等级或ID从主程序中进行分支。

[3]这是使用远程指针类以SPMD样式编写的简单树还原。 该代码是根据我的同事Andrew Belt最初编写的程序改编的。

  template <typename T> T parallel_sum(remote_ptr<T> a, size_t len) { size_t k = len; do { k = (k + 1) / 2; if (my_rank() < k && my_rank() + k < len) { a[my_rank()] += a[my_rank() + k]; } len = k; barrier(); } while (k > 1); return a[0]; } 

[4]以上代码的编译结果可以在此处找到

Source: https://habr.com/ru/post/zh-CN467421/


All Articles