Performance: remove useless header merge from Response#1325
Conversation
| def initialize(body = [], status = 200, header = {}) | ||
| @status = status.to_i | ||
| @header = Utils::HeaderHash.new.merge(header) | ||
| @header = Utils::HeaderHash.new(header) |
There was a problem hiding this comment.
I think this fail when header is a HeaderHash already. In that case new just return the same object what makes this object to mutate the original argument too. I do think it does make sense to avoid the useless instantiation when header is a regular hash but we need to take in consideration when it is a HeaderHash, probably changing new to dup the argument in that case.
There was a problem hiding this comment.
@rafaelfranca Correct me if I'm wrong. From this line of code, it seems safe to pass both Utils::HeaderHash and Hash: https://github.com/rack/rack/blob/master/lib/rack/utils.rb#L415
def self.new(hash = {})
HeaderHash === hash ? hash : super(hash)
endThere was a problem hiding this comment.
Yes, it is safe, but the object passed will be mutated.
For example, before this change:
header = Rack::Utils::HeaderHash.new
response = Rack::Response.new([], 200, header)
response.header['a'] = 1
p header # => {}With your change:
header = Rack::Utils::HeaderHash.new
response = Rack::Response.new([], 200, header)
response.header['a'] = 1
p header # => { "a" => 1 }This only happens when it is a HeaderHash:
header = {}
response = Rack::Response.new([], 200, header)
response.header['a'] = 1
p header # => {}There was a problem hiding this comment.
@rafaelfranca Thanks for the pointer.
It only happens with HeaderHash, because Hash is iterated during HeaderHash#initialize.
def initialize(hash = {})
super()
@names = {}
hash.each { |k, v| self[k] = v }
endIf I try to dup HeaderHash in .new, there is a spec that fails:
def self.new(hash = {})
HeaderHash === hash ? hash.dup : super(hash)
end# test/spec_utils.rb:677
it "avoid unnecessary object creation if possible" do
a = Rack::Utils::HeaderHash.new("foo" => "bar")
b = Rack::Utils::HeaderHash.new(a)
b.object_id.must_equal a.object_id
b.must_equal a
endThis makes sense for performance POV, but doesn't guarantee immutability.
So I grep'd for the usage of HeaderHash and discovered it's used only with new (without merge) in the rest of the code base. These are middleware, so I'm not sure if we care about immutability over there.
A solution to preserve the current behavior of .new and to make Response#initialize faster for the most common case (header is a Hash), we can introduce HeaderHash.build, as a special case:
def self.build(hash)
new(HeaderHash === hash ? hash.dup : hash)
endThis will make existing specs to pass, and also a new one that I wrote:
# test/spec_response.rb
it "doesn't mutate given headers" do
[{}, Rack::Utils::HeaderHash.new].each do |header|
response = Rack::Response.new([], 200, header)
response.header["Content-Type"] = "text/plain"
response.header["Content-Type"].must_equal "text/plain"
header.wont_include("Content-Type")
end
endHere's the results before and after this implementation:
$ wrk -t 2 http://localhost:9292/
Running 10s test @ http://localhost:9292/
2 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.00ms 480.28us 13.76ms 86.93%
Req/Sec 5.13k 237.42 5.39k 91.09%
103020 requests in 10.10s, 3.93MB read
Requests/sec: 10200.58
Transfer/sec: 398.47KB$ wrk -t 2 http://localhost:9292/
Running 10s test @ http://localhost:9292/
2 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 0.96ms 453.09us 12.87ms 87.46%
Req/Sec 5.35k 269.14 5.69k 90.50%
106459 requests in 10.00s, 4.06MB read
Requests/sec: 10644.92
Transfer/sec: 415.83KBPlease let me know what you think. Thanks! 👍
There was a problem hiding this comment.
@tenderlove what do you think about the current behavior of new? Should we care about immutability of the HeaderHash in the Response#initialize if we don't care in other places?
There was a problem hiding this comment.
I don't think we should care about immutability.
jeremy
left a comment
There was a problem hiding this comment.
Huh. It's surprising that .new doesn't dupe. Can we safely change that behavior rather than introducing new API?
"Coerce to HeaderHash unless already an instance" may be desirable in other cases, but I'd expect something other than .new to handle that coercion. Perhaps .build 😅
|
I have a few thoughts about this:
I can't find any usage of HeaderHash on GitHub code search that isn't internal to Rack, so I think we're pretty safe to change this class. I don't expect anyone to be calling @jodosha is it possible to just delete our current implementation of |
|
@tenderlove Please have a look at d671a0c, this commit:
Regarding |
I guess we could consider this PR a "breaking change" already, so I'm fine with removing the inheritance in this PR. We haven't decided on the version for master, whether it's 2.1 or 3.0. Maybe we should merge this in and call it 3.0? @rafaelfranca @matthewd @eileencodes @jeremy any opinions? As far as performance goes, this HeaderHash thing has been a thorn in our side for a while. I'd be really happy to ship this. |
|
To be clear, I think we could merge this PR as-is and ship it as 2.1. If we change inheritance to delegation we'll have to go to 3.0 as the API surface area will change drastically (there's no way we can change to delegation and have even close to 100% api compatibility with |
|
I have no objections to merging this as is 👍 |
|
@tenderlove 2.1 vs 3.0: it really depends on the general plans for Rack. If you're planning other major changes for 3.0, it's worth to modify EDIT: as I'm rewriting parts of Hanami, I'll probably submit other PRs for perf enhancement, if needed. 🐈 |
|
@tenderlove I realized that asking for Rack plans is out of the scope for this PR. So here's my proposal: if the current implementation looks good, please merge it. I'll take care after to create another PR that introduces the breaking changes. So it's ready if you folks decide one day to go for 3.0. |
|
@jodosha sorry to take so long to respond. I'll merge this.
This sounds great. If we decide master should be 3.0 we can merge that PR. |
Change
There is no need to instantiate and merge headers, as
HeaderHashalready sets the given values fromheader.By looking at the code of
HeaderHash#mergewe see that it duplicates itself:Each time we instantiate a
Rack::Responsewe instantiate aHeaderHashtwice: the first time because ofnew, and the second time because ofmerge.Req/s benchmark
By running this benchmark on my machine, I can see a performance gain of +350req/s.
Gemfile
config.ru
Run the app with:
$ bundle exec puma -e production -t 16:16 config.ruBefore:
After: