Skip to content

Commit de685f9

Browse files
authored
🔀 Merge pull request #642 from ruby/response_reader-performance
âš¡ Much faster ResponseReader performance
2 parents bc0b1f7 + 7e1af98 commit de685f9

4 files changed

Lines changed: 251 additions & 21 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
prelude: |
2+
require "net/imap"
3+
require "stringio"
4+
5+
@io = StringIO.new("", "rb")
6+
client = Object.new
7+
def client.config = @config ||= Net::IMAP::Config.new
8+
def client.max_response_size = config.max_response_size
9+
client.config # force memoization
10+
11+
@reader = Net::IMAP::ResponseReader.new(client, @io)
12+
def read
13+
@io.rewind
14+
@reader.read_response_buffer
15+
rescue Net::IMAP::ResponseTooLargeError
16+
# intentionally ignoring this
17+
end
18+
19+
def b(str) = str.to_str.b
20+
21+
# Pathological case: nothing but empty literals
22+
EMPTY_CONT = "{0}\r\n" # 5 bytes
23+
SMALL_CONT = "{19}\r\nアイマップ4。" # 25 bytes
24+
def pathological(size) = b EMPTY_CONT * (size.to_int / EMPTY_CONT.bytesize) + "\r\n"
25+
def small_literals(size) = b SMALL_CONT * (size.to_int / SMALL_CONT.bytesize) + "\r\n"
26+
def no_literals(size) = b "a" * size + "\r\n"
27+
def pathological!(...) = @io.string = pathological(...)
28+
def small_literals!(...) = @io.string = small_literals(...)
29+
def no_literals!(...) = @io.string = no_literals(...)
30+
31+
def warmup(input)
32+
@io.string = input
33+
output = read
34+
output == input or raise "Invalid input? output=%p" % [output]
35+
1000.times do read end
36+
end
37+
38+
warmup no_literals 100
39+
warmup small_literals 100
40+
warmup pathological 100
41+
42+
no_literals!(1)
43+
44+
benchmark:
45+
- { name: "100 B with no literals", prelude: "no_literals! 1e2", script: "read" }
46+
- { name: " 1KiB with no literals", prelude: "no_literals! 1e3", script: "read" }
47+
- { name: " 10KiB with no literals", prelude: "no_literals! 1e4", script: "read" }
48+
- { name: "100KiB with no literals", prelude: "no_literals! 1e5", script: "read" }
49+
- { name: " 1MiB with no literals", prelude: "no_literals! 1e6", script: "read" }
50+
51+
- { name: "100 B of 25B literals", prelude: "small_literals! 1e2", script: "read" }
52+
- { name: " 1KiB of 25B literals", prelude: "small_literals! 1e3", script: "read" }
53+
- { name: " 10KiB of 25B literals", prelude: "small_literals! 1e4", script: "read" }
54+
- { name: "100KiB of 25B literals", prelude: "small_literals! 1e5", script: "read" }
55+
# - { name: " 1MiB of 25 byte literals", prelude: "small_literals! 1e6", script: "read" }
56+
# - { name: "100MiB of 25 byte literals", prelude: "small_literals! 1e8", script: "read" }
57+
58+
- { name: "100 B of 0B literals", prelude: "pathological! 1e2", script: "read" }
59+
- { name: " 1KiB of 0B literals", prelude: "pathological! 1e3", script: "read" }
60+
- { name: " 10KiB of 0B literals", prelude: "pathological! 1e4", script: "read" }
61+
- { name: "100KiB of 0B literals", prelude: "pathological! 1e5", script: "read" }
62+
# - { name: " 1MiB of 0 byte literals", prelude: "pathological! 1e6", script: "read" }
63+
# - { name: "100MiB of 0 byte literals", prelude: "pathological! 1e8", script: "read" }
64+
65+
contexts:
66+
- name: local
67+
prelude: |
68+
$LOAD_PATH.unshift "./lib"
69+
require: false
70+
- name: local YJIT
71+
prelude: |
72+
$LOAD_PATH.unshift "./lib"
73+
RubyVM::YJIT.enable
74+
require: false
75+
76+
# - name: v0.6.3
77+
# gems:
78+
# net-imap: 0.6.3
79+
# require: false
80+
# - name: v0.5.13
81+
# gems:
82+
# net-imap: 0.5.13
83+
# require: false
84+
# - name: v0.4.23
85+
# gems:
86+
# net-imap: 0.4.23
87+
# require: false

‎lib/net/imap/response_reader.rb‎

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,51 +8,60 @@ class ResponseReader # :nodoc:
88

99
def initialize(client, sock)
1010
@client, @sock = client, sock
11+
# cached config
12+
@max_response_size = nil
13+
# response buffer state
14+
@buff = @literal_size = nil
1115
end
1216

1317
def read_response_buffer
18+
@max_response_size = client.max_response_size
1419
@buff = String.new
1520
catch :eof do
1621
while true
22+
guard_response_too_large!
1723
read_line
18-
break unless (@literal_size = get_literal_size)
24+
# check before allocating memory for literal
25+
guard_response_too_large!
26+
break unless literal_size
1927
read_literal
2028
end
2129
end
2230
buff
2331
ensure
24-
@buff = nil
32+
@buff = @literal_size = nil
2533
end
2634

