2025, Dec 05 06:02

numpy.fabs против numpy.abs: измерения, причины и выбор

Сравниваем производительность numpy.fabs и numpy.abs на массивах float32: замеры, почему fabs медленнее из-за вызова libc, корректность IEEE-754, что выбрать.

Почему numpy.fabs заметно медленнее, чем numpy.abs, на одном и том же массиве float32, хотя numpy.abs поддерживает больше типов и возможностей? Короткий ответ: одна из функций делает крюк через математическую библиотеку C, и этот обход перекрывает те оптимизации, на которые вы рассчитываете.

Минимальный пример

Скрипт ниже демонстрирует разницу во времени на массиве float32. Он измеряет задержку на вызов в микросекундах с помощью timeit.

import numpy as np, timeit as tm

buf = np.random.rand(1000).astype(np.float32)
print('Minimum, median and maximum execution time in us:')

for expr in ('np.fabs(buf)', 'np.abs(buf)'):
    timings = 10**6 * np.array(tm.repeat(stmt=expr, setup=expr, globals=globals(), number=1, repeat=999))
    print(f'{expr:20}  {np.amin(timings):8,.3f}  {np.median(timings):8,.3f}  {np.amax(timings):8,.3f}')

Показательный вывод на AMD Ryzen 7 3800X показывает, что numpy.fabs более чем в 2 раза медленнее numpy.abs при одинаковом размере данных.

Что на самом деле происходит

Причина не в корректности или редких случаях в пользовательском коде NumPy. Важно, какой путь реализации NumPy выбирает под капотом для вычисления модуля числа с плавающей точкой.

fabs всегда вызывает одноимённую функцию из математической библиотеки C (для float32 — вариант fabsf). Поэтому операцию нельзя ни встроить (inline), ни векторизовать.

Это подтверждено подменой реализации через LD_PRELOAD. Иными словами, numpy.fabs оплачивает накладные расходы внешнего вызова библиотеки. Такой выбор закрывает путь к inlining и той векторизации, которая ускоряет работу с массивами.

В glibc fabsf отображается на __builtin_fabsf(x), и сгенерированный код по своей сути не сложнее быстрой побитовой операции взятия модуля. Дело не в том, что библиотека медленная, а в том, что сам вызов в неё мешает компилятору и быстрым путям NumPy проявить максимум эффективности.

Похоже, NumPy последовательно направляет семейство функций на f… (fabs, fmin, fmax) через математическую библиотеку C. Поэтому схожих эффектов можно ожидать и для fmin/fmax по сравнению с обычной семантикой min и max в операциях над массивами, хотя fmin и fmax действительно добавляют поведение сверх простейших min/max.

Есть и платформенный аспект. Старые отчёты показывают, что разница в производительности и поведение с исключениями в арифметике не универсальны. На MIPS abs когда-то был медленнее, потому что компилятор не мог безопасно превратить типичное C‑выражение в побитовую маску из‑за возможных исключений с плавающей точкой, тогда как fabs не должен их вызывать.

Наконец, у модуля для float есть тонкая ловушка корректности: реализация в духе x < 0 ? -x : x ломается на отрицательном нуле, ведь по IEEE‑754 нужно сохранять различие между −0 и +0. Современный NumPy обеспечивает корректное поведение numpy.abs для типов с плавающей точкой, тогда как наивная реализация на C — нет. Отсюда и вывод: простая самодельная формула не является равнозначной заменой.

Практическое решение

Для массивных вычислений отдавайте предпочтение numpy.abs вместо numpy.fabs. Так операция остаётся на векторизуемом, встраиваемом пути, который NumPy умеет отлично оптимизировать.

import numpy as np

buf = np.random.rand(1000).astype(np.float32)
res = np.abs(buf)

Если измерять время на больших массивах, разрыв может заметно вырасти. Есть сообщения о двузначных ускорениях на массивах из 100 тыс. элементов, а на некоторых CPU увеличение размера массива в 100 раз усиливало различия с умеренных до очень больших. Также отмечалось, что AVX2 способен ускорять взятие модуля для float32 до 8 раз, что лишь подчёркивает важность векторизованного пути.

Почему это важно для производительности

Производительность кода над массивами часто зависит от того, можно ли операции объединять, встраивать и векторизовать. Проброс через функцию из математической библиотеки C, такую как fabsf, это исключает, и даже минимальная по смыслу операция способна стать узким местом в горячих участках. С numpy.abs всё наоборот: современный NumPy реализует её так, чтобы учитывать крайние случаи с плавающей точкой и при этом позволять быстрое исполнение.

Помните, что поведение и скорость зависят от платформы и инструментария. Исторические примеры вроде MIPS показывают: возможности компилятора и семантика исключений способны перевернуть привычную картину. Вывод не в том, что где угодно одно имя всегда быстрее, а в том, что граница вызова библиотеки в numpy.fabs — стабильный ограничитель там, где доступны SIMD и inlining.

Итоги

Если вам нужен модуль элементов массива NumPy, используйте numpy.abs. Она сохраняет детали IEEE‑754 вроде отрицательного нуля и обычно попадает на встраиваемый, векторизуемый маршрут. Осторожнее с семейством f… там, где важна пропускная способность, и помните: поведение чисел с плавающей точкой зависит от платформы. Если сомневаетесь, проводите замеры на целевом оборудовании с реалистичными размерами массивов и выбирайте API, позволяющие NumPy применять оптимизированные ядра.