autotuner 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ module Heuristic
5
+ class RememberedWBUnprotectedObjects < Base
6
+ class << self
7
+ private
8
+
9
+ def supported?
10
+ # Ruby 3.3.0 and later have support RUBY_GC_HEAP_REMEMBERED_WB_UNPROTECTED_OBJECTS_LIMIT_RATIO
11
+ RUBY_VERSION >= "3.3.0"
12
+ end
13
+ end
14
+
15
+ WB_UNPROTECTED_GC_RATIO_THRESHOLD = 0.1
16
+ MIN_WB_UNPROTECTED_GC = 10
17
+
18
+ # From the GC_HEAP_REMEMBERED_WB_UNPROTECTED_OBJECTS_LIMIT_RATIO macro
19
+ # https://github.com/ruby/ruby/blob/df4c77608e76068deed58b2781674b0eb247c325/gc.c#L295
20
+ DEFAULT_LIMIT_RATIO = 0.01
21
+
22
+ LIMIT_RATIO_ENV = "RUBY_GC_HEAP_REMEMBERED_WB_UNPROTECTED_OBJECTS_LIMIT_RATIO"
23
+
24
+ attr_reader :major_gc_count
25
+ attr_reader :remembered_wb_unprotected_gc_count
26
+
27
+ def initialize(_system_context)
28
+ super
29
+
30
+ @major_gc_count = 0
31
+ @remembered_wb_unprotected_gc_count = 0
32
+
33
+ @given_suggestion = false
34
+ end
35
+
36
+ def name
37
+ "WBUnprotectedObjects"
38
+ end
39
+
40
+ def call(request_context)
41
+ # major_by is only useful if we ran at least one major GC during the request
42
+ if request_context.after_gc_context.stat[:major_gc_count] ==
43
+ request_context.before_gc_context.stat[:major_gc_count]
44
+ return
45
+ end
46
+
47
+ # Technically, we could run more than one major GC in the request, but
48
+ # since we don't have information about the other major GC, we'll treat
49
+ # it as if there was only one major GC.
50
+ @major_gc_count += 1
51
+ @remembered_wb_unprotected_gc_count += 1 if request_context.after_gc_context.latest_gc_info[:major_by] == :shady
52
+ end
53
+
54
+ def tuning_report
55
+ # Don't give suggestions twice
56
+ return if @given_suggestion
57
+ # Don't report if there's very few data points
58
+ return if @remembered_wb_unprotected_gc_count < MIN_WB_UNPROTECTED_GC
59
+
60
+ wb_unprotected_gc_ratio = @remembered_wb_unprotected_gc_count.to_f / @major_gc_count
61
+ # Don't report if there's very few WB unprotected GC
62
+ return if wb_unprotected_gc_ratio <= WB_UNPROTECTED_GC_RATIO_THRESHOLD
63
+
64
+ @given_suggestion = true
65
+
66
+ Report::SingleEnvironmentVariable.new(
67
+ <<~MSG,
68
+ The following suggestions reduces the number of major garbage collection cycles, specifically a cycle called "remembered write barrier unprotected" (also know as "shady" due to historical reasons). Your app runs remembered write barrier unprotected cycles in approximately #{format("%.2f", wb_unprotected_gc_ratio * 100)}% of all major garbage collection cycles.
69
+
70
+ Reducing major garbage collection cycles can help reduce response times, especially for the extremes (e.g. p95 or p99 response times). The following tuning values aims to disable oldmalloc garbage collection cycles by setting it to an extremely high value. This may cause a slight increase in memory usage. You should monitor memory usage carefully to ensure your app is not running out of memory.
71
+ MSG
72
+ LIMIT_RATIO_ENV,
73
+ suggested_limit_ratio,
74
+ configured_limit_ratio,
75
+ )
76
+ end
77
+
78
+ def debug_state
79
+ {
80
+ given_suggestion: @given_suggestion,
81
+ major_gc_count: @major_gc_count,
82
+ remembered_wb_unprotected_gc_count: @remembered_wb_unprotected_gc_count,
83
+ }
84
+ end
85
+
86
+ private
87
+
88
+ def configured_limit_ratio
89
+ ENV[LIMIT_RATIO_ENV]&.to_f
90
+ end
91
+
92
+ def suggested_limit_ratio
93
+ if !configured_limit_ratio
94
+ DEFAULT_LIMIT_RATIO * 2
95
+ elsif configured_limit_ratio < DEFAULT_LIMIT_RATIO
96
+ DEFAULT_LIMIT_RATIO
97
+ else
98
+ configured_limit_ratio * 2
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ module Heuristics
5
+ HEURISTICS = Heuristic::Base.subclasses.freeze
6
+ ENABLED_HEURISTICS = HEURISTICS.dup.keep_if(&:enabled?).freeze
7
+
8
+ def enabled_heuristics
9
+ ENABLED_HEURISTICS
10
+ end
11
+ end
12
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module GCTuner
3
+ module Autotuner
4
4
  class RackPlugin
