Pull to refresh

Comments 102

Например, если поделить единицу на +0.0 и -0.0, то мы получим кардинально разные ответы: +Infinity и -Infinity, отличие между которыми уже сложно игнорировать.

А смысл деления на 0 какой? Собственно как только получили инф или нан дальше уже можно не считать, т.к. или ошибка входных данных или кривая математика.

Никто не будет специально делить на ноль - ноль может получиться в результате вычислений. Смысл деления на ноль есть, например в компьютерной графике (в шейдерах) при вычислении освещения может получится деление на ноль и, грубо говоря, получив бесконечность пиксель может быть белым, а минус бесконечность - черным. Лишние условия в шейдере - потеря производительности, поэтому если такой момент стандартизирован, то лучше им пользоваться.

В посте все же явно не шейдер на жаве пишут.

Я встречал пример с построением графика. Система с поддержкой -0.0 рисовала правильный график, а без поддержки - с артефактами. И как раз из-за подобного деления на переменную, стремящуюся к нулю (и в какой-то момент становящейся +0 или -0), где-то внутри формулы.

К сожалению, давно это было, ссылку не отыщу.

Поведение double стандартизировано, поэтому рассмотренные подходы, скорее всего, будут справедливы и для множества других языков программирования.

double в java гарантированно хранится в ieee754, это в спеке так описано? Если да, то вопросов нет.

Как хранится - это личное дело виртуальной машины. В спеке описано поведение. Поведение соответствует IEEE-754 с некоторыми упрощениями (например, никаких signalling NaN нету).

ноль может получиться в результате вычислений.

дык а не проще для этого случая проверку предусмотреть? а то иначе придется предусматривать проверку после деления на инф :)

Это относительно дорогая операция на гпу. Особенно, если у вас вектор из 4х значений, часть из которых - нули.

Суть проблемы понятна. А почему бы просто не возвращать положительный ноль, если x==0?

Хороший вариант! Но отдельное условие для нуля всё равно будет.

Немного поправил статью, упомянул такую возможность. Спасибо!

signum - тоже нетривиальная операция с условиями. Да и умножение не на константу. Заметно медленнее будет скорее всего. Но я не проверял!

Пишем языки высокого уровня.
Обнаруживаем, что они работают как-то не так.
Оперируем битами вручную.

Как будто это применимо только к языкам высокого уровня :)

Ага, забавно каким алгоритмическими кровями автор приходит к тривиальной для любого сишника реализации. При этом финальное решение полностью ортогонально концепции оригинального языка.

к тривиальной для любого сишника реализации.
Причём то, что реализация работает и для
Float.NEGATIVE_INFINITY
(я проверял), вообще говоря, не обязано было получиться (если не знать, как там внутри всё устроено).
(Напоминает историю (нагуглить не удалось), когда в советское время отдел программистов переходил с ассемблера на ЯВУ (не Джаву ^_^).
Почти у всех (кроме одного) эффективность программ упала.
Причина успеха единственного — он представлял, в какие ассемблерные инструкции транслировалась программа )

На сях же решение будет чуть чуть отличаться. Я правда не в курсе как сейчас в си, но раньше же количество бит в double зависело от архитектуры машины, а значит это надо как то в коде учитывать.

А на джаве действительно проще, код будет работать хоть на суперкомпьютере, хоть на микроконтроллере.

На джаве действительно проще - ни на суперкомпьютере, ни на микроконтроллере она работать просто не будет ;).

Чудес-то не бывает - если количество бит в "родном" double другое, чем прописано в стандарте языка, то эффективно реализовать этот тип на данной архитектуре будет в принципе невозможно.

Стоит уточнить, что авторы виртуальной машины могут это сделать. Обычный пользователь JVM не имеет такой роскоши :-)

Добро пожаловать в закон дырявой абстракции…

А если так :

double x = -0.0;
x= x-1+1;

После этого разве x не равен +0.0 ?

Даже не оптимизатор, а просто компилятор эту строку выкинет.

Не имеет права выкидывать. Только если в компиляторе стоит опция типа fast math (она никогда не стоит по умолчанию). Потому что floating point math не ассоциативна и не дистрибутивна.

В компиляторе Интел fast math включен по умолчанию.

Вариант интересный, но на низком уровне может быть гораздо более сложный. Вряд ли это будет быстрее, чем `0.0 - x`.

Это операция с потерей данных.
попробуйте:
double x = 1e-200
x=x-1+1

Вспомнилось...

"...В 60-е - 70-е годы, когда компьютеры были большими, каждая линейка компьютеров имела свою программную реализацию вычислений с плавающей запятой, свои форматы представления чисел, точность, представимые диапазоны и правила округления. Соответственно, чудеса, вроде описанных в "Неочевидных особенностях вещественных чисел", были у каждого свои. По воспоминаниям старожилов, на некоторых машинах число могло выглядеть отличным от нуля в операциях сравнения и сложения, но быть чистым нулем при умножении и делении. Чтобы без страха поделить на такое число, его следовало умножить на 1.0 и лишь потом сравнить с нулем. А другие машины могли выдать ошибку переполнения при умножении на 1.0 вполне нормального числа. Были такие малюсенькие числа (но не нули), которые давали переполнение при делении на самих себя. В программах были обычными шаманские вставки вроде X = (X + X) - X. Соответственно, одна и та же программа, даже написанная на стандартном FORTRAN'е, могла давать разные результаты на разных машинах..."

(с) Загадки округления

Сначала пишут на жабе, а потом выжимают проценты из тривиальных операций.

Как хорошо замечено в конце статьи, все современные FPU имеют аппаратную инструкцию взятия модуля, которая просто сбрасывает бит знака для IEEE-754 представления.

Так джава - очень высокопроизводительный язык! В ней всегда выжимают проценты.

в операциях сравнения +0.0 и -0.0 неразличимы
System.out.println(-0.0-(+0.0)); // -0.0
if (value <= 0) {
    return -0.0 - value;
}
Отлично, у нас теперь всегда одна ветка. Победа?
Если на входе будет +0.0, то будет ли на выходе победа?

О, спасибо! Я действительно немножко оплошал и неправильно объяснил. Поправил статью.

Всегда, когда вижу числа с плавающей точкой (double/float) в задачах отличных от физики, начинаю паниковать и включается режим "сапёра" с тщательным анализом граничных случаев и максимальной изоляции этого кода. Если вижу double/float в финансовых приложениях, "дёргаю стоп-кран" и предлагаю избавиться от него. Если вижу битовые манипуляции с такими типами, то паникую еще больше (но, к сожалению, если уж пришлось прийти к битовым манипуляциям над флоатами, то это не от хорошей жизни и обычно оправдано).

95% разработчиков легко допускает ошибки в работе с этими типами на уровне "обычных" операций (сравнить на равенство, сложить-вычесть в неправильном порядке, применить ассоциативность/коммутативность/дистрибутивность не думая, преобразования в другие типы и т.п.). Не меньше 80% разработчиков не напишут корректно даже элементарного метода Гаусса с первой попытки.

Причём не стоит думать, что "ну я -то точно легко справлюсь". До нас в этой ловушке побывали и разработчики процессоров, и разработчики ОС, и разработчики Excel, и разработчики стандартных библиотек.

PS: а статья @lany, как всегда, хороша тем, что заставляет подумать.

PPS: угадайте, что меня напрягает в JavaScript :)

В JS есть прекрасная целочисленная математика. Если только вы сами собственными руками не возьмете float.

вижу double/float в финансовых приложениях

Хм, а как вы от них избавляетесь? Очень часто невозможно предсказать какую минимальную дробность могут принимать значения. При расчете какой-нибудь себестоимоимости запросто может быть важен 4-5-6 знак после запятой.. Вводить сразу дробность в 10е-9 ? Тогда вам может int64 не хватить...

BigDecimal есть, в котором разрядностью можно управлять явно.

Полный ответ и в статью не поместится, не то что в комментарий. Потому и "стоп-кран". Дальше надо смотреть на конкретную задачу и требования. Где-то перейти в BigDecimal, где-то изолировать плавающую точку, где-то изменить исходную задачу. При расчёте себестоимости в ядре вполне может быть (чаще всего должна быть) СЛАУ, которую, наверное, можно считать в double и изолировать, "но это не точно". Но оставлять незащищенным числовой тип данных в котором нельзя написать if (a==b) (а кто-нибудь так напишет) страшновато.

согласен.

Вариант, если просто поступать так - разделяя, например, центы и доллары на целочисленные разделы и работая в BigInteger? насколько я помню BigDecimal тоже проблему не снимают....

А сколько будет стоить капля спирта в вашей системе?

0 долларов 0 центов? Накапайте мне ведерко...

К слову, у некоторых банков есть ровно такая проблема при конвертации валют, позволяющая получить профит от конвертации маленьких сумм

Решается все просто - либо делают изначально высокие лимиты, либо мониторят

По хорошему, всего есть округление до ближайшего пипа в пользу банка

Можно не обращать внимания пока идут внутренние вычисления, а на выходи форматировать.

Ну и непримитивные типы это мусор (память)

Не меньше 80% разработчиков не напишут корректно даже элементарного метода Гаусса с первой попытки.

Надо заметить, что корректная реализация метода Гаусса с учетом всех нюансов - задача не такая уж и простая. На бумаге он, конечно, элементарен. Но это на бумаге.

Да, но с другой стороны, это базовая (модельная) задача. Реальные задачи зачастую гораздо больше и сложнее.

Ну а просто посмотреть, как сделано в JDK, который оптимизируют и шлифуют уже много-много лет?


    @HotSpotIntrinsicCandidate
    public static double abs(double a) {
        return (a <= 0.0D) ? 0.0D - a : a;
    }

Причём аннотация вот эта означает, что реально в машинном коде может быть вставлено что-то другое, та же операция с битами или инструкция FPU (fabs).

То есть вы думаете, что я сослался на пулл-реквест, где этот код изменён на более свежий, но не глянул, что было до этого? :-)

Аннотированных так методов, кстати, в разы больше, чем настоящих интринсиков. Поэтому доверять этой аннотации нельзя, надо смотреть конкретно в исходники JVM (для C2 - opto/library_call.cpp)

это собеседование по Java (в которой надо просто использовать библиотечный метод, а не заниматься ерундой) или на знание стандарта IEEE?

Double.doubleToRawLongBits(value) & 0x7fffffffffffffffL); я бы записал как Double.doubleToRawLongBits(value) & ~(1L<<63)); ибо так лучше видно, что это 63 бит (ну и так немножко короче).

Интересно, что в джавадоке метода реализация без ветвления упомянута, причём ещё с Java 9

As implied by the above, one valid implementation of this method is given by the expression below
which computes a double with the same exponent and significand as the argument but with a
guaranteed zero sign bit indicating a positive value:
Double.longBitsToDouble((Double.doubleToRawLongBits(a)<<1)>>>1)

Странно, что только в Java 18 её догадались скопировать из джавадока в сам метод )))

Там не всё так просто. Когда этот тикет появился, с интринсиками дела обстояли туго, и всякие doubleToRawLongBits были реально медленными, поэтому такое изменение не имело смысла. Потом времена изменились.

А там разве не что-то типа return *((long*)(&a)) внутри?

Java вообще не так работает. В JNI методе можно оно и так, только на сам JNI оверхед будет не меньше сотни наносекунд.

inline float absF(float a) { return (*(((unsigned long*)(&a))))&0x7FFFFFF;} // :)

Math.abs не избавляет от чудес:


        double a = Math.abs(0. / 0.);
        double b = a;
        if ( a != b ) {
            System.out.println("oops");
        }

Понятно, что Math.abs() тут для отвода глаз, но тем не менее. Проблема не в том, что велосипедный abs() сработал некорректно, а в том, что поделили на ноль. На ноль делить нельзя, даже на -0., и это надо проверять перед делением:


        double x = -0.0;
        if ( x == 0. ) {
            System.out.println("oops, x == 0.");
        }
