跳至内容

性能

遵循以下提示,以获得程序在速度和内存方面的最佳性能。

过早优化

唐纳德·克努斯曾经说过

我们应该忘记大约 97% 的时间的小效率:过早优化是万恶之源。然而,我们不应该错过那关键的 3% 的机会。

但是,如果您正在编写一个程序,并且您意识到编写一个语义上等效、更快的版本只需要进行微小的更改,那么您不应该错过这个机会。

并且始终确保分析您的程序以了解其瓶颈。对于分析,在 macOS 上,您可以使用 Instruments Time Profiler(随 XCode 提供),或者使用 抽样分析器 之一。在 Linux 上,任何可以分析 C/C++ 程序的程序,例如 perfCallgrind,都应该可以工作。对于 Linux 和 OS X,您可以通过在调试器中运行程序,然后按“ctrl+c”来偶尔中断它,并发出 gdb `backtrace` 命令来查找回溯模式(或使用 gdb poor man's profiler,它为您做同样的事情,或者 OS X `sample` 命令)。

确保始终通过使用 `--release` 标志编译或运行程序来分析程序,该标志将打开优化。

避免内存分配

您可以在程序中进行的最佳优化之一是避免额外的/无用的内存分配。当您创建一个 **类** 的实例时,就会发生内存分配,这最终会分配堆内存。创建 **结构体** 的实例使用堆栈内存,不会产生性能损失。如果您不知道堆栈和堆内存之间的区别,请务必 阅读此内容

分配堆内存很慢,并且会给垃圾收集器 (GC) 带来更多压力,因为它以后必须释放该内存。

有几种方法可以避免堆内存分配。标准库以一种可以帮助您做到这一点的方式设计。

写入 IO 时不要创建中间字符串

要将数字打印到标准输出,您编写

puts 123

在许多编程语言中,会发生的事情是,将调用 `to_s` 或类似方法将对象转换为其字符串表示形式,然后将该字符串写入标准输出。这有效,但它有一个缺陷:它创建一个中间字符串,位于堆内存中,只为写入它然后丢弃它。这涉及堆内存分配,并且为 GC 带来了一些工作。

在 Crystal 中,`puts` 将在对象上调用 `to_s(io)`,将 IO 传递给它,该 IO 应该写入字符串表示形式。

因此,您永远不应该这样做

puts 123.to_s

因为它会创建一个中间字符串。始终将对象直接追加到 IO。

在编写自定义类型时,始终确保覆盖 `to_s(io)`,而不是 `to_s`,并避免在该方法中创建中间字符串。例如

class MyClass
  # Good
  def to_s(io)
    # appends "1, 2" to IO without creating intermediate strings
    x = 1
    y = 2
    io << x << ", " << y
  end

  # Bad
  def to_s(io)
    x = 1
    y = 2
    # using a string interpolation creates an intermediate string.
    # this should be avoided
    io << "#{x}, #{y}"
  end
end

这种追加到 IO 而不是返回中间字符串的理念,会导致比处理中间字符串更好的性能。您也应该在您的 API 定义中使用这种策略。

让我们比较一下时间

io_benchmark.cr
require "benchmark"

io = IO::Memory.new

Benchmark.ips do |x|
  x.report("without to_s") do
    io << 123
    io.clear
  end

  x.report("with to_s") do
    io << 123.to_s
    io.clear
  end
end

输出

$ crystal run --release io_benchmark.cr
without to_s  77.11M ( 12.97ns) (± 1.05%)       fastest
   with to_s  18.15M ( 55.09ns) (± 7.99%)  4.25× slower

始终记住,不仅是时间有所改善:内存使用率也降低了。

使用字符串插值而不是串联

有时您需要直接使用由组合字符串字面量和其他值构建的字符串。您不应该仅仅使用 `String#+(String)` 串联这些字符串,而是使用 字符串插值,它允许将表达式嵌入到字符串字面量中:`“Hello, #{name}”` 比 `“Hello, ” + name.to_s` 更好。

插值字符串由编译器转换为追加到字符串 IO,以便它自动避免中间字符串。上面的示例转换为

String.build do |io|
  io << "Hello, " << name
end

避免为字符串构建分配 IO

最好使用专用于构建字符串的 `String.build`,而不是创建一个中间的 `IO::Memory` 分配。

require "benchmark"

Benchmark.ips do |bm|
  bm.report("String.build") do
    String.build do |io|
      99.times do
        io << "hello world"
      end
    end
  end

  bm.report("IO::Memory") do
    io = IO::Memory.new
    99.times do
      io << "hello world"
    end
    io.to_s
  end
end

输出

$ crystal run --release str_benchmark.cr
String.build 597.57k (  1.67µs) (± 5.52%)       fastest
  IO::Memory 423.82k (  2.36µs) (± 3.76%)  1.41× slower

避免反复创建临时对象

考虑一下这个程序

lines_with_language_reference = 0
while line = gets
  if ["crystal", "ruby", "java"].any? { |string| line.includes?(string) }
    lines_with_language_reference += 1
  end
end
puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"

上面的程序有效,但有一个很大的性能问题:在每次迭代中,都会为 `[“crystal”, “ruby”, “java”]` 创建一个新数组。记住:数组字面量只是创建数组实例并向其中添加一些值的语法糖,并且这将在每次迭代中反复发生。

有两种方法可以解决这个问题

  1. 使用元组。如果您在上面的程序中使用 `{“crystal”, “ruby”, “java”}`,它将以相同的方式工作,但是由于元组不涉及堆内存,因此它会更快,消耗更少的内存,并且给编译器更多机会来优化程序。

    lines_with_language_reference = 0
    while line = gets
      if {"crystal", "ruby", "java"}.any? { |string| line.includes?(string) }
        lines_with_language_reference += 1
      end
    end
    puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"
    
  2. 将数组移到一个常量中。

    LANGS = ["crystal", "ruby", "java"]
    
    lines_with_language_reference = 0
    while line = gets
      if LANGS.any? { |string| line.includes?(string) }
        lines_with_language_reference += 1
      end
    end
    puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"
    

使用元组是首选方法。

循环中的显式数组字面量是创建临时对象的一种方式,但这些对象也可以通过方法调用来创建。例如,`Hash#keys` 将在每次调用时返回一个包含键的新数组。与其这样做,您可以使用 `Hash#each_key`、`Hash#has_key?` 和其他方法。

尽可能使用结构体

如果您将您的类型声明为 **结构体** 而不是 **类**,那么创建它的实例将使用堆栈内存,这比堆内存便宜得多,并且不会给 GC 带来压力。

但是,您不应始终使用结构体。结构体按值传递,因此如果您将结构体传递给方法,并且该方法对结构体进行了更改,则调用方将不会看到这些更改,因此它们可能容易出现错误。最好的做法是仅将结构体用于不可变对象,尤其是当它们很小时。

例如

class_vs_struct.cr
require "benchmark"

class PointClass
  getter x
  getter y

  def initialize(@x : Int32, @y : Int32)
  end
end

struct PointStruct
  getter x
  getter y

  def initialize(@x : Int32, @y : Int32)
  end
end

Benchmark.ips do |x|
  x.report("class") { PointClass.new(1, 2) }
  x.report("struct") { PointStruct.new(1, 2) }
end

输出

$ crystal run --release class_vs_struct.cr
 class  28.17M (± 2.86%) 15.29× slower
struct 430.82M (± 6.58%)       fastest

迭代字符串

Crystal 中的字符串始终包含 UTF-8 编码的字节。UTF-8 是一种可变长度编码:一个字符可能由多个字节表示,尽管 ASCII 范围内的字符始终由单个字节表示。因此,使用 String#[] 对字符串进行索引不是一个 O(1) 操作,因为每次都需要对字节进行解码才能找到给定位置的字符。Crystal 的 String 在这里有一个优化:如果它知道字符串中的所有字符都是 ASCII,那么 String#[] 可以用 O(1) 来实现。但是,这通常不适用。

因此,以这种方式迭代字符串并非最佳选择,实际上它的复杂度为 O(n^2)

string = "foo"
while i < string.size
  char = string[i]
  # ...
end

上述方法存在第二个问题:计算字符串的 size 也很慢,因为它不仅仅是字符串中的字节数(bytesize)。但是,一旦计算出字符串的大小,它就会被缓存。

在这种情况下,提高性能的方法是使用其中一种迭代方法(each_chareach_byteeach_codepoint),或者使用更底层的 Char::Reader 结构。例如,使用 each_char

string = "foo"
string.each_char do |char|
  # ...
end