tg_geometry 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdc6ab78992bee6ba1708b13adfdb0e4eec26fef04c4fc8eae87df21fbfed5fa
4
- data.tar.gz: ada3eff77de10cb101cbedbd35e2d40e119fe37c5a09003afe9df1ab266827fe
3
+ metadata.gz: cde617c534a8a3d9702b3fe212d46f966da46224499bb4fcb97bf40865590be8
4
+ data.tar.gz: 7e1767d31814249ef695ceae1965d6c5aaf9065ff7595309e95698abc053abbc
5
5
  SHA512:
6
- metadata.gz: 87200faec4b6d0442eab7df12f768807193aed725bb75b25502a64cc5b2e24e97f2f9d98cf9254c54e3f62d9b0481f39dc2287818f69a740e9728a33e97d9f0b
7
- data.tar.gz: 3e14f4563facef36fc109e1c8006b707502bb384f67c1c27da05faa7dcec7b7a7970d82a22d39ac99c00595c74944c7a6467fa81a5cdd1d14129df70f4d3675a
6
+ metadata.gz: 2cf6070d21ca5342425600e436523aae7215a2792d2d8392662a6c5257ba4e178301ff2a93c5ebb847fc7606d7b1aad60b9ab1fcd8e21b67f06d5f72f8c3cfa1
7
+ data.tar.gz: 99a9f9c086c6a2da9add012f57a99de800f67846007503ddf6c5d63ec25ac2a6d62cfe5b854fe1d44976ff0bddcbce04b8831db631dd8d174602c64914190e30
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 - 28.05.2026
4
+
5
+ ### Changed
6
+ - Optimized R-tree-backed point lookup by removing per-query candidate bitmap
7
+ allocation and full entry scan from Index#find_covering. The new path tracks
8
+ the lowest matching ordinal directly in the R-tree callback, preserving
9
+ insertion-order semantics while significantly improving rtree point queries
10
+ and packed batch point lookup.
11
+
3
12
  ## 0.3.0 - 27.05.2026
4
13
 
5
14
  ### Added
@@ -21,14 +21,447 @@ module TGGeometryBench
21
21
  module_function
22
22
 
23
23
  SIZES = [100, 500, 1_000, 5_000, 50_000].freeze
24
- FAST_SIZES = [100, 500, 1_000].freeze
24
+ FAST_SIZES = [100, 1_000, 5_000].freeze
25
+
26
+ DEFAULT_REPEATS = 5
27
+ DEFAULT_MIN_SECONDS = 0.25
28
+ DEFAULT_WARMUP_SECONDS = 0.05
29
+ DEFAULT_MAX_ITERATIONS = 100_000_000
30
+
31
+ def full?
32
+ ENV["TGEOMETRY_BENCH_FULL"] == "1"
33
+ end
25
34
 
26
35
  def sizes
