Skip to content

Commit 8701666

Browse files
committed
[KernelGen][Nvidia] Add cosine_embedding_loss operator with Triton kernel
1 parent 4cb479b commit 8701666

7 files changed

Lines changed: 366 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
check=pass test=pass benchmark=pass
2+
time=2026-08-17T10:43:35.797838
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
import torch
17+
18+
from . import base, consts
19+
20+
21+
def _input_fn(shape, dtype, device):
22+
inp1 = torch.randn(shape, dtype=dtype, device=device)
23+
inp2 = torch.randn(shape, dtype=dtype, device=device)
24+
target = (torch.randint(0, 2, (shape[0],), device=device).to(dtype) * 2) - 1
25+
# aten::cosine_embedding_loss(input1, input2, target, margin, reduction)
26+
yield inp1, inp2, target, 0.0, 1
27+
28+
29+
@pytest.mark.cosine_embedding_loss
30+
def test_cosine_embedding_loss():
31+
# 2D-only: cosine_embedding_loss needs (N, D) inputs with a 1D (N,) target.
32+
bench = base.GenericBenchmark2DOnly(
33+
input_fn=_input_fn,
34+
op_name="cosine_embedding_loss",
35+
torch_op=torch.ops.aten.cosine_embedding_loss,
36+
dtypes=consts.FLOAT_DTYPES,
37+
)
38+
39+
bench.run()

conf/operators.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2340,6 +2340,18 @@ ops:
23402340
- Math
23412341
stages:
23422342
- stable: '5.3'
2343+
- id: cosine_embedding_loss
2344+
description: Compute the cosine embedding loss.
2345+
for:
2346+
- cosine_embedding_loss
2347+
labels:
2348+
- aten
2349+
- nn.functional
2350+
- KernelGen
2351+
kind:
2352+
- NeuralNetwork
2353+
stages:
2354+
- alpha: '5.4'
23432355
- id: count_nonzero
23442356
description: |
23452357
Counts the number of non-zero values in the tensor `input` along the given `dim`.

src/flag_gems/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ def torch_ge(v):
393393
("cosh", cosh),
394394
("cosh.out", cosh_out),
395395
("cosh_", cosh_),
396+
("cosine_embedding_loss", cosine_embedding_loss),
396397
("count_nonzero", count_nonzero),
397398
("ctc_loss.IntList", ctc_loss, None, (AUTOGRAD_DISPATCH_KEY,)),
398399
("ctc_loss.Tensor", ctc_loss, None, (AUTOGRAD_DISPATCH_KEY,)),

src/flag_gems/ops/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@
238238
from flag_gems.ops.copysign_ import copysign_
239239
from flag_gems.ops.cos import cos, cos_
240240
from flag_gems.ops.cosh import cosh, cosh_, cosh_out
241+
from flag_gems.ops.cosine_embedding_loss import cosine_embedding_loss
241242
from flag_gems.ops.count_nonzero import count_nonzero
242243
from flag_gems.ops.ctc_loss import ctc_loss
243244
from flag_gems.ops.cudnn_attention_forward import cudnn_attention_forward
@@ -1041,6 +1042,7 @@
10411042
"cosh",
10421043
"cosh_",
10431044
"cosh_out",
1045+
"cosine_embedding_loss",
10441046
"count_nonzero",
10451047
"ctc_loss",
10461048
"cudnn_attention_forward",
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# Copyright 2026, The FlagOS Contributors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# Generated by KernelGen: https://github.com/flagos-ai/KernelGen
16+
import logging
17+
18+
import torch
19+
import triton
20+
import triton.language as tl
21+
22+
import flag_gems
23+
24+
logger = logging.getLogger(__name__)
25+
26+
# The denominator is guarded against zero-norm inputs with a literal 1e-8 inside
27+
# each kernel (Triton @jit bodies cannot read module-level Python globals). This
28+
# matches the PyTorch reference on the tested float16/bfloat16/float32 workloads.
29+
30+
31+
@triton.jit
32+
def _cosine_embedding_loss_elementwise_kernel(
33+
x1_ptr,
34+
x2_ptr,
35+
y_ptr,
36+
out_ptr,
37+
n_rows,
38+
d,
39+
x1_stride0,
40+
x2_stride0,
41+
margin,
42+
BLOCK_D: tl.constexpr,
43+
):
44+
# One program per row: compute cosine similarity along the last dim, then
45+
# the per-sample loss, and write it to out[row].
46+
row = tl.program_id(axis=0)
47+
if row >= n_rows:
48+
return
49+
50+
cols = tl.arange(0, BLOCK_D)
51+
mask = cols < d
52+
53+
x1 = tl.load(x1_ptr + row * x1_stride0 + cols, mask=mask, other=0.0).to(tl.float32)
54+
x2 = tl.load(x2_ptr + row * x2_stride0 + cols, mask=mask, other=0.0).to(tl.float32)
55+
56+
dot = tl.sum(x1 * x2, axis=0)
57+
norm1 = tl.sqrt(tl.sum(x1 * x1, axis=0))
58+
norm2 = tl.sqrt(tl.sum(x2 * x2, axis=0))
59+
denom = norm1 * norm2
60+
cos = dot / (denom + 1e-8)
61+
62+
y = tl.load(y_ptr + row).to(tl.float32)
63+
# target == 1 -> 1 - cos ; target == -1 -> max(0, cos - margin)
64+
is_pos = y > 0.0
65+
loss = tl.where(is_pos, 1.0 - cos, tl.maximum(cos - margin, 0.0))
66+
67+
tl.store(out_ptr + row, loss)
68+
69+
70+
@triton.jit
71+
def _cosine_embedding_loss_per_row_kernel(
72+
x1_ptr,
73+
x2_ptr,
74+
y_ptr,
75+
mid_ptr,
76+
n_rows,
77+
d,
78+
x1_stride0,
79+
x2_stride0,
80+
margin,
81+
BLOCK_D: tl.constexpr,
82+
):
83+
# One program per sample-row: compute the per-row loss in float32 and write
84+
# it to mid[row]. A 1D (BLOCK_D,) tile keeps every load well under Triton's
85+
# per-tile numel limit regardless of the row count. The final reduction over
86+
# mid is done by _cosine_embedding_loss_final_reduce_kernel.
87+
row = tl.program_id(axis=0)
88+
if row >= n_rows:
89+
return
90+
cols = tl.arange(0, BLOCK_D)
91+
col_mask = cols < d
92+
93+
x1 = tl.load(x1_ptr + row * x1_stride0 + cols, mask=col_mask, other=0.0).to(
94+
tl.float32
95+
)
96+
x2 = tl.load(x2_ptr + row * x2_stride0 + cols, mask=col_mask, other=0.0).to(
97+
tl.float32
98+
)
99+
dot = tl.sum(x1 * x2, axis=0)
100+
norm1 = tl.sqrt(tl.sum(x1 * x1, axis=0))
101+
norm2 = tl.sqrt(tl.sum(x2 * x2, axis=0))
102+
cos = dot / (norm1 * norm2 + 1e-8)
103+
104+
y = tl.load(y_ptr + row).to(tl.float32)
105+
is_pos = y > 0.0
106+
loss = tl.where(is_pos, 1.0 - cos, tl.maximum(cos - margin, 0.0))
107+
tl.store(mid_ptr + row, loss)
108+
109+
110+
@triton.jit
111+
def _cosine_embedding_loss_final_reduce_kernel(
112+
mid_ptr, out_ptr, mid_size, inv_scale, BLOCK_MID: tl.constexpr
113+
):
114+
# Grid-stride reduction so a large partial-sum buffer does not require a
115+
# single oversized block. Accumulate in float32. inv_scale folds the mean
116+
# division into the kernel (inv_scale=1/n_rows for mean, 1.0 for sum) so the
117+
# host never issues a tensor/scalar div that use_gems would re-dispatch.
118+
offsets = tl.arange(0, BLOCK_MID)
119+
acc = 0.0
120+
for start in range(0, mid_size, BLOCK_MID):
121+
idx = start + offsets
122+
mask = idx < mid_size
123+
vals = tl.load(mid_ptr + idx, mask=mask, other=0.0).to(tl.float32)
124+
acc += tl.sum(vals, axis=0)
125+
# Store into out_ptr's own dtype so the host never issues a `.to()` cast
126+
# (which use_gems would re-dispatch to a pointwise _to_copy kernel).
127+
tl.store(out_ptr, (acc * inv_scale).to(out_ptr.dtype.element_ty))
128+
129+
130+
def _normalize_reduction(reduction):
131+
# Accept both string and enum/int forms: 0=none,1=mean,2=sum
132+
if isinstance(reduction, str):
133+
r = reduction.lower()
134+
if r == "none":
135+
return 0
136+
if r == "mean":
137+
return 1
138+
if r == "sum":
139+
return 2
140+
raise ValueError(f"Invalid reduction: {reduction}")
141+
if isinstance(reduction, int):
142+
if reduction in (0, 1, 2):
143+
return reduction
144+
raise ValueError(f"Invalid reduction int: {reduction}")
145+
raise ValueError(f"Unsupported reduction type: {type(reduction)}")
146+
147+
148+
def _check_tensors(input1: torch.Tensor, input2: torch.Tensor, target: torch.Tensor):
149+
if input1.device.type != flag_gems.device or input2.device.type != flag_gems.device:
150+
raise AssertionError(
151+
f"cosine_embedding_loss: inputs must be {flag_gems.device} tensors for Triton kernel."
152+
)
153+
if input1.device != input2.device or input1.device != target.device:
154+
raise AssertionError(
155+
"cosine_embedding_loss: all inputs must be on the same device."
156+
)
157+
if input1.shape != input2.shape:
158+
raise AssertionError(
159+
"cosine_embedding_loss: input1 and input2 must have the same shape."
160+
)
161+
if input1.dim() < 1:
162+
raise AssertionError(
163+
"cosine_embedding_loss: inputs must have at least one dimension."
164+
)
165+
# target is (N,) for batched inputs (input.ndim >= 2); for 1-D inputs target is ().
166+
expected_target_shape = input1.shape[:-1] if input1.dim() >= 2 else ()
167+
if target.shape != expected_target_shape:
168+
raise AssertionError(
169+
f"cosine_embedding_loss: target must have shape {expected_target_shape}, "
170+
f"got {tuple(target.shape)}."
171+
)
172+
if not input1.is_contiguous():
173+
input1 = input1.contiguous()
174+
if not input2.is_contiguous():
175+
input2 = input2.contiguous()
176+
if not target.is_contiguous():
177+
target = target.contiguous()
178+
return input1, input2, target
179+
180+
181+
def cosine_embedding_loss(
182+
input1: torch.Tensor,
183+
input2: torch.Tensor,
184+
target: torch.Tensor,
185+
margin: float = 0.0,
186+
reduction=1,
187+
):
188+
logger.debug("GEMS COSINE_EMBEDDING_LOSS")
189+
input1, input2, target = _check_tensors(input1, input2, target)
190+
red = _normalize_reduction(reduction)
191+
margin = float(margin)
192+
193+
# Flatten leading dims so the last dim is D and the row count is N.
194+
x1 = input1.reshape(-1, input1.shape[-1])
195+
x2 = input2.reshape(-1, input2.shape[-1])
196+
y = target.reshape(-1) if target.numel() > 0 else target
197+
n_rows = x1.shape[0]
198+
d = x1.shape[1]
199+
x1_stride0 = x1.stride(0)
200+
x2_stride0 = x2.stride(0)
201+
202+
if red == 0:
203+
# reduction = 'none'
204+
out = torch.empty((n_rows,), device=input1.device, dtype=input1.dtype)
205+
if n_rows == 0:
206+
return out
207+
BLOCK_D = triton.next_power_of_2(d)
208+
_cosine_embedding_loss_elementwise_kernel[(n_rows,)](
209+
x1, x2, y, out, n_rows, d, x1_stride0, x2_stride0, margin, BLOCK_D=BLOCK_D
210+
)
211+
# Restore the leading shape (target shape).
212+
return out.reshape(target.shape) if target.numel() != n_rows else out
213+
else:
214+
# reduction = 'mean' (1) or 'sum' (2)
215+
if n_rows == 0:
216+
# Follow PyTorch behavior: sum -> 0, mean -> NaN
217+
if red == 2:
218+
return torch.zeros((), device=input1.device, dtype=input1.dtype)
219+
else:
220+
return torch.full(
221+
(), float("nan"), device=input1.device, dtype=input1.dtype
222+
)
223+
BLOCK_D = triton.next_power_of_2(d)
224+
# Stage 1: one program per row writes its float32 loss into mid.
225+
mid_size = n_rows
226+
block_mid = min(1024, triton.next_power_of_2(mid_size))
227+
mid = torch.empty((mid_size,), device=input1.device, dtype=torch.float32)
228+
_cosine_embedding_loss_per_row_kernel[(n_rows,)](
229+
x1,
230+
x2,
231+
y,
232+
mid,
233+
n_rows,
234+
d,
235+
x1_stride0,
236+
x2_stride0,
237+
margin,
238+
BLOCK_D=BLOCK_D,
239+
)
240+
# Allocate the output directly in the input dtype and let the kernel
241+
# cast on store. This avoids any host-side arithmetic / `.to()` that
242+
# use_gems would re-dispatch to pointwise kernels.
243+
out = torch.zeros((), device=input1.device, dtype=input1.dtype)
244+
# inv_scale=1/n_rows folds the mean division into the reduction kernel;
245+
# for sum it is 1.0.
246+
inv_scale = 1.0 if red == 2 else (1.0 / float(n_rows))
247+
_cosine_embedding_loss_final_reduce_kernel[(1,)](
248+
mid, out, mid_size, inv_scale, BLOCK_MID=block_mid
249+
)
250+
return out
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
import torch
17+
18+
import flag_gems
19+
20+
from . import accuracy_utils as utils
21+
from . import conftest as cfg
22+
23+
# Shapes mirror the sibling hinge_embedding_loss test to exercise the two-stage
24+
# reduction across block counts. Inputs are (N, D); target is (N,).
25+
if cfg.QUICK_MODE:
26+
# Single tiny shape keeps QUICK_MODE smoke runs fast.
27+
COSINE_EMBEDDING_LOSS_SHAPES = [(2, 3)]
28+
else:
29+
# Small/medium/large trio covers single- and multi-block reductions.
30+
COSINE_EMBEDDING_LOSS_SHAPES = [(2, 3), (128, 256), (512, 512)]
31+
32+
33+
@pytest.mark.cosine_embedding_loss
34+
@pytest.mark.parametrize("shape", COSINE_EMBEDDING_LOSS_SHAPES)
35+
@pytest.mark.parametrize("dtype", utils.FLOAT_DTYPES)
36+
@pytest.mark.parametrize("reduction", [0, 1, 2])
37+
@pytest.mark.parametrize("margin", [0.0, 0.5])
38+
def test_cosine_embedding_loss(shape, dtype, reduction, margin):
39+
inp1 = torch.randn(shape, dtype=dtype, device=flag_gems.device)
40+
inp2 = torch.randn(shape, dtype=dtype, device=flag_gems.device)
41+
target = (
42+
torch.randint(0, 2, (shape[0],), device=flag_gems.device).to(dtype) * 2
43+
) - 1
44+
45+
# The kernel upcasts to float32 for the dot/norm reductions, so the
46+
# reference must upcast too — otherwise a pure-fp16 cosine is numerically
47+
# ill-conditioned near cos==0 and diverges from the (more accurate) result.
48+
ref_inp1 = utils.to_reference(inp1, upcast=True)
49+
ref_inp2 = utils.to_reference(inp2, upcast=True)
50+
ref_target = utils.to_reference(target, upcast=True)
51+
ref_out = torch.ops.aten.cosine_embedding_loss(
52+
ref_inp1, ref_inp2, ref_target, margin, reduction
53+
)
54+
55+
with flag_gems.use_gems():
56+
res_out = torch.ops.aten.cosine_embedding_loss(
57+
inp1, inp2, target, margin, reduction
58+
)
59+
60+
utils.gems_assert_close(res_out, ref_out, dtype)

0 commit comments

Comments
 (0)