与C / C ++相比,Java难于“调整”性能吗?[关闭]


11

JVM的“魔力”是否会阻碍程序员对Java的微优化的影响?我最近用C ++阅读,有时数据成员的排序可以提供优化(在微秒级环境中是允许的),并且我想程序员在压缩Java性能时会束手无策吗?

我很欣赏体面的算法可以提供更高的速度增益,但是一旦有了正确的算法,由于JVM控制,Java很难进行调整吗?

如果不是这样,人们能否举例说明您可以在Java中使用哪些技巧(除了简单的编译器标志)。


14
所有Java优化背后的基本原理是:JVM可能已经比您做得更好。优化主要涉及遵循明智的编程实践,并避免常见的事情,例如在循环中串联字符串。
罗伯特·哈维

3
所有语言的微优化原理是,编译器已经比您做得更好。所有语言中的微优化的另一个原理是,比起程序员在微优化上投入更多的硬件要便宜。程序员必须倾向于解决问题(次优算法),但是微优化是浪费时间。有时,微优化在不能在其上投入更多硬件的嵌入式系统上很有意义,但是使用Java的Android及其较差的实现表明大多数已经具有足够的硬件。
2012年

1
关于“ Java性能技巧”,值得研究的是:有效的JavaAngelika Langer链接 -Brian Goetz在Java理论和实践以及Threading Lightly 系列中与Java性能和性能相关的文章在这里
gnat 2012年

2
对于技巧和窍门要格外小心-JVM,操作系统和硬件在不断发展-您最好学习性能调整方法并为您的特定环境应用增强功能:-)
Martijn Verburg,2012年

在某些情况下,VM可以在运行时进行优化,而这在编译时是不切实际的。使用托管内存可以提高性能,尽管通常也会占用更多内存。方便时释放未使用的内存,而不是尽快释放。
Brian

Answers:


5

当然,与C和C ++相比,在微优化级别上,JVM将执行您几乎无法控制的某些操作。

另一方面,C和C ++的各种编译器行为尤其会对您以任何隐约可移植的方式(甚至跨编译器版本)进行微优化的能力产生更大的负面影响。

这取决于您要调整的项目类型,目标环境等。最后,这并不重要,因为无论如何,通过算法/数据结构/程序设计优化,您可以获得更好的数量级结果。


当您发现您的应用无法跨内核扩展时,这可能很重要
James

@james-想详细说明吗?
Telastyn


1
@James,跨内核扩展几乎与实现语言(Python除外!)有关,而与应用程序体系结构有关。
詹姆斯·安德森

29

微观优化几乎从来都不值得花时间,几乎所有简单的优化都由编译器和运行时自动完成。

但是,存在一个重要的优化领域,C ++和Java根本不同,那就是大容量内存访问。C ++具有手动内存管理功能,这意味着您可以优化应用程序的数据布局和访问模式以充分利用缓存。这相当困难,在某种程度上是特定于您所运行的硬件的(因此,在不同的硬件上,性能提升可能会消失),但是,如果操作正确,可能会导致绝对惊人的性能。当然,您为它付出的代价是可能产生各种可怕的错误。

对于像Java这样的垃圾收集语言,无法在代码中完成这种优化。有些可以在运行时完成(自动或通过配置,请参见下文),而有些则是不可能的(保护您免受内存管理错误的代价)。

如果不是这样,人们能否举例说明您可以在Java中使用哪些技巧(除了简单的编译器标志)。

编译器标志在Java中是无关紧要的,因为Java编译器几乎不进行优化。运行时可以。

实际上,Java运行时具有许多可以调整的参数,尤其是有关垃圾收集器的参数。这些选项没有什么“简单”的-默认值对大多数应用程序都适用,而要获得更好的性能,则需要您准确了解这些选项的功能以及应用程序的行为。


1
+1:基本上是我在回答中写的,也许是更好的表述。
克莱姆(Klaim)2012年

