purplelight 0.1.12 → 0.1.16
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/PERFORMANCE_NOTES.md +53 -0
- data/README.md +22 -7
- data/Rakefile +11 -0
- data/benchmark/baseline.json +366 -0
- data/benchmark/harness.rb +109 -0
- data/benchmark/microbench.rb +344 -0
- data/benchmark/profile.rb +33 -0
- data/bin/purplelight +5 -1
- data/lib/purplelight/manifest.rb +68 -22
- data/lib/purplelight/partitioner.rb +11 -5
- data/lib/purplelight/queue.rb +9 -3
- data/lib/purplelight/snapshot.rb +91 -55
- data/lib/purplelight/telemetry.rb +0 -1
- data/lib/purplelight/version.rb +1 -1
- data/lib/purplelight/writer_csv.rb +76 -45
- data/lib/purplelight/writer_jsonl.rb +57 -24
- data/lib/purplelight/writer_parquet.rb +268 -145
- metadata +14 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1f2c1ee7838cf915ed8d27f16a9445ee7c887bc15a3c1008923272a39dc7829
|
|
4
|
+
data.tar.gz: f146bace9926e3fb0fa20afe4f22fef60d8678f7a873f729a1f593b57ed24e8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f5342d860b66163936ba08683d5fc689af0c44bd13427780b7ebbac41b89a39ca41ba0e5d3f744f22b524a3ad47a23c3c09bf0e5b7457fb64e558b05516f5e6
|
|
7
|
+
data.tar.gz: be99a0bc359866588513051ba124ef69746bb365e04f618dd0d36409f0937397b6ce3e7792a6300355c3990d8ecae51f50d5a2f6aceb9c8509f4c5e888ce7393
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Purplelight upgrade and performance notes
|
|
2
|
+
|
|
3
|
+
Updated: 2026-07-21
|
|
4
|
+
|
|
5
|
+
## Completed
|
|
6
|
+
|
|
7
|
+
- Pinned managed development Ruby 4.0.2; no Homebrew Ruby is used.
|
|
8
|
+
- Updated all Ruby 3.2-compatible dependencies: MongoDB Ruby driver 2.24.1, BSON 5.2.0, zstd-ruby 2.0.6, red-arrow/red-parquet 25.0.0, Rake 13.4.2, RSpec 3.13.2, RuboCop 1.88.2, SimpleCov 1.0.2, StackProf 0.2.28, and current transitives. `parallel` remains on 1.28 because 2.x requires Ruby 3.3; `diff-lcs` 2.0 remains blocked by RSpec's `< 2.0` constraint.
|
|
9
|
+
- Updated CI to MongoDB 7 and 8, Ruby 3.2 and 4.0, Apache Arrow 25, and `actions/checkout@v7.0.1`.
|
|
10
|
+
- Added 24 bounded microbenchmarks covering all eight runtime paths. The harness enforces 100% path registration, takes the median of five samples, measures allocations, and aborts each timed sample or allocation pass after two seconds. The slowest observed timed sample was 0.386 seconds.
|
|
11
|
+
- Added CPU and object-allocation StackProf tasks plus generated flamegraph support.
|
|
12
|
+
- Added SimpleCov line and branch enforcement. The full suite verifies 100% line and branch coverage, including CLI and MongoDB integration behavior.
|
|
13
|
+
- Added focused behavioral coverage for queue backpressure, manifests, telemetry, partition planning, compression backends, rotations, Parquet row groups, resumability, concurrent JSONL writing, progress, and telemetry output.
|
|
14
|
+
- Made persistent MongoDB fixtures rerunnable by dropping static test collections before seeding.
|
|
15
|
+
- Persisted the exact Extended JSON partition plan in manifest version 2. Resume now reuses the original boundaries rather than replanning against a growing collection, preventing boundary replay and duplicate rows.
|
|
16
|
+
|
|
17
|
+
## Retained performance changes
|
|
18
|
+
|
|
19
|
+
- ByteQueue stores payloads and sizes in parallel arrays instead of allocating a pair per enqueue. Confirmed round-trip runs improved from 1,792,596.54 to 2,637,130.80–2,657,101.17 operations/second: **+47.1% to +48.2%**. Oversized single items are also admitted when the queue is empty, avoiding a producer deadlock.
|
|
20
|
+
- JSONL readers pass row and byte metadata with pre-encoded batches, removing a full `String#count("\n")` scan in the writer. The bounded accounting benchmark rose from 37,313.43 to more than 320,000 operations/second: **over +750%**.
|
|
21
|
+
- JSONL writers cache their thread-local telemetry sink after the first write. Two confirmation runs improved the pre-encoded path by **+3.2% to +6.1%** without a confirmed JSONL pipeline regression.
|
|
22
|
+
- Snapshot progress reporting no longer creates a mandatory two-second shutdown delay. Runs without a callback create no progress thread; callback runs use a condition variable and wake immediately on shutdown.
|
|
23
|
+
- Two JSONL writer threads now perform real concurrent serialization/compression and allocate globally unique part numbers. On the 20,000-document incompressible pipeline, throughput improved from 9.56 to 15.14 exports/second: **+58.4%**, with complete row-count verification.
|
|
24
|
+
- JSONL batching honors `batch_size` as well as the 1 MB byte ceiling, bounding latency and memory for small documents.
|
|
25
|
+
- CSV uses an allocation-conscious serializer instead of constructing one `CSV.generate_line` result per row. Confirmed batch throughput improved from 1,211.39 to 1,716.50–1,738.04 operations/second: **+41.7% to +43.5%**.
|
|
26
|
+
- Manifest configuration avoids redundant atomic rewrites. Confirmed progress runs improved from 1,343.24 to 1,753.49–1,764.93 operations/second: **+30.5% to +31.4%**.
|
|
27
|
+
- Parquet now constructs Arrow record batches directly, caches the first row group's schema, packs primitive buffers without per-value GObject calls, preserves requested row-group boundaries through `Parquet::WriterProperties`, and keeps one native writer open per output part. The 200-document benchmark rose from 306.10 to 871.99–1,004.42 operations/second across three runs (**+184.9% to +228.1%**) while allocations fell from 12,281.0 to 2,263.2 per export (**-81.6%**). At 10,000 documents, median throughput rose from 7.23 to 46.71 operations/second (**+545.7%**) and allocations fell from 565,487 to 54,413 (**-90.4%**); output sizes remained unchanged for the compared row-group layouts.
|
|
28
|
+
- Parquet batches each BSON ObjectId column's raw 12-byte values before one hexadecimal conversion, instead of allocating a hexadecimal string and `String#unpack` result per document. Alternating matched runs improved the 200-document export from 918.40 to 982.90 operations/second (**+7.0%**) and cut allocations from 2,266.15 to 1,872.15 (**-17.4%**). The 10,000-document row-group export improved from 47.05 to 50.23 operations/second (**+6.8%**) and from 54,413 to 34,443 allocations (**-36.7%**); a single 10,000-row group improved **+8.3%** with **-39.3%** allocations. Schemas, values, row-group counts, and artifact sizes remained unchanged.
|
|
29
|
+
- Compression defaults were selected from bounded comparisons: zstd level 3 for JSONL, zstd level 9 for CSV, and gzip level 1 for throughput-oriented fallback.
|
|
30
|
+
- CSV manifests count only rows actually written; pre-encoded strings are skipped without inflating progress.
|
|
31
|
+
- Cached integer schemas now reject later values outside their represented range instead of allowing Arrow's integer builder to wrap them silently.
|
|
32
|
+
|
|
33
|
+
## Rejected or unnecessary changes
|
|
34
|
+
|
|
35
|
+
- Compact manifest JSON was measured and reverted: it remained below the 3% retention threshold.
|
|
36
|
+
- A ring-buffer rewrite for ByteQueue was rejected. MRI's front-removal behavior kept the bulk-drain case stable, while the lower-allocation parallel-array representation supplied the retained gain.
|
|
37
|
+
- Alternative CSV gzip and zstd levels that did not produce a repeatable throughput/size improvement were rejected.
|
|
38
|
+
- In-place escaping for nested JSON in CSV was measured and reverted. Dedicated row serialization moved by +2.4% and +3.1% across two runs, so it did not repeatedly clear the 3% threshold.
|
|
39
|
+
- Parquet `RecordBatch` substitution alone, native writer batch-size tuning, dictionary disabling, and extra row-group coalescing did not address the dominant per-value GObject conversion cost and were rejected.
|
|
40
|
+
- Preallocating string capacity, avoiding the integer `compact` copy, borrowing caller buffers, bulk-joining ObjectId bytes, direct native writer calls, caching writer properties, and skipping redundant directory creation each stayed below 3% or regressed and were rejected.
|
|
41
|
+
- Direct three-argument `RecordBatch` construction improved the 200-document case by 7.0% but only -0.6% to +1.9% on the 10,000-document cases, so it was rejected as a first-row-group-only gain that did not persist at scale.
|
|
42
|
+
- No replacement gems or external `alexandernicholson/gems` repository were needed. Current maintained dependencies met the compatibility and measured performance requirements.
|
|
43
|
+
|
|
44
|
+
## Follow-up suggestions
|
|
45
|
+
|
|
46
|
+
- Run the existing opt-in one-million-document load benchmark only on a dedicated, named production-like host; record CPU, storage, MongoDB topology, and compression ratio with every result. It intentionally remains outside the sub-two-second microbenchmark harness.
|
|
47
|
+
- Define schema evolution for CSV and Parquet. Columns are inferred from the first available batch, so fields first appearing in later batches are not added; an explicit Parquet schema option would also let callers avoid narrow first-row-group integer inference.
|
|
48
|
+
- Further Parquet gains likely require an upstream/native bulk-buffer API: after the retained rewrite, CPU samples were dominated by GObject-introspection constructor calls and the native Parquet writer rather than Ruby row transposition.
|
|
49
|
+
- Nested Ruby hashes now dominate the remaining avoidable Parquet allocations because compatibility requires their existing `Hash#inspect` string representation. An explicit nested Struct/JSON encoding option could remove that cost without silently changing current files.
|
|
50
|
+
- Consider batching or journaling manifest checkpoints for high-latency filesystems. Current per-batch atomic rewrites prioritize resumability over maximum throughput.
|
|
51
|
+
- Define an optional ordering contract if consumers need global document order with multiple JSONL writer threads. Current parts are collision-free and complete, but independently scheduled.
|
|
52
|
+
- Track the 3% regression threshold on a pinned benchmark runner. Compression timings vary with host load and should not become a hard gate on shared CI workers.
|
|
53
|
+
- Recheck `diff-lcs` when RSpec permits 2.x, and continue testing both MongoDB 7 and 8 before future driver upgrades.
|
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Purplelight is published on RubyGems: [purplelight on RubyGems](https://rubygems
|
|
|
9
9
|
Add to your Gemfile:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
|
-
gem 'purplelight', '~> 0.1.
|
|
12
|
+
gem 'purplelight', '~> 0.1.13'
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Or install directly:
|
|
@@ -124,8 +124,8 @@ Add optional dependencies:
|
|
|
124
124
|
```ruby
|
|
125
125
|
# Gemfile
|
|
126
126
|
group :parquet do
|
|
127
|
-
gem 'red-arrow', '
|
|
128
|
-
gem 'red-parquet', '
|
|
127
|
+
gem 'red-arrow', '>= 24.0'
|
|
128
|
+
gem 'red-parquet', '>= 24.0'
|
|
129
129
|
end
|
|
130
130
|
```
|
|
131
131
|
|
|
@@ -166,6 +166,7 @@ Notes for Parquet:
|
|
|
166
166
|
- Parquet multi-part sizing is controlled by rows via `parquet_max_rows`.
|
|
167
167
|
- `--rotate-mb` / `part_bytes` do not affect Parquet part size; they apply to JSONL/CSV.
|
|
168
168
|
- Use `sharding: { mode: :single_file }` to force a single `.parquet` file.
|
|
169
|
+
- The first row group defines the Arrow schema. Later values must remain type-compatible; integer values outside the inferred width raise `RangeError` instead of wrapping.
|
|
169
170
|
|
|
170
171
|
### Environment variables (optional)
|
|
171
172
|
|
|
@@ -212,8 +213,9 @@ bundle exec bin/purplelight \
|
|
|
212
213
|
- `--read-concern LEVEL`: `majority|local|linearizable|available|snapshot`.
|
|
213
214
|
- `--no-cursor-timeout BOOL`: Toggle `noCursorTimeout` (default true).
|
|
214
215
|
- `--parquet-row-group N`: Parquet row group size (rows).
|
|
216
|
+
- `--parquet-max-rows N`: Parquet max rows per part (enables multi-part when not `--single-file`).
|
|
215
217
|
- `--write-chunk-mb MB`: JSONL encode/write chunk size before enqueueing.
|
|
216
|
-
- `--writer-threads N
|
|
218
|
+
- `--writer-threads N`: Number of concurrent JSONL writer threads.
|
|
217
219
|
- `--telemetry on|off`: Force enable/disable telemetry output.
|
|
218
220
|
- `--resume-overwrite-incompatible`: Overwrite an existing incompatible manifest to safely resume anew.
|
|
219
221
|
- `--dry-run`: Print effective read preference JSON and exit (no snapshot).
|
|
@@ -222,7 +224,7 @@ bundle exec bin/purplelight \
|
|
|
222
224
|
Notes:
|
|
223
225
|
- Compression backend selection order is: requested format → `zstd-ruby` → `zstds` → `gzip`.
|
|
224
226
|
- `--single-file` and `--by-size` update only the sharding mode/params and preserve any provided `--prefix`.
|
|
225
|
-
|
|
227
|
+
– Parquet multi-part sizing can be controlled via `parquet_max_rows` programmatically, or via CLI `--parquet-max-rows`.
|
|
226
228
|
- To increase concurrent connections, set `maxPoolSize` on your Mongo URI (used by `--uri`), e.g., `mongodb://.../?maxPoolSize=32`. A good starting point is `maxPoolSize >= --partitions`.
|
|
227
229
|
|
|
228
230
|
### Architecture
|
|
@@ -260,6 +262,7 @@ Key points:
|
|
|
260
262
|
- **Compression**: prefer `zstd`; adjust level to balance speed/ratio. CLI: `--compression zstd --compression-level N`. For max speed, try `--compression gzip --compression-level 1`.
|
|
261
263
|
- **Rotation size**: larger (512MB–1GB) reduces finalize overhead for many parts. CLI: `--rotate-mb` (and/or `--by-size`).
|
|
262
264
|
- **JSONL chunking**: tune builder write chunk size for throughput. CLI: `--write-chunk-mb`.
|
|
265
|
+
- **JSONL writer threads**: use `--writer-threads 2` when serialization or compression is the bottleneck. Writers emit independently numbered parts; downstream consumers must not assume document order across parts.
|
|
263
266
|
- **Parquet row groups**: choose a row group size that fits downstream readers. CLI: `--parquet-row-group`.
|
|
264
267
|
- **Parquet parts (rows)**: split Parquet outputs by rows with `parquet_max_rows` (programmatic API). Set `sharding.mode` to anything other than `:single_file` to enable multi-part filenames.
|
|
265
268
|
- **Read preference**: offload to secondaries or tagged analytics nodes when available. CLI: `--read-preference`, `--read-tags`.
|
|
@@ -267,7 +270,19 @@ Key points:
|
|
|
267
270
|
- **Cursor timeout**: for very long scans, leave `noCursorTimeout` enabled. CLI: `--no-cursor-timeout true|false`.
|
|
268
271
|
- **Telemetry**: enable to inspect timing breakdowns; disable for minimal output. CLI: `--telemetry on|off`.
|
|
269
272
|
|
|
270
|
-
|
|
273
|
+
Bounded microbenchmarks:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
bundle exec rake microbench
|
|
277
|
+
|
|
278
|
+
# Compare against the checked-in baseline.
|
|
279
|
+
bundle exec ruby benchmark/microbench.rb --baseline benchmark/baseline.json
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The harness covers every runtime component, takes the median of five samples,
|
|
283
|
+
and aborts any timed sample or allocation pass after two seconds.
|
|
284
|
+
|
|
285
|
+
Optional production-scale load benchmark (not part of the microbenchmark suite):
|
|
271
286
|
|
|
272
287
|
```bash
|
|
273
288
|
# 1M docs benchmark with tunables (JSONL)
|
|
@@ -329,7 +344,7 @@ bundle exec bin/purplelight \
|
|
|
329
344
|
--read-preference secondary --read-tags nodeType=ANALYTICS --dry-run
|
|
330
345
|
```
|
|
331
346
|
|
|
332
|
-
###
|
|
347
|
+
### Example load benchmark
|
|
333
348
|
```
|
|
334
349
|
% BENCH=1 BENCH_PARTITIONS=16 BENCH_BATCH_SIZE=8000 BENCH_QUEUE_MB=512 BENCH_ROTATE_MB=512 BENCH_COMPRESSION=gzip bundle exec rspec spec/benchmark_perf_spec.rb --format doc
|
|
335
350
|
|
data/Rakefile
CHANGED
|
@@ -12,3 +12,14 @@ rescue LoadError
|
|
|
12
12
|
sh 'echo "RSpec not installed"'
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
desc 'Run the bounded microbenchmark suite'
|
|
17
|
+
task :microbench do
|
|
18
|
+
ruby 'benchmark/microbench.rb'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc 'Profile all microbenchmarks and render CPU/allocation flamegraphs'
|
|
22
|
+
task :profile do
|
|
23
|
+
ruby 'benchmark/profile.rb --mode cpu'
|
|
24
|
+
ruby 'benchmark/profile.rb --mode object'
|
|
25
|
+
end
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generated_at": "2026-07-21T12:41:06Z",
|
|
3
|
+
"coverage": {
|
|
4
|
+
"covered": [
|
|
5
|
+
"manifest",
|
|
6
|
+
"partitioner",
|
|
7
|
+
"queue",
|
|
8
|
+
"snapshot",
|
|
9
|
+
"telemetry",
|
|
10
|
+
"writer_csv",
|
|
11
|
+
"writer_jsonl",
|
|
12
|
+
"writer_parquet"
|
|
13
|
+
],
|
|
14
|
+
"required": [
|
|
15
|
+
"manifest",
|
|
16
|
+
"partitioner",
|
|
17
|
+
"queue",
|
|
18
|
+
"snapshot",
|
|
19
|
+
"telemetry",
|
|
20
|
+
"writer_csv",
|
|
21
|
+
"writer_jsonl",
|
|
22
|
+
"writer_parquet"
|
|
23
|
+
],
|
|
24
|
+
"percent": 100.0
|
|
25
|
+
},
|
|
26
|
+
"max_case_seconds": 2.0,
|
|
27
|
+
"samples": 5,
|
|
28
|
+
"results": {
|
|
29
|
+
"byte_queue_round_trip": {
|
|
30
|
+
"iterations": 20000,
|
|
31
|
+
"seconds": 0.053941,
|
|
32
|
+
"sample_seconds": [
|
|
33
|
+
0.007595,
|
|
34
|
+
0.018067,
|
|
35
|
+
0.007425,
|
|
36
|
+
0.013365,
|
|
37
|
+
0.007489
|
|
38
|
+
],
|
|
39
|
+
"operations_per_second": 2633311.36,
|
|
40
|
+
"allocated_objects": 100001,
|
|
41
|
+
"allocations_per_operation": 5.0
|
|
42
|
+
},
|
|
43
|
+
"byte_queue_bulk_drain": {
|
|
44
|
+
"iterations": 10,
|
|
45
|
+
"seconds": 0.058171,
|
|
46
|
+
"sample_seconds": [
|
|
47
|
+
0.011596,
|
|
48
|
+
0.011667,
|
|
49
|
+
0.01175,
|
|
50
|
+
0.011877,
|
|
51
|
+
0.011281
|
|
52
|
+
],
|
|
53
|
+
"operations_per_second": 857.12,
|
|
54
|
+
"allocated_objects": 70,
|
|
55
|
+
"allocations_per_operation": 7.0
|
|
56
|
+
},
|
|
57
|
+
"telemetry_counter_and_timer": {
|
|
58
|
+
"iterations": 50000,
|
|
59
|
+
"seconds": 0.04747,
|
|
60
|
+
"sample_seconds": [
|
|
61
|
+
0.009476,
|
|
62
|
+
0.009558,
|
|
63
|
+
0.009509,
|
|
64
|
+
0.009478,
|
|
65
|
+
0.009449
|
|
66
|
+
],
|
|
67
|
+
"operations_per_second": 5275374.56,
|
|
68
|
+
"allocated_objects": 0,
|
|
69
|
+
"allocations_per_operation": 0.0
|
|
70
|
+
},
|
|
71
|
+
"telemetry_construction": {
|
|
72
|
+
"iterations": 100000,
|
|
73
|
+
"seconds": 0.107426,
|
|
74
|
+
"sample_seconds": [
|
|
75
|
+
0.02273,
|
|
76
|
+
0.021634,
|
|
77
|
+
0.020193,
|
|
78
|
+
0.021322,
|
|
79
|
+
0.021547
|
|
80
|
+
],
|
|
81
|
+
"operations_per_second": 4641017.33,
|
|
82
|
+
"allocated_objects": 300000,
|
|
83
|
+
"allocations_per_operation": 3.0
|
|
84
|
+
},
|
|
85
|
+
"manifest_progress": {
|
|
86
|
+
"iterations": 250,
|
|
87
|
+
"seconds": 0.714513,
|
|
88
|
+
"sample_seconds": [
|
|
89
|
+
0.144839,
|
|
90
|
+
0.145152,
|
|
91
|
+
0.143372,
|
|
92
|
+
0.140609,
|
|
93
|
+
0.140541
|
|
94
|
+
],
|
|
95
|
+
"operations_per_second": 1743.72,
|
|
96
|
+
"allocated_objects": 21250,
|
|
97
|
+
"allocations_per_operation": 85.0
|
|
98
|
+
},
|
|
99
|
+
"partition_range_planning": {
|
|
100
|
+
"iterations": 100000,
|
|
101
|
+
"seconds": 0.045859,
|
|
102
|
+
"sample_seconds": [
|
|
103
|
+
0.009671,
|
|
104
|
+
0.009649,
|
|
105
|
+
0.009258,
|
|
106
|
+
0.008996,
|
|
107
|
+
0.008285
|
|
108
|
+
],
|
|
109
|
+
"operations_per_second": 10801468.79,
|
|
110
|
+
"allocated_objects": 100000,
|
|
111
|
+
"allocations_per_operation": 1.0
|
|
112
|
+
},
|
|
113
|
+
"partition_cursor_sampling": {
|
|
114
|
+
"iterations": 10,
|
|
115
|
+
"seconds": 0.006557,
|
|
116
|
+
"sample_seconds": [
|
|
117
|
+
0.001337,
|
|
118
|
+
0.001296,
|
|
119
|
+
0.001314,
|
|
120
|
+
0.001294,
|
|
121
|
+
0.001316
|
|
122
|
+
],
|
|
123
|
+
"operations_per_second": 7610.35,
|
|
124
|
+
"allocated_objects": 250,
|
|
125
|
+
"allocations_per_operation": 25.0
|
|
126
|
+
},
|
|
127
|
+
"snapshot_configuration": {
|
|
128
|
+
"iterations": 10000,
|
|
129
|
+
"seconds": 0.211803,
|
|
130
|
+
"sample_seconds": [
|
|
131
|
+
0.043064,
|
|
132
|
+
0.041384,
|
|
133
|
+
0.042928,
|
|
134
|
+
0.042796,
|
|
135
|
+
0.041631
|
|
136
|
+
],
|
|
137
|
+
"operations_per_second": 233666.7,
|
|
138
|
+
"allocated_objects": 170000,
|
|
139
|
+
"allocations_per_operation": 17.0
|
|
140
|
+
},
|
|
141
|
+
"snapshot_empty_export": {
|
|
142
|
+
"iterations": 20,
|
|
143
|
+
"seconds": 0.046427,
|
|
144
|
+
"sample_seconds": [
|
|
145
|
+
0.008102,
|
|
146
|
+
0.007108,
|
|
147
|
+
0.008957,
|
|
148
|
+
0.014756,
|
|
149
|
+
0.007504
|
|
150
|
+
],
|
|
151
|
+
"operations_per_second": 2468.53,
|
|
152
|
+
"allocated_objects": 3420,
|
|
153
|
+
"allocations_per_operation": 171.0
|
|
154
|
+
},
|
|
155
|
+
"snapshot_raw_batching": {
|
|
156
|
+
"iterations": 100,
|
|
157
|
+
"seconds": 0.059924,
|
|
158
|
+
"sample_seconds": [
|
|
159
|
+
0.011836,
|
|
160
|
+
0.012093,
|
|
161
|
+
0.012064,
|
|
162
|
+
0.011962,
|
|
163
|
+
0.011969
|
|
164
|
+
],
|
|
165
|
+
"operations_per_second": 8354.92,
|
|
166
|
+
"allocated_objects": 1800,
|
|
167
|
+
"allocations_per_operation": 18.0
|
|
168
|
+
},
|
|
169
|
+
"snapshot_jsonl_serial_pipeline": {
|
|
170
|
+
"iterations": 3,
|
|
171
|
+
"seconds": 1.593689,
|
|
172
|
+
"sample_seconds": [
|
|
173
|
+
0.332539,
|
|
174
|
+
0.320695,
|
|
175
|
+
0.319876,
|
|
176
|
+
0.30983,
|
|
177
|
+
0.310749
|
|
178
|
+
],
|
|
179
|
+
"operations_per_second": 9.38,
|
|
180
|
+
"allocated_objects": 543645,
|
|
181
|
+
"allocations_per_operation": 181215.0
|
|
182
|
+
},
|
|
183
|
+
"snapshot_jsonl_pipeline": {
|
|
184
|
+
"iterations": 3,
|
|
185
|
+
"seconds": 0.978552,
|
|
186
|
+
"sample_seconds": [
|
|
187
|
+
0.195724,
|
|
188
|
+
0.195973,
|
|
189
|
+
0.19579,
|
|
190
|
+
0.195125,
|
|
191
|
+
0.19594
|
|
192
|
+
],
|
|
193
|
+
"operations_per_second": 15.32,
|
|
194
|
+
"allocated_objects": 543867,
|
|
195
|
+
"allocations_per_operation": 181289.0
|
|
196
|
+
},
|
|
197
|
+
"jsonl_batch_write": {
|
|
198
|
+
"iterations": 100,
|
|
199
|
+
"seconds": 0.11875,
|
|
200
|
+
"sample_seconds": [
|
|
201
|
+
0.022894,
|
|
202
|
+
0.025733,
|
|
203
|
+
0.021527,
|
|
204
|
+
0.024267,
|
|
205
|
+
0.024329
|
|
206
|
+
],
|
|
207
|
+
"operations_per_second": 4120.82,
|
|
208
|
+
"allocated_objects": 182800,
|
|
209
|
+
"allocations_per_operation": 1828.0
|
|
210
|
+
},
|
|
211
|
+
"jsonl_zstd_batch_write": {
|
|
212
|
+
"iterations": 50,
|
|
213
|
+
"seconds": 0.071127,
|
|
214
|
+
"sample_seconds": [
|
|
215
|
+
0.016098,
|
|
216
|
+
0.012678,
|
|
217
|
+
0.013116,
|
|
218
|
+
0.013103,
|
|
219
|
+
0.016132
|
|
220
|
+
],
|
|
221
|
+
"operations_per_second": 3812.14,
|
|
222
|
+
"allocated_objects": 92050,
|
|
223
|
+
"allocations_per_operation": 1841.0
|
|
224
|
+
},
|
|
225
|
+
"jsonl_preencoded_accounting": {
|
|
226
|
+
"iterations": 10000,
|
|
227
|
+
"seconds": 0.155715,
|
|
228
|
+
"sample_seconds": [
|
|
229
|
+
0.031233,
|
|
230
|
+
0.03071,
|
|
231
|
+
0.031271,
|
|
232
|
+
0.031395,
|
|
233
|
+
0.031106
|
|
234
|
+
],
|
|
235
|
+
"operations_per_second": 320174.17,
|
|
236
|
+
"allocated_objects": 0,
|
|
237
|
+
"allocations_per_operation": 0.0
|
|
238
|
+
},
|
|
239
|
+
"csv_batch_write": {
|
|
240
|
+
"iterations": 100,
|
|
241
|
+
"seconds": 0.29281,
|
|
242
|
+
"sample_seconds": [
|
|
243
|
+
0.057804,
|
|
244
|
+
0.058016,
|
|
245
|
+
0.058681,
|
|
246
|
+
0.059051,
|
|
247
|
+
0.059258
|
|
248
|
+
],
|
|
249
|
+
"operations_per_second": 1704.13,
|
|
250
|
+
"allocated_objects": 363700,
|
|
251
|
+
"allocations_per_operation": 3637.0
|
|
252
|
+
},
|
|
253
|
+
"csv_zstd_batch_write": {
|
|
254
|
+
"iterations": 50,
|
|
255
|
+
"seconds": 0.222587,
|
|
256
|
+
"sample_seconds": [
|
|
257
|
+
0.05143,
|
|
258
|
+
0.046745,
|
|
259
|
+
0.038643,
|
|
260
|
+
0.052235,
|
|
261
|
+
0.033534
|
|
262
|
+
],
|
|
263
|
+
"operations_per_second": 1069.63,
|
|
264
|
+
"allocated_objects": 182500,
|
|
265
|
+
"allocations_per_operation": 3650.0
|
|
266
|
+
},
|
|
267
|
+
"csv_gzip_batch_write": {
|
|
268
|
+
"iterations": 50,
|
|
269
|
+
"seconds": 0.148172,
|
|
270
|
+
"sample_seconds": [
|
|
271
|
+
0.027482,
|
|
272
|
+
0.034167,
|
|
273
|
+
0.027431,
|
|
274
|
+
0.031337,
|
|
275
|
+
0.027755
|
|
276
|
+
],
|
|
277
|
+
"operations_per_second": 1801.48,
|
|
278
|
+
"allocated_objects": 182550,
|
|
279
|
+
"allocations_per_operation": 3651.0
|
|
280
|
+
},
|
|
281
|
+
"csv_counting_io_write": {
|
|
282
|
+
"iterations": 100000,
|
|
283
|
+
"seconds": 0.041051,
|
|
284
|
+
"sample_seconds": [
|
|
285
|
+
0.008018,
|
|
286
|
+
0.008041,
|
|
287
|
+
0.008821,
|
|
288
|
+
0.008223,
|
|
289
|
+
0.007948
|
|
290
|
+
],
|
|
291
|
+
"operations_per_second": 12436264.16,
|
|
292
|
+
"allocated_objects": 0,
|
|
293
|
+
"allocations_per_operation": 0.0
|
|
294
|
+
},
|
|
295
|
+
"csv_row_serialization": {
|
|
296
|
+
"iterations": 25000,
|
|
297
|
+
"seconds": 0.291323,
|
|
298
|
+
"sample_seconds": [
|
|
299
|
+
0.061351,
|
|
300
|
+
0.057502,
|
|
301
|
+
0.057275,
|
|
302
|
+
0.057677,
|
|
303
|
+
0.057518
|
|
304
|
+
],
|
|
305
|
+
"operations_per_second": 434646.55,
|
|
306
|
+
"allocated_objects": 375000,
|
|
307
|
+
"allocations_per_operation": 15.0
|
|
308
|
+
},
|
|
309
|
+
"parquet_buffer_append": {
|
|
310
|
+
"iterations": 5000,
|
|
311
|
+
"seconds": 0.005627,
|
|
312
|
+
"sample_seconds": [
|
|
313
|
+
0.001129,
|
|
314
|
+
0.001121,
|
|
315
|
+
0.001122,
|
|
316
|
+
0.001125,
|
|
317
|
+
0.00113
|
|
318
|
+
],
|
|
319
|
+
"operations_per_second": 4444444.64,
|
|
320
|
+
"allocated_objects": 0,
|
|
321
|
+
"allocations_per_operation": 0.0
|
|
322
|
+
},
|
|
323
|
+
"parquet_object_id_array": {
|
|
324
|
+
"iterations": 1000,
|
|
325
|
+
"seconds": 0.237818,
|
|
326
|
+
"sample_seconds": [
|
|
327
|
+
0.048408,
|
|
328
|
+
0.044886,
|
|
329
|
+
0.056985,
|
|
330
|
+
0.043491,
|
|
331
|
+
0.044048
|
|
332
|
+
],
|
|
333
|
+
"operations_per_second": 22278.66,
|
|
334
|
+
"allocated_objects": 64000,
|
|
335
|
+
"allocations_per_operation": 64.0
|
|
336
|
+
},
|
|
337
|
+
"parquet_batch_write": {
|
|
338
|
+
"iterations": 10,
|
|
339
|
+
"seconds": 0.049999,
|
|
340
|
+
"sample_seconds": [
|
|
341
|
+
0.010937,
|
|
342
|
+
0.00987,
|
|
343
|
+
0.009626,
|
|
344
|
+
0.00971,
|
|
345
|
+
0.009856
|
|
346
|
+
],
|
|
347
|
+
"operations_per_second": 1014.61,
|
|
348
|
+
"allocated_objects": 18692,
|
|
349
|
+
"allocations_per_operation": 1869.2
|
|
350
|
+
},
|
|
351
|
+
"parquet_row_group_write": {
|
|
352
|
+
"iterations": 2,
|
|
353
|
+
"seconds": 0.18688,
|
|
354
|
+
"sample_seconds": [
|
|
355
|
+
0.03926,
|
|
356
|
+
0.036353,
|
|
357
|
+
0.037264,
|
|
358
|
+
0.03747,
|
|
359
|
+
0.036533
|
|
360
|
+
],
|
|
361
|
+
"operations_per_second": 53.67,
|
|
362
|
+
"allocated_objects": 68880,
|
|
363
|
+
"allocations_per_operation": 34440.0
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'benchmark'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module Purplelight
|
|
8
|
+
module Microbench
|
|
9
|
+
Case = Data.define(:name, :paths, :iterations, :block)
|
|
10
|
+
|
|
11
|
+
# Runs registered microbenchmarks under strict duration and coverage limits.
|
|
12
|
+
class Harness
|
|
13
|
+
MAX_CASE_SECONDS = 2.0
|
|
14
|
+
SAMPLES = 5
|
|
15
|
+
REQUIRED_PATHS = %i[
|
|
16
|
+
manifest partitioner queue snapshot telemetry writer_csv writer_jsonl writer_parquet
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@cases = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register(name, paths:, iterations: 1, &block)
|
|
24
|
+
raise ArgumentError, 'benchmark block required' unless block
|
|
25
|
+
|
|
26
|
+
@cases << Case.new(name:, paths: Array(paths).map(&:to_sym), iterations:, block:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(baseline_path: nil)
|
|
30
|
+
verify_coverage!
|
|
31
|
+
results = @cases.to_h { |benchmark_case| [benchmark_case.name, measure(benchmark_case)] }
|
|
32
|
+
report = {
|
|
33
|
+
generated_at: Time.now.utc.iso8601,
|
|
34
|
+
coverage: coverage,
|
|
35
|
+
max_case_seconds: MAX_CASE_SECONDS,
|
|
36
|
+
samples: SAMPLES,
|
|
37
|
+
results:
|
|
38
|
+
}
|
|
39
|
+
report[:comparison] = compare(results, JSON.parse(File.read(baseline_path))) if baseline_path
|
|
40
|
+
report
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def coverage
|
|
44
|
+
covered = @cases.flat_map(&:paths).uniq
|
|
45
|
+
{
|
|
46
|
+
covered: covered.sort,
|
|
47
|
+
required: REQUIRED_PATHS,
|
|
48
|
+
percent: (covered.length.fdiv(REQUIRED_PATHS.length) * 100).round(2)
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def verify_coverage!
|
|
55
|
+
missing = REQUIRED_PATHS - @cases.flat_map(&:paths)
|
|
56
|
+
raise "missing benchmark paths: #{missing.join(', ')}" unless missing.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def measure(benchmark_case)
|
|
60
|
+
samples = Array.new(SAMPLES) do
|
|
61
|
+
Timeout.timeout(MAX_CASE_SECONDS) do
|
|
62
|
+
Benchmark.realtime do
|
|
63
|
+
benchmark_case.iterations.times { benchmark_case.block.call }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
allocated_objects = Timeout.timeout(MAX_CASE_SECONDS) do
|
|
68
|
+
allocations_before = GC.stat(:total_allocated_objects)
|
|
69
|
+
benchmark_case.iterations.times { benchmark_case.block.call }
|
|
70
|
+
GC.stat(:total_allocated_objects) - allocations_before
|
|
71
|
+
end
|
|
72
|
+
median = samples.sort.fetch(SAMPLES / 2)
|
|
73
|
+
{
|
|
74
|
+
iterations: benchmark_case.iterations,
|
|
75
|
+
seconds: samples.sum.round(6),
|
|
76
|
+
sample_seconds: samples.map { |elapsed| elapsed.round(6) },
|
|
77
|
+
operations_per_second: (benchmark_case.iterations / median).round(2),
|
|
78
|
+
allocated_objects:,
|
|
79
|
+
allocations_per_operation: allocated_objects.fdiv(benchmark_case.iterations).round(2)
|
|
80
|
+
}
|
|
81
|
+
rescue Timeout::Error
|
|
82
|
+
raise "#{benchmark_case.name} exceeded #{MAX_CASE_SECONDS}s in a single measurement"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def compare(results, baseline)
|
|
86
|
+
old_results = baseline.fetch('results')
|
|
87
|
+
results.to_h do |name, result|
|
|
88
|
+
old_result = old_results[name.to_s]
|
|
89
|
+
next [name, { baseline: false }] unless old_result
|
|
90
|
+
|
|
91
|
+
old_rate = old_result.fetch('operations_per_second')
|
|
92
|
+
delta = ((result[:operations_per_second] / old_rate) - 1.0) * 100
|
|
93
|
+
comparison = { percent: delta.round(2), significant: delta.abs > 3.0 }
|
|
94
|
+
if old_result['allocations_per_operation']
|
|
95
|
+
old_allocations = old_result.fetch('allocations_per_operation')
|
|
96
|
+
new_allocations = result[:allocations_per_operation]
|
|
97
|
+
allocation_delta = if old_allocations.zero?
|
|
98
|
+
(new_allocations.zero? ? 0.0 : nil)
|
|
99
|
+
else
|
|
100
|
+
(((new_allocations / old_allocations) - 1.0) * 100)
|
|
101
|
+
end
|
|
102
|
+
comparison[:allocation_percent] = allocation_delta&.round(2)
|
|
103
|
+
end
|
|
104
|
+
[name, comparison]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|