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

时间:2019-06-05 12:20:39

标签: performance lisp common-lisp

请考虑以下两个定义:

(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

为什么会这样?

4 个答案:

答案 0 :(得分:4)

  

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

事实上,是的。

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

; 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" 

解释模式可能有用宏,如果您想开发一个宏并且不想在每次更新代码时都重新编译所有调用者。

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

答案 1 :(得分:3)

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

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

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

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

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

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

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

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

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

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

答案 2 :(得分:2)

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

想象一下,我们想在 if 中交换if子句:

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

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

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

可以有效地编译OTOH代码中的宏用法:

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

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

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

  

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

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

答案 3 :(得分:1)

感谢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