27
- ENV["TGEOMETRY_BENCH_FULL"] == "1" ? SIZES : FAST_SIZES
36
+ full? ? SIZES : FAST_SIZES
37
+ end
38
+
39
+ def env_integer(name, default, min: nil)
40
+ value = Integer(ENV.fetch(name, default.to_s))
41
+ if min && value < min
42
+ raise ArgumentError, "#{name} must be >= #{min}, got #{value}"
43
+ end
44
+ value
45
+ end
46
+
47
+ def env_float(name, default, min: nil)
48
+ value = Float(ENV.fetch(name, default.to_s))
49
+ if min && value < min
50
+ raise ArgumentError, "#{name} must be >= #{min}, got #{value}"
51
+ end
52
+ value
53
+ end
54
+
55
+ def initial_iterations(default)
56
+ env_integer("TGEOMETRY_BENCH_ITERATIONS", default, min: 1)
57
+ end
58
+
59
+ def repeats(default = DEFAULT_REPEATS)
60
+ env_integer("TGEOMETRY_BENCH_REPEATS", default, min: 1)
61
+ end
62
+
63
+ def min_seconds(default = DEFAULT_MIN_SECONDS)
64
+ env_float("TGEOMETRY_BENCH_MIN_SECONDS", default, min: 0.0)
65
+ end
66
+
67
+ def warmup_seconds(default = DEFAULT_WARMUP_SECONDS)
68
+ env_float("TGEOMETRY_BENCH_WARMUP_SECONDS", default, min: 0.0)
69
+ end
70
+
71
+ def max_iterations(default = DEFAULT_MAX_ITERATIONS)
72
+ env_integer("TGEOMETRY_BENCH_MAX_ITERATIONS", default, min: 1)
73
+ end
74
+
75
+ def adaptive?
76
+ ENV.fetch("TGEOMETRY_BENCH_ADAPTIVE", "1") != "0"
77
+ end
78
+
79
+ def disable_gc_during_timed?
80
+ ENV["TGEOMETRY_BENCH_DISABLE_GC"] == "1"
81
+ end
82
+
83
+ def monotonic
84
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
85
+ end
86
+
87
+ def median(values)
88
+ sorted = values.sort
89
+ n = sorted.length
90
+ return 0.0 if n.zero?
91
+
92
+ mid = n / 2
93
+ n.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
94
+ end
95
+
96
+ def percentile(values, pct)
97
+ sorted = values.sort
98
+ return 0.0 if sorted.empty?
99
+
100
+ rank = (pct * (sorted.length - 1)).round
101
+ sorted.fetch(rank)
102
+ end
103
+
104
+ def gc_start
105
+ GC.start(full_mark: true, immediate_sweep: true)
106
+ rescue ArgumentError
107
+ GC.start
108
+ end
109
+
110
+ def timed_gc_delta
111
+ before = GC.stat
112
+ seconds = nil
113
+
114
+ if disable_gc_during_timed?
115
+ was_enabled = GC.enable
116
+ GC.disable
117
+ seconds = Benchmark.realtime { yield }
118
+ GC.enable if was_enabled
119
+ else
120
+ seconds = Benchmark.realtime { yield }
121
+ end
122
+
123
+ after = GC.stat
124
+ [
125
+ seconds,
126
+ after[:total_allocated_objects] - before[:total_allocated_objects],
127
+ after[:minor_gc_count] - before[:minor_gc_count],
128
+ after[:major_gc_count] - before[:major_gc_count]
129
+ ]
130
+ ensure
131
+ GC.enable if disable_gc_during_timed?
132
+ end
133
+
134
+ def calibrate_iterations(initial_iterations:, min_seconds:, max_iterations: self.max_iterations, &block)
135
+ iterations = initial_iterations
136
+ return iterations unless adaptive?
137
+ return iterations if min_seconds <= 0.0
138
+
139
+ loop do
140
+ gc_start
141
+ seconds = Benchmark.realtime { block.call(iterations) }
142
+ break iterations if seconds >= min_seconds || iterations >= max_iterations
143
+
144
+ if seconds <= 0.0
145
+ iterations = [iterations * 10, max_iterations].min
146
+ next
147
+ end
148
+
149
+ target = (iterations * (min_seconds / seconds) * 1.50).ceil
150
+ iterations = [[target, iterations * 2].max, max_iterations].min
151
+ end
152
+ end
153
+
154
+ def warmup_for(seconds, iterations, &block)
155
+ return if seconds <= 0.0
156
+
157
+ deadline = monotonic + seconds
158
+ begin
159
+ block.call(iterations)
160
+ end while monotonic < deadline
161
+ end
162
+
163
+ def measure_counted(initial_iterations:, operations_per_iteration: 1, min_seconds: self.min_seconds,
164
+ repeats: self.repeats, warmup_seconds: self.warmup_seconds,
165
+ max_iterations: self.max_iterations, &block)
166
+ iterations = calibrate_iterations(
167
+ initial_iterations: initial_iterations,
168
+ min_seconds: min_seconds,
169
+ max_iterations: max_iterations,
170
+ &block
171
+ )
172
+
173
+ warmup_for(warmup_seconds, iterations, &block)
174
+
175
+ seconds_samples = []
176
+ allocation_samples = []
177
+ minor_gc_samples = []
178
+ major_gc_samples = []
179
+
180
+ repeats.times do
181
+ gc_start
182
+ seconds, allocations, minor_gc, major_gc = timed_gc_delta { block.call(iterations) }
183
+ seconds_samples << seconds
184
+ allocation_samples << allocations
185
+ minor_gc_samples << minor_gc
186
+ major_gc_samples << major_gc
187
+ end
188
+
189
+ total_operations = iterations * operations_per_iteration
190
+ median_sec = median(seconds_samples)
191
+
192
+ {
193
+ iterations: iterations,
194
+ operations_per_iteration: operations_per_iteration,
195
+ operations_per_repeat: total_operations,
196
+ repeats: repeats,
197
+ min_seconds: min_seconds,
198
+ warmup_seconds: warmup_seconds,
199
+ best_sec: seconds_samples.min,
200
+ median_sec: median_sec,
201
+ worst_sec: seconds_samples.max,
202
+ mean_sec: seconds_samples.sum / seconds_samples.length,
203
+ p90_sec: percentile(seconds_samples, 0.90),
204
+ ops_per_sec: median_sec.positive? ? total_operations / median_sec : 0.0,
205
+ ns_per_op: median_sec.positive? ? (median_sec / total_operations) * 1e9 : 0.0,
206
+ spread_pct: seconds_samples.min.positive? ? ((seconds_samples.max - seconds_samples.min) / seconds_samples.min) * 100.0 : 0.0,
207
+ median_allocations: median(allocation_samples),
208
+ allocations_per_op: total_operations.positive? ? median(allocation_samples) / total_operations.to_f : 0.0,
209
+ median_minor_gc: median(minor_gc_samples),
210
+ median_major_gc: median(major_gc_samples),
211
+ samples_sec: seconds_samples
212
+ }
213
+ end
214
+
215
+ def format_value(value)
216
+ case value
217
+ when Float
218
+ if value.finite?
219
+ "%.6f" % value
220
+ else
221
+ value.to_s
222
+ end
223
+ when Array
224
+ value.join(":")
225
+ else
226
+ value.to_s.gsub(/\s+/, "_")
227
+ end
228
+ end
229
+
230
+ def output_format
231
+ ENV.fetch("TGEOMETRY_BENCH_FORMAT", "table")
232
+ end
233
+
234
+ def kv_output?
235
+ %w[kv both].include?(output_format)
236
+ end
237
+
238
+ def table_output?
239
+ %w[table both].include?(output_format)
240
+ end
241
+
242
+ def report_rows
243
+ @report_rows ||= []
244
+ end
245
+
246
+ def kv_line(payload)
247
+ payload.map { |key, value| "#{key}=#{format_value(value)}" }.join(" ")
248
+ end
249
+
250
+ def report(benchmark, fields = nil, stats: nil, **kwargs)
251
+ fields = (fields || {}).merge(kwargs)
252
+
253
+ payload = {
254
+ benchmark: benchmark,
255
+ ruby: RUBY_VERSION,
256
+ platform: RUBY_PLATFORM,
257
+ full: full?,
258
+ adaptive: adaptive?,
259
+ gc_disabled: disable_gc_during_timed?
260
+ }.merge(fields)
261
+
262
+ if stats
263
+ payload = payload.merge(
264
+ iterations: stats[:iterations],
265
+ ops_per_iteration: stats[:operations_per_iteration],
266
+ operations: stats[:operations_per_repeat],
267
+ repeats: stats[:repeats],
268
+ min_seconds: stats[:min_seconds],
269
+ median_sec: stats[:median_sec],
270
+ best_sec: stats[:best_sec],
271
+ worst_sec: stats[:worst_sec],
272
+ ops_per_sec: stats[:ops_per_sec],
273
+ ns_per_op: stats[:ns_per_op],
274
+ spread_pct: stats[:spread_pct],
275
+ median_allocations: stats[:median_allocations],
276
+ allocations_per_op: stats[:allocations_per_op],
277
+ median_minor_gc: stats[:median_minor_gc],
278
+ median_major_gc: stats[:median_major_gc]
279
+ )
280
+ end
281
+
282
+ puts kv_line(payload) if kv_output?
283
+ report_rows << payload if table_output?
284
+ payload
28
285
  end
29
286
 
