herringbone 0.1.0 → 0.2.0

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.
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # XXH64 (seed 0), the hash Parquet bloom filters use.
5
+ #
6
+ # When the optional "xxhash" gem (a C extension) can be loaded it is used, which is 20-40x
7
+ # faster; otherwise hashing is pure Ruby. The gem is only a speedup, so nothing fails without
8
+ # it. +XXHash.backend = :ruby+ forces pure Ruby (for tests and benchmarks).
9
+ #
10
+ # The pure-Ruby version keeps every 64-bit value as two 32-bit halves, and multiplies by the
11
+ # XXH64 primes split into 16-bit pieces, so that no intermediate result leaves the Fixnum range.
12
+ # Masked 64-bit Integer arithmetic is shorter, but allocates a Bignum for almost every operation
13
+ # (about 25 per hash), and with a large live heap (a writer holding a row group) those
14
+ # allocations trigger so many garbage collections that hashing becomes up to 10x slower. The
15
+ # arithmetic is written once with the small code generators below, whose output is inlined into
16
+ # the hashing methods: no method calls or allocations in the hot paths.
17
+ module XXHash
18
+ M = 0xFFFF_FFFF_FFFF_FFFF
19
+ P1 = 11_400_714_785_074_694_791
20
+ P2 = 14_029_467_366_897_019_727
21
+ P3 = 1_609_587_929_392_839_161
22
+ P4 = 9_650_029_242_287_828_579
23
+ P5 = 2_870_177_450_012_600_261
24
+ M32 = 0xFFFF_FFFF
25
+ WORDS = "V*" # frozen, unlike a literal in the generated code
26
+
27
+ # Gem providing the native implementation
28
+ NATIVE_GEM = "xxhash"
29
+
30
+ # Code generators for the pure-Ruby hash: each returns Ruby source operating on a 64-bit
31
+ # value held in two local variables (+hi+ and +lo+, 32 bits each), using t0/t1 as scratch
32
+ module Generator
33
+ module_function
34
+
35
+ # Shifts are written as multiplications and divisions by powers of two, which YARV has
36
+ # specialized instructions for (<< and >> on Integers are method calls).
37
+ #
38
+ # (hi:lo) = (hi:lo) * c mod 2**64. Products are at most 48 bits wide: the low halves are
39
+ # multiplied by 16-bit pieces of c, and only the low 32 bits of the cross terms are kept.
40
+ def mul(hi, lo, c, hi_zero: false)
41
+ ch = c >> 32
42
+ cl = c & M32
43
+ cross = "#{lo} * #{ch & 0xFFFF} + (#{lo} * #{ch >> 16} & 0xFFFF) * 65536"
44
+ cross += " + #{hi} * #{cl & 0xFFFF} + (#{hi} * #{cl >> 16} & 0xFFFF) * 65536" unless hi_zero
45
+ <<~RUBY
46
+ t1 = #{lo} * #{cl >> 16}
47
+ t0 = #{lo} * #{cl & 0xFFFF} + (t1 & 0xFFFF) * 65536
48
+ #{hi} = (t0 / 4294967296 + t1 / 65536 + #{cross}) & 0xFFFFFFFF
49
+ #{lo} = t0 & 0xFFFFFFFF
50
+ RUBY
51
+ end
52
+
53
+ # Rotate (hi:lo) left by r bits (0 < r < 32)
54
+ def rotl(hi, lo, r)
55
+ mask = (1 << (32 - r)) - 1
56
+ <<~RUBY
57
+ t0 = #{hi}
58
+ #{hi} = (#{hi} & #{mask}) * #{1 << r} | #{lo} / #{1 << (32 - r)}
59
+ #{lo} = (#{lo} & #{mask}) * #{1 << r} | t0 / #{1 << (32 - r)}
60
+ RUBY
61
+ end
62
+
63
+ # (hi:lo) += (a_hi:a_lo), where the addend is two expressions (constants or variables)
64
+ def add(hi, lo, a_hi, a_lo)
65
+ <<~RUBY
66
+ #{lo} += #{a_lo}
67
+ #{hi} = (#{hi} + #{a_hi} + #{lo} / 4294967296) & 0xFFFFFFFF
68
+ #{lo} &= 0xFFFFFFFF
69
+ RUBY
70
+ end
71
+
72
+ def add_const(hi, lo, c)
73
+ add(hi, lo, c >> 32, c & M32)
74
+ end
75
+
76
+ # Assigns the 64-bit constant c to (hi:lo)
77
+ def set(hi, lo, c)
78
+ "#{hi} = #{c >> 32}\n#{lo} = #{c & M32}\n"
79
+ end
80
+
81
+ # XXH64 round with a zero accumulator: (hi:lo) = rotl(lane * P2, 31) * P1
82
+ def round0(hi, lo)
83
+ mul(hi, lo, P2) + rotl(hi, lo, 31) + mul(hi, lo, P1)
84
+ end
85
+
86
+ # Stripe round: acc = rotl(acc + lane * P2, 31) * P1, with the lane in (xh:xl)
87
+ def round(hi, lo, xh, xl)
88
+ mul(xh, xl, P2) + add(hi, lo, xh, xl) + rotl(hi, lo, 31) + mul(hi, lo, P1)
89
+ end
90
+
91
+ # h ^= rotl(v * P2, 31) * P1; h = h * P1 + P4, with v in (vh:vl) (left unchanged)
92
+ def merge_round(hi, lo, vh, vl)
93
+ "xh = #{vh}\nxl = #{vl}\n" + round0("xh", "xl") +
94
+ "#{hi} ^= xh\n#{lo} ^= xl\n" + mul(hi, lo, P1) + add_const(hi, lo, P4)
95
+ end
96
+
97
+ # Consumes an 8-byte lane in (xh:xl)
98
+ def lane8(hi, lo)
99
+ round0("xh", "xl") + "#{hi} ^= xh\n#{lo} ^= xl\n" + rotl(hi, lo, 27) + mul(hi, lo, P1) + add_const(hi, lo, P4)
100
+ end
101
+
102
+ # Consumes a 4-byte word in xl
103
+ def lane4(hi, lo)
104
+ "xh = 0\n" + mul("xh", "xl", P1, hi_zero: true) + "#{hi} ^= xh\n#{lo} ^= xl\n" +
105
+ rotl(hi, lo, 23) + mul(hi, lo, P2) + add_const(hi, lo, P3)
106
+ end
107
+
108
+ # Consumes one byte in xl (as it is below 2**16, byte * P5 needs no splitting)
109
+ def lane1(hi, lo)
110
+ <<~RUBY + rotl(hi, lo, 11) + mul(hi, lo, P1)
111
+ t0 = xl * #{P5 & M32}
112
+ #{hi} ^= (t0 / 4294967296 + xl * #{P5 >> 32}) & 0xFFFFFFFF
113
+ #{lo} ^= t0 & 0xFFFFFFFF
114
+ RUBY
115
+ end
116
+
117
+ # Final mix; evaluates to the hash as one Integer
118
+ def avalanche(hi, lo)
119
+ "#{lo} ^= #{hi} / 2\n" + mul(hi, lo, P2) +
120
+ "#{lo} ^= (#{hi} & 0x1FFFFFFF) * 8 | #{lo} / 536870912\n#{hi} ^= #{hi} / 536870912\n" +
121
+ mul(hi, lo, P3) + "#{lo} ^= #{hi}\n(#{hi} << 32) | #{lo}\n"
122
+ end
123
+
124
+ def source
125
+ g = self
126
+ <<~RUBY
127
+ # XXH64 of 8 bytes given as two little-endian 32-bit halves
128
+ def self.ruby_xxh64_lane(xh, xl)
129
+ #{g.set("h", "l", P5 + 8)}
130
+ #{g.lane8("h", "l")}
131
+ #{g.avalanche("h", "l")}
132
+ end
133
+
134
+ # XXH64 of 4 bytes given as a little-endian 32-bit word
135
+ def self.ruby_xxh64_u32(xl)
136
+ #{g.set("h", "l", P5 + 4)}
137
+ #{g.lane4("h", "l")}
138
+ #{g.avalanche("h", "l")}
139
+ end
140
+
141
+ def self.ruby_xxh64(bytes)
142
+ len = bytes.bytesize
143
+ words = bytes.unpack(WORDS)
144
+ i = 0
145
+ if len >= 32
146
+ #{g.set("ah", "al", (P1 + P2) & M)}
147
+ #{g.set("bh", "bl", P2)}
148
+ ch = 0
149
+ cl = 0
150
+ #{g.set("dh", "dl", (-P1) & M)}
151
+ limit = (len >> 5) << 3
152
+ while i < limit
153
+ xl = words[i]
154
+ xh = words[i + 1]
155
+ #{g.round("ah", "al", "xh", "xl")}
156
+ xl = words[i + 2]
157
+ xh = words[i + 3]
158
+ #{g.round("bh", "bl", "xh", "xl")}
159
+ xl = words[i + 4]
160
+ xh = words[i + 5]
161
+ #{g.round("ch", "cl", "xh", "xl")}
162
+ xl = words[i + 6]
163
+ xh = words[i + 7]
164
+ #{g.round("dh", "dl", "xh", "xl")}
165
+ i += 8
166
+ end
167
+ h = ah
168
+ l = al
169
+ #{g.rotl("h", "l", 1)}
170
+ yh = bh
171
+ yl = bl
172
+ #{g.rotl("yh", "yl", 7)}
173
+ #{g.add("h", "l", "yh", "yl")}
174
+ yh = ch
175
+ yl = cl
176
+ #{g.rotl("yh", "yl", 12)}
177
+ #{g.add("h", "l", "yh", "yl")}
178
+ yh = dh
179
+ yl = dl
180
+ #{g.rotl("yh", "yl", 18)}
181
+ #{g.add("h", "l", "yh", "yl")}
182
+ #{g.merge_round("h", "l", "ah", "al")}
183
+ #{g.merge_round("h", "l", "bh", "bl")}
184
+ #{g.merge_round("h", "l", "ch", "cl")}
185
+ #{g.merge_round("h", "l", "dh", "dl")}
186
+ #{g.add("h", "l", 0, "len")}
187
+ else
188
+ #{g.set("h", "l", P5)}
189
+ #{g.add("h", "l", 0, "len")}
190
+ end
191
+ nwords = words.size
192
+ while i + 2 <= nwords
193
+ xl = words[i]
194
+ xh = words[i + 1]
195
+ #{g.lane8("h", "l")}
196
+ i += 2
197
+ end
198
+ if i < nwords
199
+ xl = words[i]
200
+ #{g.lane4("h", "l")}
201
+ i += 1
202
+ end
203
+ pos = i << 2
204
+ while pos < len
205
+ xl = bytes.getbyte(pos)
206
+ #{g.lane1("h", "l")}
207
+ pos += 1
208
+ end
209
+ #{g.avalanche("h", "l")}
210
+ end
211
+ RUBY
212
+ end
213
+ end
214
+
215
+ module_eval(Generator.source, __FILE__, __LINE__)
216
+
217
+ @native = nil # nil: not resolved yet, false: pure Ruby, else the native module
218
+ @native_lib = nil
219
+
220
+ class << self
221
+ # XXH64 of a String's bytes, as an unsigned 64-bit Integer
222
+ def xxh64(bytes)
223
+ native = @native
224
+ native = resolve_backend if native.nil?
225
+ native ? native.xxh64(bytes, 0) : ruby_xxh64(bytes)
226
+ end
227
+
228
+ # XXH64 of 8 bytes given as a little-endian 64-bit Integer (an INT64 or DOUBLE's PLAIN
229
+ # encoding), signed or unsigned: only its low 64 bits are used
230
+ def xxh64_u64(lane)
231
+ native = @native
232
+ native = resolve_backend if native.nil?
233
+ return native.xxh64([lane].pack("Q<"), 0) if native
234
+ ruby_xxh64_lane((lane >> 32) & M32, lane & M32)
235
+ end
236
+
237
+ # XXH64 of 4 bytes given as a little-endian 32-bit Integer (INT32, FLOAT), signed or unsigned
238
+ def xxh64_u32(word)
239
+ native = @native
240
+ native = resolve_backend if native.nil?
241
+ return native.xxh64([word].pack("L<"), 0) if native
242
+ ruby_xxh64_u32(word & M32)
243
+ end
244
+
245
+ # Hashes of many 64-bit Integers (low 64 bits of each)
246
+ def xxh64_u64_all(lanes)
247
+ native = @native
248
+ native = resolve_backend if native.nil?
249
+ if native
250
+ packed = lanes.pack("Q<*")
251
+ Array.new(lanes.size) { |i| native.xxh64(packed.byteslice(i << 3, 8), 0) }
252
+ else
253
+ lanes.map { |v| ruby_xxh64_lane(v / 4_294_967_296 & M32, v & M32) }
254
+ end
255
+ end
256
+
257
+ # Hashes of many 32-bit Integers (low 32 bits of each)
258
+ def xxh64_u32_all(words)
259
+ native = @native
260
+ native = resolve_backend if native.nil?
261
+ if native
262
+ packed = words.pack("L<*")
263
+ Array.new(words.size) { |i| native.xxh64(packed.byteslice(i << 2, 4), 0) }
264
+ else
265
+ words.map { |v| ruby_xxh64_u32(v & M32) }
266
+ end
267
+ end
268
+
269
+ # Hashes of many Strings
270
+ def xxh64_all(strings)
271
+ native = @native
272
+ native = resolve_backend if native.nil?
273
+ native ? strings.map { |s| native.xxh64(s, 0) } : strings.map { |s| ruby_xxh64(s) }
274
+ end
275
+
276
+ # :native when the xxhash gem is used, :ruby otherwise
277
+ def backend
278
+ native = @native
279
+ native = resolve_backend if native.nil?
280
+ native ? :native : :ruby
281
+ end
282
+
283
+ # :ruby forces pure Ruby, :native requires the xxhash gem (UnsupportedError if it cannot be
284
+ # loaded), nil goes back to the default: native when available
285
+ def backend=(name)
286
+ @native = case name
287
+ when :ruby then false
288
+ when :native
289
+ native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem could not be loaded")
290
+ when nil then nil
291
+ else raise ArgumentError, "Unknown XXHash backend #{name.inspect} (expected :ruby, :native or nil)"
292
+ end
293
+ end
294
+
295
+ # Whether the native xxhash gem can be loaded (whatever the selected backend)
296
+ def native_available?
297
+ !!native_library
298
+ end
299
+
300
+ private
301
+
302
+ def resolve_backend
303
+ @native = native_library || false
304
+ end
305
+
306
+ def native_library
307
+ if @native_lib.nil?
308
+ @native_lib = begin
309
+ require NATIVE_GEM
310
+ defined?(::XXhash::XXhashInternal) ? ::XXhash::XXhashInternal : ::XXhash
311
+ rescue LoadError
312
+ false
313
+ end
314
+ end
315
+ @native_lib
316
+ end
317
+ end
318
+ end
319
+ end
data/lib/herringbone.rb CHANGED
@@ -7,8 +7,9 @@ require_relative "herringbone/version"
7
7
  # Pure-Ruby reader and writer for Apache Parquet files
