Skip to content

Commit 43ef77b

Browse files
committed
Add Spark-safe UTF-8 string reverse for truncated trailing bytes
Libcudf reverse assumes well-formed UTF-8 and can over-read into the next row when Spark StringType holds truncated trailing multi-byte sequences. Add a JNI reverse that matches UTF8String.numBytesForFirstByte with SPARK-57507 remaining-byte clamping. Signed-off-by: Liangcai Li <firestarmanllc@gmail.com>
1 parent a2e9304 commit 43ef77b

8 files changed

Lines changed: 349 additions & 3 deletions

File tree

src/main/cpp/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ add_library(
288288
src/protobuf/protobuf_builders.cu
289289
src/protobuf/protobuf_kernels.cu
290290
src/regex_rewrite_utils.cu
291+
src/reverse_strings.cu
291292
src/row_conversion.cu
292293
src/round_float.cu
293294
src/shuffle_assemble.cu

src/main/cpp/src/StringUtilsJni.cpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025, NVIDIA CORPORATION.
2+
* Copyright (c) 2025-2026, NVIDIA CORPORATION.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@
1515
*/
1616

1717
#include "cudf_jni_apis.hpp"
18+
#include "reverse_strings.hpp"
1819
#include "uuid.hpp"
1920

2021
extern "C" {
@@ -31,4 +32,18 @@ JNIEXPORT jlong JNICALL Java_com_nvidia_spark_rapids_jni_StringUtils_randomUUIDs
3132
}
3233
JNI_CATCH(env, 0);
3334
}
35+
36+
JNIEXPORT jlong JNICALL
37+
Java_com_nvidia_spark_rapids_jni_StringUtils_reverseStrings(JNIEnv* env, jclass, jlong input_handle)
38+
{
39+
JNI_NULL_CHECK(env, input_handle, "input column is null", 0);
40+
JNI_TRY
41+
{
42+
cudf::jni::auto_set_device(env);
43+
auto const input = reinterpret_cast<cudf::column_view const*>(input_handle);
44+
return cudf::jni::release_as_jlong(
45+
spark_rapids_jni::reverse_strings(cudf::strings_column_view{*input}));
46+
}
47+
JNI_CATCH(env, 0);
48+
}
3449
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright (c) 2026, NVIDIA CORPORATION.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#include "nvtx_ranges.hpp"
18+
#include "reverse_strings.hpp"
19+
20+
#include <cudf/column/column_device_view.cuh>
21+
#include <cudf/column/column_factories.hpp>
22+
#include <cudf/detail/offsets_iterator_factory.cuh>
23+
#include <cudf/strings/string_view.cuh>
24+
#include <cudf/strings/strings_column_view.hpp>
25+
#include <cudf/utilities/default_stream.hpp>
26+
#include <cudf/utilities/memory_resource.hpp>
27+
28+
#include <rmm/cuda_stream_view.hpp>
29+
#include <rmm/exec_policy.hpp>
30+
31+
#include <cuda/iterator>
32+
#include <cuda/std/algorithm>
33+
#include <thrust/for_each.h>
34+
35+
namespace spark_rapids_jni {
36+
namespace detail {
37+
namespace {
38+
39+
/**
40+
* @brief Spark `UTF8String.numBytesForFirstByte` character width.
41+
*
42+
* Continuation bytes and UTF-8-disallowed lead bytes are treated as width 1, matching
43+
* Spark's `bytesOfCodePointInUTF8` table with the `(numBytes == 0) ? 1 : numBytes` rule.
44+
*/
45+
__device__ __forceinline__ cudf::size_type spark_num_bytes_for_first_byte(uint8_t byte)
46+
{
47+
if (byte <= 0x7F) { return 1; }
48+
// 0x80-0xBF continuation and 0xC0-0xC1 disallowed -> Spark width 1
49+
if (byte <= 0xC1) { return 1; }
50+
if (byte <= 0xDF) { return 2; }
51+
if (byte <= 0xEF) { return 3; }
52+
// 0xF0-0xF4 valid 4-byte leads; 0xF5-0xFF disallowed -> Spark width 1
53+
if (byte <= 0xF4) { return 4; }
54+
return 1;
55+
}
56+
57+
/**
58+
* @brief Reverse characters in each string with Spark clamp semantics (SPARK-57507).
59+
*/
60+
struct reverse_characters_fn {
61+
cudf::column_device_view const d_strings;
62+
cudf::detail::input_offsetalator d_offsets;
63+
char* d_chars;
64+
65+
__device__ void operator()(cudf::size_type idx) const
66+
{
67+
if (d_strings.is_null(idx)) { return; }
68+
auto const d_str = d_strings.element<cudf::string_view>(idx);
69+
auto const nbytes = d_str.size_bytes();
70+
if (nbytes == 0) { return; }
71+
72+
auto const* in = reinterpret_cast<uint8_t const*>(d_str.data());
73+
// Write character chunks from the end of this row's output region, matching Spark:
74+
// keep bytes within a character in order, reverse the order of characters, and clamp
75+
// each character width to the bytes remaining in this row.
76+
auto* out_end = d_chars + d_offsets[idx] + nbytes;
77+
cudf::size_type i = 0;
78+
while (i < nbytes) {
79+
auto const declared = spark_num_bytes_for_first_byte(in[i]);
80+
auto const len = cuda::std::min(declared, nbytes - i);
81+
out_end -= len;
82+
for (cudf::size_type j = 0; j < len; ++j) {
83+
out_end[j] = static_cast<char>(in[i + j]);
84+
}
85+
i += len;
86+
}
87+
}
88+
};
89+
90+
} // namespace
91+
92+
std::unique_ptr<cudf::column> reverse_strings(cudf::strings_column_view const& input,
93+
rmm::cuda_stream_view stream,
94+
rmm::device_async_resource_ref mr)
95+
{
96+
if (input.is_empty()) { return cudf::make_empty_column(cudf::type_id::STRING); }
97+
98+
// Preserve offsets/nulls; rewrite only the character bytes.
99+
auto result = std::make_unique<cudf::column>(input.parent(), stream, mr);
100+
auto const sv = cudf::strings_column_view(result->view());
101+
auto const d_offsets = cudf::detail::offsetalator_factory::make_input_iterator(sv.offsets());
102+
auto* d_chars = result->mutable_view().head<char>();
103+
104+
auto const d_column = cudf::column_device_view::create(input.parent(), stream);
105+
thrust::for_each_n(rmm::exec_policy_nosync(stream, cudf::get_current_device_resource_ref()),
106+
cuda::counting_iterator<cudf::size_type>{0},
107+
input.size(),
108+
reverse_characters_fn{*d_column, d_offsets, d_chars});
109+
110+
return result;
111+
}
112+
113+
} // namespace detail
114+
115+
std::unique_ptr<cudf::column> reverse_strings(cudf::strings_column_view const& input,
116+
rmm::cuda_stream_view stream,
117+
rmm::device_async_resource_ref mr)
118+
{
119+
SRJ_FUNC_RANGE();
120+
return detail::reverse_strings(input, stream, mr);
121+
}
122+
123+
} // namespace spark_rapids_jni
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright (c) 2026, NVIDIA CORPORATION.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
#pragma once
17+
18+
#include <cudf/column/column.hpp>
19+
#include <cudf/strings/strings_column_view.hpp>
20+
#include <cudf/utilities/default_stream.hpp>
21+
#include <cudf/utilities/memory_resource.hpp>
22+
23+
namespace spark_rapids_jni {
24+
25+
/**
26+
* @brief Reverse strings using Spark `UTF8String.reverse` character-width semantics.
27+
*
28+
* Unlike libcudf `cudf::strings::reverse`, character widths follow Spark's
29+
* `UTF8String.numBytesForFirstByte` and are clamped to the bytes remaining in each
30+
* row (`min(declared_width, remaining)`). This matches SPARK-57507 and avoids
31+
* reading past a truncated trailing multi-byte UTF-8 sequence into the next row.
32+
*
33+
* Well-formed UTF-8 results match libcudf reverse. Null rows remain null. Output
34+
* offsets match the input offsets (byte length is preserved per row).
35+
*
36+
* @param input Strings column
37+
* @param stream CUDA stream used for device memory operations
38+
* @param mr Device memory resource used to allocate the returned column
39+
* @return New strings column with Spark-compatible reversed contents
40+
*/
41+
std::unique_ptr<cudf::column> reverse_strings(
42+
cudf::strings_column_view const& input,
43+
rmm::cuda_stream_view stream = cudf::get_default_stream(),
44+
rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref());
45+
46+
} // namespace spark_rapids_jni

