Skip to content

Conversation

@tanishiking
Copy link

@tanishiking tanishiking commented Sep 9, 2025

Implement bellerophon algorithm from "How to Read Floating Point Numbers Accurately" by William D. Clinger

TODO:

  • corner cases
    • overflow
    • underflow (subnormal / negative infinity)
  • Parse double
  • Parse hex (should be in another PR)
    • Math.pow and Long.parseLong are required
  • more test cases
    • maybe we wanna copy test cases from somewhere


def parseFloat(s: String): scala.Float = {
import Utils._
private[this] val parseFloatImpl = linkTimeIf[ParseFloatRegExpImpl](isWebAssembly) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be needed elsewhere. Perhaps define it once and for all in an object in java.util.regex.*?

override def toString(): String =
Conversion.toDecimalScaledString(this)
linkTimeIf(targetPureWasm) {
"" // TODO: Integer.toUnsignedString
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If all you need is Integer.toUnsignedString(i: Int), you can trivially implement that one as Long.toString(Integer.toUnsignedLong(i)). Those two methods are already implemented.

* 5^e < 2^64 <=> e < log5(2^64) <=> e < 27.563299716697156...
*/
val smallPowerOfTens = Array[FloatingPoint](
FloatingPoint.normalized(new BigInteger("8000000000000000", 16), -63), // 0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems all the constants here are unsigned Long values. Store them as such, and convert them to BigInteger without having to parse a string?

This can be done with a small helper like

def ulongToBigInt(x: Long): BigInteger =
  if (x >= 0L) BigInteger.valueOf(x)
  else BigInteger.valueOf(x & ~Long.MinValue).setBit(63)

private[lang] object Constants {

final val ExtendedSigBits = 64
final val ExtendedMaxSig = BigInteger.TWO.pow(ExtendedSigBits)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
final val ExtendedMaxSig = BigInteger.TWO.pow(ExtendedSigBits)
final val ExtendedMaxSig = BigInteger.ONE.shiftLeft(ExtendedSigBits)

or even

Suggested change
final val ExtendedMaxSig = BigInteger.TWO.pow(ExtendedSigBits)
final val ExtendedMaxSig = BigInteger.ZERO.setBit(ExtendedSigBits)

?

Comment on lines 7 to 18
/** DIY floating point number with 64-bit significand bits */
private[dec2flt] class FloatingPoint private (val f: BigInteger, val e: Int) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it always stay within 64 significant bits? If yes, why not use a Long (interpreted as unsigned) instead of a BigInteger?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. When multiplying these 64-bit floating-point numbers, an intermediate integer is up to 128 bits, but this could be represented by two Longs, and the result is immediately normalized back into the 64-bit range. So, it seems we can avoid using BigInteger here 👍

@tanishiking tanishiking changed the title WIP: Parse float in pure Wasm Parse float in pure Wasm Oct 1, 2025
@tanishiking tanishiking marked this pull request as ready for review October 1, 2025 08:58
linkTimeIf(targetPureWasm) {
// java.lang.Long.parseLong may fail with NumberFormatException
// for a large input.
new BigInteger(s, radix).doubleValue()
Copy link
Collaborator

@sjrd sjrd Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When parsing truncatedMantissaStr, you should be able to use parseUnsignedLong. The largest possible string for truncatedMantissaStr is "f" * 15 + "1", which correctly (though barely) parses to -15L.

When parsing binaryExpStr, if the string has more than 11 chars (10 digits and 1 sign), you can saturate it to Int.MinValue/Int.MaxValue (depending on the sign) without parsing it. If it has at most 11 chars, you can correctly parse it with parseLong (and then convert to Int with saturation; not wrapping).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right, since maxPrecisionChars = 15 it always fit in unsigned 64 bit integer 👍

js.Dynamic.global.parseInt(s, radix).asInstanceOf[scala.Double]
}

val mantissa = nativeParseInt(truncatedMantissaStr, 16)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, concretely, I would leave nativeParseInt as being nativeJSParseInt and do

Suggested change
val mantissa = nativeParseInt(truncatedMantissaStr, 16)
val mantissa = linkTimeIf(targetPureWasm) {
val mantissaLong = Long.parseUnsignedLong(truncatedMantissaStr, 16)
// convert unsigned long to double
(mantissaLong >>> 32).toDouble * (1L << 32) + (mantissaLong & 0xffffffffL).toDouble
} {
nativeJSParseInt(truncatedMantissaStr, 16)
}

and below, for the computation of binaryExp (which does not show up in the diff so I can't comment on it directly):

    val binaryExp = linkTimeIf(targetPureWasm) {
      if (binaryExpStr.length() > 11) {
        if (binaryExpStr.charAt(0) = '-') Int.MinValue
        else Int.MaxValue
      } else {
        val binaryExpLong = Long.parseLong(binaryExpStr)
        if (binaryExpLong > Int.MaxValue.toLong) Int.MaxValue
        else if (binaryExpLong < Int.MinValue.toLong) Int.MinValue
        else binaryExpLong.toInt
      }
    } {
      val binaryExpDouble = nativeParseInt(binaryExpStr, 10)
      binaryExpDouble.toInt // caps to [MinValue, MaxValue]
    }

package java.lang

import java.lang.constant.{Constable, ConstantDesc}
import java.math.BigInteger
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider importing that only in the relevant methods, since it is a large dependency that we shouldn't use lightly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input mantissa for Bellerophon still needs to be parsed as a BigInteger.
Other dependencies should be eliminated once Math.scalb implementation is merged.

val absStr = Integer.toUnsignedString(digits(0))
// TODO: Integer.toUnsignedString hasn't yet implemented in Wasm
val absStr = linkTimeIf(targetPureWasm) {
java.lang.Long.toString(Integer.toUnsignedLong(digits(0)))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could push that one step further into Integer.toUnsignedString.

testFull("-87654.321", -87654.321)
testFull("+.3f", 0.3)

// Hex notation, with exactly the output of toHexString()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lost comment here :)

@tanishiking
Copy link
Author

@sjrd Thank you for comments! Once scala-js#5251 is merged, I'm planning to rebase on it.
Could you take a quick look especially on hex parsing? (we might want to do a serious code review on Bellorophon implementation when we upstream it?)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants