面向对象真的会影响算法性能吗?


14

面向对象已经帮助我实现了许多算法。但是,面向对象的语言有时会引导您采用“直接”方法,并且我怀疑这种方法是否总是一件好事。

OO在快速轻松地编码算法方面确实很有帮助。但是对于基于性能的软件(即程序执行速度有多快),此OOP会对它不利吗?

例如,首先将图节点存储在数据结构中似乎“直截了当”,但是如果Node对象包含许多属性和方法,这会导致算法变慢吗?

换句话说,在许多不同对象之间的许多引用,或者使用许多类中的许多方法,是否可能导致“繁重的”实现?


1
相当奇怪的问题。我可以理解OOP如何在体系结构级别上提供帮助。但是,算法的实现水平通常是建立在与OOP所代表的任何东西都非常不同的抽象之上的。因此,对于您的OOP算法实现而言,性能并不是最大的问题。至于性能,OOP的最大瓶颈通常与虚拟呼叫有关。
SK-logic

@ SK-logic>面向对象倾向于通过指针进行操作,这意味着在内存分配方面更重要的工作量,并且非本地化的数据往往不在CPU缓存中,并且最后但并非最不重要的是,这意味着很多间接的分支(虚拟功能),这对于CPU管道来说是致命的。OO是一件好事,但是在某些情况下它肯定会降低性能。
deadalnix

如果图中的节点具有一百个属性,则无论实际实现所使用的范式如何,都将需要放置它们的位置,并且我看不到任何单个范式在此方面通常具有优势。@deadalnix:也许由于增加某些优化的难度,常数因数可能会更糟。但是请注意,我要说的话要更难一些,但并非没有可能 -例如,PyPy可以在紧密循环中解包对象,而JVM一直以来一直内联虚拟函数调用。

Python非常适合用于原型算法,但是在其中实现典型算法时,您通常不需要类。
工作

1
+1对于将对象定向与算法相关联,
如今

Answers:


16

由于封装,面向对象可能会阻止某些算法优化。两种算法可能在一起工作特别好,但是如果将它们隐藏在OO接口后面,则将失去使用它们协同作用的可能性。

查看数值库。它们中的很多(不仅是60或70年代编写的那些)都不是OOP。这是有原因的-数值算法作为一组解耦的方法modules比作为具有接口和封装的OO层次结构更好。


2
这样做的主要原因是,只有C ++才想出使用表达式模板来使OO版本同样有效。
DeadMG

4
看一下现代的C ++库(STL,Boost)-它们也不是面向对象的。不仅是因为性能。通常,OOP风格无法很好地表示算法。通用编程之类的东西更适合于低级算法。
SK-logic

3
哇,什么?我想我来自与quant_dev和SK-logic不同的星球。不,另一个宇宙。具有不同的物理定律和一切。
Mike Nakis 2011年

5
@MikeNakis:观点的差异在于:(1)某种计算代码是否完全可以从OOP的人类可读性中受益(数字公式不是);(2)类OOP设计是否对准以最佳的数据结构和算法(见我的回答); (3)间接的每一层是否交付足够的“值”(就每个函数调用完成的工作量或每层的概念清晰度而言)证明了开销(由于间接,函数调用,层或数据复制所致)。(4)最后,编译器/ JIT /优化器的复杂性是限制因素。
rwong 2011年

2
@MikeNakis,你是什么意思?您认为STL是OOP库吗?无论如何,泛型编程与OOP配合并不好。不用说,OOP是一个过于狭窄的框架,仅适用于很少的实际任务,而其他任何事物都不适合。
SK-logic

9

是什么决定性能?

基础知识:数据结构,算法,计算机体系结构,硬件。加上开销。

可以将OOP程序设计为与CS理论认为最佳的数据结构和算法选择完全一致。它具有与最佳程序相同的性能特征,外加一些开销。通常可以将开销最小化。

但是,最初只考虑OOP而不考虑基础知识的程序可能最初不是最佳的。有时可以通过重构来去除次优性。有时不是-需要完全重写。

警告:性能对业务软件是否重要?

是的,但是上市时间(TTM)更重要,数量级高。商业软件强调代码对复杂商业规则的适应性。性能度量应在整个开发生命周期中进行。(请参阅部分:最佳性能是什么意思?)仅应进行适销对路的增强,并应在以后的版本中逐步引入。

最佳性能是什么意思?

通常,软件性能的问题在于:为了证明“存在一个更快的版本”,必须首先存在一个更快的版本(即除了自身以外没有其他证据)。

有时,首先会以其他语言或范例看到更快的版本。这应该被认为是改进的提示,而不是对某些其他语言或范例的自卑的判断。

如果可能会阻碍我们寻求最佳性能,我们为什么要进行OOP?

OOP引入了开销(空间和执行),以提高代码的“可操作性”并因此提高了代码的商业价值。这降低了进一步开发和优化的成本。参见@MikeNakis

OOP的哪些部分可能会鼓励最初的次优设计?

OOP的各个部分(i)鼓励简单/直观,(ii)使用口语化的设计方法而不是基础知识,(iii)阻止相同目的的多个定制实现。

  • 雅尼
  • 对象设计(例如,使用CRC卡),而没有对基本原理给予同等的考虑

严格地应用某些OOP准则(封装,消息传递,做好一件事情)确实会导致一开始的代码变慢。性能评估将有助于诊断这些问题。只要数据结构和算法与理论预测的最佳设计相符,通常可以将开销降至最低。

减轻OOP开销的常见方法是什么?

如前所述,使用最适合设计的数据结构。

某些语言支持代码内联,可以恢复某些运行时性能。

我们如何在不牺牲性能的情况下采用OOP?

学习并应用OOP和基础知识。

确实,严格遵守OOP可能会阻止您编写更快的版本。有时,只能从头开始编写更快的版本。这就是为什么它有助于使用不同的算法和范例(OOP,通用,功能,数学,意大利面条)编写多个版本的代码,然后使用优化工具使每个版本接近所观察到的最大性能的原因。

是否存在无法从OOP中受益的代码类型?

(从[@quant_dev],[@ SK-logic]和[@MikeNakis]之间的讨论扩展而来)

  1. 数值配方,源自数学。
    • 数学方程和变换本身可以理解为对象。
    • 需要非常复杂的代码转换技术来生成有效的可执行代码。天真的(“白板”)实现将具有糟糕的性能。
    • 但是,当今的主流编译器无法做到这一点。
    • 专用软件(MATLAB和Mathematica等)同时具有JIT和符号求解器,它们能够为某些子问题生成有效的代码。这些专业的求解器可以看作是专用的编译器(人类可读代码与机器可执行代码之间的中介),它们本身将从OOP设计中受益。
    • 每个子问题都需要自己的“编译器”和“代码转换”。因此,这是一个非常活跃的开放研究领域,每年都有新的成果出现。
    • 由于研究需要很长时间,因此软件编写者必须在纸上进行优化然后将优化后的代码转录为软件。转录的代码可能确实是难以理解的。
  2. 非常低级的代码。
      *

8

并不是真正的面向对象,而是容器。如果您使用双链表将像素存储在视频播放器中,则会受到影响。

但是,如果使用正确的容器,则没有理由std :: vector比数组还要慢,并且由于您已经为它编写了所有通用算法-专家们-它可能比您自己编写的数组代码要快。


1
因为编译器不是最优的(或者编程语言的规则禁止利用某些假设或优化),所以确实存在无法消除的开销。同样,某些优化(例如矢量化)具有数据组织要求(例如,阵列结构而不是结构阵列),OOP可能会增强或阻碍这些要求。(我最近刚从事一项std :: vector优化任务。)
rwong 2011年

5

OOP显然是一个好主意,就像任何好主意一样,它可能会被过度使用。以我的经验,它被过度使用了。性能差和可维护性差。

它与调用虚拟函数的开销无关,与优化器/抖动的作用无关。

它与数据结构有关,尽管数据结构具有最佳的big-O性能,但常数因子却很差。这是基于以下假设:如果应用程序中存在任何性能限制问题,那么问题就在其他地方。

这种体现的方式之一是每秒执行指令的次数,该次数假定具有O(1)性能,但可以执行数百到数千条指令(包括匹配的删除或GC时间)。可以通过保存使用过的对象来减轻这种情况,但这会使代码不太“干净”。

它体现的另一种方式是鼓励人们编写属性函数,通知处理程序,对基类函数的调用以及为维护一致性而存在的各种地下函数调用的方法。为了保持一致性,它们取得的成功有限,但是在浪费周期方面却取得了巨大的成功。程序员了解规范化数据的概念,但是他们倾向于仅将其应用于数据库设计。他们没有将其应用于数据结构设计,至少部分是因为OOP告诉他们不必这样做。设置对象中的Modified位会很简单,因为它会导致海量的更新遍历数据结构,因为没有任何值得其代码的类接受Modified调用并将其存储

