Skip to content

Commit 498b28f

Browse files
committed
further performance improvements for individual pattern matching
1 parent 3f31e82 commit 498b28f

5 files changed

Lines changed: 170 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,66 @@ set.expand("posts.index", user_id: 42) # => "/users/42/posts"
5858
* Major performance improvements for `Mustermann::Mapper`, as it is based on `Mustermann::Set` now, and can dispatch in logarithmic time instead of linear time.
5959
* Major speed improvements for sub-segment patterns with optional elements (like a format at the end of a path). These patterns are common in web applications.
6060

61-
| Scenario | Improvement over 3.1 |
62-
| ----------------------------- | --------------------- |
63-
| Simple pattern compilation | 6x speedup |
64-
| Complex pattern compilation | 30% speedup |
65-
| Simple param extraction | 2.4x speedup |
66-
| Complex param extraction | 70% speedup |
67-
| Matching a simple pattern | Same performance |
68-
| Matching a complex pattern | 8x speedup |
69-
| Matching against 1k patterns | 20x to 350x speedup |
70-
| Matching against 10k patterns | 200x to 3500x speedup |
71-
72-
Numbers are based on simple and realistic patterns run on MRI Ruby 4.0 on a MacBook Pro. The improvements you will see may vary based on your Ruby implementation, platform and patterns used.
73-
74-
Simple and complex in the above table refer to patterns with only static segments or captures matching exactly one segment each (like `/resource/:id` or `/:controller/:action`) versus patterns with more complex captures (like `/resource/*path/:id` or `/resource/:id(.:format)?`). The matching improvements against 1k and 10k patterns assume the new `Mustermann::Set` is used. Otherwise the difference should be in line with the single pattern matching improvements.
61+
<table>
62+
<thead>
63+
<tr>
64+
<th colspan="2">Scenario</th>
65+
<th>Mustermann 3.1</th>
66+
<th>Mustermann 4.0</th>
67+
<th>Improvement</th>
68+
</tr>
69+
</thead>
70+
<tbody>
71+
<tr>
72+
<td rowspan="5">Simple patterns</td>
73+
<td>Compilation</td>
74+
<td>12.1 K/s</td>
75+
<td>70.8 K/s</td>
76+
<td>6x</td>
77+
</tr>
78+
<tr>
79+
<td>Matching non-repeating strings</td>
80+
<td>4.5 M/s</td>
81+
<td>4.5 M/s</td>
82+
<td>none</td>
83+
</tr>
84+
<tr>
85+
<td>Matching repeating strings</td>
86+
<td>5.9 M/s</td>
87+
<td>17.1 M/s</td>
88+
<td>3x</td>
89+
</tr>
90+
<tr>
91+
<td>Extracting params</td>
92+
<td>0.7 M/s</td>
93+
<td>2.2 M/s</td>
94+
<td>3x</td>
95+
</tr>
96+
<tr>
97+
<td>Matching against 1k different patterns</td>
98+
<td>3 M/s</td>
99+
<td>7.1 K/s</td>
100+
<td>425x</td>
101+
</tr>
102+
<tr>
103+
<td rowspan="2">Complex patterns</td>
104+
<td>Extracting params</td>
105+
<td>486 K/s</td>
106+
<td>955 K/s</td>
107+
<td>2x</td>
108+
</tr>
109+
<tr>
110+
<td>Matching against malicious input</td>
111+
<td>3.6 K/s</td>
112+
<td>582 K/s</td>
113+
<td>162x</td>
114+
</tr>
115+
</tbody>
116+
</table>
117+
118+
Higher numbers are better.
119+
120+
Performance measured on local development machine (MacBook Pro 2024, Ruby 4.0.2) with the `bench/versions.rb` script, which measures single-threaded performance.
75121

76122
#### Housekeeping
77123

