Flooring a number is boring.
Math.floor. Use that, and there’s no reason to think.
That was until we found out it was four times more expensive than the alternatives. Quite a rabbit hole.
Here’s what we were trying to do:
Find `c` where `c` is the closest interger less than `(a + b) / 2`.
Firstly, I’d never considered an alternative. So I was surprised to find so many. More importantly - they aren’t all the same.
Use the builtin Math.floor
There are two ways of doing this:
const midpoint = Math.floor((a + b)/2)
const midpoint = a + Math.floor((b-a)/2)
Use Bitwise Operators
Bit operators, like the name suggests, operate on the binary representations hence can be significantly faster. And, they make great shorthands.
So here’s three ways we compared:
- With Right Shift (
const midpoint = a + b >> 1
- With Unsigned Right Shift (
const midpoint = a + b >>> 1
- With Not (
const midpoint = ~~(a + b)
Why do bitwise operators work?
Shifting a bit by 1, means dividing a number by 2. Easy enough to understand. But why does this operation floor the number? That was hardly intuitive.
We went back to the docs, and here’s a clue:
The Signed Right Shift Operator ( » ) Performs a sign-filling bitwise right shift operation on the left operand by the amount specified by the right operand. The production
ShiftExpression : ShiftExpression >> AdditiveExpressionis evaluated as follows:
- Let lref be the result of evaluating ShiftExpression.
- Let lval be GetValue(lref).
- Let rref be the result of evaluating AdditiveExpression.
- Let rval be GetValue(rref).
- Let lnum be ToInt32(lval).
- Let rnum be ToUint32(rval).
- Let shiftCount be the result of masking out all but the least significant 5 bits of rnum, that is, compute rnum & 0x1F.
- Return the result of performing a sign-extending right shift of lnum by shiftCount bits. The most significant bit is propagated. The result is a signed 32-bit integer.
See it yet?
Let lnum be ToInt32(lval).
The abstract operation ToInt32 converts its argument to one of 2^32 integer values in the range −2^31 through 2^31−1, inclusive. This abstract operation functions as follows:
Let number be the result of calling ToNumber on the input argument. If number is NaN, +0, −0, +∞, or −∞, return +0.
Let posInt be sign(number) * floor(abs(number)).
Let int32bit be posInt modulo 2^32; that is, a finite integer value k of Number type with positive sign and less than 2^32 in magnitude such that the mathematical difference of posInt and k is mathematically an integer multiple of 232.
If int32bit is greater than or equal to 2^31, return int32bit − 2^32, otherwise return int32bit.
posInt modulo 2^32
Let int be the mathematical value that is the same sign as number and whose magnitude is floor(abs(ℝ(number))). the 32-bit conversion floors the number internally before performing the operation
Hence, all bitwise operators first convert the number into a 32-bit representation, unless it is explicitly a
This introduces the following caveats with working with bitwise operators:
- With the signed right shift, it only works till 2 ** 31 - 1
e.g 2147483648 >> 1 => -1073741824 \n However, `BigInt` with signed right shift always works 2147483650n >> 1n => 1073741825n BigInt(Number.MAX_VALUE) >> 1n => 89884656743115785407263711865852178399035283762922…072361584369088590459649940625202013092062429184n
- Unsigned right shift works till 2 ** 32 - 1, (Note: Unsigned right shift doesnt work with bigints)
4294967295 >>> 1 => 2147483647 4294967296 >>> 1 => 0
- ~~ only works for positive numbers.
Given the limitation around handling
BigInts, we also tested our own formula.
Since the context here was only to find a rounded-off midpoint, this worked.
const midpoint = (m + n) % 2 === 0 ? (m + n) / 2 : (m + n - 1) / 2;
This did reasonably well, though it wasn’t as fast as Bitwise.
But why is Math.floor slow?
The mathematical function floor(x) yields the largest integer (closest to positive infinity) that is not larger than x. NOTE: floor(x) = x−(x modulo 1).
And here’s the culprit: Modulo.
And why is modulo slow? Long story short, if you remember grade school math, division was just a way longer process than an addition or subtraction. For a more detailed explanation, check out the great Stackoverflow answers in the references!
Finally, we took all our approaches and calculated time take for each:
- Bitwise operators outperform
Math.floorby roughly 2-4 times for all sample sizes.
- Modulo sucks.
- Our custom algorithm didn’t do too badly, performing better than Math.floor.
- There is some memoization (or magic!) happening with Math.floor. The first usage of Math.floor is slower and successive usage of Math.floor get faster.