Racket编程指南——19 性能

本文探讨了Racket编程语言中的性能优化技巧,包括字节码与实时编译、函数调用优化、突变处理、fixnum与flonum优化等方面的内容,并介绍了如何减少垃圾回收带来的暂停时间。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

19 性能

艾伦·珀利斯(Alan Perlis)有一句著名的话:“Lisp程序员知道一切的价值,而不知道一切的代价(Lisp programmers know the value of everything and the cost of nothing)。”比如,一个Racket程序员知道,程序中任何地方的lambda都会生成一个在其词法环境中封闭的值——但是分配这个值要多少代价呢?尽管大多数程序员对机器级别的各种操作和数据结构的成本有合理的把握,但Racket语言模型和计算机底层之间的差距可能相当大。

本章,我们通过解释Racket编译器和运行时系统的细节以及它们如何影响Racket代码的运行时间和内存性能来缩小这一差距。

19.1 DrRacket中的性能

默认情况下,DrRacket对程序进行调试,而调试工具(由(part ("(lib errortrace/scribblings/errortrace.scrbl)" "top"))库所提供)会显著地降低某些程序的性能。即使通过Choose Language...对话框的Show Details(显示详细信息)面板被禁用调试,默认情况下也会单击Preserve stacktrace复选框,这也会影响性能。禁用调试和堆栈跟踪保留提供了与在纯racket中运行更一致的效果。

即便如此,DrRacket和在DrRacket中开发的程序使用同一个Racket虚拟机, 因此在DrRacket中垃圾收集时间(请参见《内存管理》)可能比程序本身运行时间更长,并且DrRacket线程可能会妨碍程序线程的执行。要获得程序最可靠的的计时结果,请在普通的racket中运行而不是在DrRacket开发环境中运行。应使用非交互式模式而不是REPL,以便受益于模块系统。详情请参见《模块和性能》。

19.2 字节码和实时(JIT)编译器

每个要被Racket求值的定义或表达式都被编译成内部字节码格式。在交互模式下,此编译会自动进行且在运行中进行。像raco make和raco setup这样的工具将编译的字节码编组到一个文件中,这样你就不必每次运行程序时都从源代码进行编译。(编译文件所需的大部分时间实际上花费在宏展开中;从完全展开的代码生成字节码是比较快的。)有关生成字节码文件的详细信息,请参见《同时编译和配置:raco》。

字节码编译器应用所有标准优化,比如常量传输、常量折叠、内联和死代码消除。例如,在+具有其通常绑定的环境中,表达式(let ([x 1] [y (lambda () 4)]) (+ 1 (y)))的编译与常量5相同。

在某些平台上,字节码通过just-in-timeJIT编译器进一步编译成本机代码。JIT编译器大大加快了执行紧凑循环、小整数算法以及不精确实数算法的程序。目前,x86、x86_64(也称为AMD64)、ARM和32位PowerPC处理器支持JIT编译。JIT编译器可以通过racket的eval-jit-enabled参数或racket的--no-jit/-j命令行标志禁用。

JIT编译器在应用函数时以增量方式工作,但JIT编译器在编译过程时仅有限地使用运行时信息,因为给定的模块主体或lambda抽象只编译一次。JIT的编译粒度是单个过程主体,不包含任何词汇嵌套过程的主体。JIT编译的开销通常很小,难以检测。

19.3 模块和性能

模块系统通过帮助确保标识具有通常的绑定来帮助优化。也就是说,编译器可以识别racket/base提供的+并进行内联,这对JIT编译的代码尤为重要。相反,在传统的交互式Scheme系统中,顶级的+绑定可能会被重新定义,因此编译器不能假定固定的+绑定(除非使用特殊标志或声明来弥补模块系统的不足)。

即使在顶级环境中,使用require导入也可以实现一些内联优化。尽管顶层的+定义可能会对导入的+进行覆盖,但覆盖定义仅适用于稍后求值的表达式。

在一个模块中,内联和常量传播优化还利用了这样一个事实,即当没有set!时,模块中的定义不能发生变化在编译时可见。此类优化在顶级环境中不可用。尽管模块内的这种优化对性能很重要,但它阻碍了某些形式的交互开发和探索。当交互式探索更重要时,compile-enforce-module-constants参数禁用JIT编译器关于模块定义的假设。有关更多信息,请参阅《赋值和重定义》。

编译器可以内联函数或跨模块边界传播常量。为了避免在函数内联的情况下生成过多的代码,编译器在选择跨模块内联的候选时是保守的;有关向编译器提供内联提示的信息,请参阅《函数调用优化》。

后边《letrec性能》部分提供一些关于模块绑定内联的附加说明。

19.4 函数调用优化

当编译器检测到对即时可见函数的函数调用时,它会生成比泛型调用更高效的代码,尤其是尾部调用。例如,给定程序

