性能¶
遵循以下提示,以获得程序在速度和内存方面的最佳性能。
过早优化¶
唐纳德·克努斯曾经说过
我们应该忘记大约 97% 的时间的小效率:过早优化是万恶之源。然而,我们不应该错过那关键的 3% 的机会。
但是,如果您正在编写一个程序,并且您意识到编写一个语义上等效、更快的版本只需要进行微小的更改,那么您不应该错过这个机会。
并且始终确保分析您的程序以了解其瓶颈。对于分析,在 macOS 上,您可以使用 Instruments Time Profiler(随 XCode 提供),或者使用 抽样分析器 之一。在 Linux 上,任何可以分析 C/C++ 程序的程序,例如 perf 或 Callgrind,都应该可以工作。对于 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 定义中使用这种策略。
让我们比较一下时间
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”]` 创建一个新数组。记住:数组字面量只是创建数组实例并向其中添加一些值的语法糖,并且这将在每次迭代中反复发生。
有两种方法可以解决这个问题
-
使用元组。如果您在上面的程序中使用 `{“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}"
-
将数组移到一个常量中。
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 带来压力。
但是,您不应始终使用结构体。结构体按值传递,因此如果您将结构体传递给方法,并且该方法对结构体进行了更改,则调用方将不会看到这些更改,因此它们可能容易出现错误。最好的做法是仅将结构体用于不可变对象,尤其是当它们很小时。
例如
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_char
、each_byte
、each_codepoint
),或者使用更底层的 Char::Reader
结构。例如,使用 each_char
string = "foo"
string.each_char do |char|
# ...
end