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

Add checked math to FixedDecimals; default to overflow behavior #85

Merged
merged 21 commits into from
Dec 19, 2023

Conversation

NHDaly
Copy link
Member

@NHDaly NHDaly commented Dec 7, 2023

Description

Following the behavior of operations on Integers, FD operations will now overflow by default, and can be used in checked_* checked-math operations to get OverflowErrors on overflow.

Fixes #12.

Decisions

  • FD arithmetic operations wrap by default on overflow.
  • Except for division operations, div, /, fld, fld1, cld, rem, mod, which all throw on overflow.
  • checked_* operations support FD, Int combinations, just like how checked_add can do Int8 + Int64.
  • We introduced checked_rdiv for /.

Questions (now closed)

  • What should the behavior be for truncating division?
    • e.g. Should we allow FD{Int8,2}(1) ÷ FD{Int8,2}(0.5) to overflow?
      • (100 ÷ 50 =2. 2 * 100 = 200. 200 doesn't fit in Int8.)
      • In my current implementation, this overflows to FixedDecimal{Int8,2}(-0.56). Are we okay with that?
    • Base.div() already throws for Integers, for divide-by-zero and typemin(Int) ÷ -1, so we could consider letting it throw in these cases too. But it seems weird for all the other operations to overflow by default but for division not to. So I'm inclined to let it overflow/underflow as well.
  • Should the checked_* operations support FD, non-FD pairs? Like checked_mul(FD{Int8,2}(1), 2)?
    • We support that for regular * so i'm inclined to say yes.
  • We don't have a mechanism for checked / division... This is because the checked_* functions in Base only apply to integers, and there isn't a / for integers! :( So what should we do here?
    • We could introduce our own, like FixedPointDecimals.checked_decimal_division?
      • I'm going to try that for now... but i'm very open to other suggestions.
  • Anything else I'm missing?

@NHDaly
Copy link
Member Author

NHDaly commented Dec 7, 2023

CC: @mcmcgrath13, @Drvi

@NHDaly NHDaly mentioned this pull request Dec 7, 2023
test/FixedDecimal.jl Outdated Show resolved Hide resolved
@NHDaly NHDaly marked this pull request as ready for review December 9, 2023 01:16
@NHDaly
Copy link
Member Author

NHDaly commented Dec 9, 2023

Okay, I think this is ready for review, since I need a touch of help deciding on the last open question! :) Thanks

Comment on lines 820 to 846
@testset "division" begin
# TODO(PR): Is this the expected value?
@test typemax(T) / T(0.5) == FD2(-0.2)
@test typemin(T) / T(0.5) == FD2(0)
end

@testset "truncating division" begin
# TODO(PR): Is this the expected value?
@test typemax(T) ÷ T(0.5) == T(-0.6)
@test typemin(T) ÷ T(0.5) == T(0.6)
@test typemax(T) ÷ eps(T) == T(-1)
@test typemin(T) ÷ eps(T) == T(0)
end

@testset "fld / cld" begin
# TODO(PR): Is this the expected value?
@test fld(typemax(T), T(0.5)) == T(-0.6)
@test fld(typemin(T), T(0.5)) == T(-0.4)
@test fld(typemax(T), eps(T)) == T(-1)
@test fld(typemin(T), eps(T)) == T(0)

# TODO(PR): Is this the expected value?
@test cld(typemax(T), T(0.5)) == T(0.4)
@test cld(typemin(T), T(0.5)) == T(0.6)
@test cld(typemax(T), eps(T)) == T(-1)
@test cld(typemin(T), eps(T)) == T(0)
end
Copy link
Member Author

@NHDaly NHDaly Dec 9, 2023

Choose a reason for hiding this comment

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

(Sorry, reposting the question since I edited the tests to be FD{Int8,1} instead of FD{Int,2}):

@Drvi / @mcmcgrath13 / @omus: This is the last open question in this PR I think: What should the value of overflowing division and truncating division be?

I think that they should be the same, only differing in their rounding modes, but currently they are not.
Ideally x ÷ y would be the same as round(5 / 6, RoundToZero), which I think it currently is without overflow, but it certainly is not after overflow.
In particular, I think that trunc-divide should always return a whole-number, even if the operation overflowed?

Gosh, or maybe we should just leave all the division operators always throwing and never wrapping?? It's complicated!


What I have done so far in this PR is: trunc-divide the inner integers (so div(typemax(Int8), 5), in this case), then multiplied that by C (10), which overflows, and then I left that overflow alone:

julia> typemax(Int8) ÷ Int8(5)
25

julia> (typemax(Int8) ÷ Int8(5)) * Int8(10)
-6

But now I actually think that the right thing to do is to perform nontruncating division, and then truncate the result??
So typemax(FD{Int8,1}) ÷ FD{Int8,1}(0.5) would be 0 (since -0.2 rounds to 0) and fld would be -1?

What do you all think?

Thanks!

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, this is tricky. I find it hard to even define criteria by which I'd evaluate the different approaches, because the result of the overflowing operation is probably not useful no matter how hard one tries to define its sematics.

But if I think about overflows in multiplication / addition, here is roughtly what I expect
a) Behind the scenes, a "correct number" is produced
b) If the "correct number" is too big for the storage type, it wraps around

