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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +107 -14
- data/benchmark/_support.rb +450 -5
- data/benchmark/batch_packed_vs_loop.rb +38 -6
- data/benchmark/ewkb_roundtrip.rb +38 -0
- data/benchmark/falcon_concurrency.rb +17 -10
- data/benchmark/feature_source.rb +49 -23
- data/benchmark/flat_vs_rtree.rb +53 -11
- data/benchmark/geom_query.rb +43 -0
- data/benchmark/gvl_threshold.rb +10 -11
- data/benchmark/nearest_segment.rb +24 -0
- data/benchmark/objectspace_memsize.rb +14 -1
- data/benchmark/parse_throughput.rb +13 -10
- data/benchmark/rss_stability.rb +35 -24
- data/docs/GEOMETRY_QUERIES.md +23 -0
- data/docs/LIMITATIONS.md +12 -10
- data/docs/NEAREST_SEGMENT.md +17 -0
- data/docs/SRID_AND_EWKB.md +23 -0
- data/ext/tg_geometry/tg_geometry_ext.c +1234 -27
- data/lib/tg/geometry/active_record_source.rb +16 -34
- data/lib/tg/geometry/active_record_type.rb +61 -0
- data/lib/tg/geometry/registry.rb +17 -68
- data/lib/tg/geometry/version.rb +1 -1
- data/lib/tg/geometry.rb +85 -0
- data/spec/active_record_type_spec.rb +45 -0
- data/spec/constructors_spec.rb +104 -0
- data/spec/fixtures/feature_source/invalid_geometry_middle.geojson +8 -0
- data/spec/fixtures/feature_source/malformed_json.geojson +1 -0
- data/spec/fixtures/feature_source/mixed_geometry_types.geojson +8 -0
- data/spec/fixtures/feature_source/osm_like_feature_collection.geojson +10 -0
- data/spec/fixtures/feature_source/properties_null_missing.geojson +7 -0
- data/spec/fixtures/feature_source/simple_feature_collection.geojson +15 -0
- data/spec/fixtures/postgis/README.md +16 -0
- data/spec/fixtures/postgis/boundary_point_cases.geojson +83 -0
- data/spec/fixtures/postgis/multipolygon_large.ewkb +0 -0
- data/spec/fixtures/postgis/point_4326.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_3857.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_4326_simple.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_4326_with_hole.ewkb +0 -0
- data/spec/index_geom_query_spec.rb +68 -0
- data/spec/keyword_validation_spec.rb +31 -0
- data/spec/nearest_segment_spec.rb +62 -0
- data/spec/postgis_fixtures_spec.rb +68 -0
- data/spec/srid_spec.rb +43 -0
- data/spec/to_ewkb_spec.rb +37 -0
- metadata +50 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cde617c534a8a3d9702b3fe212d46f966da46224499bb4fcb97bf40865590be8
|
|
4
|
+
data.tar.gz: 7e1767d31814249ef695ceae1965d6c5aaf9065ff7595309e95698abc053abbc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
243
|
-
-
|
|
244
|
-
-
|
|
245
|
-
-
|
|
246
|
-
-
|
|
247
|
-
-
|
|
248
|
-
-
|
|
249
|
-
-
|
|
250
|
-
-
|
|
251
|
-
-
|
|
252
|
-
-
|
|
253
|
-
-
|
|
254
|
-
|
|
255
|
-
TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, and
|
|
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
|
|
data/benchmark/_support.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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}
|
|
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
|