在Headlands Technologies工作时,我很幸运地编写了一些实用程序来简化高性能C ++代码的创建。 本文提供了其中一种实用工具OutOfLine
的一般概述。
让我们从一个示例开始。 假设您有一个处理大量文件系统对象的系统。 这些可以是普通文件,称为UNIX套接字或管道。 由于某种原因,您在启动时打开了许多文件描述符,然后进行了大量的工作,最后,关闭描述符并删除了文件的链接(大约。该通道表示取消链接功能)。
初始(简化)版本可能如下所示:
class UnlinkingFD { std::string path; public: int fd; UnlinkingFD(const std::string& p) : path(p) { fd = open(p.c_str(), O_RDWR, 0); } ~UnlinkingFD() { close(fd); unlink(path.c_str()); } UnlinkingFD(const UnlinkingFD&) = delete; };
这是一个很好的,合乎逻辑的设计。 它依靠RAII自动释放描述符并删除链接。 您可以创建大量此类对象的数组,然后与它们一起使用,当数组不再存在时,对象本身将清除过程中所需的所有内容。
但是性能如何呢? 假设fd
经常使用,并且仅在删除对象时使用path
。 现在,数组由大小为40个字节的对象组成,但通常仅使用4个字节。 这意味着缓存中将有更多未命中,因为您需要“跳过” 90%的数据。
解决此问题的一种常见方法是从结构数组过渡到数组结构。 这将提供所需的性能,但要以放弃RAII为代价。 是否有将两种方法的优点结合在一起的选项?
一个简单的折衷方法是用大小仅为8个字节的std::unique_ptr<std::string>
替换大小为32个字节的std::unique_ptr<std::string>
。 这会将对象的大小从40个字节减少到16个字节,这是一个了不起的成就。 但是,此解决方案仍然无法使用多个数组。
OutOfLine
是一种工具,可在不放弃RAII的情况下将很少使用(冷)的字段完全移出对象。 OutOfLine用作CRTP基类,因此模板的第一个参数必须是子类。 第二个参数是与频繁使用(主要)对象相关联的很少使用(冷)数据的类型。
struct UnlinkingFD : private OutOfLine<UnlinkingFD, std::string> { int fd; UnlinkingFD(const std::string& p) : OutOfLine<UnlinkingFD, std::string>(p) { fd = open(p.c_str(), O_RDWR, 0); } ~UnlinkingFD(); UnlinkingFD(const UnlinkingFD&) = delete; };
那么这堂课是什么样的呢?
template <class FastData, class ColdData> class OutOfLine {
基本的实现思想是使用全局关联容器,该容器将指向主要对象的指针和指向包含冷数据的对象的指针映射。
inline static std::map<OutOfLine const*, std::unique_ptr<ColdData>> global_map_;
OutOfLine
可用于任何类型的冷数据,该冷数据的一个实例已创建并自动与主对象关联。
template <class... TArgs> explicit OutOfLine(TArgs&&... args) { global_map_[this] = std::make_unique<ColdData>(std::forward<TArgs>(args)...); }
删除主要对象需要自动删除相关的冷对象:
~OutOfLine() { global_map_.erase(this); }
当移动主对象(移动构造函数/移动赋值运算符)时,相应的冷对象将自动与新的主后继对象关联。 因此,您不应访问已移动对象的冷数据。
explicit OutOfLine(OutOfLine&& other) { *this = other; } OutOfLine& operator=(OutOfLine&& other) { global_map_[this] = std::move(global_map_[&other]); return *this; }
在上面的实现示例中,为简单起见,使OutOfLine不可复制。 如有必要,复制操作很容易添加;它们只需要创建并链接一个冷对象的副本。
OutOfLine(OutOfLine const&) = delete; OutOfLine& operator=(OutOfLine const&) = delete;
现在,要使它真正有用,可以访问冷数据将非常不错。 从OutOfLine
继承时OutOfLine
该类将接收cold()
的常量和非常量方法:
ColdData& cold() noexcept { return *global_map_[this]; } ColdData const& cold() const noexcept { return *global_map_[this]; }
它们返回对冷数据的适当类型的引用。
差不多了。 此UnlinkingFD
选项的大小为4个字节,提供对fd
字段的缓存友好访问,并保留RAII的优点。 与对象生命周期相关的所有工作都是完全自动化的。 当主要的常用对象移动时,很少使用的冷数据也随之移动。 当删除主要对象时,相应的冷对象也将被删除。
但是,有时候,您的数据会密谋使您的生活变得复杂-并且您面临着必须首先创建基本数据的情况。 例如,需要它们来构造冷数据。 需要以与OutOfLine
提供的顺序相反的顺序创建对象。 在这种情况下,“备份”对于我们控制初始化和取消初始化顺序很有用。
struct TwoPhaseInit {}; OutOfLine(TwoPhaseInit){} template <class... TArgs> void init_cold_data(TArgs&&... args) { global_map_.find(this)->second = std::make_unique<ColdData>(std::forward<TArgs>(args)...); } void release_cold_data() { global_map_[this].reset(); }
这是另一个可以在子类中使用的OutOfLine
构造函数;它接受类型TwoPhaseInit
的标记。 如果以这种方式创建OutOfLine
,则不会初始化冷数据,并且该对象将保持一半的构造。 要完成两阶段的构造,您需要调用init_cold_data
方法(向其中传递创建ColdData
类型的对象所需的参数)。 请记住,您不能在尚未初始化冷数据的对象上调用.cold()
。 以此类推,可以通过调用release_cold_data
在执行~OutOfLine
析构函数之前提前删除冷数据。
};
现在就全部了。 那么这29行代码给我们带来了什么? 它们是性能和易用性之间的另一个可能的折衷。 如果您有一个对象,其中某些成员的使用频率比其他对象高得多,则OutOfLine
可以用作优化缓存的一种易于使用的方法,但会大大降低对很少使用的数据的访问速度。
我们能够在多个地方应用这种技术-在罕见或意外的情况下,经常需要在工作结束时用额外的元数据来补充密集使用的工作数据。 无论是有关建立连接的用户的信息,来自发出订单的交易终端的信息,还是从事处理交换数据的硬件加速器的手柄的信息, OutOfLine
在您处于计算的关键部分(关键路径)时保持缓存的干净。
我准备了一个测试,以便您可以查看和评估差异。
剧本 | 时间(ns) |
---|
主对象中的冷数据(初始版本) | 34684547 |
完全删除冷数据(最佳情况) | 2938327 |
使用外线 | 2947645 |
使用OutOfLine
时,加速度约为OutOfLine
。 显然,该测试旨在证明OutOfLine
的潜力,但它也显示了多少缓存优化可以对性能产生重大影响,就像OutOfLine
允许OutOfLine
进行此优化一样。 使高速缓存中没有很少使用的数据,可以对其余代码进行复杂,可测量的全面改进。 与优化一样,信任度量比假设更重要,但是我希望OutOfLine
在您的实用程序集合中将被证明是有用的工具。
译者注
本文中提供的代码用于演示该想法,并不代表生产代码。