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

beef up testing and debuggability #73

Merged
merged 17 commits into from
Aug 9, 2024
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
4 changes: 2 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
steps:

- name: Set up Go 1.13
- name: Set up Go 1.20
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.20
id: go

- name: Check out code into the Go module directory
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/reviewdog.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

name: reviewdog
on: [pull_request]
jobs:
Expand All @@ -7,9 +6,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v1
uses: actions/checkout@v3
- name: golangci-lint
uses: reviewdog/action-golangci-lint@v1
uses: reviewdog/action-golangci-lint@v2
with:
github_token: ${{ secrets.github_token }}
golangci_lint_flags: "--enable-all --exclude-use-default=false"
78 changes: 61 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
# decimal

This library implements fixed-precision decimal numbers based on IEEE 754R standard;
<https://ieeexplore.ieee.org/document/4674342>
<https://ieeexplore.ieee.org/document/4674342>.
More info can be found at:
<http://speleotrove.com/decimal/>

# Features
## Features

- Decimal64, partial implementation of the ieee-754R standard
- Half up and half even rounding
- Up to 3 times faster than arbitrary precision decimal libraries in Go

# Goals
- To implement 128 bit decimal
## Goals

# Installation and use
- Implement 128 bit decimal
- Implement as much of <https://speleotrove.com/decimal/decarith.pdf> as possible.

Run `go get github.com/anz-bank/decimal`
## Installation and use

Run `go get github.com/anz-bank/decimal`

```go
package main
Expand All @@ -35,22 +37,64 @@ func main() {

fmt.Println(a, b, c, d)
}

```

# Docs
## Usage notes

### Formatting

`Decimal64` provides a range of ways to present numbers, both for human and
machine consumption.

`Decimal64` implements the following conventional interfaces:

