Fix Arrow array offset bug for sliced arrays - #3
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a correctness bug across the NumPy adapters to properly respect non-zero Arrow array offsets (e.g., sliced/filtered arrays), ensuring values and validity bitmaps are read from the correct logical start rather than buffer byte 0.
Changes:
- Update fixed-width and boolean adapters to account for
pa_array.offsetwhen reading buffers and unpacking bit-packed data. - Update bitmap and string extraction utilities to handle offsets correctly.
- Add a new test suite covering offset handling across multiple Arrow array types.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| test/test_offset.py | Adds offset-focused tests across uniform, boolean, datetime, string, struct, and bitmap boundary cases. |
| numbarrow/utils/arrow_array_utils.py | Applies offset-aware pointer arithmetic for fixed-width arrays, repacks validity bitmaps for offsets, and slices string offsets correctly. |
| numbarrow/core/is_null.py | Adds a Numba-JIT’d helper to unpack bit-packed boolean buffers with a bit offset. |
| numbarrow/core/adapters.py | Switches BooleanArray adaptation to use offset-aware boolean unpacking and offset-aware validity bitmap extraction. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| bits = np.unpackbits(raw_bitmap, bitorder="little") | ||
| sliced_bits = bits[offset:offset + length] | ||
| pad = (-length) % 8 | ||
| if pad: | ||
| sliced_bits = np.pad(sliced_bits, (0, pad), mode="constant") | ||
| result = np.packbits(sliced_bits, bitorder="little") |
There was a problem hiding this comment.
create_bitmap() repacks sliced bitmaps by calling np.unpackbits(raw_bitmap) on the entire underlying bitmap buffer. For a small slice taken from a very large Arrow array, this can allocate an extremely large temporary bits array (8 * buffer_size) and cause major memory/time overhead. Consider limiting work to only the bytes covering [offset, offset+length) (e.g., slice raw_bitmap[offset//8 : (offset+length+7)//8] and adjust by offset % 8), so repacking scales with the slice length rather than the original buffer size.
There was a problem hiding this comment.
From the fake Slim Shady:
Good catch — fixed in 378f7bf. The bitmap repacking now slices raw_bitmap[offset//8 : (offset+length+7)//8] before unpacking, so it scales with slice length rather than the original buffer size.
| for i in range(n): | ||
| start = logical_offsets[i] | ||
| end = logical_offsets[i + 1] | ||
| s = (ctypes.c_char * int(end - start)).from_address(data_p + int(start)).value |
There was a problem hiding this comment.
create_str_array() builds each string via a ctypes char array and uses .value, which stops at the first NUL byte. Arrow UTF-8 strings can legally contain embedded \x00, so this will truncate those values. Use a length-aware read (e.g., ctypes.string_at(addr, size) or .raw[:size]) and then decode, so strings with embedded NULs round-trip correctly.
| s = (ctypes.c_char * int(end - start)).from_address(data_p + int(start)).value | |
| length = int(end - start) | |
| # Use a length-aware read so embedded NUL bytes are preserved. | |
| s_bytes = ctypes.string_at(data_p + int(start), length) | |
| s = s_bytes.decode("utf-8") |
There was a problem hiding this comment.
From the fake Slim Shady:
Fair point — while this was pre-existing behavior, it's worth fixing alongside the offset work. Fixed in 40b3ae6: switched from .value (stops at first NUL) to ctypes.string_at with explicit length, then decode. Added a test with embedded NUL bytes.
| if offset == 0: | ||
| return raw_bitmap | ||
| # Re-pack bitmap bits starting from the offset bit position | ||
| num_bytes = (length + 7) // 8 | ||
| if length == 0: | ||
| return np.zeros(num_bytes, dtype=np.uint8) |
There was a problem hiding this comment.
Now that create_bitmap() receives length, the offset == 0 fast-path could still return a bitmap view that is much larger than necessary when adapting a short slice starting at 0 (Arrow slices typically keep the original buffers). Consider returning only the minimal required prefix (e.g., raw_bitmap[:(length+7)//8]) so downstream code doesn’t carry around oversized bitmap arrays.
| if offset == 0: | |
| return raw_bitmap | |
| # Re-pack bitmap bits starting from the offset bit position | |
| num_bytes = (length + 7) // 8 | |
| if length == 0: | |
| return np.zeros(num_bytes, dtype=np.uint8) | |
| # If there are no logical elements, return an empty bitmap view. | |
| if length == 0: | |
| return raw_bitmap[:0] | |
| if offset == 0: | |
| # Fast path: no need to repack bits, just return the minimal prefix | |
| # covering the requested logical length. | |
| num_bytes = (length + 7) // 8 | |
| return raw_bitmap[:num_bytes] | |
| # Re-pack bitmap bits starting from the offset bit position | |
| num_bytes = (length + 7) // 8 |
There was a problem hiding this comment.
From the fake Slim Shady:
Good catch — fixed in 378f7bf. The offset==0 fast-path now trims to (length+7)//8 bytes, and the length==0 case is handled first to return an empty view.
Limit unpackbits to the bytes covering [offset, offset+length) instead of the entire bitmap buffer. Also trim the fast-path (offset==0) to return only the minimal prefix covering the requested length.
Use ctypes.string_at with explicit length instead of .value which stops at the first NUL byte. Arrow UTF-8 strings can legally contain embedded NUL characters.
|
|
||
|
|
||
| class TestStructOffset: | ||
| def test_struct_sliced(self): |
There was a problem hiding this comment.
Can you please add some tests here etc. with slices taken off arrays with None's?
There was a problem hiding this comment.
From the fake Slim Shady:
Added in 6855bca — new tests for sliced arrays with nulls in both string (test_string_sliced_with_nulls) and struct (test_struct_sliced_with_nulls) adapters. The struct test covers nulls in different fields at different positions.
There was a problem hiding this comment.
Thanks. Will this work?
arr = pa.array([
{"a": 1, "b": 10},
None,
{"a": 3, "b": 30},
None,
{"a": 5, "b": 50},
])
There was a problem hiding this comment.
From the fake Slim Shady:
Good question — it exposed a gap. The adapter was using StructArray.field() which returns raw child arrays without incorporating the struct's own validity bitmap. So with your example, null struct rows were invisible — they showed up as zeros with no bitmap indication.
Fixed in three commits:
03b4094— Addedis_null_struct(), a@njithelper that checks both the struct-level and field-level bitmaps in a single call45ff109—structured_array_adapter()now returns a 3-tuple(struct_bitmap, field_bitmaps, field_datas), extracting the struct validity buffer viabuffers()[0]49d8ef5— Added your exact example as a test plus a combined test exercising both struct-level and field-level nulls with offset
The two-layer approach is inspired by Awkward Array's BitMaskedArray(RecordArray) design — struct-level and field-level nullability are semantically distinct per the Arrow columnar spec, so we keep them separate rather than AND-ing them eagerly. The is_null_struct() helper composes both layers for @njit consumers.
| assert is_null(3, bitmaps["ratio"]) | ||
|
|
||
| def test_struct_null_rows_sliced(self): | ||
| """Goykhman's example: struct array with null rows, sliced.""" |
|
Thanks for contributing, merging this. |
From the fake Slim Shady:
Summary
This PR fixes all adapters to correctly handle Arrow arrays with non-zero offset (sliced/filtered arrays). numbarrow was ignoring the Arrow array offset field, reading from buffer byte 0 regardless — sliced arrays returned wrong data.
What's Fixed
Tests
11 new offset tests covering all adapter types
Performance
Commits