我相信(从一些阅读中)读取/写入总线上从CPU缓存到主存储器的数据对计算任务(需要通过总线移动数据)的完成速度有相当大的限制 - < em> Von Neumann瓶颈。
到目前为止,我已经遇到过一些文章,其中提到函数式编程可能比其他范式(例如命令式方法)更具性能。 OO(在某些计算模型中)。
有人可以解释纯功能编程可以减少这个瓶颈的一些方法吗?即。发现(一般)以下任何一点是真的吗?
使用不可变数据结构意味着通常在该总线上移动的数据较少 - 写入量较少?
使用不可变数据结构意味着数据可能更容易在CPU缓存中闲置 - 因为对现有状态的更新更少意味着缓存中的对象刷新更少?
是否有可能使用不可变数据结构意味着我们甚至可能永远不会从主内存中读取数据,因为我们可能在计算期间创建对象并将其放在本地缓存中,然后在同一时间片创建一个关闭它的新的不可变对象(如果需要更新),然后我们永远不会使用原始对象,即。我们正在使用位于本地缓存中的对象进行更多工作。
答案 0 :(得分:13)
哦,伙计,这是经典之作。 John Backus’ 1977 ACM Turing Award lecture is all about that: “Can Programming Be Liberated from the von Neumann Style? A Functional Style and Its Algebra of Programs.”(论文“Lambda:The Ultimate Goto”在同一次会议上发表。)
我猜你或者提出这个问题的人都记得那个讲座。 Backus所谓的“冯·诺伊曼瓶颈”是“一个连接管,可以在CPU和商店之间传输一个单词(并向商店发送地址)。”
CPU仍然拥有数据总线,但在现代计算机中,它通常足以容纳一个单词矢量。我们也没有摆脱我们需要存储和查找大量地址的问题,例如链接到列表和树的子节点。
但巴克斯不只是谈论物理架构(强调增加):
这个电子管不仅是问题数据流量的直接瓶颈,而且,更重要的是,它是一个智能瓶颈,使我们一直与一字一句的思维联系在一起而不是鼓励我们根据手头任务的较大概念单元进行思考。因此,编程基本上是通过冯·诺伊曼瓶颈来规划和详细说明话语的巨大流量,而且大部分流量都涉及不是重要的数据本身,而是在哪里找到它。
从这个意义上说,函数式编程在很大程度上成功地让人们编写了更高级的函数,比如地图和缩减,而不是像for
循环那样的“一次一字思考”。 C.如果你试图对C中的大量数据执行操作,那么就像1977年一样,你需要把它写成一个顺序循环。潜在地,循环的每次迭代都可以对数组的任何元素或任何其他程序状态执行任何操作,甚至可以使用循环变量本身进行任何操作,并且任何指针都可能对这些变量中的任何变量进行别名。当时,Backus的第一个高级语言Fortran的DO
循环也是如此,除了关于指针别名的部分。为了在今天获得良好的性能,你试图帮助编译器弄清楚,不,循环并不真正需要按照你指定的顺序运行:这是一个可以并行化的操作,比如减少或转换一些其他数组或单独的循环索引的纯函数。
但这不再适合现代计算机的物理架构,现代计算机都是矢量化对称多处理器 - 就像70年代后期的Cray超级计算机一样,但速度更快。
实际上,C ++标准模板库现在在容器上有算法,完全独立于实现细节或数据的内部表示,Backus自己的创建Fortran添加了FORALL
和{{1}在1995年。
当你看到今天的大数据问题时,你会发现我们用来解决它们的工具类似于功能习语,而不是Backus在50年代和60年代设计的命令式语言。你不会在2018年写一堆PURE
循环去做机器学习;你可以像Tensorflow那样定义一个模型并对其进行评估。如果您想同时使用大量处理器来处理大数据,那么知道您的操作是关联的非常有用,因此可以按任何顺序进行分组然后组合,从而实现自动并行化和矢量化。或者数据结构可以是无锁且无等待的,因为它是不可变的。或者,向量上的变换是可以使用另一个向量上的SIMD指令实现的映射。
去年,我用几种不同语言编写了几个短程序来解决一个问题,即找到最小化三次多项式的系数。 C11中的暴力方法在相关部分看起来像这样:
for
C ++ 14版本的相应部分如下所示:
static variable_t ys[MOST_COEFFS];
// #pragma omp simd safelen(MOST_COEFFS)
for ( size_t j = 0; j < n; ++j )
ys[j] = ((a3s[j]*t + a2s[j])*t + a1s[j])*t + a0s[j];
variable_t result = ys[0];
// #pragma omp simd reduction(min:y)
for ( size_t j = 1; j < n; ++j ) {
const variable_t y = ys[j];
if (y < result)
result = y;
} // end for j
在这种情况下,系数向量是 const variable_t result =
(((a3s*t + a2s)*t + a1s)*t + a0s).min();
个对象,STL中的一种特殊类型的对象,它们的组件如何被别名化,并且其成员操作有限,并且有很多限制什么操作可以安全地矢量化声音,就像对纯函数的限制一样。最后std::valarray
允许的缩减列表为not coincidentally,类似于.min()
的{{1}}。如果你在STL中查看instances
,你会看到类似的故事。
现在,我不会声称C ++已经成为一种功能语言。事实上,我让程序中的所有对象都不可变并由RIIA自动收集,但这只是因为我已经有很多功能编程,这就是我现在喜欢的代码。语言本身不会强加诸如不变性,垃圾收集或没有副作用之类的东西。但是,当我们看看Backus在1977年所说的是真正的 von Neumann瓶颈时,“一个知识瓶颈让我们一直在思考,而不是鼓励我们用思考来思考对于手头任务的较大概念单元,“这适用于C ++版本吗?这些操作是系数向量的线性代数,而不是一次一个字。而C ++借用的想法 - 表达模板背后的想法更是如此 - 主要是功能概念。 (将该片段与K&amp; R C中的内容进行比较,以及Backus如何在1977年的图灵奖演讲的第5.2节中定义了计算内部产品的功能程序。)
我还在Haskell中编写了一个版本,但我不认为它是逃避这种冯诺依曼瓶颈的一个很好的例子。
绝对有可能编写满足所有Backus对冯诺依曼瓶颈描述的功能代码。回想一下我本周写的代码,我自己就完成了。构建列表的折叠或遍历?它们是高级抽象,但它们也被定义为一次一个字的操作序列,当您创建和遍历单链表时,通过瓶颈传递的一半或更多数据是地址其他数据!它们是通过von Neumann瓶颈放置数据的有效方法,这就是我做这件事的基本原因:它们是冯·诺依曼机器编程的好方法。
如果我们对以不同方式编码感兴趣,那么函数式编程为我们提供了工具。 (我不会声称它是唯一能做到的。)将缩减表示为Data.Semigroup
,将其应用于正确类型的向量,并且幺半群操作的关联性可让您将问题分解为你想要的任何大小的块,然后组合这些块。在除单链表之外的数据结构上进行操作<algorithm>
而不是折叠,并且可以自动并行化或向量化。或者以产生相同结果的其他方式进行转换,因为我们已经在更高的抽象层次上表达了结果,而不是一次一个字的特定操作序列。
到目前为止我的例子都是关于并行编程的,但我确信量子计算会从根本上改变程序的样子。