vernier 0.2.1 → 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +24 -8
- data/bin/vernier +4 -0
- data/examples/minitest.rb +20 -0
- data/examples/ractor.rb +11 -0
- data/examples/rails.rb +125 -0
- data/exe/vernier +38 -0
- data/ext/vernier/vernier.cc +571 -302
- data/lib/vernier/autorun.rb +65 -0
- data/lib/vernier/collector.rb +47 -28
- data/lib/vernier/marker.rb +11 -11
- data/lib/vernier/output/firefox.rb +76 -45
- data/lib/vernier/result.rb +139 -0
- data/lib/vernier/version.rb +1 -1
- data/lib/vernier.rb +6 -133
- data/vernier.gemspec +1 -1
- metadata +12 -5
- data/sig/vernier.rbs +0 -4
@@ -0,0 +1,65 @@
|
|
1
|
+
require "tempfile"
|
2
|
+
require "vernier"
|
3
|
+
|
4
|
+
module Vernier
|
5
|
+
module Autorun
|
6
|
+
class << self
|
7
|
+
attr_accessor :collector
|
8
|
+
attr_reader :options
|
9
|
+
end
|
10
|
+
@collector = nil
|
11
|
+
|
12
|
+
@options = ENV.to_h.select { |k,v| k.start_with?("VERNIER_") }
|
13
|
+
@options.transform_keys! do |key|
|
14
|
+
key.sub(/\AVERNIER_/, "").downcase.to_sym
|
15
|
+
end
|
16
|
+
@options.freeze
|
17
|
+
|
18
|
+
def self.start
|
19
|
+
interval = options.fetch(:interval, 500).to_i
|
20
|
+
|
21
|
+
STDERR.puts("starting profiler with interval #{interval}")
|
22
|
+
|
23
|
+
@collector = Vernier::Collector.new(:wall, interval:)
|
24
|
+
@collector.start
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.stop
|
28
|
+
result = @collector.stop
|
29
|
+
@collector = nil
|
30
|
+
output_path = options[:output]
|
31
|
+
output_path ||= Tempfile.create(["profile", ".vernier.json"]).path
|
32
|
+
File.write(output_path, Vernier::Output::Firefox.new(result).output)
|
33
|
+
|
34
|
+
STDERR.puts(result.inspect)
|
35
|
+
STDERR.puts("written to #{output_path}")
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.running?
|
39
|
+
!!@collector
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.at_exit
|
43
|
+
stop if running?
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.toggle
|
47
|
+
running? ? stop : start
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
unless Vernier::Autorun.options[:start_paused]
|
53
|
+
Vernier::Autorun.start
|
54
|
+
end
|
55
|
+
|
56
|
+
if signal = Vernier::Autorun.options[:signal]
|
57
|
+
STDERR.puts "to toggle profiler: kill -#{signal} #{Process.pid}"
|
58
|
+
trap(signal) do
|
59
|
+
Vernier::Autorun.toggle
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
at_exit do
|
64
|
+
Vernier::Autorun.at_exit
|
65
|
+
end
|
data/lib/vernier/collector.rb
CHANGED
@@ -6,44 +6,63 @@ module Vernier
|
|
6
6
|
class Collector
|
7
7
|
def initialize(mode)
|
8
8
|
@mode = mode
|
9
|
+
@markers = []
|
9
10
|
end
|
10
11
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
12
|
+
##
|
13
|
+
# Get the current time.
|
14
|
+
#
|
15
|
+
# This method returns the current time from Process.clock_gettime in
|
16
|
+
# integer nanoseconds. It's the same time used by Vernier internals and
|
17
|
+
# can be used to generate timestamps for custom markers.
|
18
|
+
def current_time
|
19
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
20
|
+
end
|
18
21
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
22
|
+
def add_marker(name:, start:, finish:, thread: Thread.current.native_thread_id, phase: Marker::Phase::INTERVAL, data: nil)
|
23
|
+
@markers << [thread,
|
24
|
+
name,
|
25
|
+
start,
|
26
|
+
finish,
|
27
|
+
phase,
|
28
|
+
data]
|
29
|
+
end
|
23
30
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
31
|
+
##
|
32
|
+
# Record an interval with a category and name. Yields to a block and
|
33
|
+
# records the amount of time spent in the block as an interval marker.
|
34
|
+
def record_interval(category, name = category)
|
35
|
+
start = current_time
|
36
|
+
yield
|
37
|
+
add_marker(
|
38
|
+
name: category,
|
39
|
+
start:,
|
40
|
+
finish: current_time,
|
41
|
+
phase: Marker::Phase::INTERVAL,
|
42
|
+
thread: Thread.current.native_thread_id,
|
43
|
+
data: { :type => 'UserTiming', :entryType => 'measure', :name => name }
|
44
|
+
)
|
45
|
+
end
|
29
46
|
|
30
|
-
|
31
|
-
|
47
|
+
def stop
|
48
|
+
result = finish
|
32
49
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
break
|
37
|
-
end
|
50
|
+
end_time = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
51
|
+
result.pid = Process.pid
|
52
|
+
result.end_time = end_time
|
38
53
|
|
39
|
-
|
40
|
-
end
|
41
|
-
end
|
54
|
+
marker_strings = Marker.name_table
|
42
55
|
|
43
|
-
|
44
|
-
|
56
|
+
markers = self.markers.map do |(tid, type, phase, ts, te, stack)|
|
57
|
+
name = marker_strings[type]
|
58
|
+
sym = Marker::MARKER_SYMBOLS[type]
|
59
|
+
data = { type: sym }
|
60
|
+
data[:cause] = { stack: stack } if stack
|
61
|
+
[tid, name, ts, te, phase, data]
|
45
62
|
end
|
46
63
|
|
64
|
+
markers.concat @markers
|
65
|
+
|
47
66
|
result.instance_variable_set(:@markers, markers)
|
48
67
|
|
49
68
|
result
|
data/lib/vernier/marker.rb
CHANGED
@@ -4,27 +4,27 @@ require_relative "vernier" # Make sure constants are loaded
|
|
4
4
|
|
5
5
|
module Vernier
|
6
6
|
module Marker
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
INTERVAL = 1
|
11
|
-
INTERVAL_START = 2
|
12
|
-
INTERVAL_END = 3
|
7
|
+
MARKER_SYMBOLS = []
|
8
|
+
Type.constants.each do |name|
|
9
|
+
MARKER_SYMBOLS[Type.const_get(name)] = name
|
13
10
|
end
|
11
|
+
MARKER_SYMBOLS.freeze
|
14
12
|
|
15
13
|
MARKER_STRINGS = []
|
16
14
|
|
17
|
-
MARKER_STRINGS[Type::GVL_THREAD_STARTED] = "
|
18
|
-
MARKER_STRINGS[Type::
|
19
|
-
MARKER_STRINGS[Type::GVL_THREAD_RESUMED] = "thread resumed"
|
20
|
-
MARKER_STRINGS[Type::GVL_THREAD_SUSPENDED] = "thread suspended"
|
21
|
-
MARKER_STRINGS[Type::GVL_THREAD_EXITED] = "thread exited"
|
15
|
+
MARKER_STRINGS[Type::GVL_THREAD_STARTED] = "Thread started"
|
16
|
+
MARKER_STRINGS[Type::GVL_THREAD_EXITED] = "Thread exited"
|
22
17
|
|
23
18
|
MARKER_STRINGS[Type::GC_START] = "GC start"
|
24
19
|
MARKER_STRINGS[Type::GC_END_MARK] = "GC end marking"
|
25
20
|
MARKER_STRINGS[Type::GC_END_SWEEP] = "GC end sweeping"
|
26
21
|
MARKER_STRINGS[Type::GC_ENTER] = "GC enter"
|
27
22
|
MARKER_STRINGS[Type::GC_EXIT] = "GC exit"
|
23
|
+
MARKER_STRINGS[Type::GC_PAUSE] = "GC pause"
|
24
|
+
|
25
|
+
MARKER_STRINGS[Type::THREAD_RUNNING] = "Thread Running"
|
26
|
+
MARKER_STRINGS[Type::THREAD_STALLED] = "Thread Stalled"
|
27
|
+
MARKER_STRINGS[Type::THREAD_SUSPENDED] = "Thread Suspended"
|
28
28
|
|
29
29
|
MARKER_STRINGS.freeze
|
30
30
|
|
@@ -8,13 +8,15 @@ module Vernier
|
|
8
8
|
# https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.js
|
9
9
|
class Firefox
|
10
10
|
class Categorizer
|
11
|
-
attr_reader :categories
|
11
|
+
attr_reader :categories
|
12
12
|
def initialize
|
13
13
|
@categories = []
|
14
|
+
@categories_by_name = {}
|
14
15
|
|
15
16
|
add_category(name: "Default", color: "grey")
|
16
17
|
add_category(name: "Idle", color: "transparent")
|
17
|
-
|
18
|
+
|
19
|
+
add_category(name: "GC", color: "red")
|
18
20
|
add_category(
|
19
21
|
name: "stdlib",
|
20
22
|
color: "red",
|
@@ -39,18 +41,21 @@ module Vernier
|
|
39
41
|
name: "Application",
|
40
42
|
color: "purple"
|
41
43
|
)
|
42
|
-
|
43
|
-
|
44
|
-
color: "blue"
|
45
|
-
)
|
44
|
+
|
45
|
+
add_category(name: "Thread", color: "grey")
|
46
46
|
end
|
47
47
|
|
48
|
-
def add_category(**kw)
|
49
|
-
category = Category.new(@categories.length, **kw)
|
48
|
+
def add_category(name:, **kw)
|
49
|
+
category = Category.new(@categories.length, name: name, **kw)
|
50
50
|
@categories << category
|
51
|
+
@categories_by_name[name] = category
|
51
52
|
category
|
52
53
|
end
|
53
54
|
|
55
|
+
def get_category(name)
|
56
|
+
@categories_by_name[name]
|
57
|
+
end
|
58
|
+
|
54
59
|
def starts_with(*paths)
|
55
60
|
%r{\A#{Regexp.union(paths)}}
|
56
61
|
end
|
@@ -92,50 +97,30 @@ module Vernier
|
|
92
97
|
attr_reader :profile
|
93
98
|
|
94
99
|
def data
|
95
|
-
|
96
|
-
timestamps: [],
|
97
|
-
weights: [],
|
98
|
-
samples: [],
|
99
|
-
categories: [],
|
100
|
-
markers: [],
|
101
|
-
}}
|
102
|
-
|
103
|
-
profile.samples.size.times do |i|
|
104
|
-
tid = profile.sample_threads[i]
|
105
|
-
thread = threads[tid]
|
106
|
-
|
107
|
-
thread[:timestamps] << profile.timestamps[i]
|
108
|
-
thread[:weights] << profile.weights[i]
|
109
|
-
thread[:samples] << profile.samples[i]
|
110
|
-
thread[:categories] << profile.sample_categories[i]
|
111
|
-
end
|
112
|
-
|
113
|
-
profile.markers.each do |marker|
|
114
|
-
threads[marker[0]][:markers] << marker
|
115
|
-
end
|
100
|
+
markers_by_thread = profile.markers.group_by { |marker| marker[0] }
|
116
101
|
|
117
102
|
thread_data = profile.threads.map do |tid, thread_info|
|
118
|
-
|
103
|
+
markers = markers_by_thread[tid] || []
|
119
104
|
Thread.new(
|
120
105
|
profile,
|
121
106
|
@categorizer,
|
107
|
+
markers: markers,
|
122
108
|
**thread_info,
|
123
|
-
**data
|
124
109
|
).data
|
125
110
|
end
|
126
111
|
|
127
112
|
{
|
128
113
|
meta: {
|
129
114
|
interval: 1, # FIXME: memory vs wall
|
130
|
-
startTime: profile.
|
131
|
-
endTime: (profile.timestamps&.max || 0) / 1_000_000.0,
|
115
|
+
startTime: profile.started_at / 1_000_000.0,
|
116
|
+
#endTime: (profile.timestamps&.max || 0) / 1_000_000.0,
|
132
117
|
processType: 0,
|
133
118
|
product: "Ruby/Vernier",
|
134
119
|
stackwalk: 1,
|
135
120
|
version: 28,
|
136
121
|
preprocessedProfileVersion: 47,
|
137
122
|
symbolicated: true,
|
138
|
-
markerSchema:
|
123
|
+
markerSchema: marker_schema,
|
139
124
|
sampleUnits: {
|
140
125
|
time: "ms",
|
141
126
|
eventDelay: "ms",
|
@@ -154,17 +139,33 @@ module Vernier
|
|
154
139
|
}
|
155
140
|
end
|
156
141
|
|
142
|
+
def marker_schema
|
143
|
+
[
|
144
|
+
{
|
145
|
+
name: "GVL_THREAD_RESUMED",
|
146
|
+
display: [ "marker-chart", "marker-table" ],
|
147
|
+
data: [
|
148
|
+
{
|
149
|
+
label: "Description",
|
150
|
+
value: "The thread has acquired the GVL and is executing"
|
151
|
+
}
|
152
|
+
]
|
153
|
+
}
|
154
|
+
]
|
155
|
+
end
|
156
|
+
|
157
157
|
class Thread
|
158
158
|
attr_reader :profile
|
159
159
|
|
160
|
-
def initialize(profile, categorizer, name:, tid:, samples:, weights:, timestamps
|
160
|
+
def initialize(profile, categorizer, name:, tid:, samples:, weights:, timestamps: nil, sample_categories: nil, markers:, started_at:, stopped_at: nil)
|
161
161
|
@profile = profile
|
162
162
|
@categorizer = categorizer
|
163
163
|
@tid = tid
|
164
164
|
@name = name
|
165
165
|
|
166
|
+
timestamps ||= [0] * samples.size
|
166
167
|
@samples, @weights, @timestamps = samples, weights, timestamps
|
167
|
-
@sample_categories =
|
168
|
+
@sample_categories = sample_categories || ([0] * samples.size)
|
168
169
|
@markers = markers
|
169
170
|
|
170
171
|
@started_at, @stopped_at = started_at, stopped_at
|
@@ -184,6 +185,25 @@ module Vernier
|
|
184
185
|
@filenames = filenames.map do |filename|
|
185
186
|
@strings[filename]
|
186
187
|
end
|
188
|
+
|
189
|
+
lines = profile.frame_table.fetch(:line)
|
190
|
+
|
191
|
+
@frame_implementations = filenames.zip(lines).map do |filename, line|
|
192
|
+
# Must match strings in `src/profile-logic/profile-data.js`
|
193
|
+
# inside the firefox profiler. See `getFriendlyStackTypeName`
|
194
|
+
if filename == "<cfunc>"
|
195
|
+
@strings["native"]
|
196
|
+
else
|
197
|
+
# FIXME: We need to get upstream support for JIT frames
|
198
|
+
if line == -1
|
199
|
+
@strings["yjit"]
|
200
|
+
else
|
201
|
+
# nil means interpreter
|
202
|
+
nil
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
187
207
|
@frame_categories = filenames.map do |filename|
|
188
208
|
@categorizer.categorize(filename)
|
189
209
|
end
|
@@ -192,7 +212,7 @@ module Vernier
|
|
192
212
|
def data
|
193
213
|
{
|
194
214
|
name: @name,
|
195
|
-
isMainThread: @tid == ::Thread.main.native_thread_id,
|
215
|
+
isMainThread: (@tid == ::Thread.main.native_thread_id) || (profile.threads.size == 1),
|
196
216
|
processStartupTime: 0, # FIXME
|
197
217
|
processShutdownTime: nil, # FIXME
|
198
218
|
registerTime: (@started_at - 0) / 1_000_000.0,
|
@@ -218,26 +238,34 @@ module Vernier
|
|
218
238
|
end
|
219
239
|
|
220
240
|
def markers_table
|
221
|
-
size = @markers.size
|
222
|
-
|
223
241
|
string_indexes = []
|
224
242
|
start_times = []
|
225
243
|
end_times = []
|
226
244
|
phases = []
|
227
245
|
categories = []
|
246
|
+
data = []
|
228
247
|
|
229
|
-
@markers.each_with_index do |(_, name, start, finish, phase), i|
|
248
|
+
@markers.each_with_index do |(_, name, start, finish, phase, datum), i|
|
230
249
|
string_indexes << @strings[name]
|
231
250
|
start_times << (start / 1_000_000.0)
|
232
251
|
|
233
252
|
# Please don't hate me. Divide by 1,000,000 only if finish is not nil
|
234
253
|
end_times << (finish&./(1_000_000.0))
|
235
254
|
phases << phase
|
236
|
-
|
255
|
+
|
256
|
+
category = case name
|
257
|
+
when /\AGC/ then gc_category.idx
|
258
|
+
when /\AThread/ then thread_category.idx
|
259
|
+
else
|
260
|
+
0
|
261
|
+
end
|
262
|
+
|
263
|
+
categories << category
|
264
|
+
data << datum
|
237
265
|
end
|
238
266
|
|
239
267
|
{
|
240
|
-
data:
|
268
|
+
data: data,
|
241
269
|
name: string_indexes,
|
242
270
|
startTime: start_times,
|
243
271
|
endTime: end_times,
|
@@ -263,7 +291,6 @@ module Vernier
|
|
263
291
|
times = (0...size).to_a
|
264
292
|
end
|
265
293
|
|
266
|
-
raise unless samples.size == size
|
267
294
|
raise unless weights.size == size
|
268
295
|
raise unless times.size == size
|
269
296
|
|
@@ -325,7 +352,7 @@ module Vernier
|
|
325
352
|
func: funcs,
|
326
353
|
nativeSymbol: none,
|
327
354
|
innerWindowID: none,
|
328
|
-
implementation:
|
355
|
+
implementation: @frame_implementations,
|
329
356
|
line: lines,
|
330
357
|
column: none,
|
331
358
|
length: size
|
@@ -357,7 +384,11 @@ module Vernier
|
|
357
384
|
private
|
358
385
|
|
359
386
|
def gc_category
|
360
|
-
@categorizer.
|
387
|
+
@categorizer.get_category("GC")
|
388
|
+
end
|
389
|
+
|
390
|
+
def thread_category
|
391
|
+
@categorizer.get_category("Thread")
|
361
392
|
end
|
362
393
|
end
|
363
394
|
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
module Vernier
|
2
|
+
class Result
|
3
|
+
attr_reader :stack_table, :frame_table, :func_table
|
4
|
+
attr_reader :markers
|
5
|
+
|
6
|
+
attr_accessor :pid, :end_time
|
7
|
+
attr_accessor :threads
|
8
|
+
attr_accessor :meta
|
9
|
+
|
10
|
+
# TODO: remove these
|
11
|
+
def weights; threads.values.flat_map { _1[:weights] }; end
|
12
|
+
def samples; threads.values.flat_map { _1[:samples] }; end
|
13
|
+
def sample_categories; threads.values.flat_map { _1[:sample_categories] }; end
|
14
|
+
|
15
|
+
# Realtime in nanoseconds since the unix epoch
|
16
|
+
def started_at
|
17
|
+
started_at_mono_ns = meta[:started_at]
|
18
|
+
current_time_mono_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
19
|
+
current_time_real_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
20
|
+
(current_time_real_ns - current_time_mono_ns + started_at_mono_ns)
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_gecko
|
24
|
+
Output::Firefox.new(self).output
|
25
|
+
end
|
26
|
+
|
27
|
+
def write(out:)
|
28
|
+
File.write(out, to_gecko)
|
29
|
+
end
|
30
|
+
|
31
|
+
def elapsed_seconds
|
32
|
+
(end_time - started_at) / 1_000_000_000.0
|
33
|
+
end
|
34
|
+
|
35
|
+
def inspect
|
36
|
+
"#<#{self.class} #{elapsed_seconds} seconds, #{threads.count} threads, #{weights.sum} samples, #{samples.uniq.size} unique>"
|
37
|
+
end
|
38
|
+
|
39
|
+
def each_sample
|
40
|
+
return enum_for(__method__) unless block_given?
|
41
|
+
samples.size.times do |sample_idx|
|
42
|
+
weight = weights[sample_idx]
|
43
|
+
stack_idx = samples[sample_idx]
|
44
|
+
yield stack(stack_idx), weight
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
class BaseType
|
49
|
+
attr_reader :result, :idx
|
50
|
+
def initialize(result, idx)
|
51
|
+
@result = result
|
52
|
+
@idx = idx
|
53
|
+
end
|
54
|
+
|
55
|
+
def to_s
|
56
|
+
idx.to_s
|
57
|
+
end
|
58
|
+
|
59
|
+
def inspect
|
60
|
+
"#<#{self.class}\n#{to_s}>"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class Func < BaseType
|
65
|
+
def label
|
66
|
+
result.func_table[:name][idx]
|
67
|
+
end
|
68
|
+
alias name label
|
69
|
+
|
70
|
+
def filename
|
71
|
+
result.func_table[:filename][idx]
|
72
|
+
end
|
73
|
+
|
74
|
+
def to_s
|
75
|
+
"#{name} at #{filename}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
class Frame < BaseType
|
80
|
+
def label; func.label; end
|
81
|
+
def filename; func.filename; end
|
82
|
+
alias name label
|
83
|
+
|
84
|
+
def func
|
85
|
+
func_idx = result.frame_table[:func][idx]
|
86
|
+
Func.new(result, func_idx)
|
87
|
+
end
|
88
|
+
|
89
|
+
def line
|
90
|
+
result.frame_table[:line][idx]
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_s
|
94
|
+
"#{func}:#{line}"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
class Stack < BaseType
|
99
|
+
def each_frame
|
100
|
+
return enum_for(__method__) unless block_given?
|
101
|
+
|
102
|
+
stack_idx = idx
|
103
|
+
while stack_idx
|
104
|
+
frame_idx = result.stack_table[:frame][stack_idx]
|
105
|
+
yield Frame.new(result, frame_idx)
|
106
|
+
stack_idx = result.stack_table[:parent][stack_idx]
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def leaf_frame_idx
|
111
|
+
result.stack_table[:frame][idx]
|
112
|
+
end
|
113
|
+
|
114
|
+
def leaf_frame
|
115
|
+
Frame.new(result, leaf_frame_idx)
|
116
|
+
end
|
117
|
+
|
118
|
+
def frames
|
119
|
+
each_frame.to_a
|
120
|
+
end
|
121
|
+
|
122
|
+
def to_s
|
123
|
+
arr = []
|
124
|
+
each_frame do |frame|
|
125
|
+
arr << frame.to_s
|
126
|
+
end
|
127
|
+
arr.join("\n")
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def stack(idx)
|
132
|
+
Stack.new(self, idx)
|
133
|
+
end
|
134
|
+
|
135
|
+
def total_bytes
|
136
|
+
weights.sum
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
data/lib/vernier/version.rb
CHANGED