【问题标题】:Performance difference between functions and pattern matching in MathematicaMathematica 中函数和模式匹配的性能差异
【发布时间】:2011-05-10 10:11:40
【问题描述】:

因此,Mathematica 与 lisp 的其他方言不同,因为它模糊了函数和宏之间的界限。在 Mathematica 中,如果用户想要编写一个数学函数,他们可能会使用模式匹配,如 f[x_]:= x*x 而不是 f=Function[{x},x*x],尽管当使用 f[x] 调用时两者都会返回相同的结果。我的理解是,第一种方法相当于 lisp 宏,根据我的经验,由于语法更简洁,因此受到青睐。

所以我有两个问题,执行函数与模式匹配/宏方法之间是否存在性能差异?尽管如果函数实际上被转换为某些版本的宏以允许实现 Listable 之类的功能,我的一部分人不会感到惊讶。

我之所以关心这个问题是因为最近的一组问题(1)(2) 是关于试图在大型程序中捕获 Mathematica 错误的。如果大多数计算是根据函数定义的,那么在我看来,跟踪评估顺序和错误起源比在输入被连续应用宏重写后尝试捕获错误更容易/模式。

【问题讨论】:

  • Mathematica 不是 Lisp 方言。 Lisp 宏也被设计成可以在编译时完全扩展,因此运行时开销为零。
  • 我认为 Mathematica 是 lisp 的 M 表达式版本,至少是它的知识遗产。不过,我不确定您的评论是什么,因为据我了解,宏扩展是 lisp 实现中每个读取-评估-打印周期的步骤之一。因此,我不确定我是否理解您所说的没有运行时开销是什么意思。我包含 lisp 标记是因为我很好奇这是否是过去在 lisp 编程讨论中出现的问题。
  • “过早的优化是万恶之源”:P
  • Lisp 基于表达式的评估。 Mathematica 基于术语重写。非常不同的方法。 Lisp 宏扩展由 Lisp 编译器一次性完成。评估前。在运行时,不再需要简化或重写 Lisp 表达式。
  • @RainerJoswig:你可以说 Mathematica 应用了宏并且从不进行 Lisp 意义上的评估。

标签: lisp wolfram-mathematica


【解决方案1】:

我对 Mathematica 的理解是它是一个巨大的搜索替换引擎。所有函数、变量和其他赋值基本上都存储为规则,在求值过程中,Mathematica 会通过这个全局规则库并应用它们,直到结果表达式停止变化。

因此,您必须通过规则列表的次数越少,评估的速度就越快。看看使用Trace会发生什么(使用gdelfino的函数g和h)

