Skip to content

Add XPU enabled jagged tensor operators - #129

Open
aagalleg wants to merge 9 commits into
intel:mainfrom
aagalleg:feat/jagged_ops_clean
Open

Add XPU enabled jagged tensor operators#129
aagalleg wants to merge 9 commits into
intel:mainfrom
aagalleg:feat/jagged_ops_clean

Conversation

@aagalleg

Copy link
Copy Markdown
Contributor

The operators enable conversion between dense and jagged representations and support element-wise operations with jagged output:

  • dense_to_jagged: Convert dense padded tensors to compact jagged format
  • jagged_to_padded_dense: Convert jagged tensors back to padded dense format
  • jagged_dense_elementwise_add_jagged_output: Element-wise addition with jagged output
  • jagged_2d_to_dense: Convert 2D jagged tensors to dense format

Four backend operators are registered under the XPU dispatch key: dense_to_jagged_forward, jagged_to_padded_dense_forward, jagged_to_padded_dense_backward and jagged_dense_elementwise_add_jagged_output. The four public operators listed above are composite or Autograd-key entry points owned by the upstream fbgemm-gpu-cpu package and come up automatically once the backends are registered, so no AutogradXPU block is needed here.

Each file is a per-file port of its FBGEMM CUDA counterpart at v1.8.0, with the CUDA source path and symbol mapping recorded in a banner comment at the top of every header.

Changes

Shared SYCL Infrastructure

sycl_kernels/jagged_common.h: SYCL counterpart of jagged_tensor_ops/common.cuh and common.h.

Operator Implementation

One .cpp/.h pair per operator, each mapping to a single CUDA source file:

sycl_kernels/dense_to_jagged_forward.{cpp,h}jagged_tensor_ops/dense_to_jagged_forward.cu
sycl_kernels/jagged_to_padded_dense_forward.{cpp,h}jagged_tensor_ops/jagged_to_padded_dense_forward.cu
sycl_kernels/jagged_to_padded_dense_backward.{cpp,h}jagged_tensor_ops/jagged_to_padded_dense_backward.cu
sycl_kernels/jagged_dense_elementwise_add_jagged_output.{cpp,h}jagged_tensor_ops/jagged_to_padded_dense_forward.cu, including the JaggedDenseAddJaggedOutputXPUOp autograd Function that mirrors CUDA's JaggedDenseAddJaggedOutputGPUOp one-for-one.

Support Helpers

fbgemm_utils/dispatch_macros.h: Adds FBGEMM_DISPATCH_FLOATING_TYPES, FBGEMM_DISPATCH_FLOAT_AND_BFLOAT16_CASE and FBGEMM_DISPATCH_ALL_TYPES_BUT_HALF_CASE, byte-identical to upstream.
fbgemm_utils/tensor_utils.h: Adds TENSOR_ON_SYCL_XPU, the XPU counterpart of upstream TENSOR_ON_CUDA_GPU.
fbgemm_utils/utils.h: Adds div_round_up and round_down, widened from upstream's int32_t to int64_t because the jagged launchers derive work-group counts from int64 tensor extents.

Build System Integration

CMakeLists.txt: Adds the four operator sources to host_sources.
ops_registry.cpp: Adds schemaExists-guarded schema declarations for the four backend operators. The schema strings are copied verbatim from upstream jagged_tensor_ops_cpu.cpp, so a standalone fbgemm-xpu install exposes identical signatures, and each guard becomes a no-op when fbgemm-gpu-cpu is present.

Testing

Instead of a standalone test file, this reuses FBGEMM's own jagged test suite through the existing test patch:

patches/0001-Add-XPU-support-to-fbgemm-tests.patch: Adds XPU support to dense_to_jagged_test.py, 2d_to_dense_test.py, 1d_to_dense_test.py, elementwise_binary_test.py, and the shared jagged/common.py. CUDA-specific helpers give way to device-agnostic accelerator probes (accelerator_unavailable, accelerator_memory_lt_gb, current_accelerator).
.github/workflows/ci.yml: Runs the five jagged test modules.

Documentation

README.md: Lists the newly reachable jagged operators.

Port fbgemm_gpu/src/jagged_tensor_ops/common.cuh and common.h to
SYCL/XPU as a header-only jagged_common.h, providing the kernels and
launchers that the individual jagged tensor operators build on.

Kernels, with CUDA functions expressed as SYCL functor classes:
  JaggedDenseElementwiseDenseOutputKernel
  JaggedDenseDenseElementwiseJaggedOutputKernel
  JaggedDenseDenseElementwiseJaggedOutputOptSearchKernel
  JaggedDenseDenseElementwiseJaggedOutputOptGatherKernel

