为什么“ let”在词法范围内更快?


31

在阅读dolist宏的源代码时,我遇到了以下注释。

;; 这不是一个可靠的测试,但这并不重要,因为两种语义都可以接受,其中一种在动态作用域下会更快,而另一种在词法作用域下会更快(并且具有更清晰的语义)

其中提到了此代码段(为清楚起见,我对其进行了简化)。

(if lexical-binding
    (let ((temp list))
      (while temp
        (let ((it (car temp)))
          ;; Body goes here
          (setq temp (cdr temp)))))
  (let ((temp list)
        it)
    (while temp
      (setq it (car temp))
      ;; Body goes here
      (setq temp (cdr temp)))))

看到let循环中使用了某种形式,这让我感到惊讶。我曾经认为,与setq在相同的外部变量上重复使用相比,这比较慢(就像上面第二种情况一样)。

我本可以将其视为无用,如果不是在其上方的注释中明确表示,这比替代方法(带有词法绑定)要快。所以...为什么呢?

  1. 为什么上面的代码在词法绑定和动态绑定方面的性能有所不同?
  2. 为什么let用词法形式更快?

Answers:


38

一般而言,词汇绑定与动态绑定

考虑以下示例:

(let ((lexical-binding nil))
  (disassemble
   (byte-compile (lambda ()
                   (let ((foo 10))
                     (message foo))))))

它会编译并立即反汇编lambda带有局部变量的简单变量。随着lexical-binding残疾人,如上,字节代码如下:

0       constant  10
1       varbind   foo
2       constant  message
3       varref    foo
4       call      1
5       unbind    1
6       return    

注意varbindvarref说明。这些指令分别在堆内存全局绑定环境中按名称分别绑定和查找变量。所有这些都会对性能产生不利影响:它涉及字符串散列和比较,用于全局数据访问的同步以及对CPU缓存的不良影响的重复堆内存访问。另外,动态变量绑定需要在末尾恢复到其先前的变量,这会为每个具有绑定的块添加额外的查找。letnletn

如果您绑定lexical-bindingt在上面的例子中,字节代码看起来有点不同:

0       constant  10
1       constant  message
2       stack-ref 1
3       call      1
4       return    

请注意,varbind并且varref完全消失了。只需将局部变量压入堆栈,并通过stack-ref指令以恒定偏移量进行引用即可。本质上,变量绑定,并用读恒定时间在堆栈存储器中读取和写入,这是完全本地并因此并发和CPU的高速缓存以及播放,并且不涉及到有任何字符串。

一般来说,局部变量的词法绑定查询(例如letsetq等)有少得多的运行时间和存储的复杂性

这个具体的例子

使用动态绑定,由于上述原因,每个let都会导致性能下降。让得越多,动态变量绑定就越多。

值得注意的是,体内有一个额外letloop变量,绑定的变量将需要在循环的每次迭代中恢复,从而为每次迭代添加一个额外的变量查找。因此,将let保留在循环主体之外的速度更快,这样,在整个循环完成之后,仅将迭代变量重置一次。但是,这并不是特别优雅,因为迭代变量在实际需要之前就已经绑定了。

使用词法绑定,lets很便宜。值得注意的是,let在循环体内(在性能方面)并不比在循环体外更差let。因此,将变量尽可能地局部绑定,并将迭代变量限制在循环主体中是完全可以的。

它也稍快一些,因为它可以编译更少的指令。考虑下面的并排反汇编(右侧的本地让):

0       varref    list            0       varref    list         
1       constant  nil             1:1     dup                    
2       varbind   it              2       goto-if-nil-else-pop 2 
3       dup                       5       dup                    
4       varbind   temp            6       car                    
5       goto-if-nil-else-pop 2    7       stack-ref 1            
8:1     varref    temp            8       cdr                    
9       car                       9       discardN-preserve-tos 2
10      varset    it              11      goto      1            
11      varref    temp            14:2    return                 
12      cdr       
13      dup       
14      varset    temp
15      goto-if-not-nil 1
18      constant  nil
19:2    unbind    2
20      return    

不过,我不知道是什么导致了差异。


7

简而言之-动态绑定非常慢。词法绑定在运行时非常快。根本原因是词法绑定可以在编译时解决,而动态绑定则不能。

考虑以下代码:

(let ((x 42))
    (foo)
    (message "%d" x))

在编译时let,编译器无法知道是否需要foo访问(动态绑定的)变量x,因此它必须为创建一个绑定x,并且必须保留该变量的名称。随着词汇绑定,编译器只是转储x上绑定堆栈,没有它的名字,并直接访问权条目。

但是,等等-还有更多。使用词法绑定,编译器能够验证此特定绑定x仅在代码中用于message;。由于x从未修改过,因此可以安全地内联x并屈服

(progn
  (foo)
  (message "%d" 42))

我不认为当前的字节码编译器会执行这种优化,但是我有信心将来会这样做。

简而言之:

  • 动态绑定是一项繁重的操作,几乎没有机会进行优化;
  • 词法绑定是一种轻量级的操作;
  • 只读值的词法绑定通常可以被优化掉。

3

此注释并不表明词汇绑定比动态绑定更快或更慢。相反,它表明那些不同形式在词汇和动态绑定下具有不同的性能特征,例如,其中一种在一个绑定规则下是可取的,而另一种在另一个约束下是可取的。

所以词法范围比动态范围更快?我怀疑在这种情况下差异不大,但我不知道-您真的必须进行衡量。


1
varbind在词法绑定下没有编译的代码。这就是重点和目的。
lunaryorn 2014年

嗯 我创建了一个包含以上源代码的文件,首先以;; -*- lexical-binding: t -*-,加载它并调用(byte-compile 'sum1),并假设生成了根据词法绑定编译的定义。但是,似乎没有。
gsg

删除了基于该错误假设的字节码注释。
gsg 2014年

lunaryon的回答表明,该代码显然在词汇快结合(尽管当然只在微观层面)。
shosti 2014年

@gsg此声明只是一个标准文件变量,它对从相应文件缓冲区外部调用的函数没有影响。IOW,只有当您访问源文件然后byte-compile在当前对应的缓冲区是当前缓冲区的情况下调用时才起作用,顺便说一句,这正是字节编译器正在执行的操作。如果byte-compile分别调用,则需要lexical-binding像我在回答中所做的那样显式设置。
lunaryorn 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.