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.