Skip to content

Commit 1a8b2d9

Browse files
committed
introduce equi joins
1 parent 1b85b1c commit 1a8b2d9

24 files changed

Lines changed: 983 additions & 66 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,4 @@ third_party/mabel/draken/core/ops.c*
218218
tests/draken/performance/.perf_baseline.json
219219
opteryx/draken/vectors/_hash_api.c*
220220
third_party/mabel/draken/vectors/interval_vector.c*
221+
third_party/mabel/draken/morsels/align.c*
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""
2+
Simple example demonstrating align_tables usage for join operations.
3+
"""
4+
import numpy as np
5+
import pyarrow as pa
6+
7+
from opteryx.draken import Morsel
8+
from opteryx.draken.morsels import align_tables_pyarray
9+
10+
11+
def example_1_basic_alignment():
12+
"""Basic example: align two tables by indices."""
13+
print("Example 1: Basic Alignment")
14+
print("-" * 50)
15+
16+
# Create left table
17+
left = pa.table({
18+
"user_id": pa.array([1, 2, 3, 4, 5], type=pa.int64()),
19+
"name": pa.array(["Alice", "Bob", "Charlie", "David", "Eve"]),
20+
"age": pa.array([25, 30, 35, 40, 45], type=pa.int64()),
21+
})
22+
23+
# Create right table
24+
right = pa.table({
25+
"order_id": pa.array([101, 102, 103, 104, 105], type=pa.int64()),
26+
"amount": pa.array([100.0, 200.0, 150.0, 300.0, 250.0]),
27+
})
28+
29+
# Convert to morsels
30+
left_morsel = Morsel.from_arrow(left)
31+
right_morsel = Morsel.from_arrow(right)
32+
33+
# Align: take rows [0, 2, 4] from left and [1, 3, 4] from right
34+
left_indices = np.array([0, 2, 4], dtype=np.int32)
35+
right_indices = np.array([1, 3, 4], dtype=np.int32)
36+
37+
result = align_tables_pyarray(left_morsel, right_morsel, left_indices, right_indices)
38+
39+
print(f"Input shapes: {left_morsel.shape} + {right_morsel.shape}")
40+
print(f"Output shape: {result.shape}")
41+
print(f"Columns: {result.column_names}\n")
42+
43+
result_arrow = result.to_arrow()
44+
print(result_arrow)
45+
print()
46+
47+
48+
def example_2_inner_join():
49+
"""Example 2: Simulate an inner join."""
50+
print("Example 2: Inner Join Simulation")
51+
print("-" * 50)
52+
53+
# Users table
54+
users = pa.table({
55+
"user_id": pa.array([1, 2, 3, 4, 5], type=pa.int64()),
56+
"name": pa.array(["Alice", "Bob", "Charlie", "David", "Eve"]),
57+
})
58+
59+
# Orders table
60+
orders = pa.table({
61+
"order_id": pa.array([101, 102, 103, 104], type=pa.int64()),
62+
"user_id": pa.array([2, 1, 4, 2], type=pa.int64()), # Foreign key
63+
"amount": pa.array([100.0, 200.0, 150.0, 300.0]),
64+
})
65+
66+
users_morsel = Morsel.from_arrow(users)
67+
orders_morsel = Morsel.from_arrow(orders)
68+
69+
# Simulate hash join: match user_id
70+
# orders.user_id = [2, 1, 4, 2]
71+
# Match to users indices: [1, 0, 3, 1]
72+
user_indices = np.array([1, 0, 3, 1], dtype=np.int32) # Bob, Alice, David, Bob
73+
order_indices = np.array([0, 1, 2, 3], dtype=np.int32) # All orders
74+
75+
result = align_tables_pyarray(users_morsel, orders_morsel, user_indices, order_indices)
76+
77+
print("Joined users with their orders:")
78+
print(result.to_arrow())
79+
print()
80+
81+
82+
def example_3_duplicate_columns():
83+
"""Example 3: Handle duplicate column names."""
84+
print("Example 3: Duplicate Column Handling")
85+
print("-" * 50)
86+
87+
left = pa.table({
88+
"id": pa.array([1, 2, 3], type=pa.int64()),
89+
"value": pa.array([10, 20, 30], type=pa.int64()),
90+
})
91+
92+
right = pa.table({
93+
"id": pa.array([4, 5, 6], type=pa.int64()), # Same column name!
94+
"score": pa.array([100, 200, 300], type=pa.int64()),
95+
})
96+
97+
left_morsel = Morsel.from_arrow(left)
98+
right_morsel = Morsel.from_arrow(right)
99+
100+
indices = np.array([0, 1, 2], dtype=np.int32)
101+
102+
result = align_tables_pyarray(left_morsel, right_morsel, indices, indices)
103+
104+
print("Note: 'id' from right table is ignored (left takes precedence)")
105+
print(f"Result columns: {result.column_names}")
106+
print(result.to_arrow())
107+
print()
108+
109+
110+
def example_4_large_scale():
111+
"""Example 4: Large-scale performance."""
112+
print("Example 4: Large-Scale Performance")
113+
print("-" * 50)
114+
115+
import time
116+
117+
n_rows = 1_000_000
118+
119+
# Large left table
120+
left = pa.table({
121+
"id": pa.array(np.arange(n_rows, dtype=np.int64)),
122+
"val1": pa.array(np.random.rand(n_rows)),
123+
"val2": pa.array(np.random.rand(n_rows)),
124+
"val3": pa.array(np.random.rand(n_rows)),
125+
})
126+
127+
# Large right table
128+
right = pa.table({
129+
"metric1": pa.array(np.random.rand(n_rows)),
130+
"metric2": pa.array(np.random.rand(n_rows)),
131+
})
132+
133+
left_morsel = Morsel.from_arrow(left)
134+
right_morsel = Morsel.from_arrow(right)
135+
136+
# Select 50% of rows
137+
sample_size = n_rows // 2
138+
indices = np.random.choice(n_rows, size=sample_size, replace=True).astype(np.int32)
139+
140+
print(f"Left table: {left_morsel.shape}")
141+
print(f"Right table: {right_morsel.shape}")
142+
print(f"Sample size: {sample_size:,} rows")
143+
144+
# Warmup
145+
_ = align_tables_pyarray(left_morsel, right_morsel, indices, indices)
146+
147+
# Benchmark
148+
num_runs = 5
149+
times = []
150+
151+
for _ in range(num_runs):
152+
start = time.perf_counter()
153+
result = align_tables_pyarray(left_morsel, right_morsel, indices, indices)
154+
elapsed = time.perf_counter() - start
155+
times.append(elapsed)
156+
157+
avg_time = np.mean(times) * 1000
158+
throughput = sample_size / (avg_time / 1000) / 1_000_000
159+
160+
print("\nPerformance:")
161+
print(f" Average time: {avg_time:.1f} ms")
162+
print(f" Throughput: {throughput:.1f} M rows/sec")
163+
print(f" Result shape: {result.shape}")
164+
print()
165+
166+
167+
def example_5_mixed_types():
168+
"""Example 5: Mixed data types."""
169+
print("Example 5: Mixed Data Types")
170+
print("-" * 50)
171+
172+
left = pa.table({
173+
"int_col": pa.array([1, 2, 3], type=pa.int64()),
174+
"float_col": pa.array([1.1, 2.2, 3.3]),
175+
"string_col": pa.array(["a", "b", "c"]),
176+
"bool_col": pa.array([True, False, True]),
177+
})
178+
179+
right = pa.table({
180+
"date_col": pa.array([
181+
pa.scalar(1, type=pa.date32()),
182+
pa.scalar(2, type=pa.date32()),
183+
pa.scalar(3, type=pa.date32()),
184+
]),
185+
"timestamp_col": pa.array([
186+
pa.scalar(1000, type=pa.timestamp('us')),
187+
pa.scalar(2000, type=pa.timestamp('us')),
188+
pa.scalar(3000, type=pa.timestamp('us')),
189+
]),
190+
})
191+
192+
left_morsel = Morsel.from_arrow(left)
193+
right_morsel = Morsel.from_arrow(right)
194+
195+
indices = np.array([0, 2], dtype=np.int32)
196+
197+
result = align_tables_pyarray(left_morsel, right_morsel, indices, indices)
198+
199+
print(f"Handling {result.num_columns} columns of different types:")
200+
for name, dtype in zip(result.column_names, result.column_types):
201+
print(f" {name.decode('utf-8')}: {dtype}")
202+
203+
print(f"\nResult:\n{result.to_arrow()}")
204+
print()
205+
206+
207+
if __name__ == "__main__":
208+
print("=" * 50)
209+
print("align_tables Usage Examples")
210+
print("=" * 50)
211+
print()
212+
213+
example_1_basic_alignment()
214+
example_2_inner_join()
215+
example_3_duplicate_columns()
216+
example_4_large_scale()
217+
example_5_mixed_types()
218+
219+
print("=" * 50)
220+
print("All examples completed!")
221+
print("=" * 50)

examples/non_equi_join_example.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
"""
77

