Skip to content

Commit 920ab5f

Browse files
committed
feat(paged): fused residual-add + RMS norm + weight multiply (patch 0042)
The transformer pre-norm residual chain `h = x + sub_out; n = rms_norm(h) * w` runs as separate CUDA launches in the paged prefill graph: a k_bin_bcast ADD (the residual) feeding the existing fused rms_norm+mul. ggml-cuda already fuses rms_norm+mul (and rms_norm+mul+ADD, where the ADD is a *post*-norm bias) but NOT the *pre*-norm residual add that feeds the norm. This is the classic add-RMSNorm fusion (as in vLLM / TensorRT-LLM) that ggml-cuda lacks; it is part of the unfused-tail prefill gap vs vLLM's torch.compile fusions. Add it as a CUDA-family graph fusion (paged series owns it; stock stays pure): - ggml_cuda_can_fuse recognizes { ADD, RMS_NORM, MUL } via ggml_can_fuse_subgraph with BOTH the ADD (node_idx) and the MUL (node_idx+2) marked as outputs - the residual ADD has a second consumer (the later skip-connection add), so it cannot pass the single-use ggml_can_fuse() gate the other rms_norm fusions use. - New kernel rms_norm_pre_add_mul_f32 computes h = a + b, publishes h to the residual buffer (downstream skip add reads it), then sum(h^2) -> scale -> dst = scale * h * w in ONE launch, emitting BOTH outputs the graph needs. - Gated by LLAMA_FUSE_ADD_RMSNORM (default ON) for a clean single-build A/B. BIT-EXACT (per-path canonical greedy md5, n=48 --temp 0 --seed 1, paged): dense q36-27b-nvfp4 : 5951a5b4d624ce891e22ab5fca9bc439 (ON == OFF == canonical) MoE q36-35b-a3b : 8cb0ce23777bf55f92f63d0292c756b0 (ON == OFF == canonical) The fused kernel reproduces the exact FP order of the unfused chain: h = a + b (IEEE add is order-free), the sum(h^2) reduction uses the same block_reduce<SUM> with the same 256/1024 block-size thresholds, and the same rsqrtf(mean+eps) scale, so the byte stream is unchanged. test-backend-ops RMS_NORM/ADD/MUL pass (CUDA0 vs CPU). PROFILE (dense prefill, nsys --cuda-graph-trace=node, npp512 ntg4 npl8): rms_norm_f32<1024> 903 launches / 96.6M ns -> 7 / 0.7M ns k_bin_bcast<op_add> 1232 launches / 138.6M ns -> 336 / 1.0M ns rms_norm_pre_add_mul (new) 896 launches / 187.2M ns -> 896 residual-add + 896 rms_norm launches folded into 896 fused launches; the norm+residual slice 233.6M -> 187.2M ns (~20% of that slice, ~1% of total prefill GPU time). S_PP dense (npp512 ntg4 npl32, 3x): 985.5 -> 990.6 t/s (+0.5%, every ON run beats every OFF run). Modest because the residual tail is a small slice of prefill; the dominant unfused cost is k_bin_bcast<op_mul> (11%, the GDN chunked-prefill gating muls) - a separate lever. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent b63eb69 commit 920ab5f

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

ggml/src/ggml-cuda/ggml-cuda.cu

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3764,6 +3764,48 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph,
37643764
}
37653765
}
37663766

3767+
// Fused residual-add + RMS norm + weight multiply. The transformer residual
3768+
// ADD feeds the next sublayer's RMS norm but is ALSO consumed by the later
3769+
// residual add (skip connection), so the ADD node is a graph output too; it
3770+
// cannot go through the single-use ggml_can_fuse() gate below. Recognize it
3771+
// here with ggml_can_fuse_subgraph, marking both the ADD (node_idx) and the
3772+
// final MUL (node_idx + 2) as outputs.
3773+
std::initializer_list<enum ggml_op> add_rms_norm_mul_ops = { GGML_OP_ADD, GGML_OP_RMS_NORM, GGML_OP_MUL };
3774+
if (is_equal(add_rms_norm_mul_ops, ops) &&
3775+
ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx, node_idx + 2 })) {
3776+
const ggml_tensor * add = cgraph->nodes[node_idx];
3777+
const ggml_tensor * rms_norm = cgraph->nodes[node_idx + 1];
3778+
const ggml_tensor * mul = cgraph->nodes[node_idx + 2];
3779+
3780+
// RMS norm must consume the residual-add output.
3781+
if (rms_norm->src[0] != add) {
3782+
return false;
3783+
}
3784+
// All operands F32 (rms norm / fused mul kernel only support F32).
3785+
if (add->src[0]->type != GGML_TYPE_F32 || add->src[1]->type != GGML_TYPE_F32 ||
3786+
add->type != GGML_TYPE_F32 || rms_norm->type != GGML_TYPE_F32 ||
3787+
mul->src[0]->type != GGML_TYPE_F32 || mul->src[1]->type != GGML_TYPE_F32 ||
3788+
mul->type != GGML_TYPE_F32) {
3789+
return false;
3790+
}
3791+
// The fused kernel computes h = a + b elementwise: same shape, no broadcast.
3792+
if (!ggml_are_same_shape(add->src[0], add->src[1])) {
3793+
return false;
3794+
}
3795+
// rms_norm kernel assumes contiguous rows for the residual operands and weight.
3796+
if (!ggml_is_contiguous(add->src[0]) || !ggml_is_contiguous(add->src[1])) {
3797+
return false;
3798+
}
3799+
if (!ggml_is_contiguous_rows(mul->src[0]) || !ggml_is_contiguous_rows(mul->src[1])) {
3800+
return false;
3801+
}
3802+
// If rms_norm is the B operand of the mul, broadcast of the A operand is unsupported.
3803+
if (rms_norm == mul->src[1] && !ggml_are_same_shape(mul->src[0], rms_norm)) {
3804+
return false;
3805+
}
3806+
return true;
3807+
}
3808+
37673809
if (!ggml_can_fuse(cgraph, node_idx, ops)) {
37683810
return false;
37693811
}
@@ -4286,6 +4328,18 @@ static int ggml_cuda_try_fuse(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph
42864328
return fused_node_count - 1;
42874329
}
42884330

4331+
// Fused residual-add + RMS norm + weight multiply (bit-exact). Default ON;
4332+
// set LLAMA_FUSE_ADD_RMSNORM=0 for a clean A/B against the unfused path.
4333+
static const bool fuse_add_rmsnorm = [] {
4334+
const char * e = getenv("LLAMA_FUSE_ADD_RMSNORM");
4335+
return e == nullptr || atoi(e) != 0;
4336+
}();
4337+
if (fuse_add_rmsnorm &&
4338+
ggml_cuda_can_fuse(cgraph, i, { GGML_OP_ADD, GGML_OP_RMS_NORM, GGML_OP_MUL }, {})) {
4339+
ggml_cuda_op_rms_norm_pre_add_mul(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2]);
4340+
return 2;
4341+
}
4342+
42894343
if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ADD }, {})) {
42904344
ggml_cuda_op_rms_norm_fused_add(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2]);
42914345
return 2;

ggml/src/ggml-cuda/norm.cu

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,87 @@ static __global__ void rms_norm_f32(const float * x,
154154
}
155155
}
156156