Host launchers: jagged_dense_elementwise_dense_output_,
jagged_dense_elementwise_jagged_output_,
jagged_dense_elementwise_jagged_output_opt_, and the
jagged_dense_dense_elementwise_jagged_output_matches_opt gate that
chooses between the vectorized and generic jagged-output paths.
@aagalleg
aagalleg marked this pull request as ready for review September 10, 2026 01:15
Comment on lines +723 to +726
JaggedLaunchConfig cfg = \
check_shape_and_partition_(x_values, x_offsets, y); \
cfg.blocks = std::max<int64_t>( \
1, div_round_up(x_values.size(0), cfg.threads_y)); \

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.

Critical — generic launch throws away the grid cap

This overwrites the SYCL-safe grid cap computed by
check_shape_and_partition_(). I reproduced the consequence on B60 with
BF16, nnz=67,108,833, D=16: the flattened range becomes exactly 2^31
work-items, and both add-jagged-output and
jagged_to_padded_dense_backward fail with:

Provided range and/or offset does not fit in int

The kernel already has a group-stride loop, so could we cap this replacement
with xpu_cap_grid_dim_x(requested_blocks, threads_x * threads_y) and add
large-grid tests for add-jagged-output, backward, and dense-to-jagged? The
hardware reproduction exercised the first two; dense-to-jagged uses this same
macro for non-FP16 inputs. The comment above currently says both consumers
preserve the cap, but this macro discards it.

Comment on lines +232 to +244
const IdxT outer_begin = static_cast<IdxT>(
item.get_group(0) * item.get_local_range(0) + item.get_local_id(0));
// CUDA: gridDim.x * blockDim.y
const IdxT outer_stride = static_cast<IdxT>(
item.get_group_range(0) * item.get_local_range(0));
const IdxT total_outer = outer_dense_size * jagged_folded_size;

// CUDA: threadIdx.x / blockDim.x
const IdxT inner_begin = static_cast<IdxT>(item.get_local_id(1));
const IdxT inner_stride = static_cast<IdxT>(item.get_local_range(1));

