Latest Post
Implementing Floating Point Numbers
For whatever reason, I wanted to figure out how floating point numbers work, at least according to folks who decided on IEEE 754.
From what I found for 32-bit floating numbers, there is a specific set of rules applied to each bit.
Going from left to right, using 0-indexed numbering system:
- bit 0 decides the sign of the number, 1 is negative, 0 is positive
Ok I think this is not too bad to understand.
- bit 1 through 8 decide the exponent, with a bias of 127. I had to look up what this means.
Turns out “bias” means the resulting number is added 127. This way the system doesn’t have to parse a bit to figure out the sign of then exponent. We just have to subtract 127 from this number. No problem.
- bit 9 to 31 represent the “significand”. I think this is the actual number itself without a decimal point. This number has an implicit leading 1. IEEE folks removed the 1 to save a bit because in binary scientific notation, there is always a leading 1.
With this understanding, I started to build a function that would convert a string of binary numbers to an actual Float. Or Float32, in Swift, since Float is a type alias for Float32.
public func convert(_ input: String) -> Float32 {
guard input.count > 0 else { return 0 }
let arr = input.map(char) // `char(:)` turns `Character`s into `Int`s by subtracting 48 from the character's ASCII value
let sign = arr[0] == 1 ? -1 : 1
let exponent = binaryToDecimal(input: Array(arr[1...8])) - 127
let significand = binaryToDecimal(input: [1] + Array(arr[9...]))
var expandedBase = 1
for _ in 0 ..< exponent {
expandedBase *= 2
}
return Float32(sign * expandedBase * significand)
}
I spend some time figuring out how to convert binary into decimal. Turns out it’s really easy: just keep multiplying by 2 and add the current power of 2 when you see a 1.
func binaryToDecimal(input: [Int]) -> Int {
var factor = 1
var result = 0
for i in input.reversed() {
if i == 1 {
result += factor
}
factor *= 2
}
return result
}
At this point I asked AI to generate a bunch of test cases for my convert function. See full test suite I used here.
Turns out the program keeps crashing at expandedBase *= 2 line. The error is arithmetic overflow. When I moused over the number in Xcode, it shows an enormous number (obviously).
So turns out I need to handle the negative exponent case. I asked AI to give a hint. AI says I can use powf(2.0, Float(exponent)). I’ve not used this function, and interestingly it’s from Foundation. Specifically it’s from Kernel. So sounds like a C function.
Okay! At least expandedBase is not overflowing anymore. But darn, the return statement is overflowing now.
Okay! After consulting AI, turns out we can’t use binaryToDecimal for the significand, because it needs to go the other way: from left to right, starting with the digit after the decimal point.
AI suggested an implementation which I didn’t look at, because what’s the fun when you’re handed the solution. One must struggle through self discovery and write horrible code first, try to optimize it, then shall be rewarded with the optimized version.
Here is my version:
public func binaryToDecimalLessThanOne(_ input: [Int]) -> Double {
var result: Double = 0
var currentNegativePowerOfTwo = 0.5
for i in input {
if i == 1 {
result += currentNegativePowerOfTwo
}
currentNegativePowerOfTwo /= 2
}
return result
}
Turns out it passed the unit tests I wrote. Moving on.
Here is what I have so far:
public func convert(_ input: String) -> Float32 {
guard input.count > 0 else { return 0 }
let arr = input.map(char)
let sign = arr[0] == 1 ? -1 : 1
let exponent = binaryToDecimal(input: Array(arr[1...8])) - 127
let significand = binaryToDecimalLessThanOne([1] + Array(arr[9...]))
let expandedBase = powf(2.0, Float(exponent))
return Float32(Double(sign) * Double(expandedBase) * significand)
}
This failed every single unit test (without crashing though!) AI has generated for me. Minus edge cases such as not enough bits in the input.
Ok, after consulting with AI, turns out the leading 1 in let significand = ... would turn into 0.5, which is wrong. We need to handle both cases where we either have an implicit 1 or not.
public func binaryToDecimalLessThanOne(_ input: [Int], implicitBit: Int = 0) -> Double {
var result: Double = Double(implicitBit)
var currentNegativePowerOfTwo: Double = 1
for i in input {
currentNegativePowerOfTwo /= 2
if i == 1 {
result += currentNegativePowerOfTwo
}
}
return result
}
Without implicit bit:
iteration 0: result = 0, currentNegativePowerOfTwo = 1, result = 0.5 (2^-1)
iteration 1: result = 0.5, currentNegativePowerOfTwo = 0.5, result = 0.75 (2^-1 + 2^-2)
With implicit bit:
iteration 0: result = 1, currentNegativePowerOfTwo = 1, result = 1.5 (2^0 + 2^-1)
iteration 1: result = 1.5, currentNegativePowerOfTwo = 0.5, result = 0.75 (2^0 + 2^-1 + 2^-2)
Two more issues. 1. We need to ensure the input has the correct 32-bits. 2. We need to handle extremes such as 0, infinities, and NaN.
Issue 1. Just add a guard, an input that’s not 32-bits we return 0, any character that’s not 1 or 0 is rejected and we return 0.
Issue 2. We need to think about this a bit more.
For the zero case, all bits should be 0s. Right now the code will treat an all-zero exponent as -127, which is wrong. we need to check if significand is zero, and exponent is zero, then we return 0 or -0. Apparently signed zero is a thing, even as part of IEEE 754.
For the infinity cases, all exponent bits should be 1s, significand are all 0. We check those special cases and return the right infinities.
For the NaN case, exponents are all 1s, and significand bits have at least one 1. We check those special cases and return the Nan.
Turns out the infinity cases are already handled. I’ve added the zero cases and NaN cases.
public func convert(_ input: String) -> Float32 {
guard input.count == 32 else { return 0 }
guard CharacterSet(charactersIn: input).isSubset(of: CharacterSet(charactersIn: "10")) else { return 0 }
let arr = input.map(char)
let sign = arr[0] == 1 ? -1 : 1
let exponent = binaryToDecimal(input: Array(arr[1...8])) - 127
let significand = binaryToDecimalLessThanOne([1] + Array(arr[9...]))
let expandedBase = powf(2.0, Float(exponent))
switch (allSame(arr[1...8]), allSame(arr[9...])) {
case (0, 0):
return sign == -1 ? -0.0 : 0.0 // must add the `.0` to preserve the sign. Putting -0 or 0 is accepted by the compiler, but the sign disappears.
case (1, 0):
return sign == -1 ? -Float.infinity : Float.infinity
case (1, _):
for i in arr[9...] {
if i == 1 {
return .nan
}
}
default:
break
}
return Float32(Double(sign) * Double(expandedBase) * significand)
}
The allSame function. It return nil if any bit is different from the first bit, otherwise return the first bit. This function ensures we have an array of the same element.
func allSame(_ arr: ArraySlice<Int>) -> Int? {
let first = arr.first // it's important to not use `arr[0]` because the indices aren't guaranteed to start with 0.
for element in arr {
if element != first {
return nil
}
}
return first
}
I used ArraySlice because I thought maybe we can be a bit more frugal. Performance wise it probably doesn’t make any noticeable difference.
At this point there are 2 more failed unit tests.
- Input of Float.leastNonzeroMagnitude should get it back. Now it’s not.
- Input of Float.pi should get it back. Now it’s not.
After consulting AI for issue 1, interestingly I found that Float.leastNonzeroMagnitude is called the smallest “subnormal” number, which means it has an all zero exponent, and a significand that ends in ...001.
Subnormal numbers don’t have a leading 1, and their exponent is exactly 2^-126.
We need to explicitly handle subnormal numbers.
For issue number 2, I asked another AI model. Turns out the test itself was flawed.
The test was this.
let piBits = "0" + "10000000" + "10010010000111111011011"
#expect(convert(piBits) == Float.pi)
It does not equal to Float.pi, which is the following.
String(Float.pi.bitPattern, radix: 2)
// "1000000010010010000111111011010"
Notice that by printing the Float.pi bit pattern, it omits the leading 0. Also these two bit patterns differ by 1 at the end.
To be fair, the test was a bit more accurate, in my opinion.
String(Float.pi) // 3.1415925
convert("01000000010010010000111111011011") // 3.1415927
I’ve updated the test cases to use Float.pi.
That’s it, we passed all of our unit tests. We now have a working function that converts a string of bits into a Float32, and conformant to the IEEE 754!