88
import pyarrow as pa
9+
10+
from opteryx import EOS
911
from opteryx.compiled.joins import non_equi_nested_loop_join
10-
from opteryx.operators import NonEquiJoinNode
1112
from opteryx.models import QueryProperties
12-
from opteryx import EOS
13+
from opteryx.operators import NonEquiJoinNode
1314

1415

1516
def example_basic_non_equi_join():

opteryx/__version__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# THIS FILE IS AUTOMATICALLY UPDATED DURING THE BUILD PROCESS
22
# DO NOT EDIT THIS FILE DIRECTLY
33

4-
__build__ = 1830
4+
__build__ = 1836
55
__author__ = "@joocer"
6-
__version__ = "0.26.2-beta.1830"
6+
__version__ = "0.26.2-beta.1836"
77

88
# Store the version here so:
99
# 1) we don't load dependencies by storing it in __init__.py
File renamed without changes.

opteryx/compiled/joins/non_equi_join.pyx renamed to opteryx/compiled/joins/nested_loop_join_non_eqi.pyx

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,14 @@ for efficient columnar comparison operations.
1515
Supports: !=, >, >=, <, <=
1616
"""
1717

18-
import numpy
19-
cimport numpy
20-
numpy.import_array()
21-
2218
from libc.stdint cimport int64_t
2319

2420
from opteryx.compiled.structures.buffers cimport IntBuffer
25-
21+
from opteryx.draken.morsels.morsel cimport Morsel
2622

2723
cpdef tuple non_equi_nested_loop_join(
28-
left_relation,
29-
right_relation,
24+
Morsel left_morsel,
25+
Morsel right_morsel,
3026
str left_column,
3127
str right_column,
3228
str comparison_op
@@ -50,15 +46,11 @@ cpdef tuple non_equi_nested_loop_join(
5046
"""
5147
from opteryx.draken.morsels.morsel import Morsel
5248