1
+1:非常好的要点,用一种非常简洁的方式解释:“这很难……但是如果做得正确,它可以带来绝对惊人的性能。 。”
Giorgio

1
@MartinBa:优化内存管理需要付出更多。如果您不尝试优化内存管理,那么C ++内存管理就没有那么困难(完全通过STL避免它,或者使用RAII使其相对容易)。当然,在C ++中实现RAII比在Java中不执行任何操作要花费更多的代码行(即,因为Java会为您处理代码)。
布莱恩

3
@Martin Ba:基本上是。悬空的指针,缓冲区溢出,未初始化的指针,指针算术中的错误,如果没有手动内存管理,所有这些都不存在。优化内存访问几乎需要您执行很多手动内存管理。
Michael Borgwardt

1
您可以在Java中做几件事。一种是对象池,它使对象的内存局部性最大化(与C ++可以保证内存局部性不同)。
RokL 2012年

5

[...(在微秒环境中,已授予)[...]

如果我们要遍历数百万到数十亿个事物,那么微秒加起来。来自C ++的个人vtune /微优化会话(无算法改进):

T-Rex (12.3 million facets):
Initial Time: 32.2372797 seconds
Multithreading: 7.4896073 seconds
4.9201039 seconds
4.6946372 seconds
3.261677 seconds
2.6988536 seconds
SIMD: 1.7831 seconds
4-valence patch optimization: 1.25007 seconds
0.978046 seconds
0.970057 seconds
0.911041 seconds

除了“多线程”,“ SIMD”(以手写方式击败编译器)和4价补丁优化之外,其他所有操作都是微级内存优化。同样,从32秒的初始时间开始的原始代码已经进行了相当多的优化(理论上最佳的算法复杂度),这是最近的一次会议。在此最近会话之前很长时间,原始版本花费了5分钟以上的时间来处理。

在单线程上下文中,优化内存效率通常可以提供几倍到几个数量级的帮助,而在多线程上下文中则可以提供更大的帮助(高效内存代表的好处通常是在多个线程中相乘)。

微观优化的重要性

这个想法让我有些激动,因为微优化是浪费时间。我同意这是很好的一般建议,但并非每个人都基于预感和迷信而不是根据测量来错误地这样做。正确完成后,不一定会产生微小的影响。如果我们采用英特尔自己的Embree(光线跟踪内核)并仅测试他们编写的简单标量BVH(而不是难以指数化的光线数据包),然后尝试击败该数据结构的性能,那可能是最甚至对于数十年来习惯于对代码进行性能分析和调优的老手来说,都是卑鄙的经历。这全是因为应用了微优化。当我看到工业专业人士从事射线追踪工作时,他们的解决方案可以每秒处理超过一亿条射线。

没有办法仅靠算法来直接实现BVH,并且无法与任何优化的编译器(甚至是英特尔自己的ICC)每秒获得超过一亿个主射线相交。一个直截了当的人甚至每秒甚至得不到一百万条射线。它需要专业质量的解决方案,通常每秒甚至可以接收几百万条射线。英特尔级别的微优化需要每秒超过一亿条射线。

演算法

我认为只要在几分钟到几秒钟(例如几小时到几分钟)的水平上性能不重要,微优化就不重要。如果我们采用像冒泡排序这样的可怕算法,然后将其用于大量输入,然后将其与甚至是合并排序的基本实现进行比较,则前者可能要花费几个月的时间,而后者可能需要12分钟二次线性复杂度。

数月与数分钟之间的差异可能会使大多数人,即使是那些不在性能关键领域工作的人,如果需要用户等待数月才能得出结果,则认为执行时间是不可接受的。

同时,如果我们将未进行微优化的直接合并排序与快速排序进行比较(这在算法上完全不优于合并排序,并且仅针对引用的位置提供了微观级别的改进),那么微优化的快速排序可能会在15秒而不是12分钟。让用户等待12分钟可能是完全可以接受的(喝咖啡休息时间)。

我认为这种差异对于大多数人来说可以忽略不计,例如在12分钟和15秒之间,这就是为什么微优化通常被视为无用的原因,因为它通常只像分钟和秒之间的差异,而不是分钟和几个月之间的差异。我认为它没有用的另一个原因是,它通常应用于无关紧要的区域:一些甚至不弯曲且不重要的小区域也会产生一些可疑的1%差异(这很可能只是噪音)。但是对于那些关心这些类型的时差并愿意测量并正确处理的人们来说,我认为至少值得关注存储器层次结构的基本概念(特别是与页面错误和缓存未命中有关的上层级) 。

Java为良好的微优化留有足够的空间

ew,对不起-除了那种咆哮:

JVM的“魔力”是否会阻碍程序员对Java的微优化的影响?

如果您做对了一点,那却有点像人们可能想到的那样。例如,如果您要进行图像处理,则使用具有手写SIMD,多线程和内存优化(访问模式甚至可能取决于图像处理算法的表示形式)的本机代码,很容易每秒处理数亿个像素(32-像素)位RGBA像素(8位颜色通道),有时甚至是每秒数十亿。

如果说要创建一个Pixel对象,那么在Java中不可能取得任何进展(仅此一项,就可以将像素大小从4字节增加到64位上的16)。

但是,如果您避免使用Pixel对象,使用字节数组并为Image对象建模,则可能会更加接近。如果您开始使用纯旧数据数组,那么Java在这里仍然很胜任。我以前在Java中尝试过这类事情,但前提是您不要在每个地方都比正常大小大4倍的地方创建一堆小的对象(例如:use int代替Integer),并开始对像Image接口,而不是Pixel接口。我什至敢说如果您遍历普通的旧数据而不是对象(float例如,很大的,则不是Float),则Java可以与C ++性能媲美。

比内存大小甚至更重要的是,int保证数组可以保证连续的表示。数组Integer不。连续性对于引用的位置通常是必不可少的,因为它意味着多个元素(例如16 ints)都可以全部放入一条高速缓存行中,并且可能在驱逐之前使用有效的内存访问模式一起访问。同时,单个Integer存储器可能被困在内存中的某个位置,而周围的存储器则无关紧要,只不过是将该区域的存储器加载到高速缓存行中仅是在逐出之前使用单个整数,而不是16个整数。即使我们非常幸运和周围Integers如果在内存中彼此相邻,那么我们只能将4放入缓存行中,因为缓存行要Integer大4倍,因此可以在逐出之前对其进行访问,这是在最佳情况下。

由于我们是在相同的内存架构/层次结构下统一的,因此这里有许多微优化。不管您使用哪种语言,内存访问模式都很重要,循环平铺/阻塞之类的概念通常在C或C ++中使用的频率更高,但是它们也同样有益于Java。

我最近用C ++阅读,有时数据成员的排序可以提供优化[...]

在Java中,数据成员的顺序通常无关紧要,但这基本上是一件好事。在C和C ++中,出于ABI的原因,保留数据成员的顺序通常很重要,因此编译器不会对此感到困惑。在那工作的人类开发人员必须小心地做一些事情,例如以降序排列(从大到小)的数据成员,以免浪费填充空间。使用Java,显然JIT可以即时为您重新排列成员的顺序,以确保正确的对齐方式同时最小化填充,因此,在这种情况下,它可以自动执行一般C和C ++程序员经常做得不好的事情,并最终浪费内存(这不仅浪费内存,而且经常通过不必要地增加AoS结构之间的跨度并导致更多的缓存丢失而浪费速度。它' 重新安排字段以最小化填充是非常机器人的事情,因此理想情况下,人类不会对此进行处理。唯一可能需要人类知道最佳排列方式的字段排列方式是,如果对象大于64字节,并且我们正在根据访问模式(而不是最佳填充)排列字段-在这种情况下,可能是一项更人性化的工作(需要了解关键路径,其中一些是编译器在不知道用户将如何使用该软件的情况下无法预期的信息)。

如果不是这样,人们能否举例说明您可以在Java中使用哪些技巧(除了简单的编译器标志)。

就优化Java和C ++的心态而言,对我而言,最大的不同是,在性能关键的情况下,C ++可以使您使用的对象比Java多(十几岁)。例如,C ++可以将一个整数包装到一个类上,而不会产生任何开销(在各处都有基准)。Java必须拥有每个对象的元数据指针样式+对齐填充开销,这Boolean比之大boolean(但是作为交换,它提供了统一的反射优势,并且能够覆盖未标记final为每个UDT的任何功能)。

在C ++中,控制非均匀字段之间的内存布局的连续性要容易一些(例如:通过结构/类将浮点和整数交织到一个数组中),因为空间局部性经常丢失(或者至少失去了控制)通过GC分配对象时使用Java。

...但是,性能最高的解决方案通常会以任何方式将其拆分,并在连续的纯旧数据阵列上使用SoA访问模式。因此,对于需要最高性能的领域,优化Java和C ++之间的内存布局的策略通常是相同的,并且经常让您拆除那些面向对象的小接口,转而使用可以执行诸如hot /非均匀的AoSoA代表在Java中似乎是不可能的(除非您只使用了原始字节数组或类似的东西),但是这两种情况很少见顺序访问和随机访问模式必须快速,同时要同时为热场混合使用场类型。对我来说,如果您要达到最佳性能,那么这两者之间在优化策略(一般级别)上的大部分差异都是没有意义的。

如果您只是为了达到“良好”的性能,则差异会大得多-不能像Integervs. 这样的小对象做更多的事情,int可能会更多地使用PITA,尤其是它与泛型交互的方式。这是一个有点难以只是建立一个通用的数据结构中的Java核心优化的目标是作品intfloat等,而避免那些大而昂贵的UDT,但往往是性能最关键的领域则需要手工编写自己的数据结构无论如何,它都是针对非常特定的目的进行调整的,因此,对于追求卓越性能而不是达到峰值性能的代码而言,这只是令人讨厌的事情。

对象开销

请注意,对于那些实际上很小的东西(例如intvs。Integer),Java对象的开销(元数据和空间局部性的损失以及临时性局部性的暂时损失)对于那些由数百万存储在某些数据结构中的东西来说通常很大。在很大程度上是连续的,并且在非常紧密的循环中进行访问。这个问题似乎有很多敏感性,因此我应该澄清一下,您不想担心像图像这样的大对象的对象开销,而只是担心像单个像素这样的微小对象。

如果有人对此部分感到怀疑,我建议您在总结一百万个随机数ints与一百万个随机数之间进行Integers重复测试(在Integers初始GC周期后会在内存中重新排列),以此作为基准。

终极技巧:留有优化余地的界面设计

因此,如果您要处理一个在小对象上处理沉重负担的地方(例如,a Pixel,一个4向量,一个4x4矩阵,一个a Particle,甚至Account只有几个小对象的话),这就是我所看到的Java的终极技巧。字段)是为了避免将对象用于这些细小的事情,并使用简单的旧数据数组(可能链接在一起)。然后对象成为集合接口等ImageParticleSystemAccounts,矩阵或矢量等的集合个别可以通过索引来访问,例如这也是最终设计花样在C和C ++中的一个,因为即使没有这种基本对象的开销和脱节内存,在单个粒子级别上对接口建模会阻止最有效的解决方案。


1
考虑到总体上的不良表现实际上可能会在关键区域有压倒性的峰值表现的几率,因此我认为没有人会完全忽略轻松拥有良好表现的优势。当将同时访问构成原始结构之一的所有(或几乎所有)值时,将结构数组转换为数组结构的技巧在某种程度上失效了。顺便说一句:我看到你发掘了很多古老的帖子,并添加了自己的好答案,有时甚至是好答案;-)
Deduplicator