也许给定应用程序的性能与编写的一样好。

另一方面,如果存在性能问题,这是我如何进行调优的示例。这是一个多阶段的过程。在每个阶段,某些特定活动占很大一部分时间,可以用更快的速度来代替。(我没有说“瓶颈”。这不是分析器擅长的类型。)为了加快速度,此过程通常需要批量替换数据结构。通常,仅因为推荐使用OOP练习才可以使用该数据结构。


3

从理论上讲,它可能会导致运行缓慢,但是即使那样,它也不是一个缓慢的算法,而是一个缓慢的实现。在实践中,面向对象将允许您尝试各种假设情况(或将来重新审视算法),从而提供算法上的改进,如果您最初是用意大利面条式的方法编写的,则您将永远无法实现的地方,因为任务将是艰巨的。(您基本上必须重写整个过程。)

例如,通过将各种任务和实体划分为干净的对象,您也许可以稍后轻松进入,例如,在某些对象之间(对它们透明)嵌入缓存工具,这可能会产生一千个倍数改善。

通常,通过使用低级语言(或使用高级语言的巧妙技巧)可以实现的改进类型会带来持续的(线性)时间改进,而改进的意义不大。通过算法改进,您可能能够实现非线性改进。那是无价的。


1
+1:意大利面条和面向对象的代码(或以明确定义的范式编写的代码)之间的区别是:重写好的代码的每个版本都会给问题带来新的理解。重写的每个版本的意大利面条都不会带来任何见识。
rwong

@rwong无法更好地解释;-)
umlcat 2011年

3

但是对于基于性能的软件(即程序执行速度有多快),此OOP会对它不利吗?

通常是!!!但...

换句话说,在许多不同对象之间的许多引用,或者使用许多类中的许多方法,是否可能导致“繁重的”实现?

不必要。这取决于语言/编译器。例如,一个优化的C ++编译器(如果您不使用虚函数)通常会将您的对象开销压缩为零。您可以执行一些操作,例如在int该处写一个包装器,或在一个普通的旧指针上编写一个作用域的智能指针,其执行速度与直接使用这些普通的旧数据类型一样快。

在其他语言(如Java)中,对象有一些开销(在很多情况下通常很小,但是在某些情况下带有真正的小对象却是天文数字)。例如,Integer效率远远低于int(占用16个字节,而不是64位上的4个字节)。然而,这不仅仅是公然的浪费或任何类似的东西。作为交换,Java提供了诸如统一地对每个用户定义类型进行反射的功能,以及覆盖未标记为的任何功能的能力final

但是,让我们以最理想的情况为例:优化的C ++编译器可以将对象接口优化到开销。即使这样,OOP仍会经常降低性能并阻止其达到顶峰。这听起来像是一个完全的悖论:怎么可能?问题在于:

接口设计与封装

问题在于,即使编译器可以将对象的结构压缩到开销(这对于优化C ++编译器来说至少通常是正确的),细粒度对象的封装和接口设计(以及累积的依赖项)通常会阻止旨在由群众聚集的对象的最佳数据表示(对于性能至关重要的软件通常是这种情况)。

举个例子:

class Particle
{
public:
    ...

private:
    double birth;                // 8 bytes
    float x;                     // 4 bytes
    float y;                     // 4 bytes
    float z;                     // 4 bytes
    /*padding*/                  // 4 bytes of padding
};
Particle particles[1000000];     // 1mil particles (~24 megs)

假设我们的内存访问模式是简单地依次遍历这些粒子,然后在每个帧中反复移动它们,将它们从屏幕的各个角落弹起,然后呈现结果。

我们已经看到,birth当粒子连续聚集时,正确对齐成员所需的4字节填充开销非常大。已经有约16.7%的内存浪费了用于对齐的死空间。

这似乎没有意义,因为这些天我们有千兆字节的DRAM。但是,即使涉及到当今最强大的计算机,当涉及到CPU缓存(L3)的最慢和最大区域时,通常也只有8 MB 。我们所能容纳的越少,就重复访问DRAM而言,我们为此付出的代价就越多,事情就越慢。突然,浪费16.7%的内存似乎不再是一件小事。

我们可以轻松消除这种开销,而不会影响字段对齐:

class Particle
{
public:
    ...

private:
    float x;                     // 4 bytes
    float y;                     // 4 bytes
    float z;                     // 4 bytes
};
Particle particles[1000000];     // 1mil particles (~12 megs)
double particle_birth[1000000];  // 1mil particle births (~8 bytes)