30
- def iterations(default)
31
- Integer(ENV.fetch("TGEOMETRY_BENCH_ITERATIONS", default.to_s))
287
+ TABLE_CONTEXT_COLUMNS = [
288
+ :kind, :n, :strategy, :mode,
289
+ :query, :method, :case, :format,
290
+ :library, :operation,
291
+ :point_index, :lon, :lat,
292
+ :rect_index, :rect,
293
+ :segments, :target_bytes, :payload_bytes,
294
+ :points_per_batch, :threads,
295
+ :entries, :rebuilds, :cycle
296
+ ].freeze
297
+
298
+ TABLE_METRIC_COLUMNS = [
299
+ :batches_per_sec, :ops_per_sec, :qps, :ns_per_op,
300
+ :median_sec, :spread_pct, :allocations_per_op,
301
+ :median_minor_gc, :median_major_gc,
302
+ :iterations, :operations,
303
+ :geom_memsize, :flat_memsize, :rtree_memsize, :rtree_over_flat,
304
+ :start_rss_kb, :peak_rss_kb, :finish_rss_kb, :drift_kb, :max_drift_kb,
305
+ :elapsed_sec, :queries, :sample_count, :rss_kb
306
+ ].freeze
307
+
308
+ TABLE_INTERNAL_COLUMNS = [
309
+ :benchmark, :ruby, :platform, :full, :adaptive, :gc_disabled,
310
+ :repeats, :min_seconds, :warmup_seconds, :max_iterations,
311
+ :ops_per_iteration, :median_allocations,
312
+ :best_sec, :worst_sec
313
+ ].freeze
314
+
315
+ TABLE_HEADERS = {
316
+ kind: "kind",
317
+ n: "n",
318
+ strategy: "strategy",
319
+ mode: "mode",
320
+ query: "query",
321
+ method: "method",
322
+ case: "case",
323
+ format: "format",
324
+ library: "lib",
325
+ operation: "op",
326
+ point_index: "pt#",
327
+ rect_index: "rect#",
328
+ lon: "lon",
329
+ lat: "lat",
330
+ rect: "rect",
331
+ segments: "segments",
332
+ target_bytes: "target B",
333
+ payload_bytes: "payload B",
334
+ points_per_batch: "points/batch",
335
+ batches_per_sec: "batches/s",
336
+ ops_per_sec: "ops/s",
337
+ qps: "qps",
338
+ ns_per_op: "ns/op",
339
+ median_sec: "median s",
340
+ spread_pct: "spread %",
341
+ allocations_per_op: "alloc/op",
342
+ median_minor_gc: "minor GC",
343
+ median_major_gc: "major GC",
344
+ iterations: "iters",
345
+ operations: "ops",
346
+ threads: "threads",
347
+ entries: "entries",
348
+ rebuilds: "rebuilds",
349
+ queries: "queries",
350
+ cycle: "cycle",
351
+ rss_kb: "rss KB",
352
+ geom_memsize: "geom B",
353
+ flat_memsize: "flat B",
354
+ rtree_memsize: "rtree B",
355
+ rtree_over_flat: "rtree-flat B",
356
+ start_rss_kb: "start KB",
357
+ peak_rss_kb: "peak KB",
358
+ finish_rss_kb: "finish KB",
359
+ drift_kb: "drift KB",
360
+ max_drift_kb: "max drift KB",
361
+ elapsed_sec: "elapsed s",
362
+ sample_count: "samples"
363
+ }.freeze
364
+
365
+ def human_int(value)
366
+ Integer(value).to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1_').reverse
367
+ rescue StandardError
368
+ value.to_s
369
+ end
370
+
371
+ def human_rate(value)
372
+ v = Float(value)
373
+ return "0" if v.zero?
374
+
375
+ abs = v.abs
376
+ if abs >= 1_000_000
377
+ "%.2fM" % (v / 1_000_000.0)
378
+ elsif abs >= 1_000
379
+ "%.1fk" % (v / 1_000.0)
380
+ elsif abs >= 100
381
+ "%.1f" % v
382
+ else
383
+ "%.3f" % v
384
+ end
385
+ end
386
+
387
+ def format_table_cell(key, value)
388
+ return "" if value.nil?
389
+
390
+ case key
391
+ when :ops_per_sec, :batches_per_sec, :qps
392
+ human_rate(value)
393
+ when :ns_per_op
394
+ "%.1f" % Float(value)
395
+ when :median_sec, :elapsed_sec
396
+ "%.4f" % Float(value)
397
+ when :spread_pct
398
+ "%.2f" % Float(value)
399
+ when :allocations_per_op
400
+ v = Float(value)
401
+ v < 0.01 ? ("%.5f" % v) : ("%.3f" % v)
402
+ when :lon, :lat
403
+ "%.3f" % Float(value)
404
+ when :rect
405
+ Array(value).map { |v| (Float(v) % 1).zero? ? Integer(v).to_s : ("%.3f" % Float(v)) }.join(":")
406
+ when :n, :iterations, :operations, :entries, :rebuilds, :queries, :cycle,
407
+ :rss_kb, :geom_memsize, :flat_memsize, :rtree_memsize, :rtree_over_flat,
408
+ :start_rss_kb, :peak_rss_kb, :finish_rss_kb, :drift_kb, :max_drift_kb,
409
+ :target_bytes, :payload_bytes, :segments, :points_per_batch, :threads,
410
+ :sample_count, :median_minor_gc, :median_major_gc
411
+ human_int(value)
412
+ when :full, :adaptive, :gc_disabled
413
+ value ? "yes" : "no"
414
+ else
415
+ value.to_s
416
+ end
417
+ end
418
+
419
+ def table_columns(rows)
420
+ keys = rows.flat_map(&:keys).uniq
421
+ context = TABLE_CONTEXT_COLUMNS.select { |key| keys.include?(key) }
422
+ metrics = TABLE_METRIC_COLUMNS.select { |key| keys.include?(key) }
423
+ extras = keys - context - metrics - TABLE_INTERNAL_COLUMNS
424
+ context + extras + metrics
425
+ end
426
+
427
+ def print_table(rows, columns)
428
+ headers = columns.map { |key| TABLE_HEADERS.fetch(key, key.to_s) }
429
+ body = rows.map do |row|
430
+ columns.map { |key| format_table_cell(key, row[key]) }
431
+ end
432
+ widths = headers.each_with_index.map do |header, index|
433
+ ([header.length] + body.map { |cells| cells[index].length }).max
434
+ end
435
+
436
+ separator = "+-#{widths.map { |w| "-" * w }.join("-+-")}-+"
437
+ puts separator
438
+ puts "| #{headers.each_with_index.map { |h, i| h.ljust(widths[i]) }.join(" | ")} |"
439
+ puts separator
440
+ body.each do |cells|
441
+ puts "| #{cells.each_with_index.map { |cell, i| cell.rjust(widths[i]) }.join(" | ")} |"
442
+ end
443
+ puts separator
444
+ end
445
+
446
+ def flush_reports
447
+ rows = report_rows
448
+ return if rows.empty?
449
+
450
+ rows.group_by { |row| row[:benchmark] }.each do |benchmark, group_rows|
451
+ notes, measured = group_rows.partition { |row| row.key?(:note) && row.keys.none? { |key| TABLE_METRIC_COLUMNS.include?(key) } }
452
+
453
+ notes.each do |row|
454
+ puts "\n-- #{benchmark} --"
455
+ puts row[:note]
456
+ end
457
+
458
+ next if measured.empty?
459
+
460
+ puts "\n-- #{benchmark} --"
461
+ print_table(measured, table_columns(measured))
462
+ end
463
+ ensure
464
+ rows&.clear
32
465
  end
33
466
 
34
467
  def box_wkt(min_x, min_y, max_x, max_y)
@@ -91,6 +524,11 @@ module TGGeometryBench
91
524
  end
92
525
  end
93
526
 
527
+ def repeated_points(kind, count)
528
+ seed = points_for(kind)
529
+ Array.new(count) { |i| seed[i % seed.length] }
530
+ end
531
+
94
532
  def packed_points(points)
95
533
  points.flatten.pack("d*")
96
534
  end
@@ -110,6 +548,13 @@ module TGGeometryBench
110
548
 
111
549
  def say_header(title)
112
550
  puts "\n== #{title} =="
113
- puts "ruby=#{RUBY_VERSION} platform=#{RUBY_PLATFORM} full=#{ENV['TGEOMETRY_BENCH_FULL'] == '1'}"
551
+ puts "ruby=#{RUBY_VERSION} platform=#{RUBY_PLATFORM} sizes=#{sizes.join(":")} repeats=#{repeats} " \
552
+ "min_seconds=#{format_table_cell(:median_sec, min_seconds)} warmup=#{format_table_cell(:median_sec, warmup_seconds)} " \
553
+ "adaptive=#{adaptive? ? "yes" : "no"} gc_disabled=#{disable_gc_during_timed? ? "yes" : "no"}"
554
+ puts "output=table; set TGEOMETRY_BENCH_FORMAT=kv for machine-readable key=value lines" if table_output? && !kv_output?
114
555
  end
115
556
  end
557
+
558
+ at_exit do
559
+ TGGeometryBench.flush_reports if TGGeometryBench.table_output?
560
+ end
@@ -3,25 +3,57 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("batch_packed_vs_loop")
6
- iterations = TGGeometryBench.iterations(500)
6
+ points_per_batch = TGGeometryBench.env_integer("TGEOMETRY_BENCH_BATCH_POINTS", 1_000, min: 1)
7
7
 
8
8
  %i[compact long_thin overlapping].each do |kind|
9
9
  TGGeometryBench.sizes.each do |size|
10
10
  entries = TGGeometryBench.entries_for(kind, size)
11
- points = Array.new(1_000) { |i| TGGeometryBench.points_for(kind)[i % TGGeometryBench.points_for(kind).length] }
11
+ points = TGGeometryBench.repeated_points(kind, points_per_batch)
12
12
  packed = TGGeometryBench.packed_points(points)
13
13
 
14
14
  %i[flat rtree].each do |strategy|
15
15
  index = TGGeometryBench.build_index(entries, strategy: strategy)
16
16
 
17
- scalar_time = Benchmark.realtime do
18
- iterations.times { points.map { |lon, lat| index.find_covering(lon, lat) } }
17
+ scalar = TGGeometryBench.measure_counted(
18
+ initial_iterations: TGGeometryBench.initial_iterations(50),
19
+ operations_per_iteration: points.length
20
+ ) do |iterations|
21
+ iterations.times do
22
+ points.each { |lon, lat| index.find_covering(lon, lat) }
23
+ end
19
24
  end
20
- batch_time = Benchmark.realtime do
25
+
26
+ batch = TGGeometryBench.measure_counted(
27
+ initial_iterations: TGGeometryBench.initial_iterations(50),
28
+ operations_per_iteration: points.length
29
+ ) do |iterations|
21
30
  iterations.times { index.covering_ids_batch_packed(packed) }
22
31
  end
23
32
 
24
- puts "kind=#{kind} n=#{size} strategy=#{strategy} points=#{points.length} scalar_sec=%.6f batch_sec=%.6f scalar_batches_per_sec=%.2f batch_batches_per_sec=%.2f" % [scalar_time, batch_time, iterations / scalar_time, iterations / batch_time]
33
+ TGGeometryBench.report(
34
+ "batch_packed_vs_loop",
35
+ {
36
+ kind: kind,
37
+ n: size,
38
+ strategy: strategy,
39
+ mode: :scalar_loop,
40
+ points_per_batch: points.length,
41
+ batches_per_sec: scalar[:ops_per_sec] / points.length
42
+ },
43
+ stats: scalar
44
+ )
45
+ TGGeometryBench.report(
46
+ "batch_packed_vs_loop",
47
+ {
48
+ kind: kind,
49
+ n: size,
50
+ strategy: strategy,
51
+ mode: :packed_batch,
52
+ points_per_batch: points.length,
53
+ batches_per_sec: batch[:ops_per_sec] / points.length
54
+ },
55
+ stats: batch
56
+ )
25
57
  end
26
58
  end
27
59
  end
@@ -1,28 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "benchmark"
4
3
  require_relative "_support"
5
4
 
5
+ TGGeometryBench.say_header("ewkb_roundtrip")
6
+
6
7
  geom = TG::Geometry.polygon([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], srid: 4326)
7
8
  ewkb = geom.to_ewkb
8
9
 
9
- Benchmark.bm(32) do |x|
10
- x.report("tg parse_wkb -> to_ewkb 100k") do
11
- 100_000.times { TG::Geometry.parse_wkb(ewkb).to_ewkb }
12
- end
10
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(5_000)) do |iterations|
11
+ iterations.times { TG::Geometry.parse_wkb(ewkb).to_ewkb }
13
12
  end
14
13
 
14
+ TGGeometryBench.report(
15
+ "ewkb_roundtrip",
16
+ { library: :tg_geometry, operation: :parse_wkb_to_ewkb, payload_bytes: ewkb.bytesize },
17
+ stats: stats
18
+ )
19
+
15
20
  if ENV["WITH_RGEO"]
16
21
  begin
17
22
  require "rgeo"
18
23
  factory = RGeo::Cartesian.factory(srid: 4326)
19
24
  rgeo_geom = factory.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")
20
25
 
21
- Benchmark.bm(32) do |x|
22
- x.report("rgeo WKT parse 100k") do
23
- 100_000.times { factory.parse_wkt(rgeo_geom.as_text) }
24
- end
26
+ rgeo_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(5_000)) do |iterations|
27
+ iterations.times { factory.parse_wkt(rgeo_geom.as_text) }
25
28
  end
29
+
30
+ TGGeometryBench.report(
31
+ "ewkb_roundtrip",
32
+ { library: :rgeo, operation: :wkt_parse, payload_bytes: rgeo_geom.as_text.bytesize },
33
+ stats: rgeo_stats
34
+ )
26
35
  rescue LoadError
27
36
  warn "rgeo is not installed"
28
37
  end
@@ -3,23 +3,30 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("falcon_concurrency")
6
- puts "No Falcon dependency is used here. This is a thread-read baseline for the immutable Index model."
7
- puts "Falcon/Async behavior remains an Pending decision until Roman approves a dedicated dependency/setup."
6
+ TGGeometryBench.report("falcon_concurrency_note", note: "thread_read_baseline_only_no_falcon_dependency")
8
7
 
9
- entries = TGGeometryBench.compact_entries(1_000)
8
+ entries_count = TGGeometryBench.env_integer("TGEOMETRY_BENCH_ENTRIES", 1_000, min: 1)
9
+ threads = TGGeometryBench.env_integer("TGEOMETRY_BENCH_THREADS", 4, min: 1)
10
+ entries = TGGeometryBench.compact_entries(entries_count)
10
11
  index = TGGeometryBench.build_index(entries, strategy: :rtree)
11
- threads = Integer(ENV.fetch("TGEOMETRY_BENCH_THREADS", "4"))
12
- iterations = TGGeometryBench.iterations(10_000)
12
+ points = TGGeometryBench.points_for(:compact)
13
13
 
14
- elapsed = Benchmark.realtime do
15
- threads.times.map do
14
+ stats = TGGeometryBench.measure_counted(
15
+ initial_iterations: TGGeometryBench.initial_iterations(2_000),
16
+ operations_per_iteration: threads
17
+ ) do |iterations_per_thread|
18
+ threads.times.map do |thread_index|
16
19
  Thread.new do