157+
// Fused residual-add + RMS norm + (optional) weight multiply.
158+
// h = a + b (the residual stream, written to h_out)
159+
// dst = rsqrt(mean(h^2)+eps) * h * mul
160+
// `a` and `b` are required to be the same shape and contiguous (the transformer
161+
// residual add), so they share `x`'s strides; `h_out`, `dst` are also contiguous
162+
// with that shape. `mul` (the RMS weight) broadcasts via the packed-modulo path.
163+
//
164+
// Bit-exactness: this reproduces the exact FP order of the unfused chain
165+
// k_bin_bcast(add): h[col] = a[col] + b[col] (f32, elementwise, order-free)
166+
// rms_norm: sumsq over h[col] in column order via block_reduce
167+
// mul: dst[col] = scale * h[col] * mul[col]
168+
// h is summed from the same f32 values in the same order, so the reduction and
169+
// the final scale are byte-identical to running the three kernels separately.
170+
template <int block_size, bool do_multiply = false>
171+
static __global__ void rms_norm_pre_add_mul_f32(const float * a,
172+
const float * b,
173+
float * h_out,
174+
float * dst,
175+
const int ncols,
176+
const int64_t stride_row,
177+
const int64_t stride_channel,
178+
const int64_t stride_sample,
179+
const float eps,
180+
const float * mul = nullptr,
181+
const int64_t mul_stride_row = 0,
182+
const int64_t mul_stride_channel = 0,
183+
const int64_t mul_stride_sample = 0,
184+
const uint3 mul_ncols_packed = make_uint3(0, 0, 0),
185+
const uint3 mul_nrows_packed = make_uint3(0, 0, 0),
186+
const uint3 mul_nchannels_packed = make_uint3(0, 0, 0),
187+
const uint3 mul_nsamples_packed = make_uint3(0, 0, 0)) {
188+
ggml_cuda_pdl_lc();
189+
const int nrows = gridDim.x;
190+
const int nchannels = gridDim.y;
191+
192+
const int row = blockIdx.x;
193+
const int channel = blockIdx.y;
194+
const int sample = blockIdx.z;
195+
const int tid = threadIdx.x;
196+
197+
const int64_t row_offset = sample*stride_sample + channel*stride_channel + row*stride_row;
198+
a += row_offset;
199+
b += row_offset;
200+
h_out += row_offset;
201+
// dst is laid out contiguously by the scheduler for the MUL output
202+
dst += ((sample*nchannels + channel)*nrows + row)*ncols;
203+
204+
if constexpr (do_multiply) {
205+
const uint32_t mul_row = fastmodulo(row, mul_nrows_packed);
206+
const uint32_t mul_channel = fastmodulo(channel, mul_nchannels_packed);
207+
const uint32_t mul_sample = fastmodulo(sample, mul_nsamples_packed);
208+
mul += mul_sample * mul_stride_sample + mul_channel * mul_stride_channel + mul_row * mul_stride_row;
209+
}
210+
211+
float tmp = 0.0f; // partial sum for thread in warp
212+
213+
ggml_cuda_pdl_sync();
214+
for (int col = tid; col < ncols; col += block_size) {
215+
const float hi = a[col] + b[col];
216+
h_out[col] = hi; // publish the residual stream for the next add
217+
tmp += hi * hi;
218+
}
219+
220+
// sum up partial sums
221+
extern __shared__ float s_sum[];
222+
tmp = block_reduce<block_reduce_method::SUM, block_size>(tmp, s_sum);
223+
224+
const float mean = tmp / ncols;
225+
const float scale = rsqrtf(mean + eps);
226+
227+
for (int col = tid; col < ncols; col += block_size) {
228+
const float hi = h_out[col];
229+
if constexpr (do_multiply) {
230+
const int mul_col = fastmodulo(col, mul_ncols_packed);
231+
dst[col] = scale * hi * mul[mul_col];
232+
} else {
233+
dst[col] = scale * hi;
234+
}
235+
}
236+
}
237+
157238
template <int block_size>
158239
static __global__ void rms_norm_back_f32(
159240
const float * grad, const float * xf, float * dst, const int ncols, const float eps) {
@@ -407,6 +488,50 @@ static void rms_norm_mul_f32_cuda(const float * x,
407488
}
408489
}
409490

