DEV Community

pmalhaire
pmalhaire

Posted on • Edited on

Concatenate strings in golang a quick benchmark : + or fmt.Sprintf ?

Concatenate strings in golang a quick benchmark

Introduction

When I begin to enter golang code bases with developer of various seniority level. I was surprised to see many ways to do join strings. Making onetwo out of one and two is used a lot in any programming language.

I decided to do a quick benchmark. Comming from the C/C++ world the result in golang surprized me (in a good way).

The origin

Let's do it in a C maner using Sprintf.

I use pointers here to simulate the fact that we are in a function.

#include <stdlib.h>
#include <stdio.h>

int main(){
    char str[] = "my_string";
    char *s = malloc(sizeof(str) + sizeof(str));
    sprintf(s, "my_string%s", str);
    printf("%s\n", s);
}
Enter fullscreen mode Exit fullscreen mode

Let's go golang

Let's do like in C. This was (at least when I begin with golang) a instinctive way for me.

package main

import "fmt"

func main() {
    str := "my_string"
    fmt.Println(fmt.Sprintf("my_string%s", str))
}
Enter fullscreen mode Exit fullscreen mode

Let's try an other way strings.Join

Discussing with others I realize that strings.Join is quite popular.

package main

import (
    "fmt"
    "strings"
)

func main() {
    str := "my_string"
    fmt.Println(strings.Join([]string{"my_string", str}, ""))
}
Enter fullscreen mode Exit fullscreen mode

Let's try C++ way using strings.Builder

Having experienced string builder in C++ and Java I thought I could give it a try.

package main

import (
    "fmt"
    "strings"
)

func main() {
    str := "my_string"
    var b strings.Builder
    b.WriteString("my_string")
    b.WriteString(str)
}
Enter fullscreen mode Exit fullscreen mode

Let's try Cpp way using +

Plus is kind of obvious but does not feel smart.

package main

import "fmt"

func main() {
    str := "my_string"
    fmt.Println("my_string" + str)
}
Enter fullscreen mode Exit fullscreen mode

Bench tells us

Golang has tremendous test utilities. We can do a benchmark really easily.

$go test -bench=. > bench.result
goos: linux
goarch: amd64

BenchmarkSprintf-4          10000000           156 ns/op
BenchmarkLongSprintf-4      10000000           186 ns/op
BenchmarkConstSprintf-4     10000000           158 ns/op

BenchmarkJoin-4             20000000            68.8 ns/op
BenchmarkLongJoin-4         20000000            86.9 ns/op
BenchmarkConstJoin-4        20000000            66.1 ns/op

BenchmarkBuilder-4          20000000           104 ns/op
BenchmarkLongBuilder-4      20000000           101 ns/op
BenchmarkConstBuilder-4     20000000           102 ns/op

BenchmarkPlus-4             50000000            25.9 ns/op
BenchmarkLongPlus-4         20000000            74.4 ns/op
BenchmarkConstPlus-4        2000000000           0.39 ns/op
Enter fullscreen mode Exit fullscreen mode

Conclusion

Surprisingly (at least for me) a simple + is the fastest.printf. Here Go make the simple way the most powerful which rocks.

Behind the curtain

While doing this post I noticed the binary size for golang and C are quite different :

  • golang : 1,9M
  • c : 8,3K

It's not surprising as C does not include any runtime, but it made me curious.

To get an overview of what's make golang and C programs different I looked at the generated syscalls using strace. See this interesting post on strace.

Note : As said in the comments it's not related to string concatenation, but deserves a future post.

C version

#include <stdlib.h>
#include <stdio.h>

int main(){
    char str[] = "my_string";
    char *s = malloc(sizeof(str) + sizeof(str));
    sprintf(s, "my_string%s", str);
    printf("%s\n", s);
}
Enter fullscreen mode Exit fullscreen mode

Let's have a look at strace.

gcc -o sprintf sprintf.c
strace -c ./sprintf
my_stringmy_string
% time     seconds  usecs/call     calls    errors syscall
----------- ----------- ----------- --------- --------- ----------------
 24.84    0.000305          23        13           mmap
 23.05    0.000283          28        10           mprotect
 10.18    0.000125          25         5           openat
  8.63    0.000106         106         1           munmap
  8.14    0.000100          17         6         6 access
  4.48    0.000055          14         4           read
  4.15    0.000051           9         6           fstat
  3.83    0.000047          47         1           write
  3.34    0.000041          41         1           arch_prctl
  2.44    0.000030           6         5           close
  2.12    0.000026           9         3           brk
  1.47    0.000018          18         1           execve
  1.14    0.000014           7         2           rt_sigaction
  0.65    0.000008           8         1           prlimit64
  0.57    0.000007           7         1           set_tid_address
  0.49    0.000006           6         1           rt_sigprocmask
  0.49    0.000006           6         1           set_robust_list
----------- ----------- ----------- --------- --------- ----------------
100.00    0.001228                    62         6 total

Enter fullscreen mode Exit fullscreen mode

Golang version

package main

import "fmt"