1
@Deduplicator希望我不会因为撞得太多而烦人!这个有一点点小麻烦-也许我应该改善一下。对于我来说,SoA与AoS常常比较困难(顺序访问与随机访问)。我很少事先知道应该使用哪一个,因为在我的情况下,通常是顺序访问和随机访问混合在一起。我经常学到的有价值的教训是设计接口,以便为数据表示留出足够的空间-较大的接口,在可能的情况下具有较大的转换算法(有时在这里和那里随机访问的小块比特是不可能的)。

1
好吧,我只是注意到,因为事情真的很慢。我花时间陪伴每个人。
Deduplicator

我真的很好奇为什么user204677走了。很好的答案。
oligofren

3

一方面,在微优化与算法的良好选择之间存在中间区域。

这是恒定因子加速的区域,它可以产生几个数量级。
这样做的方法是切断执行时间的整个部分,例如先剩下30%,然后剩下20%,再剩下50%,依此类推,进行几次迭代,直到几乎没有剩余。

在小型演示程序中看不到这一点。在大型严肃的程序中,您会看到它所包含的类数据结构很多,其中调用堆栈通常是多层的。找到加速机会的好方法是检查程序状态的随机时间样本

通常,加速包括以下内容:

  • new通过合并和重用旧对象来最大程度地减少对的调用,

  • 认识到所做的事情是出于普遍考虑而不是实际必要的,

  • 通过使用具有相同big-O行为但利用实际使用的访问模式的不同收集类来修改数据结构,

  • 保存通过函数调用获取的数据,而不是重新调用函数,(程序员认为具有较短名称的函数执行得更快是一种自然而有趣的趋势。)

  • 容忍冗余数据结构之间一定程度的不一致,而不是试图使它们与通知事件完全一致,

  • 等等等

