tg_geometry 0.3.0 → 0.3.2

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: 7cb54eb85cd48036c2f32eb5f95fa3d19cbf994d375bc102b05f2d45dfb1f8ec
4
+ data.tar.gz: c5ff1c066708d8826c125185f34a346e1f1e2bf66f65fb1b1eb13e75bf955aad
5
5
  SHA512:
6
- metadata.gz: 87200faec4b6d0442eab7df12f768807193aed725bb75b25502a64cc5b2e24e97f2f9d98cf9254c54e3f62d9b0481f39dc2287818f69a740e9728a33e97d9f0b
7
- data.tar.gz: 3e14f4563facef36fc109e1c8006b707502bb384f67c1c27da05faa7dcec7b7a7970d82a22d39ac99c00595c74944c7a6467fa81a5cdd1d14129df70f4d3675a
6
+ metadata.gz: 981e37eb58fac8eaa20c3fa9604b751c263330e3f3dc2e8614cb1c91e36eb76d31431b4a13b3cbcd44475323d5ca76ec3e58c013d8c16de9d28e2ec890704962
7
+ data.tar.gz: 05db4ca563cd1c22e13d5c27e440845452397f50516cf01dde8675032fb3e12a527ba0fe52ccd32bc1fe313b3f8d083f46c17a54e4e8c316b6e8ce9766c278d5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 - unreleased
4
+
5
+ ### Added
6
+
7
+ - Added explicit point-to-geometry distance APIs on `TG::Geometry::Geom`:
8
+ `distance_to_lnglat_meters`, `boundary_distance_to_lnglat_meters`,
9
+ `nearest_point_lnglat`, `distance_to_xy`, `boundary_distance_to_xy`, and
10
+ `nearest_point_xy`.
11
+ - Added `TG::Geometry::Index#within_distance_lnglat_meters`,
12
+ `#within_distance_ids_lnglat_meters`, `#within_distance_xy`, and
13
+ `#within_distance_ids_xy` using bbox prefilter plus exact distance filtering.
14
+ - Added distance specs and benchmarks for point-to-geometry and radius queries, including
15
+ tiny-index/full-extent radius cases and distance receiver memory-accounting checks.
16
+
17
+ ### Clarified
18
+
19
+ - lng/lat distance is approximate local equirectangular meters for local geofencing,
20
+ not geodesic distance; GeoJSON segments remain straight coordinate segments.
21
+ - The lng/lat metric is raw planar and does not wrap or split at the antimeridian.
22
+ - Distance query methods keep the GVL and do not claim Falcon/Async/Ractor behavior.
23
+
24
+ ## 0.3.1 - 28.05.2026
25
+
26
+ ### Changed
27
+ - Optimized R-tree-backed point lookup by removing per-query candidate bitmap
28
+ allocation and full entry scan from Index#find_covering. The new path tracks
29
+ the lowest matching ordinal directly in the R-tree callback, preserving
30
+ insertion-order semantics while significantly improving rtree point queries
31
+ and packed batch point lookup.
32
+
3
33
  ## 0.3.0 - 27.05.2026
4
34
 
5
35
  ### Added
data/README.md CHANGED
@@ -188,6 +188,46 @@ Predicate direction is explicit:
188
188
 
189
189
  Results are ids only and preserve insertion order. Duplicate ids remain possible if duplicate ids were inserted.
190
190
 
