Why this post?
It’s not exactly the sexiest of topics but it’s something we do a lot of as developers, outputting integers as strings. But there are some huge differences in performance depending on how you go about this in Go.
Round 1 - fmt.Sprintf()
The first contender here is fmt.Sprintf
a handy goto for a lot of string
formatting problems.
So we have the code below, we’ll be using this simple loop and a string builder for all the examples to get a true measure of the differences.
func useSprintf() string {
b := strings.Builder{}
for i := 0; i < 1000; i++ {
b.Write([]byte(fmt.Sprintf("%v", i)))
}
return b.String()
}
Innocent enough right? Let’s create a benchmark around this and check the details. We’ll use this same template for all the benchmarks.
func Benchmark_useSprintf(b *testing.B) {
var r string
for n := 0; n < b.N; n++ {
r = useSprintf()
}
result = r
}
And the outcome
go test -bench=useSprintf -benchmem
goos: linux
goarch: amd64
Benchmark_useSprintf-2 4279 284888 ns/op 23073 B/op 1746 allocs/op
PASS
ok _/home/scott/src/hexoscott/examples/string_perf 2.133s
None of this means much until we come to compare it to some other methods…
Round 2 - strconv.ItoA
ItoA is another convenient way to convert an integer to a string. So let’s give that a whirl
func useItoA() string {
b := strings.Builder{}
for i := 0; i < 1000; i++ {
b.Write([]byte(strconv.Itoa(i)))
}
return b.String()
}
The benchmark:
go test -bench=useItoA -benchmem
goos: linux
goarch: amd64
Benchmark_useItoA-2 10000 130000 ns/op 13376 B/op 912 allocs/op
PASS
ok _/home/scott/src/hexoscott/examples/string_perf 1.318s
Brilliant! What a difference! Faster, less memory used and less allocations.
Let’s try another method.
Round 3 - strconv.FormatInt
func useStrconv() string {
b := strings.Builder{}
for i := 0; i < 1000; i++ {
b.Write([]byte(strconv.FormatInt(int64(i), 10)))
}
return b.String()
}
The benchmark:
go test -bench=useStrconv -benchmem
goos: linux
goarch: amd64
Benchmark_useStrconv-2 7850 141591 ns/op 13376 B/op 912 allocs/op
PASS
ok _/home/scott/src/hexoscott/examples/string_perf 1.139s
Interesting, same allocations and memory usage, and a similar speed.
Round 4 - map[int]string
This last method seems unconventional, and in some instances it really would be. But depending on your use case it is incredibly powerful. I’ve used this to great effect in performance critical paths.
Creating an upfront map in your package with a healthy number of integers to go at then using that rather than a conversion function.
var lookup = map[int]string{}
func init() {
for i := 0; i < 1000; i++ {
lookup[i] = strconv.FormatInt(int64(i), 10)
}
}
func mapLookup() string {
b := strings.Builder{}
for i := 0; i < 1000; i++ {
b.Write([]byte(lookup[i]))
}
return b.String()
}
So this covers off our test scenario with enough mappings there. Let’s see the benchmark.
go test -bench=useMap -benchmem
goos: linux
goarch: amd64
Benchmark_useMap-2 15757 78060 ns/op 10488 B/op 12 allocs/op
PASS
ok _/home/scott/src/hexoscott/examples/string_perf 2.892s
Quite the difference as far as allocations go, it uses less memory on the whole, and it’s faster.
Why a map?
The influence for this comes from looking at the ItoA implementation in stdlib. If the integer passed in is less than 100 then the function doesn’t do any work and just returns the result straight from a long string.
Having 1000 integers as strings like this would be a little unwieldy so the map seems a sensible compromise. Here’s the code from stdlib for ItoA:
// Itoa is equivalent to FormatInt(int64(i), 10).
func Itoa(i int) string {
return FormatInt(int64(i), 10)
}
// FormatInt returns the string representation of i in the given base,
// for 2 <= base <= 36. The result uses the lower-case letters 'a' to 'z'
// for digit values >= 10.
func FormatInt(i int64, base int) string {
if fastSmalls && 0 <= i && i < nSmalls && base == 10 {
return small(int(i))
}
_, s := formatBits(nil, uint64(i), base, i < 0, false)
return s
}
// small returns the string for an i with 0 <= i < nSmalls.
func small(i int) string {
if i < 10 {
return digits[i : i+1]
}
return smallsString[i*2 : i*2+2]
}
const nSmalls = 100
const smallsString = "00010203040506070809" +
"10111213141516171819" +
"20212223242526272829" +
"30313233343536373839" +
"40414243444546474849" +
"50515253545556575859" +
"60616263646566676869" +
"70717273747576777879" +
"80818283848586878889" +
"90919293949596979899"
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
The results
To see them all side by side
go test -bench=. -benchmem
goos: linux
goarch: amd64
Benchmark_useSprintf-2 10000 124558 ns/op 23073 B/op 1746 allocs/op
Benchmark_useItoA-2 28772 41335 ns/op 13376 B/op 912 allocs/op
Benchmark_useStrconv-2 27036 44611 ns/op 13376 B/op 912 allocs/op
Benchmark_useMap-2 28666 40885 ns/op 10488 B/op 12 allocs/op
PASS
ok _/home/scott/src/hexoscott/examples/string_perf 6.127s
Conclusion
The example is a little contrived and you generally wouldn’t be outputting this
many strings for the fun of it, but if you have a loop and need to use the index
as a string for output or a similar scenario and you know roughly how many
iterations you’ll need, the map[int]string
map is a really good option.
As with anything like this, you need to play with the numbers that match your scenario to get a true picture on which approach is going to give you the best performance.