Alan Perlis著名佳句:”Lisp程序员知道一切的价值和虚无的代价(Lisp programmers know the value ofeverything and the cost of nothing)。“一个Racket程序员知道,例如,一个lambda程序中的任何地方都会生成一个在词法环境中封闭的值——但是分配这个值要多少代价呢?程序员有一个合理而且必须掌握的在机器的水平的各种操作的成本和数据结构,Racket语言模型和优先计算机械之间的差距可以是相当大的。
在这一章中,我们通过解释Racket编译器和运行时系统的细节以及它们如何影响Racket代码的运行时间和内存性能来缩小这一差距。
默认情况下,用于调试和调试工具的DrRacket工具程序能为一些程序显著地降低性能。即使调试在通过Choose Language...对话框的Show Details(显示详细信息)面板被禁用,Preserve stacktrace复选框默认被点击选择,这也会影响性能。禁用调试堆栈保存提供性能结果,它更符合在朴实无华的racket中运行。
即便如此,DrRacket和开发的程序在DrRacket中使用相同的Racket虚拟机,所以在DrRacket的垃圾收集时间(见内存管理)可能会比程序本身运行时间更长,同时DrRacket线程可能妨碍程序线程的执行。对于一个程序的最可靠的计时结果,是在普通的racket中运行而不是在DrRacket开发环境中运行。对模块的系统的效益来讲非交互式模式应该用来代替REPL。详情请参见模块和性能。
将要被Racket求值的每个定义或表达式都编译成一个内部字节码格式。在交互模式下,这种编译是自动进行并且快速。同时,raco make和raco setup这样的工具将编译的字节码编组到一个文件中,因此你不必每次你运行一个程序时从源代码中编译。(被需要去编译一个文件的大部分时间实际上是处于宏扩展中;完全展开的代码生成的字节码是比较快的。)参见同时编译和配置:raco获取生成字节码文件的更多信息。
字节码编译器应用所有标准的优化,比如常量传输、常量折叠、内联,和死代码消除。例如,在一个+有其通常的绑定的环境中,表达式(let ([x 1] [y (lambda () 4)]) (+ 1 (y)))被编译成常量 5。
在一些平台上,字节码通过一个just-in-time编译器或JIT编译器进一步编译成本机代码。JIT编译器充分加快执行紧凑的循环的程序、小整数算法以及不精确实数算法。目前,JIT编译支持x86、x86_64(也叫作AMD64)、ARM和32位PowerPC处理器。JIT编译器可以通过eval-jit-enabled参数或者--no-jit/-j命令行标志的racket禁用。
JIT编译器随着函数的应用逐步地工作,但是JIT编译器在编译过程时只对运行时信息进行有限的使用,因为给定的模块主体或lambda抽象只编译一次。JIT的编译的粒度是一个单一的程序体,不计算任何嵌套的程序的主体。JIT编译的开销通常很小以致很难检测到。
模块系统通过帮助确保标识符具有通常的绑定来帮助优化。那是,通过racket/base提供的+可由编译器辨别并内联,这对JIT编译的代码是特别重要的。相反,在一个传统的交互式Scheme系统中,顶层的+绑定可能被重新定义,因此编译器不能假定一个固定的+绑定(除非特殊的标志或声明用于弥补模块系统的不足)。
即使在顶级环境中,带require的导入使一些内联优化成为可能。尽管一个在顶层的+定义可能会对覆盖一个导入的+,但覆盖定义只适用于稍后求值的表达式。
在模块中,内联和常数传输优化采取额外的优势,在模块中不能定义可以突变时没有set!在编译时可见。此类优化在顶层环境中不可用。虽然模块内的优化对性能很重要,但它阻碍了一些交互开发和探索的形式。当交互探索更重要时,compile-enforce-module-constants参数禁用JIT编译器关于模块定义的假设。有关更多信息,请参阅赋值和重定义。
编译器可以内联函数或在模块边界上传播常量。为了避免在内联函数的情况下产生了太多的代码,编译器是保守的跨模块内联选择候选人;看到函数调用的优化提供给编译器内联提示信息。
后边的章节letrec性能提供一些额外的关于模块绑定内联的附加说明。
当编译器检测到一个对即时可见函数的函数调用时,它生成的代码比一般调用更高效,尤其是对于尾部调用。例如,给定程序
(letrec ([odd (lambda (x) (if (zero? x) #f (even (sub1 x))))] [even (lambda (x) (if (zero? x) #t (odd (sub1 x))))]) (odd 40000000))
编译器可以检测odd–even循环并通过循环展开和相关的优化产生运行速度更快的代码。
在一个模块表里,define变量是像letrec绑定这样的词法作用域,并且定义在一个模块内因此允许调用优化,那么
(define (odd x) ....) (define (even x) ....)
其中一个模块可以执行相同的letrec版本。
对于带有关键字参数的函数的直接调用,编译器通常可以静态检查关键字参数并生成一个对函数非关键字变体的直接调用,从而减少关键字检查的运行时开销。此优化仅适用于用define绑定的接受关键字的过程。
对于对足够小的函数的即时调用,编译器可以通过函数的主体替换调用来内联函数调用。除了目标函数体的大小之外,编译器的启发式考虑量已经进行内联调用站点上是否被调用函数本身调用其他比简单的基本操作函数。当一个模块被编译,在模块级定义一些函数确定为内联到其他模块的候选人;通常情况下,只有微不足道的功能被认为是跨模块内嵌的候选人,但程序员可以把函数定义开始鼓励鼓励内联内联函数。
像pair?、car和cdr这样的原始操作是在机器代码级被JIT编译器内联。参见后面的章节Fixnum和Flonum优化获取关于内联的算术运算的信息。
利用set!突变一个变量会导致坏的性能。例如,小规模基准测试
#lang racket/base (define (subtract-one x) (set! x (sub1 x)) x) (time (let loop ([n 4000000]) (if (zero? n) 'done (loop (subtract-one n)))))
比同等的慢得多
#lang racket/base (define (subtract-one x) (sub1 x)) (time (let loop ([n 4000000]) (if (zero? n) 'done (loop (subtract-one n)))))
在第一个变量中,每次迭代都为x分配一个新位置,导致性能不佳。一个更聪明的编译器可以解开在第一个示例中set!的使用,由于不支持突变(参见使用赋值的指导原则),编译器的努力将花费在其它地方。
更重要的是,突变可以掩盖在内联和传播常数可能应用的绑定。例如,在
(let ([minus1 #f]) (set! minus1 sub1) (let loop ([n 4000000]) (if (zero? n) 'done (loop (minus1 n)))))
set!掩盖了一个事实:minus1只是给内置的sub1的另一个名字。
当letrec被用于仅绑定过程和字面量,那么编译器能够以最佳方式处理绑定,有效编译绑定的使用。当其它类型的绑定与程序混合时,编译器也许不太能确定控制流。
例如,
(letrec ([loop (lambda (x) (if (zero? x) 'done (loop (next x))))] [junk (display loop)] [next (lambda (x) (sub1 x))]) (loop 40000000))
可能编译成比以下内容效率更低的代码
(letrec ([loop (lambda (x) (if (zero? x) 'done (loop (next x))))] [next (lambda (x) (sub1 x))]) (loop 40000000))
在第一种情况下,编译器可能不知道display不调用loop。如果是,那么loop可能在绑定成为可获得的之前引用next。
这关于letrec的附加说明也适用于作为内部定义或模块的函数和常量的定义。在一个模块主体中的一个定义序列类似于一个letrec绑定序列,同时在一个模块主体中的非常数表达式可以用后期绑定引用的优化干涉。
一个fixnum是一个小的精确的整数。在这种情况下,“小”取决于平台。对于一个32位的机器,可以在30位加一个符号位表示的数字代表fixnum。在64位机器上,可用62位加一个符号位。
一个flonum用来表示任何不精确实数。它们对应于所有平台上的64位IEEE浮点数。
内联fixnum和flonum算术运算是JIT编译器的最重要的好处。例如,当+应用于两个参数,生成的机器代码的测试是否两参数是fixnum,如果是的话,它使用机器的指令添加数字(和溢出检查)。如果这两个数字都不fixnum,然后检查是否都flonum;在这种情况下,直接使用机器的浮点运算。当参数既是所有fixnum也是flonum时,对于函数可以接受任意数量的参数,如+、对两个或两个以上参数的内联工作(除-之外,其争论的焦点之一是内联)。
flonum通常是被装箱(boxed),这意味着内存被分配以容纳每一个flonum计算结果。幸运的是,世代的垃圾收集器(稍后在内存管理中描述)为较短的结果廉价地做分配。fixnum相比之下没有被装箱,所以他们通常廉价地使用。
见前景并行以了解一个flonum具体操作的使用例子。
racket/flonum库提供flonum具体操作,以及flonum操作的组合允许JIT编译器生成代码,它避免装箱和拆箱的中间结果。除了在即时的组合里结果,用let绑定并被一个后来的具体flonum操作接受的flonum具体结果在临时存储之中被开箱。最后,编译器可以检测一些flonum值循环收集器并避免收集器的装箱。字节码反编译器(见(part ("(lib scribblings/raco/raco.scrbl)" "decompile")))诠释组合,那里JIT可以避免用#%flonum、#%as-flonum和#%from-flonum装箱。
局部绑定和收集器的拆箱不被PowerPC的JIT支持。
racket/unsafe/ops库提供未经检查的fixnum和flonum具体操作。未经检查的的flonum具体操作允许拆箱,并且有时它们允许编译器重排表达式来提高性能。也参见未检查的、不安全的操作,特别是关于不安全的警告。
racket/unsafe/ops库提供类似于racket/base里的其它函数的函数,但它们假设(而不是检查)提供的参数是正确的类型。例如,unsafe-vector-ref从一个向量中访问一个元素,而不检查它的第一个参数实际上是一个向量,而不检查给定的索引是否处于界限之内。对于使用这些函数的紧循环,避免检查有时可以加快计算速度,尽管不同的未检查函数和不同上下文的好处有所不同。
要小心的是,如同在库和函数名建议中的“不安全”,滥用racket/unsafe/ops导出会导致崩溃或内存损坏。
ffi/unsafe库提供非安全读写任意指针值的函数。JIT承认ptr-ref和ptr-set!的使用其在第二个参数是一个对下面的一个内置C类型的直接引用:_int8、_int16、_int32、_int64、_double、_float和_pointer。然后,,然后在生成的代码中执行指针读写操作。
字节码编译器会优化涉及整数的缩写的引用,像_int对C类型像_int32——其表现大小是静态的跨平台——所以JIT可以专门用那些C类型访问。C类型如_long或_intptr不是静态的跨平台使用,所以它们的使用目前没有被JIT专门化。
指针使用_float或_double读取和写入目前没有受到拆箱的优化。
当一个字符串或字节字符串提供一个类似regexp-match的函数,然后这个字符串被内部编译成一个regexp值。而是多次提供一个字符串或字节字符串作为一个匹配的模式,编译这个模式一次成为一个使用regexp、byte-regexp、pregexp或byte-pregexp的regexp值。在一个常量字符串或字节字符串的地方,使用#rx或#px前缀写一个常数regexp。
(define (slow-matcher str) (regexp-match? "[0-9]+" str)) (define (fast-matcher str) (regexp-match? #rx"[0-9]+" str)) (define (make-slow-matcher pattern-str) (lambda (str) (regexp-match? pattern-str str))) (define (make-fast-matcher pattern-str) (define pattern-rx (regexp pattern-str)) (lambda (str) (regexp-match? pattern-rx str)))
Racket的实现在两个变种方面有表现:3m和CGC。3m变种使用了现代的generational garbage collector(一代垃圾收集器),使得对短生存期对象的分配相对便宜。CGC变种使用一个conservative garbage collector(保守的垃圾收集器)便于交互的C代码在Racket内存管理的精度和速度上的开销。3m变体是标准的。
虽然内存分配相当便宜,但避免完全分配通常更快。有时可以避免分配的一个特殊位置是在闭包中,这是包含自由变量函数的运行时表示。例如,
(let loop ([n 40000000] [prev-thunk (lambda () #f)]) (if (zero? n) (prev-thunk) (loop (sub1 n) (lambda () n))))
在每个迭代中分配一个闭包,因为(lambda () n)有效地保存n。
编译器可以自动清除许多闭包。例如,在
(let loop ([n 40000000] [prev-val #f]) (let ([prev-thunk (lambda () n)]) (if (zero? n) prev-val (loop (sub1 n) (prev-thunk)))))
中没有闭包被永远分配给prev-thunk,因为只有应用程序是可见的,所以它是内联的。同样,在
(let n-loop ([n 400000]) (if (zero? n) 'done (let m-loop ([m 100]) (if (zero? m) (n-loop (sub1 n)) (m-loop (sub1 m))))))
中,那么let的扩展表实现m-loop包含一个n上的闭包,但编译器自动将闭包转变以传递给自己的n而不是作为一个参数。
一般来说,当垃圾收集器可以证明对象与其它(可执行到的)值执行不到时,Racket重新使用存储值。可执行性(reachability)是一个低级别的抽象概念,打破概念抽象(因此,当值彼此是可执行到的时,必须准确理解运行时系统实现的许多细节,以便准确地判断。),但一般来说,当有一些操作从第二个恢复原始值时,可以从第二个值中获得一个值。
为了帮助程序员了解什么时候一个对象不再是可执行到的以及什么时候它的存储可以重复使用,Racket提供make-weak-box和weak-box-value,创建者和访问者针对垃圾收集器专门处理的一个记录结构。在弱格子中的一个对象不算作可执行到的,所以weak-box-value可能返回格子里的对象,但它也可以返回#f标示对象不可执行到同时垃圾被收集。注意,除非实际发生垃圾收集,否则该值将保持在弱格子中,即使它是不可执行到的。
例如,考虑这个程序:
#lang racket (struct fish (weight color) #:transparent) (define f (fish 7 'blue)) (define b (make-weak-box f)) (printf "b has ~s\n" (weak-box-value b)) (collect-garbage) (printf "b has ~s\n" (weak-box-value b))
因为f的定义仍然保存着fish,它将打印b has #(struct:fish 7 blue)两次。然而,如果程序是这样的话:
#lang racket (struct fish (weight color) #:transparent) (define f (fish 7 'blue)) (define b (make-weak-box f)) (printf "b has ~s\n" (weak-box-value b)) (set! f #f) (collect-garbage) (printf "b has ~s\n" (weak-box-value b))
第二个打印输出将是b has #f,因为没有对fish的引用存在(除了格子里的那个)。
作为第一个近似值,Racket中的所有值都必须分配,并且会显示出与上面的fish相似的行为。然而,也有一些例外:
小整数(用fixnum?识别)总是没有明确分配的情况下可用。从垃圾收集器和弱格子的角度来看,它们的存储永远不会被回收。(然而,由于巧妙的表现技巧,它们的存储并不等于Racket的使用空间。也就是说,它们实际上是免费的。)
编译器可以看到所有调用站点的过程可能永远不会分配(如上所述)。类似的优化也可以消除对其他类型值的分配。
拘禁符号仅分配一次(如上所述)。Racket内部的表格跟踪这个分配,所以一个符号不能变成垃圾,因为那张表格保存着它。
可执行性是近似于CGC收集器(即当有一个收集器的时候,一个值可以对那个收集器表现为可执行到的,事实上,再没有办法抓到它了。
弱格子的一个重要用途是在测试一些抽象地释放不再需要的存储数据,但是有一个问题,它很容易造成这样的测试用例不当通过。
假设您正在设计一个数据结构,它需要暂时保存某个值,但应清除一个字段或以某种方式中断一个链接以避免引用该值,以便收集该值。弱格子是测试数据结构正确清除值的好方法。也就是说,你可以编写一个构建一个值的测试用例,从中提取一些值(希望成为不可执行到的),将提取的值放入一个弱格子中,然后检查该值是否从该格子中消失。
这段代码是一种对于遵循这种模式的尝试,但它有一个微妙的bug:
#lang racket (let* ([fishes (list (fish 8 'red) (fish 7 'blue))] [wb (make-weak-box (list-ref fishes 0))]) (collect-garbage) (printf "still there? ~s\n" (weak-box-value wb)))
具体来说,它会显示弱格子是空的,但不是因为fishes不再保存这个值,而是因为fishes本身不再是可执行到的!
把程序改成这个:
#lang racket (let* ([fishes (list (fish 8 'red) (fish 7 'blue))] [wb (make-weak-box (list-ref fishes 0))]) (collect-garbage) (printf "still there? ~s\n" (weak-box-value wb)) (printf "fishes is ~s\n" fishes))
并且现在我们看到了预期的结果。不同的是,最后一次发生的变量的fishes。这就构成了一个对列表的引用,确保列表本身不是垃圾收集的,因此red的fish也不是。
默认情况下,Racket的分代垃圾收集器(generational garbage collector)因为频繁的小收集(minor collections)而产生短暂的停顿,这些小收集只检查最近分配的对象,并对稀少的大收集(major collections)进行长时间停顿,这些大收集重新检查所有内存。
对于某些应用程序,如动画和游戏,由于大收集而产生的长时间停顿会用一个程序操作不可接受地干预。为了减少主要的收集停顿,Racket垃圾收集器支持增量垃圾收集(incrementalgarbage-collection)模式。在增量模式中,小收集通过对下一个大收集执行额外的工作来造成更长(但仍然相对较短)的停顿。如果一切顺利,大多数大收集的工作都是由一个大收集被需要的小收集时间完成的,所以大收集的停顿和小收集的停顿一样短暂。增量模式总体上运行速度较慢,但它可以提供更一致的实时行为。
当Racket启动时,如果PLT_INCREMENTAL_GC环境变量被设置成一个值,它从1、y或Y开始,增量模式是永久启用。然而,由于增量模式只对某些程序的某些部分有用,并且由于增量模式的需要是程序的一个属性而不是其环境,所以启用增量模式的首选方式是(collect-garbage 'incremental)。
调用(collect-garbage 'incremental)不会执行一个立即垃圾收集,而是请求每个小收集执行增量工作到下一个大收集。请求失效于下一个大收集。在需要实时响应的一个应用程序里的任何重复任务中制造一个对(collect-garbage 'incremental)的调用。在一个初始的(collect-garbage 'incremental)之前强制一个用(collect-garbage)的完全收集以从最佳状态启动增量模式。
要检查增量模式是否使用以及它如何影响暂停时间,使debug级日志能够为GC主题输出。例如,
racket -W "debuG@GC error" main.rkt
用垃圾收集日志对标准错误(stderr)运行"main.rkt"(同时保持为所有主题的error级记录)。小收集是由min(分钟)线报告,增量模式小收集用 mIn线报告,大收集用MAJ(专业)线报告。