191
+
192
+
193
+ ## Point-to-geometry distance
194
+
195
+ `TG::Geometry::Geom` exposes explicit point distance APIs. Units are encoded in the method names; there is no `metric:` option and no automatic lng/lat-vs-XY detection.
196
+
197
+ ```ruby
198
+ zone.distance_to_lnglat_meters(lng, lat) # => Float, approximate meters
199
+ zone.boundary_distance_to_lnglat_meters(lng, lat) # => Float, approximate meters
200
+ zone.nearest_point_lnglat(lng, lat) # => [lng, lat]
201
+
202
+ zone.distance_to_xy(x, y) # => Float in input coordinate units
203
+ zone.boundary_distance_to_xy(x, y) # => Float in input coordinate units
204
+ zone.nearest_point_xy(x, y) # => [x, y]
205
+ ```
206
+
207
+ For areal geometries (`Polygon`, `MultiPolygon`, and areal collection members), `distance_to_*` returns `0.0` for points inside the covered area or on the boundary. Holes are excluded, so a point inside a hole measures to the nearest hole ring. `boundary_distance_to_*` always measures to the nearest boundary/ring/segment; for an interior point it does not return `0.0` merely because the point is covered. `nearest_point_*` returns the nearest boundary point for areal geometries, including interior queries.
208
+
209
+ Distance methods for lng/lat geometries return approximate meters using a per-query local equirectangular frame. Segments are GeoJSON straight coordinate segments, not great-circle arcs. This is geofencing-grade metric distance, not geodesy. Accuracy is intended for local geofencing and degrades with latitude separation.
210
+
211
+ The lng/lat metric is raw planar lng/lat and does not wrap longitude at `+/-180`. A point at `179.9` and a point at `-179.9` are treated as about `360` degrees apart, matching the gem's planar `covers_xy?` model. Data that crosses the antimeridian should be cut at `+/-180` before import.
212
+
213
+ `Index` supports radius filters with an rtree bbox prefilter followed by exact distance filtering:
214
+
215
+ ```ruby
216
+ index.within_distance_lnglat_meters(lng, lat, radius_m, sort: false)
217
+ # => [[id, distance_m], ...]
218
+ index.within_distance_ids_lnglat_meters(lng, lat, radius_m)
219
+ # => [id, ...]
220
+
221
+ index.within_distance_xy(x, y, radius, sort: false)
222
+ # => [[id, distance], ...]
223
+ index.within_distance_ids_xy(x, y, radius)
224
+ # => [id, ...]
225
+ ```
226
+
227
+ `sort: true` sorts filtered `[id, distance]` pairs by ascending distance. The ids variants intentionally do not accept `sort:`. Index kNN / `nearest_ids` is not implemented.
228
+
229
+ Distance radius benchmarks compare `rtree prefilter + exact filter` against a brute-force full index scan. Any ratio from those benchmarks is a prefilter-vs-full-scan result, not a claim that the exact distance calculation itself is hundreds of times faster. The benchmark suite includes tiny-index/full-extent cases where the rtree prefilter may be neutral or slower.
230
+
191
231
  ## GeoJSON FeatureSource
192
232
 
193
233
  `TG::Geometry::FeatureSource` reads GeoJSON `FeatureCollection` sources without `JSON.parse` of the whole document into Ruby Hash/Array objects.
@@ -345,7 +385,7 @@ Not included:
345
385
  - Z/M variants of array constructors;
346
386
  - Public `release_gvl:` option.
347
387
 
348
- TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, perimeter, and nearest-segment distances are in input coordinate units, not meters.
388
+ TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, perimeter, and low-level nearest-segment distances are in input coordinate units, not meters. The explicit `*_lnglat_meters` point-to-geometry APIs are the exception: they return approximate local meters using the query-local frame documented above, not geodesic meters.
349
389
 
350
390
  ## Development
351
391
 
