From 1bec99d1e1cf3182479ccb5c2f9008479f1336c0 Mon Sep 17 00:00:00 2001 From: omegaatt36 Date: Thu, 27 Aug 2026 21:20:55 +0800 Subject: [PATCH] perf: delegate NewFromFloat shortest-decimal conversion to strconv NewFromFloat and NewFromFloat32 need the shortest decimal digit string that round-trips back to the input float. strconv already computes exactly that for FormatFloat with precision -1, but the library computes it itself through decimal-go.go and rounding.go, a copy of the 2009 strconv/decimal.go: assign the mantissa into an 800-byte digit buffer, shift it one binary bit at a time, then walk the digits against the two neighbouring floats to find where they diverge. Ask strconv for the value in 'e' format instead and read the digits back out. The 'e' layout is [-]d[.ddd]e+dd, at most 24 bytes and trivial to scan, unlike 'f' which can run past 750 bytes at the exponent extremes. The result is identical, so no caller-visible behaviour changes. Benchmarks (Ryzen 9 5900X, -benchtime 2s -count 5, median): go1.22 go1.27 Float 366 -> 168 ns 332 -> 94 ns Float32 271 -> 130 ns 241 -> 92 ns Allocations are unchanged at 2 / 40 B, both from the big.NewInt that Decimal.value requires. The gap widens on go1.27 because that release replaced strconv's shortest-float conversion with the uscale algorithm. Before go1.13, strconv.FormatFloat could return a shortest form that was not the nearest one (golang/go#29491, the bug behind the two test table entries added for it), so the old path is kept behind a !go1.13 build tag. Nothing changes for go1.10 through go1.12; once the module's minimum Go version passes 1.13, decimal-go.go, rounding.go and newfromfloat_legacy.go can be deleted outright, removing 575 lines. Verified equivalent against the previous implementation on go1.13 and go1.27 over all 4,278,190,078 non-zero finite float32 bit patterns and 578,922,574 float64 values (random bit patterns, every power of two and its neighbours, and a dense subnormal sweep), with no mismatch. Tested on go1.10.8, go1.11.13, go1.12.17, go1.13.15, go1.22.12 and go1.27.0. --- decimal-go.go | 7 +++ decimal.go | 52 +---------------- newfromfloat_go113.go | 70 +++++++++++++++++++++++ newfromfloat_go113_test.go | 114 +++++++++++++++++++++++++++++++++++++ newfromfloat_legacy.go | 76 +++++++++++++++++++++++++ rounding.go | 7 +++ 6 files changed, 276 insertions(+), 50 deletions(-) create mode 100644 newfromfloat_go113.go create mode 100644 newfromfloat_go113_test.go create mode 100644 newfromfloat_legacy.go diff --git a/decimal-go.go b/decimal-go.go index 9958d690..08984f5b 100644 --- a/decimal-go.go +++ b/decimal-go.go @@ -2,6 +2,13 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build !go1.13 +// +build !go1.13 + +// This file is only built for Go versions older than 1.13. From Go 1.13 on, +// strconv's own shortest-float conversion is correct (golang/go#29491) and +// newFromFloat delegates to it instead. See newfromfloat_go113.go. + // Multiprecision decimal numbers. // For floating-point formatting only; not general purpose. // Only operations are assign and (binary) left/right shift. diff --git a/decimal.go b/decimal.go index ee554d4c..0f649a67 100644 --- a/decimal.go +++ b/decimal.go @@ -329,7 +329,7 @@ func NewFromFloat(value float64) Decimal { if value == 0 { return New(0, 0) } - return newFromFloat(value, math.Float64bits(value), &float64info) + return newFromFloat(value, 64) } // NewFromFloat32 converts a float32 to Decimal. @@ -346,55 +346,7 @@ func NewFromFloat32(value float32) Decimal { if value == 0 { return New(0, 0) } - // XOR is workaround for https://github.com/golang/go/issues/26285 - a := math.Float32bits(value) ^ 0x80808080 - return newFromFloat(float64(value), uint64(a)^0x80808080, &float32info) -} - -func newFromFloat(val float64, bits uint64, flt *floatInfo) Decimal { - if math.IsNaN(val) || math.IsInf(val, 0) { - panic(fmt.Sprintf("Cannot create a Decimal from %v", val)) - } - exp := int(bits>>flt.mantbits) & (1<>(flt.expbits+flt.mantbits) != 0 - - roundShortest(&d, mant, exp, flt) - // If less than 19 digits, we can do calculation in an int64. - if d.nd < 19 { - tmp := int64(0) - m := int64(1) - for i := d.nd - 1; i >= 0; i-- { - tmp += m * int64(d.d[i]-'0') - m *= 10 - } - if d.neg { - tmp *= -1 - } - return Decimal{value: big.NewInt(tmp), exp: int32(d.dp) - int32(d.nd)} - } - dValue := new(big.Int) - dValue, ok := dValue.SetString(string(d.d[:d.nd]), 10) - if ok { - return Decimal{value: dValue, exp: int32(d.dp) - int32(d.nd)} - } - - return NewFromFloatWithExponent(val, int32(d.dp)-int32(d.nd)) + return newFromFloat(float64(value), 32) } // NewFromFloatWithExponent converts a float64 to Decimal, with an arbitrary diff --git a/newfromfloat_go113.go b/newfromfloat_go113.go new file mode 100644 index 00000000..2d723eb8 --- /dev/null +++ b/newfromfloat_go113.go @@ -0,0 +1,70 @@ +//go:build go1.13 +// +build go1.13 + +package decimal + +import ( + "fmt" + "math" + "math/big" + "strconv" +) + +// newFromFloat converts val to a Decimal holding the shortest decimal digit +// string that round-trips back to val when parsed at the given bit size. +// +// strconv already computes exactly that string, so we ask it for the value in +// 'e' format with precision -1 and read the digits back out. The 'e' layout is +// [-]d[.ddd]e±dd, which is bounded (at most 24 bytes) and trivial to scan, +// unlike 'f' which can run to over 750 bytes for extreme exponents. +func newFromFloat(val float64, bitSize int) Decimal { + if math.IsNaN(val) || math.IsInf(val, 0) { + panic(fmt.Sprintf("Cannot create a Decimal from %v", val)) + } + + var buf [32]byte + b := strconv.AppendFloat(buf[:0], val, 'e', -1, bitSize) + + neg := b[0] == '-' + if neg { + b = b[1:] + } + + // Split the significand from the exponent. b always contains an 'e'. + var i int + for b[i] != 'e' { + i++ + } + digits, expDigits := b[:i], b[i+1:] + + // The shortest round-trip form has at most 17 significant digits for a + // float64 and 9 for a float32, so the significand always fits in an int64. + var mant int64 + nd := 0 + for _, c := range digits { + if c == '.' { + continue + } + mant = mant*10 + int64(c-'0') + nd++ + } + if neg { + mant = -mant + } + + esign := 1 + switch expDigits[0] { + case '-': + esign = -1 + expDigits = expDigits[1:] + case '+': + expDigits = expDigits[1:] + } + e := 0 + for _, c := range expDigits { + e = e*10 + int(c-'0') + } + + // digits is d.ddd, i.e. the significand scaled by 10^(nd-1). + return Decimal{value: big.NewInt(mant), exp: int32(esign*e - nd + 1)} +} diff --git a/newfromfloat_go113_test.go b/newfromfloat_go113_test.go new file mode 100644 index 00000000..e4923ac6 --- /dev/null +++ b/newfromfloat_go113_test.go @@ -0,0 +1,114 @@ +//go:build go1.13 +// +build go1.13 + +package decimal + +import ( + "math" + "math/rand" + "strconv" + "testing" +) + +// TestNewFromFloatShortestRoundTrip pins the contract of NewFromFloat and +// NewFromFloat32: the result is the shortest decimal that parses back to the +// original float. It covers the exponent and significand shapes a +// shortest-representation conversion has to get right, namely subnormals, +// three-digit exponents, single-digit significands and the range extremes. +// +// It is gated on Go 1.13 because before that strconv itself could return a +// shortest form that was not the nearest one (golang/go#29491), which would make +// the reference value below wrong rather than the conversion under test. +func TestNewFromFloatShortestRoundTrip(t *testing.T) { + f64 := []float64{ + 1, -1, 0.1, -0.1, 0.5, 2.5, 100, 123.456, + 1e15, 1e16, 1e17, 1e21, 1e22, 1e23, 9007199254740992, + 1e-100, 1e100, 1e-300, 1e300, + math.MaxFloat64, -math.MaxFloat64, + math.SmallestNonzeroFloat64, -math.SmallestNonzeroFloat64, + math.Pi, math.E, + } + // Every power of two, which sweeps the whole exponent range including the + // subnormals, plus both neighbours of every value collected so far. + for e := -1074; e <= 1023; e++ { + f64 = append(f64, math.Ldexp(1, e)) + } + for _, f := range append([]float64{}, f64...) { + f64 = append(f64, math.Nextafter(f, math.Inf(1)), math.Nextafter(f, math.Inf(-1))) + } + + for _, f := range f64 { + if f == 0 || math.IsInf(f, 0) { + continue + } + want, err := NewFromString(strconv.FormatFloat(f, 'f', -1, 64)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + got := NewFromFloat(f) + if !got.Equal(want) { + t.Errorf("NewFromFloat(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + if back, _ := got.Float64(); back != f { + t.Errorf("NewFromFloat(%v).Float64() = %v, does not round-trip", f, back) + } + } + + f32 := []float32{ + 1, -1, 0.1, -0.1, 100, 123.456, + math.MaxFloat32, -math.MaxFloat32, + math.SmallestNonzeroFloat32, -math.SmallestNonzeroFloat32, + } + for e := -149; e <= 127; e++ { + f32 = append(f32, float32(math.Ldexp(1, e))) + } + for _, f := range f32 { + if f == 0 { + continue + } + want, err := NewFromString(strconv.FormatFloat(float64(f), 'f', -1, 32)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + got := NewFromFloat32(f) + if !got.Equal(want) { + t.Errorf("NewFromFloat32(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + } +} + +// TestNewFromFloatRandomRoundTrip sweeps random bit patterns, which puts a +// meaningful share of subnormals and extreme exponents through the conversion. +func TestNewFromFloatRandomRoundTrip(t *testing.T) { + rng := rand.New(rand.NewSource(0xdead1337)) + for i := 0; i < 200000; i++ { + f := math.Float64frombits(rng.Uint64()) + if f == 0 || math.IsNaN(f) || math.IsInf(f, 0) { + continue + } + want, err := NewFromString(strconv.FormatFloat(f, 'f', -1, 64)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + if got := NewFromFloat(f); !got.Equal(want) { + t.Fatalf("NewFromFloat(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + } + for i := 0; i < 200000; i++ { + f := math.Float32frombits(rng.Uint32()) + if f == 0 || math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) { + continue + } + want, err := NewFromString(strconv.FormatFloat(float64(f), 'f', -1, 32)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + if got := NewFromFloat32(f); !got.Equal(want) { + t.Fatalf("NewFromFloat32(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + } +} diff --git a/newfromfloat_legacy.go b/newfromfloat_legacy.go new file mode 100644 index 00000000..eafca0f1 --- /dev/null +++ b/newfromfloat_legacy.go @@ -0,0 +1,76 @@ +//go:build !go1.13 +// +build !go1.13 + +package decimal + +import ( + "fmt" + "math" + "math/big" +) + +// newFromFloat converts val to a Decimal holding the shortest decimal digit +// string that round-trips back to val when parsed at the given bit size. +// +// This is the pre-Go 1.13 implementation: it computes the shortest form itself, +// using the vendored copy of strconv's multiprecision decimal (decimal-go.go and +// rounding.go), because strconv.FormatFloat could return a shortest form that is +// not the nearest one before Go 1.13 (golang/go#29491). +func newFromFloat(val float64, bitSize int) Decimal { + if math.IsNaN(val) || math.IsInf(val, 0) { + panic(fmt.Sprintf("Cannot create a Decimal from %v", val)) + } + + var bits uint64 + var flt *floatInfo + if bitSize == 32 { + // XOR is workaround for https://github.com/golang/go/issues/26285 + a := math.Float32bits(float32(val)) ^ 0x80808080 + bits = uint64(a) ^ 0x80808080 + flt = &float32info + } else { + bits = math.Float64bits(val) + flt = &float64info + } + + exp := int(bits>>flt.mantbits) & (1<>(flt.expbits+flt.mantbits) != 0 + + roundShortest(&d, mant, exp, flt) + // If less than 19 digits, we can do calculation in an int64. + if d.nd < 19 { + tmp := int64(0) + m := int64(1) + for i := d.nd - 1; i >= 0; i-- { + tmp += m * int64(d.d[i]-'0') + m *= 10 + } + if d.neg { + tmp *= -1 + } + return Decimal{value: big.NewInt(tmp), exp: int32(d.dp) - int32(d.nd)} + } + dValue := new(big.Int) + dValue, ok := dValue.SetString(string(d.d[:d.nd]), 10) + if ok { + return Decimal{value: dValue, exp: int32(d.dp) - int32(d.nd)} + } + + return NewFromFloatWithExponent(val, int32(d.dp)-int32(d.nd)) +} diff --git a/rounding.go b/rounding.go index d4b0cd00..427dbff0 100644 --- a/rounding.go +++ b/rounding.go @@ -2,6 +2,13 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build !go1.13 +// +build !go1.13 + +// This file is only built for Go versions older than 1.13. From Go 1.13 on, +// strconv's own shortest-float conversion is correct (golang/go#29491) and +// newFromFloat delegates to it instead. See newfromfloat_go113.go. + // Multiprecision decimal numbers. // For floating-point formatting only; not general purpose. // Only operations are assign and (binary) left/right shift.