Skip to content

[Datasets] Add support for string tensor columns in ArrowTensorArray and ArrowVariableShapedTensorArray - #32143

Merged
clarkzinzow merged 7 commits into
ray-project:masterfrom
scottjlee:fix-string-tensor-tests
Feb 2, 2023
Merged

[Datasets] Add support for string tensor columns in ArrowTensorArray and ArrowVariableShapedTensorArray#32143
clarkzinzow merged 7 commits into
ray-project:masterfrom
scottjlee:fix-string-tensor-tests

Conversation

@scottjlee

@scottjlee scottjlee commented Feb 1, 2023

Copy link
Copy Markdown
Contributor

Signed-off-by: Scott Lee sjl@anyscale.com

Why are these changes needed?

Add support for creating ArrowTensorArrays and ArrowVariableShapedTensorArray with string typed columns. The previous PR #31817 had CI test failures which were not run at PR-review time. This PR replicates the functionality of the previous PR, and additionally addresses the test failures (which only occur for Arrow 8.0+).

Related issue number

Based off of PR #31817, with additional implementation on top. I have marked new sections of code with GitHub comments.

Checks

  • I've signed off every commit(by using the -s flag, i.e., git commit -s) in this PR.
  • I've run scripts/format.sh to lint the changes in this PR.
  • I've included any doc changes needed for https://docs.ray.io/en/master/.
  • I've made sure the tests are passing. Note that there might be a few flaky tests, see the recent failures at https://flakey-tests.ray.io/
  • Testing Strategy
    • Unit tests
    • Release tests
    • This PR is not tested :(

Scott Lee added 2 commits January 31, 2023 16:06
Signed-off-by: Scott Lee <sjl@anyscale.com>
Signed-off-by: Scott Lee <sjl@anyscale.com>
Scott Lee added 3 commits January 31, 2023 16:20
Signed-off-by: Scott Lee <sjl@anyscale.com>
Signed-off-by: Scott Lee <sjl@anyscale.com>
Signed-off-by: Scott Lee <sjl@anyscale.com>
Comment on lines +670 to +724
raw_values = data.values
offset = raw_values.offset
if pa.types.is_boolean(raw_values.type):
# Arrow boolean array buffers are bit-packed, with 8 entries per byte,
# and are accessed via bit offsets.
buffer_item_width = raw_values.type.bit_width
else:
# We assume all other array types are accessed via byte array
# offsets.
buffer_item_width = raw_values.type.bit_width // 8
data_buffer = raw_values.buffers()[1]
data_offset = buffer_item_width * offset

if pa.types.is_boolean(raw_values.type):
# Special handling for boolean arrays, since Arrow
# bit-packs boolean arrays while NumPy does not.
# Cast as uint8 array and let NumPy unpack into a boolean view.
# Offset into uint8 array, where each element is
# a bucket for 8 booleans.
byte_bucket_offset = data_offset // 8
# Offset for a specific boolean, within a uint8 array element.
bool_offset = data_offset % 8
# The number of uint8 array elements (buckets) that our slice spans.
# Note that, due to the offset for a specific boolean,
# the slice can span byte boundaries even if it contains
# less than 8 booleans.
num_boolean_byte_buckets = 1 + ((bool_offset + np.prod(shape) - 1) // 8)
# Construct the uint8 array view on the buffer.
arr = np.ndarray(
(num_boolean_byte_buckets,),
dtype=np.uint8,
buffer=data_buffer,
offset=byte_bucket_offset,
)
# Unpack into a byte per boolean, using LSB bit-packed ordering.
arr = np.unpackbits(arr, bitorder="little")
# Interpret buffer as boolean array.
return np.ndarray(shape, dtype=np.bool_, buffer=arr, offset=bool_offset)

# Special handling of ragged string tensors
if pa.types.is_fixed_size_binary(raw_values.type):
offset = raw_values.offset
buffer_item_width = raw_values.type.bit_width // 8
ext_dtype = np.dtype(
f"<U{raw_values.type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)
data_offset = buffer_item_width * offset
data_buffer = raw_values.buffers()[1]
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)
ext_dtype = raw_values.type.to_pandas_dtype()
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)

@scottjlee scottjlee Feb 1, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part 2 of new code from this PR (as opposed to changes from previous PR)

Comment on lines +127 to +183
# TODO(Clark): Construct ndarray view directly on tensor element buffer to
# ensure reliable zero-copy semantics.
flat_ndarray = scalar.value.values.to_numpy(zero_copy_only=False)
return flat_ndarray.reshape(self.shape)
shape = scalar.type.shape
raw_values = scalar.value.values
offset = raw_values.offset
if pa.types.is_boolean(raw_values.type):
# Arrow boolean array buffers are bit-packed, with 8 entries per byte,
# and are accessed via bit offsets.
buffer_item_width = raw_values.type.bit_width
else:
# We assume all other array types are accessed via byte array
# offsets.
buffer_item_width = raw_values.type.bit_width // 8
data_buffer = raw_values.buffers()[1]
data_offset = buffer_item_width * offset
if pa.types.is_boolean(raw_values.type):
# Special handling for boolean arrays, since Arrow bit-packs
# boolean arrays while NumPy does not.
# Cast as uint8 array and let NumPy unpack into a boolean view.
# Offset into uint8 array, where each element is
# a bucket for 8 booleans.
byte_bucket_offset = offset // 8
# Offset for a specific boolean, within a uint8 array element.
bool_offset = offset % 8
# The number of uint8 array elements (buckets) that our slice spans.
# Note that, due to the offset for a specific boolean,
# the slice can span byte boundaries even if it contains
# less than 8 booleans.
num_boolean_byte_buckets = 1 + ((bool_offset + np.prod(shape) - 1) // 8)
# Construct the uint8 array view on the buffer.
arr = np.ndarray(
(num_boolean_byte_buckets,),
dtype=np.uint8,
buffer=data_buffer,
offset=byte_bucket_offset,
)
# Unpack into a byte per boolean, using LSB bit-packed ordering.
arr = np.unpackbits(arr, bitorder="little")
# Interpret buffer as boolean array.
return np.ndarray(shape, dtype=np.bool_, buffer=arr, offset=bool_offset)
# Special handling of ragged string tensors
if pa.types.is_fixed_size_binary(raw_values.type):
offset = raw_values.offset
buffer_item_width = raw_values.type.bit_width // 8
ext_dtype = np.dtype(
f"<U{raw_values.type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)
data_offset = buffer_item_width * offset
data_buffer = raw_values.buffers()[1]
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)
ext_dtype = raw_values.type.to_pandas_dtype()
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part 1 of new code from this PR (as opposed to changes from previous PR)

"int64": np.int64,
"int64_list": object,
"float": np.float,
"float": np.float_,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This came up when I was testing locally with Arrow 8.0+. np.float is deprecated starting numpy 1.20.0, so I have replaced it with np.float_.

Comment on lines +170 to +171
offset = raw_values.offset
buffer_item_width = raw_values.type.bit_width // 8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated definitions of above

Suggested change
offset = raw_values.offset
buffer_item_width = raw_values.type.bit_width // 8

Comment on lines +175 to +176
data_offset = buffer_item_width * offset
data_buffer = raw_values.buffers()[1]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Suggested change
data_offset = buffer_item_width * offset
data_buffer = raw_values.buffers()[1]

Comment on lines +177 to +179
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, could we fallthrough to the below np.ndarray() constructor after overriding ext_dtype? Should just need to move the below ext_dtype to the top of this function to let the string override take place.

Suggested change
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)

Comment on lines +710 to +720
if pa.types.is_fixed_size_binary(raw_values.type):
offset = raw_values.offset
buffer_item_width = raw_values.type.bit_width // 8
ext_dtype = np.dtype(
f"<U{raw_values.type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)
data_offset = buffer_item_width * offset
data_buffer = raw_values.buffers()[1]
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Suggested change
if pa.types.is_fixed_size_binary(raw_values.type):
offset = raw_values.offset
buffer_item_width = raw_values.type.bit_width // 8
ext_dtype = np.dtype(
f"<U{raw_values.type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)
data_offset = buffer_item_width * offset
data_buffer = raw_values.buffers()[1]
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)
if pa.types.is_fixed_size_binary(raw_values.type):
ext_dtype = np.dtype(
f"<U{raw_values.type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)

Signed-off-by: Scott Lee <sjl@anyscale.com>
@scottjlee
scottjlee requested a review from clarkzinzow February 1, 2023 03:05
)
return np.ndarray(
shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, it appears that this and ArrowVariableShapedTensorType._extension_scalar_to_ndarray() are the same after the definitions of shape and raw_values, could we consolidate most of this logic into a single utility function that lives at the bottom of this module?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generalized ArrowTensorType._extension_scalar_to_ndarray as well (which had a few variables that were slightly different). LMK what you think

Signed-off-by: Scott Lee <sjl@anyscale.com>
@scottjlee
scottjlee requested a review from clarkzinzow February 1, 2023 04:05

@clarkzinzow clarkzinzow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, great consolidation of that common ndarray building code! It looks like we could also subsume most of ArrowTensorArray._to_numpy() into that helper as well:

if pa.types.is_boolean(value_type):
# Arrow boolean array buffers are bit-packed, with 8 entries per byte,
# and are accessed via bit offsets.
buffer_item_width = value_type.bit_width
else:
# We assume all other array types are accessed via byte array
# offsets.
buffer_item_width = value_type.bit_width // 8
# Number of items per inner ndarray.
num_items_per_element = np.prod(shape) if shape else 1
# Base offset into data buffer, e.g. due to zero-copy slice.
buffer_offset = self.offset * num_items_per_element
# Offset of array data in buffer.
offset = buffer_item_width * buffer_offset
if index is not None:
# Getting a single tensor element of the array.
offset_buffer = buffers[1]
offset_array = np.ndarray(
(len(self),), buffer=offset_buffer, dtype=self.OFFSET_DTYPE
)
# Offset into array to reach logical index.
index_offset = offset_array[index]
# Add the index offset to the base offset.
offset += buffer_item_width * index_offset
else:
# Getting the entire array of tensors.
shape = (len(self),) + shape
if pa.types.is_boolean(value_type):
# Special handling for boolean arrays, since Arrow bit-packs boolean arrays
# while NumPy does not.
# Cast as uint8 array and let NumPy unpack into a boolean view.
# Offset into uint8 array, where each element is a bucket for 8 booleans.
byte_bucket_offset = offset // 8
# Offset for a specific boolean, within a uint8 array element.
bool_offset = offset % 8
# The number of uint8 array elements (buckets) that our slice spans.
# Note that, due to the offset for a specific boolean, the slice can span
# byte boundaries even if it contains less than 8 booleans.
num_boolean_byte_buckets = 1 + ((bool_offset + np.prod(shape) - 1) // 8)
# Construct the uint8 array view on the buffer.
arr = np.ndarray(
(num_boolean_byte_buckets,),
dtype=np.uint8,
buffer=data_buffer,
offset=byte_bucket_offset,
)
# Unpack into a byte per boolean, using LSB bit-packed ordering.
arr = np.unpackbits(arr, bitorder="little")
# Interpret buffer as boolean array.
return np.ndarray(shape, dtype=np.bool_, buffer=arr, offset=bool_offset)
# Special handling of binary/string types. Assumes unicode string tensor columns
if pa.types.is_fixed_size_binary(value_type):
ext_dtype = np.dtype(
f"<U{value_type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)
return np.ndarray(shape, dtype=ext_dtype, buffer=data_buffer, offset=offset)

@scottjlee

Copy link
Copy Markdown
Contributor Author

Awesome, great consolidation of that common ndarray building code! It looks like we could also subsume most of ArrowTensorArray._to_numpy() into that helper as well:

if pa.types.is_boolean(value_type):
# Arrow boolean array buffers are bit-packed, with 8 entries per byte,
# and are accessed via bit offsets.
buffer_item_width = value_type.bit_width
else:
# We assume all other array types are accessed via byte array
# offsets.
buffer_item_width = value_type.bit_width // 8
# Number of items per inner ndarray.
num_items_per_element = np.prod(shape) if shape else 1
# Base offset into data buffer, e.g. due to zero-copy slice.
buffer_offset = self.offset * num_items_per_element
# Offset of array data in buffer.
offset = buffer_item_width * buffer_offset
if index is not None:
# Getting a single tensor element of the array.
offset_buffer = buffers[1]
offset_array = np.ndarray(
(len(self),), buffer=offset_buffer, dtype=self.OFFSET_DTYPE
)
# Offset into array to reach logical index.
index_offset = offset_array[index]
# Add the index offset to the base offset.
offset += buffer_item_width * index_offset
else:
# Getting the entire array of tensors.
shape = (len(self),) + shape
if pa.types.is_boolean(value_type):
# Special handling for boolean arrays, since Arrow bit-packs boolean arrays
# while NumPy does not.
# Cast as uint8 array and let NumPy unpack into a boolean view.
# Offset into uint8 array, where each element is a bucket for 8 booleans.
byte_bucket_offset = offset // 8
# Offset for a specific boolean, within a uint8 array element.
bool_offset = offset % 8
# The number of uint8 array elements (buckets) that our slice spans.
# Note that, due to the offset for a specific boolean, the slice can span
# byte boundaries even if it contains less than 8 booleans.
num_boolean_byte_buckets = 1 + ((bool_offset + np.prod(shape) - 1) // 8)
# Construct the uint8 array view on the buffer.
arr = np.ndarray(
(num_boolean_byte_buckets,),
dtype=np.uint8,
buffer=data_buffer,
offset=byte_bucket_offset,
)
# Unpack into a byte per boolean, using LSB bit-packed ordering.
arr = np.unpackbits(arr, bitorder="little")
# Interpret buffer as boolean array.
return np.ndarray(shape, dtype=np.bool_, buffer=arr, offset=bool_offset)
# Special handling of binary/string types. Assumes unicode string tensor columns
if pa.types.is_fixed_size_binary(value_type):
ext_dtype = np.dtype(
f"<U{value_type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)
return np.ndarray(shape, dtype=ext_dtype, buffer=data_buffer, offset=offset)

I was looking into this as well, but I think that method has some extra logic that's not present in the other methods, for handling the offsets for a single element:

if index is not None:
# Getting a single tensor element of the array.
offset_buffer = buffers[1]
offset_array = np.ndarray(
(len(self),), buffer=offset_buffer, dtype=self.OFFSET_DTYPE
)
# Offset into array to reach logical index.
index_offset = offset_array[index]
# Add the index offset to the base offset.
offset += buffer_item_width * index_offset

I could further generalize the helper method to work for this, but I think it could be overgeneralized with this new addition. Thoughts? @clarkzinzow

@clarkzinzow

Copy link
Copy Markdown
Contributor

@scottjlee Ah good point, I missed that on the skim! In that case, this looks good to merge!

@clarkzinzow
clarkzinzow merged commit 74266a2 into ray-project:master Feb 2, 2023
edoakes pushed a commit to edoakes/ray that referenced this pull request Mar 22, 2023
…` and `ArrowVariableShapedTensorArray` (ray-project#32143)

Add support for creating ArrowTensorArrays and ArrowVariableShapedTensorArray with string typed columns. The previous PR ray-project#31817 had CI test failures which were not run at PR-review time. This PR replicates the functionality of the previous PR, and additionally addresses the test failures (which only occur for Arrow 8.0+).

Signed-off-by: Scott Lee <sjl@anyscale.com>
Signed-off-by: Edward Oakes <ed.nmi.oakes@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants