性能因素
创建于:2025年03月14日 最后编辑于: 2025年05月27日
谈起 Crystal 和 Ruby 的区别,就不能不谈性能因素,毕竟很多人离开 Ruby 而采用其他语言, 例如,go、rust、包括 Crystal,都是因为 Ruby 相对而言速度较慢,对吧?
这里主要分析一下使用 Crystal 之后,值得注意的性能相关的因素。
本文部分内容参考自官方 performance 文档
§ 不要过早的优化
We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.
我们应该忽略那些细微的效率优化,例如:在 97% 的情况下,过早优化是万恶之源。 然而,我们也不能放过那关键的 3% 的优化机会。
然而,如果你正在编写一个程序,并意识到通过进行一些微小的修改就可以写出一个语义相同且运行速度更快的版本时,你绝对不应该错过这个机会。
你总是应该首先对程序进行剖析,以了解其瓶颈所在。
-
在 macOS 上你可以使用随 XCode 提供的 Instruments Time Profiler,或任何一个 sample profile 工具。
-
在 Linux 上,任何能够剖析 C/C++ 程序的工具,如 perf 或 Callgrind,都应能正常工作。更多的例子,见 查找性能瓶颈
-
无论是 Linux 还是 OS X,你都可以使用调试器运行程序,然后偶尔按下 ctrl+c 中断它,并执行 gdbbacktrace 来查看路径追踪中的模式(或者使用 gdb 的穷人版剖析工具,该工具为你做了同样的事情,或者 OS X 的 sample 命令)。
§ 尽可能的避免 额外/不必要 的内存分配
例如,创建一个类的实例,将在堆中分配内存。
但是,创建一个 struct 的实例使用栈内存, 不会产生性能惩罚。
如果你不懂堆和栈的区别,请参考 这个答案
分配堆内存速度更慢,而且它给垃圾收集器 GC (Garbage Collector )更多的压力,因为它 不得不在稍后释放这些内存。
见下面的 benchmark
1
2 # class_vs_struct.cr
3
4 require "benchmark"
5
6 class PointClass
7 getter x
8 getter y
9
10 def initialize(@x : Int32, @y : Int32)
11 end
12 end
13
14 struct PointStruct
15 getter x
16 getter y
17
18 def initialize(@x : Int32, @y : Int32)
19 end
20 end
21
22 Benchmark.ips do |x|
23 x.report("class") { PointClass.new(1, 2) }
24 x.report("struct") { PointStruct.new(1, 2) }
25 end
26
1
2 ╰──➤ $ crystal run --release class_vs_struct.cr
3 class 134.44M ( 7.44ns) (± 6.40%) 16.0B/op 4.74× slower
4 struct 637.87M ( 1.57ns) (±20.96%) 0.0B/op fastest
5
但是 struct 也不是万能的,struct 按照值的方式传递(而不是大多数对象采用的引用方式传递)
任何时候,一个 struct 对象 (作为参数)被 传递 或 返回 时,都会创建一个新的副本。 如果恰巧在传递后修改了它,则只是修改的副本,这点极容易引起 bug。
例如:如果你传递一个 struct 给一个方法,并且在方法内部修改了它,方法的调用者(caller) 无法 看到这些改变,看下面这个例子:
1
2 class Klass
3 property array = ["str"]
4 end
5
6 struct Strukt
7 property array = ["str"]
8 end
9
10 def modify(obj)
11 obj.array << "foo" # 这个直接在数组 ["str"] 引用之上修改,class/struct 都有效
12 obj.array = ["new"] # 这个是直接修改 object.array 的属性,class 直接修改传入的 obj, struct 修改的是副本。
13 obj.array << "bar" # 这个是在上面的新的 array 之上操作。
14
15 obj
16 end
17
18 klass = Klass.new
19 # 类是作为引用被传入的
20 puts modify(klass) # => ["new", "bar"]
21 puts klass.array # => ["new", "bar"]
22
23 strukt = Strukt.new
24 # 结构体作为一个值的副本被传入的
25 puts modify(strukt) # ,=> ["new", "bar"]
26 puts strukt.array # => ["str", "foo"]
27
甚至结构体内通过 self 返回,都是一个副本,所以结构体更适合于包装 不可变的(immutable)对象,尤其是小对象。
§ 不要建立中间字符串,而是尽可能的直接写入 IO
如果你使用 Ruby, 当打印一个数字到标准输出时,例如:
1
2 puts 123
3
实际做的事情是:puts 会查找对象是否有实现 #to_s, 如果有,调用它,返回对象的字符串形式, 然后,将字符串写入标准输出。这工作的很好,但是有一个瑕疵,它在堆上建立一个中间字符串 (当调用#to_s 时),用完之后又立即丢弃它,这是不必要的。
在 Crystal 中,puts 调用的是 123.to_s(io), 那个额外的 io 参数就是 puts 希望输出到的 IO
所以,Crystal 下请不要这样做:
1
2 puts 123.to_s
3
而代之,应该总是附加一个对象直接到 IO
下面是一个例子:
1
2 class MyClass
3 # Good
4 def to_s(io)
5 # appends "1, 2" to IO without creating intermediate strings
6 x = 1
7 y = 2
8 io << x << ", " << y
9 end
10
11 # Bad
12 def to_s(io)
13 x = 1
14 y = 2
15 # using a string interpolation creates an intermediate string.
16 # this should be avoided
17 io << "#{x}, #{y}"
18 end
19 end
20
所以,对于自定义类型,总是应该覆写(override) #to_s(io), 而不是 to_s 方法,来 避免中间字符串,获取更好的性能。
下面是一个 benchmark
1
2 # io_benchmark.cr
3
4 require "benchmark"
5
6 io = IO::Memory.new
7
8 Benchmark.ips do |x|
9 x.report("without to_s") do
10 io << 123
11 io.clear
12 end
13
14 x.report("with to_s") do
15 io << 123.to_s
16 io.clear
17 end
18 end
19
1
2 ╰──➤ $ crystal run --release io_benchmark.cr
3 without to_s 161.36M ( 6.20ns) (± 2.96%) 0.0B/op fastest
4 with to_s 55.23M ( 18.11ns) (± 3.40%) 32.0B/op 2.92× slower
5
§ 使用字符串插值,而不是字符串拼接
几乎在所有情况下,字符串插值,例如 "Hello, #{name}" 总是好过后者 "Hello, " + name.to_s
字符串插值由编译器转换成如下形式,来避免中间字符串:
1
2 String.build do |io|
3 io << "Hello, " << name
4 end
5
§ 使用优化的 String.build,而不是 IO::Memory 来构建字符串
参见下面的 benchmark
1
2 require "benchmark"
3
4 Benchmark.ips do |bm|
5 bm.report("String.build") do
6 String.build do |io|
7 99.times do
8 io << "hello world"
9 end
10 end
11 end
12
13 bm.report("IO::Memory") do
14 io = IO::Memory.new
15 99.times do
16 io << "hello world"
17 end
18 io.to_s
19 end
20 end
21
1
2 ╰──➤ $ crystal run --release 1.cr
3 String.build 1.92M (519.54ns) (± 7.71%) 5.88kB/op fastest
4 IO::Memory 870.79k ( 1.15µs) (± 6.98%) 5.88kB/op 2.21× slower
5
§ 避免反复的创建临时对象。
参见下面的例子:
1
2 lines_with_language_reference = 0
3
4 while line = gets
5 if ["crystal", "ruby", "java"].any? { |string| line.includes?(string) }
6 lines_with_language_reference += 1
7 end
8 end
9
10 puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"
11
上面的代码存在一个重大性能问题!
当迭代每一行时,一个新的(不变的)数组对象 ["crystal", "ruby", "java"] 被反复创建。
解决办法:
-
使用 tuple {"crystal", "ruby", "java"} 代替数组,它在 stack 中被创建,占用内存很少, 而且,编译器大概率会将它优化掉, 因此这是首选的方式。
-
将数组作为一个常量, 并移到循环外面。
1
2 LANGS = ["crystal", "ruby", "java"]
3
4 lines_with_language_reference = 0
5 while line = gets
6 if LANGS.any? { |string| line.includes?(string) }
7 lines_with_language_reference += 1
8 end
9 end
10 puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"
11
总是仔细检查在循环中存在类似上面的 array literal,这些事情也可能发生在一个方法调用中。
§ 迭代字符串
Crystal 中的字符串使用 UTF-8 编码, 因为 UTF-8 是一个可变长的编码,不同的字符可能需要 不同的字节来保存。因此 String#[] 方法复杂度不是 O(1) 的,因为寻找指定位置的字符, 需要遍历整个字符串,不断的执行解码操作。
下面的方法拥有 O(N^2) 的复杂度。(而且 string.size 也是一个 slow 操作)
1
2 string = "foo"
3 while i < string.size
4 char = string[i]
5 # ...
6 end
7
8
但是 ASCII 字符总是单字节的,如果我们知道一个字符串全部由 ASCII 字符组成,那么 String#[] 可以是 O(1) 的,如果我们知道它是合法的 ASCII 字符串,我们可以使用 each_char 来遍历:
1
2 string = "foo"
3 string.each_char do |char|
4 # ...
5 end
6