Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

I've written a full BigDecimal implementation polyfill. #82

Open
NoUJoe opened this issue Aug 10, 2023 · 2 comments
Open

I've written a full BigDecimal implementation polyfill. #82

NoUJoe opened this issue Aug 10, 2023 · 2 comments

Comments

@NoUJoe
Copy link

NoUJoe commented Aug 10, 2023

Hiya

https://github.com/NoUJoe/JSBD

This is a full implementation of BigDecimal (and not BigDecimal128)

Let me give a rundown of why I made this and then a rundown of the library.

I was working on a personal project, and needed decimal precision, specificlly add and multiply. Due to it being a personal project, I decided to take on the challenge and implement my own functions. I literally called them "MultiplyTwoStrings" and "AddTwoStrings". These functions became staples in my next few personal projects, I improved them over time too. On one project, I'm trying to sort a bottleneck, and finally decide it's time to concede and use a decimal library, because it will obviously be faster than my code. So I download some decimal libraries (I think the 2 most popular, whatever shows at the top of google/npm). I wrote a test to compare performance vs my functions (add and multiply)... Low and behold, to my utter surprise, mine were faster... I remember sitting there just sorta tilting my head thinking "huh...". I can't remember how much faster, it wasn't like ground breaking 300x or anything, but the main point being, there was no point swapping my functions out for a library.

Blah blah blah, jump forward and I've added a "DivideTwoStrings" and a couple of comparison functions. At some point, I have an epiphany, my functions take strings and return strings (if the names didn't give it away), and the string inputs need to be processed every function call. I can remove this processing if I make a class/object/whatever javascript terminology, and save even more time processing wise. Really obvious, I know... But sometimes it takes a year or so of staring at the same code for it to click...

So I decide to finally make a class, JSBI springs to mind, so I have a search and find this proposal. Thought that I'm doing this anyway, so why not implement this proposal?

And here we are, all caught up!

Ok, so, the library. It is essentially an implementation of this: https://github.com/tc39/proposal-decimal/blob/main/bigdecimal-reference.md

There are a few minor differences, I'll explain them first

  • I included a decimals instance property, which returns the amount of decimal places the number has.
    (1.12345m).decimals === 5

  • roundingOptions is different from the proposal, this is because there is prescedent for this in Javascript. See https://developer.mozilla.org/en-US/docs/Web/Javascript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
    The roundingOptions interface is
    { roundingMode, roundingIncrement, maxFractionDigits }
    See the MDN link which has all the roundingMode and roundingIncrement values

  • I included a few extra operators. I did this for "fullness", I'd rather present as full of a implementation as possible, things can always be removed easily.

Other than that, it's to the T. I'll paste all methods/properties at the end, I'm not going to explain them all. The only two that need explaining are:

logicalNot2() and bitwiseNot2(). These are shortcuts for
!! and ~~ respectively. With logcialNot() and bitwiseNot() being the single versions.

It's thoroughly tested, I have ~8k lines of tests, across 4 files, in the "tests" folder. (and yes, I used 69.6969 A LOT in the rounding tests, a guy's allowed a little fun when testing 9 rounding modes)

Written in pure Javascript, with a complete type definition file.

I think that covers most of it. Honestly, if anyone is interested/wants to know more, you should just check out the repo. The test files are organised extremely neatly for the purpose of showcasing. The repo is set up as a node module so you should be able to:

npm i "https://github.com/NoUJoe/JSBD"

It's ESM only right now, I've done no transpiling or the likes. It's a very basic setup.

And to wrap up. I have 0 intention of releasing this myself as a package on npm. That's why I'm posting here. It's my offer back to the community. I'm going to use this current version in my projects (my original reason for starting this), but other than that, it's honestly not in my interest to release/maintain a module.

So if you're interested, let me know, and we can take it from there!

And here's the full API:

//Construction
static BigD(from: number | string | boolean | bigint | Object): JSBD;

//Instance
get decimals (): number;

toString(radix?: number): string;
toLocaleString (locale?: string | string[], options?: Intl.NumberFormatOptions): string
toFixed (digits?: number): string;
toExponential (fractionDigits?: number): string;
toPrecision (precision?: number): string;

//Round
static round (value: JSBD, roundOpts?: DecimalRoundingOptions): JSBD;

//Conversion
static toNumber(x: JSBD): number;
static toBigInt (x: JSBD): bigint;
static toString (x: JSBD): string;
static toBoolean (x: JSBD): boolean;
static toSymbol (x: JSBD): symbol;

//Arithmetic
static add(lhs: JSBD, rhs: JSBD, roundOpts?: DecimalRoundingOptions): JSBD;
static subtract(lhs: JSBD, rhs: JSBD, roundOpts?: DecimalRoundingOptions): JSBD;
static multiply(lhs: JSBD, rhs: JSBD, roundOpts?: DecimalRoundingOptions): JSBD;
static divide(lhs: JSBD, rhs: JSBD, roundOpts?: DecimalRoundingOptions): JSBD;
static remainder(lhs: JSBD, rhs: JSBD, roundOpts?: DecimalRoundingOptions): JSBD;
static exponentiate(lhs: JSBD, rhs: JSBD, roundOpts?: DecimalRoundingOptions): JSBD;