现在我们将内存从24兆减少到20兆。通过顺序访问模式,计算机现在将更快地消耗此数据。

但是,让我们birth更仔细地看一下这个领域。假设它记录了粒子出生(创建)的开始时间。想象一下,仅在首次创建粒子时才访问该字段,并且每10秒查看一次粒子是否应该死亡并在屏幕上的随机位置重生。在那种情况下,birth是一个寒冷的领域。在我们的性能关键循环中无法访问它。

结果,实际的关键性能数据不是20兆字节,而是12兆字节的连续块。我们经常访问的实际热内存已缩小到一半!期望比我们最初的24兆字节解决方案有显着的提速(不需要衡量-已经做过这种事一千次了,但是如有疑问,请放心)。

但是请注意我们在这里所做的。我们彻底破坏了这个粒子对象的封装。现在,其状态在Particle类型的专用字段和单独的并行数组之间划分。这就是颗粒状的面向对象设计的出路。

当局限于单个非常细粒度的对象(如单个粒子,单个像素,甚至单个4分量矢量,甚至可能是游戏中的单个“生物”对象)的界面设计时,我们无法表达最佳数据表示形式如果猎豹站在一个2平方米的小岛上,那将浪费它的速度,这就是非常精细的面向对象设计通常在性能方面所做的工作。它将数据表示限制为次优性质。

进一步说,假设我们只是在移动粒子,因此我们实际上可以在三个单独的循环中访问它们的x / y / z字段。在这种情况下,我们可以从具有AVX寄存器的SoA风格SIMD内在函数中受益,该寄存器可以并行向量化8个SPFP操作。但是要做到这一点,我们现在必须使用以下表示形式:

float particle_x[1000000];       // 1mil particle X positions (~4 megs)
float particle_y[1000000];       // 1mil particle Y positions (~4 megs)
float particle_z[1000000];       // 1mil particle Z positions (~4 megs)
double particle_birth[1000000];  // 1mil particle births (~8 bytes)

现在我们进行粒子模拟,但是看看粒子设计发生了什么。它已经完全拆除,我们现在正在研究4个并行数组,没有任何对象可以对其进行聚合。我们的面向对象Particle设计已经走了。

这在我对性能至关重要的领域中工作过很多次,在这些领域中,用户要求速度,而正确性是他们要求更高的一件事。这些小小的面向对象的设计必须拆除,并且级联的损坏通常要求我们使用缓慢的折旧策略来实现更快的设计。

上面的情况仅提出了面向对象的精细设计问题。在这些情况下,由于SoA代表,热/冷字段拆分,顺序访问模式的填充减少,我们常常最终不得不拆除结构以表示更有效的表示(填充有时对随机访问的性能有所帮助)在AoS情况下会出现这种模式,但几乎总是阻碍顺序访问模式),等等。

但是,我们可以采用我们所确定的最终表示形式,并仍然为面向对象的接口建模:

// Represents a collection of particles.
class ParticleSystem
{
public:
    ...

private:
    double particle_birth[1000000];  // 1mil particle births (~8 bytes)
    float particle_x[1000000];       // 1mil particle X positions (~4 megs)
    float particle_y[1000000];       // 1mil particle Y positions (~4 megs)
    float particle_z[1000000];       // 1mil particle Z positions (~4 megs)
};

现在我们很好。我们可以获得喜欢的所有面向对象的东西。猎豹在全国范围内尽其所能地奔跑。我们的界面设计不再使我们陷入瓶颈。

ParticleSystem甚至可能是抽象的并且使用虚函数。现在要讨论了,我们要在粒子收集级别而不是每个粒子级别支付开销。如果我们在单个粒子级别对对象进行建模,则开销是其他情况的1 / 1,000,000。

所以这是在处理大负荷真正的性能关键领域的解决方案,并为所有类型的编程语言(这种技术的好处C,C ++,Python和Java的,JavaScript的,Lua中,斯威夫特等)。而且它不容易被标记为“过早优化”,因为这与界面设计体系结构有关。我们无法编写将单个粒子建模为对象的代码库,并且将大量客户依赖项Particle's公共接口,然后稍后再改变主意。当被调用来优化遗留代码库时,我已经做了很多事情,最终可能需要花费数月的时间仔细重写成千上万行代码才能使用更大的设计。理想情况下,这可以影响我们预先设计事物的方式,前提是我们可以预料到很重的负载。

