startup-time 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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