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

Reduce overflow via common denominators #24

Merged
merged 3 commits into from
Sep 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "Ratios"
uuid = "c84ed2f1-dad5-54f0-aa8e-dbefe2724439"
authors = ["Tim Holy <[email protected]>"]
version = "0.4.1"
version = "0.4.2"

[deps]
Requires = "ae029012-a4dd-5104-9daa-d747884805df"
Expand Down
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,76 @@

[![Build Status](https://travis-ci.org/timholy/Ratios.jl.svg?branch=master)](https://travis-ci.org/timholy/Ratios.jl)

This package provides types similar to Julia's `Rational` type, which make some sacrifices but have better computational performance.
This package provides types similar to Julia's `Rational` type, which make some sacrifices but have better computational performance at the risk of greater risk of overflow.

Currently the only type provided is `SimpleRatio(num, den)` for two integers `num` and `den`.

Demo:

```julia
julia> x, y, z = SimpleRatio(1, 8), SimpleRatio(1, 4), SimpleRatio(2, 8)
(SimpleRatio{Int}(1, 8), SimpleRatio{Int}(1, 4), SimpleRatio{Int}(2, 8))

julia> x+y
SimpleRatio{Int}(12, 32)

julia> x+z
SimpleRatio{Int}(3, 8)
```

`y` and `z` both represent the rational number `1//4`, but when performing arithmetic with `x`
`z` is preferred because it has the same denominator and is less likely to overflow.

To detect overflow, [SaferIntegers.jl](https://github.com/JeffreySarnoff/SaferIntegers.jl) is recommended:

```julia
julia> using Ratios, SaferIntegers

julia> x, y = SimpleRatio{SafeInt8}(1, 20), SimpleRatio{SafeInt8}(1, 21)
(SimpleRatio{SafeInt8}(1, 20), SimpleRatio{SafeInt8}(1, 21))

julia> x + y
ERROR: OverflowError: 20 * 21 overflowed for type Int8
Stacktrace:
[...]
```

[FastRationals](https://github.com/JeffreySarnoff/FastRationals.jl) is another package with safety and performance characteristics that lies somewhere between `SimpleRatio` and `Rational`:

```julia
julia> @benchmark x + y setup=((x, y) = (SimpleRatio(rand(-20:20), rand(2:20)), SimpleRatio(rand(-20:20), rand(2:20))))
BenchmarkTools.Trial: 10000 samples with 1000 evaluations.
Range (min … max): 1.727 ns … 28.575 ns ┊ GC (min … max): 0.00% … 0.00%
Time (median): 1.739 ns ┊ GC (median): 0.00%
Time (mean ± σ): 1.753 ns ± 0.445 ns ┊ GC (mean ± σ): 0.00% ± 0.00%

▂ ▃ ▃ ▃ ▄ ▆ ▇ █ ▆ ▅ ▅ ▇ ▄ ▁
▂▁▂▁▃▁▅▁█▁█▁█▁█▁█▁█▁█▁█▁█▁█▁█▁▁█▁█▁█▁█▁▆▁▃▁▃▁▃▁▃▁▃▁▃▁▃▁▃▁▂ ▄
1.73 ns Histogram: frequency by time 1.76 ns <

Memory estimate: 0 bytes, allocs estimate: 0.

julia> @benchmark x + y setup=((x, y) = (FastRational(rand(-20:20), rand(2:20)), FastRational(rand(-20:20), rand(2:20))))
BenchmarkTools.Trial: 10000 samples with 1000 evaluations.
Range (min … max): 3.192 ns … 89.167 ns ┊ GC (min … max): 0.00% … 0.00%
Time (median): 3.215 ns ┊ GC (median): 0.00%
Time (mean ± σ): 3.307 ns ± 1.820 ns ┊ GC (mean ± σ): 0.00% ± 0.00%

▃█▆█▃▂
▄███████▅▄▃▃▃▃▃▃▃▃▃▂▂▂▂▂▂▂▂▂▂▂▁▂▂▂▂▁▂▂▂▂▂▂▁▂▂▂▂▂▂▂▂▂▁▂▂▂▂▂ ▃
3.19 ns Histogram: frequency by time 3.45 ns <

Memory estimate: 0 bytes, allocs estimate: 0.

julia> @benchmark x + y setup=((x, y) = (Rational(rand(-20:20), rand(2:20)), Rational(rand(-20:20), rand(2:20))))
BenchmarkTools.Trial: 10000 samples with 996 evaluations.
Range (min … max): 22.385 ns … 81.269 ns ┊ GC (min … max): 0.00% … 0.00%
Time (median): 32.777 ns ┊ GC (median): 0.00%
Time (mean ± σ): 33.162 ns ± 4.743 ns ┊ GC (mean ± σ): 0.00% ± 0.00%

▁ ▇ ▄▂ ▁▆▃ ▂█▃ ▁ ▇ ▁ ▁
▁▁▁▁▁▄▂▁▆▂▂█▄▃█▅▆█▇▇█████████████▅█▆▂▁█▇▁▂▁█▄▁▂▁▁▆▂▁▁▁▁▃▁▁▁ ▃
22.4 ns Histogram: frequency by time 45.8 ns <

Memory estimate: 0 bytes, allocs estimate: 0.
```
64 changes: 61 additions & 3 deletions src/Ratios.jl
Original file line number Diff line number Diff line change
@@ -1,16 +1,51 @@
"""
`Ratios` provides `SimpleRatio`, a faster variant of `Rational`.
Speed comes at the cost of greater vulnerability to overflow.

API summary:

- `r = SimpleRatio(num, den)` is equivalent to `num // den`
- `common_denominator` standardizes a collection of `SimpleRatio`s to have the same denominator,
making some arithmetic operations among them less likely to overflow.
"""
module Ratios

import Base: convert, promote_rule, *, /, +, -, ^, ==, decompose

using Requires

export SimpleRatio
export SimpleRatio, common_denominator

struct SimpleRatio{T<:Integer} <: Real
num::T
den::T
end

"""
SimpleRatio(num::Integer, den::Integer)

Construct the equivalent of the rational number `num // den`.

Operations with `SimpleRatio` are faster, but also more vulnerable to integer overflow,
than with `Rational`. Arithmetic with `SimpleRatio` does not perform any simplification of the resulting ratios.
The best defense against overflow is to use ratios with the same denominator: in such cases,
`+` and `-` will skip forming the product of denominators. See [`common_denominator`](@ref).

If overflow is a risk, consider constructing them using SaferIntegers.jl.

# Examples

```julia
julia> x, y, z = SimpleRatio(1, 8), SimpleRatio(1, 4), SimpleRatio(2, 8)
(SimpleRatio{$Int}(1, 8), SimpleRatio{$Int}(1, 4), SimpleRatio{$Int}(2, 8))

julia> x+y
SimpleRatio{$Int}(12, 32)

julia> x+z
SimpleRatio{$Int}(3, 8)
```
"""
SimpleRatio(num::Integer, den::Integer) = SimpleRatio(promote(num, den)...)

convert(::Type{BigFloat}, r::SimpleRatio{S}) where {S} = BigFloat(r.num)/r.den
Expand All @@ -32,8 +67,10 @@ Rational{T}(r::SimpleRatio{S}) where {T<:Integer, S<:Integer} = convert(T, r.num
/(x::Integer, y::SimpleRatio) = SimpleRatio(x*y.den, y.num)
+(x::Integer, y::SimpleRatio) = SimpleRatio(x*y.den + y.num, y.den)
-(x::Integer, y::SimpleRatio) = SimpleRatio(x*y.den - y.num, y.den)
+(x::SimpleRatio, y::SimpleRatio) = SimpleRatio(x.num*y.den + x.den*y.num, x.den*y.den)
-(x::SimpleRatio, y::SimpleRatio) = SimpleRatio(x.num*y.den - x.den*y.num, x.den*y.den)
+(x::SimpleRatio, y::SimpleRatio) = x.den == y.den ? SimpleRatio(x.num + y.num, x.den) :
SimpleRatio(x.num*y.den + x.den*y.num, x.den*y.den)
-(x::SimpleRatio, y::SimpleRatio) = x.den == y.den ? SimpleRatio(x.num - y.num, x.den) :
SimpleRatio(x.num*y.den - x.den*y.num, x.den*y.den)
Comment on lines +70 to +73
Copy link

Choose a reason for hiding this comment

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

The one other question I had with this implementation is if using ifelse might be advantageous over the ternary operator in order to avoid branching logic. However, I have not found a good test case to evaluate this. Obviously on a small scale, doing only one calculation is better. The question is if there is any vectorization that occurs before this change and if that vectorization is preserved by introducing this branch.

Suggested change
+(x::SimpleRatio, y::SimpleRatio) = x.den == y.den ? SimpleRatio(x.num + y.num, x.den) :
SimpleRatio(x.num*y.den + x.den*y.num, x.den*y.den)
-(x::SimpleRatio, y::SimpleRatio) = x.den == y.den ? SimpleRatio(x.num - y.num, x.den) :
SimpleRatio(x.num*y.den - x.den*y.num, x.den*y.den)
+(x::SimpleRatio, y::SimpleRatio) = ifelse(x.den == y.den, SimpleRatio(x.num + y.num, x.den),
SimpleRatio(x.num*y.den + x.den*y.num, x.den*y.den) )
-(x::SimpleRatio, y::SimpleRatio) = ifelse(x.den == y.den, SimpleRatio(x.num - y.num, x.den),
SimpleRatio(x.num*y.den - x.den*y.num, x.den*y.den) )

Copy link
Owner Author

Choose a reason for hiding this comment

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

Agreed in principle, but

julia> f(x, y) = x == y ? 1 : 1.0
f (generic function with 1 method)

julia> @code_llvm f(2, 3)
;  @ REPL[4]:1 within `f'
define { {}*, i8 } @julia_f_413([8 x i8]* noalias nocapture align 8 dereferenceable(8) %0, i64 signext %1, i64 signext %2) {
top:
; ┌ @ promotion.jl:410 within `=='
   %.not = icmp eq i64 %1, %2
; └
  %spec.select = select i1 %.not, { {}*, i8 } { {}* inttoptr (i64 140004302721120 to {}*), i8 -126 }, { {}*, i8 } { {}* inttoptr (i64 140004314812816 to {}*), i8 -127 }
  ret { {}*, i8 } %spec.select
}

select already avoids a branch and is SIMD-friendly.

Curiously, the LLVM for ifelse looks much worse:

julia> fifelse(x, y) = ifelse(x == y, 1, 1.0)
fifelse (generic function with 1 method)

julia> @code_llvm fifelse(2, 3)
;  @ REPL[6]:1 within `fifelse'
define { {}*, i8 } @julia_fifelse_435([8 x i8]* noalias nocapture align 8 dereferenceable(8) %0, i64 signext %1, i64 signext %2) {
top:
; ┌ @ promotion.jl:410 within `=='
   %.not = icmp eq i64 %1, %2
; └
  %3 = select i1 %.not, {}* inttoptr (i64 140004302721120 to {}*), {}* inttoptr (i64 140003489954752 to {}*)
  %4 = select i1 %.not, i1 icmp eq ({}* inttoptr (i64 140004302721120 to {}*), {}* null), i1 icmp eq ({}* inttoptr (i64 140003489954752 to {}*), {}* null)
  br i1 %4, label %postnull, label %nonnull

nonnull:                                          ; preds = %top
  %5 = bitcast {}* %3 to i64*
  %6 = getelementptr inbounds i64, i64* %5, i64 -1
  %7 = load atomic i64, i64* %6 unordered, align 8
  %8 = and i64 %7, -16
  %9 = inttoptr i64 %8 to {}*
  br label %postnull

postnull:                                         ; preds = %nonnull, %top
  %10 = phi {}* [ null, %top ], [ %9, %nonnull ]
  %11 = icmp eq {}* %10, inttoptr (i64 140004383587200 to {}*)
  %12 = zext i1 %11 to i8
  %13 = icmp eq {}* %10, inttoptr (i64 140004380990320 to {}*)
  %.op = or i8 %12, -128
  %14 = select i1 %13, i8 -126, i8 %.op
  %15 = insertvalue { {}*, i8 } undef, {}* %3, 0
  %16 = insertvalue { {}*, i8 } %15, i8 %14, 1
  ret { {}*, i8 } %16
}

I am puzzled, but it seems clear which one we want.

Copy link

Choose a reason for hiding this comment

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

I think it has to do with type stability.

julia> f(x, y) = x == y ? 1.0 : 2.0
f (generic function with 1 method)

julia> @code_llvm f(2,3)
;  @ REPL[23]:1 within `f`
; Function Attrs: uwtable
define double @julia_f_542(i64 signext %0, i64 signext %1) #0 {
top:
; ┌ @ promotion.jl:424 within `==`
   %.not = icmp eq i64 %0, %1
; └
  %spec.select = select i1 %.not, double 1.000000e+00, double 2.000000e+00
  ret double %spec.select
}

julia> fifelse(x, y) = ifelse(x == y, 1.0, 2.0)
fifelse (generic function with 1 method)

julia> @code_llvm fifelse(2,3)
;  @ REPL[25]:1 within `fifelse`
; Function Attrs: uwtable
define double @julia_fifelse_544(i64 signext %0, i64 signext %1) #0 {
top:
; ┌ @ promotion.jl:424 within `==`
   %.not = icmp eq i64 %0, %1
; └
  %2 = select i1 %.not, double 1.000000e+00, double 2.000000e+00
  ret double %2
}

The ternary operator seems generate better code in either case

^(x::SimpleRatio, y::Integer) = SimpleRatio(x.num^y, x.den^y)

-(x::SimpleRatio{T}) where {T<:Signed} = SimpleRatio(-x.num, x.den)
Expand Down Expand Up @@ -61,6 +98,27 @@ end

decompose(x::SimpleRatio) = x.num, 0, x.den

"""
common_denominator(x::SimpleRatio, ys::SimpleRatio...)

Return the equivalent of `(x, ys...)` but using a common denominator.
This can be useful to avoid overflow.

!!! info
This function is quite slow. In performance-sensitive contexts where the
ratios are constructed with literal constants, it is better to ensure a common
denominator at the time of original construction.
"""
function common_denominator(x::SimpleRatio, ys::SimpleRatio...)
all(y -> y.den == x.den, ys) && return (x, ys...)
cd = gcd(x.den, map(y -> y.den, ys)...) # common divisor
# product of the denominators
pd = Base.Checked.checked_mul(cd, mapreduce(z -> z.den ÷ cd, Base.Checked.checked_mul, (x, ys...)))
return map((x, ys...)) do z
SimpleRatio(z.num * (pd ÷ z.den), pd)
end
end

function __init__()
@require FixedPointNumbers = "53c48c17-4a7d-5ca2-90c5-79b7896eea93" begin
using .FixedPointNumbers: FixedPoint, Fixed, Normed, rawone
Expand Down
16 changes: 16 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ using FixedPointNumbers
@test r == 0.5
@test 0.5 == r

r3 = SimpleRatio(7,3)
@test r2+r3 === SimpleRatio(9,3)
@test r2-r3 === SimpleRatio(-5,3)

r8 = SimpleRatio{Int8}(1, 20)
@test r8 + r8 == SimpleRatio(1, 10)

@test_throws OverflowError -SimpleRatio(0x02,0x03)

@test r + SimpleRatio(0x02,0x03) == SimpleRatio(7,6)
Expand All @@ -42,4 +49,13 @@ using FixedPointNumbers
@test SimpleRatio(5,3) * -0.03Q0f7 == SimpleRatio{Int}(rationalize((5.0*(-0.03Q0f7))/3))
r = @inferred(SimpleRatio(0.75Q0f7))
@test r == 3//4 && r isa SimpleRatio{Int16}

# common_denominator
@test common_denominator(SimpleRatio(2,7), SimpleRatio(3,11), SimpleRatio(-1,5)) ===
(SimpleRatio(2*11*5,385), SimpleRatio(3*7*5,385), SimpleRatio(-1*7*11,385))
@test common_denominator(SimpleRatio(5,12), SimpleRatio(4,15), SimpleRatio(-1,9)) ===
(SimpleRatio(75,180), SimpleRatio(48,180), SimpleRatio(-20,180))
@test_throws OverflowError common_denominator(SimpleRatio{Int8}(1, 20), SimpleRatio{Int8}(2, 21))
@test common_denominator(SimpleRatio{Int8}(1, 20), SimpleRatio{Int8}(3, 20)) ===
(SimpleRatio{Int8}(1, 20), SimpleRatio{Int8}(3, 20))
end