@@ -21,14 +21,454 @@ 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, :receiver
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
+ :before_memsize, :after_memsize, :delta_memsize, :allocated_objects
307
+ ].freeze
308
+
309
+ TABLE_INTERNAL_COLUMNS = [
310
+ :benchmark, :ruby, :platform, :full, :adaptive, :gc_disabled,
311
+ :repeats, :min_seconds, :warmup_seconds, :max_iterations,
312
+ :ops_per_iteration, :median_allocations,
313
+ :best_sec, :worst_sec
314
+ ].freeze
315
+
316
+ TABLE_HEADERS = {
317
+ kind: "kind",
318
+ n: "n",
319
+ strategy: "strategy",
320
+ mode: "mode",
321
+ query: "query",
322
+ method: "method",
323
+ case: "case",
324
+ format: "format",
325
+ library: "lib",
326
+ operation: "op",
327
+ point_index: "pt#",
328
+ rect_index: "rect#",
329
+ lon: "lon",
330
+ lat: "lat",
331
+ rect: "rect",
332
+ segments: "segments",
333
+ target_bytes: "target B",
334
+ payload_bytes: "payload B",
335
+ points_per_batch: "points/batch",
336
+ batches_per_sec: "batches/s",
337
+ ops_per_sec: "ops/s",
338
+ qps: "qps",
339
+ ns_per_op: "ns/op",
340
+ median_sec: "median s",
341
+ spread_pct: "spread %",
342
+ allocations_per_op: "alloc/op",
343
+ median_minor_gc: "minor GC",
344
+ median_major_gc: "major GC",
345
+ iterations: "iters",
346
+ operations: "ops",
347
+ threads: "threads",
348
+ entries: "entries",
349
+ rebuilds: "rebuilds",
350
+ queries: "queries",
351
+ cycle: "cycle",
352
+ receiver: "receiver",
353
+ rss_kb: "rss KB",
354
+ geom_memsize: "geom B",
355
+ flat_memsize: "flat B",
356
+ rtree_memsize: "rtree B",
357
+ rtree_over_flat: "rtree-flat B",
358
+ start_rss_kb: "start KB",
359
+ peak_rss_kb: "peak KB",
360
+ finish_rss_kb: "finish KB",
361
+ drift_kb: "drift KB",
362
+ max_drift_kb: "max drift KB",
363
+ elapsed_sec: "elapsed s",
364
+ sample_count: "samples",
365
+ before_memsize: "before B",
366
+ after_memsize: "after B",
367
+ delta_memsize: "delta B",
368
+ allocated_objects: "alloc objs"
369
+ }.freeze
370
+
371
+ def human_int(value)
372
+ Integer(value).to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1_').reverse
373
+ rescue StandardError
374
+ value.to_s
375
+ end
376
+
377
+ def human_rate(value)
378
+ v = Float(value)
379
+ return "0" if v.zero?
380
+
381
+ abs = v.abs
382
+ if abs >= 1_000_000
383
+ "%.2fM" % (v / 1_000_000.0)
384
+ elsif abs >= 1_000
385
+ "%.1fk" % (v / 1_000.0)
386
+ elsif abs >= 100
387
+ "%.1f" % v
388
+ else
389
+ "%.3f" % v
390
+ end
391
+ end
392
+
393
+ def format_table_cell(key, value)
394
+ return "" if value.nil?
395
+
396
+ case key
397
+ when :ops_per_sec, :batches_per_sec, :qps
398
+ human_rate(value)
399
+ when :ns_per_op
400
+ "%.1f" % Float(value)
401
+ when :median_sec, :elapsed_sec
402
+ "%.4f" % Float(value)
403
+ when :spread_pct
404
+ "%.2f" % Float(value)
405
+ when :allocations_per_op
406
+ v = Float(value)
407
+ v < 0.01 ? ("%.5f" % v) : ("%.3f" % v)
408
+ when :lon, :lat
409
+ "%.3f" % Float(value)
410
+ when :rect
411
+ Array(value).map { |v| (Float(v) % 1).zero? ? Integer(v).to_s : ("%.3f" % Float(v)) }.join(":")
412
+ when :n, :iterations, :operations, :entries, :rebuilds, :queries, :cycle,
413
+ :rss_kb, :geom_memsize, :flat_memsize, :rtree_memsize, :rtree_over_flat,
414
+ :start_rss_kb, :peak_rss_kb, :finish_rss_kb, :drift_kb, :max_drift_kb,
415
+ :target_bytes, :payload_bytes, :segments, :points_per_batch, :threads,
416
+ :sample_count, :median_minor_gc, :median_major_gc,
417
+ :before_memsize, :after_memsize, :delta_memsize, :allocated_objects
418
+ human_int(value)
419
+ when :full, :adaptive, :gc_disabled
420
+ value ? "yes" : "no"
421
+ else
422
+ value.to_s
423
+ end
424
+ end
425
+
426
+ def table_columns(rows)
427
+ keys = rows.flat_map(&:keys).uniq
428
+ context = TABLE_CONTEXT_COLUMNS.select { |key| keys.include?(key) }
429
+ metrics = TABLE_METRIC_COLUMNS.select { |key| keys.include?(key) }
430
+ extras = keys - context - metrics - TABLE_INTERNAL_COLUMNS
431
+ context + extras + metrics
432
+ end
433
+
434
+ def print_table(rows, columns)
435
+ headers = columns.map { |key| TABLE_HEADERS.fetch(key, key.to_s) }
436
+ body = rows.map do |row|
437
+ columns.map { |key| format_table_cell(key, row[key]) }
438
+ end
439
+ widths = headers.each_with_index.map do |header, index|
440
+ ([header.length] + body.map { |cells| cells[index].length }).max
441
+ end
442
+
443
+ separator = "+-#{widths.map { |w| "-" * w }.join("-+-")}-+"
444
+ puts separator
445
+ puts "| #{headers.each_with_index.map { |h, i| h.ljust(widths[i]) }.join(" | ")} |"
446
+ puts separator
447
+ body.each do |cells|
448
+ puts "| #{cells.each_with_index.map { |cell, i| cell.rjust(widths[i]) }.join(" | ")} |"
449
+ end
450
+ puts separator
451
+ end
452
+
453
+ def flush_reports
454
+ rows = report_rows
455
+ return if rows.empty?
456
+
457
+ rows.group_by { |row| row[:benchmark] }.each do |benchmark, group_rows|
458
+ notes, measured = group_rows.partition { |row| row.key?(:note) && row.keys.none? { |key| TABLE_METRIC_COLUMNS.include?(key) } }
459
+
460
+ notes.each do |row|
461
+ puts "\n-- #{benchmark} --"
462
+ puts row[:note]
463
+ end
464
+
465
+ next if measured.empty?
466
+
467
+ puts "\n-- #{benchmark} --"
468
+ print_table(measured, table_columns(measured))
469
+ end
470
+ ensure
471
+ rows&.clear
32
472
  end