Проблема не в том, что велосипедный abs() сработал некорректно, а в том, что поделили на ноль. На ноль делить нельзя, даже на -0.,
По-моему, проблема (если она есть) тут в том, что «не-число» (в данном случае это должно быть «любое число», так как 0*икс == 0 при любом «обычном» икс) не равно «не-числу», что логично (как и NULL в SQL).
(А делить на ноль в Java можно, и результат даже получается достаточно разумным.)
(А делить на ноль в Java можно, и результат даже получается достаточно разумным.)

Не всегда:


        int x = 0;
        int y = 0;
        System.out.println("x / y = " + (x / y));

В целом, согласен, есть определенный смысл в -/+ Infinity для вычислений с плавающей точкой, но мне пока не выпал случай этот смысл сознательно использовать.


Получить исключение от целочисленного деления на ноль гораздо более реально.

public static double abs(double value) {
  if (value+1 < 1) {
    return -value;
  }
  return value;
}

Не проканает?

Это из-за приведения? А с 1.0?

Нет, это из-за формата, в котором хранится floating point. Оно не просто так называется числом с плавающей точкой. Оно состоит из мантиссы и порядка (и их знаковых бит) в виде
M * 2^E
Так, в 64-битном double под мантиссу M отведено 52 бита и 10 под порядок E.
Чтобы сложить два числа, их мантиссы нужно выровнять битовым сдвигом так чтобы точка оказалась в одном и том же месте. Если порядки этих чисел различаются на 52 и больше, то при сдвиге мантиссы меньшего из них какой-либо информации об ее значении в двоичном представлении числа просто не останется. Если разница меньше, то потеряется часть точности.

Ах да, я не задумался о том, что сдвиг такой большой.
Спасибо.
UFO just landed and posted this here

Это особенность не Java как таковой. А формата кодирования чисел с плавающей запятой, которому следуют большинство ЯП.

UFO just landed and posted this here

Полагаю то, что для знака выделен 1 бит и всё (0, infinity, NaN хоть для него знак и не имеет смысла) может быть либо отрицательным либо положительным.

В C# и C++ работает так же, поскольку поведение double стандартизировано и в конечном счёте сводится к одним и тем же арифметическим инструкциям процессора, какой бы язык мы ни использовали.

Насколько сам понимаю, в формате с плавающей запятой под знак числа выделен отдельный бит, то есть отрицательный ноль получается сам собой из положительного путём инвертирования знакового бита. В целочисленной же арифметике отрицательные числа записываются в дополнительном коде (как такового знакового бита нет), из-за чего само собой выходит, что +0 и -0 побитово эквивалентны, если же инвертировать у +0 условный "знаковый" бит, то получится уже не -0, а минимально возможное отрицательное число, например, -128 для типа byte.

а может так, сойдет?

tolerance = 0.00000001

EQ_ge { s t } { return ( s > (t - tolerance) ) }

Abs { v } {

if EQ_ge { v 0.0 } { return v }

return ((-1)*v)

}

:)

Интересно, а в .NET как модуль вычисляется?

Ага, а в ассемблере они раскрываются вот в такое (x64):


C.Abs(Double)
    L0000: vzeroupper
    L0003: vmovsd xmm0, [C.Abs(Double)]
    L000b: vandps xmm0, xmm0, xmm1
    L000f: ret

Видимо vandps и обнуляет знаковый бит.


На x86 используются команды FPU:


C.Abs(Double)
    L0000: fld qword [esp+0x4]
    L0004: fabs
    L0006: ret 0x8

По ссылке доступны функции для целых и вещественных типов. Что удивительно — ассемблерный код для первых содержит больше инструкций, чем для вторых.

Напомнило факториал на хаскеле.

Не спорю, раз в столько-то там лет оно может и будет иметь какое-нибудь значение в проекте, но подозреваю, что в большинстве случаев #define abs(x) x < 0 ? -x : x будет точно так же эффективно в плане перформанса и многократно эффективнее в плане читабельности.

