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