2735
private
2836

37+
# cached config
38+
attr_reader :max_response_size
39+
40+
# response buffer state
2941
attr_reader :buff, :literal_size
3042

3143
def bytes_read = buff.bytesize
3244
def empty? = buff.empty?
33-
def done? = line_done? && !get_literal_size
45+
def done? = line_done? && !literal_size
3446
def line_done? = buff.end_with?(CRLF)
35-
def get_literal_size = /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i
47+
48+
def get_literal_size(buff)
49+
buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
50+
end
3651

3752
def read_line
38-
buff << (@sock.gets(CRLF, read_limit) or throw :eof)
39-
max_response_remaining! unless line_done?
53+
line = (@sock.gets(CRLF, max_response_remaining) or throw :eof)
54+
@literal_size = get_literal_size(line)
55+
buff << line
4056
end
4157

4258
def read_literal
43-
# check before allocating memory for literal
44-
max_response_remaining!
4559
literal = String.new(capacity: literal_size)
46-
buff << (@sock.read(read_limit(literal_size), literal) or throw :eof)
60+
buff << (@sock.read(literal_size, literal) or throw :eof)
4761
ensure
4862
@literal_size = nil
4963
end
5064

51-
def read_limit(limit = nil)
52-
[limit, max_response_remaining!].compact.min
53-
end
54-
55-
def max_response_size = client.max_response_size
5665
def max_response_remaining = max_response_size &.- bytes_read
5766
def response_too_large? = max_response_size &.< min_response_size
5867
def min_response_size = bytes_read + min_response_remaining
@@ -61,10 +70,11 @@ def min_response_remaining
6170
empty? ? 3 : done? ? 0 : (literal_size || 0) + 2
6271
end
6372

