[Operator Mechanism]Add CUDA small-stride index gradient kernel - #79716
[Operator Mechanism]Add CUDA small-stride index gradient kernel#79716feixi139 wants to merge 15 commits into
Conversation
Failed CI看板
日志分析报告当前 CI 快照抓取超时,未能取得失败 job 列表和日志;PR diff 也不可用。因此无法确认失败流水线、失败测试或根本原因,也不能据此判断 CI 已通过。 失败的测试 case: 根本原因分析: 现有数据不足,不能区分 PR 代码问题、CI 基础设施问题或上游依赖问题,也不能建立 job 之间的因果链。 修复建议:
Powered by Nyanpasu with gpt-5.6-luna 默认推理级别, please check the suggestions carefully. |
1d23f75 to
f210354
Compare
risemeup1111
left a comment
There was a problem hiding this comment.
Paddle-Bot Review Board (review完成)
| 序号 | 位置 | 优先级 | 规则来源 | 状态 |
|---|---|---|---|---|
| 1 | 步长切片梯度写回 | 仓库规则:算子与内存正确性 | 🚧 |
| IndexPutWithSortKernel<T, int64_t>( | ||
| dev_ctx, out_grad, index, layout, accumulate, &view_grad); | ||
| auto grad_meta = x_grad->meta(); | ||
| StridedCopyKernel<T, Context>(dev_ctx, |
There was a problem hiding this comment.
这里把
layout.view_offset(来自 DealWithIndex 对 sub_tensor.data() - tensor.data() 的结果)传给 StridedCopyKernel。对于 x[::2, idx] 这类步长切片,strided_slice kernel 会为 sub_tensor 单独分配连续 buffer,并不与 tensor 共享 storage;此时指针差不是 x_grad 内的合法字节偏移,可能为负并在 DenseTensorMeta::offset 中转成超大无符号值。写回会导致梯度写错或越界。请仅在确认共享 storage 且偏移有效时走该路径,步长切片保留 elementwise/slice-backward 回退,并补充回归测试。
There was a problem hiding this comment.
risemeup1111 感谢 review。这里的前提需要澄清一下:strided_slice 在 FLAGS_use_stride_kernel 打开时返回的是共享 storage 的视图,不会单独分配连续 buffer。
paddle/phi/kernels/stride/strided_slice_kernel.cc:110 就是 out->ResetHolder(input.Holder()),实测各种跨步切片都共享 holder、offset 合法非负:
flag 关闭时切片确实是拷贝,但那种状态下这段代码根本不执行:index_elementwise_get 全仓库只有 4 个真实调用点,全部位于 slice_utils.h:810 / :1334 的 FLAGS_use_stride_kernel 分支内,flag 为 0 时 getitem 会退回 gather / gather_nd,index_elementwise_get_grad 不入图,layout.view_offset 也就不会被构造。也就是说"strided_slice 单独分配 buffer"与"执行这段 grad kernel"这两个条件互斥。
关于建议的"步长切片保留 elementwise/slice-backward 回退":这条不能采纳。slice_utils.h:725 在 FLAGS_use_stride_kernel && pos_of_new_dim != 0 时不做 transpose(transed_tensor = tensor),所以在 stride 路径上退回 gather_nd(transed_tensor, ...) 会 gather 到前导轴,得到形状正确但数值错误的结果 —— 例如 x[1:5, idx] 会变成按行 gather。同构的问题在 bool 分支上已经存在(x[::2][:, mask] 实测得到 [2,4],正确应为 [3,2])。要加这种回退必须先真正 transpose,否则是引入静默错误。
边界检查这一条很有价值,已按此补上(commit 见上):
kernel 入口校验 slice_offset,同时覆盖排序路径和 elementwise 回落两条路:
const int64_t grad_bytes = x_grad->numel() *
static_cast<int64_t>(sizeof(T));
PADDLE_ENFORCE_GE(slice_offset, 0, ...);
PADDLE_ENFORCE_LT(slice_offset, grad_bytes, ...);
DeriveSortedPathLayout 里补视图跨度检查。原来只有 numel > grad_numel → return false,对跨步视图不够 —— 稀疏视图的元素数小但跨度可能超出缓冲区:
int64_t last = slice_offset / elesize;
for (size_t i = 0; i < view.size(); ++i) last += (view[i] - 1) * vstride[i];
PADDLE_ENFORCE_LT(last, grad_numel, ...); // 报错信息带 view / strides / slice_offset
回归测试也补了 TestIndexElementwiseGetGradSlicedView:5 个表达式(含非零 offset 的 x[1::2, idx]、x[2:7:2, idx])× fp32/fp16/bf16 × FLAGS_use_stride_kernel 开关两态,断言覆盖整个 x.grad,所以"梯度块被搬到错误位置"会直接暴露为未触及行非零。
验证情况:
test_index_elementwise_grad 15 / test_index_elementwise 8 全过,新增 ENFORCE 无误报
回归全绿:test_getitem 435 / test_setitem 323 / getitem_appendix 18 / setitem_appendix 12 / test_index_put_op 86 / test_set_value_op 163
paddle-vs-torch 反向逐位 sweep(--atol=0 --rtol=0)792/792 完全相等,含 x[1::2, idx]、x[2:7:2, idx]、x[::2, idx, ::2] 等跨步视图,4 种 shape × 3 dtype × 重复度 {2,8,40} × 正/负下标
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #79716 +/- ##
===========================================
Coverage ? 100.00%
===========================================
Files ? 2
Lines ? 8
Branches ? 0
===========================================
Hits ? 8
Misses ? 0
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
5425e48 to
9fc8e83
Compare
|
@Paddle-Bot review此pr |
1 similar comment
|
@Paddle-Bot review此pr |
Advanced indexing on a reversed view (`x[::-1, idx]`) raised CUDA error 700. `strided_slice` returns a view whose base offset points at the highest address of the reversed axis, so the gather/scatter kernels have to reach backwards from `slice_offset`, but their offset calculators were instantiated with an unsigned stride type that wrapped those offsets into huge positive values. - Instantiate the offset calculators of index_elementwise get/put, forward and backward, CPU and GPU, with signed strides. Add `funcs::IsInInt32Range` for the 32/64-bit dispatch, since the unsigned bound no longer applies, and guard the new `CheckOffsetRange` with `if constexpr (signed_strides)` so the unsigned instantiations under `stride/` keep their previous behaviour. The forward gather kernel now validates both ends of the touched span and reports a diagnosable error instead of faulting. - Order the iteration dimensions in `reorder_dimensions` by |stride| instead of by signed value. A negative stride always compared smallest and was sorted innermost, and the contiguous strides that `allocate_or_resize_outputs()` assigns afterwards then described a permuted layout while the output tensor's meta stayed C-contiguous, silently transposing the result for rank >= 3. `coalesce_dimensions` keeps its signed comparison on purpose, otherwise a reversed axis would be merged into its neighbour.
The indexing offset calculators are instantiated with signed_strides=true, so their 32 bit fast path is bounded by int32_t rather than uint32_t. The dispatch, however, only measured x and out. The third operand walks the index tensors with byte strides scaled by sizeof(int64_t), so it can reach past int32_t while x and out stay small: a bool x[1] gathered by an int64 index of 3e8 elements produces a 2.4 GB span and CheckOffsetRange rejected it, even though the previous unsigned path handled that shape. Add IndexOperandByteSpan(), which derives the broadcast index shape with cal_shape_stride and returns (elements - 1) * sizeof(int64_t), and feed it into the dispatch at all six entries that build a signed calculator (index_elementwise get / get_grad / put / put_grad). The index tensors may be broadcast, so index[0]->numel() would understate the span and is not used.
index_elementwise_put_with_tensor hands `value` to the offset calculator with its own dims and strides, but the dispatch only weighed x, out and the index operand. Their extents do not bound value's: a complex128 value of 2e8 elements reaches 3.2e9 bytes while x, out and the int64 index operand (1.6e9 bytes) all stay inside int32_t, so the 32 bit path was chosen and CheckOffsetRange then rejected a shape the 64 bit path handles.
CPUOffsetCalculator took a signed_strides flag mirroring the GPU one, and with it the CPU kernels inherited the GPU's tradeoff: signed offsets make reversed views work but halve the reach, and the GPU pays for that by dispatching between a 32 bit and a 64 bit calculator. The CPU has no such dispatch, so a contiguous float32 [2, 600000000] -- 2.4 GB of reachable byte offsets, past int32_t but inside uint32_t -- was rejected by CheckOffsetRange, a shape the previous unsigned instantiation handled. The tradeoff does not exist on the CPU: a 64 bit add costs the same as a 32 bit one, and only the divmod over linear_idx benefits from index_t staying 32 bit, which is independent of the offset width. So accumulate offsets in int64_t unconditionally. That leaves signed_strides with no false user, so drop the flag from the calculator and its three factories and revert the five call sites; the reach check goes with it, since int64 bounds cannot be exceeded by a real allocation.
Three places needed the same quantity: the lowest and highest offset that walking a set of dims with a set of strides can produce. The bounds check in GPUIndexElementwiseGetKernel and the layout derivation in DeriveSortedPathLayout each carried their own loop, and StridedOperandByteSpan summed |(dim - 1) * stride| for the dispatch, which is the same hi - lo written differently. Each copy has to get the same two details right: a size-1 axis never uses its stride, and a negative stride pulls the low end below the base instead of raising the high end. Collect that in OperandReach plus AccumulateReach and have all three use it. AccumulateReach adds into an existing reach so that operands sharing a base -- x and the index tensor are both offset from slice_offset -- combine, and takes a scale so a caller can pass element strides and get bytes. Also correct the comment above IsNonOverlapping. It claimed a reversed view cannot reach the grad kernel because the forward kernel truncates negative strides to unsigned, but the forward kernel instantiates its offset calculator with signed_strides = true, so reversed views do arrive here and the negative case is live.
1980b22 to
bf233e8
Compare
PR Category
Operator Mechanism
PR Types
Bug fixes
Description
本 PR 修复 GPU 高级索引(advanced indexing)反向的三个问题:
x[:, idx]这类表达式的梯度精度明显低于x[idx];None(newaxis)出现在高级索引之前时,索引被绑定到错误的轴,前向 shape 就是错的;index_elementwise_get_grad没有校验slice_offset和被索引视图的跨度,非法值会变成越界写。1. 高级索引反向的低精度累加
torch 的
index_put_(accumulate=True)CUDA 实现完全没有 atomicAdd,只有排序路径,所以它的结果确定、可复现,且重复下标只舍入一次。本节的三处改动合起来把 Paddle 的所有 accumulate 反向也导向这条路径:1.1 放开路由,1.2 补上排序路径缺失的 kernel,1.3 让排序路径能处理放开后新进来的跨步视图。1.1 路由判据用错了维度,
x[:, idx]落到逐项舍入的 atomicAdd问题
index_elementwise_get_grad原来的路由条件是:is_combined来自 pybind:eager_method.cc里index_size = PyTuple_GET_SIZE(index_ptr)是__getitem__索引元组的元素个数(slice/None/Ellipsis都算),slice_utils.h里is_combined = (index_size == 1) ? false : true。因此真实判据是「索引元组只有一个元素」:x[idx]→ 走排序 kernel(IndexingBackwardKernelStride1/IndexingBackwardKernel),重复下标在opmath_t(fp32)寄存器里归约,只在写回时舍入一次;x[:, idx]、x[..., idx]、x[idx, None]→is_combined=true,落到GPUIndexElementwiseGetGrad,IndexEleGetGradAccKernel内是CudaAtomicAdd(reinterpret_cast<T*>(...), T value),即 fp16/bf16 原生 atomicAdd,每加一次就舍回低精度,误差随重复次数增长,且累加顺序不确定、run-to-run 不可复现。触发条件是同一下标重复 ≥3 次:N=2 时低精度逐步累加恒等于正确舍入(
0+a精确,a+b只舍一次),所以必须重复三次以上才会暴露。[16, 8]配x[:, idx](索引长 5)200 组随机数据统计:改动
路由门从
accumulate && index.size() == 1 && !is_combined改为if (accumulate)(index_elementwise_get_grad_kernel.cu:724)。放开之后有两类输入是排序路径原来处理不了的,分别由 1.2、1.3 补上。1.2 排序路径缺
1 < sliceSize <= 32的 kernel问题
sliceSize(被索引轴之后的元素个数)落在1 < sliceSize <= 32时,即使走到排序路径,也只有IndexingBackwardKernelStride1(仅sliceSize == 1)和按 feature 展开的通用IndexingBackwardKernel两个选择,后者对每个重复项都做一次grad_weight的 read-modify-write,同样是每步舍入。torch 在这里有专门的indexing_backward_kernel_small_stride,且它真正的分派维度就是sliceSize与 warp size 的比较,而不是索引表达式的写法。改动
新增
IndexingBackwardKernelSmallStride(:344),覆盖1 < sliceSize <= WARP_SIZE:一个线程独占一个 feature 列,该下标的所有重复项在opmath_t(fp32)寄存器内归约,写回时才舍入一次。分派处(:638)由此按sliceSize三分:== 1走IndexingBackwardKernelStride1,<= WARP_SIZE走新 kernel,其余走通用IndexingBackwardKernel。1.3 排序 kernel 无法寻址跨步视图
问题
排序 kernel 把
grad_weight当作一块平坦 buffer 寻址;原IndexPutWithSortKernel自己做 transpose / inverse-perm 并用funcs::makeLinearIndex构造线性索引,只在「被索引对象就是整块连续x_grad」时成立。1.1 放开路由后,x[1::2, idx]这类先切片再索引也会进来,此时被索引对象是x_grad内的一块跨步子区域,直接跑排序 kernel 会写到错误位置。改动
IndexPutWithSortKernel改为接收一个描述被索引视图的SortedPathLayout(:401),并直接用funcs::computeLinearIndex在该视图上构造线性索引。被索引轴的位置由 layout 给出,且必然是连续的一段,因此transposeToFrontAndInvPerm那一步在此路径上不再需要,也省掉了 permute 后的Contiguous与写回时的反向 transpose。视图信息的恢复方式(
DeriveSortedPathLayout,:437):kernel 参数描述的是 restride 之后的视图 ——restride_src()(paddle/fluid/pybind/slice_utils.h)把被索引轴换成广播后的 index shape 并给这些轴零 stride,所以input_strides里连续的一段 0 就标出了被索引块,起点即dims_before;而index_dims(即AdvancedIndex::indexed_sizes)开头正是这些轴的 extent,于是视图在索引之前的 shape 及其在x_grad内的 stride 都可以还原。据此分三种情况:x_grad时,直接在x_grad上跑排序 kernel;x[1::2, idx]),先分配一个与该视图同形的连续 scratch,在其上归约,再用StridedCopyKernel按view_strides/view_offset搬回x_grad—— 这与 torch 用slice_backward组合index_backward的做法一致。x_grad已经清零,所以搬回时无需累加。注意phi::StridedCopyKernel会覆写out的 meta,调用方需自行 save / restore;IsNonOverlapping(:411)拒绝自重叠的as_strided式视图,这类布局回落到原来的 elementwise 路径(精度更低、run-to-run 不确定),与上游行为保持一致。slice_offset/index_strides是字节偏移(slice_utils.h里stride * element_size_bytes),派生 layout 时统一按elesize换算回元素单位,不能整除即视为非法并回落。2.
None出现在高级索引之前导致轴绑定错位问题
None位于高级索引之前时,前向 shape 就与 numpy / torch 不一致:x为[16, 8],i长 3)x[None, :, i](1, 16, 3)(1, 3, 8)x[:, None, i](16, 1, 3)(16, 3, 8)x[None, i](1, 3, 8)(3, 16, 8)x[i, None](3, 1, 8)(3, 1, 8)(正确)ParseIndex里的estimated_dim是基础索引输出张量的轴号(slice保留 →++,整数 → 不++(会 decrease),Ellipsis→+= rank - specified,bool tensor →+= rank),advanced_index_dim[estimated_dim] = estimated_dim用它记录高级索引落在哪个轴。但Py_None分支只 pushnone_axes而不推进estimated_dim,而getTensorWithBasicIndexing结尾的unsqueeze_ad_func(out, *none_axes)确实在输出里插入了这个轴(none_axes已按decrease_axis修正,就是基础输出的坐标系)。两个坐标系因此错开none_count个位置,索引被绑到错误的轴上。同分支的单 bool(
True/ 0-D bool tensor)处理是对的:它 pushnone_axes的同时也estimated_dim++,正好印证None分支缺这一步。静态图
parse_index有同构的 bug,且更早暴露:replace_none先把None从indices里剥掉,循环根本看不到None,修改前静态图x[None, i]直接在gather_nd报 index out of range。改动
slice_utils.h的ParseIndexPy_None分支补estimated_dim++;eager_method.cc两处advanced_index_dim容量从rank * 2改为rank * 2 + index_size(setitem 侧为+ size)。否则 rank 2 张量上x[None, None, None, None, None, i]会让estimated_dim到 5,发生越界写;variable_index.py的parse_index改成_, none_axes = replace_none(indices)保留原indices,循环里遇到None时estimated_dim += 1; continue,并把advanced_index扩容len(indices)。3.
slice_offset与视图跨度的越界校验问题
slice_offset是前向记录的「被索引视图在x_gradbuffer 内的字节偏移」。排序路径和 elementwise 回落路径都会把它加到x_grad的基址上,因此一个非法值(负数,或超出 buffer)会直接变成越界写,而不是报错。同样地,跨步视图是稀疏的,它触达的最远位置由跨度决定而不是元素个数决定,只检查
numel <= grad_numel不足以保证不越界。改动
IndexElementwiseGetGradKernel入口校验0 <= slice_offset < x_grad 字节数;DeriveSortedPathLayout里校验视图触达的最后一个位置slice_offset / elesize + Σ (view_dims[k] - 1) * view_strides[k] < x_grad->numel(),报错信息带上 offset、view shape 和 strides,便于定位。修改后的行为
x[:, idx]等重复下标反向1 < sliceSize <= 32的排序路径x[1::2, idx])x[None, :, i]等None在前__setitem__多数直接报 broadcast 失败、静态图gather_nd越界Noneadvanced_index_dim可能越界写index_size扩展slice_offset/ 越界视图自重叠的
as_strided式视图仍回落到原 elementwise 路径,行为与之前相同。是否引起精度变化
是