(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绑定的关键字接受过程。

对于对足够小的函数的即时调用,编译器可以通过用函数主体替换调用来内联函数调用。除了目标函数体的大小之外,编译器的试探法还考虑了在调用位置已经执行的内联数量,以及被调用函数本身是否调用了简单基元操作以外的函数。当编译模块时,在模块级定义的一些函数被确定为内联到其它模块的候选函数;通常情况下,只有足够小的函数才被认为是跨模块内联的候选函数,但程序员可以用begin-encourage-inline包装函数定义,以鼓励函数内联。

pair?carcdr这样的基础操作是在机器代码级被JIT编译器内联的。有关内联运算活动的信息,请参见后面的《Fixnum和Flonum优化》。

19.5 突变和性能

利用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的另一个名字的事实。

19.6 letrec性能

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绑定的序列,模块主体中非常量表达式可能会干扰对后面绑定的引用的优化。

19.7 Fixnum和Flonum优化

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特定操作允许取消装盒,有时它们允许编译器重新排序表达式以提高性能。另请参见《未检查、不安全的操作》,尤其是关于不安全的警告。

19.8 未检查、不安全的操作

racket/unsafe/ops库提供的函数与racket/base中的其它函数类似,但它们假定(而不是检查)提供的参数类型正确。例如,unsafe-vector-ref从一个向量中访问一个元素,而不检查它的第一个参数实际上是一个向量,也不检查给定的索引是否在边界内。对于使用这些函数的紧凑循环,避免检查有时可以加快计算速度,尽管不同的未检查函数和不同上下文有不同的好处。

请注意,正如库和函数名称中的“不安全(unsafe)”所暗示的那样,错误使用racket/unsafe/ops的导出可能会导致崩溃或内存损坏。

19.9 外部指针

ffi/unsafe库提供了不安全读取和写入任意指针值的函数。JIT识别ptr-refptr-set!的用法,其中第二个参数是对以下内置C类型之一的直接引用:_int8_int16_int32_int64、 _double_float_pointer。然后,如果第一个参数是ptr-refptr-set!是C指针(不是字节字符串),则指针的读取或写入在生成的代码中内联执行。

字节码编译器会优化对整数缩写的引用,如_int到C类型(如_int32),其中表示大小在平台之间是恒定的,因此JIT可以专门访问这些C类型。C类型(如_long_intptr)在不同平台上是不恒定的,因此它们的使用目前没有被JIT专门化。

使用_float_double进行指针读取和写入目前不受拆盒优化的影响。

19.10 正则表达式性能

当一个字符串或字节字符串被提供给regexp-match这样的函数时,该字符串将在内部编译为regexp值。与其多次提供字符串或字节字符串作为匹配模式,不如使用regexpbyte-regexppregexp或 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)))

19.11 内存管理

Racket实现有两种变体:3mCGC3m变体使用了一个现代的分代垃圾收集器,使得对短期对象的分配开销相对较小。CGC变体使用了一个保守垃圾收集器,它以牺牲Racket内存管理的精度和速度为代价,促进与C代码的交互。3m变体是标准变型。

虽然内存分配开销相当小,但完全避免分配通常更快。有时可以避免分配的一个特定位置是closures(闭包),它包含自由变量函数的运行时表示。例如,

(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,但编译器会自动转换闭包,将其作为参数传递给n

19.12 可访问性和垃圾回收

通常,当垃圾回收器可以证明对象无法从任何其它(可访问)值访问时,Racket会重新使用存储空间来获取某个值。可访问性(reachability)是一个低级的,打破抽象的概念(因此,必须理解运行时系统实现精确判断的许多细节,准确地说,当值可以相互访问时),但一般来说,当存在从第二个值恢复原始值时,一个值可以从另一个值访问。

为了帮助程序员理解对象何时不再可访问并且其存储可以重用,Racket提供了make-weak-boxweak-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))

它将打印两次b has #(struct:fish 7 blue),因为f的定义仍然适用于fish。然而,如果程序是这样的:

#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回收器(即,当实际上无法再访问某个值时,该回收器可能会看到该值是可访问的。

19.13 弱盒及测试

弱盒的一个重要用途是在测试某些抽象是否正确地释放了不再需要的数据的存储,但有一个问题很容易导致此类测试用例不正确地通过。

假设你正在设计一个数据结构,该数据结构需要暂时保存某个值,但应清除字段或以某种方式断开链接,以避免引用该值,以便回收该值。弱箱子是测试数据结构是否能正确清除值的好方法。也就是说,你可以编写一个测试用例,构建一个值,从中提取一些其他值(你希望它变得不可访问),将提取的值放入一个弱盒中,然后检查该值是否从盒子消失。

这段代码试图遵循这种模式,但它有一个微妙的错误:

#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也不是。

19.14 减少垃圾回收暂停

默认情况下,Racket的分代垃圾回收器为频繁的小回收(minor collections)创建短暂停,这只会检查最近分配的对象,为不频繁的大回收(major collections)创建长暂停,这会重新检查所有内存。

对于某些应用程序,如动画和游戏,由于一个大集合而导致的长时间暂停可能会对程序的操作造成无法接受的干扰。为了减少大回收暂停,Racket垃圾回收器支持增量式垃圾回收(incremental garbage-collection)模式。在增量模式中,小回收通过向下一个大回收执行额外工作来创建更长(但仍然相对较短)的暂停。如果一切顺利,一个大回收的大部分工作都是由小回收完成的,在需要大回收的时候,所以大回收暂停和小回收暂停一样短。增量模式总体上运行更慢,但它可以提供更一致的实时行为。

当在Racket启动时将PLT_INCREMENTAL_GC环境变量设置为以1yY开头的值,则将永久启用增量模式。然而,由于增量模式仅对某些程序的某些部分有用,并且由于增量模式的需要是程序的属性而不是其环境的属性,因此启用增量模式的首选方式是使用(collect-garbage 'incremental)

调用(collect-garbage 'incremental)不会立即执行垃圾回收,而是请求每个小回收执行增量工作,直到下一个大回收。该请求将在下一次大回收时过期。在应用程序中需要实时响应的任何重复任务中调用(collect-garbage 'incremental)。在初始(collect-garbage 'incremental)之前,使用(collect-garbage)强制进行完全回收,以从最佳状态启动增量模式。

要检查是否使用了增量模式以及它如何影响暂停时间,请为GC主题启用debug级日志记录输出。例如,

  racket -W "debuG@GC error" main.rkt

运行"main.rkt"并将垃圾回收日志记录到标准错误(stderr)(同时保留所有主题的error级日志记录)。小回收由min行报告,增量模式小回收由mIn行报告,大回收由MAJ(专业)行报告。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值