53-
# Convert Arrow tables to draken morsels
54-
cdef object left_morsel = Morsel.from_arrow(left_relation)
55-
cdef object right_morsel = Morsel.from_arrow(right_relation)
56-
57-
cdef int64_t left_rows = left_relation.num_rows
58-
cdef int64_t right_rows = right_relation.num_rows
49+
cdef int64_t left_rows = left_morsel.num_rows
50+
cdef int64_t right_rows = right_morsel.num_rows
5951

6052
if left_rows == 0 or right_rows == 0:
61-
return numpy.empty(0, dtype=numpy.int64), numpy.empty(0, dtype=numpy.int64)
53+
return (), ()
6254

6355
# Get column vectors
6456
cdef object left_col_bytes = left_column.encode('utf-8')
@@ -76,28 +68,28 @@ cpdef tuple non_equi_nested_loop_join(
7668
# Nested loop join - compare each left row with each right row
7769
for i in range(left_rows):
7870
left_val = left_vec[i]
79-
71+
8072
# Skip null values in left
8173
if left_val is None:
8274
continue
8375

8476
for j in range(right_rows):
8577
right_val = right_vec[j]
86-
78+
8779
# Skip null values in right
8880
if right_val is None:
8981
continue
9082

9183
# Perform the comparison
92-
if comparison_op == 'not_equals':
84+
if comparison_op == 'NotEq':
9385
comparison_result = left_val != right_val
94-
elif comparison_op == 'greater_than':
86+
elif comparison_op == 'Gt':
9587
comparison_result = left_val > right_val
96-
elif comparison_op == 'greater_than_or_equals':
88+
elif comparison_op == 'GtEq':
9789
comparison_result = left_val >= right_val
98-
elif comparison_op == 'less_than':
90+
elif comparison_op == 'Lt':
9991
comparison_result = left_val < right_val
100-
elif comparison_op == 'less_than_or_equals':
92+
elif comparison_op == 'LtEq':
10193
comparison_result = left_val <= right_val
10294
else:
10395
raise ValueError(f"Unsupported comparison operator: {comparison_op}")

opteryx/compiled/structures/buffers.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ cdef class IntBuffer:
3232
cpdef void append(self, int64_t value)
3333
cpdef void extend(self, iterable)
3434
cpdef numpy.ndarray[int64_t, ndim=1] to_numpy(self)
35+
cpdef const int64_t[::1] get_buffer(self)
3536
cpdef size_t size(self)
3637
cpdef void extend_numpy(self, numpy.ndarray[int64_t, ndim=1] arr)
3738
cpdef void reserve(self, size_t capacity)

opteryx/compiled/structures/buffers.pyx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ cdef class IntBuffer:
8787
memcpy(<void*>&arr[0], <const void*>data_ptr, size * sizeof(int64_t))
8888
return arr
8989

90+
cpdef const int64_t[::1] get_buffer(self):
91+
"""
92+
Get a read-only memoryview of the underlying buffer (zero-copy).
93+
94+
This provides direct access to the C++ buffer without copying.
95+
The memoryview remains valid as long as the IntBuffer exists
96+
and no modifications are made to it.
97+
98+
Returns:
99+
const int64_t[::1]: Read-only memoryview of the buffer
100+
"""
101+
cdef size_t size = self.c_buffer.size()
102+
if size == 0:
103+
return numpy.empty(0, dtype=numpy.int64)
104+
105+
cdef const int64_t* data_ptr = self.c_buffer.data()
106+
return <const int64_t[:size]>data_ptr
107+
90108
cpdef size_t size(self):
91109
return self.c_buffer.size()
92110

opteryx/draken/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
- evaluate: Compiled expression evaluator
1414
"""
1515

16+
from opteryx.draken.morsels.align import align_tables
17+
from opteryx.draken.morsels.align import align_tables_pyarray
1618
from opteryx.draken.morsels.morsel import Morsel
1719
from opteryx.draken.vectors.vector import Vector
1820

@@ -34,4 +36,4 @@ def evaluate(morsel, expression):
3436
return _evaluate(morsel, expression)
3537

3638

37-
__all__ = ("Vector", "Morsel", "evaluate")
39+
__all__ = ("Vector", "Morsel", "evaluate", "align_tables", "align_tables_pyarray")

opteryx/draken/morsels/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@
33
44
Morsel (batch) data structures for columnar processing.
55
"""
6+
7+
from opteryx.draken.morsels.align import align_tables
8+
from opteryx.draken.morsels.align import align_tables_pyarray
9+
10+
__all__ = ["align_tables", "align_tables_pyarray"]

0 commit comments

Comments
 (0)