17
- iterations.times do |i|
18
- lon, lat = TGGeometryBench.points_for(:compact)[i % 3]
20
+ iterations_per_thread.times do |i|
21
+ lon, lat = points[(i + thread_index) % points.length]
19
22
  index.find_covering(lon, lat)
20
23
  end
21
24
  end
22
25
  end.each(&:join)
23
26
  end
24
27
 
25
- puts "threads=#{threads} iterations_per_thread=#{iterations} total_queries=#{threads * iterations} seconds=%.6f qps=%.2f" % [elapsed, (threads * iterations) / elapsed]
28
+ TGGeometryBench.report(
29
+ "falcon_concurrency",
30
+ { threads: threads, entries: entries_count, strategy: :rtree, operation: :find_covering },
31
+ stats: stats
32
+ )
@@ -40,7 +40,7 @@ end
40
40
 
41
41
  TGGeometryBench.say_header("feature_source")
42
42
 
43
- sizes = ENV["TGEOMETRY_BENCH_FULL"] == "1" ? [100, 1_000, 10_000, 50_000] : [100, 1_000]
43
+ sizes = TGGeometryBench.full? ? [100, 1_000, 10_000, 50_000] : [100, 1_000, 10_000]
44
44
 
45
45
  sizes.each do |size|
46
46
  json = FeatureSourceBenchData.feature_collection(size)
@@ -54,38 +54,64 @@ sizes.each do |size|
54
54
  direct_index = nil
55
55
  roundtrip_index = nil
56
56
 
57
- ruby_time = Benchmark.realtime do
58
- ruby_entries = FeatureSourceBenchData.ruby_json_parse_entries(path)
57
+ ruby = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
58
+ iterations.times { ruby_entries = FeatureSourceBenchData.ruby_json_parse_entries(path) }
59
59
  end
60
60
 
61
- read_entries_time = Benchmark.realtime do
62
- feature_entries = TG::Geometry::FeatureSource.read_entries_file(path, id: ["properties", "@id"])
61
+ read_entries = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
62
+ iterations.times do
63
+ feature_entries = TG::Geometry::FeatureSource.read_entries_file(path, id: ["properties", "@id"])
64
+ end
63
65
  end
64
66
 
65
- read_features_time = Benchmark.realtime do
66
- feature_rows = TG::Geometry::FeatureSource.read_features_file(path, id: ["properties", "@id"])
67
+ read_features = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
68
+ iterations.times do
69
+ feature_rows = TG::Geometry::FeatureSource.read_features_file(path, id: ["properties", "@id"])
70
+ end
67
71
  end
68
72
 
69
- direct_index_time = Benchmark.realtime do
70
- direct_index = TG::Geometry::FeatureSource.build_index_file(path, id: ["properties", "@id"], strategy: :rtree)
73
+ direct_index_stat = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
74
+ iterations.times do
75
+ direct_index = TG::Geometry::FeatureSource.build_index_file(path, id: ["properties", "@id"], strategy: :rtree)
76
+ end
71
77
  end
72
78
 
73
- roundtrip_index_time = Benchmark.realtime do
74
- roundtrip_index = TG::Geometry::Index.build(feature_entries, via: :geojson, strategy: :rtree)
79
+ # Ensure the roundtrip benchmark does not accidentally include read_entries.
80
+ feature_entries ||= TG::Geometry::FeatureSource.read_entries_file(path, id: ["properties", "@id"])
81
+
82
+ roundtrip_index_stat = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.30) do |iterations|
83
+ iterations.times do
84
+ roundtrip_index = TG::Geometry::Index.build(feature_entries, via: :geojson, strategy: :rtree)
85
+ end
75
86
  end
76
87
 
77
- puts "n=#{size} ruby_json_parse_sec=%.6f read_entries_sec=%.6f read_features_sec=%.6f build_index_direct_sec=%.6f build_index_from_entries_sec=%.6f entries=%d features=%d direct_size=%d roundtrip_size=%d rss_kb=%d" % [
78
- ruby_time,
79
- read_entries_time,
80
- read_features_time,
81
- direct_index_time,
82
- roundtrip_index_time,
83
- ruby_entries.length,
84
- feature_rows.length,
85
- direct_index.size,
86
- roundtrip_index.size,
87
- TGGeometryBench.rss_kb
88
- ]
88
+ read_plus_roundtrip_sec = read_entries[:median_sec] + roundtrip_index_stat[:median_sec]
89
+ read_plus_roundtrip_ops_per_sec = read_plus_roundtrip_sec.positive? ? read_entries[:iterations] / read_plus_roundtrip_sec : 0.0
90
+
91
+ common = {
92
+ n: size,
93
+ json_bytes: json.bytesize,
94
+ ruby_entries: ruby_entries.length,
95
+ feature_entries: feature_entries.length,
96
+ feature_rows: feature_rows.length,
97
+ direct_size: direct_index.size,
98
+ roundtrip_size: roundtrip_index.size,
99
+ rss_kb: TGGeometryBench.rss_kb
100
+ }
101
+
102
+ TGGeometryBench.report("feature_source", common.merge(mode: :ruby_json_parse_entries), stats: ruby)
103
+ TGGeometryBench.report("feature_source", common.merge(mode: :read_entries_file), stats: read_entries)
104
+ TGGeometryBench.report("feature_source", common.merge(mode: :read_features_file), stats: read_features)
105
+ TGGeometryBench.report("feature_source", common.merge(mode: :build_index_direct), stats: direct_index_stat)
106
+ TGGeometryBench.report("feature_source", common.merge(mode: :build_index_from_prepared_entries), stats: roundtrip_index_stat)
107
+ TGGeometryBench.report(
108
+ "feature_source_end_to_end",
109
+ common.merge(
110
+ mode: :read_entries_plus_build_index,
111
+ median_sec: read_plus_roundtrip_sec,
112
+ ops_per_sec: read_plus_roundtrip_ops_per_sec
113
+ )
114
+ )
89
115
  ensure
90
116
  file.close!
91
117
  end
@@ -3,25 +3,67 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("flat_vs_rtree")
6
- iterations = TGGeometryBench.iterations(1_000)
7
6
 
8
7
  %i[compact long_thin overlapping].each do |kind|
9
8
  TGGeometryBench.sizes.each do |size|
10
9
  entries = TGGeometryBench.entries_for(kind, size)
11
10
 
12
- build_flat = Benchmark.realtime { @flat = TGGeometryBench.build_index(entries, strategy: :flat) }
13
- build_rtree = Benchmark.realtime { @rtree = TGGeometryBench.build_index(entries, strategy: :rtree) }
11
+ flat = nil
12
+ rtree = nil
14
13
 
