面向对象已经帮助我实现了许多算法。但是,面向对象的语言有时会引导您采用“直接”方法,并且我怀疑这种方法是否总是一件好事。
OO在快速轻松地编码算法方面确实很有帮助。但是对于基于性能的软件(即程序执行速度有多快),此OOP会对它不利吗?
例如,首先将图节点存储在数据结构中似乎“直截了当”,但是如果Node对象包含许多属性和方法,这会导致算法变慢吗?
换句话说,在许多不同对象之间的许多引用,或者使用许多类中的许多方法,是否可能导致“繁重的”实现?
面向对象已经帮助我实现了许多算法。但是,面向对象的语言有时会引导您采用“直接”方法,并且我怀疑这种方法是否总是一件好事。
OO在快速轻松地编码算法方面确实很有帮助。但是对于基于性能的软件(即程序执行速度有多快),此OOP会对它不利吗?
例如,首先将图节点存储在数据结构中似乎“直截了当”,但是如果Node对象包含许多属性和方法,这会导致算法变慢吗?
换句话说,在许多不同对象之间的许多引用,或者使用许多类中的许多方法,是否可能导致“繁重的”实现?
Answers:
由于封装,面向对象可能会阻止某些算法优化。两种算法可能在一起工作特别好,但是如果将它们隐藏在OO接口后面,则将失去使用它们协同作用的可能性。
查看数值库。它们中的很多(不仅是60或70年代编写的那些)都不是OOP。这是有原因的-数值算法作为一组解耦的方法modules
比作为具有接口和封装的OO层次结构更好。
基础知识:数据结构,算法,计算机体系结构,硬件。加上开销。
可以将OOP程序设计为与CS理论认为最佳的数据结构和算法选择完全一致。它具有与最佳程序相同的性能特征,外加一些开销。通常可以将开销最小化。
但是,最初只考虑OOP而不考虑基础知识的程序可能最初不是最佳的。有时可以通过重构来去除次优性。有时不是-需要完全重写。
是的,但是上市时间(TTM)更重要,数量级高。商业软件强调代码对复杂商业规则的适应性。性能度量应在整个开发生命周期中进行。(请参阅部分:最佳性能是什么意思?)仅应进行适销对路的增强,并应在以后的版本中逐步引入。
通常,软件性能的问题在于:为了证明“存在一个更快的版本”,必须首先存在一个更快的版本(即除了自身以外没有其他证据)。
有时,首先会以其他语言或范例看到更快的版本。这应该被认为是改进的提示,而不是对某些其他语言或范例的自卑的判断。
OOP引入了开销(空间和执行),以提高代码的“可操作性”并因此提高了代码的商业价值。这降低了进一步开发和优化的成本。参见@MikeNakis。
OOP的各个部分(i)鼓励简单/直观,(ii)使用口语化的设计方法而不是基础知识,(iii)阻止相同目的的多个定制实现。
严格地应用某些OOP准则(封装,消息传递,做好一件事情)确实会导致一开始的代码变慢。性能评估将有助于诊断这些问题。只要数据结构和算法与理论预测的最佳设计相符,通常可以将开销降至最低。
如前所述,使用最适合设计的数据结构。
某些语言支持代码内联,可以恢复某些运行时性能。
学习并应用OOP和基础知识。
确实,严格遵守OOP可能会阻止您编写更快的版本。有时,只能从头开始编写更快的版本。这就是为什么它有助于使用不同的算法和范例(OOP,通用,功能,数学,意大利面条)编写多个版本的代码,然后使用优化工具使每个版本接近所观察到的最大性能的原因。
(从[@quant_dev],[@ SK-logic]和[@MikeNakis]之间的讨论扩展而来)
并不是真正的面向对象,而是容器。如果您使用双链表将像素存储在视频播放器中,则会受到影响。
但是,如果使用正确的容器,则没有理由std :: vector比数组还要慢,并且由于您已经为它编写了所有通用算法-专家们-它可能比您自己编写的数组代码要快。
OOP显然是一个好主意,就像任何好主意一样,它可能会被过度使用。以我的经验,它被过度使用了。性能差和可维护性差。
它与调用虚拟函数的开销无关,与优化器/抖动的作用无关。
它与数据结构有关,尽管数据结构具有最佳的big-O性能,但常数因子却很差。这是基于以下假设:如果应用程序中存在任何性能限制问题,那么问题就在其他地方。
这种体现的方式之一是每秒执行新指令的次数,该次数假定具有O(1)性能,但可以执行数百到数千条指令(包括匹配的删除或GC时间)。可以通过保存使用过的对象来减轻这种情况,但这会使代码不太“干净”。
它体现的另一种方式是鼓励人们编写属性函数,通知处理程序,对基类函数的调用以及为维护一致性而存在的各种地下函数调用的方法。为了保持一致性,它们取得的成功有限,但是在浪费周期方面却取得了巨大的成功。程序员了解规范化数据的概念,但是他们倾向于仅将其应用于数据库设计。他们没有将其应用于数据结构设计,至少部分是因为OOP告诉他们不必这样做。设置对象中的Modified位会很简单,因为它会导致海量的更新遍历数据结构,因为没有任何值得其代码的类接受Modified调用并将其存储。
也许给定应用程序的性能与编写的一样好。
另一方面,如果存在性能问题,这是我如何进行调优的示例。这是一个多阶段的过程。在每个阶段,某些特定活动占很大一部分时间,可以用更快的速度来代替。(我没有说“瓶颈”。这不是分析器擅长的类型。)为了加快速度,此过程通常需要批量替换数据结构。通常,仅因为推荐使用OOP练习才可以使用该数据结构。
从理论上讲,它可能会导致运行缓慢,但是即使那样,它也不是一个缓慢的算法,而是一个缓慢的实现。在实践中,面向对象将允许您尝试各种假设情况(或将来重新审视算法),从而提供算法上的改进,如果您最初是用意大利面条式的方法编写的,则您将永远无法实现的地方,因为任务将是艰巨的。(您基本上必须重写整个过程。)
例如,通过将各种任务和实体划分为干净的对象,您也许可以稍后轻松进入,例如,在某些对象之间(对它们透明)嵌入缓存工具,这可能会产生一千个倍数改善。
通常,通过使用低级语言(或使用高级语言的巧妙技巧)可以实现的改进类型会带来持续的(线性)时间改进,而改进的意义不大。通过算法改进,您可能能够实现非线性改进。那是无价的。
但是对于基于性能的软件(即程序执行速度有多快),此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取代了算法分析,则可能会导致您过早实现,并且必须在最低层次上将OOP抽象放在一边。
这个问题源于OOP对单个实例的重视。我认为可以说,OOP思考算法的方法是思考一组特定的值并以这种方式实现。如果这是您的最高途径,那么您不太可能实现会带来O大收益的转型或重组。
在算法级别,通常要考虑更大的图景以及导致大O增益的值之间的约束或关系。一个例子可能是,OOP心态中没有什么会导致您将“求和一个连续范围的整数”从循环转换为(max + min) * n/2
在实现级别上,尽管计算机对于大多数应用程序级算法来说“足够快”,但是在低级性能关键代码中,人们会非常担心局部性。同样,OOP强调考虑单个实例,并且一个实例通过循环的值可能是负面的。在高性能代码中,您可能不希望编写一个简单的循环,而是要部分展开循环,在顶部将几个加载指令分组,然后将它们转换为一组,然后将它们写入一组。一直以来,您都将注意力放在中间计算上,并且非常注意缓存和内存访问;OOP抽象不再有效的问题。而且,如果遵循的话,可能会产生误导作用:在这个级别上,您必须了解并考虑机器级别的表示形式。
当您看诸如英特尔的性能基元之类的东西时,您实际上有成千上万个快速傅立叶变换的实现,每个都经过了调整,以针对特定的数据大小和机器架构更好地工作。(令人着迷的是,事实证明,这些实现的大部分是机器生成的:MarkusPüschel自动性能编程)
当然,正如大多数答案所言,对于大多数开发而言,对于大多数算法而言,OOP与性能无关。只要您不“过早地悲观”并添加许多非本地调用,this
指针就不在这里或那里。
一个好的面向对象的设计帮助我大大加快了应用程序的速度。必须以算法方式生成复杂的图形。我是通过Microsoft Visio自动化完成的。我工作了,但是速度非常慢。幸运的是,我在逻辑(算法)和Visio东西之间插入了额外的抽象层次。我的Visio组件确实通过接口公开了其功能。这使我可以轻松地将慢速组件替换为另一个创建的SVG文件,速度至少快50倍!如果没有干净的面向对象方法,算法和Vision控件的代码将以某种方式纠缠在一起,这将使更改变成一场噩梦。