Skip to content

Commit 00595c6

Browse files
committed
Add accuracy compatible path to cosine_similarity
Keep torch's literal order, expand both inputs to the broadcast shape and then take the norm, behind FLAGS_use_accuracy_compatible_kernel. Since paddle.broadcast_to is not a 0-copy view like torch.expand, that path pays an extra O(prod(broadcast_shape)) allocation per input, so the default stays on unsqueeze plus the ||repeat(x, m)|| == sqrt(m) * ||x|| correction. Tests cover the three shape families: equal shapes, mismatched ranks in both dygraph and static graph, and large inputs broadcast along the reduced axis. The peak memory test asserts the no-expansion property and is therefore skipped when the compatible path is enabled.
1 parent 83601ed commit 00595c6

2 files changed

Lines changed: 254 additions & 31 deletions

File tree

python/paddle/nn/functional/common.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,20 +2504,36 @@ def cosine_similarity(
25042504
# 2. ||repeat(x,m)|| = sqrt(m) * |x|.
25052505
# This replacement could save memory at vector_norm when x1.dims[axis] != x2.dims[axis]
25062506
# and broadcast happens at axis dim.
2507+
# The literal torch order, expand first and then take the norm, is kept behind
2508+
# FLAGS_use_accuracy_compatible_kernel at the cost of that extra memory.
25072509
rank = max(len(x1.shape), len(x2.shape))
25082510
if len(x1.shape) < rank:
25092511
x1 = unsqueeze(x1, axis=list(range(rank - len(x1.shape))))
25102512
if len(x2.shape) < rank:
25112513
x2 = unsqueeze(x2, axis=list(range(rank - len(x2.shape))))
25122514
bs = paddle.broadcast_shape(x1.shape, x2.shape)
25132515
dim = axis + rank if axis < 0 else axis
2514-
n1 = paddle.linalg.vector_norm(x1, p=2, axis=dim, keepdim=True)
2515-
n2 = paddle.linalg.vector_norm(x2, p=2, axis=dim, keepdim=True)
2516-
# A dynamic (-1) length fails both checks and is left uncorrected.
2517-
if x1.shape[dim] > 0 and bs[dim] > x1.shape[dim]:
2518-
n1 = n1 * math.sqrt(bs[dim] / x1.shape[dim])
2519-
if x2.shape[dim] > 0 and bs[dim] > x2.shape[dim]:
2520-
n2 = n2 * math.sqrt(bs[dim] / x2.shape[dim])
2516+
if paddle.get_flags(["FLAGS_use_accuracy_compatible_kernel"]).get(
2517+
"FLAGS_use_accuracy_compatible_kernel", False
2518+
):
2519+
# Reproduce torch step by step: expand both inputs to the broadcast
2520+
# shape and take the norm of the expanded tensors. Unlike torch.expand,
2521+
# paddle.broadcast_to is not a 0-copy view, so each expanded input costs
2522+
# an extra O(prod(bs)) allocation that is also read back by vector_norm.
2523+
# For x1=[B, 1, D] against x2=[B, N, D] that is O(B * N * D) instead of
2524+
# the O(B * D) the data occupies, and it may OOM when N is large.
2525+
x1 = paddle.broadcast_to(x1, bs)
2526+
x2 = paddle.broadcast_to(x2, bs)
2527+
n1 = paddle.linalg.vector_norm(x1, p=2, axis=dim, keepdim=True)
2528+
n2 = paddle.linalg.vector_norm(x2, p=2, axis=dim, keepdim=True)
2529+
else:
2530+
n1 = paddle.linalg.vector_norm(x1, p=2, axis=dim, keepdim=True)
2531+
n2 = paddle.linalg.vector_norm(x2, p=2, axis=dim, keepdim=True)
2532+
# A dynamic (-1) length fails both checks and is left uncorrected.
2533+
if x1.shape[dim] > 0 and bs[dim] > x1.shape[dim]:
2534+
n1 = n1 * math.sqrt(bs[dim] / x1.shape[dim])
2535+
if x2.shape[dim] > 0 and bs[dim] > x2.shape[dim]:
2536+
n2 = n2 * math.sqrt(bs[dim] / x2.shape[dim])
25212537
return sum(
25222538
paddle.multiply(x1 / clip(n1, min=eps), x2 / clip(n2, min=eps)),
25232539
axis=dim,

test/legacy_test/test_cosine_similarity_api.py

Lines changed: 231 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,23 +23,22 @@
2323
from paddle.base import Executor
2424

2525

26+
def _np_cosine_similarity(x1, x2, axis=1, eps=1e-8):
27+
"""Reference following torch2.12.0: broadcast both inputs first, then divide
28+
each of them by its own norm along ``axis`` clamped to ``eps``.
29+
"""
30+
x1, x2 = np.broadcast_arrays(x1, x2)
31+
n1 = np.maximum(np.sqrt(np.sum(x1 * x1, axis=axis, keepdims=True)), eps)
32+
n2 = np.maximum(np.sqrt(np.sum(x2 * x2, axis=axis, keepdims=True)), eps)
33+
return np.sum((x1 / n1) * (x2 / n2), axis=axis)
34+
35+
2636
class TestCosineSimilarityAPI(unittest.TestCase):
2737
def setUp(self):
2838
self.places = get_places()
2939

3040
def _get_numpy_out(self, x1, x2, axis=1, eps=1e-8):
31-
bs = np.broadcast_shapes([x1.shape[axis]], [x2.shape[axis]])
32-
w12 = np.sum(x1 * x2, axis=axis)
33-
w1 = np.sum(x1 * x1, axis=axis)
34-
w2 = np.sum(x2 * x2, axis=axis)
35-
m1, m2 = bs[0] / x1.shape[axis], bs[0] / x2.shape[axis]
36-
if m1 != 1:
37-
w1 = w1 * m1
38-
if m2 != 1:
39-
w2 = w2 * m2
40-
n12 = np.sqrt(np.clip(w1 * w2, eps * eps, None))
41-
cos_sim = w12 / n12
42-
return cos_sim
41+
return _np_cosine_similarity(x1, x2, axis=axis, eps=eps)
4342

4443
def check_static_result(self, place):
4544
paddle.enable_static()
@@ -177,18 +176,7 @@ def setUp(self):
177176
self.places = get_places()
178177

179178
def _get_numpy_out(self, x1, x2, axis=1, eps=1e-8):
180-
bs = np.broadcast_shapes([x1.shape[axis]], [x2.shape[axis]])
181-
w12 = np.sum(x1 * x2, axis=axis)
182-
w1 = np.sum(x1 * x1, axis=axis)
183-
w2 = np.sum(x2 * x2, axis=axis)
184-
m1, m2 = bs[0] / x1.shape[axis], bs[0] / x2.shape[axis]
185-
if m1 != 1:
186-
w1 = w1 * m1
187-
if m2 != 1:
188-
w2 = w2 * m2
189-
n12 = np.sqrt(np.clip(w1 * w2, eps * eps, None))
190-
cos_sim = w12 / n12
191-
return cos_sim
179+
return _np_cosine_similarity(x1, x2, axis=axis, eps=eps)
192180

193181
def test_dygraph_1(self):
194182
paddle.disable_static()
@@ -211,5 +199,224 @@ def test_dygraph_1(self):
211199
np.testing.assert_allclose(tensor_x1.grad.shape, tensor_x1.shape)
212200

213201

202+
class TestCosineSimilarityAPI_RankMismatch(unittest.TestCase):
203+
"""x1 and x2 may have different ranks: they are broadcast first, so
204+
``axis`` indexes the common shape rather than each input's own shape.
205+
"""
206+
207+
def test_dygraph(self):
208+
paddle.disable_static()
209+
np.random.seed(1)
210+
for shape1, shape2, axis in [
211+
([2, 3], [1, 2, 3], 1),
212+
([2, 3], [1, 2, 3], 2),
213+
([2, 3], [1, 2, 3], -1),
214+
([2, 3], [1, 2, 3], -2),
215+
([4, 5], [3, 4, 5], 0),
216+
([5], [3, 4, 5], 2),
217+
([1, 7], [2, 3, 7], 1),
218+
([3, 4, 5], [4, 5], 1),
219+
]:
220+
np_x1 = np.random.rand(*shape1).astype(np.float32)
221+
np_x2 = np.random.rand(*shape2).astype(np.float32)
222+
np_out = _np_cosine_similarity(np_x1, np_x2, axis=axis)
223+
224+
y = F.cosine_similarity(
225+
paddle.to_tensor(np_x1), paddle.to_tensor(np_x2), axis=axis
226+
)
227+
msg = f"x1={shape1} x2={shape2} axis={axis}"
228+
self.assertEqual(list(y.shape), list(np_out.shape), msg)
229+
np.testing.assert_allclose(
230+
y.numpy(), np_out, rtol=1e-05, err_msg=msg
231+
)
232+
233+
def test_static(self):
234+
paddle.enable_static()
235+
np.random.seed(1)
236+
try:
237+
for shape1, shape2, axis in [
238+
([2, 3], [1, 2, 3], 1),
239+
([4, 5], [3, 4, 5], 0),
240+
([5], [3, 4, 5], 2),
241+
([3, 4, 5], [4, 5], -1),
242+
]:
243+
np_x1 = np.random.rand(*shape1).astype(np.float32)
244+
np_x2 = np.random.rand(*shape2).astype(np.float32)
245+
np_out = _np_cosine_similarity(np_x1, np_x2, axis=axis)
246+
msg = f"x1={shape1} x2={shape2} axis={axis}"
247+
248+
main_program = static.Program()
249+
startup_program = static.Program()
250+
with static.program_guard(main_program, startup_program):
251+
x1 = static.data(name="x1", shape=shape1)
252+
x2 = static.data(name="x2", shape=shape2)
253+
result = F.cosine_similarity(x1, x2, axis=axis)
254+
for place in get_places():
255+
fetches = Executor(place).run(
256+
main_program,
257+
feed={"x1": np_x1, "x2": np_x2},
258+
fetch_list=[result],
259+
)
260+
np.testing.assert_allclose(
261+
fetches[0], np_out, rtol=1e-05, err_msg=msg
262+
)
263+
finally:
264+
paddle.disable_static()
265+
266+
267+
class TestCosineSimilarityAPI_LargeBroadcast(unittest.TestCase):
268+
"""Large inputs that broadcast along ``axis``: the reduction length is the
269+
broadcast one, which is what the ||repeat(x, m)|| == sqrt(m) * ||x||
270+
correction has to reproduce.
271+
"""
272+
273+
def test_dygraph(self):
274+
paddle.disable_static()
275+
np.random.seed(1)
276+
b, n, d = 4, 512, 256
277+
for shape1, shape2, axis in [
278+
# broadcast on the reduced axis, so both norms need the correction
279+
([b, 1, d], [b, n, d], 1),
280+
# broadcast on the reduced axis with mismatched ranks
281+
([1, d], [b, n, d], 1),
282+
# broadcast off the reduced axis, no correction
283+
([b, 1, d], [b, n, d], 2),
284+
([d], [b, n, d], 2),
285+
]:
286+
np_x1 = np.random.rand(*shape1).astype(np.float32)
287+
np_x2 = np.random.rand(*shape2).astype(np.float32)
288+
np_out = _np_cosine_similarity(np_x1, np_x2, axis=axis)
289+
290+
y = F.cosine_similarity(
291+
paddle.to_tensor(np_x1), paddle.to_tensor(np_x2), axis=axis
292+
)
293+
msg = f"x1={shape1} x2={shape2} axis={axis}"
294+
self.assertEqual(list(y.shape), list(np_out.shape), msg)
295+
np.testing.assert_allclose(
296+
y.numpy(), np_out, rtol=1e-05, err_msg=msg
297+
)
298+
299+
300+
class TestCosineSimilarityAPI_Numerics(unittest.TestCase):
301+
def test_small_but_valid_vectors(self):
302+
# |x1| * |x2| is below eps while both norms are far above it, so
303+
# clamping the product instead of each norm would attenuate the
304+
# result to about zero
305+
paddle.disable_static()
306+
np_x1 = np.array([[1e-6, 0.0, 0.0]], dtype=np.float32)
307+
np_x2 = np.array([[2e-6, 0.0, 0.0]], dtype=np.float32)
308+
y = F.cosine_similarity(
309+
paddle.to_tensor(np_x1), paddle.to_tensor(np_x2), axis=1
310+
)
311+
np.testing.assert_allclose(
312+
y.numpy(), np.ones([1], dtype=np.float32), rtol=1e-06
313+
)
314+
315+
def test_large_vectors_do_not_overflow(self):
316+
# |x1|^2 * |x2|^2 overflows float32 here while each norm stays finite
317+
paddle.disable_static()
318+
np_x1 = np.full([1, 4], 1e10, dtype=np.float32)
319+
np_x2 = np.full([1, 4], -1e10, dtype=np.float32)
320+
y = F.cosine_similarity(
321+
paddle.to_tensor(np_x1), paddle.to_tensor(np_x2), axis=1
322+
)
323+
np.testing.assert_allclose(
324+
y.numpy(), -np.ones([1], dtype=np.float32), rtol=1e-06
325+
)
326+
327+
def test_zero_vector(self):
328+
paddle.disable_static()
329+
np_x1 = np.zeros([2, 4], dtype=np.float32)
330+
np_x2 = np.random.rand(2, 4).astype(np.float32)
331+
x1 = paddle.to_tensor(np_x1)
332+
x1.stop_gradient = False
333+
y = F.cosine_similarity(x1, paddle.to_tensor(np_x2), axis=1)
334+
np.testing.assert_allclose(
335+
y.numpy(), np.zeros([2], dtype=np.float32), rtol=1e-06
336+
)
337+
y.sum().backward()
338+
self.assertFalse(np.isnan(x1.grad.numpy()).any())
339+
340+
341+
class TestCosineSimilarityAPI_BroadcastMemory(unittest.TestCase):
342+
"""Taking the norm of a broadcast input must not materialize the expanded
343+
copy: for x1=[B, 1, D] against x2=[B, N, D] that would be an extra
344+
O(B * N * D) allocation. FLAGS_use_accuracy_compatible_kernel opts into that
345+
allocation on purpose, so this only holds for the default path.
346+
"""
347+
348+
@unittest.skipIf(
349+
not paddle.is_compiled_with_cuda(), "peak memory is queried from CUDA"
350+
)
351+
@unittest.skipIf(
352+
paddle.get_flags(["FLAGS_use_accuracy_compatible_kernel"]).get(
353+
"FLAGS_use_accuracy_compatible_kernel", False
354+
),
355+
"the accuracy compatible path expands the inputs on purpose",
356+
)
357+
def test_no_expanded_copy(self):
358+
paddle.disable_static()
359+
origin_device = paddle.get_device()
360+
paddle.set_device('gpu')
361+
try:
362+
B, N, D = 32, 256, 1024
363+
x1 = paddle.randn([B, 1, D])
364+
x2 = paddle.randn([B, N, D])
365+
full_size = B * N * D * 4
366+
367+
paddle.device.cuda.empty_cache()
368+
paddle.device.cuda.reset_max_memory_allocated()
369+
base = paddle.device.cuda.max_memory_allocated()
370+
F.cosine_similarity(x1, x2, axis=1).numpy()
371+
extra = paddle.device.cuda.max_memory_allocated() - base
372+
373+
# x2 / n2 and their product are the only full size temporaries
374+
self.assertLess(
375+
extra,
376+
2.5 * full_size,
377+
f"{extra} bytes of temporaries for a {full_size} byte input, "
378+
"the broadcast input is likely being expanded",
379+
)
380+
finally:
381+
paddle.set_device(origin_device)
382+
383+
384+
class TestCosineSimilarityAPI_DtypePromotion(unittest.TestCase):
385+
def test_integral_input_is_promoted(self):
386+
paddle.disable_static()
387+
np_x1 = np.array([[1, 2, 3]], dtype=np.int32)
388+
np_x2 = np.array([[3.0, 2.0, 1.0]], dtype=np.float32)
389+
y = F.cosine_similarity(
390+
paddle.to_tensor(np_x1), paddle.to_tensor(np_x2), axis=1
391+
)
392+
self.assertEqual(y.dtype, paddle.float32)
393+
np_out = _np_cosine_similarity(np_x1.astype(np.float32), np_x2)
394+
np.testing.assert_allclose(y.numpy(), np_out, rtol=1e-06)
395+
396+
def test_mixed_float_dtypes(self):
397+
paddle.disable_static()
398+
np_x1 = np.random.rand(2, 5).astype(np.float32)
399+
np_x2 = np.random.rand(2, 5).astype(np.float64)
400+
y = F.cosine_similarity(
401+
paddle.to_tensor(np_x1), paddle.to_tensor(np_x2), axis=1
402+
)
403+
self.assertEqual(y.dtype, paddle.float64)
404+
np_out = _np_cosine_similarity(np_x1.astype(np.float64), np_x2)
405+
np.testing.assert_allclose(y.numpy(), np_out, rtol=1e-06)
406+
407+
def test_non_floating_common_dtype(self):
408+
paddle.disable_static()
409+
for dtype in ('int32', 'int64', 'bool', 'complex64'):
410+
x = paddle.ones([1, 3], dtype=dtype)
411+
with self.assertRaises(TypeError):
412+
F.cosine_similarity(x, x, axis=1)
413+
414+
def test_negative_eps(self):
415+
paddle.disable_static()
416+
x = paddle.ones([1, 3])
417+
with self.assertRaises(ValueError):
418+
F.cosine_similarity(x, x, axis=1, eps=-1e-8)
419+
420+
214421
if __name__ == '__main__':
215422
unittest.main()

0 commit comments

Comments
 (0)