herringbone 0.2.0 → 0.3.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,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ class Reader
5
+ # read(as: :numo) and each_batch(as: :numo): columns as Numo arrays. Numo is optional:
6
+ # "numo/narray" (from numo-narray-alt or numo-narray) is required on first use.
7
+ #
8
+ # Flat INT32 / INT64 / FLOAT / DOUBLE / BOOLEAN columns are decoded without a Ruby object
9
+ # per value: PLAIN and BYTE_STREAM_SPLIT pages go through Numo::X.from_binary, dictionary
10
+ # pages through dictionary[indices], and definition levels become a validity mask. Other
11
+ # columns are read like as: :columns and converted per batch.
12
+ #
13
+ # Type mapping (following Polars' Series#to_numo and Rover):
14
+ # INT32 / INT64 (also TIME) Numo::Int32 / Int64
15
+ # INT(8/16, signed) Int8 / Int16
16
+ # INT(8/16/32/64, unsigned) UInt8 / UInt16 / UInt32 / UInt64
17
+ # FLOAT / FLOAT16 / DOUBLE SFloat / SFloat / DFloat, NaN for nulls
18
+ # integers with nulls DFloat with NaN (exact up to 2^53)
19
+ # BOOLEAN Bit; RObject of true/false/nil with nulls
20
+ # list<number>, all rows of the 2-D [rows, length] of the element type
21
+ # same length and no nulls
22
+ # anything else RObject of the values as: :rows returns
23
+ module NumoColumns
24
+ # Whether the host is little-endian, so PLAIN bytes (always little-endian) can be handed to
25
+ # Numo's from_binary, which reads native byte order.
26
+ LITTLE_ENDIAN = [1].pack("S") == [1].pack("v")
27
+ # Shorthand for Format::Type.
28
+ T = Format::Type
29
+
30
+ @loaded = false
31
+ @mutex = Mutex.new
32
+
33
+ # How a top-level field becomes a Numo array.
34
+ # kind: :fixed (a numeric or boolean leaf), :list (list of numbers) or :object
35
+ # klass: the Numo class of the result without nulls
36
+ # bin_klass: for :fixed leaves whose pages can be decoded straight into Numo, the class
37
+ # of the decoded values (same width as the physical type), else nil
38
+ # arrays: whether values may be Arrays (list fields)
39
+ Spec = Struct.new(:kind, :klass, :bin_klass, :arrays) do
40
+ # @return [Boolean] whether pages can be decoded straight into Numo (via NumoCursor)
41
+ def fast? = !bin_klass.nil?
42
+
43
+ # @return [Boolean] whether the result class is a float type, which holds nulls as NaN in place
44
+ def float? = klass == Numo::SFloat || klass == Numo::DFloat
45
+ end
46
+
47
+ module_function
48
+
49
+ # Requires Numo once per process (thread-safe); a no-op after the first success.
50
+ # @return [void]
51
+ # @raise [UnsupportedError] if neither numo-narray-alt nor numo-narray can be loaded
52
+ def load!
53
+ return if @loaded
54
+ @mutex.synchronize do
55
+ next if @loaded
56
+ begin
57
+ require_library
58
+ rescue LoadError => e
59
+ raise UnsupportedError, "as: :numo needs the \"numo-narray-alt\" gem (or \"numo-narray\"), " \
60
+ "which could not be loaded (#{e.message}). Add `gem \"numo-narray-alt\"` to your Gemfile " \
61
+ "to read columns into Numo arrays."
62
+ end
63
+ @loaded = true
64
+ end
65
+ end
66
+
67
+ # Separate from load! so tests can stub it to simulate a missing gem.
68
+ # @return [Boolean] true if the library was loaded by this call
69
+ # @raise [LoadError] if "numo/narray" is not installed
70
+ def require_library
71
+ require "numo/narray"
72
+ end
73
+
74
+ # Picks how +field+ is converted: a numeric or boolean leaf, a list of numbers with a single
75
+ # repetition level, or anything else as RObject.
76
+ # @param field [Schema::Field] top-level field of the read schema
77
+ # @return [Spec] how to build the field's Numo array
78
+ def spec_for(field)
79
+ if field.leaf?
80
+ klass, bin = leaf_classes(field.column)
81
+ return Spec.new(:fixed, klass, bin) if klass
82
+ elsif field.kind == :list && field.element.leaf? && field.element.column.max_repetition_level == 1
83
+ klass, = leaf_classes(field.element.column)
84
+ return Spec.new(:list, klass, nil) if klass && klass != Numo::Bit
85
+ end
86
+ Spec.new(:object, Numo::RObject, nil, field.kind == :list)
87
+ end
88
+
89
+ # [Numo class, class to decode pages into (nil: convert Ruby values)], or nil for columns
90
+ # that become RObject
91
+ # @param column [Schema::Column] leaf column
92
+ # @return [Array(Class, Class), Array(Class, nil), nil] result class and page decode class, or nil
93
+ def leaf_classes(column)
94
+ kind, bits, signed = Types.logical_of(column.node)
95
+ case column.type
96
+ when T::BOOLEAN
97
+ [Numo::Bit, Numo::Bit] if kind.nil?
98
+ when T::INT32
99
+ case kind
100
+ when nil, :time then [Numo::Int32, Numo::Int32]
101
+ when :integer
102
+ if signed
103
+ [{8 => Numo::Int8, 16 => Numo::Int16}.fetch(bits, Numo::Int32), Numo::Int32]
104
+ else
105
+ [{8 => Numo::UInt8, 16 => Numo::UInt16}.fetch(bits, Numo::UInt32), Numo::UInt32]
106
+ end
107
+ end
108
+ when T::INT64
109
+ case kind
110
+ when nil, :time then [Numo::Int64, Numo::Int64]
111
+ when :integer then signed ? [Numo::Int64, Numo::Int64] : [Numo::UInt64, Numo::UInt64]
112
+ end
113
+ when T::FLOAT then [Numo::SFloat, Numo::SFloat] if kind.nil?
114
+ when T::DOUBLE then [Numo::DFloat, Numo::DFloat] if kind.nil?
115
+ when T::FIXED_LEN_BYTE_ARRAY then [Numo::SFloat, nil] if kind == :float16
116
+ end
117
+ end
118
+
119
+ # The result for a field read through Numo cursors: +parts+ are [values, validity] pairs
120
+ # (validity is a Numo::Bit, or nil when every value is present)
121
+ # Booleans with nulls become an RObject of true/false/nil, other types with nulls a float
122
+ # array with NaN in the null slots.
123
+ # @param spec [Spec] field spec from spec_for
124
+ # @param parts [Array<Array(Numo::NArray, Numo::Bit)>] one [values, validity] pair per batch
125
+ # @return [Numo::NArray] the whole column
126
+ def finish_fixed(spec, parts)
127
+ return spec.klass.new(0) if parts.empty?
128
+ full = (parts.size == 1) ? parts[0][0] : Numo::NArray.concatenate(parts.map(&:first))
129
+ valid = nil
130
+ if parts.any? { |_, v| v }
131
+ valid = (parts.size == 1) ? parts[0][1] : Numo::NArray.concatenate(parts.map { |f, v| v || Numo::Bit.ones(f.size) })
132
+ valid = nil if valid.count_false.zero?
133
+ end
134
+ unless valid
135
+ return full if full.instance_of?(spec.klass)
136
+ return spec.klass.cast(full)
137
+ end
138
+ if spec.klass == Numo::Bit
139
+ bits = full.to_a
140
+ present = valid.to_a
141
+ return robject(Array.new(bits.size) { |i| (present[i] == 1) ? bits[i] == 1 : nil })
142
+ end
143
+ out_class = spec.float? ? spec.klass : Numo::DFloat
144
+ out = full.instance_of?(out_class) ? full.dup : out_class.cast(full) # never write into a view
145
+ out[(~valid).where] = Float::NAN
146
+ out
147
+ end
148
+
149
+ # The result for a field read as Ruby values (+parts+ are Arrays of values)
150
+ # @param spec [Spec] field spec from spec_for
151
+ # @param parts [Array<Array>] the field's values, one Array per batch
152
+ # @return [Numo::NArray] the whole column
153
+ def finish_values(spec, parts)
154
+ values = (parts.size == 1) ? parts[0] : parts.flatten(1)
155
+ case spec.kind
156
+ when :object then robject(values, arrays: spec.arrays)
157
+ when :list then list_array(spec.klass, values)
158
+ else from_values(spec.klass, values)
159
+ end
160
+ end
161
+
162
+ # A numeric / boolean leaf's Ruby values as a Numo array
163
+ # @param klass [Class] Numo class for the column without nulls
164
+ # @param values [Array<Numeric, Boolean, nil>] the column's values, nil for nulls
165
+ # @return [Numo::NArray] +klass+ without nulls; SFloat/DFloat with NaN (or RObject for booleans) with nulls
166
+ def from_values(klass, values)
167
+ if values.include?(nil)
168
+ return robject(values) if klass == Numo::Bit
169
+ filled = values.map { |v| v.nil? ? Float::NAN : v }
170
+ return ((klass == Numo::SFloat) ? Numo::SFloat : Numo::DFloat).cast(filled)
171
+ end
172
+ return klass.new(0) if values.empty?
173
+ return Numo::Bit.cast(values.map { |v| v ? 1 : 0 }) if klass == Numo::Bit
174
+ klass.cast(values)
175
+ end
176
+
177
+ # Lists of numbers: 2-D [rows, length] when every row is a list of the same, non-zero
178
+ # length without nulls; otherwise an RObject of the Arrays
179
+ # @param klass [Class] Numo class of the list elements
180
+ # @param values [Array<Array<Numeric, nil>, nil>] one list (or nil) per row
181
+ # @return [Numo::NArray] a 2-D +klass+ array or a 1-D Numo::RObject
182
+ def list_array(klass, values)
183
+ first = values.first
184
+ width = first.is_a?(Array) ? first.size : 0
185
+ if width.positive? && values.all? { |v| v.is_a?(Array) && v.size == width && !v.include?(nil) }
186
+ return klass.cast(values)
187
+ end
188
+ robject(values, arrays: true)
189
+ end
190
+
191
+ # A 1-D Numo::RObject holding +values+ as they are. Only list columns hold Arrays, which
192
+ # #store and .cast would turn into more dimensions.
193
+ # @param values [Array] one Ruby value per row
194
+ # @param arrays [Boolean] whether +values+ may contain Arrays that must stay single elements
195
+ # @return [Numo::RObject] 1-D array of +values+
196
+ def robject(values, arrays: false)
197
+ return Numo::RObject.new(values.size).seq.map { |i| values[i] } if arrays
198
+ out = Numo::RObject.new(values.size)
199
+ out.store(values)
200
+ out
201
+ end
202
+
203
+ # Width => Numo vector of 1 << j for each bit j, built lazily; dotting a bit matrix with it
204
+ # turns rows of bits into integers. Int64 above 30 bits so the sums cannot overflow.
205
+ POWERS = Hash.new { |h, w| h[w] = ((w > 30) ? Numo::Int64 : Numo::Int32).cast(Array.new(w) { |j| 1 << j }) }
206
+
207
+ # +count+ bit-packed values of +width+ bits (LSB first) from +data+ at +pos+ as a Numo
208
+ # array, without a Ruby object per value
209
+ # @param data [String] binary page data
210
+ # @param pos [Integer] byte offset of the first packed value
211
+ # @param count [Integer] number of values, a multiple of 8
212
+ # @param width [Integer] bits per value
213
+ # @return [Numo::NArray] unsigned integer array of +count+ values (element type depends on +width+)
214
+ def unpack_bits(data, pos, count, width)
215
+ return Numo::Int32.zeros(count) if width.zero?
216
+ nbytes = count * width / 8 # count is a multiple of 8
217
+ bytes = data.byteslice(pos, nbytes) || "".b
218
+ bytes += "\0" * (nbytes - bytes.bytesize) if bytes.bytesize < nbytes # truncated last run
219
+ case width
220
+ when 1 then return Numo::UInt8.cast(Numo::Bit.from_binary(bytes, [count]))
221
+ when 8 then return Numo::UInt8.from_binary(bytes)
222
+ when 16 then return Numo::UInt16.from_binary(bytes) if LITTLE_ENDIAN
223
+ when 32 then return Numo::UInt32.from_binary(bytes) if LITTLE_ENDIAN
224
+ end
225
+ # Row i of the [count, width] bit matrix holds value i's bits, least significant first
226
+ bits = Numo::Bit.from_binary(bytes, [count * width]).reshape(count, width)
227
+ pow = POWERS[width]
228
+ pow.class.cast(bits).dot(pow)
229
+ end
230
+ end
231
+
232
+ # Hands out the next +k+ rows of a flat (non-repeated) numeric or boolean column as a
233
+ # [Numo values, validity] pair: values has one slot per row (zero where the row is null),
234
+ # validity is a Numo::Bit or nil when all rows are present.
235
+ class NumoCursor
236
+ # @param chunk_reader [ColumnChunkReader] reader for the column's chunk in one row group
237
+ # @param spec [NumoColumns::Spec] a :fixed spec with a bin_klass
238
+ def initialize(chunk_reader, spec)
239
+ @src = chunk_reader
240
+ col = chunk_reader.column
241
+ @path = col.dotted_path
242
+ @max_def = col.max_definition_level
243
+ @klass = spec.bin_klass
244
+ @bytes = NumoColumns::LITTLE_ENDIAN && @klass != Numo::Bit
245
+ @dictionaries = {}.compare_by_identity
246
+ @page = nil
247
+ @row = 0
248
+ @page_idx = -1
249
+ end
250
+
251
+ # @return [Integer] index within the chunk of the next row #take returns
252
+ attr_reader :row
253
+
254
+ # Moves forward to row +target+ of the chunk, jumping over pages with the OffsetIndex
255
+ # @param target [Integer] row index within the chunk, not before #row
256
+ # @return [void]
257
+ # @raise [ArgumentError] if +target+ is before the current row
258
+ # @raise [FormatError] if the chunk runs out of pages
259
+ def seek(target)
260
+ raise ArgumentError, "Cannot seek backwards (at row #{@row}, asked for #{target})" if target < @row
261
+ return if target == @row
262
+ locs = @src.locations
263
+ if locs
264
+ j = locs.bsearch_index { |loc| loc.first_row_index > target }
265
+ j = (j || locs.size) - 1
266
+ if j > @page_idx
267
+ @src.jump_to_page(j)
268
+ @page = nil
269
+ @page_idx = j - 1
270
+ @row = locs[j].first_row_index
271
+ end
272
+ end
273
+ k = target - @row
274
+ while k > 0
275
+ load_page! while @page.nil? || @page.remaining.zero?
276
+ t = @page.remaining
277
+ t = k if k < t
278
+ defs, = @page.read_levels(t)
279
+ @page.skip_values(defs ? defs.count(@max_def) : t)
280
+ k -= t
281
+ end
282
+ @row = target
283
+ end
284
+
285
+ # Reads the next +k+ rows, crossing page boundaries as needed.
286
+ # @param k [Integer] number of rows
287
+ # @return [Array(Numo::NArray, Numo::Bit), Array(Numo::NArray, nil)] values (zero in null slots)
288
+ # and validity, nil when all +k+ rows are present
289
+ # @raise [FormatError] if the chunk runs out of pages
290
+ def take(k)
291
+ parts = []
292
+ nulls = false
293
+ @row += k
294
+ while k > 0
295
+ load_page! while @page.nil? || @page.remaining.zero?
296
+ t = @page.remaining
297
+ t = k if k < t
298
+ nv = t
299
+ if (valid = @page.read_validity_numo(t, @max_def))
300
+ nv = valid.count_true
301
+ end
302
+ dense = values(nv)
303
+ if nv < t
304
+ full = @klass.zeros(t)
305
+ full[valid.where] = dense if nv.positive?
306
+ nulls = true
307
+ else
308
+ full = dense
309
+ valid = nil
310
+ end
311
+ parts << [full, valid]
312
+ k -= t
313
+ end
314
+ return parts.first if parts.size == 1
315
+ full = Numo::NArray.concatenate(parts.map(&:first))
316
+ return [full, nil] unless nulls
317
+ [full, Numo::NArray.concatenate(parts.map { |f, v| v || Numo::Bit.ones(f.size) })]
318
+ end
319
+
320
+ private
321
+
322
+ # Decodes the current page's next +n+ non-null values into a +@klass+ array, by the cheapest
323
+ # route the value decoder supports: raw bytes, dictionary indices, its own Numo decoding, or
324
+ # Ruby values as a last resort.
325
+ # @param n [Integer] number of non-null values
326
+ # @return [Numo::NArray] +n+ values, never a view into a cached dictionary
327
+ def values(n)
328
+ return @klass.new(0) if n.zero?
329
+ dec = @page.value_decoder
330
+ if @bytes && dec.respond_to?(:read_bytes)
331
+ @klass.from_binary(dec.read_bytes(n))
332
+ elsif dec.is_a?(PageStream::DictionaryDecoder)
333
+ dict = @dictionaries[dec.dictionary] ||= cast(dec.dictionary)
334
+ dict[dec.read_indices_numo(n)].dup # a copy, not a view the caller could write through
335
+ elsif dec.respond_to?(:read_numo)
336
+ dec.read_numo(n)
337
+ else
338
+ vals = @page.read_values(n)
339
+ conv = @page.converter
340
+ cast(conv ? vals.map!(&conv) : vals)
341
+ end
342
+ end
343
+
344
+ # @param values [Array<Numeric, Boolean>] decoded values, without nulls
345
+ # @return [Numo::NArray] +values+ as a +@klass+ array (booleans as 0/1 bits)
346
+ def cast(values)
347
+ if @klass == Numo::Bit
348
+ Numo::Bit.cast(values.map { |v| v ? 1 : 0 })
349
+ else
350
+ @klass.cast(values)
351
+ end
352
+ end
353
+
354
+ # Advances to the chunk's next data page.
355
+ # @return [void]
356
+ # @raise [FormatError] if there are no more pages
357
+ def load_page!
358
+ @page = @src.next_stream
359
+ @page_idx += 1
360
+ return if @page
361
+ raise FormatError, "Column #{@path}: ran out of pages after #{@src.seen} of #{@src.total} values"
362
+ end
363
+ end
364
+ end
365
+ end