src/main/cpp/tests/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ ConfigureTest(MAP_UTILS
122122
ConfigureTest(SUBSTRING_INDEX
123123
substring_index.cpp)
124124

125+
ConfigureTest(REVERSE_STRINGS
126+
reverse_strings.cpp)
127+
125128
ConfigureTest(SHUFFLE_SPLIT
126129
shuffle_split.cu)
127130

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright (c) 2026, NVIDIA CORPORATION.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#include "reverse_strings.hpp"
18+
19+
#include <cudf_test/base_fixture.hpp>
20+
#include <cudf_test/column_utilities.hpp>
21+
#include <cudf_test/column_wrapper.hpp>
22+
23+
#include <cudf/strings/strings_column_view.hpp>
24+
25+
#include <string>
26+
27+
namespace {
28+
29+
std::string bytes_to_string(std::initializer_list<uint8_t> bytes)
30+
{
31+
return std::string(bytes.begin(), bytes.end());
32+
}
33+
34+
} // namespace
35+
36+
struct ReverseStringsTests : public cudf::test::BaseFixture {};
37+
38+
TEST_F(ReverseStringsTests, EmptyAndNulls)
39+
{
40+
auto const input = cudf::test::strings_column_wrapper{};
41+
auto const result = spark_rapids_jni::reverse_strings(cudf::strings_column_view{input});
42+
CUDF_TEST_EXPECT_COLUMNS_EQUAL(*result, input);
43+
44+
auto const with_nulls =
45+
cudf::test::strings_column_wrapper({"abc", "", "世界"}, {true, false, true});
46+
auto const expected =
47+
cudf::test::strings_column_wrapper({"cba", "", "界世"}, {true, false, true});
48+
auto const reversed = spark_rapids_jni::reverse_strings(cudf::strings_column_view{with_nulls});
49+
CUDF_TEST_EXPECT_COLUMNS_EQUAL(*reversed, expected);
50+
}
51+
52+
TEST_F(ReverseStringsTests, WellFormedUtf8)
53+
{
54+
// ASCII, complete 2-/3-/4-byte characters.
55+
auto const input = cudf::test::strings_column_wrapper(
56+
{"ABC",
57+
bytes_to_string({0x41, 0xC3, 0xA9}), //
58+
bytes_to_string({0x41, 0xE4, 0xB8, 0x96}), // A世
59+
bytes_to_string({0x41, 0xF0, 0x90, 0x8D, 0x88})}); // A + U+10048-ish 4-byte
60+
auto const expected =
61+
cudf::test::strings_column_wrapper({"CBA",
62+
bytes_to_string({0xC3, 0xA9, 0x41}),
63+
bytes_to_string({0xE4, 0xB8, 0x96, 0x41}),
64+
bytes_to_string({0xF0, 0x90, 0x8D, 0x88, 0x41})});
65+
auto const result = spark_rapids_jni::reverse_strings(cudf::strings_column_view{input});
66+
CUDF_TEST_EXPECT_COLUMNS_EQUAL(*result, expected);
67+
}
68+
69+
TEST_F(ReverseStringsTests, TruncatedTrailingUtf8NoOverread)
70+
{
71+
// SPARK-57507 cases. Neighbor rows carry sentinel bytes; an over-read would pull them in.
72+
auto const row0 = bytes_to_string({0x41, 0xCE}); // A + truncated 2-byte lead
73+
auto const row1 = bytes_to_string({0xA9, 0x42}); // must not leak into row0
74+
auto const row2 = bytes_to_string({0x41, 0xE4, 0xB8}); // A + truncated 3-byte lead
75+
auto const row3 = bytes_to_string({0x96, 0x43}); // must not leak into row2
76+
auto const row4 = bytes_to_string({0x41, 0xF0, 0x90}); // A + truncated 4-byte lead
77+
auto const row5 = bytes_to_string({0x8D, 0x88, 0x44}); // must not leak into row4
78+
auto const row6 = bytes_to_string({0xE4, 0xB8, 0x96, 0xCE}); // 世 + orphan 2-byte lead
79+
auto const row7 = bytes_to_string({0x45});
80+
81+
auto const input =
82+
cudf::test::strings_column_wrapper({row0, row1, row2, row3, row4, row5, row6, row7});
83+
auto const expected = cudf::test::strings_column_wrapper(
84+
{bytes_to_string({0xCE, 0x41}),
85+
bytes_to_string({0x42, 0xA9}), // 0xA9 is continuation -> Spark width 1, then 'B'
86+
bytes_to_string({0xE4, 0xB8, 0x41}),
87+
bytes_to_string({0x43, 0x96}),
88+
bytes_to_string({0xF0, 0x90, 0x41}),
89+
bytes_to_string({0x44, 0x88, 0x8D}),
90+
bytes_to_string({0xCE, 0xE4, 0xB8, 0x96}),
91+
bytes_to_string({0x45})});
92+
93+
auto const result = spark_rapids_jni::reverse_strings(cudf::strings_column_view{input});
94+
CUDF_TEST_EXPECT_COLUMNS_EQUAL(*result, expected);
95+
}

src/main/java/com/nvidia/spark/rapids/jni/StringUtils.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025, NVIDIA CORPORATION.
2+
* Copyright (c) 2025-2026, NVIDIA CORPORATION.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,13 +17,20 @@
1717
package com.nvidia.spark.rapids.jni;
1818

1919
import ai.rapids.cudf.ColumnVector;
20+
import ai.rapids.cudf.ColumnView;
2021
import ai.rapids.cudf.Cuda;
22+
import ai.rapids.cudf.CudfException;
23+
import ai.rapids.cudf.NativeDepsLoader;
2124
import java.lang.management.ManagementFactory;
2225
import java.util.Arrays;
2326
import java.util.concurrent.atomic.AtomicLong;
2427

2528
public class StringUtils {
2629

30+
static {
31+
NativeDepsLoader.loadNativeDeps();
32+
}
33+
2734
// Stores the sequence ID of calling generate UUIDs.
2835
private static AtomicLong sequence = new AtomicLong(0);
2936

@@ -88,5 +95,20 @@ public static ColumnVector randomUUIDsWithSeed(int rowCount, long seed) {
8895
return new ColumnVector(randomUUIDs(rowCount, seed));
8996
}
9097

98+
/**
99+
* Reverse each string using Spark {@code UTF8String.reverse} semantics.
100+
* Character widths follow Spark's {@code numBytesForFirstByte} and are clamped to the
101+
* bytes remaining in each row (SPARK-57507), so truncated trailing UTF-8 sequences do
102+
* not read past the row boundary.
103+
*
104+
* @param input strings column
105+
* @return new strings column with reversed contents
106+
*/
107+
public static ColumnVector reverseStrings(ColumnView input) {
108+
return new ColumnVector(reverseStrings(input.getNativeView()));
109+
}
110+
91111
private static native long randomUUIDs(int rowCount, long seed);
112+
113+
private static native long reverseStrings(long inputHandle) throws CudfException;
92114
}

0 commit comments

Comments
 (0)