profile-tools 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,97 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'concurrent'
4
-
5
- class ProfileTools
6
- # Collects stats around method calls
3
+ module ProfileTools
4
+ # Measures calls and adds them up per method for one profiling run (a request, a job, a block).
5
+ #
6
+ # Allocations are read from GC.stat(:total_allocated_objects), which only goes up, so the
7
+ # counts stay exact when garbage collection runs mid-call. The counter is process-wide:
8
+ # objects allocated by other threads at the same time are counted too.
9
+ #
10
+ # Nothing is allocated between the start and end readings, so a profiled method nested
11
+ # inside another doesn't add to the outer method's count.
7
12
  class Collector
8
- attr_reader :methods,
9
- :total_collection_calls
13
+ # @return [Hash{String => MethodStats}] stats for every method seen by this collector
14
+ attr_reader :stats
10
15
 
11
- def initialize
12
- @methods = {}
13
- @total_collection_calls = 0
16
+ # Creates the stats for the given methods up front. A method seen for the first time
17
+ # inside another method's call allocates its {MethodStats}, which that outer call counts.
18
+ #
19
+ # @param method_names [Array<String>] display names of the profiled methods
20
+ def initialize(method_names = [])
21
+ @stats = {}
14
22
  @sort_order = 0
23
+ method_names.each { |method| stats_for(method) }
15
24
  end
16
25
 
17
- def init_method(method)
18
- @methods[method] = {
19
- method: method,
20
- duration: 0.0,
21
- calls: 0,
22
- count_objects: Hash.new(0),
23
- num_collection_calls: 0,
24
- sort_order: nil
25
- }
26
+ # Runs the block and adds its time, allocations and garbage collection to the method's totals.
27
+ # A call made while the same method is already running is counted but not measured again.
28
+ #
29
+ # @param method [String] display name of the method
30
+ # @yield the code to measure
31
+ # @return [Object] the block's result
32
+ def instrument(method, &)
33
+ stats = stats_for(method)
34
+ recursive = stats.running?
35
+ stats.enter(@sort_order += 1)
36
+ begin
37
+ recursive ? yield : measure(stats, &)
38
+ ensure
39
+ stats.leave
40
+ end
26
41
  end
27
42
 
43
+ # @return [Array<MethodStats>] the methods that were called, in the order they were first called
28
44
  def called_methods
29
- @methods
30
- .values
31
- .reject { |info| info[:calls].zero? }
32
- .sort { |a, b| a[:sort_order] <=> b[:sort_order] }
33
- end
34
-
35
- def instrument(method)
36
- current_collection_calls = @total_collection_calls
37
- result = nil
38
- duration = nil
39
- @methods[method][:sort_order] ||= (@sort_order += 1)
40
- count_objects = count_objects_around do
41
- started_at = now
42
- result = yield
43
- duration = now - started_at
44
- end
45
- add(
46
- method,
47
- duration * 1000.0,
48
- count_objects,
49
- @total_collection_calls - current_collection_calls
50
- )
51
- result
45
+ @stats.values.select(&:called?).sort_by(&:sort_order)
52
46
  end
53
47
 
54
48
  private
55
49
 
56
- def add(method, duration, count_object_changes, num_collection_calls)
57
- @total_collection_calls += 1
58
- @methods[method][:calls] += 1
59
- @methods[method][:duration] += duration
60
- @methods[method][:num_collection_calls] = num_collection_calls
61
- add_object_changes(@methods[method][:count_objects], count_object_changes)
62
- adjust_count_objects(@methods[method][:count_objects], num_collection_calls)
63
- end
64
-
65
- def add_object_changes(current_objects, new_objects)
66
- new_objects.each do |name, cnt|
67
- current_objects[name] += cnt
68
- end
69
- current_objects
50
+ def stats_for(method)
51
+ @stats[method] ||= MethodStats.new(method)
70
52
  end
71
53
 
72
- def adjust_count_objects(count_objects, num_collection_calls)
73
- return if num_collection_calls.zero?
74
-
75
- count_objects[:T_STRING] -= (1 * num_collection_calls)
76
- count_objects[:T_ARRAY] -= (1 * num_collection_calls)
77
- count_objects[:T_HASH] -= (2 * num_collection_calls)
54
+ def measure(stats)
55
+ started_at = now
56
+ allocations = allocated_objects
57
+ gc_count = GC.count
58
+ gc_time = GC.stat(:time)
59
+ yield
60
+ ensure
61
+ stats.add(now - started_at, allocated_objects - allocations, GC.count - gc_count, GC.stat(:time) - gc_time)
78
62
  end
