startup-time 1.0.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.
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'shellwords'
5
+ require_relative 'util'
6
+
7
+ module StartupTime
8
+ # StartupTime::Builder - clean and prepare the build directory
9
+ #
10
+ # this class provides two methods which clean (i.e. remove) and prepare the build
11
+ # directory. the latter is done by executing the following tasks:
12
+ #
13
+ # 1) copy source files from the source directory to the build directory
14
+ # 2) compile all of the target files that need to be compiled from source files
15
+ #
16
+ # once these tasks are complete, everything required to run the benchmark tests
17
+ # will be available in the build directory
18
+ class Builder
19
+ SRC_DIR = File.absolute_path('../../resources/src', __dir__)
20
+
21
+ include Rake::DSL
22
+ include Util # for `which`
23
+ include Services.mixin %i[options selected_tests]
24
+
25
+ def initialize
26
+ @verbosity = options.verbosity
27
+ @build_dir = options.build_dir
28
+
29
+ Rake.verbose(@verbosity != :quiet)
30
+ end
31
+
32
+ # remove the build directory and its contents
33
+ def clean!
34
+ rm_rf @build_dir
35
+ end
36
+
37
+ # ensure the build directory is in a fit state to run the tests i.e. copy
38
+ # source files and compile target files
39
+ def build!
40
+ verbose(@verbosity == :verbose) do
41
+ mkdir_p(@build_dir) unless Dir.exist?(@build_dir)
42
+ cd @build_dir
43
+ end
44
+
45
+ register_tasks
46
+
47
+ Rake::Task[:build].invoke
48
+ end
49
+
50
+ private
51
+
52
+ # a wrapper for Rake's +FileUtils#sh+ method (which wraps +Kernel#spawn+)
53
+ # which allows the command's environment to be included in the final options
54
+ # hash rather than cramming it in as the first argument i.e.:
55
+ #
56
+ # before:
57
+ #
58
+ # sh FOO_VERBOSE: "0", "foo -c hello.foo -o hello", out: File::NULL
59
+ #
60
+ # after:
61
+ #
62
+ # shell "foo -c hello.foo -o hello", env: { FOO_VERBOSE: "0" }, out: File::NULL
63
+ #
64
+ def shell(args, **options)
65
+ args = Array(args) # args is a string or array
66
+ env = options.delete(:env)
67
+ args.unshift(env) if env
68
+ sh(*args, options)
69
+ end
70
+
71
+ # a conditional version of Rake's `file` task which compiles a source file to
72
+ # a target file via the block provided. if the compiler isn't installed, the
73
+ # task is skipped.
74
+ #
75
+ # returns a truthy value (the target filename) if the task is created, or nil
76
+ # otherwise
77
+ def compile_if(id, **options)
78
+ tests = options[:force] ? Registry::TESTS : selected_tests
79
+
80
+ # look up the test's spec among the remaining tests which haven't been
81
+ # excluded by --omit or --only
82
+ return unless (test = tests[id])
83
+
84
+ # the compiler name (e.g. "crystal") is usually the same as the ID for
85
+ # the test (e.g. "crystal"), but can be supplied explicitly in the test
86
+ # spec e.g. { id: "java-native", compiler: "native-image" }
87
+ compiler = test[:compiler] || id
88
+
89
+ return unless (compiler_path = which(compiler))
90
+
91
+ # the source filename must be supplied
92
+ source = test.fetch(:source)
93
+
94
+ # infer the target if not specified
95
+ unless (target = test[:target])
96
+ command = Array(test[:command])
97
+
98
+ if command.length == 1
99
+ target = command.first
100
+ elsif source.match?(/^[A-Z]/) # JVM language
101
+ target = source.pathmap('%n.class')
102
+ else # native executable
103
+ target = '%s.out' % source
104
+ end
105
+ end
106
+
107
+ # update the test hash's compiler field to point to the compiler's
108
+ # absolute path (which may be mocked)
109
+ test = test.merge(compiler: compiler_path)
110
+
111
+ # pass the test object as the block's second argument. Rake passes an
112
+ # instance of +Rake::TaskArguments+, a Hash-like object which provides
113
+ # access to the command-line arguments for a Rake task e.g. { name:
114
+ # "world" } for `rake greet[world]`. since we're not relying on Rake's
115
+ # limited option-handling support, we have no use for that here, so we
116
+ # simply replace it with the test data.
117
+ wrapper = ->(task, _) { yield(task, test) }
118
+
119
+ # declare the prerequisites for the target file.
120
+ # compiler_path: recompile if the compiler has been
121
+ # updated since the target was last built
122
+ file(target => [source, compiler_path], &wrapper)
123
+
124
+ # add the target file to the build task
125
+ task :build => target
126
+
127
+ target
128
+ end
129
+
130
+ # register the prerequisites of the :build task. creates file tasks which:
131
+ #
132
+ # a) keep the build directory sources in sync with the source directory
133
+ # b) rebuild target files if their source files are modified
134
+ # c) rebuild target files if their compilers are updated
135
+ def register_tasks
136
+ copy_source_files
137
+ compile_target_files
138
+ end
139
+
140
+ # ensure each file in the source directory is mirrored to the build
141
+ # directory, and add each task which ensures this as a prerequisite
142
+ # of the master task (:build)
143
+ def copy_source_files
144
+ Dir["#{SRC_DIR}/*.*"].each do |path|
145
+ filename = File.basename(path)
146
+
147
+ source = file(filename => path) do
148
+ verbose(@verbosity == :verbose) { cp path, filename }
149
+ end
150
+
151
+ task build: source
152
+ end
153
+ end
154
+
155
+ # run a shell command (string) by substituting the compiler path, source
156
+ # file, and target file into the supplied template and executing the
157
+ # resulting command with the test's (optional) environment hash
158
+ def run(template, task, test)
159
+ replacements = {
160
+ compiler: Shellwords.escape(test[:compiler]),
161
+ source: Shellwords.escape(task.source),
162
+ target: Shellwords.escape(task.name),
163
+ }
164
+
165
+ command = template % replacements
166
+ shell(command, env: test[:env])
167
+ end
168
+
169
+ # make sure the target files (e.g. native executables and JVM .class files)
170
+ # are built if their compilers are installed
171
+ def compile_target_files
172
+ # handle the tests which have compile templates by turning them into
173
+ # blocks which substitute the compiler, source file and target file into
174
+ # the corresponding placeholders in the template and then execute the
175
+ # command via `shell`
176
+ selected_tests.each do |id, selected|
177
+ if (command = selected[:compile])
178
+ block = ->(task, test) { run(command, task, test) }
179
+ compile_if(id, &block)
180
+ end
181
+ end
182
+
183
+ # native-image compiles .class files to native binaries. it differs from
184
+ # the other tasks because it depends on a target file rather than a
185
+ # source file i.e. it depends on the target of the javac task
186
+ java_native = compile_if('java-native', connect: false) do |t, test|
187
+ # XXX native-image doesn't provide a way to silence its output, so
188
+ # send it to /dev/null
189
+ shell [test[:compiler], "-H:Name=#{t.name}", '--no-server', '-O1', t.source.ext], {
190
+ out: File::NULL
191
+ }
192
+ end
193
+
194
+ if java_native
195
+ javac = compile_if(:javac, force: true) do |task, test|
196
+ run('%{compiler} -d . %{source}', task, test)
197
+ end
198
+
199
+ if javac
200
+ task :build => java_native
201
+ end
202
+ else
203
+ compile_if :javac do |task, test|
204
+ run('%{compiler} -d . %{source}', task, test)
205
+ end
206
+ end
207
+
208
+ compile_if 'kotlinc-native' do |t, test|
209
+ # XXX kotlinc-native doesn't provide a way to silence
210
+ # its debug messages, so file them under /dev/null
211
+ shell %W[#{test[:compiler]} -opt -o #{t.name} #{t.source}], out: File::NULL
212
+
213
+ # XXX work around a kotlinc-native "feature"
214
+ # https://github.com/JetBrains/kotlin-native/issues/967
215
+ exe = "#{t.name}.kexe" # XXX or .exe, or...
216
+ verbose(@verbosity == :verbose) { mv exe, t.name } if File.exist?(exe)
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'env_paths'
4
+ require 'optparse'
5
+
6
+ module StartupTime
7
+ # StartupTime::Options - a struct-like interface to the app options set or
8
+ # overridden on the command line
9
+ class Options
10
+ BUILD_DIR = EnvPaths.get('startup-time', suffix: false).cache
11
+ ROUNDS = 10
12
+
13
+ attr_reader :action, :build_dir, :format, :rounds, :verbosity
14
+
15
+ include Services.mixin %i[registry]
16
+
17
+ def initialize(args)
18
+ @action = :benchmark
19
+ @build_dir = BUILD_DIR
20
+ @format = :default
21
+ @parser = nil
22
+ @rounds = ROUNDS
23
+ @verbosity = :default
24
+
25
+ parse! args
26
+ end
27
+
28
+ # the usage message (string) generated by the option parser for this tool
29
+ def usage
30
+ @parser.to_s
31
+ end
32
+
33
+ private
34
+
35
+ # process the command-line options and assign values to the corresponding
36
+ # instance variables
37
+ def parse!(args)
38
+ @parser = OptionParser.new do |opts|
39
+ opts.on(
40
+ '-c',
41
+ '--count',
42
+ '--rounds INTEGER',
43
+ Integer,
44
+ "The number of times to execute each test (default: #{ROUNDS})"
45
+ ) do |value|
46
+ @rounds = value
47
+ end
48
+
49
+ opts.on(
50
+ '--clean',
51
+ 'Remove the build directory and exit (targets will be ',
52
+ 'recompiled on the next run)'
53
+ ) do
54
+ @action = :clean
55
+ end
56
+
57
+ opts.on(
58
+ '-d',
59
+ '--dir PATH',
60
+ String,
61
+ "Specify the build directory (default: #{BUILD_DIR})"
62
+ ) do |value|
63
+ @build_dir = value
64
+ end
65
+
66
+ opts.on(
67
+ '-h',
68
+ '--help',
69
+ 'Show this help message and exit'
70
+ ) do
71
+ @action = :help
72
+ end
73
+
74
+ opts.on(
75
+ '-H',
76
+ '--help-only',
77
+ '--help-omit',
78
+ 'Show the IDs and groups that can be passed to --only and --omit'
79
+ ) do
80
+ @action = :show_ids
81
+ end
82
+
83
+ opts.on(
84
+ '-j',
85
+ '--json',
86
+ 'Output the results in JSON format (implies --quiet)'
87
+ ) do
88
+ @format = :json
89
+ @verbosity = :quiet
90
+ end
91
+
92
+ opts.on(
93
+ '-o',
94
+ '--only LIST',
95
+ Array, # comma-separated strings
96
+ 'Only execute the specified tests (comma-separated list of ',
97
+ 'IDs/groups)'
98
+ ) do |values|
99
+ values.each { |value| registry.only(value.strip) }
100
+ end
101
+
102
+ opts.on(
103
+ '-O',
104
+ '--omit LIST',
105
+ Array, # comma-separated strings
106
+ "Don't execute the specified tests (comma-separated list ",
107
+ 'of IDs/groups)'
108
+ ) do |values|
109
+ values.each { |value| registry.omit(value.strip) }
110
+ end
111
+
112
+ opts.on(
113
+ '-q',
114
+ '--quiet',
115
+ 'Suppress all inessential output'
116
+ ) do
117
+ @verbosity = :quiet
118
+ end
119
+
120
+ opts.on(
121
+ '-v',
122
+ '--verbose',
123
+ 'Enable verbose logging'
124
+ ) do
125
+ @verbosity = :verbose
126
+ end
127
+
128
+ opts.on(
129
+ '-V',
130
+ '--version',
131
+ 'Display the version and exit'
132
+ ) do
133
+ @action = :version
134
+ end
135
+ end
136
+
137
+ @parser.parse!(args)
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/hash/indifferent_access'
5
+ require 'active_support/core_ext/hash/slice' # XXX in core since 2.5
6
+ require 'set'
7
+ require 'shellwords'
8
+ require 'yaml'
9
+
10
+ module StartupTime
11
+ # StartupTime::Registry - an interface to the tests configured in
12
+ # resources/tests.yaml
13
+ class Registry
14
+ # the path to the test configuration file
15
+ TEST_DATA = File.join(__dir__, '../../resources/tests.yaml')
16
+
17
+ # map each group (e.g. "jvm") to an array of its member IDs
18
+ # (e.g. ["java", "kotlin", "scala"])
19
+ GROUPS = Hash.new { |h, k| h[k] = [] }.with_indifferent_access
20
+
21
+ # the top-level hash in tests.yaml. keys are test IDs (e.g. "ruby"); values
22
+ # are test specs
23
+ TESTS = YAML.load_file(TEST_DATA).with_indifferent_access
24
+
25
+ # perform some basic sanity checks on the test specs and populate the group
26
+ # -> tests map
27
+ TESTS.each do |id, test|
28
+ if test[:compile] && !test[:source]
29
+ abort "invalid test spec (#{id}): compiled tests must define a source file"
30
+ end
31
+
32
+ test[:groups].each do |group|
33
+ abort "invalid test spec (#{id}): group ID (#{group}) conflicts with test ID" if TESTS[group]
34
+ GROUPS[group] << id
35
+ end
36
+ end
37
+
38
+ # returns a hash which maps test-ID keys (e.g. "scala") to their
39
+ # corresponding group names (e.g. "compiled, jvm, slow")
40
+ def self.ids_to_groups
41
+ TESTS.entries.map { |id, test| [id, test[:groups].sort.join(', ')] }
42
+ end
43
+
44
+ def initialize
45
+ @omit = Set.new
46
+ @only = Set.new
47
+ end
48
+
49
+ # remove the specified test(s) from the set of enabled tests
50
+ def omit(id)
51
+ @omit.merge(ids_for(id))
52
+ end
53
+
54
+ # add the specified test(s) to the set of enabled tests
55
+ def only(id)
56
+ @only.merge(ids_for(id))
57
+ end
58
+
59
+ # the subset of candidate tests which satisfy the `only` and `omit` criteria
60
+ def selected_tests
61
+ only = !@only.empty? ? @only : TESTS.keys.to_set
62
+ test_ids = (only - @omit).to_a
63
+ TESTS.slice(*test_ids)
64
+ end
65
+
66
+ private
67
+
68
+ # takes a test ID or group ID and resolves it into its corresponding test IDs.
69
+ # if it's already a test ID, it's wrapped in an array; otherwise, an array
70
+ # of test IDs belonging to the group is returned.
71
+ def ids_for(id)
72
+ if TESTS[id]
73
+ ids = [id]
74
+ elsif GROUPS.key?(id)
75
+ ids = GROUPS[id]
76
+ else
77
+ abort "Can't resolve IDs for: #{id.inspect}"
78
+ end
79
+
80
+ ids
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wireless'
4
+
5
+ module StartupTime
6
+ # shared dependencies required by multiple components
7
+ Services = Wireless.new do
8
+ # the component responsible for managing the build directory
9
+ once(:builder) { Builder.new }
10
+
11
+ # a hash which maps test IDs (e.g. "scala") to group names
12
+ # (e.g. "compiled, jvm, slow")
13
+ once(:ids_to_groups) { Registry.ids_to_groups }
14
+
15
+ # an interface to the tests configured in resources/tests.yaml
16
+ once(:registry) { Registry.new }
17
+
18
+ # the tests which remain after the --only and --omit filters have been
19
+ # applied
20
+ once(:selected_tests) { |wl| wl[:registry].selected_tests }
21
+ end
22
+ end