Skip to content

Commit 77bccaa

Browse files
committed
ggml : add fused SINKHORN_NORM op (Sinkhorn-Knopp normalization) + use in DeepSeek-V4
## Summary This adds a new ggml op, `GGML_OP_SINKHORN_NORM` (`ggml_sinkhorn_norm`), that performs a fused **Sinkhorn-Knopp doubly-stochastic normalization** on small square matrices, and wires it into the DeepSeek-V4 (mHC / manifold-constrained hyper-connections) graph. DeepSeek-V4 makes its per-token, per-layer hyper-connection mixing matrix (`[hc, hc]`, with `hc = 4`) doubly-stochastic by running Sinkhorn-Knopp (softmax, then alternating row/column normalization) for `sinkhorn_iterations` (20 in the released model). Today this is expressed as a decomposition of primitive ggml ops (`soft_max` + repeated `sum_rows` / `add` / `div` / `cont(permute)` transpose), which expands to **~137 ops per layer**. On the tiny 4x4 slices this is almost entirely kernel-launch and memory-round-trip overhead with negligible arithmetic. The new op computes the entire normalization in a single kernel (per layer), keeping the matrix in registers across all iterations. ## Motivation / profiling Profiling DeepSeek-V4-Flash (UD-Q4_K_XL, 284B, 43 layers) inference on an AMD MI250X (gfx90a, ROCm/HIP) with `rocprofv3 --kernel-trace` showed the decomposed Sinkhorn dominating GPU time: - `k_bin_bcast` (broadcast div/add): ~31% of GPU kernel time, ~217k dispatches - `reduce_rows_f32` (row sums): ~8%, ~67k dispatches - `cpy_scalar_transpose`: ~4.5%, ~34k dispatches Predicted vs. observed dispatch counts confirmed these are almost entirely the Sinkhorn decomposition (e.g. transpose count matched to within <1%). In aggregate the normalization was **~30% of total GPU kernel time** across ~250k tiny dispatches. ## Results (DeepSeek-V4-Flash, 4x MI250X GCDs, llama-bench, back-to-back A/B) | variant | prefill (pp64) | decode (tg16) | |----------------------------------|---------------:|--------------:| | decomposed (baseline) | 111.5 t/s | 10.3 t/s | | fused SINKHORN_NORM (this PR) | 119.8 t/s | 16.5 t/s | **Decode +60.9%, prefill +7.4%** end-to-end on the full model. ## Implementation - New op `GGML_OP_SINKHORN_NORM` + constructor `ggml_sinkhorn_norm(ctx, a, n_iters, eps)` (op params: `n_iters` int32, `eps` f32). Operates independently on each `[n, n]` slice (`ne0 == ne1 == n`): softmax over `ne0`, add `eps`, one column normalization, then `n_iters - 1` alternating {row, column} normalizations (each `+ eps`). The interface is general (arbitrary square `n`, configurable `n_iters` / `eps`); DeepSeek-V4 is simply the first consumer. - **CPU** reference implementation (`ggml-cpu/ops.cpp`), parallelized over slices. - **CUDA/HIP** kernel (`ggml-cuda/sinkhorn-norm.cu`): - warp-cooperative kernel for power-of-two `n` with `n*n <= warp_size`: one lane per matrix element, fully coalesced global load/store, segmented `__shfl_xor` butterfly reductions for the row/column sums; - a generic one-thread-per-slice fallback otherwise. - **Other backends** (Metal/Vulkan/SYCL/CANN/OpenCL): `supports_op` returns false for this op (default), so the graph scheduler falls back to the CPU implementation. No crashes; a dedicated kernel can be added later if desired. - **Tests**: `test-backend-ops` cases for `SINKHORN_NORM` (n=4, tokens {1,7,128,1024}, iters {1,2,20}) plus perf cases; CPU-vs-CUDA all pass. - DeepSeek-V4 (`src/models/deepseek4.cpp`) `build_hc_sinkhorn` now calls the fused op. ## Precedent ggml already contains architecture-specific fused ops in the core op enum, e.g. `GGML_OP_SSM_CONV` / `GGML_OP_SSM_SCAN` (Mamba), `GGML_OP_RWKV_WKV6` / `GGML_OP_RWKV_WKV7` / `GGML_OP_GATED_LINEAR_ATTN` (RWKV). `SINKHORN_NORM` follows the same pattern for DeepSeek-V4, but is defined as a general normalization primitive rather than a model-specific op. ## Test plan - [ ] `test-backend-ops test -o SINKHORN_NORM` passes on CPU and CUDA/HIP (12/12 here). - [ ] `test-backend-ops perf -o SINKHORN_NORM` runs. - [ ] Full `test-backend-ops` shows no regressions (new op cleanly inserted into the enum). - [ ] DeepSeek-V4 output unchanged vs. the decomposed graph (validated via the CPU reference). - [ ] Confirm graceful CPU fallback on a non-CUDA backend (e.g. Metal/Vulkan). ## Notes for reviewers - Open question / happy to adjust: keep this as a public `GGML_OP_SINKHORN_NORM`, or fold it into an internal fused helper for DeepSeek-V4 only? Went with a general op given the Mamba/RWKV precedent, but will follow maintainer preference. - Warp kernel assumes `n` is a power of two and `n*n <= warp_size` (true for `hc = 4`); the generic kernel covers everything else.
1 parent bec4772 commit 77bccaa