5
5
  def initialize(app)
6
6
  @app = app
@@ -8,7 +8,7 @@ module GCTuner
8
8
  end
9
9
 
10
10
  def call(env)
11
- if GCTuner.enabled?
11
+ if Autotuner.enabled?
12
12
  @request_collector.request do
13
13
  @app.call(env)
14
14
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ module Report
5
+ class Base
6
+ DISCLAIMER_MESSAGE = <<~MSG
7
+ It is always recommended to experiment with these suggestions as some suggestions may not always yield positive performance improvements. The recommended method is to perform A/B testing where a portion of traffic does not have the these suggested values and a portion of traffic with these suggested values.
8
+ MSG
9
+
10
+ attr_reader :assist_message
11
+
12
+ def initialize(assist_message)
13
+ @assist_message = assist_message
14
+ end
15
+
16
+ def to_s
17
+ msg = +assist_message
18
+ msg << "\n"
19
+
20
+ m = message
21
+ if m
22
+ msg << m
23
+ msg << "\n"
24
+ end
25
+
26
+ msg << DISCLAIMER_MESSAGE
27
+ msg.freeze
28
+ end
29
+
30
+ private
31
+
32
+ def message
33
+ raise NotImplementedError
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ module Report
5
+ class MultipleEnvironmentVariables < Base
6
+ attr_reader :env_name
7
+ attr_reader :suggested_value
8
+ attr_reader :configured_value
9
+
10
+ def initialize(assist_message, env_name, suggested_value, configured_value)
11
+ super(assist_message)
12
+ @env_name = env_name
13
+ @suggested_value = suggested_value
14
+ @configured_value = configured_value
15
+ end
16
+
17
+ private
18
+
19
+ def message
20
+ msg = +"Suggested tuning values:\n"
21
+ env_name.each_with_index do |env, i|
22
+ msg << suggested_tuning_str(env, suggested_value[i], configured_value[i])
23
+ end
24
+ msg
25
+ end
26
+
27
+ def suggested_tuning_str(env, suggested, configured)
28
+ str = +" #{env}=#{suggested}"
29
+ str << " (configured value: #{configured})" if configured
30
+ str << "\n"
31
+ str
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ module Report
5
+ class SingleEnvironmentVariable < Base
6
+ attr_reader :env_name
7
+ attr_reader :suggested_value
8
+ attr_reader :configured_value
9
+
10
+ def initialize(assist_message, env_name, suggested_value, configured_value)
11
+ super(assist_message)
12
+
13
+ @env_name = env_name
14
+ @suggested_value = suggested_value
15
+ @configured_value = configured_value
16
+ end
17
+
18
+ private
19
+
20
+ def message
21
+ msg = +"Suggested tuning value:\n"
22
+ msg << " #{env_name}=#{suggested_value}"
23
+ msg << " (configured value: #{configured_value})" if configured_value
24
+ msg << "\n"
25
+ msg
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ module Report
5
+ class String < Base
6
+ private
7
+
8
+ def message
9
+ nil
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ class RequestCollector
5
+ HEURISTICS_POLLING_FREQUENCY = 100
6
+ DEBUG_EMIT_FREQUENCY = 1000
7
+
8
+ attr_reader :heuristics
9
+
10
+ def initialize
11
+ @request_count = 0
12
+
13
+ @request_context = RequestContext.new
14
+
15
+ @system_context = SystemContext.new
16
+
17
+ @heuristics = Autotuner.enabled_heuristics.map { |h| h.new(@system_context) }
18
+ end
19
+
20
+ def request
21
+ before_request
22
+
23
+ yield
24
+ ensure
25
+ after_request
26
+ end
27
+
28
+ private
29
+
30
+ def before_request
31
+ @request_context.before_request
32
+
33
+ @request_count += 1
34
+ end
35
+
36
+ def after_request
37
+ @request_context.after_request
38
+
39
+ @system_context.update(@request_context)
40
+
41
+ heuristics.each do |heuristic|
42
+ heuristic.call(@request_context)
43
+ end
44
+
45
+ emit_heuristic_reports if @request_count % HEURISTICS_POLLING_FREQUENCY == 0
46
+ emit_debugging_states if @request_count % DEBUG_EMIT_FREQUENCY == 0
47
+ emit_metrics
48
+ end
49
+
50
+ def emit_heuristic_reports
51
+ heuristics.each do |heuristic|
52
+ report = heuristic.tuning_report
53
+
54
+ next unless report
55
+
56
+ if Autotuner.reporter
57
+ Autotuner.reporter.call(report)
58
+ else
59
+ warn("Autotuner has been enabled but Autotuner.reporter has not been configured")
60
+ end
61
+ end
62
+ end
63
+
64
+ def emit_debugging_states
65
+ return unless Autotuner.debug_reporter
66
+
67
+ debug_states = {
68
+ system_context: @system_context.debug_state,
69
+ }
70
+
71
+ heuristics.each do |h|
72
+ debug_states[h.name] = h.debug_state
73
+ end
74
+
75
+ Autotuner.debug_reporter.call(debug_states)
76
+ end
77
+
78
+ def emit_metrics
79
+ return unless Autotuner.metrics_reporter
80
+
81
+ metrics = {
82
+ # Diff metrics
83
+ "diff.time" => gc_stat_diff(:time),
84
+ "diff.minor_gc_count" => gc_stat_diff(:minor_gc_count),
85
+ "diff.major_gc_count" => gc_stat_diff(:major_gc_count),
86
+ "request_time" => @request_context.request_time,
87
+
88
+ # Metrics
89
+ "heap_pages" => @request_context.after_gc_context.stat[:heap_eden_pages],
90
+ }
91
+
92
+ Autotuner.metrics_reporter.call(metrics)
93
+ end
94
+
95
+ def gc_stat_diff(stat)
96
+ @request_context.after_gc_context.stat[stat] - @request_context.before_gc_context.stat[stat]
97
+ end
98
+ end
99
+ end
@@ -1,36 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module GCTuner
4
- class RequestCollector
3
+ module Autotuner
4
+ class RequestContext
5
+ attr_reader :before_gc_context
6
+ attr_reader :after_gc_context
7
+ attr_reader :request_time
8
+
5
9
  def initialize