但是,当然,在没有首先通过取样证明存在问题的情况下,这些事情都不应做。


2

Java(据我所知)无法控制内存中的变量位置,因此您很难避免虚假共享和变量对齐(可以用几个未使用的成员填充类)。我认为您不能利用的另一件事是诸如的指令mmpause,但是这些东西是特定于CPU的,因此,如果您认为需要,则Java可能不是使用的语言。

存在一个Unsafe类,它为您提供C / C ++的灵活性,但也有C / C ++的危险。

它可以帮助您查看JVM为您的代码生成汇编代码

要了解有关Java应用程序的详细信息,请参阅LMAX发布的Disruptor代码。


2

这个问题很难回答,因为它取决于语言的实现。

总的来说,这些天“微优化”的空间很小。主要原因是编译器在编译过程中利用了此类优化。例如,在前增加和后增加运算符的语义相同的情况下,它们之间没有性能差异。另一个示例是例如这样的循环for(int i=0; i<vec.size(); i++),在该循环中,可以争论而不是调用size()成员函数在每次迭代过程中,最好在循环之前获取向量的大小,然后与该单个变量进行比较,从而避免每次迭代调用函数。但是,在某些情况下,编译器会检测到这种无聊的情况并缓存结果。但是,只有在函数没有副作用且编译器可以确保向量大小在循环期间保持恒定时,才有可能这样做,因此它仅适用于相当琐碎的情况。