15
- TGGeometryBench.points_for(kind).each do |lon, lat|
16
- flat_time = Benchmark.realtime { iterations.times { @flat.find_covering(lon, lat) } }
17
- rtree_time = Benchmark.realtime { iterations.times { @rtree.find_covering(lon, lat) } }
18
- puts "kind=#{kind} n=#{size} query=point lon=#{lon} lat=#{lat} flat_sec=%.6f rtree_sec=%.6f flat_qps=%.2f rtree_qps=%.2f build_flat=%.6f build_rtree=%.6f" % [flat_time, rtree_time, iterations / flat_time, iterations / rtree_time, build_flat, build_rtree]
14
+ flat_build = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.25) do |iterations|
15
+ iterations.times { flat = TGGeometryBench.build_index(entries, strategy: :flat) }
19
16
  end
17
+ rtree_build = TGGeometryBench.measure_counted(initial_iterations: 1, min_seconds: 0.25) do |iterations|
18
+ iterations.times { rtree = TGGeometryBench.build_index(entries, strategy: :rtree) }
19
+ end
20
+
21
+ # Rebuild once outside the build benchmark so query numbers do not depend on
22
+ # the last object produced by calibration/repeats.
23
+ flat = TGGeometryBench.build_index(entries, strategy: :flat)
24
+ rtree = TGGeometryBench.build_index(entries, strategy: :rtree)
25
+
26
+ TGGeometryBench.report("flat_vs_rtree_build", { kind: kind, n: size, strategy: :flat }, stats: flat_build)
27
+ TGGeometryBench.report("flat_vs_rtree_build", { kind: kind, n: size, strategy: :rtree }, stats: rtree_build)
28
+
29
+ TGGeometryBench.points_for(kind).each_with_index do |(lon, lat), point_index|
30
+ flat_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(2_000)) do |iterations|
31
+ iterations.times { flat.find_covering(lon, lat) }
32
+ end
33
+ rtree_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(2_000)) do |iterations|
34
+ iterations.times { rtree.find_covering(lon, lat) }
35
+ end
36
+
37
+ TGGeometryBench.report(
38
+ "flat_vs_rtree_point",
39
+ { kind: kind, n: size, strategy: :flat, point_index: point_index, lon: lon, lat: lat },
40
+ stats: flat_stats
41
+ )
42
+ TGGeometryBench.report(
43
+ "flat_vs_rtree_point",
44
+ { kind: kind, n: size, strategy: :rtree, point_index: point_index, lon: lon, lat: lat },
45
+ stats: rtree_stats
46
+ )
47
+ end
48
+
49
+ TGGeometryBench.rects_for(kind).each_with_index do |rect, rect_index|
50
+ flat_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(200)) do |iterations|
51
+ iterations.times { flat.intersecting_rect(*rect) }
52
+ end
53
+ rtree_stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(200)) do |iterations|
54
+ iterations.times { rtree.intersecting_rect(*rect) }
55
+ end
20
56
 
21
- TGGeometryBench.rects_for(kind).each do |rect|
22
- flat_time = Benchmark.realtime { iterations.times { @flat.intersecting_rect(*rect) } }
23
- rtree_time = Benchmark.realtime { iterations.times { @rtree.intersecting_rect(*rect) } }
24
- puts "kind=#{kind} n=#{size} query=rect rect=#{rect.join(',')} flat_sec=%.6f rtree_sec=%.6f flat_qps=%.2f rtree_qps=%.2f" % [flat_time, rtree_time, iterations / flat_time, iterations / rtree_time]
57
+ TGGeometryBench.report(
58
+ "flat_vs_rtree_rect",
59
+ { kind: kind, n: size, strategy: :flat, rect_index: rect_index, rect: rect },
60
+ stats: flat_stats
61
+ )
62
+ TGGeometryBench.report(
63
+ "flat_vs_rtree_rect",
64
+ { kind: kind, n: size, strategy: :rtree, rect_index: rect_index, rect: rect },
65
+ stats: rtree_stats
66
+ )
25
67
  end
26
68
  end
27
69
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "benchmark"
4
3
  require_relative "_support"
5
4
 
6
- COUNTS = [1_000, 10_000].freeze
7
- STRATEGIES = %i[flat rtree].freeze
5
+ TGGeometryBench.say_header("geom_query")
8
6
 
7
+ COUNTS = (TGGeometryBench.full? ? [1_000, 10_000, 50_000] : [1_000, 10_000]).freeze
8
+ STRATEGIES = %i[flat rtree].freeze
9
9
 
10
10
  def box(x, y, size = 1.0)
11
11
  TG::Geometry.polygon([[x, y], [x + size, y], [x + size, y + size], [x, y + size], [x, y]])
@@ -18,16 +18,26 @@ COUNTS.each do |count|
18
18
  [i, box(x, y)]
19
19
  end
20
20
 
21
- small_query = box(10.5, 10.5, 2.0)
22
- large_query = box(0.5, 0.5, 120.0)
21
+ queries = {
22
+ small: box(10.5, 10.5, 2.0),
23
+ large: box(0.5, 0.5, 120.0),
24
+ miss: box(-1_000.0, -1_000.0, 1.0)
25
+ }
23
26
 
24
- puts "\n#{count} polygons"
25
27
  STRATEGIES.each do |strategy|
26
28
  index = TG::Geometry::Index.build(entries, via: :geom, strategy: strategy)
27
29
 
28
- Benchmark.bm(32) do |x|
29
- x.report("#{strategy} small intersecting_geom_ids") { 10_000.times { index.intersecting_geom_ids(small_query) } }
30
- x.report("#{strategy} large intersecting_geom_ids") { 1_000.times { index.intersecting_geom_ids(large_query) } }
30
+ queries.each do |query_name, query_geom|
31
+ initial = query_name == :large ? 100 : 1_000
32
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(initial)) do |iterations|
33
+ iterations.times { index.intersecting_geom_ids(query_geom) }
34
+ end
35
+
36
+ TGGeometryBench.report(
37
+ "geom_query",
38
+ { n: count, strategy: strategy, query: query_name, method: :intersecting_geom_ids },
39
+ stats: stats
40
+ )
31
41
  end
32
42
  end
33
43
  end
@@ -3,12 +3,9 @@
3
3
  require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("gvl_threshold")
6
- puts "First release intentionally performs parse/write/batch/query with GVL held."
7
- puts "This harness records baseline parse wall time only; it does not enable no-GVL execution."
6
+ TGGeometryBench.report("gvl_threshold_note", note: "baseline_only_gvl_is_held_no_public_release_gvl_knob")
8
7
 
9
- # Build one valid WKT polygon close to the requested byte size. The previous
10
- # implementation accidentally benchmarked the same tiny 39-byte polygon for all
11
- # target sizes because it used Array(...).first.
8
+ # Build one valid WKT polygon close to the requested byte size.
12
9
  def polygon_wkt_at_least(target_bytes)
13
10
  points_count = [4, target_bytes / 38].max
14
11
 
@@ -27,15 +24,17 @@ def polygon_wkt_at_least(target_bytes)
27
24
  end