for (IdxT outer = outer_begin; outer < total_outer;
outer += outer_stride) {

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.

Critical — grid-stride induction can overflow int32

The per-tensor numel < INT_MAX check does not make this int32 induction safe.
For total_outer=2^30+1, D=1, the capped launch gives
outer_stride=1,073,741,856; the final active work-item then evaluates
1,073,741,824 + 1,073,741,856 = 2,147,483,680, overflowing int32 before the
next loop condition. The generic jagged-output int offset loop has the same
pattern.

The paired inner loops also evaluate iidx * 2 + 1 in int32. With
inner_dense_size=INT_MAX-1, that expression eventually overflows even though
the tensor-size gate selected the int32 path.

Because signed overflow is undefined behavior, this can become an
out-of-bounds device iteration rather than a clean error. Could we keep
grid-stride and paired-inner counters/arithmetic in int64 (while retaining
validated 32-bit accessors if desired), or explicitly guard the final
increment/multiply, and add large-outer and large-inner regressions?

Comment on lines +850 to +868
int count = B_ - 1;
int first = 1;
while (count > 0) {
int idx = first;
int step = count / 2;
idx += step;
if (offsets_sh[idx] <= row) {
first = ++idx;
count -= step + 1;
} else {
count = step;
}
}
--first;

const int dense_row = first;
const int offset = static_cast<int>(offsets_sh[dense_row]);
const int dense_col = row - offset;
rows_[row] = dense_row;

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.

High — FP16 search misses the final sentinel

The generic search covers offsets.size()-1 == B entries, but this FP16 path
uses B-1 and never examines the final offsets[B] sentinel.

I reproduced wrong output on B60 for both int32 and int64 offsets with
dense=[2,1,8], offsets=[0,1,1], total_L=2: the second jagged row should
be zero, but this path returns dense row 1 ([9,...,16]).

Could we search the complete sentinel range and add this regression for both
offset dtypes? After correcting the search, an out-of-range row can produce
dense_row == B, so y0_ptr/y1_ptr must also be formed only after the
existing bounds check; currently the invalid references are constructed first.

Comment on lines +786 to +788
if (y.numel() == 0 || x_values.numel() == 0) {
return;
}

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.

High — zero max length leaves outputs uninitialized

This returns before writing a non-empty output when the dense max length is
zero. Both callers allocate with at::empty, so the result is uninitialized.
With deterministic uninitialized-memory filling enabled on B60:

  • dense-to-jagged with dense=[1,0,8], offsets=[0,2], total_L=2 returned
    [2,8] NaNs instead of zeros;
  • add-jagged-output with x=[2,8], y=[1,0,8] returned NaNs instead of
    unchanged x.

These differ from the CPU semantics. Could we initialize these zero-max-length
cases explicitly and add regressions for both operators?

Comment on lines +480 to +487
constexpr int64_t kWarpSize = static_cast<int64_t>(kThreadGroupSize);

// CUDA: inner_dense_size >= kWarpSize / 2 ? kWarpSize : inner_dense_size.
// Clamped to at least 1 so a zero-width inner dim cannot produce an empty
// nd_range (CUDA tolerates a zero-sized block dim by never launching).
const int64_t threads_x = std::max<int64_t>(
1, inner_dense_size >= kWarpSize / 2 ? kWarpSize : inner_dense_size);
const int64_t threads_y = static_cast<int64_t>(kMaxThreads) / kWarpSize;

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.

High — CUDA geometry is up to 3.8x slower on BMG

Each work-item below handles two adjacent inner elements, but this copied CUDA
rule jumps to threads_x=32 at D=16. Together with threads_y=32, that
creates a 1024-item work-group while only 8 inner lanes do useful work.

I A/B-built a variant changing only this expression to use
min(32, ceil(D/2)). The same 24-case parity/autograd matrix passed. On B60,
BF16 D=16 improved:

  • jagged-to-padded-dense: 0.568 -> 0.159 ms (3.57x);
  • dense-to-jagged: 1.337 -> 0.350 ms (3.82x);
  • add-jagged-output: 1.349 -> 0.359 ms (3.76x).

D=32 improved about 1.8-2.0x and D>=64 was unchanged. Could we derive the
inner range from the two-element chunks and validate the full D sweep? The
inner axis should remain SYCL's fastest-varying dimension.

Comment on lines +773 to +776
TENSOR_ON_SYCL_XPU(x_values);
for (const auto& x_offset : x_offsets) {
TENSOR_ON_SYCL_XPU(x_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.

High — mixed-device pointers are not rejected

These checks validate only that values and each offset are some XPU tensor.
They do not validate y/output placement or require every tensor to use the
same XPU index. The add wrapper selects its guard from dense, so a CPU dense
tensor or a different XPU index can select a queue and pass foreign pointers
to the kernel.

Could we validate values, dense/output, and every offset are on the same XPU
before selecting the stream, and add mixed CPU/XPU plus XPU:0/XPU:1 argument
tests? This should fail cleanly at the API boundary rather than risking invalid
device-memory access.

Comment on lines +1206 to +1217
if (jagged_dense_dense_elementwise_jagged_output_matches_opt(
num_jagged_dim,
x_values,
x_offsets,
y_reshaped,
y_reshaped,
output_values)) {
AT_DISPATCH_INDEX_TYPES(
x_offsets[0].scalar_type(), "jagged_indices_fast_path", [&] {
const auto nnz = output_values.size(0);
const auto B = y_reshaped.size(0);
const auto E = y_reshaped.size(2);

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.

High — FP16 fast path skips shape validation

This branch bypasses check_shape_and_partition_(), so unlike the generic
path it never validates the inner widths or offsets[0].numel() == B+1.

I reproduced both omissions on B60. FP16 add with x_values=[2,16],
dense=[2,1,8], offsets [0,1,2] succeeded and left the final eight output
values per row as NaNs; CPU rejects the size mismatch. Dense-to-jagged with
dense=[2,1,8], offsets [0,1,2,2], total_L=2 also silently succeeded
while CPU rejects the extra interval. Reversing the width mismatch makes the
vector gather store the larger dense width into shorter output rows.

Could we run the common shape validation before selecting the optimized path
and add FP16 regressions for both width directions and short/long offset
lists?

Comment on lines +1130 to +1140
// Each row is aligned to 128-bit
matches &= (x_values.stride(-2) % 8 == 0);
matches &= (output_values.stride(-2) % 8 == 0);
matches &= (y_0_reshaped.stride(-2) % 8 == 0);
matches &= (y_1_reshaped.stride(-2) % 8 == 0);

// Base addresses aligned to 128-bit
matches &= (reinterpret_cast<uint64_t>(x_values.data_ptr()) % 16 == 0);
matches &= (reinterpret_cast<uint64_t>(output_values.data_ptr()) % 16 == 0);
matches &= (reinterpret_cast<uint64_t>(y_0_reshaped.data_ptr()) % 16 == 0);
matches &= (reinterpret_cast<uint64_t>(y_1_reshaped.data_ptr()) % 16 == 0);

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.

High — FP16 matcher misses dense batch alignment

These checks cover the base and stride(-2), but not the reshaped dense
tensor's outer batch stride. A later batch can therefore be misaligned even
though the gather dereferences it as HalfVec8*.

On B60, a valid FP16 dense tensor with shape [2,1,8] and strides (17,8,1)
entered this path. Adding contiguous x_values=[2,8] should have produced row
1 [66,68,70,72,74,76,78,80]; it returned
[8,67,69,71,73,75,77,79].

Could we require every dense row used by the vector loads to be 16-byte
aligned (including stride(0)), or fall back to unaligned/generic loads, and
add this strided regression?

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.

2 participants