6

I'm interested to learn when I should start considering using move semantics in favour over copying data depending on the size of that data and the usage of the class. For example for a Matrix4 class we have two options:

struct Matrix4{
    float* data;

    Matrix4(){ data = new float[16]; }
    Matrix4(Matrix4&& other){
        *this = std::move(other);
    }
    Matrix4& operator=(Matrix4&& other)
    {
       ... removed for brevity ...
    }
    ~Matrix4(){ delete [] data; }

    ... other operators and class methods ...
};

struct Matrix4{
    float data[16]; // let the compiler do the magic

    Matrix4(){}
    Matrix4(const Matrix4& other){
        std::copy(other.data, other.data+16, data);
    }
    Matrix4& operator=(const Matrix4& other)
    {
       std::copy(other.data, other.data+16, data);
    }

    ... other operators and class methods ...
};

I believe there is some overhead having to alloc and dealloc memory "by hand", and given the chances of really hitting the move construct when using this class what is the preferred implementations of a class with such small in memory size? Is really always preferred move over copy?

4

3 回答 3

9

在第一种情况下,分配和释放是昂贵的——因为你是从堆中动态分配内存,即使你的矩阵是在堆栈上构建的——而且移动很便宜(只是复制一个指针)。

在第二种情况下,分配和释放很便宜,但移动很昂贵——因为它们实际上是副本。

因此,如果您正在编写一个应用程序并且您只关心该应用程序的性能,那么“哪个更好? ”这个问题的答案可能取决于您创建/销毁矩阵的数量与复制/移动它们的数量 -无论如何,做你自己的测量来支持任何猜想。

通过进行测量,您还将检查您的编译器是否在您希望进行移动的地方进行了大量的复制/移动省略 - 结果可能与您的预期相反。

此外,缓存局部性可能会在这里产生影响:如果您在堆上为矩阵的数据分配存储空间,那么在堆栈上创建三个要逐个元素处理的矩阵可能需要相当分散的内存访问模式 - 可能导致更多的缓存未命中。

另一方面,如果您使用在堆栈上为其分配内存的数组,则同一缓存行很可能能够保存所有这些矩阵的数据 - 从而提高缓存命中率。更不用说为了访问堆上的元素,您首先需要读取data指针的值,这意味着访问与保存元素的内存区域不同的内存区域。

所以再一次,这个故事的寓意是:做你自己的测量

另一方面,如果您正在编写一个库,并且您无法预测客户端将执行多少次构造/破坏与移动/复制,那么您可以提供两个这样的矩阵类,并将常见行为分解为基类- 可能是一个基类模板

这将为客户提供灵活性,并为您提供足够高的重用程度 - 无需将所有常见成员函数的实现编写两次。

这样,客户可以选择最适合他们正在使用的应用程序的创建/移动配置文件的矩阵类。


更新:

正如DeadMG在评论中指出的那样,与动态分配方法相比,基于数组的方法的一个优点是后者通过原始指针、new和进行手动资源管理delete,这迫使您编写用户定义的析构函数、复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符。

如果您使用 ,您可以避免所有这些std::vector,这将为您执行内存管理任务,并且可以让您免于定义所有这些特殊成员函数的负担。

这就是说,仅仅建议使用std::vector而不是进行手动内存管理这一事实 - 尽管它在设计和编程实践方面是一个很好的建议 - 并不能回答这个问题,而我相信原始答案可以。

于 2013-04-21T09:07:33.493 回答
2

就像编程中的其他一切一样,特别是在性能方面,这是一个复杂的权衡。

在这里,您有两种设计:将数据保存在类中(方法 1)或在堆上分配数据并在类中保存指向它的指针(方法 2)。

据我所知,这些是您正在做出的权衡:

  1. 构造/销毁速度:天真的实现,方法2在这里会慢一些,因为它需要动态的内存分配和释放。但是,您可以使用自定义内存分配器来帮助解决这种情况,特别是在您的数据大小是可预测和/或固定的情况下。

  2. 大小:在您的 4x4 矩阵示例中,方法 2 需要存储一个额外的指针,加上内存分配大小开销(通常可以是 4 到 32 字节之间的任何位置。)这可能是一个因素,也可能不是一个因素,但它肯定必须考虑,特别是如果您的类实例很小。

  3. 移动速度:方法 2 的移动操作非常快,因为它只需要设置两个指针。在方法 1 中,您别无选择,只能复制数据。然而,虽然能够依赖快速移动可以使您的代码漂亮、直接、可读和更高效,但编译器非常擅长复制省略,这意味着您甚至可以编写漂亮、直接和可读的按值传递接口如果您实现方法 1 并且编译器无论如何都不会生成太多副本。但是您不能确定这一点,因此依赖此编译器优化,特别是如果您的实例较大,则需要测量和检查生成的代码。

  4. 会员访问速度:在我看来,这是小班最重要的区别。每次访问使用方法 2 实现的矩阵中的元素(或访问以这种方式实现的类中的字段,即使用外部数据)时,您都会访问内存两次:一次读取外部内存块的地址,以及一次实际读取您想要的数据。在方法 1 中,您只需直接访问所需的字段或元素。这意味着在方法 2 中,每次访问都可能产生额外的缓存未命中,这可能会影响您的性能。如果您的类实例很小(例如 4x4 矩阵)并且您对存储在数组或向量中的许多实例进行操作,这一点尤其重要。

    事实上,这就是为什么您可能希望在复制/移动矩阵实例时实际复制字节,而不是仅仅设置一个指针:保持数据连续。这就是为什么扁平数据结构(如值数组)在高性能代码中比指针意大利面条数据结构(如指针数组、链表等)更受青睐。隔离,您有时想要复制您的实例以使(或保持)一大堆连续的实例,并使迭代和访问它们的效率更高。

  5. 长度/大小的灵活性:方法 2 在这方面显然更灵活,因为您可以决定在运行时需要多少数据,无论是 16 字节还是 16777216 字节。

总而言之,这是我建议您用于选择一种实现的算法:

  • 如果您需要可变数量的数据,请选择方法 2。
  • 如果您的类的每个实例中都有大量数据(例如几千字节),请选择方法 2。
  • 如果您需要大量复制您的类的实例(我的意思是很多!)选择方法 2(但尝试测量性能改进并检查生成的代码,特别是在热点区域。)
  • 在所有其他情况下,首选方法 1。

简而言之,方法 1 应该是您的默认设置,除非另有证明。证明任何有关性能的方法就是测量!因此,除非您已经测量并证明一种方法比另一种更好,否则不要优化任何东西,而且(如其他答案中所述)如果您正在编写库并让用户选择执行。

于 2013-04-21T09:46:33.110 回答
-1

我可能会使用已经实现移动语义的 stdlib 容器(例如 std::vector 或 std::array),然后我会简单地让向量或数组移动。

例如,您可以使用 std::array< std::array, 4 > 或 std::vector< std::vector< float > > 来表示您的矩阵类型。

我认为对于 4x4 矩阵来说这并不重要,但对于 10000x10000 可能会很重要。所以,是的,矩阵类型的移动构造函数绝对值得,特别是如果您打算使用大量临时矩阵(当您想用它们进行计算时似乎很可能)。它还将允许有效地返回 Matrix4 对象(而您必须使用 by-ref 调用来绕过复制)。

与此事无关,但可能值得一提:如果您决定使用 std::array,请将 Matrix 设为模板类(而不是将大小嵌入到类名中)。

于 2013-04-21T09:01:30.650 回答