28
25
  end
29
26
 
30
- sizes = [128, 1_024, 16_384, 262_144]
31
- iterations = TGGeometryBench.iterations(2_000)
32
-
33
- sizes.each do |target_bytes|
27
+ [128, 1_024, 16_384, 262_144].each do |target_bytes|
34
28
  payload = polygon_wkt_at_least(target_bytes)
29
+ initial = target_bytes >= 262_144 ? 10 : 500
35
30
 
36
- time = Benchmark.realtime do
31
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(initial)) do |iterations|
37
32
  iterations.times { TG::Geometry.parse_wkt(payload) }
38
33
  end
39
34
 
40
- puts "target_bytes=#{target_bytes} payload_bytes=#{payload.bytesize} iterations=#{iterations} seconds=%.6f ops_per_sec=%.2f" % [time, iterations / time]
35
+ TGGeometryBench.report(
36
+ "gvl_threshold_parse_wkt",
37
+ { target_bytes: target_bytes, payload_bytes: payload.bytesize },
38
+ stats: stats
39
+ )
41
40
  end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "benchmark"
4
3
  require_relative "_support"
5
4
 
5
+ TGGeometryBench.say_header("nearest_segment")
6
+
6
7
  [100, 1_000, 10_000].each do |count|
7
8
  points = count.times.map do |i|
8
9
  angle = 2.0 * Math::PI * i / count
@@ -11,10 +12,13 @@ require_relative "_support"
11
12
  points << points.first
12
13
  ring = TG::Geometry.polygon(points).polygon.exterior_ring
13
14
 
14
- puts "\nring segments: #{ring.num_segments}"
15
- Benchmark.bm(28) do |x|
16
- x.report("1M nearest_segment calls") do
17
- 1_000_000.times { ring.nearest_segment(0.25, 0.33) }
18
- end
15
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(10_000)) do |iterations|
16
+ iterations.times { ring.nearest_segment(0.25, 0.33) }
19
17
  end
18
+
19
+ TGGeometryBench.report(
20
+ "nearest_segment",
21
+ { segments: ring.num_segments, query: "0.25:0.33" },
22
+ stats: stats
23
+ )
20
24
  end
@@ -8,10 +8,23 @@ TGGeometryBench.say_header("objectspace_memsize")
8
8
  %i[compact long_thin overlapping].each do |kind|
9
9
  TGGeometryBench.sizes.each do |size|
10
10
  entries = TGGeometryBench.entries_for(kind, size)
11
+ TGGeometryBench.gc_start
11
12
  flat = TGGeometryBench.build_index(entries, strategy: :flat)
13
+ TGGeometryBench.gc_start
12
14
  rtree = TGGeometryBench.build_index(entries, strategy: :rtree)
13
15
  geom = TG::Geometry.parse_geojson(entries.first.last)
14
16
 
15
- puts "kind=#{kind} n=#{size} geom_memsize=#{ObjectSpace.memsize_of(geom)} flat_memsize=#{ObjectSpace.memsize_of(flat)} rtree_memsize=#{ObjectSpace.memsize_of(rtree)}"
17
+ TGGeometryBench.report(
18
+ "objectspace_memsize",
19
+ {
20
+ kind: kind,
21
+ n: size,
22
+ geom_memsize: ObjectSpace.memsize_of(geom),
23
+ flat_memsize: ObjectSpace.memsize_of(flat),
24
+ rtree_memsize: ObjectSpace.memsize_of(rtree),
25
+ rtree_over_flat: ObjectSpace.memsize_of(rtree) - ObjectSpace.memsize_of(flat),
26
+ rss_kb: TGGeometryBench.rss_kb
27
+ }
28
+ )
16
29
  end
17
30
  end
@@ -21,18 +21,21 @@ geojson = '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]
21
21
  wkb = TG::Geometry.parse_wkt(small).to_wkb
22
22
 
23
23
  cases = {
24
- "wkt:small" => [small, :wkt],
25
- "geojson:small" => [geojson, :geojson],
26
- "wkb:small" => [wkb, :wkb],
27
- "wkt:medium" => [medium, :wkt],
28
- "wkt:large" => [large, :wkt]
24
+ "wkt_small" => [small, :wkt, 5_000],
25
+ "geojson_small" => [geojson, :geojson, 5_000],
26
+ "wkb_small" => [wkb, :wkb, 5_000],
27
+ "wkt_medium" => [medium, :wkt, 500],
28
+ "wkt_large" => [large, :wkt, 50]
29
29
  }
30
30
 
31
- iterations = TGGeometryBench.iterations(10_000)
32
-
33
- cases.each do |name, (payload, format)|
34
- time = Benchmark.realtime do
31
+ cases.each do |name, (payload, format, initial)|
32
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(initial)) do |iterations|
35
33
  iterations.times { TG::Geometry.parse(payload, format: format) }
36
34
  end
37
- puts "%s iterations=%d seconds=%.6f ops_per_sec=%.2f" % [name, iterations, time, iterations / time]
35
+
36
+ TGGeometryBench.report(
37
+ "parse_throughput",
38
+ { case: name, format: format, payload_bytes: payload.bytesize },
39
+ stats: stats
40
+ )
38
41
  end
@@ -4,27 +4,28 @@ require_relative "_support"
4
4
 
5
5
  TGGeometryBench.say_header("rss_stability")
6
6
 
7
- total_queries = Integer(ENV.fetch("TGEOMETRY_RSS_QUERIES", "10_000_000"))
8
- rebuilds = Integer(ENV.fetch("TGEOMETRY_RSS_REBUILDS", "100"))
9
- entries_count = Integer(ENV.fetch("TGEOMETRY_RSS_ENTRIES", "1_000"))
10
- max_drift_kb = Integer(ENV.fetch("TGEOMETRY_RSS_MAX_DRIFT_KB", "51_200"))
7
+ total_queries = TGGeometryBench.env_integer("TGEOMETRY_RSS_QUERIES", 10_000_000, min: 1)
8
+ rebuilds = TGGeometryBench.env_integer("TGEOMETRY_RSS_REBUILDS", 100, min: 1)
9
+ entries_count = TGGeometryBench.env_integer("TGEOMETRY_RSS_ENTRIES", 1_000, min: 1)
10
+ max_drift_kb = TGGeometryBench.env_integer("TGEOMETRY_RSS_MAX_DRIFT_KB", 51_200, min: 0)
11
11
 
12
12
  queries_per_rebuild = (total_queries / rebuilds).clamp(1, total_queries)
13
13
  entries = TGGeometryBench.compact_entries(entries_count)
14
14
  points = TGGeometryBench.points_for(:compact)
15
15
  packed_batch = TGGeometryBench.packed_points(points * 10)
16
16
 
17
- GC.start
18
- GC.start
17
+ TGGeometryBench.gc_start
18
+ TGGeometryBench.gc_start
19
19
  start_rss = TGGeometryBench.rss_kb