79
63
 
80
64
  def now
81
- Concurrent.monotonic_time
65
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
82
66
  end
83
67
 
84
- def count_objects_changes(starting_objects, new_objects)
85
- new_objects.each do |name, _|
86
- new_objects[name] -= starting_objects[name]
87
- new_objects[name] -= 1 if name == :T_HASH
88
- end
89
- end
90
-
91
- def count_objects_around
92
- starting_objects = ObjectSpace.count_objects
93
- yield
94
- count_objects_changes(starting_objects, ObjectSpace.count_objects)
68
+ def allocated_objects
69
+ GC.stat(:total_allocated_objects)
95
70
  end
96
71
  end
97
72
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProfileTools
4
+ # Base class for errors raised by ProfileTools
5
+ class Error < StandardError; end
6
+ end
@@ -1,22 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ProfileTools
4
- # Logs the collector stats
3
+ require 'active_support'
4
+ require 'active_support/log_subscriber'
5
+
6
+ module ProfileTools
7
+ # Writes one log line per called method when a profiling run finishes.
8
+ #
9
+ # ProfileTools::LogSubscriber.attach_to :profile_tools
10
+ #
11
+ # The {Railtie} attaches it for you. Lines are logged at info level to
12
+ # ActiveSupport::LogSubscriber.logger (Rails.logger in a Rails app).
5
13
  class LogSubscriber < ActiveSupport::LogSubscriber
14
+ # Logs the stats of every method called during the run.
15
+ #
16
+ # @param event [ActiveSupport::Notifications::Event] carries the run's collector in its payload
17
+ # @return [void]
6
18
  def profile(event)
7
- event.payload[:collector].called_methods.each do |info|
8
- duration = info[:duration].round(5)
9
- count_objects = display_count_objects(info[:count_objects])
10
- logger.info "method #{info[:method]} took #{duration}ms, called #{info[:calls]}, objects: #{count_objects}"
19
+ event.payload[:collector].called_methods.each do |stats|
20
+ info { format_stats(stats) }
11
21
  end
12
22
  end
23
+ subscribe_log_level :profile, :info
13
24
 
14
25
  private
15
26
 
16
- def display_count_objects(count_objects)
17
- count_objects.reject! { |_, cnt| cnt.zero? }
18
- count_objects.delete(:FREE)
19
- count_objects.to_a.map { |k, v| "#{k}: #{v}" }.join(', ')
27
+ def format_stats(stats)
28
+ "[ProfileTools] #{stats.method}: #{pluralize(stats.calls, 'call')}, #{stats.duration.round(3)}ms, " \
29
+ "#{pluralize(stats.allocations, 'allocation')}, #{pluralize(stats.gc_count, 'GC run')} (#{stats.gc_time}ms)"
30
+ end
31
+
32
+ def pluralize(count, word)
33
+ "#{count} #{count == 1 ? word : "#{word}s"}"
20
34
  end
21
35
  end
