tg_geometry 0.2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +107 -14
  4. data/benchmark/_support.rb +450 -5
  5. data/benchmark/batch_packed_vs_loop.rb +38 -6
  6. data/benchmark/ewkb_roundtrip.rb +38 -0
  7. data/benchmark/falcon_concurrency.rb +17 -10
  8. data/benchmark/feature_source.rb +49 -23
  9. data/benchmark/flat_vs_rtree.rb +53 -11
  10. data/benchmark/geom_query.rb +43 -0
  11. data/benchmark/gvl_threshold.rb +10 -11
  12. data/benchmark/nearest_segment.rb +24 -0
  13. data/benchmark/objectspace_memsize.rb +14 -1
  14. data/benchmark/parse_throughput.rb +13 -10
  15. data/benchmark/rss_stability.rb +35 -24
  16. data/docs/GEOMETRY_QUERIES.md +23 -0
  17. data/docs/LIMITATIONS.md +12 -10
  18. data/docs/NEAREST_SEGMENT.md +17 -0
  19. data/docs/SRID_AND_EWKB.md +23 -0
  20. data/ext/tg_geometry/tg_geometry_ext.c +1234 -27
  21. data/lib/tg/geometry/active_record_source.rb +16 -34
  22. data/lib/tg/geometry/active_record_type.rb +61 -0
  23. data/lib/tg/geometry/registry.rb +17 -68
  24. data/lib/tg/geometry/version.rb +1 -1
  25. data/lib/tg/geometry.rb +85 -0
  26. data/spec/active_record_type_spec.rb +45 -0
  27. data/spec/constructors_spec.rb +104 -0
  28. data/spec/fixtures/feature_source/invalid_geometry_middle.geojson +8 -0
  29. data/spec/fixtures/feature_source/malformed_json.geojson +1 -0
  30. data/spec/fixtures/feature_source/mixed_geometry_types.geojson +8 -0
  31. data/spec/fixtures/feature_source/osm_like_feature_collection.geojson +10 -0
  32. data/spec/fixtures/feature_source/properties_null_missing.geojson +7 -0
  33. data/spec/fixtures/feature_source/simple_feature_collection.geojson +15 -0
  34. data/spec/fixtures/postgis/README.md +16 -0
  35. data/spec/fixtures/postgis/boundary_point_cases.geojson +83 -0
  36. data/spec/fixtures/postgis/multipolygon_large.ewkb +0 -0
  37. data/spec/fixtures/postgis/point_4326.ewkb +0 -0
  38. data/spec/fixtures/postgis/polygon_3857.ewkb +0 -0
  39. data/spec/fixtures/postgis/polygon_4326_simple.ewkb +0 -0
  40. data/spec/fixtures/postgis/polygon_4326_with_hole.ewkb +0 -0
  41. data/spec/index_geom_query_spec.rb +68 -0
  42. data/spec/keyword_validation_spec.rb +31 -0
  43. data/spec/nearest_segment_spec.rb +62 -0
  44. data/spec/postgis_fixtures_spec.rb +68 -0
  45. data/spec/srid_spec.rb +43 -0
  46. data/spec/to_ewkb_spec.rb +37 -0
  47. metadata +50 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3b2fd9ecb971425116e95aed147c0467eba4105b151ff15ba128ef35b10ad6a
4
- data.tar.gz: e87484d1ab62a0c21b19c79603ed7da3f0017962543c32b5cd5095cc353a044e
3
+ metadata.gz: cde617c534a8a3d9702b3fe212d46f966da46224499bb4fcb97bf40865590be8
4
+ data.tar.gz: 7e1767d31814249ef695ceae1965d6c5aaf9065ff7595309e95698abc053abbc
5
5
  SHA512:
6
- metadata.gz: e82e6a1729a0e076fdd16e6c378b3e363a744fddb0b41216d1371a4a7e320234772f829cb38e024b5b397bca630dcb787ae9d31e0cc114e84836ae7cb0bb904e
7
- data.tar.gz: '0529e7db75d831d0fa39877ca0ddd4f43d9138c985471f2827bbd92abefc9fec07ad9bba881ec9ba1c157f14a8c5c99be229d2c0fa9c08db73904620e043a029'
6
+ metadata.gz: 2cf6070d21ca5342425600e436523aae7215a2792d2d8392662a6c5257ba4e178301ff2a93c5ebb847fc7606d7b1aad60b9ab1fcd8e21b67f06d5f72f8c3cfa1
7
+ data.tar.gz: 99a9f9c086c6a2da9add012f57a99de800f67846007503ddf6c5d63ec25ac2a6d62cfe5b854fe1d44976ff0bddcbce04b8831db631dd8d174602c64914190e30
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
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
+
12
+ ## 0.3.0 - 27.05.2026
13
+
14
+ ### Added
15
+
16
+ - `TG::Geometry.line_string`, `TG::Geometry.polygon`, and `TG::Geometry.multi_polygon` constructors from Ruby arrays.
17
+ - `TG::Geometry::Geom#srid` with automatic EWKB SRID extraction in `parse_wkb` and `parse_hex`.
18
+ - `TG::Geometry::Geom#to_ewkb` writer with explicit `srid:` override.
19
+ - `TG::Geometry::Index#intersecting_geom_ids`, `#covering_geom_ids`, and `#containing_geom_ids`.
20
+ - `TG::Geometry::Line#nearest_segment` and `TG::Geometry::Ring#nearest_segment` with `TG::Geometry::NearestSegment` result objects.
21
+ - `TG::Geometry::ActiveRecordType` read-only optional require.
22
+ - PostGIS/EWKB fixture suite in `spec/fixtures/postgis/`.
23
+
24
+ ### Fixed
25
+
26
+ - Reject unsupported/unknown keywords on v0.3.0 APIs instead of silently ignoring them, including non-goals such as `release_gvl:`, `parse_wkb(srid:)`, and `parse_wkb(ewkb:)`.
27
+ - Harden constructor cleanup paths so intermediate C `tg_ring` / `tg_poly` objects are released when Ruby exceptions occur during nested polygon construction.
28
+ - Keep geometry-index query collection in a C-only status-returning phase before Ruby result materialization, preserving the intended no-GVL-safe internal shape.
29
+
30
+ ### Clarified
31
+
32
+ - `Index.build(predicate:)` affects only legacy point query methods: `find_covering`, `covering_ids(x, y)`, and `covering_ids_batch_packed`.
33
+
34
+ ### Not included
35
+
36
+ - Geodesic / Haversine distance.
37
+ - Projection / reprojection.
38
+ - Buffer / union / difference / convex hull.
39
+ - Index nearest_ids (KNN).
40
+ - GeoBIN bbox helpers.
41
+ - Streaming FeatureSource.
42
+ - Index serialization / mmap.
43
+ - Ractor support.
44
+ - Windows / JRuby.
45
+ - Write-side ActiveRecordType / AR scopes / migrations.
46
+ - Z/M variants of constructors.
47
+ - Public `release_gvl:` option.
48
+
3
49
  ## 0.1.0 - unreleased
4
50
 
5
51
  Initial public release candidate for `tg_geometry`.
data/README.md CHANGED
@@ -58,6 +58,44 @@ TG::Geometry.parse_geobin(bytes, index: :ystripes)
58
58
 
59
59
  `TG::Geometry::Geom` objects are immutable and cannot be manually allocated or manually freed from Ruby.
60
60
 
61
+ ## Constructors
62
+
63
+ Construct simple planar geometries directly from Ruby arrays without parsing strings or bytes:
64
+
65
+ ```ruby
66
+ line = TG::Geometry.line_string([[0.0, 0.0], [10.0, 0.0]], index: :natural, srid: 4326)
67
+ poly = TG::Geometry.polygon(
68
+ [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]],
69
+ holes: [[[2, 2], [4, 2], [4, 4], [2, 4], [2, 2]]],
70
+ index: :ystripes,
71
+ srid: 4326
72
+ )
73
+ mp = TG::Geometry.multi_polygon([
74
+ { exterior: [[0, 0], [1, 0], [1, 1], [0, 0]], holes: [] },
75
+ [[10, 10], [11, 10], [11, 11], [10, 10]]
76
+ ])
77
+ ```
78
+
79
+ Constructors are strict: rings must already be closed, invalid coordinates raise `TG::Geometry::ArgumentError`, and no winding/self-intersection fixes are performed. `srid:` is metadata on the wrapper; it does not alter coordinates or perform reprojection.
80
+
81
+ ## SRID and EWKB
82
+
83
+ `parse_wkb` and `parse_hex` preserve EWKB SRID metadata when the EWKB SRID flag is present:
84
+
85
+ ```ruby
86
+ geom = TG::Geometry.parse_wkb(postgis_bytea)
87
+ geom.srid # => 4326, 3857, 0, or nil for plain WKB
88
+ ```
89
+
90
+ `to_wkb` always writes plain WKB. Use `to_ewkb` when a PostGIS-compatible SRID-bearing payload is required:
91
+
92
+ ```ruby
93
+ ewkb = geom.to_ewkb # uses geom.srid
94
+ ewkb = geom.to_ewkb(srid: 4326) # explicit override
95
+ ```
96
+
97
+ SRID is metadata only. `tg_geometry` does not check SRID compatibility, transform coordinates, or calculate geodesic distances.
98
+
61
99
  ## Rect
62
100
 
63
101
  ```ruby
@@ -127,8 +165,29 @@ Accepted predicates:
127
165
  - `:covers` — default for geofencing; boundary points are included.
128
166
  - `:contains` — stricter containment semantics.
129
167
 
168
+ The `predicate:` option affects only the legacy point-based query methods (`find_covering`, `covering_ids(x, y)`, `covering_ids_batch_packed`). The geometry-based query methods (`intersecting_geom_ids`, `covering_geom_ids`, `containing_geom_ids`) use their own predicates based on the method name.
169
+
130
170
  `strategy: :auto` is intentionally not exposed. Choose the strategy explicitly and benchmark on your own data.
131
171
 
172
+ ## Geometry-based index queries
173
+
174
+ ```ruby
175
+ query = TG::Geometry.polygon([[1, 1], [2, 1], [2, 2], [1, 1]])
176
+ index.intersecting_geom_ids(query)
177
+ index.covering_geom_ids(query)
178
+ index.containing_geom_ids(query)
179
+ ```
180
+
181
+ Predicate direction is explicit:
182
+
183
+ | Method | Predicate direction | Boundary semantics |
184
+ | --- | --- | --- |
185
+ | `intersecting_geom_ids(query)` | stored geom intersects query | any intersection |
186
+ | `covering_geom_ids(query)` | stored geom covers query | boundary included |
187
+ | `containing_geom_ids(query)` | stored geom contains query | strict interior; boundary excluded |
188
+
189
+ Results are ids only and preserve insertion order. Duplicate ids remain possible if duplicate ids were inserted.
190
+
132
191
  ## GeoJSON FeatureSource
133
192
 
134
193
  `TG::Geometry::FeatureSource` reads GeoJSON `FeatureCollection` sources without `JSON.parse` of the whole document into Ruby Hash/Array objects.
@@ -186,6 +245,20 @@ index.covering_ids_batch_packed(points)
186
245
 
187
246
  Input is a Ruby String containing native-endian doubles in `lon, lat` pairs. Length must be a multiple of 16 bytes. Empty string returns `[]`.
188
247
 
248
+ ## Nearest segment
249
+
250
+ `Line#nearest_segment(x, y)` and `Ring#nearest_segment(x, y)` return a frozen `TG::Geometry::NearestSegment`:
251
+
252
+ ```ruby
253
+ nearest = polygon.polygon.exterior_ring.nearest_segment(5, 5)
254
+ nearest.segment # => TG::Geometry::Segment
255
+ nearest.index # => segment index
256
+ nearest.distance # => Float
257
+ nearest.point # => [x, y] projection on the segment
258
+ ```
259
+
260
+ Distance is planar Euclidean distance in input coordinate units. It is not meters unless your input coordinates are already meters. Equal-distance tie-breaks follow tg iteration order and are not API-stable.
261
+
189
262
  ## Registry helper
190
263
 
191
264
  `Registry` is Ruby-level sugar over immutable indexes:
@@ -208,6 +281,23 @@ registry.find_covering(5, 5)
208
281
 
209
282
  Reload builds a new immutable index first and swaps the reference only after a successful build. Existing readers keep using the previous index safely.
210
283
 
284
+ ## ActiveRecord integration — read-only
285
+
286
+ `TG::Geometry::ActiveRecordType` is an optional read-only convenience type for PostGIS columns. It is not required by `tg/geometry`; load it explicitly:
287
+
288
+ ```ruby
289
+ require "tg/geometry/active_record_type"
290
+
291
+ class Zone < ApplicationRecord
292
+ attribute :geom, TG::Geometry::ActiveRecordType.new
293
+ end
294
+
295
+ zone.geom.srid
296
+ zone.geom.covers_xy?(lon, lat)
297
+ ```
298
+
299
+ It can deserialize EWKB bytes, hex EWKB, `\x`-prefixed hex EWKB, GeoJSON, and WKT. Writing `Geom` values is intentionally unsupported in v0.3.0. User applications need `activemodel >= 6.0`; `activerecord` is not a gem dependency.
300
+
211
301
  ## Memory and concurrency
212
302
 
213
303
  The implementation uses explicit allocator pairs and Ruby GC accounting for native memory. `ObjectSpace.memsize_of(index)` includes entries, owned TG geometries, and exact rtree allocation bytes. Borrowed geometries are not double-counted by the index.
@@ -229,6 +319,9 @@ bundle exec ruby benchmark/rss_stability.rb
229
319
  bundle exec ruby benchmark/gvl_threshold.rb
230
320
  bundle exec ruby benchmark/falcon_concurrency.rb
231
321
  bundle exec ruby benchmark/feature_source.rb
322
+ bundle exec ruby benchmark/geom_query.rb
323
+ bundle exec ruby benchmark/nearest_segment.rb
324
+ bundle exec ruby benchmark/ewkb_roundtrip.rb
232
325
  ```
233
326
 
234
327
  The benchmarks are engineering tools, not marketing claims.
@@ -239,20 +332,20 @@ The benchmarks are engineering tools, not marketing claims.
239
332
 
240
333
  Not included:
241
334
 
242
- - geocoding;
243
- - routing;
244
- - projections;
245
- - geodesic distance/area;
246
- - buffer / union / difference / overlay result geometry operations;
247
- - nearest POI index;
248
- - Rails dependency in the native extension;
249
- - Redis or external service dependency;
250
- - public callback/search APIs;
251
- - Ractor support claim;
252
- - no-GVL execution claim;
253
- - universal `:auto` strategy.
254
-
255
- TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, and perimeter-style values are in input coordinate units, not meters.
335
+ - Geodesic / Haversine distance;
336
+ - Projection / reprojection;
337
+ - Buffer / union / difference / convex hull;
338
+ - Index nearest_ids (KNN);
339
+ - GeoBIN bbox helpers;
340
+ - Streaming FeatureSource;
341
+ - Index serialization / mmap;
342
+ - Ractor support;
343
+ - Windows / JRuby;
344
+ - Write-side ActiveRecordType / AR scopes / migrations;
345
+ - Z/M variants of array constructors;
346
+ - Public `release_gvl:` option.
347
+
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.
256
349
 
257
350
  ## Development
258
351
 
@@ -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