繁体   English   中英

在Common Lisp中,函数和宏之间是否存在性能差异?

[英]In Common Lisp, Is there a performance difference between functions and macros?

请考虑以下两个定义:

(defun fun-add (a b) (+ a b))
(defmacro macro-add (a b) `(+ ,a ,b))

以我有限的理解,“运行”一个函数要比宏快,因为“运行一个宏”还涉及代码扩展。 但是,使用SBCL我得到以下结果:

CL-USER> (time (loop for i below 1e7
                     do (fun-add 15 25)))
Evaluation took:
  0.180 seconds of real time
  0.179491 seconds of total run time (0.179491 user, 0.000000 system)
  99.44% CPU
  396,303,718 processor cycles
  0 bytes consed

NIL


CL-USER> (time (loop for i below 1e7
                     do (macro-add 15 25)))
Evaluation took:
  0.034 seconds of real time
  0.033719 seconds of total run time (0.033719 user, 0.000000 system)
  100.00% CPU
  74,441,518 processor cycles
  0 bytes consed

NIL

为什么会这样呢?

有没有办法使其扩展多次?

事实上,是的。

这是一个示例,首先是在使用宏时通常会遇到的情况,即在评估之前宏只被扩展了一次:

; SLIME 2.23
CL-USER> (defmacro test () (print "EXPANDING"))
TEST
CL-USER> (test)

"EXPANDING" ;; printed
"EXPANDING" ;; return value

CL-USER> (dotimes (i 10) (test))

"EXPANDING" 
NIL

现在,切换到解释模式:

CL-USER> (setf sb-ext:*evaluator-mode* :interpret)
:INTERPRET

CL-USER> (dotimes (i 10) (test))

"EXPANDING"
"EXPANDING" 
"EXPANDING" 
"EXPANDING" 
"EXPANDING" 
"EXPANDING" 
"EXPANDING" 
"EXPANDING" 
"EXPANDING" 
"EXPANDING" 

如果您想开发一个宏并且不想在每次更新代码时都重新编译所有调用程序,则解释模式对wrt宏很有用。

但是,这会带来性能上的损失,因此我认为基准测试不重要。 此外,您最初提出的问题是将苹果与橙子进行比较,因为宏的用途与函数的用途完全不同。

这个问题出了一些混乱,我认为值得尝试解决这个混乱的答案。

首先,宏和函数在Lisp代码中的作用不同,如果您想知道在给定情况下使用哪种宏,几乎肯定会犯错。

  • 函数 (可能更正确地称为procedure ,因为它们可能不计算函数)是运行时计算的事物:它们具有参数,返回结果并且可能具有副作用。 而且它们有一些运行时成本,包括可能的固定调用开销。 有一些技巧可以减少固定成本,也有一些技巧可以使您发现并优化特殊情况:请参见下文。 它们还具有一些编译时开销:编译器不是即时的。 函数的编译时成本通常可以在运行时通过对其进行多次调用来摊销,并且渐近地视为零。 这并不总是正确的:例如,在交互式环境中开发程序时,您可能会非常在意编译时的成本。
  • 是将一些源代码作为其参数并计算另一部分源代码的函数:其扩展。 进行宏扩展的功能( defmacro定义的内容,您可以通过macro-function &c获得),是在编译时而不是运行时调用的。 这意味着宏的所有扩展成本都是程序的编译时成本的一部分,因此,如果程序运行多次,则宏的扩展成本将逐渐变为零。 宏的运行时成本是评估它返回的代码的成本,因为在已编译的代码中没有宏 :它们都已由编译器扩展,只在代码中扩展了它们。

从这一点可以很清楚地看出,首先,函数和宏在程序中扮演着本质上不同的角色–函数执行运行时计算,而宏允许您扩展语言–其次,宏的运行时成本为零。

事情可能比这更复杂的原因有两个。

第一个是,很早以前在Lisp的历史中,人们就想优化小型函数的方法:那些调用函数的固定开销足够大的函数。 Lisp编译器是原始的东西,它们不提供执行此操作的功能,并且不够聪明,无法自行执行。 而且,当然,事实证明,您可以使用宏来执行此操作:您可以滥用宏提供的功能来计算源代码转换以实现内联函数。 人们做到了。

但这是很久以前的事:Common Lisp提供了两种功能,可以消除对此的需要。

  • 您可以将函数声明为内联函数,这给编译器一个巨大的提示,即应对其进行内联调用。 而且您可以执行此操作而无需完全更改函数的定义:您只需在代码中添加适当的(declaim (inline ...)) ,任何合理的编译器都将为您进行内联。
  • 您可以定义编译器宏 ,它们是与编译器将在编译时调用的函数相关联的一种特殊类型的宏,例如,可以检测到函数的特别简单的调用并对其进行优化,同时打孔在更复杂的通话中。 同样,编译器宏完全不会干扰正常的函数定义,尽管它们应谨慎扩展为与编译器宏所使用的功能等效的代码。

不仅如此,现代的Lisp编译器比老式的编译器要聪明得多(现在没有人认为编译Lisp非常困难,我们需要特殊的智能硬件,因此我们可以坚持使用笨拙的编译器),它们通常会做得非常好优化简单调用,尤其是对CL标准中定义的函数的调用本身。

事情可能更加复杂的第二个原因是运行时和编译时可能并不总是截然不同的。 例如,如果您正在编写一个程序,该程序可以编写程序(除了编写宏,这只是简单的例子),那么事件序列会变得很混乱(例如,compile-run-read-metacompile-compile-run) 。 在这种情况下,宏扩展可能会在不同的时间发生,并且您可能最终会遇到与元编译过程相关联的您自己的元宏系统。 这超出了此答案的范围。

在Lisp中,需要进行代码转换。 例如,可以使用它实现新的控件结构。

假设我们要在not if中交换if子句:

(defmacro nif (test else then)
  `(if ,test ,then ,else))

提供这些转换的最初尝试之一是所谓的FEXPR函数:对其参数进行不评估的函数。 然后,FEXPR函数可以决定使用参数做什么,以及在哪种情况下进行评估。

当使用Lisp解释器时,这可以正常工作-一个直接解释Lisp代码的评估器。 但是尚不清楚如何编译此类代码。

可以很好地编译OTOH代码中的宏用法:

  • 代码被扩展
  • 扩展的代码被编译

因此,我们使用宏(而不是FEXPR)的原因之一是它们在编译时得到了扩展,并且在运行时没有开销。

对于Lisp解释器,宏将在运行时扩展。

“运行”函数要比宏快,因为“运行宏”还涉及代码扩展

仅在运行时扩展宏时。 但是在已编译的代码中却并非如此。

感谢Scott Hunter指出。

宏仅扩展一次-可以通过检查

(defvar *macro-count* 0)
(defmacro macro-add (a b) 
  (incf *macro-count*)
  `(+ ,a ,b))
CL-USER> (time (loop for i below 1e8
                     do (macro-add 15 25)))
Evaluation took:
  0.335 seconds of real time
  0.335509 seconds of total run time (0.335509 user, 0.000000 system)
  100.30% CPU
  740,823,874 processor cycles
  0 bytes consed

NIL
CL-USER> *macro-count*
1

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM