Crystal: Concatenating a few strings is faster than interpolation

Created on 13 Sep 2017  路  8Comments  路  Source: crystal-lang/crystal

I don't know if there exists a general recommendation about this for Crystal, but I think it should be preferable to use string interpolation instead of concatenating strings.

But there is an issue: When the number of expressions is small, concatenation is actually a little bit faster than interpolation.
This is probably caused by the overhead of initializing a StringBuilder which is used for interpolation: a#{foo} becomes (StringBuilder.new << "a" << foo).to_s.

Can this be optimized? Maybe it would be worth expanding a string interpolation to a concatenation if the number of expressions is small (maybe 3 or less).

I think it should be ensured that interpolation is usually better and certainly not slower than concatenation.

Benchmark:

def foo
  rand.to_s
end

Benchmark.ips do |bm|
  bm.report "+ single" { 10.times { "a" + foo }}
  bm.report "# single" { 10.times { "a#{foo}" }}
end

# + single 169.52k (   5.9碌s) (卤 8.68%)       fastest
# # single 127.56k (  7.84碌s) (卤10.35%)  1.33脳 slower

Note: I'm running these benchmarks on Ubuntu on Linux on not-so-modern laptop, so the absolute performance is quite low. I'd be happy if someone could verify this running on a native system.

performance someday compiler

Most helpful comment

OK. So the optimization you are proposing is:

If there's an interpolation "string#{value}" or "#{value}string" and value is known to be a String, replace that with string + value or value + string respectively

Sounds good. The only problem is that, due to how Crystal's type inference works, this is incredibly hard to implement.

So let's leave this open, maybe some day someone can implement this.

By the way, I also found that string#{value}string is also slower than string + value + string and a few other variations too. So maybe someone should refine that heuristic.

All 8 comments

Same results here:

./string-interpolation
+ single 248.47k (  4.02碌s) (卤 4.58%)       fastest
# single  189.3k (  5.28碌s) (卤 0.69%)  1.31脳 slower

Crystal 0.23.1 on OSX Sierra, i7 2.5Ghz.

I'd definitely want users to not have to think "I'm joining few strings, so I'd rather go with concatenation" and let the compiler handle that for her 馃憤

What about this?

require "benchmark"

foo = (1..1000).to_a

Benchmark.ips do |bm|
  bm.report "+ single" { 10.times { "a" + foo.to_s } }
  bm.report "# single" { 10.times { "a#{foo}" } }
end

If you take out the random number generation from the loop, the interpolation is 3x slower. Asterite's benchmark code runs about as fast for both cases.

Personally, I'm fine with string concatenation being faster. Is it even possible for it not to be?

@vegai What do you mean with "take out"? For proper benchmark results, foo needs to be something that can't be simply inlined by some LLVM optimization, just as it would be with values in a real use case.
If concatenation is faster - even if it's just about nanoseconds,, this will raises doubts and discussions about whether simple interpolations shouldn't better be replaced with concatenation.

@asterite Your example is about the same speed. That shouldn't come to a surprise, because the only difference is concatenating "a" to the resulting string of the StringBuilder vs. one more string (the other "a") appended to the StringBuilder. In both cases 1000 iterations on the string builder are way more expensive anyway.
Even a smaller array size doesn't show much significance.

Though it could be possible to optimize only if all expression types are only strings or primitives to rule out performance losses.

If I change the 1000 above to 10_000:

require "benchmark"

foo = (1..10_000).to_a

Benchmark.ips do |bm|
  bm.report "+ single" { 10.times { "a" + foo.to_s } }
  bm.report "# single" { 10.times { "a#{foo}" } }
end

My results:

+ single 162.86  (  6.14ms) (卤 0.95%)  1.10脳 slower
# single 178.38  (  5.61ms) (卤 1.56%)       fastest

Interpolation is faster.

@straight-shoota In your benchmark you are always interpolating two strings, and that's always going to be slower than concatenating two strings because of the need to create a String::Builder. In the general case you don't know what's foo, if it's a string or not. In my benchmark foo is not a string, and creating a big string for foo.to_s and then adding it to another string is slower than just appending that big string to String::Builder.

And I wouldn't change the compiler to "inspect" the type of foo and determine how to expand code, it can be very bug-prone and it will make the compiler harder to understand. Plus the difference is minimal, I really doubt this can be a bottleneck for anyone.

Heck, even if I fix the first benchmark, interpolation is faster:

require "benchmark"

def foo
  rand
end

Benchmark.ips do |bm|
  bm.report "+ single" { 10.times { "a" + foo.to_s } }
  bm.report "# single" { 10.times { "a#{foo}" } }
end

Results:

+ single 209.32k (  4.78碌s) (卤 3.15%)  1.13脳 slower
# single 236.93k (  4.22碌s) (卤 1.35%)       fastest

Doing rand.to_s in foo is cheating: you are converting to string in both cases, and + will be faster. With interpolation, rand is never converted to a string (no memory allocated) but instead directly appended to String::Builder.

The issue is primarily about strings, that's why to_s is needed. It could also be some random string value from a database or whatever.
In this respect, I'd say your cheating when you let the interpolation sample use to_s(io) for better performance. When the interpolated value is a string, it makes no difference.

OK. So the optimization you are proposing is:

If there's an interpolation "string#{value}" or "#{value}string" and value is known to be a String, replace that with string + value or value + string respectively

Sounds good. The only problem is that, due to how Crystal's type inference works, this is incredibly hard to implement.

So let's leave this open, maybe some day someone can implement this.

By the way, I also found that string#{value}string is also slower than string + value + string and a few other variations too. So maybe someone should refine that heuristic.

Yes, it's by no means a pressing issue at all. Just wanted to bring it to attention, maybe there is some way to improve this.

Perhaps it would make sense to add a note about this in the guidelines, like interpolation should generally be preferred.

I actually don't have single interpolation with a string before or after in mind, but rather a small number of expressions. Like a + b + c + d + e + ... where each one can be a string literal (though two subsequent string literals make no sense) or an expression returning a string or primitive type, and the corresponding interpolation (like "aaa#{b}#{c}ddd#{e}...").
From my observation it's only at about eight or so expressions of simple strings where interpolation catches up with concatenation. Though this is only a vague assessment.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

asterite picture asterite  路  78Comments

akzhan picture akzhan  路  67Comments

ezrast picture ezrast  路  84Comments

stugol picture stugol  路  70Comments

asterite picture asterite  路  139Comments