xmi 0.6.1 → 0.7.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,274 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "benchmark/ips"
4
-
5
- # Ensure lib/ is on the load path regardless of tmp location
6
- lib_path = File.expand_path(File.join(__dir__, "..", "..", "lib"))
7
- $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
8
-
9
- require "xmi"
10
-
11
- # Pretty terminal formatting for benchmark output
12
- module Term
13
- CLEAR = "\e[0m"
14
- BOLD = "\e[1m"
15
- DIM = "\e[2m"
16
- RED = "\e[31m"
17
- GREEN = "\e[32m"
18
- YELLOW = "\e[33m"
19
- CYAN = "\e[36m"
20
- MAGENTA = "\e[35m"
21
-
22
- HL = "─"
23
- VL = "│"
24
- TL = "┌"
25
- TR = "┐"
26
- BL = "└"
27
- BR = "┘"
28
-
29
- def self.header(title, color: CYAN)
30
- width = 78
31
- line = HL * width
32
- puts
33
- puts "#{color}#{TL}#{line}#{TR}#{CLEAR}"
34
- puts "#{color}#{VL}#{CLEAR} #{BOLD}#{color}#{title}#{CLEAR}#{' ' * (width - title.length - 4)}#{color}#{VL}#{CLEAR}"
35
- puts "#{color}#{BL}#{line}#{BR}#{CLEAR}"
36
- end
37
-
38
- def self.sep(char: HL, width: 78)
39
- puts "#{DIM}#{char * width}#{CLEAR}"
40
- end
41
-
42
- def self.env_info(ruby_version, platform)
43
- puts
44
- puts " #{DIM}Environment:#{CLEAR}"
45
- puts " #{VL} Ruby #{ruby_version} on #{platform}#{' ' * (60 - ruby_version.length - platform.length)}#{VL}"
46
- puts " #{DIM}#{BL}#{HL * 76}#{BR}#{CLEAR}"
47
- puts
48
- end
49
-
50
- def self.category(title, icon:, description:, failure_means:,
51
- compare_against: nil)
52
- puts
53
- puts "#{CYAN}#{VL}#{CLEAR} #{BOLD}#{MAGENTA}#{icon} #{title}#{CLEAR}"
54
- puts
55
- puts " #{DIM}#{description}#{CLEAR}"
56
- puts
57
-
58
- if compare_against
59
- puts " #{CYAN}Comparing against:#{CLEAR} #{compare_against}"
60
- puts
61
- end
62
-
63
- puts " #{YELLOW}⚠️ Failure means:#{CLEAR} #{failure_means}"
64
- puts
65
- sep(width: 76)
66
- puts
67
- end
68
- end
69
-
70
- class BenchmarkRunner
71
- REPO_ROOT = File.expand_path(File.join(__dir__, "..", ".."))
72
-
73
- # Benchmark configuration
74
- DEFAULT_RUN_TIME = 5
75
- DEFAULT_WARMUP = 2
76
-
77
- # Category definitions with descriptions
78
- CATEGORIES = {
79
- xmi_parsing: {
80
- name: "XMI Parsing",
81
- icon: "📄",
82
- description: "XMI parsing performance tests. Measures how quickly we can convert XMI files into Ruby objects.",
83
- failure_means: "Slow XMI parsing impacts all downstream operations. A regression here means users will experience delays when processing XMI documents.",
84
- compare_against: "Previous branch (main).",
85
- },
86
- }.freeze
87
-
88
- # Test definitions
89
- BENCHMARKS = {
90
- xmi_parsing: [
91
- { name: "XMI 2.4.2 (small)", method: :xmi_parse_242_small,
92
- desc: "XMI 2.4.2 ~100KB file" },
93
- { name: "XMI 2.4.2 (medium)", method: :xmi_parse_242_medium,
94
- desc: "XMI 2.4.2 ~500KB file with extensions" },
95
- { name: "XMI 2.4.2 (large)", method: :xmi_parse_242_large,
96
- desc: "XMI 2.4.2 ~3.5MB file" },
97
- { name: "XMI 2.5.1", method: :xmi_parse_251,
98
- desc: "XMI 2.5.1 ~100KB file" },
99
- ],
100
- }.freeze
101
-
102
- # Test data - fixture paths
103
- FIXTURES = {
104
- xmi_parse_242_small: "spec/fixtures/xmi-v2-4-2-default.xmi",
105
- xmi_parse_242_medium: "spec/fixtures/xmi-v2-4-2-default-with-citygml.xmi",
106
- xmi_parse_242_large: "spec/fixtures/full-242.xmi",
107
- xmi_parse_251: "spec/fixtures/ea-xmi-2.5.1.xmi",
108
- }.freeze
109
-
110
- def initialize(run_time: nil, warmup: nil, benchmark: nil)
111
- @run_time = run_time || DEFAULT_RUN_TIME
112
- @warmup = warmup || DEFAULT_WARMUP
113
- @benchmark = benchmark
114
- @results = {}
115
- @env_shown = false
116
- @all_results = []
117
- end
118
-
119
- def run_benchmarks
120
- Term.header("XMI Performance Benchmarks", color: Term::CYAN)
121
-
122
- unless @env_shown
123
- Term.env_info(RUBY_VERSION, RUBY_PLATFORM)
124
- @env_shown = true
125
- end
126
-
127
- BENCHMARKS.each do |category, tests|
128
- run_category(category, tests)
129
- end
130
-
131
- print_summary
132
-
133
- @results
134
- end
135
-
136
- private
137
-
138
- def run_category(category, tests)
139
- config = CATEGORIES[category]
140
- Term.category(
141
- config[:name],
142
- icon: config[:icon],
143
- description: config[:description],
144
- failure_means: config[:failure_means],
145
- compare_against: config[:compare_against],
146
- )
147
-
148
- category_results = []
149
-
150
- tests.each do |test|
151
- # Redirect stdout during benchmark
152
- original_stdout = $stdout
153
- $stdout = StringIO.new
154
-
155
- result = run_single_test(test[:method])
156
- (result[:lower] + result[:upper]) / 2.0
157
- category_results << { name: test[:name], result: result }
158
-
159
- # Restore stdout
160
- $stdout = original_stdout
161
- end
162
-
163
- # Print results
164
- puts " #{'Benchmark'.ljust(40)} #{'IPS'.rjust(12)} #{'Deviation'.rjust(12)}"
165
- puts " #{Term::DIM}#{Term::HL * 66}#{Term::CLEAR}"
166
-
167
- category_results.each do |r|
168
- ips = (r[:result][:lower] + r[:result][:upper]) / 2.0
169
- deviation = calculate_deviation(r[:result])
170
- label = "#{config[:name]}: #{r[:name]}"
171
- @all_results << { label: label, ips: ips }
172
- @results[label] = r[:result]
173
-
174
- puts " #{r[:name].ljust(40)} #{format('%.2f',
175
- ips).rjust(12)} #{format('%.1f%%',
176
- deviation).rjust(12)}"
177
- end
178
-
179
- puts
180
- end
181
-
182
- def run_single_test(method)
183
- fixture_path = FIXTURES[method]
184
- raise "Unknown fixture: #{method}" unless fixture_path
185
-
186
- # Try to resolve fixture path relative to REPO_ROOT
187
- full_path = File.join(REPO_ROOT, fixture_path)
188
- unless File.exist?(full_path)
189
- # Fallback: try current directory
190
- full_path = fixture_path
191
- end
192
-
193
- xml_content = File.read(full_path)
194
-
195
- case method
196
- when :xmi_parse_242_small, :xmi_parse_242_medium, :xmi_parse_242_large, :xmi_parse_251
197
- measure_time { Xmi::Sparx::Root.parse_xml(xml_content) }
198
- else
199
- raise "Unknown benchmark: #{method}"
200
- end
201
- end
202
-
203
- def measure(&)
204
- job = Benchmark::IPS::Job.new
205
- job.config(time: @run_time, warmup: @warmup)
206
- job.report("test", &)
207
- job.run
208
-
209
- entry = job.full_report.entries.first
210
- samples = entry.stats.samples
211
-
212
- return { lower: 0, upper: 0 } if samples.empty?
213
-
214
- mean = samples.sum.to_f / samples.size
215
- variance = samples.sum { |x| (x - mean)**2 } / (samples.size - 1)
216
- std_dev = Math.sqrt(variance)
217
- error_margin = std_dev / mean
218
- error_pct = error_margin.round(4)
219
-
220
- { lower: mean.round(4) * (1 - error_pct),
221
- upper: mean.round(4) * (1 + error_pct) }
222
- end
223
-
224
- def measure_time
225
- times = []
226
- iterations = 5
227
-
228
- iterations.times do
229
- start_t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
- yield
231
- finish_t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
- times << (finish_t - start_t)
233
- end
234
-
235
- mean = times.sum / times.size
236
- variance = times.sum { |t| (t - mean)**2 } / (times.size - 1)
237
- std_dev = Math.sqrt(variance)
238
-
239
- # Use conservative estimates for time-based measurement
240
- lower_time = [mean - std_dev, mean * 0.5].max
241
- lower_ips = (1.0 / (lower_time * 1.5)).round(4)
242
- upper_ips = (1.0 / mean).round(4)
243
-
244
- # For fast operations, estimate more conservatively
245
- if mean < 0.001
246
- upper_ips = (1.0 / mean).round(4)
247
- lower_ips = (upper_ips * 0.8).round(4)
248
- end
249
-
250
- { lower: lower_ips, upper: upper_ips }
251
- end
252
-
253
- def calculate_deviation(metrics)
254
- return 0 if metrics[:upper].zero?
255
-
256
- ((metrics[:upper] - metrics[:lower]) / metrics[:upper] * 100).round(1)
257
- end
258
-
259
- def print_summary
260
- puts
261
- Term.sep(width: 78)
262
- puts
263
- puts " #{Term::BOLD}#{Term::MAGENTA}SUMMARY#{Term::CLEAR}"
264
- puts
265
-
266
- @all_results.each do |r|
267
- puts " #{r[:label].ljust(60)} #{format('%.2f', r[:ips]).rjust(10)} IPS"
268
- end
269
-
270
- puts
271
- puts " #{Term::DIM}#{@all_results.length} benchmarks completed#{Term::CLEAR}"
272
- puts
273
- end
274
- end
@@ -1,88 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "performance_helpers"
4
-
5
- class PerformanceComparator
6
- REPO_ROOT = File.expand_path(File.join(__dir__, "..", ".."))
7
- DEFAULT_RUN_TIME = 10
8
- DEFAULT_THRESHOLD = 0.10 # 10% (more lenient for complex operations)
9
- DEFAULT_BASE = "main"
10
- TMP_PERF_DIR = File.join(REPO_ROOT, "tmp", "performance")
11
- BENCH_SCRIPT = File.join(TMP_PERF_DIR, "benchmark_runner.rb")
12
-
13
- def run
14
- setup_environment
15
- run_benchmarks_comparison
16
- ensure
17
- cleanup
18
- end
19
-
20
- private
21
-
22
- def setup_environment
23
- Dir.chdir(REPO_ROOT)
24
- FileUtils.mkdir_p(TMP_PERF_DIR)
25
- FileUtils.cp(File.join(REPO_ROOT, "lib", "tasks", "benchmark_runner.rb"),
26
- BENCH_SCRIPT)
27
-
28
- PerformanceHelpers.load_into_namespace(PerformanceHelpers::Current,
29
- BENCH_SCRIPT)
30
- PerformanceHelpers.clone_base_repo(DEFAULT_BASE, TMP_PERF_DIR, BENCH_SCRIPT)
31
- end
32
-
33
- def run_benchmarks_comparison
34
- all_current = {}
35
- all_base = {}
36
-
37
- puts PerformanceHelpers::Term.header("Performance Comparison", color: PerformanceHelpers::CYAN)
38
- puts
39
- puts " #{PerformanceHelpers::DIM}Comparing#{PerformanceHelpers::CLEAR}:"
40
- puts " #{PerformanceHelpers::CYAN} Current#{PerformanceHelpers::CLEAR}: #{PerformanceHelpers.current_branch}"
41
- puts " #{PerformanceHelpers::CYAN} Base#{PerformanceHelpers::CLEAR}: #{DEFAULT_BASE}"
42
- puts " #{PerformanceHelpers::CYAN} Threshold#{PerformanceHelpers::CLEAR}: #{(DEFAULT_THRESHOLD * 100).round(0)}% regression allowed"
43
- puts
44
-
45
- # Run all benchmarks
46
- base_runner = PerformanceHelpers::Base::BenchmarkRunner.new(
47
- run_time: DEFAULT_RUN_TIME,
48
- )
49
- current_runner = PerformanceHelpers::Current::BenchmarkRunner.new(
50
- run_time: DEFAULT_RUN_TIME,
51
- )
52
-
53
- PerformanceHelpers.run_benchmarks(
54
- base_runner,
55
- current_runner,
56
- DEFAULT_THRESHOLD,
57
- all_base,
58
- all_current,
59
- )
60
-
61
- summary = PerformanceHelpers.summary_report(
62
- all_current,
63
- all_base,
64
- DEFAULT_BASE,
65
- DEFAULT_RUN_TIME,
66
- DEFAULT_THRESHOLD,
67
- )
68
-
69
- handle_results(summary)
70
- end
71
-
72
- def handle_results(summary)
73
- puts
74
- if summary[:regressions].any?
75
- puts " #{PerformanceHelpers::RED}#{PerformanceHelpers::BOLD}❌ PERFORMANCE REGRESSIONS DETECTED#{PerformanceHelpers::CLEAR}"
76
- puts " #{PerformanceHelpers::RED}#{summary[:regressions].length} benchmark(s) regressed beyond threshold#{PerformanceHelpers::CLEAR}"
77
- puts
78
- exit(1)
79
- else
80
- puts " #{PerformanceHelpers::GREEN}#{PerformanceHelpers::BOLD}✅ ALL BENCHMARKS PASSED#{PerformanceHelpers::CLEAR}"
81
- puts
82
- end
83
- end
84
-
85
- def cleanup
86
- FileUtils.rm_rf(TMP_PERF_DIR)
87
- end
88
- end
@@ -1,238 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "open3"
5
- require "tmpdir"
6
- require "fileutils"
7
-
8
- module PerformanceHelpers
9
- # ANSI color codes for terminal output
10
- CLEAR = "\e[0m"
11
- BOLD = "\e[1m"
12
- DIM = "\e[2m"
13
- CYAN = "\e[36m"
14
- GREEN = "\e[32m"
15
- YELLOW = "\e[33m"
16
- RED = "\e[31m"
17
- GRAY = "\e[90m"
18
- MAGENTA = "\e[35m"
19
-
20
- # Terminal formatting helpers
21
- module Term
22
- extend self
23
-
24
- HL = "─"
25
- VL = "│"
26
- TL = "┌"
27
- TR = "┐"
28
- BL = "└"
29
- BR = "┘"
30
-
31
- def header(title, color: PerformanceHelpers::CYAN)
32
- width = 78
33
- line = HL * width
34
- puts
35
- puts "#{color}#{TL}#{line}#{TR}#{CLEAR}"
36
- puts "#{color}#{VL}#{CLEAR} #{BOLD}#{color}#{title}#{CLEAR}#{' ' * (width - title.length - 4)}#{color}#{VL}#{CLEAR}"
37
- puts "#{color}#{BL}#{line}#{BR}#{CLEAR}"
38
- end
39
-
40
- def sep(char: HL, width: 78)
41
- puts "#{DIM}#{char * width}#{CLEAR}"
42
- end
43
- end
44
-
45
- module Base
46
- end
47
-
48
- module Current
49
- end
50
-
51
- class << self
52
- def load_into_namespace(module_obj, file_path)
53
- content = File.read(file_path)
54
- module_obj.module_eval(content, file_path)
55
- end
56
-
57
- def ruby_exec(cmd, env: {})
58
- Open3.capture3(env, cmd)
59
- end
60
-
61
- def current_branch
62
- stdout, = ruby_exec("git rev-parse --abbrev-ref HEAD")
63
- stdout.strip
64
- end
65
-
66
- # Clone base branch into a temp dir and return its path
67
- def clone_base_repo(base, performance_dir, script)
68
- puts "#{DIM}Cloning base #{base}...#{CLEAR}"
69
- safe_ref = base.gsub(/[^0-9A-Za-z._-]/, "-")
70
- clone_dir = File.join(performance_dir, "base-#{safe_ref}")
71
- FileUtils.rm_rf(clone_dir)
72
-
73
- repo_url, = ruby_exec("git config --get remote.origin.url")
74
- repo_url = repo_url.strip
75
-
76
- stdout, stderr, status = ruby_exec("git clone --branch #{safe_ref} --single-branch #{repo_url} #{clone_dir}")
77
- raise "git clone failed: #{stderr}\n#{stdout}" unless status.success?
78
-
79
- Dir.chdir(clone_dir) do
80
- stdout, stderr, status = ruby_exec("bundle install --quiet")
81
- raise "bundle install failed: #{stderr}\n#{stdout}" unless status.success?
82
-
83
- bench_copy_dir = File.join(clone_dir, "lib", "tasks")
84
- FileUtils.mkdir_p(bench_copy_dir)
85
- bench_copy = File.join(bench_copy_dir, "benchmark_runner.rb")
86
- File.write(bench_copy, File.read(script))
87
- load_into_namespace(Base, bench_copy)
88
- end
89
- end
90
-
91
- def run_benchmarks(base_runner, current_runner, threshold, all_base,
92
- all_current)
93
- base_results = base_runner.run_benchmarks
94
- curr_results = current_runner.run_benchmarks
95
-
96
- all_base.merge!(base_results)
97
- all_current.merge!(curr_results)
98
-
99
- # Collect comparison results
100
- comparison_rows = []
101
-
102
- curr_results.each do |label, result|
103
- base_result = base_results[label]
104
- cmp = compare_metrics(label, result, base_result, threshold)
105
- comparison_rows << cmp
106
- end
107
-
108
- print_comparison_table(comparison_rows, threshold)
109
- end
110
-
111
- def print_comparison_table(comparison_rows, threshold)
112
- rows = comparison_rows.map do |cmp|
113
- {
114
- benchmark: cmp[:label],
115
- base_ips: cmp[:base_ips]&.round(1),
116
- curr_ips: cmp[:curr_ips]&.round(1),
117
- change: cmp[:change] ? "#{(cmp[:change] * 100).round(1)}%" : "N/A",
118
- status: if cmp[:base_ips].nil?
119
- "NEW"
120
- elsif cmp[:change] < -threshold
121
- "REGRESSED"
122
- else
123
- "OK"
124
- end,
125
- }
126
- end
127
-
128
- return if rows.empty?
129
-
130
- puts " #{'Benchmark'.ljust(40)} #{'Base IPS'.rjust(12)} #{'Curr IPS'.rjust(12)} #{'Change'.rjust(10)} #{'Status'.rjust(10)}"
131
- puts " #{DIM}#{'─' * 86}#{CLEAR}"
132
-
133
- rows.each do |row|
134
- status_color = case row[:status]
135
- when "REGRESSED" then RED
136
- when "NEW" then YELLOW
137
- else GREEN
138
- end
139
- row[:status] == "REGRESSED" ? RED : DIM
140
-
141
- puts " #{row[:benchmark].ljust(40)} #{format('%-12.1f',
142
- row[:base_ips] || 0)} #{format('%-12.1f',
143
- row[:curr_ips] || 0)} #{format('%-10s', row[:change]).gsub('%',
144
- '%%')} #{status_color}#{row[:status].rjust(10)}#{CLEAR}"
145
- end
146
-
147
- puts
148
- end
149
-
150
- def compare_metrics(label, curr, base, threshold)
151
- unless base
152
- return { label: label, base_ips: nil, curr_ips: nil, change: nil,
153
- regressed: false }
154
- end
155
-
156
- base_ips = base.fetch(:lower)
157
- curr_ips = curr.fetch(:upper)
158
- change = (curr_ips - base_ips) / base_ips.to_f
159
-
160
- {
161
- label: label,
162
- base_ips: base_ips,
163
- curr_ips: curr_ips,
164
- change: change,
165
- regressed: change < -threshold,
166
- }
167
- end
168
-
169
- def summary_report(current_results, base_results, base, run_time, threshold)
170
- summary = {
171
- run_time: run_time,
172
- threshold: threshold,
173
- branch: current_branch,
174
- base: base,
175
- regressions: [],
176
- new_benchmarks: [],
177
- }
178
-
179
- current_results.each do |label, metrics|
180
- base_result = base_results[label]
181
- cmp = compare_metrics(label, metrics, base_result, threshold)
182
-
183
- # Track new benchmarks that don't exist in base
184
- if base_result.nil?
185
- summary[:new_benchmarks] << label
186
- next
187
- end
188
-
189
- next unless cmp[:regressed]
190
-
191
- summary[:regressions] << {
192
- label: label,
193
- base_ips: cmp[:base_ips],
194
- curr_ips: cmp[:curr_ips],
195
- delta_fraction: cmp[:change],
196
- }
197
- end
198
-
199
- log_regressions(summary[:regressions], threshold)
200
- log_new_benchmarks(summary[:new_benchmarks])
201
- summary
202
- end
203
-
204
- def log_new_benchmarks(new_benchmarks)
205
- return if new_benchmarks.empty?
206
-
207
- puts
208
- puts "#{YELLOW}🆕 New benchmarks (not in base branch):#{CLEAR}"
209
- new_benchmarks.each do |label|
210
- puts " • #{label}"
211
- end
212
- end
213
-
214
- def log_regressions(regressions, threshold)
215
- return if regressions.empty?
216
-
217
- puts
218
- puts "#{RED}⚠️ Performance Regressions Detected#{CLEAR}"
219
- puts "#{RED} (< -#{(threshold * 100).round(2)}% IPS)#{CLEAR}"
220
- puts
221
- regressions.each do |regression|
222
- delta = regression[:delta_fraction]
223
- base_ips = regression[:base_ips]
224
- curr_ips = regression[:curr_ips]
225
-
226
- delta_str = delta ? format("%+0.2f%%", delta * 100) : "N/A"
227
- base_str = base_ips ? format("%.2f", base_ips) : "N/A"
228
- curr_str = curr_ips ? format("%.2f", curr_ips) : "N/A"
229
-
230
- puts " #{BOLD}#{regression[:label]}#{CLEAR}"
231
- puts " #{GRAY}base: #{base_str} IPS#{CLEAR}"
232
- puts " #{RED}curr: #{curr_str} IPS#{CLEAR}"
233
- puts " #{RED}change: #{delta_str}#{CLEAR}"
234
- puts
235
- end
236
- end
237
- end
238
- end