8
8
  module Herringbone
9
9
  class Error < StandardError; end
10
+ # The file is not valid Parquet (bad metadata, corrupt pages...)
10
11
  class FormatError < Error; end
11
- class DecodeError < FormatError; end
12
+ # A value cannot be written to its column
12
13
  class EncodeError < Error; end
13
14
  class UnsupportedError < Error; end
14
15
  end
@@ -28,18 +29,44 @@ require_relative "herringbone/active_record"
28
29
  require_relative "herringbone/reader"
29
30
  require_relative "herringbone/byte_values"
30
31
  require_relative "herringbone/writer"
32
+ require_relative "herringbone/xxhash"
33
+ require_relative "herringbone/bloom_filter"
34
+ require_relative "herringbone/inspector"
35
+ require_relative "herringbone/visualizer"
31
36
 
32
37
  module Herringbone
33
- Delta = Encodings::Delta
34
-
35
38
  module_function
36
39
 
37
- def open(path, &block) = Reader.open(path, &block)
38
- def read(path, columns: nil) = Reader.open(path) { |r| r.rows(columns: columns) }
40
+ # Writes +records+ to +io+ (any IO responding to #write; Herringbone never opens files by path)
41
+ # and returns the number of rows written. +records+ is an Enumerable of rows, or an ActiveRecord
42
+ # model or relation, which is read with find_each. Without +schema+, the schema comes from the
43
+ # model's columns (Schema.from_active_record) or is inferred from the first rows (Schema.infer).
44
+ # Other options go to Writer.
45
+ #
46
+ # File.open("orders.parquet", "wb") { |f| Herringbone.write(f, Order.where(created_at: 1.year.ago..)) }
47
+ def write(io, records, schema: nil, **options)
48
+ model = if records.respond_to?(:klass) then records.klass
49
+ elsif records.respond_to?(:columns) && records.respond_to?(:find_each) then records
50
+ end
51
+ schema ||= model ? Schema.from_active_record(model) : Schema.infer(records)
52
+ Writer.open(io, schema, **options) do |writer|
53
+ if records.respond_to?(:find_each)
54
+ records.find_each { |record| writer << record }
55
+ else
56
+ records.each { |record| writer << record }
57
+ end
58
+ writer.rows_written
59
+ end
60
+ end
39
61
 
40
- # Writes an Enumerable of row Hashes. Without a schema, one is inferred from the first rows.
41
- def write(path, rows, schema: nil, **options)
42
- schema ||= Schema.infer(rows)
43
- Writer.open(path, schema, **options) { |w| w.write_rows(rows) }
62
+ # Compression codecs this process can read and write, e.g. [:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd].
63
+ # :zstd and :brotli are listed when the zstd-ruby / brotli gems can be loaded.
64
+ def codecs
65
+ Compression::NAMES.values.select do |name|
66
+ Compression.ensure_available!(name)
67
+ true
68
+ rescue UnsupportedError
69
+ false
70
+ end
44
71
  end
45
72
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: herringbone
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -23,37 +23,9 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: brotli
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
- - !ruby/object:Gem::Dependency
41
- name: zstd-ruby
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - ">="
45
- - !ruby/object:Gem::Version
46
- version: '0'
47
- type: :runtime
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - ">="
52
- - !ruby/object:Gem::Version
53
- version: '0'
54
- description: Reads and writes Apache Parquet files without native extensions or Thrift.
55
- Snappy and LZ4 are implemented in Ruby; ZSTD and Brotli are used when their gems
56
- are present.
26
+ description: Reads and writes Apache Parquet files in Ruby, without Thrift or native
27
+ extensions of its own. Snappy and LZ4 are implemented in Ruby and GZIP uses zlib;
28
+ ZSTD and Brotli are available when the zstd-ruby / brotli gems are installed.
57
29
  email:
58
30
  - me@julik.nl
59
31
  executables:
@@ -66,6 +38,7 @@ files:
66
38
  - bin/herringbone
67
39
  - lib/herringbone.rb
68
40
  - lib/herringbone/active_record.rb
41
+ - lib/herringbone/bloom_filter.rb
69
42
  - lib/herringbone/byte_values.rb
70
43
  - lib/herringbone/codecs/lz4.rb
71
44
  - lib/herringbone/codecs/snappy.rb
@@ -74,13 +47,20 @@ files:
74
47
  - lib/herringbone/encodings/plain.rb
75
48
  - lib/herringbone/encodings/rle.rb
76
49
  - lib/herringbone/format.rb
50
+ - lib/herringbone/inspector.rb
77
51
  - lib/herringbone/io_buffer_support.rb
78
52
  - lib/herringbone/reader.rb
53
+ - lib/herringbone/reader/column_chunk_reader.rb
54
+ - lib/herringbone/reader/column_cursor.rb
55
+ - lib/herringbone/reader/page_stream.rb
56
+ - lib/herringbone/reader/scan.rb
79
57
  - lib/herringbone/schema.rb
80
58
  - lib/herringbone/thrift.rb
81
59
  - lib/herringbone/types.rb
82
60
  - lib/herringbone/version.rb
61
+ - lib/herringbone/visualizer.rb
83
62
  - lib/herringbone/writer.rb
63
+ - lib/herringbone/xxhash.rb
84
64
  licenses:
85
65
  - MIT
86
66
  metadata: {}