В плане и перформанса, и читабельности в Java, разумеется, надо использовать метод стандартной библиотеки, а не изобретать велосипед. Статья не предполагает, что написание такого метода вам необходимо для вашего сурового энтерпрайза. Статья помогает разобраться во внутреннем устройстве вещей, которые нас окружают.

(шутка)

  • А давайте придумаем числа, чтобы посчитать сколько у нас яблок..

  • А давайте придумаем такое число 0, когда у нас нет ни одного яблока..

  • А давайте придумаем отрицательные числа, чтобы посчитать какого количества яблок у нас нету..

  • .... а давайте придумаем такое число -0, чтобы указать что у нас нету ни одного яблока, которого у нас нету..

  • О! А давайте придумаем функцию Abs() ...

Хмм, а разве нельзя представить число (хоть 0, хоть -0.0, хоть +0.0) в строковом виде, а там сделать проверку наличия символа "минус" (или как он там будет называться - дефис, тире или ещё как), при нахождении его удалить и полученную строку снова конвертировать в числовой вид и дальше уже оперировать с ним..?

Мнение непросвещённого, так что не обессудьте.

Это ужасно медленно. Невообразимо медленно. Может сотни наносекунд занять.

Но по идее правильно? На Луа такое прокатывает.

В принципе, можно и без проверки на наличие минуса вытащить без минуса.. :) Но я догадываюсь, что всё равно ответ будет - "медленно".

Ну это из разряда проверять истинность булевой переменной через String.valueOf(myFlag).length() == 4. Работает, конечно, вопросов нет. Может есть языки, где такой подход идиоматичен.

public static double abs(double value) {
return Double.longBitsToDouble(
Double.doubleToRawLongBits(value) & 0x7fffffffffffffffL);
}

Если без «магии» и платформонезависимо, то, наверное, лучше просто copySign()
public static double abs(double value) {
return Math.copySign(value,1);
}

Так это решение абсолютно платформонезависимо! А copySign внутри сделает то же самое. Возможно, JIT даже докрутит ваш вариант до моего.

Глянул в исходники Math, там действительно похоже. Только не конкретная маска используется, а (DoubleConsts.SIGN_BIT_MASK). Подозреваю, что эта константа точно равна 0x7fffffffffffffffL (я так то не знаток java, я эмбед-сишник, поэтому с типами ооочень осторожен)
UPD:
public static double copySign(double magnitude, double sign) {
        return Double.longBitsToDouble((Double.doubleToRawLongBits(sign) &
                                        (DoubleConsts.SIGN_BIT_MASK)) |
                                       (Double.doubleToRawLongBits(magnitude) &
                                        (DoubleConsts.EXP_BIT_MASK |
                                         DoubleConsts.SIGNIF_BIT_MASK)));
    }


Так что вместо 0x7fffffffffffffffL можно (нужно) подставить
(DoubleConsts.EXP_BIT_MASK | DoubleConsts.SIGNIF_BIT_MASK) и JIT докрутит до конкретного значения (=0x7fffffffffffffffL)
и итоговая функция будет
public static double abs(double value) {
        return Double.longBitsToDouble(Double.doubleToRawLongBits(value) &
                                        (DoubleConsts.EXP_BIT_MASK |
 DoubleConsts.SIGNIF_BIT_MASK));
    }

(DoubleConsts.EXP_BIT_MASK | DoubleConsts.SIGNIF_BIT_MASK) докрутит javac. Это по стандарту константа времени компиляции, она уже в байткод ляжет в виде конкретного числа. А вот сделать инлайнинг, подставить параметр sign и вычислить (Double.doubleToRawLongBits(sign) & (DoubleConsts.SIGN_BIT_MASK) до константы 0 и удалить - уже задача JIT-компилятора (вполне посильная).

Я понимаю что js не джава и т.д. но можно x<<1>>1 (обнулить двумя сдвигами первый бит), ну или хотя бы x & MAX_POSITIVE_DOUBLE, а если у вас есть поинтеры, то можно вообще по поинтеру переписать первый бит.

Или в джаве с этими способами туго?

signed integer overflow это норм в JS?

Просто сдвиг происходит в окне памяти переменной, этот бит не уйдет куда-то в overflow. Конечно для языков с нормальным управлением памятью это минус, но в js это нужно крайне редко. Вроде sharedArrayBuffer для этого есть. В общем в js уже даже null pointer (x is undefined) редко встречается. Скриптовый язык)

