经过 漫长的设计过程 和 Julia 0.5 中的初步基础,Julia 0.6 包含了用于以“矢量化”风格(从 Matlab、Numpy、R 等熟悉)编写代码的新功能,同时避免了这种编程风格通常带来的开销:现在可以将多个矢量化操作“融合”成一个循环,而无需分配任何额外的临时数组。
这可以用一个示例(在下面演示中,我们获得了内存和时间的数量级节省)来最好地说明这一点。假设我们有一个函数 f(x) = 3x^2 + 5x + 2
用于计算多项式,我们希望计算 f(2x^2 + 6x^3 - sqrt(x))
用于整个数组 X
,并将结果就地存储在 X
中。现在您可以执行以下操作
X .= f.(2 .* X.^2 .+ 6 .* X.^3 .- sqrt.(X))
或者,等效地
@. X = f(2X^2 + 6X^3 - sqrt(X))
整个计算将被融合到一个循环中,就地操作,性能与手工编写的“非矢量化”循环相当
for i in eachindex(X)
x = X[i]
X[i] = f(2x^2 + 6x^3 - sqrt(x))
end
(当然,与所有 Julia 代码一样,为了获得良好的性能,这两个代码片段都应该在某个函数内部执行,而不是在全局范围内。)要查看此示例代码的各种性能实验的详细信息,请在附带的 IJulia/Jupyter X .= ...
代码中跟踪性能。性能在手工非矢量化循环的 10% 之内(本身在 C 代码速度的 5% 之内),除了非常小的数组,那里存在适度的开销(例如,对于长度为 1 的数组 X
,开销为 50%)。
在这篇博文中,我们将深入探讨此新开发的一些细节,以回答此功能出现时经常提出的问题
传统“矢量化”代码的开销是多少?矢量化代码不是应该已经很快了吗?
为什么需要所有这些点?Julia 不能只优化“普通”矢量代码吗?
这是 Julia 独有的东西,还是其他语言也能做到同样的事情?
简短的回答是
普通的矢量化代码很快,但不如手工编写的循环快(假设循环被有效编译,就像在 Julia 中一样),因为每个矢量化操作都会生成一个新的临时数组并执行一个单独的循环,当多个矢量化操作组合在一起时会导致大量开销。
这些点允许 Julia 在语法级别(在例如 x
的类型已知之前)识别操作的“矢量化”性质,因此循环融合是一个语法保证,而不是一个编译器优化,该优化可能或可能不会发生在精心编写的代码中。它们还允许调用者“矢量化”任何函数,而不是依赖于函数作者。(@.
宏允许您在表达式中的每个操作中添加点,从而提高包含大量点的表达式的可读性。)
其他语言也为矢量化操作实现了循环融合,但通常只针对编译器或矢量化库已知的少数类型和操作/函数。Julia 能够通用地进行操作,即使对于用户定义的数组类型和函数/运算符,也是不寻常的,部分依赖于上述语法选择以及它高效地编译高阶函数的能力。
最后,我们将回顾一下,由于这些点实际上对应于 broadcast
操作,因此它们可以将数组和标量组合起来,或者组合不同形状和类型的容器,并将 broadcast
和 map
进行比较。此外,Julia 0.6 扩展并澄清了 broadcast
中“标量”的概念,因此它不仅限于数值操作:您可以使用 broadcast
和融合的“点调用”完成许多其他任务(例如,字符串处理)。
为了探索这个问题(也讨论了 在这篇博文中),让我们从将上面的代码重写为更传统的矢量化风格开始,没有那么多的点,例如您可能在 Julia 0.4 或其他语言(最有名的是 Matlab、Python/Numpy 或 R)中使用。
X = f(2 * X.^2 + 6 * X.^3 - sqrt(X))
当然,这假设函数 sqrt
和 f
是“矢量化”的,即它们接受矢量参数 X
并逐元素计算函数。这在 Julia 0.4 中适用于 sqrt
,但这意味着我们必须将上面的函数 f
重写为矢量化风格,例如 f(x) = 3x.^2 + 5x + 2
(将 f
更改为使用逐元素运算符 .^
,因为 vector^scalar
没有定义)。(如果我们使用的是 Julia 0.4 并非常关心效率,我们可能已经使用 @vectorize_1arg f Number
宏来生成更专业的逐元素代码。)
顺便说一句,这个例子说明了矢量化风格的一个令人讨厌的地方:您必须提前决定给定函数 f(x)
是否也将在数组上逐元素应用,并专门编写它或定义一个相应的逐元素方法。
(我们的函数 f
接受任何 x
类型,在 Matlab 或 R 中,标量和 1 元素数组之间没有区别。但是,即使一个函数接受数组参数 x
,也不意味着它会按元素工作对于一个数组,除非您在编写函数时考虑到这一点。)
对于像 sqrt
这样的库函数,这意味着库作者必须猜测哪些函数应该具有矢量化方法,而用户必须猜测哪些模糊定义的库函数子集适用于矢量。
一个可能的解决方案是自动矢量化每个函数。语言 Chapel 实现了这一点:每个函数 f(x...)
隐式地定义一个函数 f(x::Array...)
,它计算 map(f, x...)
(Chamberlain 等人,2011)。这也可以在 Julia 中通过函数调用重载来实现 (Bezanson,2015:第 4 章),但我们选择了一个不同的方向。
相反,从 Julia 0.5 开始,任何函数 f(x)
都可以用 “点调用”语法 f.(X)
逐元素应用于数组 X
。因此,调用者决定将哪些函数矢量化。在 Julia 0.6 中,“传统上”矢量化的库函数(如 sqrt(X)
)被 弃用,支持 sqrt.(X)
,并且像 x .+ y
这样的点运算符被 现在等效于 点调用 (+).(x,y)
。与 Chapel 的隐式矢量化不同,Julia 的 f.(x...)
语法对应于 broadcast(f, x...)
而不是 map
,允许您组合数组和标量或不同形状/维度的数组。(broadcast
和 map
在这篇文章的最后进行了比较;每个都有其独特的特点。)从程序员的角度来看,这增加了一定程度的清晰度,因为它明确地指示了何时发生逐元素操作。从编译器的角度来看,点调用语法使下面更详细描述的语法循环融合优化成为可能,我们认为这是一种压倒性的优势。
在许多流行的用于交互式技术计算的动态类型语言(Matlab、Python、R 等)中,矢量化被视为一项关键(通常是最关键)的性能优化。它允许您的代码利用针对 scalar*array
或 sqrt(array)
等基本操作进行了高度优化的(甚至可能是并行化的)库例程。反过来,这些函数通常是用 C 或 Fortran 等低级语言实现的。相比之下,编写您自己的“非矢量化”循环太慢了,除非您愿意自己降级到低级语言,因为这些动态语言的语义使得在一般情况下很难将它们编译为高效的代码。
由于 Julia 的设计,在 Julia 中,正确编写的非矢量化循环的性能与 C 或 Fortran 相差无几,因此没有必要矢量化;这在附带的 笔记本 中对于上面的非矢量化循环进行了明确的演示。但是,矢量化对于某些问题来说仍然可能很方便。并且像 scalar*array
或 sqrt(array)
这样的矢量化操作在 Julia 中仍然很快(调用优化的库例程,虽然是在 Julia 本身中编写的)。
此外,如果您的问题涉及一个没有在 Julia 中预先编写、高度优化、矢量化的库例程,并且不能轻易分解成像 scalar*array
这样的现有矢量化构建块,那么您就可以编写自己的构建块,而无需降级到低级语言。(如果所有您将来会用到的关键性能代码都以优化的库例程的形式存在,那么编程就会容易得多!)
计算中的两个一般原则之间存在矛盾:一方面,重用高度优化的代码有利于性能;另一方面,为您的问题专门化的优化代码通常可以胜过通用函数。上面的代码的传统矢量化版本很好地说明了这一点
f(x) = 3x.^2 + 5x + 2
X = f(2 * X.^2 + 6 * X.^3 - sqrt(X))
像 X.^2
和 5*X
这样的每个操作都单独调用高度优化的函数,但当 X
是一个数组时,它们的组合会导致很多性能损失。要明白这一点,您必须意识到这段代码等效于
tmp1 = X.^2
tmp2 = 2*tmp1
tmp3 = X.^3
tmp4 = 6 * tmp3
tmp5 = tmp2 + tmp4
tmp6 = sqrt(X)
tmp7 = tmp5 - tmp6
X = f(tmp7)
也就是说,每个矢量化操作都会分配一个单独的临时数组,并且是具有自身内部循环的单独的库调用。这两种特性对性能都不利。
首先,分配了八个数组(tmp1
到 tmp7
,加上 f(tmp7)
的结果,以及 f(tmp7)
内部分配的另外四个数组,出于同样的原因,总共分配了 12 个数组。生成的 X = ...
表达式不会就地更新 X
,而是使变量 X
“指向”由 f(tmp7)
返回的新数组,丢弃旧的数组 X
。所有这些额外的数组最终都会由 Julia 的垃圾收集器释放,但在那之前,它会浪费大量内存(数量级!)
就其本身而言,分配/释放内存与我们执行的其他计算相比,可能需要相当长的时间。如果 X
非常小,以至于分配开销很重要(在我们基准 笔记本 中,我们为一个 6 元素数组支付了 10 倍的成本,为一个 36 元素数组支付了 6 倍的成本),或者如果 X
非常大,以至于内存周转很重要(见下文以获取数字),情况尤其如此。此外,由于您有 12 个循环(对内存进行 12 次遍历)而不是一个,您还将付出不同的性能代价,部分原因是失去了 内存局部性。
特别是,读取或写入主计算机内存(RAM)中的数据比执行标量算术运算(如+
和*
)慢得多,因此计算机硬件将最近使用的数据存储在缓存中:一小部分更快的内存。此外,存在着更小、更快的缓存层次结构,最终到达 CPU 本身的寄存器内存。这意味着,为了获得良好的性能,您应该一次加载每个数据x = X[i]
(以便它进入缓存,或者对于足够小的类型进入寄存器),然后在您仍然可以快速访问它时执行多个运算,例如f(2x^2 + 6x^3 - sqrt(x))
,然后再加载下一个数据;这被称为“时间局部性”。传统的向量化代码会丢弃这种潜在的局部性:每个X[i]
只加载一次,用于一个小的操作,例如2*X[i]
,将结果写入一个临时数组,然后立即读取下一个X[i]
。
因此,在典型的性能基准测试中(参见笔记本),传统的向量化代码X = f(2 * X.^2 + 6 * X.^3 - sqrt(X))
实际上比本文开头针对X = zeros(10^6)
的去向量化或融合向量化版本的相同代码慢约 10 倍。即使我们预先分配了所有临时数组(完全消除了分配成本),我们的基准测试表明,对每个操作执行一个单独的循环对于一百万个元素的X
仍然慢约 4-5 倍。这不是 Julia 独有的!除非语言的编译器可以自动融合所有这些循环(即使是那些出现在函数调用内部的循环),否则向量化代码在任何语言中都是次优的,而这种情况很少发生,原因如下。
您可能会看到一个像2 * X.^2 + 6 * X.^3 - sqrt(X)
这样的表达式,并认为它“显然”可以组合成一个关于X
的循环。为什么 Julia 的编译器不能足够聪明地识别这一点呢?
您需要意识到的是,在 Julia 中,+
或 sqrt
没有什么特别之处——它们是任意的函数,可以做任何事情。X + Y
可以发送电子邮件或打开一个绘图窗口,这是编译器所不知道的。为了弄清楚它可以将例如2*X + Y
融合成一个循环,为结果分配一个数组,编译器需要
推断X
和Y
的类型,并找出要调用的*
和+
函数。(Julia 已经做到了这一点,至少是在类型推断成功时。)
查看这些函数内部,意识到它们是关于X
和Y
的元素级循环,并且意识到它们是纯函数(例如,2*X
没有副作用,例如修改Y
)。
分析像X[i]
这样的表达式(它们是调用一个函数getindex(X, i)
,它对于编译器来说是“另一个函数”),以检测它们是内存读写并确定它们所隐含的数据依赖关系(例如,要弄清楚2*X
分配了一个可以消除的临时数组)。
第二和第三步提出了一个巨大的挑战:查看一个任意函数并从这个层面上“理解”它,对于计算机来说是一个非常困难的问题。如果将融合视为编译器的优化,那么编译器只有在能够证明融合不会改变结果的情况下才能进行融合,这需要检测纯度和其他数据依赖关系分析。
相反,当 Julia 编译器看到像2 .* X .+ Y
这样的表达式时,它仅仅从语法(“拼写”)就知道这些是元素级操作,并且 Julia 保证代码将始终融合成一个循环,从而使其摆脱了证明纯度的必要性。这就是我们所说的语法循环融合,下面将详细介绍。
您可能会想到的一种方法,并且已经在各种语言中实现(例如,Kennedy & McKinley, 1993;Lewis et al., 1998;Chakravarty & Keller, 2001;Manjikian & Abdelrahman, 2002;Sarkar, 2010;Prasad et al., 2011;Wu et al., 2012),是只对编译器可以识别的一些“内置”类型和运算执行循环融合。同样的想法也已经作为库(例如,C++ 中的模板库:Veldhuizen, 1995)或特定领域语言 (DSL)作为现有语言的扩展来实现;例如,在 Python 中,可以从Theano、PyOP2和Numba软件中找到针对一组向量运算和数组/标量类型的小型循环融合。同样,在 Julia 中,我们也可以潜在地构建编译器来识别它可以融合*
、+
、.^
以及针对内置Array
类型的类似运算(并且可能只针对少数标量类型)。事实上,这已经在 Julia 中作为基于宏的 DSL(您在向量化表达式中添加@vec
或@acc
装饰器)在Devectorize和ParallelAccelerator包中实现了。
但是,即使 Julia 肯定会随着时间的推移而实现额外的编译器优化,Julia 设计的一个关键原则是在核心语言中“内置”尽可能少的东西,在 Julia 本身中实现尽可能多的东西(Bezanson, 2015)。换句话说,相同的优化应该同样适用于用户定义的类型和函数,就像适用于 Julia 标准库(Base
)的“内置”函数一样。您应该能够定义自己的数组类型(例如,通过StaticArrays包或PETSc 数组)和函数(如上面的f
),并使它们能够融合向量化运算。
此外,复杂的编译器优化的一个困难之处在于,作为一名程序员,您经常不确定它们是否会发生。您必须学会避免编码风格,这些风格会意外地阻止编译器识别融合机会(例如,因为您调用了一个“非内置”函数),您需要学习使用额外的编译器诊断工具来识别正在进行的哪些优化,并且您需要在发布编译器和语言的新版本时不断检查这些诊断。对于向量化代码来说,丢失融合优化可能意味着浪费一个数量级的内存和时间,因此您需要比针对典型的编译器微优化所担心的更多。
相反,Julia 的方法非常简单和通用:调用者通过添加点来指示哪些函数调用和运算符旨在被应用于元素级(具体来说,作为broadcast
调用)。编译器在解析时(或技术上在“降低”时,但无论如何都是在它了解变量类型等等之前)注意到这些点,并将它们转换为对broadcast
的调用。此外,它保证嵌套的“点调用”将始终融合成单个广播调用,即单个循环。
换句话说,f.(g.(x .+ 1))
被 Julia 视为仅仅是语法糖,用于broadcast(x -> f(g(x + 1)), x)
。赋值y .= f.(g.(x .+ 1))
被视为broadcast!(x -> f(g(x + 1)), y, x)
的语法糖。编译器不需要证明这会产生与相应的非融合操作相同的结果,因为融合是作为语言的一部分定义的强制转换,而不是可选优化。
任意的用户定义函数f(x)
适用于这种机制,任意的用户定义集合类型适用于x
,只要您为集合定义了broadcast
方法。(默认的broadcast
已经适用于AbstractArray
的任何子类型。)
此外,点运算符现在不仅适用于像.+
这样的熟悉 ASCII 运算符,而且适用于 Julia 解析为二元运算符的任何字符。这包括大量 Unicode 符号,例如⊗
、∪
和⨳
,其中大多数默认情况下未定义。因此,例如,如果您为克罗内克积定义了⊗(x,y) = kron(x,y)
,那么您可以立即执行[A, B] .⊗ [C, D]
来计算“元素级”运算[A ⊗ C, B ⊗ D]
,因为x .⊗ y
是broadcast(⊗, x, y)
的语法糖。
请注意,“并排”二元运算实际上等同于嵌套调用,因此它们在点运算中融合。例如,3 .* x .+ y
等同于(+).((*).(3, x), y)
,因此它融合成broadcast((x,y) -> 3*x+y, x, y)
。还要注意,融合只在遇到“非点”调用时才会停止,例如sqrt.(abs.(sort!(x.^2)))
将sqrt
和abs
运算融合成一个循环,但x.^2
会在另一个循环中执行(生成一个临时数组),因为存在一个中间的“非点”函数调用sort!(...)
。
为了完整起见,我们应该提到一些其他可能性,这些可能性将部分解决向量化的问题。例如,可以对函数进行特殊的注释,以声明它们是纯函数,可以对具有类似数组语义的容器类型进行特殊注释,等等,以帮助编译器识别融合的可能性。但这会对库作者施加很多要求,并且他们还需要预先确定哪些函数可能应用于向量(因此值得付出额外的分析和注释工作)。
另一个提出的方法是定义更新运算符,例如x += y
,使其等同于调用一个特殊函数,例如x = plusequals!(x, y)
,该函数可以定义为一个就地操作,而不是像 Julia 中那样将x += y
视为x = x + y
的同义词。(NumPy 就是这样做的。)本身,这可以用于避免某些简单情况下的临时数组,通过将它们分解成一系列就地更新,但它不会处理更复杂的表达式,仅限于少数操作,例如+
,并且不会解决多个循环的缓存效率问题。(在 Julia 0.6 中,您可以执行x .+= y
,它等同于x .= x .+ y
,它会就地执行一个融合循环,但这种语法现在扩展到了任意函数的任意组合。)
显然,Julia 的语法循环融合方法部分依赖于这样一个事实:作为一门年轻的语言,我们仍然有相对的自由来重新定义核心语法元素,例如 `f.(x)` 和 `x .+ y`。但假设你愿意将这种或类似的语法添加到现有的语言中,例如 Python 或 Go,或者如上所述在这些语言之上创建一个 DSL 附加组件;那么你能够有效地实现相同的融合语义吗?
有一个陷阱:`2 .* x .+ x .^ 2` 在 Julia 中是 `broadcast(x -> 2*x + x^2, x)` 的语法糖,但为了使它快速,我们需要 高阶函数 `broadcast` 也非常快。首先,这需要将任意用户定义的标量(非矢量化!)函数(如 `x -> 2*x + x^2`)编译成快速代码,这在高级动态语言中通常是一个挑战。其次,它理想情况下需要像 `broadcast` 这样的高阶函数能够 内联 函数参数 `x -> 2*x + x^2`,而这种功能更少见。(它在 Julia 版本 0.5 之前不可用。)
此外,`broadcast` 能够组合不同形状的数组和标量或数组的能力(见下文)结果证明,在不失去通用性的情况下,高效地实现它很微妙。当前的实现依赖于 Julia 提供的元编程功能,称为 生成函数,以便在参数的数量和类型上获得编译时特化。对内联和特化问题的另一种解决方案是将 `broadcast` 函数构建到编译器中,但随后你可能会失去 `broadcast` 对用户定义容器可重载的能力,用户也不能编写具有类似功能的自己的高阶函数。
特别地,考虑 `broadcast` 的一个朴素实现(仅针对单参数函数)
function naivebroadcast(f, x)
y = similar(x)
for i in eachindex(x)
y[i] = f(x[i])
end
return y
end
在 Julia 中,与其他语言一样,`f` 必须是某种形式的 函数指针 或 函数对象。通常,对函数对象 `f` 的调用 `f(x[i])` 必须找出函数的实际 机器码 在哪里(在 Julia 中,这涉及根据 `x[i]` 的类型进行调度;在面向对象的语言中,它可能涉及根据 `f` 的类型进行调度),通过寄存器和/或 调用堆栈 将参数 `x[i]` 等传递给 `f`,跳转到机器指令以执行它们,跳回到调用者 `naivebroadcast`,并提取返回值。也就是说,调用函数参数 `f` 涉及一些超出 `f` 内部计算成本的开销。
如果 `f(x)` 足够昂贵,那么函数调用的开销可能是可以忽略的,但对于像 `f(x) = 2*x + x^2` 这样廉价的函数,开销可能非常大:使用 Julia 0.4,与手写循环评估 `z = x[i]; y[i] = 2*z + z^2` 相比,开销大约是两倍。由于实际上很多矢量化代码都评估了像这样的相对廉价的函数,因此对于基于 `broadcast` 的通用矢量化方法来说,这将是一个大问题。(函数调用也会抑制 SIMD 优化,编译器会阻止 `f(x)` 中的计算同时应用于多个 `x[i]` 元素。)
然而,在 Julia 0.5 中,每个函数都有自己的类型。并且,在 Julia 中,每当你调用像 `naivebroadcast(f, x)` 这样的函数时,都会为 `typeof(f)` 和 `typeof(x)` 编译一个 `naivebroadcast` 的特化版本。由于编译后的代码是针对 `typeof(f)` 的,即传递的特定函数,因此 Julia 编译器可以自由地 内联 `f(x)` 到生成的代码中,如果它想要的话,所有函数调用的开销都可以消失。
Julia 既不是第一个也不是唯一一个可以内联高阶函数的语言;例如,据报道,在 Haskell 和 Kotlin 语言中这是可能的。尽管如此,这似乎是一个罕见的特性,特别是在 命令式语言 中。快速高阶函数是 Julia 的一个关键组成部分,它允许像 `broadcast` 这样的函数在 Julia 本身中编写(因此可以扩展到用户定义的容器),而不是必须构建到编译器中(并且可能仅限于“内置”容器类型)。
点调用对应于 Julia 中的 `broadcast` 函数。广播是一个强大的概念(例如,也在 NumPy 和 Matlab 中),其中“逐元素”运算的概念被扩展到包含组合不同形状的数组或数组和标量。此外,这不仅限于数字数组,并且从 Julia 0.6 开始,`broadcast` 上下文中的“标量”可以是任意类型的对象。
你可能已经注意到上面的示例包含了像 `6 .* X.^3` 这样的表达式,它将一个数组 ( `X` ) 与标量 ( `6` 和 `3` ) 组合在一起。从概念上讲,在 `X.^3` 中,标量 `3` 被“扩展”(或“广播”)以匹配 `X` 的大小,就好像它变成了一个数组 `[3,3,3,...]`,然后执行逐元素的 `^`。当然,在实践中,从未显式地构造 `3` 的数组。
更一般地,如果你组合了两个不同维度或形状的数组,任何一个数组的“单例”(长度为 1)或缺失维度都会被“广播”到另一个数组的该维度。例如,`A .+ [1,2,3]` 将 `[1,2,3]` 添加到 3×*n* 矩阵 `A` 的每一列。另一个典型的示例是将一个行向量(或一个 1×*n* 数组)和一个列向量组合在一起,形成一个矩阵(二维数组)
julia> [1 2 3] .+ [10,20,30]
3×3 Array{Int64,2}:
11 12 13
21 22 23
31 32 33
(如果 `x` 是一个行向量,`y` 是一个列向量,那么 `A = x .+ y` 将创建一个矩阵,其中 `A[i,j] = x[j] + y[i]`。)
尽管其他语言也实现了类似的 `broadcast` 语义,但 Julia 的不同之处在于它能够为任意用户定义的函数和类型支持此类操作,其性能可与手写 C 循环相媲美,即使它的 `broadcast` 函数是完全用 Julia 编写的,没有来自编译器的任何特殊支持。这不仅需要高效的编译和高阶内联(如上所述),还需要能够 高效地迭代在编译时为每个调用者确定的任意维度的数组。
尽管上面的示例都是针对数值计算的,但实际上,`broadcast` 函数和点调用融合语法都不限于数值数据。例如
julia> s = ["The QUICK Brown", "fox jumped", "over the LAZY dog."];
julia> s .= replace.(lowercase.(s), r"\s+", "-")
3-element Array{String,1}:
"the-quick-brown"
"fox-jumped"
"over-the-lazy-dog."
在这里,我们取一个字符串数组 `s`,将每个字符串转换为小写,然后将任何空白序列(正则表达式 `r"\s+"`)替换为连字符 `"-"`。由于这两个点调用是嵌套的,因此它们被融合成一个对 `s` 的循环,并且由于 `s .= ...`(在这个过程中会分配临时字符串,但不会分配临时字符串数组)而在 `s` 中就地写入。此外,请注意,参数 `r"\s+"` 和 `"-"` 被视为“标量”,并被“广播”到 `s` 的每个元素中。
一般规则(从 Julia 0.6 开始)是,在 `broadcast` 中,任何类型的参数默认情况下都被视为标量。主要例外是数组(`AbstractArray` 的子类型)和元组,它们被视为容器并被迭代。(如果你定义了自己的容器类型,该类型不是 `AbstractArray` 的子类型,你可以通过重载 `Base.Broadcast.containertype` 和几个其他函数,告诉 `broadcast` 将其视为要迭代的容器。)
由于点调用语法对应于 `broadcast`,而 `broadcast` 只是一个普通的 Julia 函数,你可以向它添加自己的方法(与某种特权的编译器内置函数相反),因此打开了多种可能性。你不仅可以将融合点调用扩展到自己的数据结构(例如,DistributedArrays 扩展了 `broadcast` 以适用于跨多个计算机 分布 的数组),还可以将相同的语法应用于几乎不是“容器”的数据类型。
例如,ApproxFun 包定义了一个名为 `Fun` 的对象,它表示用户定义函数的数值近似(本质上,`Fun` 是一个花哨的多项式拟合)。通过为 `Fun` 定义 `broadcast` 方法,你现在可以取一个 `f::Fun` 并执行例如 `exp.(f.^2 .+ f.^3)`,它将转换为 `broadcast(y -> exp(y^2 + y^3), f)`。这个 `broadcast` 调用反过来将为 `y = f(x)`(在巧妙选择的 `x` 点上)评估 `exp(y^2 + y^3)`,构造一个多项式拟合,并返回一个表示拟合的新 `Fun` 对象。(从概念上讲,这用函数的逐点操作替换了容器的逐元素操作。)相比之下,ApproxFun 也允许你使用 `exp(f^2 + f^3)` 计算相同的结果,但在这种情况下,它将进行四次拟合过程(构造四个 `Fun` 对象),一次用于像 `f^2` 这样的每个操作,由于缺乏融合,速度慢了一个数量级以上。
最后,比较 `broadcast` 和 `map` 是很有启发性的,因为 `map`也将一个函数逐元素地应用于一个或多个数组。(点调用语法调用 `broadcast`,而不是 `map`。)基本区别是
`broadcast` 仅处理具有“形状”的容器 M×N×⋯(即,`size` 和维度),而 `map` 处理像 `Set` 这样的“无形状”容器或像 `eachline(file)` 这样的未知长度的迭代器。
`map` 要求所有参数具有相同的长度(因此无法组合数组和标量),并且(对于数组容器)具有相同的形状,而 `broadcast` 则不然(它可以“扩展”较小的容器以匹配较大的容器)。
`map` 默认情况下将所有参数视为容器,特别是希望其参数 充当迭代器。相反,`broadcast` 默认情况下将其参数视为标量(即,一个元素的 0 维数组),除了几个明确声明为广播容器的类型,例如 `AbstractArray` 和 `Tuple`。
当然,有时它们的行为会一致,例如 `map(sqrt, [1,2,3])` 和 `sqrt.([1,2,3])` 会给出相同的结果。但是,一般来说,`map` 和 `broadcast` 都不泛化对方——两者都有对方做不到的事情。