//Comparison
static lessThan(lhs: JSBD, rhs: JSBD): boolean;
static lessThanOrEqual(lhs: JSBD, rhs: JSBD): boolean;
static greaterThan(lhs: JSBD, rhs: JSBD): boolean;
static greaterThanOrEqual(lhs: JSBD, rhs: JSBD): boolean;
static equal(lhs: JSBD, rhs: JSBD): boolean;
static notEqual(lhs: JSBD, rhs: JSBD): boolean;

//Strict unary operators
static unaryMinus(rhs: JSBD): JSBD;
static logicalNot (rhs: JSBD): boolean;
static logicalNot2 (rhs: JSBD): boolean;

//Bitwise
static bitwiseNot(rhs: JSBD): JSBD;
static bitwiseNot2(rhs: JSBD): JSBD;

static bitwiseOr(lhs: JSBD, rhs: JSBD): JSBD;
static bitwiseXOr(lhs: JSBD, rhs: JSBD): JSBD;
static bitwiseAnd(lhs: JSBD, rhs: JSBD): JSBD;
static bitwiseLeftShift(lhs: JSBD, rhs: JSBD): JSBD;
static bitwiseRightShift(lhs: JSBD, rhs: JSBD): JSBD;

//Loose operators
static EQ(lhs: any, rhs: any): boolean;
static NE(lhs: any, rhs: any): boolean;
static LT(lhs: any, rhs: any): boolean;
static LE(lhs: any, rhs: any): boolean;
static GT(lhs: any, rhs: any): boolean;
static GE(lhs: any, rhs: any): boolean;
static ADD(lhs: any, rhs: any): any;
@NoUJoe
Copy link
Author

NoUJoe commented Aug 16, 2023

A follow up to my post. Two things to cover.

Why I used strings

First up, for anyone who may take a look at this, you might wonder why I used strings to store the value. There is some logic to this. I feel I should explain why.

As I mentioned in my orginal post, my AddTwoStrings and MultiplyTwoStrings functions worked with string inputs. When I first made the class, I just copied/pasted my original code, and went from there, so I was just blindly continuing to use strings. Then I finally had the realisation that I should use a BigInt. So I duplicated the code, and made a version that uses BigInts to internally store the value. This was fairly early on, I had add, subtract, multiply and divide implemented. Then I compared the two versions, and once again, to my surprise, the string version was more performant... Yep... Somehow

BigInt ("1234" + "0".repeat (20))

was more performant than

1234n * 10 ** 20

So I just continued on with the string based version. Then, a few days ago, I wondered, out of the blue, "I wonder if BigInt literal experssions are optimised, like numbers are" (constant folding). Just a completely normal, everyday thought. So I wrote a test, run it in node, and output suggested that no, BigInts are not constant folded. I was kinda surprised, so I copied the code into chrome dev tools console. The results were massively different, not only did it fold, but was between 2-3x faster on top of that... I always thought BigInts were a bit too slow, turns out they were, and have been massively improved in V8 engine.

The version of node is 18.7.0. So, somewhere between that version of node, and the latest v8 engine, BigInts were massively improved. I want to go through and replace the whole codebase using BigInts. Not only for the performance gain, but it will also improve memory useage a lot, not having strings thrown about like they are! For the time being, I'm going to leave it. If I find myself needing even more performance, or if interest in this library picks up, then I will do it!

TL;DR (i dont blame you):
NodeJS V 18.7.0 = string fast, BigInt slow
Latest V8 engine = BigInt fast, string slow

And now for thing 2... Division...

Immediately after replacing my old functions with this library in my project, I got a problem straight away. My output was now different from before. So I dug in, and yeah, it was division... And it was intermittent, it wasn't all division, just some division. Let's illustrate the problem:

The default maxFractionDigits for division is 34. And the way I implemented it means that:

1 / 10000000000000000000000000000000000 = 0.0000000000000000000000000000000001
(That's 1 with 34 zeros)

1 / 100000000000000000000000000000000000 = 0
(That's 1 with 35 zeros)

So, without overriding the maxFractionDigits, the second equation will return 0 because it has > 34 fractional digits. In my project, there's no guarantee of how small a number could be (or big). So I can't really pick a "safe" maxFractionDigits.

The reason why this wasn't a problem for me before, was because I didn't have a maxFractionDigits in my original functions, and I truncated/rounded/limited my inputs/outputs in, "my own way", we'll call it...

So, this got me thinking that divide needs to behave differently, using significant digits instead. Much like other decimal types in various languages. However, I wasn't sold on the idea of it behaving exactly like other types, where the integral part of the number would be rounded to a certain precision, getting results with trailing zeros like 12340000. The reason I don't particularly like this is because this is supposed to be a BigDecimal, arbitary in nature kinda deal.

What I've done is, I've made a branch called alternate-divide. In this branch, I've added 3 alternate divide functions:

divideSignif1, divideSignif2, divideSignif3

Each one behaves differently, and they use maxFractionDigits to mean "significant digits" in one way or another. I don't know if the spec / interface / naming / whatever needs to be refined. There would be implementation details to finialise. For now, they're just tests and meant to showcase potential behaviour.

In operator tests, there's a section that shows the differences in behaviour:

https://github.com/NoUJoe/JSBD/blob/aab3bd37665ca367eb58e2685c535a9abfd89a0c/tests/operator-test.js#L475-L504

@jessealama
Copy link
Collaborator

This is amazing work, thank you!

The current thinking for decimal is that we'd like to align with IEEE 754 fixed bit-width Decimal128 rather than with unlimited BigDecimal. I have an NPM library that you can use to experiment with these kinds of decimals.

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

No branches or pull requests

2 participants