- `fmt`: `Formatter`, `Scanner` and `Stringer`
- It currently supports specifying a precision argument, e.g., `%.10f` for the `f` and `F` verbs, while support `g` and `G` is [on the board](https://github.com/anz-bank/decimal/issues/72), as is [support for a width argument](https://github.com/anz-bank/decimal/issues/72).
- `json`: `Marshaller` and `Unmarshaller`
- `encoding`: `BinaryMarshaler`, `BinaryUnmarshaler`, `TextMarshaler` and `TextUnmarshaler`
- `encoging/gob`: `GobEncoder` and `GobDecoder`
- thus enabling the use of decimal numbers in the `fmt.Printf` family.
- `Decimal64.Append` formats straight into a `[]byte` buffer.
- `Decimal64.Text` formats in the same way, but returns a `string`.

### Debugging

tl;dr: Use the `decimal_debug` compiler tag during debugging to greatly ease
runtime inspection of `Decimal64` values.

Debugging with the `decimal` package can be challeging because a `Decimal64`
number is encoded in a `uint64` and the values it holds are inscrutable even to
the trained eye. For example, the number one `decimal.One64` is represented
internally as the number `3450757314565799936` (`2fe38d7ea4c68000` in
hexadecimal).

To ease debugging, `Decimal64` holds an optional `debugInfo` structure that
contains a string representation and unpacked components of the `uint64`
representation for every `Decimal64` value.

This feature is enabled through the `decimal_debug` compiler tag. This is done
at compile time instead of through runtime flags because having the structure
there even if not used would double the size of each number and greatly increase
the cost of using it. The size and runtime cost of this feature is zero when the
compiler tag is not present.

## Docs

<https://godoc.org/github.com/anz-bank/decimal>

# Why decimal
## Why decimal?

Binary floating point numbers are fundamentally flawed when it comes to representing exact numbers in a decimal world. Just like 1/3 can't be represented in base 10 (it evaluates to 0.3333333333 repeating), 1/10 can't be represented in binary.
The solution is to use a decimal floating point number.
Binary floating point numbers (often just called floating point numbers) are usually in the form
`Sign * Significand * 2 ^ exp`
and decimal floating point numbers change this to
`Sign * Significand * 10 ^ exp`
This eliminates the decimal fraction problem, as the base is in 10.
Binary floating point numbers (often just called floating point numbers) are usually in the form `Sign * Significand * 2 ^ exp` and decimal floating point numbers change this to `Sign * Significand * 10 ^ exp`.
The use of base 10 eliminates the decimal fraction problem.

## Why fixed precision?

Most implementations of a decimal floating point datatype implement an *arbitrary precision* type, which often uses an underlying big int. This gives flexibility in that as the number grows, the number of bits assigned to the number grows ( hence the term "arbitrary precision").
This library is different. It uses a 64-bit decimal datatype as specified in the IEEE-754R standard. This sacrifices the ability to represent arbitrarily large numbers, but is much faster than arbitrary precision libraries.
There are two main reasons for this:

# Why fixed precision
Most implementations of a decimal floating point datatype implement an 'arbitrary precision' type, which often uses an underlying big int. This gives flexibility in that as the number grows, the number of bits assigned to the number grows ( and thus 'arbitrary precision').
This library is different as it specifies a 64 bit decimal datatype as specified in the ieee-754R standard. This gives the sacrifice of being able to represent arbitrarily large numbers, but is faster than other arbitrary precision libraries.
1. The fixed-size data type is a `uint64` under the hood and never requires
heap allocation.
2. All the algorithms can hard-code assumptions about the number of bits to work with. In fact, many of the operations work on the entire number as a single unit using 64-bit integer arithmetic and, on the occasions it needs to use more, 128 bits always suffices.
33 changes: 16 additions & 17 deletions decimal64.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import (
"math/bits"
)

type flavor int
type roundingMode int
type discardedDigit int

const (
Expand All @@ -16,13 +14,17 @@ const (
gt5
)

type flavor int

const (
flNormal flavor = iota
flNormal flavor = 1 << iota
flInf
flQNaN
flSNaN
)

type roundingMode int

const (
roundHalfUp roundingMode = iota
roundHalfEven
Expand All @@ -33,16 +35,8 @@ const (
// It uses the binary representation method.
// Decimal64 is intentionally a struct to ensure users don't accidentally cast it to uint64
type Decimal64 struct {
bits uint64
}

// decParts stores the constituting decParts of a decimal64.
type decParts struct {
fl flavor
sign int
exp int
significand uint128T
original Decimal64
bits uint64
debugInfo //nolint:unused
}

// Context64 stores the rounding type for arithmetic operations.
Expand Down Expand Up @@ -166,7 +160,7 @@ func roundStatus(significand uint64, exp int, targetExp int) discardedDigit {
return gt5
}

//func from stack overflow: samgak
// func from stack overflow: samgak
// TODO: make this more efficent
func countTrailingZeros(n uint64) int {
zeros := 0
Expand Down Expand Up @@ -198,12 +192,12 @@ func newFromParts(sign int, exp int, significand uint64) Decimal64 {
if significand < 0x8<<50 {
// s EEeeeeeeee (0)ttt tttttttttt tttttttttt tttttttttt tttttttttt tttttttttt
// EE ∈ {00, 01, 10}
return Decimal64{s | uint64(exp+expOffset)<<(63-10) | significand}
return Decimal64{bits: s | uint64(exp+expOffset)<<(63-10) | significand}.debug()
}
// s 11EEeeeeeeee (100)t tttttttttt tttttttttt tttttttttt tttttttttt tttttttttt
// EE ∈ {00, 01, 10}
significand &= 0x8<<50 - 1
return Decimal64{s | uint64(0xc00|(exp+expOffset))<<(63-12) | significand}
return Decimal64{bits: s | uint64(0xc00|(exp+expOffset))<<(63-12) | significand}.debug()
}

func (d Decimal64) parts() (fl flavor, sign int, exp int, significand uint64) {
Expand Down Expand Up @@ -361,6 +355,11 @@ func (d Decimal64) IsInt() bool {
}
}

// quiet returns a quiet form of d, which must be a NaN.
func (d Decimal64) quiet() Decimal64 {
return Decimal64{bits: d.bits &^ (2 << 56)}.debug()
}

// IsSubnormal returns true iff d is a subnormal.
func (d Decimal64) IsSubnormal() bool {
flav, _, _, significand := d.parts()
Expand Down Expand Up @@ -408,7 +407,7 @@ func (d Decimal64) Class() string {

}

//numDecimalDigits returns the magnitude (number of digits) of a uint64.
// numDecimalDigits returns the magnitude (number of digits) of a uint64.
func numDecimalDigits(n uint64) int {
numBits := 64 - bits.LeadingZeros64(n)
numDigits := numBits * 3 / 10
Expand Down
33 changes: 33 additions & 0 deletions decimal64_debug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//go:build decimal_debug
// +build decimal_debug

package decimal

// Decimal64 represents an IEEE 754 64-bit floating point decimal number.
// It uses the binary representation method.
// Decimal64 is intentionally a struct to ensure users don't accidentally cast it to uint64
type debugInfo struct {
s string
fl flavor
sign int
exp int
significand uint64
}

func (d Decimal64) debug() Decimal64 {
s := d.s
if s == "" {
s = d.String()
}
fl, sign, exp, significand := d.parts()
return Decimal64{
bits: d.bits,
debugInfo: debugInfo{
s: s,
fl: fl,
sign: sign,
exp: exp,
significand: significand,
},
}
}
13 changes: 13 additions & 0 deletions decimal64_ndebug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !decimal_debug
// +build !decimal_debug

package decimal

// Decimal64 represents an IEEE 754 64-bit floating point decimal number.
// It uses the binary representation method.
// Decimal64 is intentionally a struct to ensure users don't accidentally cast it to uint64
type debugInfo struct{} //nolint:unused

func (d Decimal64) debug() Decimal64 {
return d
}
9 changes: 3 additions & 6 deletions decimal64_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ func TestDecimal64Float64(t *testing.T) {

require.Equal(-1.0, NegOne64.Float64())
require.Equal(0.0, Zero64.Float64())
require.Equal(-0.0, NegZero64.Float64())
require.Equal(1.0, One64.Float64())
require.Equal(10.0, New64FromInt64(10).Float64())

Expand Down Expand Up @@ -70,12 +69,10 @@ func TestDecimal64Int64(t *testing.T) {
require.EqualValues(int64(math.MaxInt64), Infinity64.Int64())
require.EqualValues(int64(math.MinInt64), NegInfinity64.Int64())

googol, err := Parse64("1e100")
require.NoError(err)
googol := MustParse64("1e100")
require.EqualValues(int64(math.MaxInt64), googol.Int64())

long, err := Parse64("91234567890123456789e20")
require.NoError(err)
long := MustParse64("91234567890123456789e20")
require.EqualValues(int64(math.MaxInt64), long.Int64())
}

Expand Down Expand Up @@ -154,7 +151,7 @@ func TestDecimal64isZero(t *testing.T) {
require := require.New(t)

require.Equal(true, Zero64.IsZero())
require.Equal(true, Decimal64{Zero64.bits | neg64}.IsZero())
require.Equal(true, Decimal64{bits: Zero64.bits | neg64}.IsZero())
require.Equal(false, One64.IsZero())
}

Expand Down
20 changes: 12 additions & 8 deletions decimal64const.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,29 @@ var One64 = newFromParts(0, -15, decimal64Base)
var NegOne64 = newFromParts(1, -15, decimal64Base)

// Infinity64 represents ∞ as a Decimal64.
var Infinity64 = Decimal64{inf64}
var Infinity64 = Decimal64{bits: inf64}.debug()

// NegInfinity64 represents -∞ as a Decimal64.
var NegInfinity64 = Decimal64{neg64 | inf64}
var NegInfinity64 = Decimal64{bits: neg64 | inf64}.debug()

// QNaN64 represents a quiet NaN as a Decimal64.
var QNaN64 = Decimal64{0x7c << 56}
var QNaN64 = Decimal64{bits: 0x7c << 56}.debug()

// SNaN64 represents a signalling NaN as a Decimal64.
var SNaN64 = Decimal64{0x7e << 56}
var SNaN64 = Decimal64{bits: 0x7e << 56}.debug()

// Pi64 represents π.
var Pi64 = newFromParts(0, -15, 3141592653589793)
var Pi64 = newFromParts(0, -15, 3_141592653589793)

// E64 represents e (lim[n→∞](1+1/n)ⁿ).
var E64 = newFromParts(0, -15, 2718281828459045)
var E64 = newFromParts(0, -15, 2_718281828459045)

var neg64 uint64 = 0x80 << 56
var inf64 uint64 = 0x78 << 56

// 1E15
const decimal64Base uint64 = 1000 * 1000 * 1000 * 1000 * 1000
const decimal64Base uint64 = 1_000_000_000_000_000
const decimal64Digits = 16

// maxSig is the maximum significand possible that fits in 16 decimal places.
const maxSig = 10*decimal64Base - 1
Expand All @@ -48,9 +49,12 @@ var Max64 = newFromParts(0, expMax, maxSig)
// NegMax64 is the minimum finite number (most negative) possible with Decimal64 (negative).
var NegMax64 = newFromParts(1, expMax, maxSig)

// Min64 is the smallest number that is subnormal possible with Decimal64.
// Min64 is the closest positive number to zero.
var Min64 = newFromParts(0, -398, 1)

// Min64 is the closest negative number to zero.
var NegMin64 = newFromParts(1, -398, 1)

var zeroes = []Decimal64{Zero64, NegZero64}
var infinities = []Decimal64{Infinity64, NegInfinity64}

Expand Down
13 changes: 12 additions & 1 deletion decimal64decParts.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
package decimal

// decParts stores the constituting decParts of a decimal64.
type decParts struct {
fl flavor
sign int
exp int
significand uint128T
original Decimal64
}

// add128 adds two decParts with full precision in 128 bits of significand
func (dp *decParts) add128(ep *decParts) decParts {
dp.matchScales128(ep)
Expand Down Expand Up @@ -67,7 +76,7 @@ func (dp *decParts) isInf() bool {
}

func (dp *decParts) isNaN() bool {
return dp.fl == flQNaN || dp.fl == flSNaN
return dp.fl&(flQNaN|flSNaN) != 0
}

func (dp *decParts) isQNaN() bool {
Expand Down Expand Up @@ -123,9 +132,11 @@ func (dp *decParts) unpack(d Decimal64) {
dp.fl = flInf
case 2:
dp.fl = flQNaN
dp.significand.lo = d.bits & (1<<51 - 1) // Payload
return
case 3:
dp.fl = flSNaN
dp.significand.lo = d.bits & (1<<51 - 1) // Payload
return
}
case 12, 13, 14:
Expand Down
Loading
Loading