10 files changed

Lines changed: 450 additions & 30 deletions

File tree

ggml/include/ggml.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@ extern "C" {
509509
GGML_OP_RMS_NORM_BACK,
510510
GGML_OP_GROUP_NORM,
511511
GGML_OP_L2_NORM,
512+
GGML_OP_SINKHORN_NORM,
512513

513514
GGML_OP_MUL_MAT,
514515
GGML_OP_MUL_MAT_ID,
@@ -1406,6 +1407,17 @@ extern "C" {
14061407
struct ggml_tensor * a,
14071408
float eps);
14081409

1410+
// fused Sinkhorn-Knopp normalization for DeepSeek-V4 mHC hyper-connections.
1411+
// operates independently on each [n, n] slice (ne0 == ne1 == n) of the input:
1412+
// softmax over ne0, add eps, then a row normalization followed by
1413+
// (n_iters - 1) alternating {column, row} normalizations (each + eps).
1414+
// replaces the ~137-op decomposed graph with a single kernel per call.
1415+
GGML_API struct ggml_tensor * ggml_sinkhorn_norm(
1416+
struct ggml_context * ctx,
1417+
struct ggml_tensor * a,
1418+
int n_iters,
1419+
float eps);
1420+
14091421
// a - x
14101422
// b - dy
14111423
GGML_API struct ggml_tensor * ggml_rms_norm_back(

ggml/src/ggml-cpu/ggml-cpu.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,6 +1833,10 @@ static void ggml_compute_forward(struct ggml_compute_params * params, struct ggm
18331833
{
18341834
ggml_compute_forward_l2_norm(params, tensor);
18351835
} break;
1836+
case GGML_OP_SINKHORN_NORM:
1837+
{
1838+
ggml_compute_forward_sinkhorn_norm(params, tensor);
1839+
} break;
18361840
case GGML_OP_MUL_MAT:
18371841
{
18381842
ggml_compute_forward_mul_mat(params, tensor);
@@ -2306,6 +2310,7 @@ static int ggml_get_n_tasks(struct ggml_tensor * node, int n_threads) {
23062310
case GGML_OP_RMS_NORM:
23072311
case GGML_OP_RMS_NORM_BACK:
23082312
case GGML_OP_L2_NORM:
2313+
case GGML_OP_SINKHORN_NORM:
23092314
case GGML_OP_GROUP_NORM:
23102315
case GGML_OP_CONCAT:
23112316
case GGML_OP_MUL_MAT:

ggml/src/ggml-cpu/ops.cpp

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4237,6 +4237,131 @@ void ggml_compute_forward_l2_norm(
42374237
}
42384238
}
42394239

4240+
// ggml_compute_forward_sinkhorn_norm
4241+
4242+
#define GGML_SINKHORN_NORM_MAX_N 32
4243+
4244+
static void ggml_compute_forward_sinkhorn_norm_f32(
4245+
const ggml_compute_params * params,
4246+
ggml_tensor * dst) {
4247+
4248+
const ggml_tensor * src0 = dst->src[0];
4249+
4250+
GGML_ASSERT(ggml_are_same_shape(src0, dst));
4251+
GGML_ASSERT(ggml_is_contiguous(src0));
4252+
4253+
const int ith = params->ith;
4254+
const int nth = params->nth;
4255+
4256+
GGML_TENSOR_UNARY_OP_LOCALS
4257+
4258+
const int32_t n_iters = ggml_get_op_params_i32(dst, 0);
4259+
const float eps = ggml_get_op_params_f32(dst, 1);
4260+
4261+
const int64_t n = ne00; // square [n, n] slices, ne00 == ne01
4262+
GGML_ASSERT(ne01 == n);
4263+
GGML_ASSERT(n <= GGML_SINKHORN_NORM_MAX_N);
4264+
GGML_ASSERT(n_iters >= 1);
4265+
4266+
const int64_t n_slices = ne02 * ne03;
4267+
4268+
float m[GGML_SINKHORN_NORM_MAX_N * GGML_SINKHORN_NORM_MAX_N];
4269+
4270+
// one square slice (spanning ne0 x ne1) per token; parallelize across slices
4271+
for (int64_t s = ith; s < n_slices; s += nth) {
4272+
const int64_t i02 = s % ne02;
4273+
const int64_t i03 = s / ne02;
4274+
4275+
const float * x = (const float *) ((const char *) src0->data + i02*nb02 + i03*nb03);
4276+
float * y = (float *) (( char *) dst->data + i02*nb2 + i03*nb3);
4277+
4278+
// load slice: m[b*n + a], a is ne0 (fastest, contiguous)
4279+
for (int64_t b = 0; b < n; ++b) {
4280+
for (int64_t a = 0; a < n; ++a) {
4281+
m[b*n + a] = x[b*n + a];
4282+
}
4283+
}
4284+
4285+
// softmax over ne0 (a) for each b (matches ggml_soft_max, with max subtraction)
4286+
for (int64_t b = 0; b < n; ++b) {
4287+
float mx = -INFINITY;
4288+
for (int64_t a = 0; a < n; ++a) {
4289+
mx = fmaxf(mx, m[b*n + a]);
4290+
}
4291+
float sum = 0.0f;
4292+
for (int64_t a = 0; a < n; ++a) {
4293+
const float e = expf(m[b*n + a] - mx);
4294+
m[b*n + a] = e;
4295+
sum += e;
4296+
}
4297+
for (int64_t a = 0; a < n; ++a) {
4298+
m[b*n + a] /= sum;
4299+
}
4300+
}
4301+
4302+
// add eps
4303+
for (int64_t i = 0; i < n*n; ++i) {
4304+
m[i] += eps;
4305+
}
4306+
4307+
// norm_cols: divide each element by (sum over b of its row a) + eps
4308+
auto norm_cols = [&]() {
4309+
for (int64_t a = 0; a < n; ++a) {
4310+
float r = 0.0f;
4311+
for (int64_t b = 0; b < n; ++b) {
4312+
r += m[b*n + a];
4313+
}
4314+
r += eps;
4315+
for (int64_t b = 0; b < n; ++b) {
4316+
m[b*n + a] /= r;
4317+
}
4318+
}
4319+
};
4320+
4321+
// norm_rows: divide each element by (sum over a of its column b) + eps
4322+
auto norm_rows = [&]() {
4323+
for (int64_t b = 0; b < n; ++b) {
4324+
float c = 0.0f;
4325+
for (int64_t a = 0; a < n; ++a) {
4326+
c += m[b*n + a];
4327+
}
4328+
c += eps;
4329+
for (int64_t a = 0; a < n; ++a) {
4330+
m[b*n + a] /= c;
4331+
}
4332+
}
4333+
};
4334+
4335+
norm_cols();
4336+
for (int32_t it = 1; it < n_iters; ++it) {
4337+
norm_rows();
4338+
norm_cols();
4339+
}
4340+
4341+
for (int64_t i = 0; i < n*n; ++i) {
4342+
y[i] = m[i];
4343+
}
4344+
}
4345+
}
4346+
4347+
void ggml_compute_forward_sinkhorn_norm(
4348+
const ggml_compute_params * params,
4349+
ggml_tensor * dst) {
4350+
4351+
const ggml_tensor * src0 = dst->src[0];
4352+
4353+
switch (src0->type) {
4354+
case GGML_TYPE_F32:
4355+
{
4356+
ggml_compute_forward_sinkhorn_norm_f32(params, dst);
4357+
} break;
4358+
default:
4359+
{
4360+
GGML_ABORT("fatal error");
4361+
}
4362+
}
4363+
}
4364+
42404365
// ggml_compute_forward_out_prod
42414366

42424367
static void ggml_compute_forward_out_prod_f32(

ggml/src/ggml-cpu/ops.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ void ggml_compute_forward_rms_norm_mul_fused(const struct ggml_compute_params *
4848
void ggml_compute_forward_rms_norm_back(const struct ggml_compute_params * params, struct ggml_tensor * dst);
4949
void ggml_compute_forward_group_norm(const struct ggml_compute_params * params, struct ggml_tensor * dst);
5050
void ggml_compute_forward_l2_norm(const struct ggml_compute_params * params, struct ggml_tensor * dst);
51+
void ggml_compute_forward_sinkhorn_norm(const struct ggml_compute_params * params, struct ggml_tensor * dst);
5152
void ggml_compute_forward_out_prod(const struct ggml_compute_params * params, struct ggml_tensor * dst);
5253
void ggml_compute_forward_scale(const struct ggml_compute_params * params, struct ggml_tensor * dst);
5354
void ggml_compute_forward_set(const struct ggml_compute_params * params, struct ggml_tensor * dst);

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
#include "ggml-cuda/mmvf.cuh"
3434
#include "ggml-cuda/mmvq.cuh"
3535
#include "ggml-cuda/norm.cuh"
36+
#include "ggml-cuda/sinkhorn-norm.cuh"
3637
#include "ggml-cuda/opt-step-adamw.cuh"
3738
#include "ggml-cuda/opt-step-sgd.cuh"
3839
#include "ggml-cuda/out-prod.cuh"
@@ -2083,6 +2084,9 @@ static bool ggml_cuda_compute_forward(ggml_backend_cuda_context & ctx, struct gg
20832084
case GGML_OP_GROUP_NORM:
20842085
ggml_cuda_op_group_norm(ctx, dst);
20852086
break;
2087+
case GGML_OP_SINKHORN_NORM:
2088+
ggml_cuda_op_sinkhorn_norm(ctx, dst);
2089+
break;
20862090
case GGML_OP_L2_NORM:
20872091
ggml_cuda_op_l2_norm(ctx, dst);
20882092
break;
@@ -4841,6 +4845,11 @@ static bool ggml_backend_cuda_device_supports_op(ggml_backend_dev_t dev, const g
48414845
case GGML_OP_SILU_BACK:
48424846
return ggml_is_contiguous(op->src[0]) && op->src[0]->type == GGML_TYPE_F32;
48434847
break;
4848+
case GGML_OP_SINKHORN_NORM:
4849+
return op->src[0]->type == GGML_TYPE_F32 &&
4850+
ggml_is_contiguous(op->src[0]) &&
4851+
op->src[0]->ne[0] == op->src[0]->ne[1] &&
4852+
op->src[0]->ne[0] <= 8;
48444853
case GGML_OP_NORM:
48454854
case GGML_OP_RMS_NORM:
48464855
case GGML_OP_L2_NORM:

0 commit comments

Comments
 (0)