Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Speedup ActionView::OutputBuffer #45614

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

casperisfine
Copy link
Contributor

@casperisfine casperisfine commented Jul 18, 2022

MRI has a lot of optimizations for string concatenation that are only available when concatenating into a String instance.

Using a String subclass disable these optimizations.

The difference is even more important in Ruby 3.2 where both YJIT and the VM noticeably improve String#<< performance.

So ideally we want the buffer not to be a String subclass. Luckily, the Action View buffer is for internal use only, so we can replace inheritance by composition without much work.

Benchmark:

ActionView::OutputBuffer:   147644.2 i/s
    optimized buffer:   228001.4 i/s - 1.54x  (± 0.00) faster

Source: https://gist.github.com/casperisfine/7199579a138e268fda71d6a91366af49

NB: That 50% faster figure is to be contextualized, it can radically change
from one template to the other, but is always faster.

cc @jhawthorn @matthewd @noahgibbs @tenderlove

@rails-bot rails-bot bot added the actionview label Jul 18, 2022
@casperisfine casperisfine force-pushed the faster-output-buffer branch 5 times, most recently from 879c5d6 to 6ca8332 Compare Jul 18, 2022
fragment = output_buffer.slice!(pos..-1)
fragment = output_buffer.to_s.slice!(pos..-1)
Copy link
Member

@jhawthorn jhawthorn Jul 18, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this change is quite right. Previously this was actually mutating the output buffer (it's a really strange thing to do). @seejohnrun and I ran into this as well when experimenting with modifying outputbuffer, and tried replacing what this method does capture https://github.com/jhawthorn/rails/pull/1/files#diff-7e8f6bc4873643df271aa007b513b993a8e424964b76e4eec2c1d156d9108ac3R252 (but I don't remember if it works)

Copy link
Contributor Author

@casperisfine casperisfine Jul 18, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look more into it tomorrow, but I was under the impression that mutating the buffer was simply an unneeded side effect. I'm pretty sure that buffer instance is no longer used after that slice!.

Copy link
Contributor Author

@casperisfine casperisfine Jul 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so I was wrong. This slice! is to handle formats such as .builder, as it doesn't access @output_buffer, it is initialized once with it.

So in cache do we can't just substitute the buffer, we have to mutate it.

That's really unfortunate.

I'm tempted to add some stack to the buffer, the problem is that it's not clear to be wether @output_buffer is guaranteed to be an OutputBuffer (or StreamBuffer) as there are some tests using ActiveSupport::SafeBuffer. I'd have to dig into that as well to see wether it's legit or not.

@jhawthorn
Copy link
Member

@jhawthorn jhawthorn commented Jul 18, 2022

I like this change. I think the speedup is more avoiding the indirection than the concatenation itself (I don't think anything called by rb_str_concat checks the class, just the builtin type), but equally worthwhile! 🚀

I don't know if this happens in practice but here's another behaviour change we should guard against:

b1 = ActionView::OutputBuffer.new
b1 << "foo"
b2 = b1.dup
b1 << "bar"

p b1.to_s # => "foobar"
p b2.to_s # => "foobar"

Probably worth adding an initialize_copy definition to ActionView::OutputBuffer

@noahgibbs
Copy link

@noahgibbs noahgibbs commented Jul 18, 2022

John: one of many reasons we're hoping to avoid concatenating onto a non-basic string is that YJIT is significantly faster when the receiver is a string. That's not the only reason we're doing it, but it's a side benefit :-)

@jhawthorn
Copy link
Member

@jhawthorn jhawthorn commented Jul 18, 2022

John: one of many reasons we're hoping to avoid concatenating onto a non-basic string is that YJIT is significantly faster when the receiver is a string. That's not the only reason we're doing it, but it's a side benefit :-)

As I said, I'm in favour of this change. But I don't think that's true. String concatenation onto a subclass can be more or less the same speed.

EDIT:

# frozen_string_literal: true
require 'benchmark/ips'

class SafeBuffer < String
end

class OutputBuffer < SafeBuffer
  def <<(value)
    return self if value.nil?
    super(value.to_s)
  end
end

str = +""
safe_buffer = SafeBuffer.new
output_buffer = OutputBuffer.new
safe_buffer_encoded = SafeBuffer.new("")
output_buffer_encoded = OutputBuffer.new("")

puts "Encodings:"
pp({str:, safe_buffer:, output_buffer:, safe_buffer_encoded:, output_buffer_encoded:}.transform_values(&:encoding))

Benchmark.ips do |x|
  x.report("String") { str << "" }
  x.report("SafeBuffer") { safe_buffer << "" }
  x.report("OutputBuffer") { output_buffer << "" }
  x.report("SafeBuffer (same encoding)") { safe_buffer_encoded << "" }
  x.report("OutputBuffer (same encoding)") { output_buffer_encoded << "" }
  x.compare!(order: :baseline)
