hayaoki 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/exe/hayaoki +7 -0
- data/lib/hayaoki/boot_report/advisor.rb +113 -0
- data/lib/hayaoki/boot_report/aggregator.rb +367 -0
- data/lib/hayaoki/boot_report/formatters/json.rb +52 -0
- data/lib/hayaoki/boot_report/formatters/redact.rb +32 -0
- data/lib/hayaoki/boot_report/formatters/speedscope.rb +118 -0
- data/lib/hayaoki/boot_report/formatters/text.rb +275 -0
- data/lib/hayaoki/boot_report/gemfile.rb +146 -0
- data/lib/hayaoki/boot_report/report.rb +22 -0
- data/lib/hayaoki/boot_report/rules/base.rb +113 -0
- data/lib/hayaoki/boot_report/rules/bootsnap_ineffective.rb +88 -0
- data/lib/hayaoki/boot_report/rules/controller_action_methods.rb +33 -0
- data/lib/hayaoki/boot_report/rules/dev_gems_in_production.rb +80 -0
- data/lib/hayaoki/boot_report/rules/devise_reload_routes.rb +43 -0
- data/lib/hayaoki/boot_report/rules/eager_load_tradeoff.rb +32 -0
- data/lib/hayaoki/boot_report/rules/heavy_app_initializer.rb +59 -0
- data/lib/hayaoki/boot_report/rules/tzinfo_eager_load.rb +33 -0
- data/lib/hayaoki/boot_report/rules/unused_heavy_gem.rb +149 -0
- data/lib/hayaoki/boot_report/runner.rb +472 -0
- data/lib/hayaoki/cli.rb +210 -0
- data/lib/hayaoki/probe.rb +513 -0
- data/lib/hayaoki/version.rb +5 -0
- data/lib/hayaoki.rb +28 -0
- metadata +70 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../version"
|
|
5
|
+
require_relative "../advisor"
|
|
6
|
+
|
|
7
|
+
module Hayaoki
|
|
8
|
+
module BootReport
|
|
9
|
+
module Formatters
|
|
10
|
+
class Json
|
|
11
|
+
SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
def initialize(pretty: true)
|
|
14
|
+
@pretty = pretty
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_accessor :suggestions, :suggestion_warnings
|
|
18
|
+
|
|
19
|
+
def with_suggestions(list, warnings = [])
|
|
20
|
+
@suggestions = list
|
|
21
|
+
@suggestion_warnings = warnings
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# reports: { "warm" => Report, ... } または Report 単体
|
|
26
|
+
def format(reports)
|
|
27
|
+
reports = { reports.condition.to_s => reports } unless reports.is_a?(Hash)
|
|
28
|
+
reports = reports.transform_values { |r| scrub(r.to_h) }
|
|
29
|
+
reports = reports.transform_values { |r| scrub(r.to_h) }
|
|
30
|
+
doc = { "schema_version" => SCHEMA_VERSION, "hayaoki_version" => Hayaoki::VERSION,
|
|
31
|
+
"reports" => reports }
|
|
32
|
+
if @suggestions
|
|
33
|
+
doc["suggestions"] = @suggestions.map { |s| s.respond_to?(:to_h) ? s.to_h : s }
|
|
34
|
+
# 非加算の上限 (findings は重複しうる)
|
|
35
|
+
doc["savings_upper_bound_ms"] = Suggestion.savings_upper_bound(@suggestions)
|
|
36
|
+
doc["suggestion_warnings"] = Array(@suggestion_warnings)
|
|
37
|
+
end
|
|
38
|
+
(@pretty ? ::JSON.pretty_generate(doc) : ::JSON.generate(doc)) + "\n"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# 失敗 run の stderr_tail は秘密を含みうるので出力しない (message / exit_status / error_class は残す)
|
|
44
|
+
def scrub(h)
|
|
45
|
+
meta = h["meta"]
|
|
46
|
+
return h unless meta.is_a?(Hash) && meta["failed_runs"].is_a?(Array)
|
|
47
|
+
h.merge("meta" => meta.merge("failed_runs" => meta["failed_runs"].map { |f| f.reject { |k, _| k == "stderr_tail" } }))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hayaoki
|
|
4
|
+
module BootReport
|
|
5
|
+
module Formatters
|
|
6
|
+
# 出力 (JSON / speedscope の文字列) から個人を特定できる絶対パスを消す。
|
|
7
|
+
# gem のパスは <gem:name>/、アプリのルートは <app>、ホームは ~ に置き換える。
|
|
8
|
+
class Redact
|
|
9
|
+
GEM_PATH = %r{(?:/[^\s"/\\]+)+/gems/([^\s"/\\]+?)-(?:\d|[0-9a-f]{7,})[^\s"/\\]*/}
|
|
10
|
+
|
|
11
|
+
def initialize(app_dir: nil, home: Dir.home)
|
|
12
|
+
@apps = []
|
|
13
|
+
if app_dir
|
|
14
|
+
exp = File.expand_path(app_dir)
|
|
15
|
+
real = (File.realpath(exp) rescue exp) # macOS: /var と /private/var
|
|
16
|
+
@apps = [exp, real].uniq.sort_by { |a| -a.size }
|
|
17
|
+
end
|
|
18
|
+
@home = home
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def apply(text)
|
|
22
|
+
out = text.gsub(GEM_PATH) { "<gem:#{Regexp.last_match(1)}>/" }
|
|
23
|
+
@apps.each do |a|
|
|
24
|
+
out = out.gsub(/#{Regexp.escape(a)}(?=[\/"\s\\]|\z)/, "<app>") unless a == "/"
|
|
25
|
+
end
|
|
26
|
+
out = out.gsub(/#{Regexp.escape(@home)}(?=[\/"\s\\]|\z)/, "~") if @home && @home != "/"
|
|
27
|
+
out
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../version"
|
|
5
|
+
|
|
6
|
+
module Hayaoki
|
|
7
|
+
module BootReport
|
|
8
|
+
module Formatters
|
|
9
|
+
# payload (probe の出力) 1 回分を speedscope の evented 形式にする。メインスレッド (tid 0) のみ。
|
|
10
|
+
class Speedscope
|
|
11
|
+
SCHEMA = "https://www.speedscope.app/file-format-schema.json"
|
|
12
|
+
KINDS = %w[require require_relative load initializer on_load hooks eager_load phase].freeze
|
|
13
|
+
|
|
14
|
+
# payloads: 配列。boot の total に一番近い (=中央値に近い) run を選ぶ。
|
|
15
|
+
def self.pick_median(payloads)
|
|
16
|
+
ok = payloads.select { |p| p["status"] == "ok" }
|
|
17
|
+
ok = payloads if ok.empty?
|
|
18
|
+
sorted = ok.sort_by { |p| p["boot_end_us"] || p["exit_us"] || 0 }
|
|
19
|
+
sorted[sorted.size / 2]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(name: "hayaoki boot")
|
|
23
|
+
@name = name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# RunResult の payloads (Hash) / 配列 / 単一 payload を受け付ける。
|
|
27
|
+
def format(input, condition: nil)
|
|
28
|
+
picks = pick_all(input, condition)
|
|
29
|
+
docs = picks.map { |name, payload| build(payload, name: name) }
|
|
30
|
+
doc = docs.first
|
|
31
|
+
if docs.size > 1
|
|
32
|
+
# 条件ごとに profile を持つ 1 ファイルにまとめる (frame は条件ごとの添字がずれるので付け替える)
|
|
33
|
+
frames = []
|
|
34
|
+
profiles = docs.each_with_index.map do |d, di|
|
|
35
|
+
map = d["shared"]["frames"].map { |f| frames << f; frames.size - 1 }
|
|
36
|
+
prof = d["profiles"].first.dup
|
|
37
|
+
prof["events"] = prof["events"].map { |e| e.merge("frame" => map[e["frame"]]) }
|
|
38
|
+
prof
|
|
39
|
+
end
|
|
40
|
+
doc = doc.merge("shared" => { "frames" => frames }, "profiles" => profiles)
|
|
41
|
+
end
|
|
42
|
+
::JSON.generate(doc) + "\n"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build(payload, name: @name)
|
|
46
|
+
w = payload["width"] || 8
|
|
47
|
+
ev = payload["events"] || []
|
|
48
|
+
exit_us = payload["exit_us"] || ev.each_slice(w).filter_map { |e| e[4] }.max || 0
|
|
49
|
+
spans = ev.each_slice(w).with_index.map do |(kind, n, par, t0, t1, _res, tid), i|
|
|
50
|
+
{ idx: i * w, kind: kind, name: n, parent: par, t0: t0, t1: t1 || [exit_us, t0].max, tid: tid.to_i, kids: [] }
|
|
51
|
+
end
|
|
52
|
+
main = spans.select { |s| s[:tid].zero? }
|
|
53
|
+
by = main.to_h { |s| [s[:idx], s] }
|
|
54
|
+
roots = []
|
|
55
|
+
main.each { |s| (by[s[:parent]] ? by[s[:parent]][:kids] : roots) << s }
|
|
56
|
+
origin = main.map { |s| s[:t0] }.min || 0
|
|
57
|
+
frames = []
|
|
58
|
+
fidx = {}
|
|
59
|
+
events = []
|
|
60
|
+
emit = lambda do |s, lo, hi|
|
|
61
|
+
a = [[s[:t0], lo].max, hi].min
|
|
62
|
+
b = [[s[:t1], a].max, hi].min
|
|
63
|
+
key = [s[:kind], s[:name]]
|
|
64
|
+
f = (fidx[key] ||= (frames << { "name" => "#{KINDS[s[:kind]] || s[:kind]}: #{s[:name]}" }; frames.size - 1))
|
|
65
|
+
events << { "type" => "O", "frame" => f, "at" => a - origin }
|
|
66
|
+
cursor = a
|
|
67
|
+
s[:kids].sort_by { |k| k[:t0] }.each do |k|
|
|
68
|
+
next if k[:t0] < cursor && k[:t1] <= cursor
|
|
69
|
+
emit.call(k, cursor, b)
|
|
70
|
+
cursor = [cursor, [k[:t1], b].min].max
|
|
71
|
+
end
|
|
72
|
+
events << { "type" => "C", "frame" => f, "at" => b - origin }
|
|
73
|
+
end
|
|
74
|
+
cursor = origin
|
|
75
|
+
roots.sort_by { |s| s[:t0] }.each do |s|
|
|
76
|
+
next if s[:t1] <= cursor && s[:t0] < cursor
|
|
77
|
+
emit.call(s, cursor, [s[:t1], cursor].max)
|
|
78
|
+
cursor = [cursor, s[:t1]].max
|
|
79
|
+
end
|
|
80
|
+
end_value = [events.map { |e| e["at"] }.max || 0, 0].max
|
|
81
|
+
{
|
|
82
|
+
"$schema" => SCHEMA,
|
|
83
|
+
"shared" => { "frames" => frames },
|
|
84
|
+
"profiles" => [{ "type" => "evented", "name" => name, "unit" => "microseconds",
|
|
85
|
+
"startValue" => 0, "endValue" => end_value, "events" => events }],
|
|
86
|
+
"name" => @name, "activeProfileIndex" => 0, "exporter" => "hayaoki #{Hayaoki::VERSION}"
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# [[profile 名, payload], ...]。condition 指定が無く複数条件 (bootsnap: both) なら条件ごとに出す。
|
|
93
|
+
def pick_all(input, condition)
|
|
94
|
+
if !condition && !input.is_a?(Array) && !(input.is_a?(Hash) && input.key?("events"))
|
|
95
|
+
map = input.is_a?(Hash) ? input : input.payloads
|
|
96
|
+
if map.size > 1
|
|
97
|
+
return map.map { |c, list| ["#{@name} [#{c}]", self.class.pick_median(list)] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
[[@name, pick(input, condition)]]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def pick(input, condition)
|
|
104
|
+
case input
|
|
105
|
+
when Hash
|
|
106
|
+
if input.key?("events") then input
|
|
107
|
+
else self.class.pick_median(condition ? input[condition.to_s] : input.values.first)
|
|
108
|
+
end
|
|
109
|
+
when Array then self.class.pick_median(input)
|
|
110
|
+
else
|
|
111
|
+
list = condition ? input.payloads[condition.to_s] : input.payloads.values.first
|
|
112
|
+
self.class.pick_median(list)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../advisor"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Formatters
|
|
8
|
+
# 端末向けテキスト。reports は { "warm" => Report, "cold" => Report } (aggregate_result の戻り値)。
|
|
9
|
+
class Text
|
|
10
|
+
WIDTH = 80
|
|
11
|
+
BAR = "█"
|
|
12
|
+
BAR_WIDTH = 28
|
|
13
|
+
PHASE_LABELS = { "vm_boot" => "vm boot", "bundler_require" => "bundler require", "initialize" => "initialize",
|
|
14
|
+
"eager_load" => "eager load", "boot" => "boot" }.freeze
|
|
15
|
+
|
|
16
|
+
# redact: 文字列を受け取って伏せた文字列を返すもの (--redact)。切り詰める前に適用しないとパスが一致しない。
|
|
17
|
+
def initialize(top: 10, redact: nil)
|
|
18
|
+
@top = top
|
|
19
|
+
@redact = redact
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Advisor の結果 (Suggestion の配列 / 警告の配列)。nil なら Suggestions の節を出さない。
|
|
23
|
+
attr_accessor :suggestions, :suggestion_warnings
|
|
24
|
+
|
|
25
|
+
def with_suggestions(list, warnings = [])
|
|
26
|
+
@suggestions = list
|
|
27
|
+
@suggestion_warnings = warnings
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def format(reports)
|
|
32
|
+
reports = { reports.condition.to_s => reports } unless reports.is_a?(Hash)
|
|
33
|
+
@conds = reports.keys
|
|
34
|
+
@reports = reports
|
|
35
|
+
out = []
|
|
36
|
+
header(out)
|
|
37
|
+
phases(out)
|
|
38
|
+
categories(out)
|
|
39
|
+
ranked(out, "Top gems (self time)", :gems, ->(r) { r.gems }, ->(e) { e["name"] }, "self_ms", breakdown: { "req" => "require_ms", "onload" => "on_load_ms", "eager" => "eager_ms" })
|
|
40
|
+
ranked(out, "Top initializers (inclusive)", :inits, ->(r) { r.initializers }, ->(e) { [e["name"], e["context"]].compact.join(" ") }, "inclusive_ms",
|
|
41
|
+
breakdown: { "init body (own time, not blamed on gem)" => "init_body_ms" })
|
|
42
|
+
ranked(out, "Top on_load hooks (inclusive)", :on_loads, ->(r) { r.on_loads }, ->(e) { [e["name"], e["source"]].compact.join(" ") }, "inclusive_ms")
|
|
43
|
+
if @reports.values.any? { |r| Array(r.eager_namespaces).any? }
|
|
44
|
+
ranked(out, "Top eager_load namespaces (inclusive)", :eager_namespaces, ->(r) { r.eager_namespaces }, ->(e) { e["name"] }, "inclusive_ms")
|
|
45
|
+
end
|
|
46
|
+
false_requires(out)
|
|
47
|
+
overhead(out)
|
|
48
|
+
warnings(out)
|
|
49
|
+
suggestions_section(out)
|
|
50
|
+
out.map { |l| clip(l) }.join("\n") + "\n"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def header(out)
|
|
56
|
+
m = (@reports.values.first.meta || {})
|
|
57
|
+
out << "hayaoki boot-report"
|
|
58
|
+
out << " app: #{m["app_dir"]}" if m["app_dir"]
|
|
59
|
+
out << " runs=#{m["runs"] || @reports.values.first.runs} env=#{m["env"]} bootsnap=#{m["bootsnap"] || @conds.join(",")}"
|
|
60
|
+
@reports.each do |c, r|
|
|
61
|
+
failed = r.failed.to_i.positive? ? " (#{r.failed} failed)" : ""
|
|
62
|
+
out << " total[#{c}]: #{stat_s(r.phases["total"])}#{failed}"
|
|
63
|
+
end
|
|
64
|
+
out << " diff (#{@conds.last} - #{@conds.first}): #{diff_s(med(first_total), med(last_total))}" if @conds.size > 1
|
|
65
|
+
out << ""
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def first_total = @reports[@conds.first].phases["total"]
|
|
69
|
+
def last_total = @reports[@conds.last].phases["total"]
|
|
70
|
+
|
|
71
|
+
def phases(out)
|
|
72
|
+
out << "Phases (median)"
|
|
73
|
+
keys = PHASE_LABELS.keys.select { |k| @reports.values.any? { |r| r.phases[k] } }
|
|
74
|
+
max = keys.flat_map { |k| @reports.values.map { |r| med(r.phases[k]) } }.compact.max.to_f
|
|
75
|
+
keys.each do |k|
|
|
76
|
+
@reports.each do |c, r|
|
|
77
|
+
s = r.phases[k]
|
|
78
|
+
next unless s
|
|
79
|
+
label = @conds.size > 1 ? "#{PHASE_LABELS[k]} [#{c}]" : PHASE_LABELS[k]
|
|
80
|
+
label = " └ #{label}" if k == "eager_load" # initialize の内側 (足し算しない)
|
|
81
|
+
n = max.positive? ? (med(s) / max * BAR_WIDTH).round : 0
|
|
82
|
+
n = 1 if n.zero? && med(s).to_f.positive?
|
|
83
|
+
out << Kernel.format(" %-22s %-#{BAR_WIDTH}s %s", label, BAR * n, stat_s(s, range: @conds.size == 1))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
out << " (└ eager load runs within initialize; do not add them)" if keys.include?("eager_load")
|
|
87
|
+
out << ""
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def categories(out)
|
|
91
|
+
return if @reports.values.all? { |r| (r.categories || {}).empty? }
|
|
92
|
+
out << "Self time by category (median; boot - attributed = unattributed)"
|
|
93
|
+
@reports.each do |c, r|
|
|
94
|
+
cats = r.categories || {}
|
|
95
|
+
next if cats.empty?
|
|
96
|
+
tag = @conds.size > 1 ? " [#{c}]" : ""
|
|
97
|
+
cats.each { |k, v| out << Kernel.format(" %-22s %10s", "#{k}#{tag}", "#{fmt(med(v["self_ms"]))}") }
|
|
98
|
+
end
|
|
99
|
+
out << ""
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ranked(out, title, _key, getter, namer, metric, breakdown: nil)
|
|
103
|
+
out << "#{title} (top #{@top})"
|
|
104
|
+
lists = @reports.transform_values { |r| Array(getter.call(r)) }
|
|
105
|
+
names = lists.values.first.first(@top).map(&namer)
|
|
106
|
+
if @conds.size > 1
|
|
107
|
+
names |= lists.values.last.first(@top).map(&namer)
|
|
108
|
+
names = names.first(@top * 2)
|
|
109
|
+
end
|
|
110
|
+
if names.empty?
|
|
111
|
+
out << " (none)"
|
|
112
|
+
out << ""
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
idx = lists.transform_values { |l| l.to_h { |e| [namer.call(e), e] } }
|
|
116
|
+
nw = [WIDTH - 2 - 12 * @conds.size - (@conds.size > 1 ? 10 : 22), 20].max
|
|
117
|
+
names.each do |n|
|
|
118
|
+
vals = @conds.map { |c| idx[c][n] && idx[c][n][metric] }
|
|
119
|
+
line = Kernel.format(" %-#{nw}s", trunc(n.to_s, nw))
|
|
120
|
+
vals.each { |v| line += Kernel.format(" %10s", v ? "#{fmt(med(v))}" : "-") }
|
|
121
|
+
if @conds.size > 1
|
|
122
|
+
line += Kernel.format(" %9s", vals.all? ? signed(med(vals.last) - med(vals.first)) : "-")
|
|
123
|
+
else
|
|
124
|
+
v = vals.first
|
|
125
|
+
line += Kernel.format(" %-20s", v ? "[#{fmt(v["min"])}-#{fmt(v["max"])}]" : "")
|
|
126
|
+
e = idx[@conds.first][n]
|
|
127
|
+
if breakdown && e
|
|
128
|
+
out << line.rstrip
|
|
129
|
+
parts = breakdown.filter_map do |l, kk|
|
|
130
|
+
x = e[kk] && med(e[kk])
|
|
131
|
+
"#{l} #{fmt(x)}" if x && x >= 0.05
|
|
132
|
+
end
|
|
133
|
+
next if parts.empty?
|
|
134
|
+
cat = e["category"] ? "#{e["category"]}: " : ""
|
|
135
|
+
out << " #{cat}#{parts.join(", ")}".rstrip
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
out << line.rstrip
|
|
140
|
+
end
|
|
141
|
+
out << " (ms, median)" if @conds.size > 1
|
|
142
|
+
out << ""
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def false_requires(out)
|
|
146
|
+
out << "require that returned false"
|
|
147
|
+
@reports.each do |c, r|
|
|
148
|
+
f = r.false_require || {}
|
|
149
|
+
tag = @conds.size > 1 ? " [#{c}]" : ""
|
|
150
|
+
out << " count#{tag}: #{f["count"] ? fmt(med(f["count"]), 0) : "-"}, time: #{stat_s(f["time_ms"])}"
|
|
151
|
+
end
|
|
152
|
+
out << ""
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def overhead(out)
|
|
156
|
+
out << "Measurement overhead"
|
|
157
|
+
ov = (@reports.values.first.meta || {})["overhead"] || {}
|
|
158
|
+
@conds.each do |c|
|
|
159
|
+
o = ov[c]
|
|
160
|
+
next out << " #{c}: n/a" unless o
|
|
161
|
+
ratio = o["ratio"] ? Kernel.format(", x%.3f", o["ratio"]) : ""
|
|
162
|
+
noise = overhead_noise(o)
|
|
163
|
+
diff = if noise && o["diff_ms"].abs < noise
|
|
164
|
+
"diff within noise (±#{fmt(noise)} ms) (#{signed(o["diff_ms"])} ms#{ratio})"
|
|
165
|
+
else
|
|
166
|
+
"diff #{signed(o["diff_ms"])} ms#{ratio.empty? ? "" : " (#{ratio[2..]})"}"
|
|
167
|
+
end
|
|
168
|
+
out << " #{c}: baseline #{fmt(o["baseline_ms"])} ms, with probe #{fmt(o["probe_ms"])} ms"
|
|
169
|
+
out << " #{diff}"
|
|
170
|
+
end
|
|
171
|
+
out << ""
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# 基準側と probe 側の run ごとのばらつき (min〜max の半幅) の大きい方。サンプルが 2 件未満なら nil。
|
|
175
|
+
def overhead_noise(o)
|
|
176
|
+
halves = %w[baseline_samples_ms probe_samples_ms].filter_map do |k|
|
|
177
|
+
v = Array(o[k]).compact
|
|
178
|
+
(v.max - v.min) / 2.0 if v.size >= 2
|
|
179
|
+
end
|
|
180
|
+
halves.max
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def warnings(out)
|
|
184
|
+
w = @reports.values.flat_map { |r| Array(r.warnings) }.uniq
|
|
185
|
+
return if w.empty?
|
|
186
|
+
out << "Warnings"
|
|
187
|
+
w.each { |x| out << " ! #{x}" }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
SUGGESTION_ORDER = %w[high medium low info].freeze
|
|
191
|
+
|
|
192
|
+
def suggestions_section(out)
|
|
193
|
+
return unless @suggestions
|
|
194
|
+
out << ""
|
|
195
|
+
out << "Suggestions"
|
|
196
|
+
if @suggestions.empty?
|
|
197
|
+
out << " (none)"
|
|
198
|
+
end
|
|
199
|
+
sorted = @suggestions.each_with_index.sort_by { |s, i| [SUGGESTION_ORDER.index(s.confidence.to_s) || 9, i] }.map(&:first)
|
|
200
|
+
sorted.each do |s|
|
|
201
|
+
wrap("[#{s.confidence}] #{s.title}", 2, 9).each { |l| out << l }
|
|
202
|
+
wrap("id: #{s.id}", 9, 11).each { |l| out << l } if s.id
|
|
203
|
+
ev = Array(s.evidence)
|
|
204
|
+
ev.first(5).each { |e| wrap("- #{evidence_s(e)}", 9, 11).each { |l| out << l } }
|
|
205
|
+
out << " - (+#{ev.size - 5} more)" if ev.size > 5
|
|
206
|
+
out << " estimated savings: up to #{fmt(s.estimated_savings_ms)} ms" if s.estimated_savings_ms
|
|
207
|
+
wrap("overlaps with: #{s.overlaps.join(", ")} (savings not additive)", 9, 11).each { |l| out << l } unless Array(s.overlaps).empty?
|
|
208
|
+
out << " action:"
|
|
209
|
+
s.action.to_s.split("\n").each do |line|
|
|
210
|
+
lead = line[/\A */].size
|
|
211
|
+
wrap(line.strip, 11 + lead, 13 + lead).each { |l| out << l }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
ub = Suggestion.savings_upper_bound(@suggestions)
|
|
215
|
+
if ub && @suggestions.count(&:estimated_savings_ms) > 1
|
|
216
|
+
wrap("total savings: at most #{fmt(ub)} ms (upper bound; findings overlap, so do not treat as additive)", 2, 4).each { |l| out << l }
|
|
217
|
+
end
|
|
218
|
+
Array(@suggestion_warnings).each { |w| wrap("! #{w}", 2, 4).each { |l| out << l } }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def evidence_s(e)
|
|
222
|
+
e.is_a?(Hash) ? e.map { |k, v| "#{k}=#{v.is_a?(Array) ? v.join("/") : v}" }.join(" ") : e.to_s
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# 80 桁に収まるように単語で折り返す。1 行目は indent、続きは cont 桁で字下げする。
|
|
226
|
+
def wrap(text, indent, cont)
|
|
227
|
+
lines = []
|
|
228
|
+
cur = " " * indent
|
|
229
|
+
fresh = true
|
|
230
|
+
text.to_s.split(/\s+/).reject(&:empty?).each do |w|
|
|
231
|
+
w = w[0, WIDTH - cont - 2] + "…" if w.length > WIDTH - cont - 1
|
|
232
|
+
if !fresh && cur.length + 1 + w.length > WIDTH - 1
|
|
233
|
+
lines << cur
|
|
234
|
+
cur = " " * cont
|
|
235
|
+
fresh = true
|
|
236
|
+
end
|
|
237
|
+
cur += (fresh ? "" : " ") + w
|
|
238
|
+
fresh = false
|
|
239
|
+
end
|
|
240
|
+
lines << cur
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def med(s) = s.is_a?(Hash) ? s["median"] : s
|
|
244
|
+
|
|
245
|
+
def fmt(x, digits = 1)
|
|
246
|
+
x.nil? ? "-" : Kernel.format("%.#{digits}f", x)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def signed(x) = Kernel.format("%+.1f", x)
|
|
250
|
+
|
|
251
|
+
def diff_s(a, b)
|
|
252
|
+
return "-" unless a && b
|
|
253
|
+
pct = a.positive? ? Kernel.format(" (%+.1f%%)", (b - a) / a * 100) : ""
|
|
254
|
+
"#{signed(b - a)} ms#{pct}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def stat_s(s, range: true)
|
|
258
|
+
return "-" unless s
|
|
259
|
+
base = "#{fmt(s["median"])} ms"
|
|
260
|
+
range ? "#{base} [#{fmt(s["min"])}-#{fmt(s["max"])}]" : base
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def trunc(s, w)
|
|
264
|
+
s = @redact.call(s) if @redact
|
|
265
|
+
s.length > w ? "…#{s[-(w - 1)..]}" : s
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def clip(l)
|
|
269
|
+
l = @redact.call(l) if @redact
|
|
270
|
+
l.length > WIDTH ? l[0, WIDTH - 1] + "…" : l
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
# Gemfile の行ベースの簡易パーサ (eval しない)。
|
|
8
|
+
# group ブロック / インラインの group: groups: / require: / eval_gemfile を拾う。
|
|
9
|
+
module Gemfile
|
|
10
|
+
# groups: 空配列 = どの group にも属さない (production で読み込まれる)
|
|
11
|
+
# require: nil (指定なし) / false / 文字列の配列 (別名で require される)
|
|
12
|
+
Entry = Struct.new(:name, :groups, :require, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
GEM_RE = /\A\s*gem\s*\(?\s*["']([^"']+)["']/
|
|
15
|
+
GROUP_BLOCK_RE = /\A\s*group\s*\(?\s*([^#]*?)\)?\s+do\b/
|
|
16
|
+
GROUP_ONELINE_RE = /\A\s*group\s*\(?\s*([^#]*?)\)?\s+do\b(.*)\bend\s*\z/
|
|
17
|
+
DO_BLOCK_RE = /\bdo\s*(\|[^|]*\|)?\s*\z/
|
|
18
|
+
END_RE = /\A\s*end\b/
|
|
19
|
+
# 文のかたち (modifier の `gem "x" if foo` は行頭が gem なのでここには来ない)
|
|
20
|
+
STATEMENT_RE = /\A\s*(?:if|unless|case|while|until|begin|def|class|module|for)\b/
|
|
21
|
+
EVAL_RE = /\beval_gemfile\s*\(?\s*(?:["']([^"']+)["']|\w+)/
|
|
22
|
+
GLOB_RE = /Dir(?:\.glob\s*\(?|\s*\[)\s*(?:[\w.:]*expand_path\s*\(\s*)?["']([^"']+)["']/
|
|
23
|
+
PERCENT_RE = /%[iIwW][\[(]([^\])]*)[\])]/
|
|
24
|
+
MAX_DEPTH = 8
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# dir: eval_gemfile の相対パスの基準 (Gemfile のあるディレクトリ)。nil なら eval_gemfile は無視する。
|
|
29
|
+
# => { "gem-name" => Entry }
|
|
30
|
+
def parse(src, dir: nil)
|
|
31
|
+
entries = {}
|
|
32
|
+
walk(src, dir, entries, [], Set.new, 0)
|
|
33
|
+
entries
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def walk(src, dir, entries, base_groups, seen, depth)
|
|
37
|
+
stack = [base_groups] # 各要素: group 名の配列 (group ブロック) か nil (その他のブロック)
|
|
38
|
+
glob = nil
|
|
39
|
+
logical_lines(src).each do |line|
|
|
40
|
+
if (m = line.match(GROUP_ONELINE_RE))
|
|
41
|
+
add_gem(entries, m[2], stack.compact.flatten + symbols(m[1]))
|
|
42
|
+
elsif (m = line.match(GROUP_BLOCK_RE))
|
|
43
|
+
stack.push(symbols(m[1]))
|
|
44
|
+
elsif line.match?(END_RE)
|
|
45
|
+
stack.pop if stack.size > 1
|
|
46
|
+
elsif line.match?(GEM_RE)
|
|
47
|
+
add_gem(entries, line, stack.compact.flatten)
|
|
48
|
+
elsif line.match?(EVAL_RE)
|
|
49
|
+
glob = line[GLOB_RE, 1] || glob
|
|
50
|
+
eval_files(line, glob, dir).each do |path|
|
|
51
|
+
next if depth >= MAX_DEPTH || !seen.add?(path)
|
|
52
|
+
inner = read_file(path)
|
|
53
|
+
walk(inner, File.dirname(path), entries, stack.compact.flatten, seen, depth + 1) if inner
|
|
54
|
+
seen.delete(path)
|
|
55
|
+
end
|
|
56
|
+
elsif line.match?(GLOB_RE)
|
|
57
|
+
glob = line[GLOB_RE, 1]
|
|
58
|
+
push_do_or_statement(stack, line)
|
|
59
|
+
else
|
|
60
|
+
push_do_or_statement(stack, line)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read_file(path)
|
|
66
|
+
File.read(path)
|
|
67
|
+
rescue SystemCallError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def push_do_or_statement(stack, line)
|
|
72
|
+
if line.match?(STATEMENT_RE)
|
|
73
|
+
stack.push(nil) unless line.match?(/\bend\s*\z/)
|
|
74
|
+
elsif line.match?(DO_BLOCK_RE)
|
|
75
|
+
stack.push(nil)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 行内 eval_gemfile "path" / Dir.glob(...) 経由の eval_gemfile f を実ファイルに解決する
|
|
80
|
+
def eval_files(line, glob, dir)
|
|
81
|
+
return [] unless dir
|
|
82
|
+
m = line.match(EVAL_RE)
|
|
83
|
+
pattern = m[1] || glob
|
|
84
|
+
return [] unless pattern
|
|
85
|
+
File.expand_path(pattern, dir).then { |p| Dir.glob(p).sort }.select { File.file?(_1) }.map { File.realpath(_1) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# コメントを除き、行末が , か \ の行は次の行と連結する
|
|
89
|
+
def logical_lines(src)
|
|
90
|
+
out = []
|
|
91
|
+
buf = nil
|
|
92
|
+
src.each_line do |raw|
|
|
93
|
+
line = raw.sub(/(?<!["'\w])#.*/, "").rstrip
|
|
94
|
+
if buf
|
|
95
|
+
buf = "#{buf} #{line.strip}"
|
|
96
|
+
else
|
|
97
|
+
buf = line
|
|
98
|
+
end
|
|
99
|
+
if buf.end_with?(",") || buf.end_with?("\\")
|
|
100
|
+
buf = buf.chomp("\\")
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
out << buf
|
|
104
|
+
buf = nil
|
|
105
|
+
end
|
|
106
|
+
out << buf if buf
|
|
107
|
+
out
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add_gem(entries, line, block_groups)
|
|
111
|
+
m = line.match(GEM_RE) or return
|
|
112
|
+
name = m[1]
|
|
113
|
+
groups = (block_groups + inline_groups(line)).uniq
|
|
114
|
+
req = require_opt(line)
|
|
115
|
+
if (old = entries[name])
|
|
116
|
+
# if/else などで複数宣言されたら、全ての宣言が group 付きのときだけ group 付きとみなす
|
|
117
|
+
groups = (old.groups.empty? || groups.empty?) ? [] : (old.groups | groups)
|
|
118
|
+
req = nil unless old.require == req
|
|
119
|
+
end
|
|
120
|
+
entries[name] = Entry.new(name: name, groups: groups, require: req)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def symbols(str)
|
|
124
|
+
found = []
|
|
125
|
+
str.scan(PERCENT_RE) { |(body)| found.concat(body.split) }
|
|
126
|
+
rest = str.gsub(PERCENT_RE, " ")
|
|
127
|
+
found + rest.scan(/:(\w+)|["'](\w+)["']/).map { |a, b| a || b }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def inline_groups(line)
|
|
131
|
+
m = line.match(/(?:\bgroups?:|:groups?\s*=>)\s*(\[[^\]]*\]|%[iIwW][\[(][^\])]*[\])]|:\w+|["']\w+["'])/)
|
|
132
|
+
m ? symbols(m[1]) : []
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def require_opt(line)
|
|
136
|
+
m = line.match(/(?:\brequire:|:require\s*=>)\s*(false|nil|true|\[[^\]]*\]|%[iIwW][\[(][^\])]*[\])]|"[^"]*"|'[^']*')/)
|
|
137
|
+
return nil unless m
|
|
138
|
+
v = m[1]
|
|
139
|
+
return false if v == "false"
|
|
140
|
+
return nil if v == "nil" || v == "true"
|
|
141
|
+
return v[PERCENT_RE, 1].split if v.start_with?("%")
|
|
142
|
+
v.scan(/["']([^"']*)["']/).flatten
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hayaoki
|
|
4
|
+
module BootReport
|
|
5
|
+
# 集計結果。to_h は JSON.generate に渡せる値 (String キーの Hash / Array / 数値 / nil) だけで作る。
|
|
6
|
+
class Report
|
|
7
|
+
ATTRS = %i[condition runs failed phases categories gems files initializers app_initializer_times on_loads eager_namespaces false_require threads
|
|
8
|
+
ruby features meta warnings].freeze
|
|
9
|
+
attr_reader(*ATTRS)
|
|
10
|
+
|
|
11
|
+
def initialize(**kw)
|
|
12
|
+
unknown = kw.keys - ATTRS
|
|
13
|
+
raise ArgumentError, "unknown keys: #{unknown.inspect}" unless unknown.empty?
|
|
14
|
+
ATTRS.each { |a| instance_variable_set(:"@#{a}", kw[a]) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
ATTRS.to_h { |a| [a.to_s, public_send(a)] }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|