33
473
 
34
474
  def box_wkt(min_x, min_y, max_x, max_y)
@@ -91,6 +531,11 @@ module TGGeometryBench
91
531
  end
92
532
  end
93
533
 
534
+ def repeated_points(kind, count)
535
+ seed = points_for(kind)
536
+ Array.new(count) { |i| seed[i % seed.length] }
537
+ end
538
+
94
539
  def packed_points(points)
95
540
  points.flatten.pack("d*")
96
541
  end
@@ -110,6 +555,13 @@ module TGGeometryBench
110
555
 
111
556
  def say_header(title)
112
557
  puts "\n== #{title} =="
113
- puts "ruby=#{RUBY_VERSION} platform=#{RUBY_PLATFORM} full=#{ENV['TGEOMETRY_BENCH_FULL'] == '1'}"
558
+ puts "ruby=#{RUBY_VERSION} platform=#{RUBY_PLATFORM} sizes=#{sizes.join(":")} repeats=#{repeats} " \
559
+ "min_seconds=#{format_table_cell(:median_sec, min_seconds)} warmup=#{format_table_cell(:median_sec, warmup_seconds)} " \
560
+ "adaptive=#{adaptive? ? "yes" : "no"} gc_disabled=#{disable_gc_during_timed? ? "yes" : "no"}"
561
+ puts "output=table; set TGEOMETRY_BENCH_FORMAT=kv for machine-readable key=value lines" if table_output? && !kv_output?
114
562
  end
115
563
  end
564
+
565
+ at_exit do
566
+ TGGeometryBench.flush_reports if TGGeometryBench.table_output?
567
+ 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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_support"
4
+ require "objspace"
5
+
6
+ TGGeometryBench.say_header("distance_memory_accounting")
7
+
8
+ geom = TG::Geometry.polygon([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]])
9
+ index = TG::Geometry::Index.build([[:zone, geom]], via: :geom, strategy: :rtree)
10
+
11
+ CASES = [
12
+ ["geom_distance_to_xy", "Geom", geom, -> { geom.distance_to_xy(5, 5) }],
13
+ ["geom_boundary_distance_to_xy", "Geom", geom, -> { geom.boundary_distance_to_xy(5, 5) }],
14
+ ["geom_nearest_point_xy", "Geom", geom, -> { geom.nearest_point_xy(5, 5) }],
15
+ ["geom_distance_to_lnglat_meters", "Geom", geom, -> { geom.distance_to_lnglat_meters(0.01, 0.01) }],
16
+ ["index_within_distance_xy", "Index", index, -> { index.within_distance_xy(5, 5, 10) }],
17
+ ["index_within_distance_lnglat_meters", "Index", index, -> { index.within_distance_lnglat_meters(0.01, 0.01, 2_000) }]
18
+ ].freeze
19
+
20
+ ITERATIONS = TGGeometryBench.env_integer("TGEOMETRY_DISTANCE_MEMORY_ITERATIONS", 10_000, min: 1)
21
+
22
+ CASES.each do |name, receiver_name, receiver, callable|
23
+ TGGeometryBench.gc_start
24
+ before_memsize = ObjectSpace.memsize_of(receiver)
25
+ before_allocated = GC.stat[:total_allocated_objects]
26
+
27
+ ITERATIONS.times { callable.call }
28
+
29
+ TGGeometryBench.gc_start
30
+ after_memsize = ObjectSpace.memsize_of(receiver)
31
+ after_allocated = GC.stat[:total_allocated_objects]
32
+
33
+ TGGeometryBench.report(
34
+ "distance_memory_accounting",
35
+ {
36
+ case: name,
37
+ receiver: receiver_name,
38
+ operations: ITERATIONS,
39
+ before_memsize: before_memsize,
40
+ after_memsize: after_memsize,
41
+ delta_memsize: after_memsize - before_memsize,
42
+ allocated_objects: after_allocated - before_allocated,
43
+ note: "delta B must stay 0; Ruby result allocations are expected for arrays/pairs"
44
+ }
45
+ )
46
+ end