64-
def max_response_remaining!
65-
return max_response_remaining unless response_too_large?
66-
raise ResponseTooLargeError.new(
67-
max_response_size:, bytes_read:, literal_size:,
73+
def guard_response_too_large! = (raise self if response_too_large?)
74+
75+
def exception(msg = nil)
76+
ResponseTooLargeError.new(
77+
msg, max_response_size:, bytes_read:, literal_size:,
6878
)
6979
end
7080

‎test/lib/helper.rb‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,117 @@ def wait_for_response_count(imap, type:, count:,
6262
end
6363
end
6464

65+
# assert_linear_performance didn't fail reliably until "n" was far too high,
66+
# even though the problem was very obvious at lower "n" values, by looking at
67+
# the mean (plus stddev) rather than the max (plus variance-based "safety
68+
# factor").
69+
#
70+
# So rather than use "max" as the baseline, this uses μ + 2σ (or max).
71+
def assert_strict_linear_time(sequence, prepare: proc do end,
72+
base_repeats: 100,
73+
repeats: 5,
74+
allow_stdev_above_mean: 2,
75+
outlier_safety_factor: 3,
76+
mean_safety_factor: 2,
77+
verbose: false,
78+
&code)
79+
pend "No PERFORMANCE_CLOCK found" unless defined?(PERFORMANCE_CLOCK)
80+
81+
measure = proc do |&block|
82+
st = Process.clock_gettime(PERFORMANCE_CLOCK)
83+
block.call
84+
t = Process.clock_gettime(PERFORMANCE_CLOCK)
85+
t - st
86+
end
87+
88+
measure_base = proc do |sequence, prepare:, &code|
89+
stats = RunningStats.new
90+
base_repeats.times do
91+
*args = prepare.(sequence.first)
92+
time = measure.call { code.call(*args) }
93+
warn " - %0.9f" % [time] if verbose == :very
94+
stats.push time
95+
end
96+
stats
97+
end
98+
99+
scale = ->(base, base_size, size) { base * size.fdiv(base_size) }
100+
101+
warn "Measuring (#{base_repeats} times) for n=#{sequence.first}." if verbose
102+
base_stats = measure_base.(sequence, prepare:, &code)
103+
base_time = [base_stats.stddev_above_mean(3), base_stats.max].min
104+
105+
base_timeout_msg = "min=%s max=%s mean=%s stddev=%s timeout=%s" % [
106+
base_stats.min, base_stats.max, base_stats.mean, base_stats.stddev,
107+
base_time
108+
].map { "%0.6f" % _1 }
109+
110+
warn " n=%d -> %p" % [sequence.first, base_stats] if verbose
111+
warn " base timeout=%0.6f" % [base_time] if verbose
112+
113+
sequence.each.drop(1).to_h {|n|
114+
linear_limit = scale.(base_time, sequence.first, n)
115+
each_timeout = linear_limit * outlier_safety_factor
116+
mean_timeout = linear_limit * mean_safety_factor
117+
full_timeout = mean_timeout * repeats * 1.1
118+
timeout_msg = "for n=%s linear_limit=%0.6f timeout=%0.6f mean_timeout=%0.6f" % [
119+
n, linear_limit, each_timeout, mean_timeout
120+
]
121+
warn "Measuring (#{repeats} times) #{timeout_msg}:" if verbose
122+
timeout_msg = "#{timeout_msg} #{base_timeout_msg}"
123+
*args = prepare.call(n)
124+
times = Timeout.timeout(full_timeout, Timeout::Error, timeout_msg) do
125+
Array.new(repeats) {
126+
time = Timeout.timeout(each_timeout, Timeout::Error, timeout_msg) do
127+
measure.call do code.call(*args) end
128+
end
129+
assert_operator time, :<=, each_timeout,
130+
"super-linear time %0.6f %s" % [time, timeout_msg]
131+
warn " ---- %0.9f" % [time] if verbose == :very
132+
time
133+
}
134+
end
135+
stats = RunningStats.new(times)
136+
warn " n=%d -> %p" % [n, stats] if verbose
137+
assert_operator stats.mean, :<=, mean_timeout,
138+
"super-linear mean time %0.6f %s" % [stats.mean, timeout_msg]
139+
[n, stats]
140+
}
141+
end
142+
143+
class RunningStats
144+
attr_reader :samples, :min, :max, :mean
145+
146+
def initialize(input = nil)
147+
@samples = 0
148+
@mean = 0.0
149+
@s = 0.0
150+
@min = nil
151+
@max = nil
152+
input&.each do push _1 end
153+
end
154+
155+
def push(x)
156+
@min = @min ? [@min, x].min : x
157+
@max = @max ? [@max, x].max : x
158+
@samples += 1
159+
delta = (x - @mean)
160+
@mean += delta / @samples
161+
@s += delta * (x - @mean)
162+
end
163+
164+
def variance; (@samples >= 1) ? @s / (@samples - 1) : 0.0 end
165+
def stddev; Math.sqrt(variance) end
166+
167+
def stddev_above_mean(mult = 1) = mean + mult * stddev
168+
169+
def inspect
170+
"#<%s samples=%d min=%0.6f max=%0.6f mean=%0.6f stddev=%0.6f>" % [
171+
self.class, samples, min, max, mean, stddev
172+
]
173+
end
174+
end
175+
65176
# Copied from minitest
66177
def assert_pattern
67178
flunk "assert_pattern requires a block to capture errors." unless block_given?

‎test/net/imap/test_response_reader.rb‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,15 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
5454
exact = "+ 345678\r\n"
5555
very_over = "+ 3456789 #{?a * (16<<10)}}\r\n"
5656
slightly_over = "+ 34567890\r\n" # CRLF after the limit
57-
io = StringIO.new([under, exact, very_over, slightly_over].join)
57+
io = StringIO.new([under, exact, very_over, slightly_over].join, "rb")
5858
rcvr = Net::IMAP::ResponseReader.new(client, io)
5959
assert_equal under, rcvr.read_response_buffer.to_str
6060
assert_equal exact, rcvr.read_response_buffer.to_str
6161
assert_raise Net::IMAP::ResponseTooLargeError do
6262
result = rcvr.read_response_buffer
6363
flunk "Got result: %p" % [result]
6464
end
65-
io = StringIO.new(slightly_over)
65+
io = StringIO.new(slightly_over, "rb")
6666
rcvr = Net::IMAP::ResponseReader.new(client, io)
6767
assert_raise Net::IMAP::ResponseTooLargeError do
6868
result = rcvr.read_response_buffer
@@ -74,12 +74,34 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
7474
barely_over = "+ 3456789\r\n" # CRLF straddles the boundary
7575
client = FakeClient.new
7676
client.config.max_response_size = 10
77-
io = StringIO.new(barely_over)
77+
io = StringIO.new(barely_over, "rb")
7878
rcvr = Net::IMAP::ResponseReader.new(client, io)
7979
assert_raise Net::IMAP::ResponseTooLargeError do
8080
result = rcvr.read_response_buffer
8181
flunk "Got result: %p" % [result]
8282
end
8383
end
8484

85+
test "linear performance detecting literal continuation" do
86+
omit_unless_cruby "flaky on different platforms"
87+
omit_if(ENV["CI"], "slow and flaky, skipping in CI")
88+
89+
client = FakeClient.new
90+
io = StringIO.new "", "rb"
91+
rcvr = Net::IMAP::ResponseReader.new(client, io)
92+
93+
sequence = [100, 1_000, 10_000]
94+
assert_strict_linear_time(sequence, prepare: ->(n) {
95+
parts = Array.new(n) {|i| "BODY[#{i.succ}] {1}\r\nX" }.join(" ")
96+
response = "* 1 FETCH (#{parts})\r\n"
97+
embedded = "#{response}* OK next response\r\n"
98+
io.string = embedded
99+
assert_equal response, rcvr.read_response_buffer
100+
io.rewind
101+
response
102+
}) do
103+
io.rewind
104+
rcvr.read_response_buffer
105+
end
106+
end
85107
end

0 commit comments

Comments
 (0)