491+
static void rms_norm_pre_add_mul_f32_cuda(const float * a,
492+
const float * b,
493+
float * h_out,
494+
float * dst,
495+
const int ncols,
496+
const int nrows,
497+
const int nchannels,
498+
const int nsamples,
499+
const int64_t stride_row,
500+
const int64_t stride_channel,
501+
const int64_t stride_sample,
502+
const float * mul,
503+
const int64_t mul_stride_row,
504+
const int64_t mul_stride_channel,
505+
const int64_t mul_stride_sample,
506+
const uint32_t mul_ncols,
507+
const uint32_t mul_nrows,
508+
const uint32_t mul_nchannels,
509+
const uint32_t mul_nsamples,
510+
const float eps,
511+
cudaStream_t stream) {
512+
const dim3 blocks_num(nrows, nchannels, nsamples);
513+
GGML_ASSERT(mul != nullptr);
514+
const uint3 mul_ncols_packed = init_fastdiv_values(mul_ncols);
515+
const uint3 mul_nrows_packed = init_fastdiv_values(mul_nrows);
516+
const uint3 mul_nchannels_packed = init_fastdiv_values(mul_nchannels);
517+
const uint3 mul_nsamples_packed = init_fastdiv_values(mul_nsamples);
518+
if (ncols < 1024) {
519+
const dim3 block_dims(256, 1, 1);
520+
const ggml_cuda_kernel_launch_params launch_params = ggml_cuda_kernel_launch_params{blocks_num, block_dims, block_dims.x > WARP_SIZE ? 32 * sizeof(float) : 0, stream};
521+
ggml_cuda_kernel_launch(rms_norm_pre_add_mul_f32<256, true>, launch_params,
522+
a, b, h_out, dst, ncols, stride_row, stride_channel, stride_sample, eps,
523+
mul, mul_stride_row, mul_stride_channel, mul_stride_sample,
524+
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed);
525+
} else {
526+
const dim3 block_dims(1024, 1, 1);
527+
const ggml_cuda_kernel_launch_params launch_params = ggml_cuda_kernel_launch_params{blocks_num, block_dims, block_dims.x > WARP_SIZE ? 32 * sizeof(float) : 0, stream};
528+
ggml_cuda_kernel_launch(rms_norm_pre_add_mul_f32<1024, true>, launch_params,
529+
a, b, h_out, dst, ncols, stride_row, stride_channel, stride_sample, eps,
530+
mul, mul_stride_row, mul_stride_channel, mul_stride_sample,
531+
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed);
532+
}
533+
}
534+
410535
static void rms_norm_back_f32_cuda(const float * grad, const float * xf, float * dst, const int ncols, const int nrows, const float eps, cudaStream_t stream) {
411536
if (ncols < 1024) {
412537
const dim3 block_dims(WARP_SIZE, 1, 1);
@@ -647,6 +772,77 @@ void ggml_cuda_op_rms_norm_fused_add(ggml_backend_cuda_context & ctx,
647772
eps, stream);
648773
}
649774

775+
void ggml_cuda_op_rms_norm_pre_add_mul(ggml_backend_cuda_context & ctx,
776+
ggml_tensor * add_tensor,
777+
ggml_tensor * rms_norm_tensor,
778+
ggml_tensor * mul_tensor) {
779+
// The RMS norm consumes the residual-add output.
780+
GGML_ASSERT(rms_norm_tensor->src[0] == add_tensor);
781+
782+
const ggml_tensor * a_src = add_tensor->src[0];
783+
const ggml_tensor * b_src = add_tensor->src[1];
784+
785+
float eps = 0.0f;
786+
memcpy(&eps, rms_norm_tensor->op_params, sizeof(float));
787+
GGML_ASSERT(eps >= 0.0f);
788+
789+
const float * a_d = (const float *) a_src->data;
790+
const float * b_d = (const float *) b_src->data;
791+
float * h_d = (float *) add_tensor->data;
792+
793+
const float * mul_d = nullptr;
794+
const ggml_tensor * mul_src = nullptr;
795+
if (mul_tensor->src[0] == rms_norm_tensor) {
796+
mul_d = (const float *) mul_tensor->src[1]->data;
797+
mul_src = mul_tensor->src[1];
798+
} else if (mul_tensor->src[1] == rms_norm_tensor) {
799+
mul_d = (const float *) mul_tensor->src[0]->data;
800+
mul_src = mul_tensor->src[0];
801+
} else {
802+
GGML_ASSERT(false);
803+
}
804+
805+
float * dst_d = (float *) mul_tensor->data;
806+
cudaStream_t stream = ctx.stream();
807+
808+
GGML_ASSERT(a_src->type == GGML_TYPE_F32);
809+
GGML_ASSERT(b_src->type == GGML_TYPE_F32);
810+
GGML_ASSERT(add_tensor->type == GGML_TYPE_F32);
811+
GGML_ASSERT(rms_norm_tensor->type == GGML_TYPE_F32);
812+
GGML_ASSERT(mul_tensor->type == GGML_TYPE_F32);
813+
GGML_ASSERT(ggml_are_same_shape(a_src, b_src));
814+
815+
const int64_t ne00 = add_tensor->ne[0];
816+
const int64_t ne01 = add_tensor->ne[1];
817+
const int64_t ne02 = add_tensor->ne[2];
818+
const int64_t ne03 = add_tensor->ne[3];
819+
820+
// a and b share the (contiguous) residual layout
821+
const size_t ts0 = ggml_type_size(a_src->type);
822+
GGML_ASSERT(a_src->nb[0] == ts0 && b_src->nb[0] == ts0);
823+
const int64_t s01 = a_src->nb[1] / ts0;
824+
const int64_t s02 = a_src->nb[2] / ts0;
825+
const int64_t s03 = a_src->nb[3] / ts0;
826+
827+
const size_t ts_mul = ggml_type_size(mul_src->type);
828+
GGML_ASSERT(mul_src->nb[0] == ts_mul);
829+
const int64_t mul_s01 = mul_src->nb[1] / ts_mul;
830+
const int64_t mul_s02 = mul_src->nb[2] / ts_mul;
831+
const int64_t mul_s03 = mul_src->nb[3] / ts_mul;
832+
833+
const int mul_ncols = mul_src->ne[0];
834+
const int mul_nrows = mul_src->ne[1];
835+
const int mul_nchannels = mul_src->ne[2];
836+
const int mul_nsamples = mul_src->ne[3];
837+
838+
rms_norm_pre_add_mul_f32_cuda(a_d, b_d, h_d, dst_d,
839+
ne00, ne01, ne02, ne03,
840+
/*s00*/ s01, s02, s03,
841+
mul_d, /*mul_s00*/ mul_s01, mul_s02, mul_s03,
842+
mul_ncols, mul_nrows, mul_nchannels, mul_nsamples,
843+
eps, stream);
844+
}
845+
650846
void ggml_cuda_op_rms_norm_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst) {
651847
const ggml_tensor * grad = dst->src[0]; // gradients
652848
const ggml_tensor * src0f = dst->src[1]; // src0 from forward pass

ggml/src/ggml-cuda/norm.cuh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ void ggml_cuda_op_rms_norm_fused_add(ggml_backend_cuda_context & ctx,
1313
ggml_tensor * mul_tensor,
1414
ggml_tensor * add_tensor);
1515

16+
void ggml_cuda_op_rms_norm_pre_add_mul(ggml_backend_cuda_context & ctx,
17+
ggml_tensor * add_tensor,
18+
ggml_tensor * rms_norm_tensor,
19+
ggml_tensor * mul_tensor);
20+
1621
void ggml_cuda_op_rms_norm_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst);
1722

1823
void ggml_cuda_op_l2_norm(ggml_backend_cuda_context & ctx, ggml_tensor * dst);

0 commit comments

Comments
 (0)