In[1]:= Trace@(#*#)&@x
Out[1]= {x x,x^2}
In[2]:= Trace@g@x
Out[2]= {g[x],x x,x^2}
In[3]:= Trace@h@x
Out[3]= {{h,Function[{x},x x]},Function[{x},x x][x],x x,x^2}

很明显为什么匿名函数是最快的,为什么使用Function 会比简单的SetDelayed 引入额外的开销。我建议您查看 Leonid Shifrin 的优秀书籍的introduction,其中对这些概念进行了一些详细的解释。

我有时会构建一个包含我需要的所有函数的Dispatch 表,并将其手动应用于我的起始表达式。与正常评估相比,这显着提高了速度,因为 Mathematica 的任何内置函数都不需要与我的表达式匹配。

【讨论】:

  • +1 ...也感谢这本书。顺便说一句,帮我建立一个关于 Mathematica 的参考书目,在这里回答 programmers.stackexchange.com/questions/19146/…
  • 当,我打算在这里问这个确切的问题。我看到过关于其他几种语言的类似问题,例如stackoverflow.com/q/336715/181759,除非有一些不利于它们的一般规则,否则我认为关于 Mathematica 的问题会很棒。
  • @Timo 我已经在 SO 上看到关于我的 q 的主题性的讨论,现在社区 Wiki 标签已经消失。我宁愿通过在那里张贴来饿死巨魔。当然,q 旨在为所有 Mma 社区提供参考,因此请随意发布您感兴趣的任何材料。我会做家务以维护 q 文本中的列表以方便搜索。
  • @belisarius 你知道,我正要问“我是不是很密集,或者如何让一个问题成为维基?”题。是的,如果 wiki 标签消失了,那么书/手册问题在这里就没有意义了,也许我也应该将包/工具问题也转移给程序员。
  • @Timo,您推断 mma 是一个巨大的搜索和替换引擎在某种程度上是有道理的,因为当您执行 f[x_] := x_ 之类的操作时,您会收到错误 RuleDelayed::rhs : Pattern x_ appears on right-hand side of rule f[x_]:>x_. 所以,至少在某种程度上,@ 987654333@ 被解释为替换规则。
【解决方案2】:

我的理解是,第一种方法相当于 lisp 宏,根据我的经验,由于语法更简洁,因此受到青睐。

不是真的。 Mathematica 是一个术语重写器,Lisp 宏也是。

所以我有两个问题,执行函数与模式匹配/宏方法之间是否存在性能差异?

是的。请注意,在 Mathematica 中,您永远不是真正的“执行函数”。您只是在应用重写规则将一个表达式更改为另一个。

考虑将Sqrt 函数映射到浮点数的压缩数组。 Mathematica 中最快的解决方案是将Sqrt 函数直接应用于打包数组,因为它恰好实现了我们想要的并且针对这种特殊情况进行了优化:

In[1] := N@Range[100000];

In[2] := Sqrt[xs]; // AbsoluteTiming

Out[2] = {0.0060000, Null}

我们可以定义一个全局重写规则,将sqrt[x] 形式的术语重写为Sqrt[x],以便计算平方根:

In[3] := Clear[sqrt];
         sqrt[x_] := Sqrt[x];
         Map[sqrt, xs]; // AbsoluteTiming

Out[3] = {0.4800007, Null}

请注意,这比之前的解决方案慢约 100 倍。

或者,我们可以定义一个全局重写规则,将符号 sqrt 替换为调用 Sqrt 的 lambda 函数:

In[4] := Clear[sqrt];
         sqrt = Function[{x}, Sqrt[x]];
         Map[sqrt, xs]; // AbsoluteTiming

Out[4] = {0.0500000, Null}

请注意,这比之前的解决方案快了约 10 倍。

为什么?因为慢速第二个解决方案是在内部循环中查找重写规则sqrt[x_] :> Sqrt[x](对于数组的每个元素),而快速第三个解决方案查找符号sqrt 的值Function[...] 一次,然后应用该 lambda反复发挥作用。相比之下,最快的第一个解决方案是用 C 编写的循环调用sqrt。因此,搜索全局重写规则非常昂贵,而术语重写也很昂贵。

如果是这样,为什么Sqrt 总是那么快?您可能会期待 2 倍而不是 10 倍的减速,因为我们已经在内部循环中将 Sqrt 的一个查找替换为 sqrtSqrt 的两个查找,但事实并非如此,因为 Sqrt 具有特殊状态作为一个内置函数,它将在 Mathematica 术语重写器本身的核心中匹配,而不是通过通用的全局重写表。

其他人已经描述了相似功能之间的性能差异要小得多。我相信这些情况下的性能差异只是 Mathematica 内部的确切实现的微小差异。 Mathematica 最大的问题是全局重写表。尤其是,这是 Mathematica 与传统术语级解释器不同的地方。

您可以通过编写迷你 Mathematica 实现来了解 Mathematica 的性能。在这种情况下,上述解决方案可能会编译为(例如)F#。数组可以这样创建:

> let xs = [|1.0..100000.0|];;
...

内置的sqrt函数可以被转换成一个闭包并像这样传递给map函数:

> Array.map sqrt xs;;
Real: 00:00:00.006, CPU: 00:00:00.015, GC gen0: 0, gen1: 0, gen2: 0
...

这需要 6 毫秒,就像 Mathematica 中的 Sqrt[xs] 一样。但这是意料之中的,因为此代码已被 .NET JIT 编译为机器代码以进行快速评估。

在 Mathematica 的全局重写表中查找重写规则类似于在以函数名称为键的字典中查找闭包。在 F# 中可以这样构造这样的字典:

> open System.Collections.Generic;;
> let fns = Dictionary<string, (obj -> obj)>(dict["sqrt", unbox >> sqrt >> box]);;

这类似于 Mathematica 中的 DownValues 数据结构,不同之处在于我们不会搜索多个结果规则来寻找第一个匹配函数参数的规则。

然后程序变成:

> Array.map (fun x -> fns.["sqrt"] (box x)) xs;;
Real: 00:00:00.044, CPU: 00:00:00.031, GC gen0: 0, gen1: 0, gen2: 0
...

请注意,由于内部循环中的哈希表查找,我们得到了类似的 10 倍性能下降。

另一种方法是将与符号关联的DownValues 存储在符号本身中,以避免哈希表查找。

我们甚至可以用几行代码编写一个完整的术语重写器。术语可以表示为以下类型的值:

> type expr =
    | Float of float
    | Symbol of string
    | Packed of float []
    | Apply of expr * expr [];;

请注意,Packed 实现了 Mathematica 的打包列表,即未装箱的数组。

以下init 函数使用函数n 构造一个Listn 元素,如果每个返回值都是Float 或更通用的Apply(Symbol "List", ...),则返回Packed,否则:

> let init n f =
    let rec packed ys i =
      if i=n then Packed ys else
        match f i with
        | Float y ->
            ys.[i] <- y
            packed ys (i+1)
        | y ->
            Apply(Symbol "List", Array.init n (fun j ->
              if j<i then Float ys.[i]
              elif j=i then y
              else f j))
    packed (Array.zeroCreate n) 0;;
val init : int -> (int -> expr) -> expr

以下rule 函数使用模式匹配来识别它可以理解的表达式并将它们替换为其他表达式:

> let rec rule = function
    | Apply(Symbol "Sqrt", [|Float x|]) ->
        Float(sqrt x)
    | Apply(Symbol "Map", [|f; Packed xs|]) ->
        init xs.Length (fun i -> rule(Apply(f, [|Float xs.[i]|])))
    | f -> f;;
val rule : expr -> expr

请注意,此函数expr -&gt; expr 的类型是术语重写的特征:重写用其他表达式替换表达式,而不是将它们简化为值。

我们的程序现在可以由我们的自定义术语重写器定义和执行:

> rule (Apply(Symbol "Map", [|Symbol "Sqrt"; Packed xs|]));;
Real: 00:00:00.049, CPU: 00:00:00.046, GC gen0: 24, gen1: 0, gen2: 0

我们已经恢复了 Map[Sqrt, xs] 在 Mathematica 中的性能!

我们甚至可以通过添加适当的规则来恢复Sqrt[xs] 的性能:

| Apply(Symbol "Sqrt", [|Packed xs|]) ->
    Packed(Array.map sqrt xs)

我在term rewriting in F#写了一篇文章。

【讨论】:

  • 非常有趣 - +1。实际上,OCaml 在我的学习愿望清单上(也许 F# 也是如此,但在列表中的位置较低)。要考虑的另一件事是Map 自动编译 (JIT) 长度大于 SystemOptions["CompileOptions"-&gt;"MapCompileLength"] 的列表上的函数,默认情况下为 100。这才是真正解释了您注意到的 10 倍差异的原因。
【解决方案3】:

一些测量

基于@rcollyer 的@gdelfino 答案和cmets,我制作了这个小程序:

j = # # + # # &;
g[x_] := x x + x x ;
h = Function[{x}, x x + x x ];


anon = Table[Timing[Do[ # # + # # &[i], {i, k}]][[1]], {k, 10^5, 10^6, 10^5}];
jj   = Table[Timing[Do[ j[i],           {i, k}]][[1]], {k, 10^5, 10^6, 10^5}];
gg   = Table[Timing[Do[ g[i],           {i, k}]][[1]], {k, 10^5, 10^6, 10^5}];
hh   = Table[Timing[Do[ h[i],           {i, k}]][[1]], {k, 10^5, 10^6, 10^5}];

ListLinePlot[ {anon,   jj,    gg,   hh}, 
 PlotStyle -> {Black, Red, Green, Blue},
 PlotRange -> All]

至少对我来说,结果非常令人惊讶:

有什么解释吗?请随时编辑此答案(cmets 对于长文本来说是一团糟)

编辑

使用恒等函数 f[x] = x 进行测试,以将解析与实际评估隔离开来。结果(相同颜色):

注意:对于常数函数 (f[x]:=1),结果与此图非常相似;

【讨论】:

  • 没有编辑能力。但是,你的匿名函数和ghj不一样,所以结果当然不一样。
  • @rcoyller Tnx!我混合了两个版本。现已更正。还是有蓝线(Function ...)说明...
【解决方案4】:

模式匹配似乎更快:

In[1]:= g[x_] := x*x
In[2]:= h = Function[{x}, x*x];

In[3]:= Do[h[RandomInteger[100]], {1000000}] // Timing
Out[3]= {1.53927, Null}

In[4]:= Do[g[RandomInteger[100]], {1000000}] // Timing
Out[4]= {1.15919, Null}

模式匹配也更加灵活,因为它允许您重载定义:

In[5]:= g[x_] := x * x
In[6]:= g[x_,y_] := x * y

对于简单的函数,您可以编译以获得最佳性能:

In[7]:= k[x_] = Compile[{x}, x*x]
In[8]:= Do[k[RandomInteger[100]], {100000}] // Timing
Out[8]= {0.083517, Null}

【讨论】:

  • 我编辑了我的答案以表明使用编译函数可以实现最大性能。但这并不总是可能的。
  • 来自帮助: * Compile 计算对象的次数和顺序可能与普通的 Mathematica 代码不同。 * AND * 编译后的代码不像普通的 Mathematica 代码那样处理数值精度和局部变量。 *
  • @rcollyer 在我的机器中 j = # # &;始终比两者都快
  • @belisarius,很有趣。我的机器也是。因此,使用 Function 这个词似乎会带来一些开销,但纯函数的效果要好得多。在我的机器上设置j=# #&amp; 并使用j 引入了一些开销v 直接使用# #
  • @rcollyer:参加聚会很晚,但仍然......Function[x,...] 较慢的原因是它是一个词法范围构造,必须关心可能的变量名冲突。模式匹配更快的原因在于,虽然它也是一个作用域构造,但它并不关心内部作用域构造中的名称冲突。 #-& 符号函数最快的原因是它不是其他意义上的词法范围构造,换句话说,它要做的检查次数最少。
【解决方案5】:

您可以使用之前answer 中的函数 recordSteps 来查看 Mathematica 对函数的实际作用。它像对待任何其他 Head 一样对待它。 IE,假设你有以下

f = Function[{x}, x + 2];
f[2]

它首先将 f[2] 转换为

Function[{x}, x + 2][2]

在下一步中,x+2 被转换为 2+2。本质上,“函数”评估的行为类似于模式匹配规则的应用,因此它并不快也就不足为奇了。

您可以将 Mathematica 中的所有内容都视为一个表达式,其中评估是在 predefined sequence 中重写部分表达式的过程,这适用于 Function 就像任何其他头一样

【讨论】:

  • 迟来的评论... Function 的评估可能类似于模式匹配,但本质上是不同的。您观察到的原因是 Function[{x},x+2] 确实是一个表达式,并且在其他任何事情之前都对 head 进行了评估。但是,如果我们将Function 视为带有子值的内置符号,则无法解释Function[x,Head[Unevaluated[x]],HoldAll][Print[1]] 如何返回Print。从这个意义上说,Function 独立于模式匹配机制。关于速度问题,请参阅我对其他答案的评论。
  • @Leonid 啊,没有办法为SubValues 指定HoldAll 而不是DownValues,是吗?
  • 是的,没错。可以模仿,但不容易。当我们使用SetDelayed时,符号就是所谓的符号头。对于f[g,h][x,y],它是f。所以,当fHoldAll 时,它将保持g,h,而不是x,y。所以,如果我们假设Function 只是一个带有内置SubValues 的头部,则无法解释它如何保存传递给它的参数。事实上,有一种方法可以通过规则重现此功能,使用评估堆栈,但这既不简单也不强大,而且肯定不是Function 的实现方式。它似乎与“魔法符号”之一有着深厚的联系。
猜你喜欢
  • 2015-09-04
  • 1970-01-01
  • 1970-01-01
  • 2011-08-13
  • 1970-01-01
  • 2016-05-16
  • 1970-01-01
  • 2017-08-17
  • 1970-01-01
相关资源
最近更新 更多