20
20
  peak_rss = start_rss
21
21
  samples = []
22
22
 
23
23
  queries_executed = 0
24
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ started_at = TGGeometryBench.monotonic
25
25
 
26
26
  rebuilds.times do |cycle|
27
- index = TGGeometryBench.build_index(entries, strategy: (cycle.even? ? :flat : :rtree))
27
+ strategy = cycle.even? ? :flat : :rtree
28
+ index = TGGeometryBench.build_index(entries, strategy: strategy)
28
29
 
29
30
  queries_per_rebuild.times do |q|
30
31
  lon, lat = points[(q + cycle) % points.length]
@@ -39,29 +40,39 @@ rebuilds.times do |cycle|
39
40
 
40
41
  index = nil
41
42
 
42
- if (cycle % 10).zero?
43
- GC.start
44
- rss = TGGeometryBench.rss_kb
45
- peak_rss = [peak_rss, rss].max
46
- samples << [cycle, queries_executed, rss]
47
- end
43
+ next unless (cycle % 10).zero?
44
+
45
+ TGGeometryBench.gc_start
46
+ rss = TGGeometryBench.rss_kb
47
+ peak_rss = [peak_rss, rss].max
48
+ samples << [cycle, queries_executed, rss]
48
49
  end
49
50
 
50
- GC.start
51
- GC.start
51
+ TGGeometryBench.gc_start
52
+ TGGeometryBench.gc_start
52
53
  finish_rss = TGGeometryBench.rss_kb
53
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
54
+ elapsed = TGGeometryBench.monotonic - started_at
54
55
  drift_kb = finish_rss - start_rss
55
56
 
56
- puts format(
57
- "queries=%d rebuilds=%d entries=%d elapsed_s=%.2f start_rss_kb=%d peak_rss_kb=%d finish_rss_kb=%d drift_kb=%d",
58
- queries_executed, rebuilds, entries_count, elapsed,
59
- start_rss, peak_rss, finish_rss, drift_kb
57
+ TGGeometryBench.report(
58
+ "rss_stability",
59
+ {
60
+ queries: queries_executed,
61
+ rebuilds: rebuilds,
62
+ entries: entries_count,
63
+ elapsed_sec: elapsed,
64
+ qps: queries_executed / elapsed,
65
+ start_rss_kb: start_rss,
66
+ peak_rss_kb: peak_rss,
67
+ finish_rss_kb: finish_rss,
68
+ drift_kb: drift_kb,
69
+ max_drift_kb: max_drift_kb,
70
+ sample_count: samples.length
71
+ }
60
72
  )
61
73
 
62
- if samples.length > 1
63
- puts "samples (cycle, queries, rss_kb):"
64
- samples.each { |row| puts " #{row.inspect}" }
74
+ samples.each do |cycle, queries, rss|
75
+ TGGeometryBench.report("rss_stability_sample", cycle: cycle, queries: queries, rss_kb: rss)
65
76
  end
66
77
 
67
78
  if max_drift_kb.positive? && drift_kb > max_drift_kb
@@ -3127,6 +3127,43 @@ static bool rtree_mark_candidate_iter(const double *min, const double *max, cons
3127
3127
  return true;
3128
3128
  }
3129
3129
 
3130
+ typedef struct {
3131
+ const tg_index_t *idx;
3132
+ const struct tg_geom *point;
3133
+ const tg_index_entry_t *best;
3134
+ long best_ordinal;
3135
+ } tg_rtree_find_args_t;
3136
+
3137
+ static bool rtree_find_covering_iter(const double *min, const double *max, const void *data,
3138
+ void *udata) {
3139
+ const tg_index_entry_t *entry = (const tg_index_entry_t *)data;
3140
+ tg_rtree_find_args_t *args = (tg_rtree_find_args_t *)udata;
3141
+
3142
+ (void)min;
3143
+ (void)max;
3144
+
3145
+ if (!entry) {
3146
+ return true;
3147
+ }
3148
+
3149
+ if (entry->ordinal < 0 || entry->ordinal >= args->idx->len) {
3150
+ return true;
3151
+ }
3152
+
3153
+ if (args->best && entry->ordinal >= args->best_ordinal) {
3154
+ return true;
3155
+ }
3156
+ if (index_entry_matches_point(args->idx, entry, args->point)) {
3157
+ args->best = entry;
3158
+ args->best_ordinal = entry->ordinal;
3159
+
3160
+ if (entry->ordinal == 0) {
3161
+ return false;
3162
+ }
3163
+ }
3164
+ return true;
3165
+ }
3166
+
3130
3167
  static unsigned char *rtree_candidate_marks(tg_index_t *idx, struct tg_rect query_rect) {
3131
3168
  unsigned char *marks;
3132
3169
  tg_rtree_mark_args_t args;
@@ -3195,33 +3232,31 @@ static VALUE index_find_covering_value(tg_index_t *idx, double lon, double lat)
3195
3232
 
3196
3233
  if (idx->strategy == TG_GEOMETRY_INDEX_STRATEGY_RTREE) {
3197
3234
  struct tg_rect point_rect = tg_rect_from_xyxy(lon, lat, lon, lat);
3198
- unsigned char *candidates = rtree_candidate_marks(idx, point_rect);
3235
+ tg_rtree_find_args_t args;
3236
+ double min[2];
3237
+ double max[2];
3199
3238
 
3200
3239
  point = tg_query_point_new(lon, lat);
3201
- if (!point) {
3202
- free(candidates);
3203
- rb_raise(rb_eNoMemError, "TG point geometry allocation failed");
3204
- }
3205
- if (tg_geom_error(point)) {
3240
+ tg_query_point_raise_if_invalid(point);
3241
+
3242
+ if (!idx->rtree) {
3206
3243
  tg_geom_free(point);
3207
- free(candidates);
3208
- rb_raise(eTGGeometryError, "TG point geometry error");
3244
+ return Qnil;
3209
3245
  }
3210
3246
 
3211
- for (long i = 0; i < idx->len; i++) {
3212
- tg_index_entry_t *entry = &idx->entries[i];
3247
+ args.idx = idx;
3248
+ args.point = point;
3249
+ args.best = NULL;
3250
+ args.best_ordinal = 0;
3213
3251
 
3214
- if (!candidates[i]) {
3215
- continue;
3216
- }
3217
- if (index_entry_matches_point(idx, entry, point)) {
3218
- result = entry->id;
3219
- break;
3220
- }
3252
+ tg_rect_to_arrays(point_rect, min, max);
3253
+ rtree_search(idx->rtree, min, max, rtree_find_covering_iter, &args);
3254
+
3255
+ if (args.best) {
3256
+ result = args.best->id;
3221
3257
  }
3222
3258
 
3223
3259
  tg_geom_free(point);
3224
- free(candidates);
3225
3260
  return result;
3226
3261
  }
3227
3262
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module TG
4
4
  module Geometry
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tg_geometry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haydarov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-05-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake