purplelight 0.1.17 → 0.1.19
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 +8 -3
- data/README.md +6 -2
- data/benchmark/baseline.json +628 -194
- data/benchmark/microbench.rb +88 -0
- data/lib/purplelight/queue.rb +6 -1
- data/lib/purplelight/snapshot.rb +25 -4
- data/lib/purplelight/version.rb +1 -1
- data/lib/purplelight/writer_parquet.rb +52 -2
- metadata +1 -1
data/benchmark/microbench.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'bundler/setup'
|
|
5
|
+
require 'date'
|
|
5
6
|
require 'fileutils'
|
|
6
7
|
require 'json'
|
|
7
8
|
require 'stringio'
|
|
@@ -28,6 +29,41 @@ PARQUET_DOCS = Array.new(10_000) do |index|
|
|
|
28
29
|
'nested' => { 'value' => index }
|
|
29
30
|
}
|
|
30
31
|
end.freeze
|
|
32
|
+
PARQUET_READ_DOCS = Array.new(10_000) do |index|
|
|
33
|
+
object_id = BSON::ObjectId.from_string(format('%024x', index + 1))
|
|
34
|
+
nullable = (index % 11).zero?
|
|
35
|
+
{
|
|
36
|
+
'row_id' => 4_294_967_296 + index,
|
|
37
|
+
'u8' => index % 256,
|
|
38
|
+
'u16' => 1_000 + index,
|
|
39
|
+
'u32' => 1_000_000_000 + index,
|
|
40
|
+
'u64' => 5_000_000_000 + index,
|
|
41
|
+
'i8' => (index % 256) - 128,
|
|
42
|
+
'i16' => index - 32_768,
|
|
43
|
+
'i32' => index - 2_000_000_000,
|
|
44
|
+
'i64' => index - 9_000_000_000,
|
|
45
|
+
'flag' => index.even?,
|
|
46
|
+
'nullable_flag' => (nullable ? nil : index.even?),
|
|
47
|
+
'score' => index * 0.25,
|
|
48
|
+
'nullable_score' => (nullable ? nil : index * 0.5),
|
|
49
|
+
'category' => "category-#{index % 8}",
|
|
50
|
+
'symbol' => :"symbol-#{index % 8}",
|
|
51
|
+
'unique_text' => format('row-%<index>08d-value-%<encoded>08x',
|
|
52
|
+
index:, encoded: index * 2_654_435_761),
|
|
53
|
+
'utf8_text' => "日本語-#{index % 32}",
|
|
54
|
+
'long_text' => "#{format('%08d', index)}-#{'abcdefghijklmnopqrstuvwxyz' * 4}",
|
|
55
|
+
'object_id' => object_id,
|
|
56
|
+
'mixed_id' => (index.even? ? object_id : "literal-#{index}"),
|
|
57
|
+
'nested' => { 'index' => index, 'flag' => index.even? },
|
|
58
|
+
'date' => Date.new(2020, 1, 1) + (index % 365),
|
|
59
|
+
'time' => Time.utc(2020, 1, 1) + index,
|
|
60
|
+
'int_list' => (nullable ? nil : [index, index + 1, index + 2]),
|
|
61
|
+
'nested_int_list' => (nullable ? nil : [[index, index + 1], [index + 2]]),
|
|
62
|
+
'string_list' => (nullable ? nil : ["tag-#{index % 16}", "tag-#{(index + 1) % 16}"]),
|
|
63
|
+
'object_id_list' => (nullable ? nil : [object_id]),
|
|
64
|
+
'hash_list' => (nullable ? nil : [{ 'index' => index }])
|
|
65
|
+
}
|
|
66
|
+
end.freeze
|
|
31
67
|
ENCODED_JSONL = DOCS.map { |document| "#{JSON.generate(document)}\n" }.join * 10
|
|
32
68
|
ENCODED_BATCH = Purplelight::WriterJSONL::EncodedBatch.new(
|
|
33
69
|
data: ENCODED_JSONL,
|
|
@@ -334,6 +370,58 @@ Dir.mktmpdir('purplelight-microbench') do |directory|
|
|
|
334
370
|
writer.write_many(PARQUET_DOCS)
|
|
335
371
|
writer.close
|
|
336
372
|
end
|
|
373
|
+
parquet_read_writer = Purplelight::WriterParquet.new(
|
|
374
|
+
directory:,
|
|
375
|
+
prefix: 'parquet-read',
|
|
376
|
+
compression: :zstd,
|
|
377
|
+
row_group_size: 10_000,
|
|
378
|
+
single_file: true
|
|
379
|
+
)
|
|
380
|
+
parquet_read_writer.write_many(PARQUET_READ_DOCS)
|
|
381
|
+
parquet_read_writer.close
|
|
382
|
+
parquet_read_path = File.join(directory, 'parquet-read.parquet')
|
|
383
|
+
parquet_schema_reader = Parquet::ArrowFileReader.new(parquet_read_path)
|
|
384
|
+
parquet_column_indices = parquet_schema_reader.schema.fields.each_with_index.to_h do |field, index|
|
|
385
|
+
[field.name, index]
|
|
386
|
+
end.freeze
|
|
387
|
+
parquet_schema_reader.close
|
|
388
|
+
|
|
389
|
+
harness.register('parquet_read_full_table', paths: :writer_parquet, iterations: 10) do
|
|
390
|
+
reader = Parquet::ArrowFileReader.new(parquet_read_path)
|
|
391
|
+
reader.use_threads = true
|
|
392
|
+
table = reader.read_table
|
|
393
|
+
reader.close
|
|
394
|
+
raise 'Parquet full read row mismatch' unless table.n_rows == PARQUET_READ_DOCS.length
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
harness.register('parquet_read_row_groups', paths: :writer_parquet, iterations: 10) do
|
|
398
|
+
reader = Parquet::ArrowFileReader.new(parquet_read_path)
|
|
399
|
+
reader.use_threads = true
|
|
400
|
+
rows = reader.each_row_group.sum(&:n_rows)
|
|
401
|
+
reader.close
|
|
402
|
+
raise 'Parquet row-group read mismatch' unless rows == PARQUET_READ_DOCS.length
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
projected_indices = %w[row_id flag score].map { |name| parquet_column_indices.fetch(name) }.freeze
|
|
406
|
+
harness.register('parquet_read_projection', paths: :writer_parquet, iterations: 25) do
|
|
407
|
+
reader = Parquet::ArrowFileReader.new(parquet_read_path)
|
|
408
|
+
reader.use_threads = true
|
|
409
|
+
rows = reader.n_row_groups.times.sum do |index|
|
|
410
|
+
reader.read_row_group(index, projected_indices).n_rows
|
|
411
|
+
end
|
|
412
|
+
reader.close
|
|
413
|
+
raise 'Parquet projected read mismatch' unless rows == PARQUET_READ_DOCS.length
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
parquet_column_indices.each do |column, index|
|
|
417
|
+
harness.register("parquet_read_#{column}", paths: :writer_parquet, iterations: 50) do
|
|
418
|
+
reader = Parquet::ArrowFileReader.new(parquet_read_path)
|
|
419
|
+
reader.use_threads = true
|
|
420
|
+
values = reader.read_column_data(index)
|
|
421
|
+
reader.close
|
|
422
|
+
raise "Parquet #{column} read mismatch" unless values.length == PARQUET_READ_DOCS.length
|
|
423
|
+
end
|
|
424
|
+
end
|
|
337
425
|
|
|
338
426
|
baseline_index = ARGV.index('--baseline')
|
|
339
427
|
output_index = ARGV.index('--output')
|
data/lib/purplelight/queue.rb
CHANGED
data/lib/purplelight/snapshot.rb
CHANGED
|
@@ -131,6 +131,12 @@ module Purplelight
|
|
|
131
131
|
|
|
132
132
|
# Reader queue
|
|
133
133
|
queue = ByteQueue.new(max_bytes: @queue_size_bytes)
|
|
134
|
+
failure_mutex = Mutex.new
|
|
135
|
+
worker_failure = nil
|
|
136
|
+
record_failure = lambda do |error|
|
|
137
|
+
failure_mutex.synchronize { worker_failure ||= error }
|
|
138
|
+
queue.close(discard: true)
|
|
139
|
+
end
|
|
134
140
|
|
|
135
141
|
# Writer
|
|
136
142
|
writer_count = @format == :jsonl ? [@writer_threads.to_i, 1].max : 1
|
|
@@ -163,9 +169,15 @@ module Purplelight
|
|
|
163
169
|
readers = partition_filters.each_with_index.map do |pf, idx|
|
|
164
170
|
Thread.new do
|
|
165
171
|
local_telemetry = @telemetry_enabled ? Telemetry.new(enabled: true) : Telemetry::NULL
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
begin
|
|
173
|
+
read_partition(idx: idx, filter_spec: pf, queue: queue, batch_size: @batch_size,
|
|
174
|
+
manifest: manifest, telemetry: local_telemetry)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
record_failure.call(e)
|
|
177
|
+
ensure
|
|
178
|
+
# Merge per-thread telemetry
|
|
179
|
+
@telemetry.merge!(local_telemetry) if @telemetry_enabled
|
|
180
|
+
end
|
|
169
181
|
end
|
|
170
182
|
end
|
|
171
183
|
|
|
@@ -182,8 +194,14 @@ module Purplelight
|
|
|
182
194
|
|
|
183
195
|
worker_writer.write_many(batch)
|
|
184
196
|
end
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
record_failure.call(e)
|
|
185
199
|
ensure
|
|
186
|
-
|
|
200
|
+
begin
|
|
201
|
+
worker_writer.close
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
record_failure.call(e)
|
|
204
|
+
end
|
|
187
205
|
end
|
|
188
206
|
end
|
|
189
207
|
|
|
@@ -212,6 +230,9 @@ module Purplelight
|
|
|
212
230
|
else
|
|
213
231
|
@running = false
|
|
214
232
|
end
|
|
233
|
+
worker_error = failure_mutex.synchronize { worker_failure }
|
|
234
|
+
raise worker_error if worker_error
|
|
235
|
+
|
|
215
236
|
if @telemetry_enabled
|
|
216
237
|
total = @telemetry.timers.values.sum
|
|
217
238
|
breakdown = @telemetry.timers
|
data/lib/purplelight/version.rb
CHANGED
|
@@ -13,6 +13,8 @@ module Purplelight
|
|
|
13
13
|
# WriterParquet writes Parquet files via Apache Arrow when available.
|
|
14
14
|
class WriterParquet
|
|
15
15
|
DEFAULT_ROW_GROUP_SIZE = 10_000
|
|
16
|
+
DICTIONARY_CARDINALITY_LIMIT = 16
|
|
17
|
+
DICTIONARY_MIN_REPETITIONS = 4
|
|
16
18
|
|
|
17
19
|
def initialize(directory:, prefix:, compression: :zstd, row_group_size: DEFAULT_ROW_GROUP_SIZE, logger: nil,
|
|
18
20
|
manifest: nil, single_file: true, schema: nil, rotate_rows: nil)
|
|
@@ -63,6 +65,8 @@ module Purplelight
|
|
|
63
65
|
@buffer_head = 0
|
|
64
66
|
@arrow_schema = nil
|
|
65
67
|
@arrow_data_types = nil
|
|
68
|
+
@dictionary_paths = nil
|
|
69
|
+
@dictionary_paths_resolved = false
|
|
66
70
|
@writer_path = nil
|
|
67
71
|
end
|
|
68
72
|
|
|
@@ -79,10 +83,21 @@ module Purplelight
|
|
|
79
83
|
|
|
80
84
|
def build_record_batch(documents)
|
|
81
85
|
@columns ||= infer_columns(documents)
|
|
86
|
+
resolve_dictionary_paths = !@dictionary_paths_resolved
|
|
87
|
+
dictionary_paths = [] if resolve_dictionary_paths && @columns.none? { |name| name.include?('.') }
|
|
88
|
+
@dictionary_paths_resolved = true if resolve_dictionary_paths
|
|
82
89
|
arrays = @columns.each_with_index.map do |name, index|
|
|
83
90
|
values = documents.map { |document| extract_value(document, name) }
|
|
84
|
-
build_arrow_array(values, @arrow_data_types&.at(index))
|
|
91
|
+
array = build_arrow_array(values, @arrow_data_types&.at(index))
|
|
92
|
+
if dictionary_paths
|
|
93
|
+
data_type = array.value_data_type
|
|
94
|
+
path = list_dictionary_path(name, data_type)
|
|
95
|
+
path ||= name if data_type.is_a?(Arrow::StringDataType) && low_cardinality_strings?(values)
|
|
96
|
+
dictionary_paths << path if path
|
|
97
|
+
end
|
|
98
|
+
array
|
|
85
99
|
end
|
|
100
|
+
@dictionary_paths = dictionary_paths if resolve_dictionary_paths
|
|
86
101
|
batch = if @arrow_schema
|
|
87
102
|
Arrow::RecordBatch.new(@arrow_schema, arrays)
|
|
88
103
|
else
|
|
@@ -103,7 +118,7 @@ module Purplelight
|
|
|
103
118
|
return build_string_array(values)
|
|
104
119
|
end
|
|
105
120
|
return build_string_array(values) if data_type.is_a?(Arrow::StringDataType) ||
|
|
106
|
-
first.is_a?(String) || first.is_a?(Hash)
|
|
121
|
+
first.is_a?(String) || first.is_a?(Symbol) || first.is_a?(Hash)
|
|
107
122
|
if (data_type.is_a?(Arrow::ListDataType) || first.is_a?(Array)) &&
|
|
108
123
|
values.all? { |value| value.nil? || value.is_a?(Array) } &&
|
|
109
124
|
(data_type || values.any? { |value| value && !value.empty? })
|
|
@@ -287,10 +302,45 @@ module Purplelight
|
|
|
287
302
|
end
|
|
288
303
|
end
|
|
289
304
|
|
|
305
|
+
def list_dictionary_path(name, data_type)
|
|
306
|
+
return unless data_type.is_a?(Arrow::ListDataType)
|
|
307
|
+
|
|
308
|
+
path = name.dup
|
|
309
|
+
while data_type.is_a?(Arrow::ListDataType)
|
|
310
|
+
path << '.list.element'
|
|
311
|
+
data_type = data_type.field.data_type
|
|
312
|
+
end
|
|
313
|
+
return if data_type.is_a?(Arrow::BooleanDataType) || data_type.is_a?(Arrow::NullDataType)
|
|
314
|
+
|
|
315
|
+
path
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def low_cardinality_strings?(values)
|
|
319
|
+
first = values.find { |value| !value.nil? }
|
|
320
|
+
string_like = first.is_a?(String) || first.is_a?(Symbol)
|
|
321
|
+
return false unless string_like
|
|
322
|
+
|
|
323
|
+
distinct = {}
|
|
324
|
+
count = 0
|
|
325
|
+
values.each do |value|
|
|
326
|
+
next if value.nil?
|
|
327
|
+
return false unless value.is_a?(String) || value.is_a?(Symbol)
|
|
328
|
+
|
|
329
|
+
count += 1
|
|
330
|
+
distinct[value] = true
|
|
331
|
+
return false if distinct.length > DICTIONARY_CARDINALITY_LIMIT
|
|
332
|
+
end
|
|
333
|
+
!distinct.empty? && count >= distinct.length * DICTIONARY_MIN_REPETITIONS
|
|
334
|
+
end
|
|
335
|
+
|
|
290
336
|
def write_record_batch(batch, path)
|
|
291
337
|
unless @pq_writer
|
|
292
338
|
properties = build_writer_properties_for_compression(@compression)
|
|
293
339
|
properties ||= Parquet::WriterProperties.new
|
|
340
|
+
if @dictionary_paths
|
|
341
|
+
properties.disable_dictionary
|
|
342
|
+
@dictionary_paths.each { |column_path| properties.enable_dictionary(column_path) }
|
|
343
|
+
end
|
|
294
344
|
properties.max_row_group_length = @row_group_size
|
|
295
345
|
@pq_writer = Parquet::ArrowFileWriter.open(batch.schema, path, properties)
|
|
296
346
|
end
|