func main() {
    str := "my_string"
    fmt.Println("my_string" + str)
}
Enter fullscreen mode Exit fullscreen mode

Let's have a look at strace.

$ go build plus.go
$ strace -c ./plus
my_stringmy_string
% time     seconds  usecs/call     calls    errors syscall
----------- ----------- ----------- --------- --------- ----------------
 69.80    0.001900          17       114           rt_sigaction
  8.74    0.000238          79         3           clone
  5.95    0.000162          20         8           rt_sigprocmask
  4.70    0.000128          32         4           futex
  4.41    0.000120         120         1           readlinkat
  2.24    0.000061           8         8           mmap
  1.84    0.000050          50         1           write
  1.69    0.000046          15         3           fcntl
  0.62    0.000017          17         1           gettid
  0.00    0.000000           0         1           execve
  0.00    0.000000           0         2           sigaltstack
  0.00    0.000000           0         1           arch_prctl
  0.00    0.000000           0         1           sched_getaffinity
----------- ----------- ----------- --------- --------- ----------------
100.00    0.002722                   148           total

Enter fullscreen mode Exit fullscreen mode

What strace tells us

Go does a bit more system calls than c does, which seems legit given the fact that it has a gc.

To get to understand more I invite you to disassemble the result binaries.

As a matter of fact we must also have a look at the default compilation parameters for C and golang to have a proper comparison.

Code reference

Here is the code used to bench different options.

package main_test

import (
    "fmt"
    "strings"
    "testing"
)

var str, longStr string = "my_string", `qwertyuiopqwertyuiopqwertyuio
qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiop`

const cStr = "my_string"

func BenchmarkPlus(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = "my_string" + str
    }
}

func BenchmarkLongPlus(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = "my_string" + longStr
    }
}

func BenchmarkConstPlus(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = "my_string" + cStr
    }
}

func BenchmarkJoin(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = strings.Join([]string{"my_string%s", str}, "")
    }
}

func BenchmarkLongJoin(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = strings.Join([]string{"my_string%s", longStr}, "")
    }
}

func BenchmarkConstJoin(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = strings.Join([]string{"my_string%s", cStr}, "")
    }
}
func BenchmarkSprintf(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = fmt.Sprintf("my_string%s", str)
    }
}

func BenchmarkLongSprintf(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = fmt.Sprintf("my_string%s", longStr)
    }
}

func BenchmarkConstSprintf(b *testing.B) {
    for n := 0; n < b.N; n++ {
        _ = fmt.Sprintf("my_string%s", cStr)
    }
}

func BenchmarkBuilder(b *testing.B) {
    for n := 0; n < b.N; n++ {
        var b strings.Builder
        b.WriteString("my_string")
        b.WriteString(longStr)
    }
}

func BenchmarkLongBuilder(b *testing.B) {
    for n := 0; n < b.N; n++ {
        var b strings.Builder
        b.WriteString("my_string")
        b.WriteString(longStr)
    }
}

func BenchmarkConstBuilder(b *testing.B) {
    for n := 0; n < b.N; n++ {
        var b strings.Builder
        b.WriteString("my_string")
        b.WriteString(longStr)
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (11)

Collapse
 
fasmat profile image
Matthias Fasching • Edited

Your benchmark is incorrect, the only thing you are measuring is the optimizations of the golang compiler / runtime in simple cases. Here is a slightly improved benchmark that shows how hugely different results you get if you disable golang optimizations (or make it hard for go to optimize your code):

package test

import (
    "fmt"
    "strings"
    "testing"
)

var str, longStr string = "my_string", `qwertyuiopqwertyuiopqwertyuio
qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiop`

const cStr = "my_string"

var result string

func BenchmarkPlus(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s += str
    }
    result = s
}

func BenchmarkLongPlus(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s += longStr
    }
    result = s
}

func BenchmarkConstPlus(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s += cStr
    }
    result = s
}

func BenchmarkJoin(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s = strings.Join([]string{s, str}, "")
    }
    result = s
}

func BenchmarkLongJoin(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s = strings.Join([]string{s, longStr}, "")
    }
    result = s
}

func BenchmarkConstJoin(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s = strings.Join([]string{s, cStr}, "")
    }
    result = s
}
func BenchmarkSprintf(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s = fmt.Sprintf("%s %s", s, str)
    }
    result = s
}

func BenchmarkLongSprintf(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s = fmt.Sprintf("%s %s", s, longStr)
    }
    result = s
}

func BenchmarkConstSprintf(b *testing.B) {
    var s string
    for n := 0; n < b.N; n++ {
        s = fmt.Sprintf("%s %s", s, cStr)
    }
    result = s
}

func BenchmarkBuilder(b *testing.B) {
    var sb strings.Builder
    for n := 0; n < b.N; n++ {
        sb.WriteString(str)
    }
    result = sb.String()
}

func BenchmarkLongBuilder(b *testing.B) {
    var sb strings.Builder
    for n := 0; n < b.N; n++ {
        sb.WriteString(longStr)
    }
    result = sb.String()
}