6
10
  @before_gc_context = GCContext.new
7
11
  @after_gc_context = GCContext.new
12
+ @request_time = 0.0
8
13
 
9
14
  @start_time_ms = 0.0
10
15
  end
11
16
 
12
- def request
13
- before_request
14
-
15
- yield
16
- ensure
17
- after_request
18
- end
19
-
20
- private
21
-
22
17
  def before_request
23
18
  @before_gc_context.update
24
19
  @start_time_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
25
20
  end
26
21
 
27
22
  def after_request
28
- request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - @start_time_ms
23
+ @request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - @start_time_ms
29
24
  @after_gc_context.update
30
-
31
- GCTuner.heuristics.each do |heuristic|
32
- heuristic.call(request_time, @before_gc_context, @after_gc_context)
33
- end
34
25
  end
35
26
  end
36
27
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ class SystemContext
5
+ attr_reader :request_time_data
6
+
7
+ def initialize
8
+ @request_time_data = DataStructure::DataPoints.new(Configuration::DATA_POINTS_COUNT)
9
+ end
10
+
11
+ def update(request_context)
12
+ @request_time_data.insert(request_context.request_time)
13
+ end
14
+
15
+ def debug_state
16
+ {
17
+ request_time_data: @request_time_data.debug_state,
18
+ }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autotuner
4
+ VERSION = "1.0.0"
5
+ end
data/lib/autotuner.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "autotuner/data_structure/data_points"
4
+
5
+ require_relative "autotuner/heuristic/base"
6
+ require_relative "autotuner/heuristic/gc_compact"
7
+ require_relative "autotuner/heuristic/heap_size_warmup"
8
+ require_relative "autotuner/heuristic/malloc"
9
+ require_relative "autotuner/heuristic/oldmalloc"
10
+ require_relative "autotuner/heuristic/remembered_wb_unprotected_objects"
11
+
12
+ require_relative "autotuner/report/base"
13
+ require_relative "autotuner/report/multiple_environment_variables"
14
+ require_relative "autotuner/report/single_environment_variable"
15
+ require_relative "autotuner/report/string"
16
+
17
+ require_relative "autotuner/configuration"
18
+ require_relative "autotuner/gc_context"
19
+ require_relative "autotuner/heuristics"
20
+ require_relative "autotuner/rack_plugin"
21
+ require_relative "autotuner/request_collector"
22
+ require_relative "autotuner/request_context"
23
+ require_relative "autotuner/system_context"
24
+ require_relative "autotuner/version"
25
+
26
+ module Autotuner
27
+ extend Configuration
28
+ extend Heuristics
29
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: autotuner
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Zhu
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-06-13 00:00:00.000000000 Z
11
+ date: 2023-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mocha
@@ -64,22 +64,32 @@ files:
64
64
  - LICENSE.txt
