Skip to content

Commit

Permalink
perf: parse string to decimal faster
Browse files Browse the repository at this point in the history
benchmark indicates improvement from 75ns to 55ns per string
  • Loading branch information
howeyc committed Sep 25, 2023
1 parent 2564e29 commit f67a9b7
Show file tree
Hide file tree
Showing 2 changed files with 57 additions and 23 deletions.
69 changes: 49 additions & 20 deletions decimal/decimal.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,27 @@ package decimal
import (
"errors"
"fmt"
"strconv"
"strings"
)

// Decimal represents a fixed-point decimal.
type Decimal int64

// scaleFactor used for math operations, 3 digit precision
const scaleFactor Decimal = 1000
// scaleFactor used for math operations,
const scaleFactor = 1000

// precision of 3 digits
const precision = 3

// Zero constant, to make initializations easier.
const Zero = Decimal(0)

// One constant, to make initializations easier.
const One = scaleFactor
const One = Decimal(scaleFactor)

// Parse max/min for whole number part
const parseMax = (1<<63 - 1) / scaleFactor
const parseMin = (-1 << 63) / scaleFactor

// NewFromFloat converts a float64 to Decimal. Only 3 digits of precision after
// the decimal point are preserved.
Expand All @@ -43,27 +49,51 @@ func NewFromInt(i int64) Decimal {
return Decimal(i) * scaleFactor
}

// atoi64 is equivalent to strconv.Atoi
func atoi64(s string) (bool, int64, error) {
sLen := len(s)
if sLen < 1 || sLen > 18 {
return false, 0, errors.New("atoi failed")
}
neg := false
if s[0] == '-' {
neg = true
s = s[1:]
if len(s) < 1 {
return false, 0, errors.New("atoi failed")
}
}

var n int64
for _, ch := range []byte(s) {
ch -= '0'
if ch > 9 {
return false, 0, errors.New("atoi failed")
}
n = n*10 + int64(ch)
}
if neg {
n = -n
}
return neg, n, nil
}

// NewFromString returns a Decimal from a string representation. Throws an
// error if integer parsing fails.
func NewFromString(s string) (Decimal, error) {
neg := false
if whole, frac, split := strings.Cut(s, "."); split {
if strings.HasPrefix(whole, "-") {
neg = true
}
w, err := strconv.ParseInt(whole, 10, 64)
neg, w, err := atoi64(whole)
if err != nil {
return Zero, err
}
b := w
w = w * int64(scaleFactor)

// overflow
if w/int64(scaleFactor) != b {
if w > parseMax || w < parseMin {
return Zero, errors.New("number too big")
}
w = w * int64(scaleFactor)

// Parse up to 3 digits and scale up
// Parse up to *precision* digits and scale up
var f int64
var seen int
for _, b := range frac {
Expand All @@ -73,11 +103,11 @@ func NewFromString(s string) (Decimal, error) {
}
f += int64(b - '0')
seen++
if seen == 3 {
if seen == precision {
break
}
}
for seen < 3 {
for seen < precision {
f *= 10
seen++
}
Expand All @@ -87,13 +117,12 @@ func NewFromString(s string) (Decimal, error) {
}
return Decimal(w + f), err
} else {
i, err := strconv.ParseInt(s, 10, 64)
b := i
d := NewFromInt(i)
if int64(d/scaleFactor) != b {
_, i, err := atoi64(s)
if i > parseMax || i < parseMin {
return Zero, errors.New("number too big")
}
return d, err
i = i * int64(scaleFactor)
return Decimal(i), err
}
}

Expand Down
11 changes: 8 additions & 3 deletions decimal/decimal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,14 +315,19 @@ var testParseCases = []testCase{
"invalid syntax",
"0.e0",
},
{
"error-5",
"atoi failed",
"5555555555555555555555555550000000000000000",
},
{
"error-badint-1",
`strconv.ParseInt: parsing "1QZ": invalid syntax`,
`atoi failed`,
"1QZ.56",
},
{
"error-expr-1",
`strconv.ParseInt: parsing "(123 * 6)": invalid syntax`,
`atoi failed`,
"(123 * 6)",
},
}
Expand Down Expand Up @@ -369,7 +374,7 @@ func FuzzStringParse(f *testing.F) {
}

func BenchmarkNewFromString(b *testing.B) {
numbers := []string{"10.0", "245.6", "3", "2.456"}
numbers := []string{"10.0", "245.6", "354", "2.456"}
for n := 0; n < b.N; n++ {
for _, numStr := range numbers {
NewFromString(numStr)
Expand Down

0 comments on commit f67a9b7

Please sign in to comment.