|
5 | 5 | # cython: infer_types=True |
6 | 6 | # cython: wraparound=False |
7 | 7 | # cython: boundscheck=False |
| 8 | +# cython: embedsignature=False |
| 9 | +# cython: c_string_type=bytes |
| 10 | +# cython: c_string_encoding=ascii |
| 11 | +# cython: profile=True |
| 12 | +# cython: linetrace=True |
8 | 13 |
|
9 | 14 | from libc.stdint cimport uint64_t |
10 | 15 |
|
11 | 16 | from opteryx.third_party.abseil.containers cimport FlatHashSet |
12 | | -from cpython.object cimport PyObject_Hash |
13 | | -from libc.stdint cimport int32_t, int64_t, uint8_t, uint64_t, uintptr_t |
14 | | -from cpython.object cimport PyObject_Hash |
15 | | -from cpython.bytes cimport PyBytes_AsString, PyBytes_Size |
| 17 | +cimport cython |
| 18 | +from libc.stdlib cimport malloc, free |
| 19 | +from libc.string cimport memset |
16 | 20 |
|
17 | | -from opteryx.third_party.cyan4973.xxhash cimport cy_xxhash3_64 |
18 | | -from opteryx.third_party.abseil.containers cimport FlatHashSet |
| 21 | +from opteryx.draken.interop.arrow cimport vector_from_arrow |
| 22 | +from opteryx.draken.vectors.vector cimport Vector |
19 | 23 |
|
20 | 24 | import pyarrow |
21 | 25 |
|
22 | | -cdef: |
23 | | - int64_t NULL_HASH = <int64_t>0xBADF00D |
24 | | - int64_t EMPTY_HASH = <int64_t>0xBADC0FFEE |
25 | | - uint64_t SEED = <uint64_t>0x9e3779b97f4a7c15 |
26 | | - |
27 | | -cpdef FlatHashSet count_distinct(column, FlatHashSet seen_hashes): |
28 | | - |
29 | | - """Process column using type-specific handlers""" |
30 | | - cdef object chunk |
31 | | - |
32 | | - for chunk in column.chunks if isinstance(column, pyarrow.ChunkedArray) else [column]: |
33 | | - dtype = chunk.type |
34 | | - if pyarrow.types.is_string(dtype) or pyarrow.types.is_binary(dtype): |
35 | | - process_string_chunk(chunk, seen_hashes) |
36 | | - elif pyarrow.types.is_integer(dtype) or pyarrow.types.is_floating(dtype) or pyarrow.types.is_temporal(dtype): |
37 | | - process_primitive_chunk(chunk, seen_hashes) |
38 | | - elif pyarrow.types.is_list(dtype): |
39 | | - process_list_chunk(chunk, seen_hashes) |
40 | | - elif pyarrow.types.is_boolean(dtype): |
41 | | - process_boolean_chunk(chunk, seen_hashes) |
42 | | - else: |
43 | | - process_generic_chunk(chunk, seen_hashes) |
| 26 | + |
| 27 | +cdef inline FlatHashSet _count_distinct(object column, FlatHashSet seen_hashes): |
| 28 | + """Fast distinct counter that hashes via Draken vectors when possible.""" |
| 29 | + |
| 30 | + cdef list chunks |
| 31 | + cdef Vector draken_vector |
| 32 | + cdef Py_ssize_t row_count = 0 |
| 33 | + cdef Py_ssize_t num_chunks = 0 |
| 34 | + cdef Py_ssize_t i |
| 35 | + cdef uint64_t* data_ptr = NULL |
| 36 | + cdef uint64_t[::1] hash_buffer |
| 37 | + cdef Py_ssize_t max_rows = 0 |
| 38 | + |
| 39 | + if seen_hashes is None: |
| 40 | + seen_hashes = FlatHashSet() |
| 41 | + |
| 42 | + # Get chunks efficiently |
| 43 | + if isinstance(column, pyarrow.ChunkedArray): |
| 44 | + chunks = column.chunks |
| 45 | + num_chunks = len(chunks) |
| 46 | + else: |
| 47 | + chunks = [column] |
| 48 | + num_chunks = 1 |
| 49 | + |
| 50 | + # Find max chunk size |
| 51 | + for i in range(num_chunks): |
| 52 | + max_rows = max(max_rows, len(chunks[i])) |
| 53 | + |
| 54 | + if max_rows > 0: |
| 55 | + data_ptr = <uint64_t*>malloc(max_rows * cython.sizeof(uint64_t)) |
| 56 | + if data_ptr == NULL: |
| 57 | + raise MemoryError("Failed to allocate hash buffer") |
| 58 | + |
| 59 | + try: |
| 60 | + for i in range(num_chunks): |
| 61 | + chunk = chunks[i] |
| 62 | + row_count = len(chunk) |
| 63 | + if row_count == 0: |
| 64 | + continue |
| 65 | + |
| 66 | + memset(data_ptr, 0, row_count * cython.sizeof(uint64_t)) |
| 67 | + hash_buffer = <uint64_t[:row_count]>data_ptr |
| 68 | + draken_vector = <Vector>vector_from_arrow(chunk) |
| 69 | + draken_vector.hash_into(hash_buffer) |
| 70 | + |
| 71 | + seen_hashes.insert_many(data_ptr, row_count) |
| 72 | + finally: |
| 73 | + if data_ptr != NULL: |
| 74 | + free(data_ptr) |
44 | 75 |
|
45 | 76 | return seen_hashes |
46 | 77 |
|
47 | 78 |
|
48 | | -# String Chunk Handler |
49 | | -cdef void process_string_chunk(chunk, FlatHashSet seen_hashes): |
50 | | - cdef: |
51 | | - const uint8_t* validity |
52 | | - const int32_t* offsets |
53 | | - const char* data |
54 | | - Py_ssize_t i, row_count, buffer_length |
55 | | - Py_ssize_t arr_offset, offset_in_bits, offset_in_bytes, byte_index, bit_index |
56 | | - uint64_t hash_val |
57 | | - list buffers = chunk.buffers() |
58 | | - Py_ssize_t str_len |
59 | | - Py_ssize_t start, end |
60 | | - |
61 | | - # Handle potential missing buffers |
62 | | - validity = <uint8_t*><uintptr_t>(buffers[0].address) if len(buffers) > 0 and buffers[0] else NULL |
63 | | - offsets = <int32_t*><uintptr_t>(buffers[1].address) if len(buffers) > 1 else NULL |
64 | | - data = <const char*><uintptr_t>buffers[2].address if len(buffers) > 2 else NULL |
65 | | - row_count = len(chunk) |
66 | | - buffer_length = buffers[2].size if len(buffers) > 2 and buffers[2] else 0 |
67 | | - arr_offset = chunk.offset # Account for non-zero offset in chunk |
68 | | - |
69 | | - # Calculate the byte and bit offset for validity |
70 | | - offset_in_bits = arr_offset & 7 |
71 | | - offset_in_bytes = arr_offset >> 3 |
72 | | - |
73 | | - for i in range(row_count): |
74 | | - |
75 | | - # locate validity bit for this row |
76 | | - byte_index = offset_in_bytes + ((offset_in_bits + i) >> 3) |
77 | | - bit_index = (offset_in_bits + i) & 7 |
78 | | - |
79 | | - # Check validity bit |
80 | | - if validity and not (validity[byte_index] & (1 << bit_index)): |
81 | | - hash_val = NULL_HASH |
82 | | - else: |
83 | | - # Calculate the position in offsets array |
84 | | - start = offsets[arr_offset + i] |
85 | | - end = offsets[arr_offset + i + 1] |
86 | | - str_len = end - start |
87 | | - |
88 | | - # Validate string length and boundaries |
89 | | - if str_len < 0 or (start + str_len) > buffer_length: |
90 | | - hash_val = EMPTY_HASH |
91 | | - else: |
92 | | - # Hash the string using xxhash3_64 |
93 | | - hash_val = <int64_t>cy_xxhash3_64(data + start, <size_t>str_len) |
94 | | - |
95 | | - seen_hashes.just_insert(hash_val) |
96 | | - |
97 | | - |
98 | | -# Primitive Numeric Handler (Int/Float) |
99 | | -cdef void process_primitive_chunk(chunk, FlatHashSet seen_hashes): |
100 | | - cdef: |
101 | | - const uint8_t* validity |
102 | | - const uint8_t* data |
103 | | - Py_ssize_t i, length, item_size |
104 | | - Py_ssize_t arr_offset, offset_in_bits, offset_in_bytes, byte_index, bit_index |
105 | | - uint64_t hash_val |
106 | | - list buffers = chunk.buffers() |
107 | | - |
108 | | - validity = <uint8_t*><uintptr_t>(buffers[0].address) if buffers[0] else NULL |
109 | | - data = <uint8_t*><uintptr_t>(buffers[1].address) |
110 | | - length = len(chunk) |
111 | | - item_size = chunk.type.bit_width // 8 |
112 | | - arr_offset = chunk.offset # Account for non-zero offset in chunk |
113 | | - |
114 | | - # Calculate the byte and bit offset for validity |
115 | | - offset_in_bits = arr_offset & 7 |
116 | | - offset_in_bytes = arr_offset >> 3 |
117 | | - |
118 | | - for i in range(length): |
119 | | - # Correctly locate validity bit for this row |
120 | | - byte_index = offset_in_bytes + ((offset_in_bits + i) >> 3) |
121 | | - bit_index = (offset_in_bits + i) & 7 |
122 | | - |
123 | | - # Check validity bit, considering chunk offset |
124 | | - if validity and not (validity[byte_index] & (1 << bit_index)): |
125 | | - hash_val = NULL_HASH |
126 | | - elif item_size == <uint64_t>8: |
127 | | - # cast 8-byte values directly into hash_val |
128 | | - hash_val = (<uint64_t*>(data + ((arr_offset + i) * 8)))[0] |
129 | | - else: |
130 | | - hash_val = cy_xxhash3_64(data + ((arr_offset + i) * item_size), <size_t>item_size) |
131 | | - |
132 | | - seen_hashes.just_insert(hash_val) |
133 | | - |
134 | | - |
135 | | -# Composite Type Handler (List) |
136 | | -cdef void process_list_chunk(chunk, FlatHashSet seen_hashes): |
137 | | - """ |
138 | | - Processes a ListArray chunk by slicing the child array correctly, |
139 | | - combining each sub-element's hash, and mixing it into `row_hashes`. |
140 | | - """ |
141 | | - |
142 | | - cdef: |
143 | | - const uint8_t* validity |
144 | | - const int32_t* offsets |
145 | | - Py_ssize_t i, j, length, data_size |
146 | | - Py_ssize_t start, end, sub_length |
147 | | - Py_ssize_t arr_offset, child_offset |
148 | | - object child_array, sublist |
149 | | - uint64_t hash_val |
150 | | - list buffers = chunk.buffers() |
151 | | - uint64_t c1 = <uint64_t>0xbf58476d1ce4e5b9 |
152 | | - uint64_t c2 = <uint64_t>0x94d049bb133111eb |
153 | | - cdef char* data_ptr |
154 | | - |
155 | | - # Obtain addresses of validity bitmap and offsets buffer |
156 | | - validity = <uint8_t*><uintptr_t>(buffers[0].address) if buffers[0] else NULL |
157 | | - offsets = <int32_t*><uintptr_t>(buffers[1].address) |
158 | | - |
159 | | - # The child array holds the sub-elements of the list |
160 | | - child_array = chunk.values |
161 | | - |
162 | | - # Number of "top-level" list entries in this chunk |
163 | | - length = len(chunk) |
164 | | - |
165 | | - # Arrow can slice a chunk, so account for chunk.offset |
166 | | - arr_offset = chunk.offset |
167 | | - |
168 | | - # Child array can also be offset |
169 | | - child_offset = child_array.offset |
170 | | - |
171 | | - for i in range(length): |
172 | | - # Check validity for the i-th list in this chunk |
173 | | - if validity and not (validity[i >> 3] & (1 << (i & 7))): |
174 | | - hash_val = NULL_HASH |
175 | | - else: |
176 | | - # Properly compute start/end using arr_offset |
177 | | - start = offsets[arr_offset + i] |
178 | | - end = offsets[arr_offset + i + 1] |
179 | | - sub_length = end - start |
180 | | - |
181 | | - # Initialize hash with a seed |
182 | | - hash_val = SEED |
183 | | - |
184 | | - # Handle empty list |
185 | | - if sub_length == 0: |
186 | | - hash_val = EMPTY_HASH |
187 | | - else: |
188 | | - # Correctly slice child array by adding child_offset |
189 | | - sublist = child_array.slice(start + child_offset, sub_length) |
190 | | - |
191 | | - # Combine each element in the sublist |
192 | | - for j in range(sub_length): |
193 | | - # Convert to Python string, then to UTF-8 bytes |
194 | | - element = sublist[j].as_py().encode("utf-8") |
195 | | - data_ptr = PyBytes_AsString(element) |
196 | | - data_size = PyBytes_Size(element) |
197 | | - |
198 | | - # Combine each element's hash with a simple mix |
199 | | - hash_val = cy_xxhash3_64(<const void*>data_ptr, <size_t>data_size) ^ hash_val |
200 | | - |
201 | | - # Optionally apply SplitMix64 finalizer (commented out for now) |
202 | | - hash_val = (hash_val ^ (hash_val >> 30)) * c1 |
203 | | - hash_val = (hash_val ^ (hash_val >> 27)) * c2 |
204 | | - hash_val = hash_val ^ (hash_val >> 31) |
205 | | - |
206 | | - seen_hashes.just_insert(hash_val) |
207 | | - |
208 | | -# Add boolean chunk handler |
209 | | -cdef void process_boolean_chunk(chunk, FlatHashSet seen_hashes): |
210 | | - cdef: |
211 | | - const uint8_t* validity |
212 | | - const uint8_t* data |
213 | | - Py_ssize_t i, length |
214 | | - uint64_t hash_val |
215 | | - list buffers = chunk.buffers() |
216 | | - |
217 | | - validity = <uint8_t*><uintptr_t>(buffers[0].address) if buffers[0] else NULL |
218 | | - data = <uint8_t*><uintptr_t>(buffers[1].address) if buffers[1] else NULL |
219 | | - length = len(chunk) |
220 | | - |
221 | | - for i in range(length): |
222 | | - if validity and not (validity[i >> 3] & (1 << (i & 7))): |
223 | | - hash_val = NULL_HASH |
224 | | - else: |
225 | | - # Booleans are bit-packed - use bitwise ops to extract values |
226 | | - hash_val = (data[i >> 3] & (1 << (i & 7))) != 0 |
227 | | - |
228 | | - seen_hashes.just_insert(hash_val) |
229 | | - |
230 | | - |
231 | | -cdef void process_generic_chunk(chunk, FlatHashSet seen_hashes): |
232 | | - """Fallback handler for types without a specific handler""" |
233 | | - cdef: |
234 | | - const uint8_t* validity |
235 | | - Py_ssize_t i, length |
236 | | - uint64_t hash_val |
237 | | - list buffers = chunk.buffers() |
238 | | - |
239 | | - validity = <uint8_t*><uintptr_t>(buffers[0].address) if buffers[0] else NULL |
240 | | - length = len(chunk) |
241 | | - |
242 | | - for i in range(length): |
243 | | - if validity and not (validity[i >> 3] & (1 << (i & 7))): |
244 | | - hash_val = NULL_HASH |
245 | | - else: |
246 | | - hash_val = PyObject_Hash(chunk[i]) |
247 | | - |
248 | | - seen_hashes.just_insert(hash_val) |
| 79 | +cpdef FlatHashSet count_distinct(object column, FlatHashSet seen_hashes): |
| 80 | + return _count_distinct(column, seen_hashes) |
| 81 | + |
| 82 | + |
| 83 | +cpdef FlatHashSet count_distinct_draken(object column, FlatHashSet seen_hashes): |
| 84 | + return _count_distinct(column, seen_hashes) |
0 commit comments