65
65
  - README.md
66
66
  - Rakefile
67
- - lib/gc_tuner.rb
68
- - lib/gc_tuner/configuration.rb
69
- - lib/gc_tuner/data_structure/data_points.rb
70
- - lib/gc_tuner/gc_context.rb
71
- - lib/gc_tuner/heuristic/base.rb
72
- - lib/gc_tuner/heuristic/size_pool_warmup.rb
73
- - lib/gc_tuner/heuristics.rb
74
- - lib/gc_tuner/rack_plugin.rb
75
- - lib/gc_tuner/request_collector.rb
76
- - lib/gc_tuner/version.rb
77
- homepage: https://github.com/Shopify/gc_tuner
67
+ - lib/autotuner.rb
68
+ - lib/autotuner/configuration.rb
69
+ - lib/autotuner/data_structure/data_points.rb
70
+ - lib/autotuner/gc_context.rb
71
+ - lib/autotuner/heuristic/base.rb
72
+ - lib/autotuner/heuristic/gc_compact.rb
73
+ - lib/autotuner/heuristic/heap_size_warmup.rb
74
+ - lib/autotuner/heuristic/malloc.rb
75
+ - lib/autotuner/heuristic/oldmalloc.rb
76
+ - lib/autotuner/heuristic/remembered_wb_unprotected_objects.rb
77
+ - lib/autotuner/heuristics.rb
78
+ - lib/autotuner/rack_plugin.rb
79
+ - lib/autotuner/report/base.rb
80
+ - lib/autotuner/report/multiple_environment_variables.rb
81
+ - lib/autotuner/report/single_environment_variable.rb
82
+ - lib/autotuner/report/string.rb
83
+ - lib/autotuner/request_collector.rb
84
+ - lib/autotuner/request_context.rb
85
+ - lib/autotuner/system_context.rb
86
+ - lib/autotuner/version.rb
87
+ homepage: https://github.com/Shopify/autotuner
78
88
  licenses:
79
89
  - MIT
80
90
  metadata:
81
- homepage_uri: https://github.com/Shopify/gc_tuner
82
- source_code_uri: https://github.com/Shopify/gc_tuner
91
+ homepage_uri: https://github.com/Shopify/autotuner
92
+ source_code_uri: https://github.com/Shopify/autotuner
83
93
  post_install_message:
84
94
  rdoc_options: []
