⚙️ Small Fixed-Point Decimals (FP3Decimal)

When you have small and simple float-like numbers. Precise and Fast. Perfect for money.

Warning Watchout arithmetic operaions or wrap into a struct. Linter is not ready yet. More details in Appendix D.

codecov Go Reference Go Report Card

  • 100LOC
  • Fuzz tests
  • 100% coverage
  • JSON encoding/decoding
  • Arithmetic operators
  • As fast as integers (parsing, formatting, operations)
  • 3x faster parsing than float
  • 2x faster printing than float
  • 30x faster parsing than fmt
  • 20x faster than shopspring/decimal

var BuySP500Price = fpdecimal.FP3DecimalFromInt(9000)

input := []byte(`{"sp500": 9000.023}`)

type Stocks struct {
    SP500 fpdecimal.FP3Decimal `json:"sp500"`
}
var v Stocks
if err := json.Unmarshal(input, &v); err != nil {
    log.Fatal(err)
}

var amountToBuy fpdecimal.FP3Decimal
if v.SP500 > BuySP500Price {
    amountToBuy += v.SP500 * 2
}

fmt.Println(amountToBuy)
// Output: 18000.046

Implementation

Parsing and Printing is expensive operation and requires a lot of code. However, if you know that your numbers are always small and simple and you do not care or do not permit lots of fractions like -1234.567, then parsing and printing can be greatly simplified. Code is heavily influenced by hot-path from Go core strconv package.

Benchmarks

Parse

$ go test -bench=. -benchtime=5s -benchmem ./...
goos: darwin
goarch: arm64
pkg: github.com/nikolaydubina/fpdecimal
BenchmarkFP3DecimalFromString/small-10                      827744476             7.13 ns/op           0 B/op           0 allocs/op
BenchmarkFP3DecimalFromString/large-10                      276668296            21.79 ns/op           0 B/op           0 allocs/op
BenchmarkParseInt_strconvAtoi/small-10                     1000000000             4.71 ns/op           0 B/op           0 allocs/op
BenchmarkParseInt_strconvAtoi/large-10                      416969704            14.18 ns/op           0 B/op           0 allocs/op
BenchmarkParseInt_strconvParseInt/small/int32-10            567803484            10.56 ns/op           0 B/op           0 allocs/op
BenchmarkParseInt_strconvParseInt/small/int64-10            567515059            10.56 ns/op           0 B/op           0 allocs/op
BenchmarkParseInt_strconvParseInt/large/int64-10            221833478            27.14 ns/op           0 B/op           0 allocs/op
BenchmarkParseFloat_strconvParseFloat/small/float32-10      349272979            17.34 ns/op           0 B/op           0 allocs/op
BenchmarkParseFloat_strconvParseFloat/small/float64-10      333610484            17.82 ns/op           0 B/op           0 allocs/op
BenchmarkParseFloat_strconvParseFloat/large/float32-10      129024007            46.45 ns/op           0 B/op           0 allocs/op
BenchmarkParseFloat_strconvParseFloat/large/float64-10      128212430            46.79 ns/op           0 B/op           0 allocs/op
BenchmarkParseFloat_fmtSscanf/small-10                       20381784           293.4  ns/op          69 B/op           2 allocs/op
BenchmarkParseFloat_fmtSscanf/large-10                        9484489           629.3  ns/op          88 B/op           3 allocs/op
PASS
ok      github.com/nikolaydubina/fpdecimal    175.518s

Format

$ go test -bench=. -benchtime=5s -benchmem ./...
goos: darwin
goarch: arm64
pkg: github.com/nikolaydubina/fpdecimal
BenchmarkFP3Decimal_String/small-10                         161572239            37.1 ns/op          10 B/op           1 allocs/op
BenchmarkFP3Decimal_String/large-10                          92706448            63.5 ns/op          48 B/op           2 allocs/op
BenchmarkStringInt_strconvItoa/small-10                     729627100             8.3 ns/op           1 B/op           0 allocs/op
BenchmarkStringInt_strconvItoa/large-10                     233921521            25.6 ns/op          16 B/op           1 allocs/op
BenchmarkStringInt_strconvFormatInt/small-10                736678662             8.1 ns/op           1 B/op           0 allocs/op
BenchmarkStringFloat_strconvFormatFloat/small/float32-10     50491785           117.8 ns/op          31 B/op           2 allocs/op
BenchmarkStringFloat_strconvFormatFloat/small/float64-10     40790115           147.4 ns/op          31 B/op           2 allocs/op
BenchmarkStringFloat_strconvFormatFloat/large/float32-10     60102750            99.3 ns/op          48 B/op           2 allocs/op
BenchmarkStringFloat_strconvFormatFloat/large/float64-10     61115224            97.4 ns/op          48 B/op           2 allocs/op
BenchmarkStringFloat_fmtSprintf/small-10                     43199199           138.2 ns/op          16 B/op           2 allocs/op
BenchmarkStringFloat_fmtSprintf/large-10                     47292736           126.2 ns/op          28 B/op           2 allocs/op
PASS
ok      github.com/nikolaydubina/fpdecimal    175.518s