func BenchmarkConstBuilder(b *testing.B) {
    var sb strings.Builder
    for n := 0; n < b.N; n++ {
        sb.WriteString(cStr)
    }
    result = sb.String()
}
Enter fullscreen mode Exit fullscreen mode

If executed with go test -gcflags=-N -bench=. returns the following results:

BenchmarkPlus-4                   114433            119694 ns/op
BenchmarkLongPlus-4                10000            118300 ns/op
BenchmarkConstPlus-4              122350            128256 ns/op
BenchmarkJoin-4                    91172            122361 ns/op
BenchmarkLongJoin-4                10000            142659 ns/op
BenchmarkConstJoin-4               86749            114199 ns/op
BenchmarkSprintf-4                 57369            152416 ns/op
BenchmarkLongSprintf-4             10000            268300 ns/op
BenchmarkConstSprintf-4            47094            139441 ns/op
BenchmarkBuilder-4              29206484                77.87 ns/op
BenchmarkLongBuilder-4           8734220              1438 ns/op
BenchmarkConstBuilder-4         37201794                41.32 ns/op
PASS
Enter fullscreen mode Exit fullscreen mode

As you can see a Builder is often more than 1000x faster than other approaches. fmt.Sprintf and strings.Join have about the same speed as +, but this changes as soon as you do multiple concatenations in a single call:

s := "string1" + "string2" + "string3"
s := strings.Join([]string{"string1", "string2", "string3"})
Enter fullscreen mode Exit fullscreen mode

here strings.Join will be measurable faster than +.

Collapse
 
bgadrian profile image
Adrian B.G.

I tried to do this micro optimization a few months ago and kinda failed.

The recommended way is to use strings.Builder. As I did not knew the string size a simple + worked better in benchmarks (at least for strings less than ~20 characters.

I ended up approximating the result (and pre allocate memory with a buffer) and got the best result, but most of the times + is the best choice.

Collapse
 
fasmat profile image
Matthias Fasching • Edited

+ will always be the slowest way to concatenate strings.

In simple cases (concatenate only exactly 2 strings) every other method: builder, join, and sprintf will be ~ the same speed as +.

The benchmark here is just incorrect. Because the resulting string in the Plus tests isn't assigned to anything the compiler just makes it a NOP before executing the tests.

Run the benchmarks again and disable optimizations (go test -gcflags=-N -bench=.) and you will see that all methods have ~ the same execution time. In cases where you concatenate more than 2 strings + will always be the slowest (and most memory hungry) method.

Collapse
 
pmalhaire profile image
pmalhaire

Thanks for your comment I'll update my post accordingly, note that the c version preallocates the buffer.

Collapse
 
titogeorge profile image
Tito George

In my case join wins by a long margin, cant figure out whats wrong.

var word = []string{"9b4e6f7f-1c37-4730-9417-e23747572608", "9b4e6f7f-1c37-4730-9417-e23747572608", "9b4e6f7f-1c37-4730-9417-e23747572608", "9b4e6f7f-1c37-4730-9417-e23747572608", "9b4e6f7f-1c37-4730-9417-e23747572608", "9b4e6f7f-1c37-4730-9417-e23747572608"}

func BenchmarkStringsSprint(b *testing.B) {
    b.ReportAllocs()
    values := ""
    for i := 0; i < b.N; i++ {
        for _, s := range word {
            values = fmt.Sprintf("%s %s", values, s)
        }
    }
}

func BenchmarkStringsJoin(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        values := strings.Join(word, " ")
        _ = values
    }
}

func BenchmarkBuilder(b *testing.B) {
    b.ReportAllocs()
    for n := 0; n < b.N; n++ {
        var b strings.Builder
        for _, s := range word {
            b.WriteString(s)
            b.WriteByte(' ')
        }
        _ = b.String()
    }
}
Enter fullscreen mode Exit fullscreen mode

Join has less allocs as well

BenchmarkStringsSprint
BenchmarkStringsSprint-16                      10000       2180843 ns/op    13404016 B/op         32 allocs/op
BenchmarkStringsJoin
BenchmarkStringsJoin-16                     10398318           139.0 ns/op       224 B/op          1 allocs/op
BenchmarkBuilder
BenchmarkBuilder-16                          4303065           247.6 ns/op       720 B/op          4 allocs/op
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jrwren profile image
Jay R. Wren

On strace: string concatenation isn't a system call. The strace for these programs should be the same as the strace for hello world.

Collapse
 
pmalhaire profile image
pmalhaire

I didn't mean to say that. What made you think this way ? Maybe I Can make my post more clear with your help.

Collapse
 
jrwren profile image
Jay R. Wren

The article is about string concatenation. Why look at strace at all?

Thread Thread
 
pmalhaire profile image
pmalhaire

It's to explain why C is more efficient than Go, which is no explicitly explained.

Thread Thread
 
jrwren profile image
Jay R. Wren

I don't agree that a syscall count has anything to do with a languages efficiency compared to another language.

Thread Thread
 
pmalhaire profile image
pmalhaire

I'll make it more clear.