end
$ ./ruby --yjit benchmark_string_concat.rb
Encodings:
{:str=>#<Encoding:UTF-8>,
 :safe_buffer=>#<Encoding:ASCII-8BIT>,
 :output_buffer=>#<Encoding:ASCII-8BIT>,
 :safe_buffer_encoded=>#<Encoding:UTF-8>,
 :output_buffer_encoded=>#<Encoding:UTF-8>}
Warming up --------------------------------------
              String   573.166k i/100ms
          SafeBuffer   366.353k i/100ms
        OutputBuffer   360.232k i/100ms
SafeBuffer (same encoding)
                       597.930k i/100ms
OutputBuffer (same encoding)
                       582.716k i/100ms
Calculating -------------------------------------
              String      5.989M (± 1.1%) i/s -     30.378M in   5.073130s
          SafeBuffer      3.787M (± 1.2%) i/s -     19.050M in   5.031701s
        OutputBuffer      3.712M (± 0.9%) i/s -     18.732M in   5.046107s
SafeBuffer (same encoding)
                          5.985M (± 1.5%) i/s -     30.494M in   5.096533s
OutputBuffer (same encoding)
                          5.915M (± 1.2%) i/s -     29.719M in   5.024993s

Comparison:
              String:  5988771.9 i/s
SafeBuffer (same encoding):  5984749.7 i/s - same-ish: difference falls within error
OutputBuffer (same encoding):  5915046.7 i/s - same-ish: difference falls within error
          SafeBuffer:  3786659.5 i/s - 1.58x  (± 0.00) slower
        OutputBuffer:  3712492.5 i/s - 1.61x  (± 0.00) slower

@noahgibbs
Copy link

@noahgibbs noahgibbs commented Jul 18, 2022

Okay. I don't think I have permissions on that repo. But at a minimum we would need to build more infrastructure for YJIT to make that true. So with current YJIT as it exists, I'm pretty certain that's true.

@jhawthorn
Copy link
Member

@jhawthorn jhawthorn commented Jul 18, 2022

benchmark attached, but again, I am in favour of this change.

I also don't think YJIT needs extra infrastructure for that to be true, because Ruby does not special case rb_cString for concatenation (just T_STRING). Even if it did (it doesn't) we are going to want it because ActiveSupport::SafeBuffer is public and a string subclass.

Let's continue the YJIT part of this discussion elsewhere because I think this is a good change anyways.

@casperisfine
Copy link
Contributor Author

@casperisfine casperisfine commented Jul 18, 2022

@jhawthorn this is the optimization I'm talking about: https://github.com/ruby/ruby/blob/85ea46730deff70172a9f50172f0011a7401f371/vm_insnhelper.c#L5374-L5380

static VALUE
vm_opt_ltlt(VALUE recv, VALUE obj)
{
    if (SPECIAL_CONST_P(recv)) {
	return Qundef;
    }
    else if (RBASIC_CLASS(recv) == rb_cString &&
	     BASIC_OP_UNREDEFINED_P(BOP_LTLT, STRING_REDEFINED_OP_FLAG)) {
	if (LIKELY(RB_TYPE_P(obj, T_STRING))) {
	    return rb_str_buf_append(recv, obj);
	} else {
	    return rb_str_concat(recv, obj);
	}
    }
    else if (RBASIC_CLASS(recv) == rb_cArray &&
	     BASIC_OP_UNREDEFINED_P(BOP_LTLT, ARRAY_REDEFINED_OP_FLAG)) {
	return rb_ary_push(recv, obj);
    }
    else {
	return Qundef;
    }
}

String subclasses are explictly excluded, and it's particularly true for SafeBuffer as << is redefined.

@casperisfine
Copy link
Contributor Author

@casperisfine casperisfine commented Jul 18, 2022

Probably worth adding an initialize_copy

Good catch.

MRI has a lot of optimizations for string concatenation that
are only available when concatenating into a `String` instance.

Using a `String` subclass disable these optimizations.

The difference is even more important with YJIT.

So ideally we want the buffer not to be a String subclass.
Luckily, the Action View buffer is for internal use only, so
we can replace inheritance by composition without much work.

Benchmark:

```
ActionView::OutputBuffer:   147644.2 i/s
    optimized buffer:   228001.4 i/s - 1.54x  (± 0.00) faster
```

Source: https://gist.github.com/casperisfine/7199579a138e268fda71d6a91366af49

NB: That 50% faster figure is to be contextualized, it can radically change
from one template to the other, but is always faster.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants