Skip to content

Commit c1c528d

Browse files
skipkayhilbyroot
andcommitted
Speed up GTG Simulator by reducing slices/matches
At a high level, the while loop in `#memos` breaks up the string into parts where each part is one of the three "special characters" or a group of consecutive characters that aren't one of those "special characters". However, the current implementation does a lot of duplicate or unnecessary work. The StringScanner allocates the scanned string, but then only uses its length. The string is then re-allocated with `#slice` inside `#move`. Additionally, because the StringScanner scans for both the "special characters" and non-"special characters" cases, `#move` has to `#match?` to figure out which of the two cases was scanned. This commit improves the performance of routing by 25-35% in simple cases by removing the duplication and preventing excess string allocations. The improvement is larger for routes with more children or deeper in the tree. The StringScanner is replaced with manually iterating through the string to look for the three "special characters". This prevents string allocations from scanning and also prevents Regexp matching (since there is no more StringScanner), which is slower than the lookup table. The only time a string is allocated is now the non "special character" case. Additionally, the `#match?` inside `#move` is replaced with a boolean parameter because the two match cases are now distinct, and that information can be passed on instead of checking a second time. Benchmark: ```ruby require "action_dispatch" require "benchmark/ips" routes = ActionDispatch::Routing::RouteSet.new routes.draw do get "/products", to: ->(e) { [200, {}, ["p"]] } get "/products/:id", to: ->(e) { [200, {}, ["pid"]] } get "/collections", to: ->(e) { [200, {}, ["c"]] } get "/collections/:id/products", to: ->(e) { [200, {}, ["cidp"]] } end stage = ENV.fetch("STAGE", "before") requests = { index: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/products", "SCRIPT_NAME" => "", "rack.input" => File.open("/dev/null"), }.freeze, show: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/products/1", "SCRIPT_NAME" => "", "rack.input" => File.open("/dev/null"), }.freeze, show_nested: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/collections/1/products", "SCRIPT_NAME" => "", "rack.input" => File.open("/dev/null"), }.freeze, }.each do |name, env| puts "== #{name} ==" Benchmark.ips do |x| x.report(stage) { routes.call(env.dup) } x.save!("/tmp/action_dispatch_#{name}") x.compare!(order: :baseline) end end ``` ``` == index == ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- after 33.759k i/100ms Calculating ------------------------------------- after 338.664k (± 0.4%) i/s (2.95 μs/i) - 1.722M in 5.083918s Comparison: before: 269469.3 i/s after: 338664.4 i/s - 1.26x faster == show == ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- after 25.635k i/100ms Calculating ------------------------------------- after 257.573k (± 0.3%) i/s (3.88 μs/i) - 1.307M in 5.075826s Comparison: before: 194468.2 i/s after: 257572.6 i/s - 1.32x faster == show_nested == ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- after 22.175k i/100ms Calculating ------------------------------------- after 222.257k (± 2.4%) i/s (4.50 μs/i) - 1.131M in 5.091618s Comparison: before: 165457.3 i/s after: 222256.8 i/s - 1.34x faster ``` Co-authored-by: Jean Boussier <[email protected]>
1 parent 08c7230 commit c1c528d

File tree

3 files changed

+29
-19
lines changed

3 files changed

+29
-19
lines changed

actionpack/lib/action_dispatch/journey/gtg/simulator.rb

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
# :markup: markdown
44

5-
require "strscan"
6-
75
module ActionDispatch
86
module Journey # :nodoc:
97
module GTG # :nodoc:
@@ -16,6 +14,12 @@ def initialize(memos)
1614
end
1715

1816
class Simulator # :nodoc:
17+
STATIC_TOKENS = Array.new(64)
18+
STATIC_TOKENS[".".ord] = "."
19+
STATIC_TOKENS["/".ord] = "/"
20+
STATIC_TOKENS["?".ord] = "?"
21+
STATIC_TOKENS.freeze
22+
1923
INITIAL_STATE = [ [0, nil] ].freeze
2024