在许多性能问题中,尤其是与面向对象设计有关的问题,我一直以某种形式或其他形式回应。面向对象的设计仍然可以与最高需求的性能需求兼容,但是我们必须稍微改变一下思考方式。我们必须给猎豹提供一定的运行空间,以使其尽可能快地运行,而如果我们设计几乎不存储任何状态的小物件,这通常是不可能的。


太棒了 这是我在将OOP与高性能需求相结合方面的实际需求。我真的不明白为什么它没有得到更多支持。
pbx

2

是的,在算法和实现级别的高性能编程方面,面向对象的观念绝对可以是中立的或消极的。如果OOP取代了算法分析,则可能会导致您过早实现,并且必须在最低层次上将OOP抽象放在一边。

这个问题源于OOP对单个实例的重视。我认为可以说,OOP思考算法的方法是思考一组特定的值并以这种方式实现。如果这是您的最高途径,那么您不太可能实现会带来O大收益的转型或重组。

在算法级别,通常要考虑更大的图景以及导致大O增益的值之间的约束或关系。一个例子可能是,OOP心态中没有什么会导致您将“求和一个连续范围的整数”从循环转换为(max + min) * n/2

在实现级别上,尽管计算机对于大多数应用程序级算法来说“足够快”,但是在低级性能关键代码中,人们会非常担心局部性。同样,OOP强调考虑单个实例,并且一个实例通过循环的值可能是负面的。在高性能代码中,您可能不希望编写一个简单的循环,而是要部分展开循环,在顶部将几个加载指令分组,然后将它们转换为一组,然后将它们写入一组。一直以来,您都将注意力放在中间计算上,并且非常注意缓存和内存访问;OOP抽象不再有效的问题。而且,如果遵循的话,可能会产生误导作用:在这个级别上,您必须了解并考虑机器级别的表示形式。

当您看诸如英特尔的性能基元之类的东西时,您实际上有成千上万个快速傅立叶变换的实现,每个都经过了调整,以针对特定的数据大小和机器架构更好地工作。(令人着迷的是,事实证明,这些实现的大部分是机器生成的:MarkusPüschel自动性能编程

当然,正如大多数答案所言,对于大多数开发而言,对于大多数算法而言,OOP与性能无关。只要您不“过早地悲观”并添加许多非本地调用,this指针就不在这里或那里。


0

它与之相关,并且经常被忽视。

这不是一个简单的答案,取决于您要做什么。

有些算法在使用普通结构化编程时性能更好,而其他算法在使用面向对象时则更好。

在面向对象之前,许多学校都通过结构化编程来教(编)算法设计。如今,许多学校都在教授面向对象的程序设计,而忽略了算法的设计和性能。

当然,那里的学校教授结构化编程,根本不关心算法。


0

最后,所有性能都取决于CPU和内存周期。但是,OOP消息传递和封装的开销与更广泛的开放式编程语义之间的百分比差异可能或可能不足够大,无法在您的应用程序性能上产生显着差异。如果应用程序受磁盘或数据高速缓存未命中限制,则任何OOP开销都可能完全被噪声所抵消。

但是,在实时信号和图像处理以及其他数值计算绑定应用程序的内部循环中,差异很可能是CPU和内存周期的显着百分比,这会使执行任何OOP开销的成本更高。

特定OOP语言的语义可能会或可能不会为编译器提供足够的机会来优化这些循环,或者使CPU的分支预测电路始终正确猜测并用预取和流水线覆盖这些循环。


0

一个好的面向对象的设计帮助我大大加快了应用程序的速度。必须以算法方式生成复杂的图形。我是通过Microsoft Visio自动化完成的。我工作了,但是速度非常慢。幸运的是,我在逻辑(算法)和Visio东西之间插入了额外的抽象层次。我的Visio组件确实通过接口公开了其功能。这使我可以轻松地将慢速组件替换为另一个创建的SVG文件,速度至少快50倍!如果没有干净的面向对象方法,算法和Vision控件的代码将以某种方式纠缠在一起,这将使更改变成一场噩梦。


您是说OO设计应用了过程语言,还是OO设计和OO编程语言?
umlcat 2011年

我说的是C#应用程序。设计和语言都是面向对象的,因为该语言的面向对象特性会带来一些小的性能影响(虚拟方法调用,对象创建,通过接口的成员访问),所以面向对象设计帮助我创建了一个更快的应用程序。我要说的是:忘记由于OO(语言和设计)而导致的性能下降。除非您要进行数百万次迭代的大量计算,否则OO不会损害您。I / O通常会浪费很多时间。
Olivier Jacot-Descombes
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.