22
36
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProfileTools
4
+ # A method to profile, named the way Ruby docs name methods:
5
+ # "User#save" for an instance method, "User.find" for a class method.
6
+ class MethodName
7
+ # Splits "Class#method" or "Class.method". The class part may be namespaced ("Admin::User").
8
+ PATTERN = /\A(?<class_name>[A-Z]\w*(?:::[A-Z]\w*)*)(?<separator>[#.])(?<method_name>.+)\z/
9
+
10
+ # @return [String] the full name, such as "User#save"
11
+ attr_reader :name
12
+
13
+ # @return [String] the class or module name, such as "User"
14
+ attr_reader :class_name
15
+
16
+ # @return [Symbol] the method name, such as :save
17
+ attr_reader :method_name
18
+
19
+ # @param name [String, Symbol] "Class#method" or "Class.method"
20
+ # @raise [Error] if the name isn't in either form
21
+ def initialize(name)
22
+ match = PATTERN.match(name.to_s)
23
+ raise Error, "#{name.inspect} is not a method name, expected Class#method or Class.method" unless match
24
+
25
+ @name = name.to_s.freeze
26
+ @class_name = match[:class_name]
27
+ @method_name = match[:method_name].to_sym
28
+ @class_method = match[:separator] == '.'
29
+ end
30
+
31
+ # @return [Boolean] true for a class method ("User.find")
32
+ def class_method?
33
+ @class_method
34
+ end
35
+
36
+ # The class or module the method is defined on: the singleton class for a class method.
37
+ # Looking it up loads the constant, so this autoloads app classes.
38
+ #
39
+ # @return [Module]
40
+ # @raise [NameError] if the class doesn't exist
41
+ def owner
42
+ constant = Object.const_get(class_name)
43
+ class_method? ? constant.singleton_class : constant
44
+ end
45
+
46
+ # @return [String] the full name
47
+ def to_s
48
+ name
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProfileTools
4
+ # Totals for one profiled method within a single {Collector}.
5
+ #
6
+ # Duration, allocations and GC numbers include everything the method calls.
7
+ # Recursive calls are counted in {#calls}, but only the outermost call is
8
+ # measured, so a recursive method isn't counted twice.
9
+ class MethodStats
10
+ # @return [String] the method's display name, such as "User#save" or "User.find"
11
+ attr_reader :method
12
+
13
+ # @return [Integer] how many times the method was called
14
+ attr_reader :calls
15
+
16
+ # @return [Float] total time spent in the method, in milliseconds
17
+ attr_reader :duration
18
+
19
+ # @return [Integer] objects allocated while the method ran
20
+ attr_reader :allocations
21
+
22
+ # @return [Integer] garbage collections that ran while the method ran
23
+ attr_reader :gc_count
24
+
25
+ # @return [Integer] time spent in garbage collection while the method ran, in milliseconds
26
+ attr_reader :gc_time
27
+
28
+ # @return [Integer, nil] order in which the method was first called, nil until it is called
29
+ attr_reader :sort_order
30
+
31
+ # @param method [String] display name of the method
32
+ def initialize(method)
33
+ @method = method
34
+ @calls = 0
35
+ @duration = 0.0
36
+ @allocations = 0
37
+ @gc_count = 0
38
+ @gc_time = 0
39
+ @sort_order = nil
40
+ @depth = 0
41
+ end
42
+
43
+ # @return [Boolean] true once the method has been called
44
+ def called?
45
+ @calls.positive?
46
+ end
47
+
48
+ # @return [Boolean] true while a call to the method is in progress, so a nested call is recursion
49
+ def running?
50
+ @depth.positive?
51
+ end
52
+
53
+ # Records the start of a call.
54
+ #
55
+ # @api private
56
+ # @param sort_order [Integer] position to use if this is the method's first call
57
+ # @return [void]
58
+ def enter(sort_order)
59
+ @sort_order ||= sort_order
60
+ @calls += 1
61
+ @depth += 1
62
+ end
63
+
64
+ # Records the end of a call.
65
+ #
66
+ # @api private
67
+ # @return [void]
68
+ def leave
69
+ @depth -= 1
70
+ end
71
+
72
+ # Adds one measured call to the totals.
73
+ #
74
+ # @api private
75
+ # @param duration [Float] milliseconds
76
+ # @param allocations [Integer] objects allocated
77
+ # @param gc_count [Integer] garbage collections
78
+ # @param gc_time [Integer] milliseconds spent in garbage collection
79
+ # @return [void]
80
+ def add(duration, allocations, gc_count, gc_time)
81
+ @duration += duration
82
+ @allocations += allocations
83
+ @gc_count += gc_count
84
+ @gc_time += gc_time
85
+ end
86
+
87
+ # @return [Hash] the totals as a hash
88
+ def to_h
89
+ {
90
+ method: method,
91
+ calls: calls,
92
+ duration: duration,
93
+ allocations: allocations,
94
+ gc_count: gc_count,
95
+ gc_time: gc_time
96
+ }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProfileTools
4
+ # Adds and removes the profiling wrapper around a method.
5
+ #
6
+ # Wrappers live in a module prepended to the method's class (one per class, reused),
7
+ # so the original method is never renamed. Each wrapper forwards its arguments with
8
+ # `...` and calls `super`, which keeps positional, keyword and block arguments intact
9
+ # and allocates nothing per call.
10
+ class MethodWrapper
11
+ # Method names that can't be written as `def name(...)`, wrapped with define_method instead.
12
+ DEF_NAME = %r{\A(?:[A-Za-z_]\w*[?!=]?|\[\]=?|[-+]@|[-+*/%<>!~^&|`]|\*\*|<=>|===?|=~|!=|!~|<<|>>|<=|>=)\z}
13
+
14
+ # Marks the modules this class prepends, so they're found again and shown clearly in ancestors.
15
+ class WrapperModule < Module
16
+ # @return [String]
17
+ def inspect
18
+ 'ProfileTools::MethodWrapper::WrapperModule'
19
+ end
20
+ alias to_s inspect
21
+ end
22
+
23
+ # @param method_name [MethodName] the method to wrap
24
+ def initialize(method_name)
25
+ @method_name = method_name
26
+ @owner = method_name.owner
27
+ end
28
+
29
+ # Wraps the method. Its visibility (public, protected or private) is kept.
30
+ #
31
+ # @return [void]
32
+ # @raise [UnknownMethodError] if the class doesn't define the method
33
+ def wrap
34
+ visibility = method_visibility
35
+ raise UnknownMethodError, "#{@method_name} is not defined" unless visibility
36
+
37
+ define_wrapper(wrapper_module)
38
+ wrapper_module.send(visibility, name)
39
+ end
40
+
41
+ # Removes the wrapper added by {#wrap}. The original method is untouched, so calls go
42
+ # straight to it again.
43
+ #
44
+ # @return [void]
45
+ def unwrap
46
+ existing_wrapper_module.send(:remove_method, name)
47
+ end
48
+
49
+ private
50
+
51
+ def name
52
+ @method_name.method_name
53
+ end
54
+
55
+ def method_visibility
56
+ if @owner.public_method_defined?(name) then :public
57
+ elsif @owner.protected_method_defined?(name) then :protected
58
+ elsif @owner.private_method_defined?(name) then :private
59
+ end
60
+ end
61
+
62
+ def wrapper_module
63
+ existing_wrapper_module || WrapperModule.new.tap { |module_| @owner.prepend(module_) }
64
+ end
65
+
66
+ # Only modules prepended directly to the owner come before it in its ancestors.
67
+ def existing_wrapper_module
68
+ @owner.ancestors.take_while { |ancestor| ancestor != @owner }.grep(WrapperModule).first
69
+ end
70
+
71
+ def define_wrapper(module_)
72
+ if DEF_NAME.match?(name.to_s)
73
+ module_.module_eval(def_source, __FILE__, __LINE__)
74
+ else
75
+ define_method_wrapper(module_)
76
+ end
77
+ end
78
+
79
+ # `"...".freeze` compiles to a single frozen string, so passing the display name allocates nothing.
80
+ def def_source
81
+ <<~RUBY
82
+ def #{name}(...)
83
+ ::ProfileTools.profiler.instrument(#{@method_name.name.dump}.freeze) { super(...) }
84
+ end
85
+ RUBY
86
+ end
87
+
88
+ def define_method_wrapper(module_)
89
+ display_name = @method_name.name
90
+ module_.send(:define_method, name) do |*args, **kwargs, &block|
91
+ ::ProfileTools.profiler.instrument(display_name) { super(*args, **kwargs, &block) }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProfileTools
4
+ # Rack middleware that makes each request one profiling run, so the profiled methods it calls
5
+ # are reported together, under a "GET /path" line for the whole request.
6
+ #
7
+ # The {Railtie} adds it when a config file is present. Without it, every outermost call to a
8
+ # profiled method is reported on its own.
9
+ class Middleware
10
+ # @param app [#call] the next Rack app
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ # @param env [Hash] the Rack environment
16
+ # @return [Array] the Rack response
17
+ def call(env)
18
+ ProfileTools.instrument("#{env['REQUEST_METHOD']} #{env['PATH_INFO']}") { @app.call(env) }
19
+ end
20
+ end
21
+ end
@@ -1,59 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ProfileTools
4
- # Aggregates profile stats into the collector
3
+ require 'active_support'
4
+ require 'active_support/notifications'
5
+
6
+ module ProfileTools
7
+ # Tracks the profiling run on the current thread (fiber-local, through Thread.current).
8
+ #
9
+ # The outermost {#instrument} call starts a run: it creates a {Collector} and publishes it in
10
+ # a "profile.profile_tools" ActiveSupport notification once the block finishes. Calls made
11
+ # during the run add to that collector.
5
12
  class Profiler
13
+ # Name used by {ProfileTools.instrument} when none is given
14
+ DEFAULT_NAME = 'ProfileTools.instrument'
15
+
16
+ # @return [Collector, nil] the collector of the current run, or of the last finished run
6
17
  attr_reader :collector
7
18
 
8
19
  def initialize
9
- @call_depth = 0
10
- end
11
-
12
- def instrument(class_and_method_name = 'ProfileTools::Profiler#instrument')
13
- result = nil
14
- if increment_call_depth == 1
15
- @collector = new_collector
16
- @collector.init_method(class_and_method_name)
17
- instrument_with_notifications(class_and_method_name) do
18
- result = yield
19
- end
20
- else
21
- instrument_with_collector(class_and_method_name) do
22
- result = yield
23
- end
24
- end
25
- decrement_call_depth
26
- result
20
+ @collector = nil
21
+ @running = false
27
22
  end
28
23
 
29
- private
24
+ # Measures the block as the named method. Starts a new run if none is in progress.
25
+ #
26
+ # @param name [String] display name to record the block under
27
+ # @yield the code to measure
28
+ # @return [Object] the block's result
29
+ def instrument(name = DEFAULT_NAME, &)
30
+ return @collector.instrument(name, &) if running?
30
31
 
31
- def instrument_with_notifications(class_and_method_name)
32
- ActiveSupport::Notifications.instrument(EVENT, collector: @collector) do
33
- instrument_with_collector(class_and_method_name) do
34
- yield
35
- end
36
- end
32
+ run(name, &)
37
33
  end
38
34
 
39
- def instrument_with_collector(class_and_method_name)
40
- @collector.instrument(class_and_method_name) do
41
- yield
42
- end
43
- end
44
-
45
- def increment_call_depth
46
- @call_depth += 1
35
+ # @return [Boolean] true while a run is in progress
36
+ def running?
37
+ @running
47
38
  end
48
39
 
49
- def decrement_call_depth
50
- @call_depth -= 1
51
- end
40
+ private
52
41
 
53
- def new_collector
54
- ::ProfileTools::Collector.new.tap do |collector|
55
- ::ProfileTools.profiled_methods.each { |display_name| collector.init_method(display_name) }
42
+ def run(name, &)
43
+ @running = true
44
+ @collector = Collector.new(ProfileTools.profiled_methods)
45
+ ActiveSupport::Notifications.instrument(EVENT, name: name, collector: @collector) do
46
+ @collector.instrument(name, &)
56
47
  end
48
+ ensure
49
+ @running = false
57
50
  end
58
51
  end
59
52
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module ProfileTools
6
+ # Turns profiling on in a Rails app when a config file is present, with no code changes:
7
+ # put the file in place, restart, read the log, remove the file, restart.
8
+ #
9
+ # The file is config/profile_tools.yml, or the path in the PROFILE_TOOLS_CONFIG environment
10
+ # variable. When it exists, the Railtie adds {Middleware} after Rails::Rack::Logger (so log
11
+ # lines keep the request's tags), attaches {LogSubscriber}, and once the app has booted
12
+ # (and eager loaded) wraps the methods the file lists.
13
+ class Railtie < Rails::Railtie
14
+ initializer('profile_tools.middleware') { |app| ProfileTools::Railtie.setup(app) }
15
+ config.after_initialize { |app| ProfileTools::Railtie.profile(app) }
16
+
17
+ class << self
18
+ # @param app [Rails::Application]
19
+ # @return [Pathname] where the config file is looked for
20
+ def config_path(app)
21
+ Pathname.new(ENV.fetch('PROFILE_TOOLS_CONFIG') { app.root.join('config/profile_tools.yml') })
22
+ end
23
+
24
+ # Adds the middleware and log subscriber if the config file exists.
25
+ #
26
+ # @api private
27
+ # @param app [Rails::Application]
28
+ # @return [void]
29
+ def setup(app)
30
+ return unless config_path(app).exist?
31
+
32
+ app.config.middleware.insert_after Rails::Rack::Logger, ProfileTools::Middleware
33
+ ProfileTools::LogSubscriber.attach_to :profile_tools
34
+ end
35
+
36
+ # Profiles the methods in the config file if it exists.
37
+ #
38
+ # @api private
39
+ # @param app [Rails::Application]
40
+ # @return [void]
41
+ def profile(app)
42
+ path = config_path(app)
43
+ ProfileTools.load(path) if path.exist?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProfileTools
4
+ # Raised when asked to profile a method the class doesn't define
5
+ class UnknownMethodError < Error; end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProfileTools
4
+ # Gem version, bumped by release-please
5
+ VERSION = '0.2.0'
6
+ end