Future Work

  • Adding wrapper into a struct to block arithmetic operations (+benchmarks encoding, method calls overhead, long chains of calls)
  • Separate repo to benchmark other open source versions (decimals) with same benchmark tests as in this repo (do not include other modules here just for benchmarking)
  • Linter to warn about using constants in expressions containing fpdecimal type.

References

Appendix A: Comparison to other libraries

Appendix B: Benchmarking shopspring/decimal

2022-05-28

$ go test -bench=. -benchtime=5s -benchmem ./...
goos: darwin
goarch: arm64
pkg: github.com/shopspring/decimal
BenchmarkNewFromFloatWithExponent-10                        59701516          97.7 ns/op         106 B/op           4 allocs/op
BenchmarkNewFromFloat-10                                    14771503         410.3 ns/op          67 B/op           2 allocs/op
BenchmarkNewFromStringFloat-10                              16246342         375.2 ns/op         175 B/op           5 allocs/op
Benchmark_FloorFast-10                                    1000000000           2.1 ns/op           0 B/op           0 allocs/op
Benchmark_FloorRegular-10                                   53857244         106.3 ns/op         112 B/op           6 allocs/op
Benchmark_DivideOriginal-10                                        7   715322768   ns/op   737406446 B/op    30652495 allocs/op
Benchmark_DivideNew-10                                            22   262893689   ns/op   308046721 B/op    12054905 allocs/op
BenchmarkDecimal_RoundCash_Five-10                           9311530         636.5 ns/op         616 B/op          28 allocs/op
Benchmark_Cmp-10                                                  44   133191579   ns/op          24 B/op           1 allocs/op
Benchmark_decimal_Decimal_Add_different_precision-10        31561636         176.6 ns/op         280 B/op           9 allocs/op
Benchmark_decimal_Decimal_Sub_different_precision-10        36892767         164.4 ns/op         240 B/op           9 allocs/op
Benchmark_decimal_Decimal_Add_same_precision-10            134831919          44.9 ns/op          80 B/op           2 allocs/op
Benchmark_decimal_Decimal_Sub_same_precision-10            134902627          43.1 ns/op          80 B/op           2 allocs/op
BenchmarkDecimal_IsInteger-10                               92543083          66.1 ns/op           8 B/op           1 allocs/op
BenchmarkDecimal_NewFromString-10                             827455        7382   ns/op        3525 B/op         216 allocs/op
BenchmarkDecimal_NewFromString_large_number-10                212538       28836   ns/op       16820 B/op         360 allocs/op
BenchmarkDecimal_ExpHullAbraham-10                             10000      572091   ns/op      486628 B/op         568 allocs/op
BenchmarkDecimal_ExpTaylor-10                                  26343      222915   ns/op      431226 B/op        3172 allocs/op
PASS
ok      github.com/shopspring/decimal    123.541sa

Appendix C: Why this is good fit for money?

There are only ~200 currencies in the world. All currencies have at most 3 decimal digits, thus it is sufficient to handle 3 decimal fractions. Next, currencies without decimal digits are typically 1000x larger than dollar, but even then maximum number that fits into int64 (without 3 decimal fractions) is 9 223 372 036 854 775.807 which is ~9 quadrillion. This should be enough for most operations with money.

Appendix D: Is it safe to use arithmetic operators in Go?

Sort of…

Operations with defined types (variables) will fail.

var a int64
var b fpdecimal.FP3DecimalFromInt(1000)

// does not compile
a + b

However, untyped constants will be resolved to underlying type int64 and will be allowed.

const a 10000
var b fpdecimal.FP3DecimalFromInt(1000)

// compiles
a + b

// also compiles
b - 42

// this one too
b *= 23

Is this a problem?

  • For multiplication and division – yes, it can be. You have to be careful not to multiply two fpdecimal numbers, since scaling factor will quadruple. Multiplying by constants is ok tho.
  • For addition substraction – yes, it can be. You have to be careful and remind yourself that constants would be reduced 1000x.

Both of this can be addressed at compile time by providing linter. This can be also addressed by wrapping into a struct and defining methods.

GitHub

View Github