bench/versions.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@
2929
scenarios = {
3030
compile: "Compilation of #{format[counts[:compile]]} patterns",
3131
single_match: "Matching #{format[counts[:single_match]]} times against a single pattern",
32+
single_string: "Matching #{format[counts[:single_match]]} times against the same string",
3233
simple_params: "Extracting params #{format[counts[:params]]} times for a simple pattern",
3334
complex_params: "Extracting params #{format[counts[:params]]} times for a complex pattern",
35+
expand: "Expanding #{format[counts[:params]]} times for a simple pattern",
3436
set_match: "Matching #{format[counts[:set_match]]} times against a set of #{format[counts[:set_size]]} patterns",
3537
look_ahead_fail: "Matching #{format[counts[:look_ahead_fail]]} times with look-ahead pattern on a long failing input (atomic group speedup)",
3638
}
@@ -86,13 +88,25 @@
8688
strings = counts[:single_match].times.map { "/foo/#{element.succ!}" }
8789
x.report(version) { strings.each { |string| pattern === string } }
8890

91+
when "single_string"
92+
pattern = Mustermann.new("/foo/:bar")
93+
string = "/foo/bar"
94+
x.report(version) { counts[:single_match].times { pattern.match(string) } }
95+
8996
when "simple_params"
9097
pattern = Mustermann.new("/:controller/:action")
9198
element = String.new("a")
9299
100.times { pattern.params("/#{element.succ!}/show.html") }
93100
strings = counts[:params].times.map { "/#{element.succ!}/show.html" }
94101
x.report(version) { strings.each { |string| pattern.params(string) } }
95-
102+
103+
when "expand"
104+
pattern = Mustermann.new("/foo/:bar")
105+
element = String.new("a")
106+
100.times { pattern.expand(bar: element.succ!) }
107+
params = counts[:params].times.map { { bar: element.succ!.dup } }
108+
x.report(version) { params.each { |param| pattern.expand(param) } }
109+
96110
when "complex_params"
97111
pattern = Mustermann.new("/:controller/:action(.:format)")
98112
element = String.new("a")

mustermann/lib/mustermann/ast/fast_pattern.rb

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,6 @@ module FastPattern
2626

2727
private_constant :SIMPLE, :ENCODED, :SEGMENT_SCAN
2828

29-
# Bypasses the generic build_match overhead for simple patterns: uses
30-
# MatchData#named_captures directly and avoids match.to_s / post_match /
31-
# pre_match calls (all no-ops for \A…\Z anchored regexps).
32-
def match(string)
33-
return super unless @fast_match
34-
return unless match = @regexp.match(string)
35-
params = match.named_captures
36-
params.transform_values! { |v| unescape(v) } if string.include?('%')
37-
Match.new(self, match, params:)
38-
end
39-
4029
# Public override: fast path for simple patterns, falls through to super otherwise.
4130
# Must remain public to match AST::Pattern#to_ast visibility.
4231
def to_ast
@@ -46,8 +35,24 @@ def to_ast
4635
ast
4736
end
4837

38+
def params(string = nil)
39+
return super unless @fast_match
40+
return unless md = @regexp.match(string)
41+
result = md.named_captures
42+
result.transform_values! { |v| v.include?('%') ? unescape(v) : v } if string.include?('%')
43+
result
44+
end
45+
4946
private
5047

48+
def build_match(regexp, string)
49+
return super unless @fast_match
50+
return unless match = regexp.match(string)
51+
params = match.named_captures
52+
params.transform_values! { |v| v.include?('%') ? unescape(v) : v } if string.include?('%')
53+
Match.new(self, match, params: params)
54+
end
55+
5156
def simple_pattern?
5257
options[:capture].nil? &&
5358
options[:except].nil? &&

mustermann/lib/mustermann/regexp_based.rb

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ def initialize(string, **options)
2020
@peek_regexp = /\A#{regexp}/
2121
@regexp = /\A#{regexp}\Z/
2222
@simple_captures = @regexp.named_captures.none? { |name, positions| positions.size > 1 || always_array?(name) }
23+
24+
if defined?(ObjectSpace::WeakKeyMap)
25+
@match_cache = ObjectSpace::WeakKeyMap.new
26+
@peek_cache = ObjectSpace::WeakKeyMap.new
27+
else
28+
@match_cache = false
29+
@peek_cache = false
30+
end
2331
end
2432