I'm not sure how economical is to produce the "correct number" only for it wrap around, maybe there are ways to speed things up at the cost of some UB (and maybe there is a place for unsafe_div, not sure), but I think it makes sense and provides a reasonable mental model for diagnosing weird results. So for example:

julia> div(FD{Int8,2}(0.5), FD{Int8,2}(0.33)) # (50 / 33) * 100 = 151.515... (overflows max of 127) -> round to 100 (no longer overflows) -> convert to FD (% Int8, no change)
FixedDecimal{Int8,2}(1.00)

julia> div(FD{Int8,2}(0.5), FD{Int8,2}(0.2))  # (50 / 20) * 100 = 250 (overflows max of 127) -> round to 200 (still overflows) -> convert to FD (% Int8, we get -56)
FixedDecimal{Int8,2}(-0.56)

in your example:

typemax(FD{Int8,1}) ÷ FD{Int8,1}(0.5) # (127 / 5) * 10 = 254 (overflows max of 127) -> round to 250 (still overflows) -> convert to FD (% Int8, we get -6)
FixedDecimal{Int8,1}(-0.6)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, it's so tricky! :/ I agree, i'm not even sure if overflow makes sense for truncating division.

I like your formalization, thanks. I think it's quite similar to what the code is currently doing, which is why we're getting -0.6. 👍

But the more that I think about it, i'm wondering about swapping the order of the last two operations?:

  • You wrote:
    • (127 / 5) * 10 = 254 (overflows max of 127) -> round to 250 (still overflows) -> convert to FD (% Int8, we get -6)
    • (50 / 20) * 100 = 250 (overflows max of 127) -> round to 200 (still overflows) -> convert to FD (% Int8, we get -56)
    • (50 / 33) * 100 = 151.515... (overflows max of 127) -> round to 100 (no longer overflows) -> convert to FD (% Int8, no change)
  • Whereas I think I'd like to do this:
    • (127 / 5) * 10 = 254 (overflows max of 127) -> convert to FD (% Int8, gives -2) -> round `-0.2` to `-0`.
    • (50 / 20) * 100 = 250 (overflows max of 127) -> convert to FD (% Int8, gives -6) -> round `-0.6` to `-0`.
    • (50 / 33) * 100 = 151.515... (overflows max of 127) -> convert to FD (% Int8, gives -105) -> round `-1.05` to `1.`.

This way, we preserve the invariant that div is supposed to drop the fractional part of the division. From the docs:

help?> div
search: div divrem DivideError splitdrive code_native @code_native

  div(x, y)
  ÷(x, y)

  The quotient from Euclidean (integer) division. Generally equivalent to a mathematical operation x/y without a fractional part.

  See also: cld, fld, rem, divrem.

So I think any div() implementation that can return a fractional result is wrong. It seems like it should always be safe to do Int(div(x, y))?

Copy link
Member Author

Choose a reason for hiding this comment

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

But I do wonder about just throwing in this case instead 😅

Copy link
Member Author

@NHDaly NHDaly Dec 13, 2023

Choose a reason for hiding this comment

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

I also like that I think my approach gives the same answer as / before the truncation, which is what I think I'd expect. It's just the truncating version of /, and they both overflow in the same way.

Choose a reason for hiding this comment

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

I'm not very well informed in this area, but I think I'd expect

So I think any div() implementation that can return a fractional result is wrong. It seems like it should always be safe to do Int(div(x, y))?

to be true. Especially since the docs say that div will drop the fractional part, right?

Copy link
Collaborator

@Drvi Drvi Dec 13, 2023

Choose a reason for hiding this comment

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

Hmm.
One issue here is that we are combining

  • wrap-around behavior which is an "integral" phenomenon
  • truncation to an integer which is a "fractional-number" phenomenon

The former is the limitation of the storage type and is an implementation detail for a FD. There is no precedence for floating point numbers to manifest wrap-around behavior (they just increase scale and eventually call it a day an Inf), while for fixed point... who knows? For floating point arithmetic IIUC, the mental model of "produce the correct number behind the scenes and then round to nearest representable" does capture their semantics.

Which brings me to another point which can help us decide: what is truncating division? Is it a single, atomic operation? Or a combination of two separate operations (the / and the trunc)? For a) it would make sense to wrap at the end, for b) it would make sense to do what you suggest. One argument for a) is that the user can always implement b) by composing the two operations manually (but maybe this would be confusing to users? Freedom for one user is possible confusion for the other...).

I guess the nearest sibling FixedDecimals have in this regard are Rationals, which also use integers to implement fractional numbers. They apparently recognize this is not a well defined situation an throw:

julia> Rational{Int8}(127, 1) // Rational{Int8}(1, 2)
ERROR: OverflowError: 127 * 2 overflowed for type Int8

Note that an alternative to fixed point decimals are floating point decimals (e.g. https://github.com/JuliaMath/DecFP.jl). IIUC, they could be a viable substitute for fixed point decimal and they have some advantages too:

  • They are well-defined by a IEEE standard
  • They have one fewer type param
  • Being a floating point, they're able to adapt to what your scale is (and if you are not surpassing the number of significant digits within that scale, they are precise, I think)

But...:

  • Their precision is lower than for fixed decimals (7, 16, and 34 digits for 32, 64 and 128 bits respectively)

Copy link
Member Author

Choose a reason for hiding this comment

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

OKAY, following the behavior of Rational, I have changed this PR to always throw OverflowError on overflow during division operations.

With this, the tests are passing, and i think this PR is finally ready to go! Thanks for the great discussion.

I'll leave this thread open here for anyone who reads the PR in the future.

Copy link
Collaborator

@Drvi Drvi left a comment

Choose a reason for hiding this comment

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

I think this looks pretty good, but I still need to spend more time on it.

One meta comment about "inexact" errors, I think they can happen also as a result of multiplication or division and it might be desireable for the user to make those throw as well. E.g. FD{Int8,2}(1.1) * FD{Int8,2}(1.01) = FD{Int8,2}(1.11) while the correct result 1.111. It might be that the user doesn't care for the results to be more precise than 2 dec places, but there is no straightforward way to check... not sure how big of an issue this is for financial applications.

In the python decimal module one can setup a check a "trap" for inexact results e.g:

>>> import decimal
>>> r = decimal.Decimal("1.1") * decimal.Decimal("1.11111111111111111111111111111111111111111111111111111")
>>> r
Decimal('1.222222222222222222222222222')


>>> decimal.getcontext().traps[decimal.Inexact] = True
>>> r = decimal.Decimal("1.1") * decimal.Decimal("1.11111111111111111111111111111111111111111111111111111")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Inexact: [<class 'decimal.Inexact'>]

src/FixedPointDecimals.jl Show resolved Hide resolved
src/FixedPointDecimals.jl Outdated Show resolved Hide resolved
src/FixedPointDecimals.jl Show resolved Hide resolved
Comment on lines 820 to 846
@testset "division" begin
# TODO(PR): Is this the expected value?
@test typemax(T) / T(0.5) == FD2(-0.2)
@test typemin(T) / T(0.5) == FD2(0)
end

@testset "truncating division" begin
# TODO(PR): Is this the expected value?
@test typemax(T) ÷ T(0.5) == T(-0.6)
@test typemin(T) ÷ T(0.5) == T(0.6)
@test typemax(T) ÷ eps(T) == T(-1)
@test typemin(T) ÷ eps(T) == T(0)
end

@testset "fld / cld" begin
# TODO(PR): Is this the expected value?
@test fld(typemax(T), T(0.5)) == T(-0.6)
@test fld(typemin(T), T(0.5)) == T(-0.4)
@test fld(typemax(T), eps(T)) == T(-1)
@test fld(typemin(T), eps(T)) == T(0)

# TODO(PR): Is this the expected value?
@test cld(typemax(T), T(0.5)) == T(0.4)
@test cld(typemin(T), T(0.5)) == T(0.6)
@test cld(typemax(T), eps(T)) == T(-1)
@test cld(typemin(T), eps(T)) == T(0)
end
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, this is tricky. I find it hard to even define criteria by which I'd evaluate the different approaches, because the result of the overflowing operation is probably not useful no matter how hard one tries to define its sematics.

But if I think about overflows in multiplication / addition, here is roughtly what I expect
a) Behind the scenes, a "correct number" is produced
b) If the "correct number" is too big for the storage type, it wraps around

I'm not sure how economical is to produce the "correct number" only for it wrap around, maybe there are ways to speed things up at the cost of some UB (and maybe there is a place for unsafe_div, not sure), but I think it makes sense and provides a reasonable mental model for diagnosing weird results. So for example:

julia> div(FD{Int8,2}(0.5), FD{Int8,2}(0.33)) # (50 / 33) * 100 = 151.515... (overflows max of 127) -> round to 100 (no longer overflows) -> convert to FD (% Int8, no change)
FixedDecimal{Int8,2}(1.00)

julia> div(FD{Int8,2}(0.5), FD{Int8,2}(0.2))  # (50 / 20) * 100 = 250 (overflows max of 127) -> round to 200 (still overflows) -> convert to FD (% Int8, we get -56)
FixedDecimal{Int8,2}(-0.56)

in your example:

typemax(FD{Int8,1}) ÷ FD{Int8,1}(0.5) # (127 / 5) * 10 = 254 (overflows max of 127) -> round to 250 (still overflows) -> convert to FD (% Int8, we get -6)
FixedDecimal{Int8,1}(-0.6)

Comment on lines 190 to 191
Base.:*(x::Integer, y::FD{T, f}) where {T, f} = reinterpret(FD{T, f}, *(promote(x, y.i)...))
Base.:*(x::FD{T, f}, y::Integer) where {T, f} = reinterpret(FD{T, f}, *(promote(x.i, y)...))
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think when the Integer is a BigInt, and T is not, the promote would allocate another bigint which might not be needed because there are usually specialized methods for BigInt x Integer that avoid the allocation.

Copy link
Member Author

Choose a reason for hiding this comment

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

So maybe i should just leave it without the promote() and let * do the promotion internally if needed? I'll try that

Base.checked_rem(x::FD, y::FD) = Base.checked_rem(promote(x, y)...)
Base.checked_mod(x::FD, y::FD) = Base.checked_mod(promote(x, y)...)

Base.checked_add(x::FD, y) = Base.checked_add(promote(x, y)...)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Also here would be good to audit if promote is a good idea when one of the inputs is a BigInt

Copy link
Member Author

Choose a reason for hiding this comment

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

Currently, I think that this package just relies on promotion to do arithmetic on BigInts, which I agree is causing unnecessary allocs:

julia> @which FD{BigInt,2}(2) + 2
+(x::Number, y::Number)
     @ Base promotion.jl:410

julia> @code_typed FD{BigInt,2}(2) + 2
CodeInfo(
1%1 = invoke Base.GMP.MPZ.set_si(10::Int64)::BigInt%2 = invoke Base.GMP.bigint_pow(%1::BigInt, 2::Int64)::BigInt%3 = invoke Base.GMP.MPZ.mul_si(%2::BigInt, y::Int64)::BigInt%4 = Base.getfield(x, :i)::BigInt%5 = invoke Base.GMP.MPZ.add(%4::BigInt, %3::BigInt)::BigInt%6 = %new(FixedDecimal{BigInt, 2}, %5)::FixedDecimal{BigInt, 2}
└──      return %6
) => FixedDecimal{BigInt, 2}

julia> @code_typed optimize=false FD{BigInt,2}(2) + 2
CodeInfo(
1%1 = Base.:+::Core.Const(+)
│   %2 = Base.promote(x, y)::Tuple{FixedDecimal{BigInt, 2}, FixedDecimal{BigInt, 2}}%3 = Core._apply_iterate(Base.iterate, %1, %2)::FixedDecimal{BigInt, 2}
└──      return %3
) => FixedDecimal{BigInt, 2}

I'm just going to file this as a future improvement and move on, since I feel bad about how long this PR has lagged for.

Copy link
Member Author

Choose a reason for hiding this comment

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

Filed: #87.

@NHDaly
Copy link
Member Author

NHDaly commented Dec 13, 2023

Interesting notes on inexact! I think this is the same thing that @davidwzhao was referring to on slack as well... is that right david?

i don't know if our current API will support opting-in and opting-out on those errors? Does checked_mul throw for the InexactErrors in the current implementation in this PR? should it?

I do kind of think truncating to 1.11 for FD{2} is the expected behavior for this package 🤔

@davidwzhao
Copy link

@NHDaly i think this is the same, yes! I think it's a bit more complicated than the truncating case though, e.g., the following also throws an InexactError even though the decimal precision is correct:

julia> FixedDecimal{Int8, 2}(1.0) / FixedDecimal{Int8, 2}(0.5)
ERROR: InexactError: trunc(Int8, 200)

@Drvi
Copy link
Collaborator

Drvi commented Dec 13, 2023

Re InexactErrors: I don't want to derail this PR further, I knew this was going to be a rabbit hole:) But it would be nice, if there was a global switch that could change all the _round_to_nearest calls to an equivalent that uses the RoundThrows for checked operations. We already support this rounding mode in parsing -- i.e. anytime there is anything to round (the remainder) but a series of zeros, we'd throw with this mode. Then the user can have "really" checked operations as an opt-in option.

NHDaly and others added 4 commits December 18, 2023 16:55
Co-authored-by: Tomáš Drvoštěp <tomas.drvostep@gmail.com>
This allows BigInt * Int to avoid allocating another BigInt.
Copy link
Member Author

@NHDaly NHDaly left a comment

Choose a reason for hiding this comment

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

Okay this is ready for a final review! Please take another look!!

Copy link

@davidwzhao davidwzhao left a comment

Choose a reason for hiding this comment

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

Looks good to me, thank you!! I'm not super familiar with this kind of code though, so it may be a good idea to get a second review

@NHDaly
Copy link
Member Author

NHDaly commented Dec 19, 2023

Unfortunately, @Drvi has just gone out for holiday leave..
Lemme see if I can find another reviewer. Otherwise, based on his previous review, your LGTM, and our test suite, I think I would feel good to merge. Thanks!

@NHDaly
Copy link
Member Author

NHDaly commented Dec 19, 2023

Had a chat internally and we feel good about merging. Thanks for the reviews, @davidwzhao and @Drvi! :) Merging now. thanks!

@NHDaly NHDaly merged commit e769be4 into master Dec 19, 2023
14 checks passed
@NHDaly NHDaly deleted the nhd-checked-math branch December 19, 2023 16:49
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.

Handling Overflow
3 participants