Что такое «окно памяти переменной»?

Область оперативки под эту значение этой переменной, очевидно что в js напрямую управлять поинтерами и давать доступ к физической памяти нельзя, (т.е. с поинтерами чуть-чуть можно). И нельзя ни при каких условиях выходить за рамки виртуалки под вкладку. В противном случае js из одной вкладки браузера мог бы, теоретически, читать оперативку всего компа. Впрочем зачастую он это и может, например если не стоят патчи от spectre и meltdown и их аналогов для amd. В таком грустном мире живем)

А сколько бит выделено под эту переменую? Насколько ее можно сдвинуть влево?

Я подозреваю бит под значение, ибо переменная в js это не только ее значение, грубо говоря все переменные в js это enum т.е. занимают весьма много места изначально вне зависимости от типа.

Сдвигать можно насколько угодно, просто лишние биты удалятся, а вместо недостающих вставится 0. Это ожидаемое поведение ибо в js нельзя просто так поставить переменные в памяти рядом (можно). По размерам... Не знаю насколько js стандартизирован, поэтому теоретически размеры переменных в разных браузерах/системах/ноде могут быть разными, но если верить https://exploringjs.com/impatient-js/ch_numbers.html то числа с точкой это 64 бита, и 53 для инта, только инт нифига не инт)) Т.е. есть еще Bigints которые не ограниченны. Но сдвигать можно все равно на любое число, просто свдиг больше размера числа его обнулит. Также сдвиг приводит число с точно к инту, типа здраствуйте магическая константа из дума) Т.е. ```123.123>>0 === 123 //true```

Ваше решение даже в js работать не будет, потому что это решение для целых, а надо для вещественных.

Руководствовался больше названием) Да в js битовые операции числам с точкой недоступны. Когда-то писал себе утилитку чтобы их хотя бы посмотреть в битовом виде. Однако в js -0 это всегда int XD поэтому в принципе всей этой мороки не будет) Тем более я не решение предлагал, а спрашивал будет ли это работать?)

Т.е. изначально мой вопрос можно было сформулировать как: "А почему нельзя просто обнулить первый бит"?

Ведь это не требует ветвлений, или даже чтения переменной до конца. Основная проблема это NaN которые тоже Double. Впрочем в конце статьи уже есть это решение и про NaN ни слова, видимо NaN в этом бите не нуждается.

Однако в js -0 это всегда int XD поэтому в принципе всей этой мороки не будет)

Да ладно?


> const abs = x => x < 0 ? -x : x;
< undefined
> 1 / abs(-0)
< -Infinity

Эм, я про мороку с тем, что эти способы в js с даблом не сработают да и перейдут сразу в Math.abs)

Правда если верить https://galactic.ink/journal/2011/11/bitwise-gems-and-other-optimizations/ то ваше решение все равно будет работать быстрее хоть и не будет работать для -0. Хотя глядя на 1 / (-0 >> 0) === Infinity (тоже самое с parseInt и parseFloat для -0) можно решить что -0 в js не обязательно int.

-0 в js не обязательно int

Вообще-то в js нет никаких int, есть только number.
И есть операции, которые работают с number как c целым числом — в частности, сдвиги.

Я не про тип в js, а про то как число хранится в памяти. Ибо целые, большие целые и с точкой хранятся по разному. Да и большие целые формально в js не number. Плюс через sharedArrayBuffer типы чуть более явные. Но его в js не часто используют, разве что игры оптимизировать.

А какая разница как оно там хранится в памяти? Это всего лишь деталь реализации, но если спецификация JS требует различать -0 и +0 — они будут различаться, иначе это баг и он рано или поздно будет исправлен.

Sign up to leave a comment.

Articles