85
95
  require_paths:
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GCTuner
4
- module Configuration
5
- attr_reader :sample_ratio
6
- attr_writer :enabled
7
-
8
- def enabled?
9
- @enabled
10
- end
11
-
12
- def sample_ratio=(ratio)
13
- raise ArgumentError, "ratio must be between 0 and 1.0" unless (0..1.0).include?(ratio)
14
-
15
- @sample_ratio = ratio
16
-
17
- self.enabled = rand < ratio
18
- end
19
- end
20
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GCTuner
4
- class GCContext
5
- attr_reader :stat, :stat_heap
6
-
7
- def initialize
8
- @stat = GC.stat
9
- @stat_heap = GC.stat_heap
10
- end
11
-
12
- def update
13
- GC.stat(@stat)
14
- GC.stat_heap(nil, @stat_heap)
15
- end
16
- end
17
- end
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GCTuner
4
- module Heuristic
5
- class SizePoolWarmup < Base
6
- DATA_POINTS_COUNT = 1_000
7
- SIZE_POOL_CONFIGURATION_DELTA_RATIO = 0.01
8
- SIZE_POOL_CONFIGURATION_DELTA = 1
9
-
10
- class << self
11
- private
12
-
13
- def supported?
14
- # Ruby 3.3.0 and later have support RUBY_GC_HEAP_INIT_SIZE_%d_SLOTS
15
- # RUBY_VERSION >= "3.3.0"
16
- # TODO: use the check above
17
- true
18
- end
19
- end
20
-
21
- def initialize
22
- super
23
-
24
- @request_time_data = DataStructure::DataPoints.new(DATA_POINTS_COUNT)
25
-
26
- @size_pool_count = GC::INTERNAL_CONSTANTS[:SIZE_POOL_COUNT]
27
- @size_pools_data = Array.new(@size_pool_count)
28
- @size_pools_tuning_configuration = Array.new(@size_pool_count)
29
- @size_pool_count.times do |i|
30
- @size_pools_data[i] = DataStructure::DataPoints.new(DATA_POINTS_COUNT)
31
- @size_pools_tuning_configuration[i] = ENV[env_name_for_size_pool(i)].to_i
32
- end
33
-
34
- @plateaued = false
35
- end
36
-
37
- def call(request_time, _before_gc_context, after_gc_context)
38
- # We only want to collect data at boot until the request time plateaus
39
- return if @plateaued
40
-
41
- insert_data(request_time, after_gc_context)
42
-
43
- return unless @request_time_data.plateaued?
44
-
45
- @plateaued = true
46
- end
47
-
48
- def tuning_message
49
- msg = nil
50
-
51
- if @plateaued
52
- size_pool_messages = @size_pool_count.times.map do |i|
53
- tuning_message_for_size_pool(i)
54
- end.compact
55
-
56
- unless size_pool_messages.empty?
57
- msg = <<~MSG
58
- Here are the recommended tuning values for size pools and the confidence scores.
59
- Confidence scores are between 0 and 1.0 and represent the correlation between
60
- the tuning value and the response time.
61
-
62
- MSG
63
-
64
- msg += size_pool_messages.join
65
- end
66
- else
67
- msg = <<~MSG.chomp
68
- There is not enough data and/or response times have not plateaued.
69
- MSG
70
- end
71
-
72
- msg
73
- end
74
-
75
- def debug_message
76
- msg = <<~MSG
77
- plateaued: #{@plateaued}
78
- request_time_data: #{@request_time_data}
79
- MSG
80
-
81
- @size_pools_data.each_with_index do |data, i|
82
- msg += "size_pools_data[#{i}]: #{data}\n"
83
- end
84
-
85
- if @plateaued
86
- msg += @size_pool_count.times.map do |i|
87
- tuning_message_for_size_pool(i, debug: true)
88
- end.join
89
- end
90
-
91
- msg
92
- end
93
-
94
- private
95
-
96
- def insert_data(request_time, after_gc_context)
97
- @request_time_data.insert(request_time)
98
-
99
- @size_pools_data.each_with_index do |data, i|
100
- data.insert(after_gc_context.stat_heap[i][:heap_eden_pages])
101
- end
102
- end
103
-
104
- def env_name_for_size_pool(size_pool)
105
- slot_size = GC::INTERNAL_CONSTANTS[:BASE_SLOT_SIZE] * (2**size_pool)
106
-
107
- "RUBY_GC_HEAP_INIT_SIZE_#{slot_size}_SLOTS"
108
- end
109
-
110
- def tuning_message_for_size_pool(size_pool, debug: false)
111
- configured_value = @size_pools_tuning_configuration[size_pool]
112
-
113
- data = @size_pools_data[size_pool]
114
- suggested_value = data.samples[data.length - 1].to_i
115
-
116
- diff = (configured_value - suggested_value).abs
117
- if debug ||
118
- (diff > configured_value * SIZE_POOL_CONFIGURATION_DELTA_RATIO && diff > SIZE_POOL_CONFIGURATION_DELTA)
119
- confidence = @request_time_data.correlation(data).abs
120
-
121
- msg = ""
122
- msg += "#{env_name_for_size_pool(size_pool)}=#{suggested_value} (confidence: #{format("%.2f", confidence)}"
123
- msg += ", tuned value: #{configured_value}" if configured_value > 0
124
- msg += ")\n"
125
-
126
- msg
127
- end
128
- end
129
- end
130
- end
131
- end