2125
attr_reader :tt
@@ -25,16 +29,25 @@ def initialize(transition_table)
2529
end
2630

2731
def memos(string)
28-
input = StringScanner.new(string)
2932
state = INITIAL_STATE
30-
start_index = 0
3133

32-
while sym = input.scan(%r([/.?]|[^/.?]+))
33-
end_index = start_index + sym.length
34+
pos = 0
35+
eos = string.bytesize
36+
37+
while pos < eos
38+
start_index = pos
39+
pos += 1
3440

35-
state = tt.move(state, string, start_index, end_index)
41+
if (token = STATIC_TOKENS[string.getbyte(start_index)])
42+
state = tt.move(state, string, token, start_index, false)
43+
else
44+
while pos < eos && STATIC_TOKENS[string.getbyte(pos)].nil?
45+
pos += 1
46+
end
3647

37-
start_index = end_index
48+
token = string.byteslice(start_index, pos - start_index)
49+
state = tt.move(state, string, token, start_index, true)
50+
end
3851
end
3952

4053
acceptance_states = state.each_with_object([]) do |s_d, memos|

actionpack/lib/action_dispatch/journey/gtg/transition_table.rb

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,25 +47,22 @@ def eclosure(t)
4747
Array(t)
4848
end
4949

50-
def move(t, full_string, start_index, end_index)
50+
def move(t, full_string, token, start_index, token_matches_default)
5151
return [] if t.empty?
5252

5353
next_states = []
5454

55-
tok = full_string.slice(start_index, end_index - start_index)
56-
token_matches_default_component = DEFAULT_EXP_ANCHORED.match?(tok)
57-
5855
t.each { |s, previous_start|
5956
if previous_start.nil?
6057
# In the simple case of a "default" param regex do this fast-path and add all
6158
# next states.
62-
if token_matches_default_component && std_state = @stdparam_states[s]
59+
if token_matches_default && std_state = @stdparam_states[s]
6360
next_states << [std_state, nil].freeze
6461
end
6562

6663
# When we have a literal string, we can just pull the next state
6764
if states = @string_states[s]
68-
next_states << [states[tok], nil].freeze unless states[tok].nil?
65+
next_states << [states[token], nil].freeze unless states[token].nil?
6966
end
7067
end
7168

@@ -80,7 +77,7 @@ def move(t, full_string, start_index, end_index)
8077
previous_start
8178
end
8279

83-
slice_length = end_index - slice_start
80+
slice_length = start_index + token.length - slice_start
8481
curr_slice = full_string.slice(slice_start, slice_length)
8582

8683
states.each { |re, v|

actionpack/test/journey/gtg/builder_test.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ module GTG
88
class TestBuilder < ActiveSupport::TestCase
99
def test_following_states_multi
1010
table = tt ["a|a"]
11-
assert_equal 1, table.move([[0, nil]], "a", 0, 1).length
11+
assert_equal 1, table.move([[0, nil]], "a", "a", 0, true).length
1212
end
1313

1414
def test_following_states_multi_regexp
1515
table = tt [":a|b"]
16-
assert_equal 1, table.move([[0, nil]], "fooo", 0, 4).length
17-
assert_equal 2, table.move([[0, nil]], "b", 0, 1).length
16+
assert_equal 1, table.move([[0, nil]], "fooo", "fooo", 0, true).length
17+
assert_equal 2, table.move([[0, nil]], "b", "b", 0, true).length
1818
end
1919

2020
def test_multi_path
@@ -26,7 +26,7 @@ def test_multi_path
2626
[2, "/"],
2727
[1, "c"],
2828
].inject([[0, nil]]) { |state, (exp, sym)|
29-
new = table.move(state, sym, 0, sym.length)
29+
new = table.move(state, sym, sym, 0, sym != "/")
3030
assert_equal exp, new.length
3131
new
3232
}

0 commit comments

Comments
 (0)