JVM的“魔力”是否会阻碍程序员对Java的微优化的影响?我最近用C ++阅读,有时数据成员的排序可以提供优化(在微秒级环境中是允许的),并且我想程序员在压缩Java性能时会束手无策吗?
我很欣赏体面的算法可以提供更高的速度增益,但是一旦有了正确的算法,由于JVM控制,Java很难进行调整吗?
如果不是这样,人们能否举例说明您可以在Java中使用哪些技巧(除了简单的编译器标志)。
JVM的“魔力”是否会阻碍程序员对Java的微优化的影响?我最近用C ++阅读,有时数据成员的排序可以提供优化(在微秒级环境中是允许的),并且我想程序员在压缩Java性能时会束手无策吗?
我很欣赏体面的算法可以提供更高的速度增益,但是一旦有了正确的算法,由于JVM控制,Java很难进行调整吗?
如果不是这样,人们能否举例说明您可以在Java中使用哪些技巧(除了简单的编译器标志)。
Answers:
微观优化几乎从来都不值得花时间,几乎所有简单的优化都由编译器和运行时自动完成。
但是,存在一个重要的优化领域,C ++和Java根本不同,那就是大容量内存访问。C ++具有手动内存管理功能,这意味着您可以优化应用程序的数据布局和访问模式以充分利用缓存。这相当困难,在某种程度上是特定于您所运行的硬件的(因此,在不同的硬件上,性能提升可能会消失),但是,如果操作正确,可能会导致绝对惊人的性能。当然,您为它付出的代价是可能产生各种可怕的错误。
对于像Java这样的垃圾收集语言,无法在代码中完成这种优化。有些可以在运行时完成(自动或通过配置,请参见下文),而有些则是不可能的(保护您免受内存管理错误的代价)。
如果不是这样,人们能否举例说明您可以在Java中使用哪些技巧(除了简单的编译器标志)。
编译器标志在Java中是无关紧要的,因为Java编译器几乎不进行优化。运行时可以。
实际上,Java运行时具有许多可以调整的参数,尤其是有关垃圾收集器的参数。这些选项没有什么“简单”的-默认值对大多数应用程序都适用,而要获得更好的性能,则需要您准确了解这些选项的功能以及应用程序的行为。
[...(在微秒环境中,已授予)[...]
如果我们要遍历数百万到数十亿个事物,那么微秒加起来。来自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中似乎是不可能的(除非您只使用了原始字节数组或类似的东西),但是这两种情况很少见顺序访问和随机访问模式必须快速,同时要同时为热场混合使用场类型。对我来说,如果您要达到最佳性能,那么这两者之间在优化策略(一般级别)上的大部分差异都是没有意义的。
如果您只是为了达到“良好”的性能,则差异会大得多-不能像Integer
vs. 这样的小对象做更多的事情,int
可能会更多地使用PITA,尤其是它与泛型交互的方式。这是一个有点难以只是建立一个通用的数据结构中的Java核心优化的目标是作品int
,float
等,而避免那些大而昂贵的UDT,但往往是性能最关键的领域则需要手工编写自己的数据结构无论如何,它都是针对非常特定的目的进行调整的,因此,对于追求卓越性能而不是达到峰值性能的代码而言,这只是令人讨厌的事情。
对象开销
请注意,对于那些实际上很小的东西(例如int
vs。Integer
),Java对象的开销(元数据和空间局部性的损失以及临时性局部性的暂时损失)对于那些由数百万存储在某些数据结构中的东西来说通常很大。在很大程度上是连续的,并且在非常紧密的循环中进行访问。这个问题似乎有很多敏感性,因此我应该澄清一下,您不想担心像图像这样的大对象的对象开销,而只是担心像单个像素这样的微小对象。
如果有人对此部分感到怀疑,我建议您在总结一百万个随机数ints
与一百万个随机数之间进行Integers
重复测试(在Integers
初始GC周期后会在内存中重新排列),以此作为基准。
终极技巧:留有优化余地的界面设计
因此,如果您要处理一个在小对象上处理沉重负担的地方(例如,a Pixel
,一个4向量,一个4x4矩阵,一个a Particle
,甚至Account
只有几个小对象的话),这就是我所看到的Java的终极技巧。字段)是为了避免将对象用于这些细小的事情,并使用简单的旧数据数组(可能链接在一起)。然后对象成为集合接口等Image
,ParticleSystem
,Accounts
,矩阵或矢量等的集合个别可以通过索引来访问,例如这也是最终设计花样在C和C ++中的一个,因为即使没有这种基本对象的开销和脱节内存,在单个粒子级别上对接口建模会阻止最有效的解决方案。
user204677
走了。很好的答案。
一方面,在微优化与算法的良好选择之间存在中间区域。
这是恒定因子加速的区域,它可以产生几个数量级。
这样做的方法是切断执行时间的整个部分,例如先剩下30%,然后剩下20%,再剩下50%,依此类推,进行几次迭代,直到几乎没有剩余。
在小型演示程序中看不到这一点。在大型严肃的程序中,您会看到它所包含的类数据结构很多,其中调用堆栈通常是多层的。找到加速机会的好方法是检查程序状态的随机时间样本。
通常,加速包括以下内容:
new
通过合并和重用旧对象来最大程度地减少对的调用,
认识到所做的事情是出于普遍考虑而不是实际必要的,
通过使用具有相同big-O行为但利用实际使用的访问模式的不同收集类来修改数据结构,
保存通过函数调用获取的数据,而不是重新调用函数,(程序员认为具有较短名称的函数执行得更快是一种自然而有趣的趋势。)
容忍冗余数据结构之间一定程度的不一致,而不是试图使它们与通知事件完全一致,
等等等
但是,当然,在没有首先通过取样证明存在问题的情况下,这些事情都不应做。
Java(据我所知)无法控制内存中的变量位置,因此您很难避免虚假共享和变量对齐(可以用几个未使用的成员填充类)。我认为您不能利用的另一件事是诸如的指令mmpause
,但是这些东西是特定于CPU的,因此,如果您认为需要,则Java可能不是使用的语言。
存在一个Unsafe类,它为您提供C / C ++的灵活性,但也有C / C ++的危险。
它可以帮助您查看JVM为您的代码生成的汇编代码
要了解有关Java应用程序的详细信息,请参阅LMAX发布的Disruptor代码。
这个问题很难回答,因为它取决于语言的实现。
总的来说,这些天“微优化”的空间很小。主要原因是编译器在编译过程中利用了此类优化。例如,在前增加和后增加运算符的语义相同的情况下,它们之间没有性能差异。另一个示例是例如这样的循环for(int i=0; i<vec.size(); i++)
,在该循环中,可以争论而不是调用size()
成员函数在每次迭代过程中,最好在循环之前获取向量的大小,然后与该单个变量进行比较,从而避免每次迭代调用函数。但是,在某些情况下,编译器会检测到这种无聊的情况并缓存结果。但是,只有在函数没有副作用且编译器可以确保向量大小在循环期间保持恒定时,才有可能这样做,因此它仅适用于相当琐碎的情况。
const
在此向量上调用方法,我敢肯定很多优化的编译器都会弄清楚它。
有人可以举例说明您可以在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);
}
}
JVM可能并且经常干扰,并且JIT编译器可能在版本之间发生重大变化。由于语言限制,例如对超线程友好的或最新的Intel处理器的SIMD集合,Java中无法进行某些微优化。
建议阅读Disruptor作者中有关该主题的内容丰富的博客,其内容如下:
人们总是要问,如果要进行微优化,为什么还要费心使用Java,因此有许多替代方法可以加速功能,例如使用JNA或JNI传递给本机库。