2533
# @param (see Mustermann::Pattern#peek_size)
@@ -33,20 +41,38 @@ def peek_size(string)
3341
# @param (see Mustermann::Pattern#peek_match)
3442
# @return (see Mustermann::Pattern#peek_match)
3543
# @see (see Mustermann::Pattern#peek_match)
36-
def peek_match(string) = build_match(@peek_regexp.match(string))
44+
def peek_match(string) = cache_match(@peek_cache, @peek_regexp, string)
3745

3846
# @param (see Mustermann::Pattern#match)
3947
# @return (see Mustermann::Pattern#match)
4048
# @see (see Mustermann::Pattern#match)
41-
def match(string) = build_match(@regexp.match(string))
49+
def match(string) = cache_match(@match_cache, @regexp, string)
50+
51+
# Extracts params directly from the regexp without allocating a Match object or
52+
# populating the match cache — significant GC savings when called in hot loops.
53+
# @param (see Mustermann::Pattern#params)
54+
# @return (see Mustermann::Pattern#params)
55+
def params(string = nil)
56+
return unless md = @regexp.match(string)
57+
build_params(md)
58+
end
4259

4360
extend Forwardable
4461
def_delegators :regexp, :===, :=~, :names
4562

4663
private
4764

48-
def build_match(match)
49-
return unless match
65+
def cache_match(cache, regexp, string)
66+
if cache
67+
return cache[string] if cache.key?(string)
68+
cache[string] = build_match(regexp, string)
69+
else
70+
build_match(regexp, string)
71+
end
72+
end
73+
74+
def build_match(regexp, string)
75+
return unless match = regexp.match(string)
5076
Match.new(self, match, params: build_params(match))
5177
end
5278

mustermann/spec/sinatra_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,4 +1019,53 @@
10191019
let(:pattern) { "/:b(.:c)?" }
10201020
it { should match("/a/b.json") }
10211021
end
1022+
1023+
describe "match caching" do
1024+
subject(:pattern) { Mustermann.new("/foo/:bar") }
1025+
1026+
if defined?(ObjectSpace::WeakKeyMap)
1027+
it "returns the same Match object for the same string object" do
1028+
string = "/foo/bar"
1029+
expect(pattern.match(string)).to be_equal(pattern.match(string))
1030+
end
1031+
1032+
it "returns nil for a non-matching string without caching errors" do
1033+
expect(pattern.match("/nope")).to be_nil
1034+
expect(pattern.match("/nope")).to be_nil
1035+
end
1036+
end
1037+
1038+
it "returns a match without a cache" do
1039+
pattern.instance_variable_set(:@match_cache, false)
1040+
expect(pattern.match("/foo/bar")).not_to be_nil
1041+
expect(pattern.match("/nope")).to be_nil
1042+
end
1043+
end
1044+
1045+
describe "params without match caching" do
1046+
subject(:pattern) { Mustermann.new("/params_cache_test/:bar") }
1047+
1048+
it "extracts params correctly" do
1049+
expect(pattern.params("/params_cache_test/baz")).to eq("bar" => "baz")
1050+
end
1051+
1052+
it "decodes percent-encoded params" do
1053+
expect(pattern.params("/params_cache_test/f%20o")).to eq("bar" => "f o")
1054+
end
1055+
1056+
it "returns nil for non-matching string" do
1057+
expect(pattern.params("/nope")).to be_nil
1058+
end
1059+
1060+
if defined?(ObjectSpace::WeakKeyMap)
1061+
it "does not populate the match cache when calling params" do
1062+
string = "/params_cache_test/baz"
1063+
cache = pattern.instance_variable_get(:@match_cache)
1064+
1065+
pattern.params(string)
1066+
1067+
expect(cache.key?(string)).to be false
1068+
end
1069+
end
1070+
end
10221071
end

0 commit comments

Comments
 (0)