编译器将优化它

2021-05-05 20:43:17

很多程序员都认为编译器是魔法黑匣子,您可以在其中普通杂乱的代码,并获得一个很好的优化二进制。走廊哲学家频道经常开始争论哪个语言功能或编译器标志使用的命令来捕获编译器的魔术的全部力量。如果您见过GCC码库,您真的相信它必须是来自另一个星球的工人魔法优化。

尽管如此,如果您分析编译器的输出,您将发现编译器并不是在优化代码时非常重要。不是因为编写它们的人不会知道如何生成有效的指示,而是因为编译器只能推理一个非常小的问题空间1。

为了理解为什么魔术编译器优化不会加快速度的软件,我们必须回到过去的恐龙漫游地球和处理器仍然非常慢。下图显示了整个年份(1980-2010)的一个相对处理者和内存性能改进,从2中获取:

这张图片代表的问题是多年来,CPU经过大多数的CPU(Y轴处于对数刻度),而内存性能以较慢的速度增加:

所以呢?我们在从内存加载某些东西时花了几个周期,但计算机仍然比以前更快的方式。谁关心多少次度过的花费?

那么,对于一个很悲哀地知道,即使我们得到更好的编译器或更快的硬件,我们的软件的速度不会显着提高,sincethey并不是让我们的软件速度慢的原因。今天的主要问题是利用CPU到他们的全部潜力。

下表显示了常见操作的延迟编号,从3中获取。缩放延迟列表示easierto对人类的数字的延迟。

在查看缩放延迟列时,我们可以快速弄清楚那个Accessing Memory不是免费的,对于绝大多数ApplicationSthe CPU在等待内存到达时,CPU正在旋转它的拇指4.这是两倍的原因:

我们仍在使用今天的编程语言是在时间的时间内完成,而处理器缓慢,内存访问延迟并不大大问题。

行业最佳实践仍在围绕面向对象的编程,在现代硬件上表现不佳。

上面列出的编程语言全部超过20岁及其初始设计决策,如Python的全局翻译锁定或Java的一切都是一个对象的心态,不再发出任何意义。硬件在添加CPU缓存和添加CPU缓存时更改了。多个核心,而编程语言基于不再是真实的想法竞争。

大多数现代编程语言都试图让您的生活更轻松地脱离手动内存管理的悲伤。虽然不必思考记忆将使您更加富有成效,但以沉重的价格提供生产力。

在没有监督的情况下分配内存的块是慢慢的,大部分是由于随机内存访问(缓存未命中),这将花费几百个CPU周期。尽管如此,上面列出的大多数编程语言仍然像随机内存放置一样,你不应该担心它,因为电脑太快了。

是的,计算机非常快,但只有当您以Waythat写下您的软件时,只有您的硬件播放。在相同的硬件上,您可以体验蝴蝶3D游戏和明显滞后的MS Word。显然,硬件不是问题,我们可以比平均应用程序更多地挤出更多。

目前编程语言的另一个问题是它们不超过C型编程类型。不是写入循环写作的任何东西,但它越来越难以利用CPU来充分潜力,同时仍然管理现代人的复杂性。

在某种程度上,他们缺乏允许您对人类方便的方式进行编程的功能,同时仍然利用HardWareto来实现最佳性能。虽然我们今天非常方便的抽象方便,但它们往往不会在现代硬件上表现良好。

这将是一个很长的蜿蜒解释,但让我们从一个例子开始:

想象一下,我们正在模拟蚂蚁殖民地。殖民地的行政管理部门在最新的食蚁兽攻击中被摧毁,因此他们不再了解了许多争吵蚂蚁仍然生活在殖民地。

以下是大多数程序员(包括我自己)的示例是如何编写解决此问题的代码。它是用典型的对象导向的企业缓存Trasher方式,原谅我的Java:

All {公共字符串名称="未知用品" ;公共字符串颜色="红色" ;公共布尔isWarrior = false;公共int年龄= 0; } // shh,它' s一个微小的蚁群名单<蚂蚁> Antcolony =新的ArrayList<(100); //用蚂蚁填充菌落//计算战士蚂蚁长号numofwarriors = 0; for(蚂蚁蚂蚁:antcolony){if(蚂蚁。iswarrior){numofwarriors ++; }}

上述解决方案短且易于理解。不幸的是,它并不是在现代硬件上的表现。

每次请求尚未存在于其中一个CPU高速缓存中的内存字节时,即使只需要1个字节,也从主内存中获取整个高速缓存行。从主内存中获取数据非常昂贵(请参阅上面的延迟表),我们宁愿保留尽可能小的内存量。这可以通过两种方式实现:

通过将必要的数据保持在连续块中,以便充分利用TheCache行。

对于上面的例子,我们可以推理有关福切斯的数据(我假设正在使用压缩糟糕,请纠正我,如果我错了):

+ 4个字节的名称参考+ 4个字节的颜色引用+ 1个字节为Warrior Flag + 3个字节用于填充+ 4个字节的年龄整数+ 8个字节为类标题------------- ---------------------24字节是每个蚂蚁

我们必须多次触摸主内存,以计算所有战士(假设蚁群数据尚未在缓存中加载)?

如果我们考虑在现代CPU上,缓存行大小是64个字节,这意味着我们可以在每个缓存行中最多2.6个蚂蚁实例获取。自从该examps中写入Java以来​​,其中一般的一个物体在堆中的某个地方生活,我们知道Ant实例可以在不同的高速缓存行6上实现。

在最糟糕的情况下,ant实例未在另一个陆续分配,并且我们只能从每个缓存行中获取一个实例。对于易于触摸主内存的整个蚁群100次,并且对于每个缓存行(64字节),我们仅使用1字节。换句话说,我们扔掉了98%的Fetcheddata。这是计算我们蚂蚁的一种非常低效的方法。

我们可以以更谨慎的方式重写我们的程序,从而提高我们程序的性能吗?我们可以。

我们将使用最幼稚的数据riendedaproach。而不是一个接一个地建模蚂蚁,我们立即模拟整个殖民地:

类antcolony {public int size = 0;公共字符串[]名称=新字符串[100]; public string []颜色=新字符串[100];公共int []年龄= new int [100]; Public Boolean [] Warriors = New Boolean [100]; //我知道这个阵列可以删除//通过将殖民区分成两个(勇士,非战士),//而不是这个故事的重点。 // //是的,您还可以对其进行排序,并在额外的//由于分支预测而享受。 antcolony antcolony_do = new antcolony(); //用蚂蚁填充殖民地,更新尺寸计数器//计算战士蚂蚁长numofwarriors = 0; for(int i = 0; i< antcolony_do。size; i ++){boolean iswarrior = antcolony_do。勇士[I]; if(isswarrior){numofwarriors ++; }}

两个呈现的示例是算法等效的(O(n)),但是数据面向解决方案优于面向对象的情况。为什么?

这是因为数据导向示例取消了更少的数据,并且数据被获取了中间的块 - 我们立即获取64个战士标志,没有浪费.Since我们只获取我们需要的数据,我们只需要触摸主内存每一个蚂蚁一次(100次)。这是一种更有效地计算我们的战士蚂蚁的方法。

我用Java Microbenchmark Marness(JMH)工具包进行了一些性能基准,结果如下表所示(在英特尔I7-7700HQ @ 3.80GHz上测量)。我忽略了置信区间以保持表格清洁,但是你可以通过下载和运行基准代码来运行自己的基准。

通过改变我们看待问题的方式,我们能够获得巨额救护。请记住,在我们的示例中,蚂蚁类是相对体积的,因此数据很可能留在其中一个CPU缓存和两个方法之间的差异不像夸张(根据延迟表,从缓存中访问存储器IS30- 100倍更快)。

尽管上面的示例是在自由敏锐的一切中写入的一切,但数据导向的设计并没有禁止您使用Iterprise控制围栏方法。您仍然可以使每个字段私有和最终,提供Getters和Setter,如果您真的Intothat,请执行一些接口。

您甚至可能意识到所有企业Cruft并不是必要的,谁知道?但是这个例子中最重要的外卖器是通过一种时尚举行一个时尚,而不是作为一个群体思考的蚂蚁。毕竟,你只有一只蚂蚁见过什么?

可是等等!为什么一个面向对象的方法如此流行,如果它表现得如此糟糕?

大多数语言都支持这种类型的编程,并且很容易将您的心态包裹在对象的概念。

大多数企业软件的性能要求是令人尴尬的低ANDANY OL'代码会。这也被称为“但是,顾客不会追逐”综合征。

行业中的想法正在缓慢地移动,软件邪教者拒绝改变。仅仅20年前的内存延迟不是一个大问题,并且编程最佳实践并未陷入硬件的变化。

问题:假设我们想了解超过1岁的红蚂蚁,生活在殖民地的1岁。与面向对象的方法,这很简单:

长号numofchosenants = 0; for(蚂蚁蚂蚁:antcolony){if(蚂蚁年龄> 1&"红色"。等于(蚂蚁。颜色)){numofchosenators ++; }}

通过面向数据的方法,它会稍微烦人,因为我们必须小心我们迭代我们的阵列(我们必须使用位于同一索引的元素):

长号numofchosenants = 0; for(int i = 0; i< antcolony。尺寸; i ++){int年龄= antcolony。年龄[i];字符串颜色= antcolony。颜色[i]; if(年龄> 1&&"红色"。等于(颜色)){numofchosenants ++; }}

现在现在想象有人想根据他们的命名方式对殖民地中的所有蚂蚁分类,然后用排序数据做点什么(例如,计算排序数据的前10%的所有红色蚂蚁。蚂蚁可能具有这样的奇怪业务规则,不要判断它们)。在面向对象的方式中,您只需使用标准库的排序函数。在面向数据的方式中,您必须对一个名称数组进行排序,但在Sametime也基于所有其他阵列关于名称阵列的指标如何移动(假设您关注哪种颜色,年龄和战士标志与ANT的名称一起使用)7。

这正是通过我们的编程语言的特性解决的问题的类型,因为写出这些自定义排序功能非常繁琐。如果编程语言向我们提供的ASTructure,那么像一个结构阵列,但内部它会真的表现得像阵列的结构?我们可以以典型的方式编程方便的人类,同时仍然享受着良好的性能,因为它的硬件很好。

到目前为止,我知道这个支持这种类型的杂交数据转换的唯一编程语言是jai,但遗憾的是它仍然是封闭的beta和未使用的普通公众使用。为什么在地球上是这种功能,在我们的“现代”编程语言中烘焙了一些事情要思考。

如果您曾进入企业商店并在其Codebase中窥探,您最有可能看到的是一堆巨大的课程,其中众多成员字段和遍在场洒在上面。大多数软件仍然是以这种方式编写的,因为由于过去的影响,将你的思想围绕这种类型的编程方式相对容易.LARGE CodeBases也是一个巨大的糖蜜8,那么巨大的糖蜜8,那么那样正在倾向于倾向于倾向于熟悉的样式,他们每天都看到。

改变关于如何接近问题的想法通常需要更多的努力,而不是占据Parroting:“使用const和编译器将优化它!”没有人知道编译器会做什么,如果没有人检查编译器所做的内容。

我写这篇文章的原因是因为很多程序员都认为你是一个天才或知道artacane编译器技巧,以便编写执行的代码。关于系统中流动的数据的简单推理将让您带来您的系统大多数部分9.在一个复杂的计划中,这并不总是容易,但是那么任何人都可以学习的东西,如果他们愿意花一些时间来修补这些概念。

如果您想了解有关此主题的更多信息,请务必阅读“有关数据导向的设计书”,并在“注释”部分中列出的链接。

[额外]一篇文章,描述了面向对象的编程问题:以数据为导向的设计(或者为什么您可能会用OOP射击自己)

从面向数据的设计 - Mike Acton(2014)谈谈他指出,在分析的赛段中只有10%的问题,编译器可以在理论上优化的问题,90%的问题是Compilercan不可能优化的内容。

如果您想了解有关内存的更多信息,您可能希望阅读每个程序员应该了解内存的内容。如果您对某个CPU指令刻录的周期数感到好奇,请随时潜入CPU指令表。 ↩︎

来自面向对象的陷阱 - Tony Albrecht(2009),幻灯片17.您还可以在同一主题查看他的视频(2017)。 ↩︎

内存并不总是瓶颈。如果您是写入大量数据的写作,那么瓶颈很可能是您的硬盘。如果您在屏幕上渲染很多东西,则瓶颈可能会Beyour GPU。 ↩︎

我们都知道一团糟是c ++。我知道你最喜欢的利基语言是在列表中的不熟句,C#只有19岁,冷静下来。 ↩︎

如果您同时分配所有实例,则机会是,它们也将在堆中接一个地位于堆中的迭代中。一般而言之,最好预先释放所有Dataat启动,以避免使用整个堆的实例,虽然如果您使用的是托管语言,但很难推出在背景中拨打的内副函数收集者的作用。例如,JVM DevelopersClaim将拨出小对象并将其释放后能够更好地执行,而不是保持原代垃圾收集器的方式所做的代价池。 ↩︎

您也可以复制名称数组,对其进行排序并查找原始未排序的名称阵列的相关名称以返回相关元素的索引。您拥有元素数组索引您可以做任何您想要的,但它是泰迪托制作这种查找。如果你的阵列很大,这种方法也很慢。知道你的数据!

上面未提及的另一个问题是从数组中间插入或删除元素。当您从中间添加或删除通常涉及将整个修改的数组复制到内存中的新位置的数组中的元素时。复制数据很慢,如果您不小心携带数据,您也可以用完内存。

如果阵列中元素的顺序无关紧要,您还可以使用阵列的最后一个元素交换已删除的元素,并减少计算组中活动元素数的内部关闭。在这种情况下迭代元素时,您将基本上只迭代集团的活动部分。

链接列表不是此问题的可行解决方案,因为您的数据是在连续的块中分配的数据,这使得迭代非常慢(不足的利用率)。 ↩︎

这并不意味着代码将始终简单且易于理解 - 高效矩阵乘法的代码非常毛茸茸。 ↩︎