至于第二种情况,我认为编译器无法在可预见的将来对其进行优化。检测到安全优化vec.size()取决于证明向量/丢失在循环内没有变化时的大小,由于死机问题,我认为这是不确定的。
Lie Ryan

@LieRyan我见过很多(简单)情况,如果结果被手动“缓存”并且调用了size(),则编译器会生成完全相同的二进制文件。我写了一些代码,结果发现行为高度依赖于程序的运行方式。在某些情况下,编译器可以保证在循环期间矢量大小不可能改变,然后在某些情况下它不能保证它的大小,这与您提到的停止问题非常相似。目前,我无法验证我的主张(C ++拆卸很
麻烦

2
@Lie Ryan:很多在一般情况下无法决定的事情对于特定但常见的情况都是完全可以决定的,而这实际上就是您所需要的。
Michael Borgwardt 2012年

@LieRyan如果仅const在此向量上调用方法,我敢肯定很多优化的编译器都会弄清楚它。
K.Steff

在C#中,我想我也在Java中阅读过,如果不缓存大小,编译器知道它可以删除检查以查看是否超出数组范围,并且如果要缓存大小,则必须进行检查,其成本通常比您通过缓存节省的成本要高。试图使优化器不胜一筹,这几乎不是一个好计划。
凯特·格雷戈里

1

有人可以举例说明您可以在Java中使用哪些技巧(除了简单的编译器标志)。

除了改进算法之外,请务必考虑内存层次结构以及处理器如何使用它。一旦了解了相关语言如何将内存分配给其数据类型和对象,就可以减少内存访问延迟,这将带来巨大的好处。

Java示例,用于访问1000x1000 int数组

考虑下面的示例代码-它访问相同的内存区域(一个1000x1000的int数组),但是顺序不同。在我的Mac mini(Core i7,2.7 GHz)上,输出如下所示,表明按行遍历数组的性能提高了一倍以上(平均每轮超过100轮)。

Processing columns by rows*** took 4 ms (avg)
Processing rows by columns*** took 10 ms (avg) 

这是因为存储数组时,连续的列(即int值)在内存中相邻放置,而连续的行则不相邻。为了使处理器实际使用数据,必须将其传输到其缓存中。内存的传输是通过一个字节块(称为高速缓存行)进行的 -直接从内存中加载高速缓存行会引入延迟,从而降低程序的性能。

对于Core i7(桑迪桥),高速缓存行包含64个字节,因此每个内存访问都检索64个字节。因为第一个测试以可预测的顺序访问内存,所以处理器将在程序实际消耗数据之前预取数据。总体而言,这可减少内存访问的延迟,从而提高性能。

样本代码:

  package test;

  import java.lang.*;

  public class PerfTest {
    public static void main(String[] args) {
      int[][] numbers = new int[1000][1000];
      long startTime;
      long stopTime;
      long elapsedAvg;
      int tries;
      int maxTries = 100;

      // process columns by rows 
      System.out.print("Processing columns by rows");
      for(tries = 0, elapsedAvg = 0; tries < maxTries; tries++) {
       startTime = System.currentTimeMillis();
       for(int r = 0; r < 1000; r++) {
         for(int c = 0; c < 1000; c++) {
           int v = numbers[r][c]; 
         }
       }
       stopTime = System.currentTimeMillis();
       elapsedAvg += ((stopTime - startTime) - elapsedAvg) / (tries + 1);
      }

      System.out.format("*** took %d ms (avg)\n", elapsedAvg);     

      // process rows by columns
      System.out.print("Processing rows by columns");
      for(tries = 0, elapsedAvg = 0; tries < maxTries; tries++) {
       startTime = System.currentTimeMillis();
       for(int c = 0; c < 1000; c++) {
         for(int r = 0; r < 1000; r++) {
           int v = numbers[r][c]; 
         }
       }
       stopTime = System.currentTimeMillis();
       elapsedAvg += ((stopTime - startTime) - elapsedAvg) / (tries + 1);
      }

      System.out.format("*** took %d ms (avg)\n", elapsedAvg);     
    }
  }

1

JVM可能并且经常干扰,并且JIT编译器可能在版本之间发生重大变化。由于语言限制,例如对超线程友好的或最新的Intel处理器的SIMD集合,Java中无法进行某些微优化。

建议阅读Disruptor作者中有关该主题的内容丰富的博客,其内容如下:

人们总是要问,如果要进行微优化,为什么还要费心使用Java,因此有许多替代方法可以加速功能,例如使用JNA或JNI传递给本机库。

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.