Optimizing Go string operations with practical examples
medium.com
medium.com
> [with] this change ... the speed improved significantly [but] the readability and ease of coding decreased quite a bit
The speed of this specific function, measured with the described (micro) benchmark, improved, yes, no doubt. But that's not (by itself) relevant! If you want to use an optimized implementation of some code, it has to represent a significant improvement in the overall system as measured by meaningful, agreed-upon, high-level performance metrics.
If this function is called many times per user request, and the optimized version has a measurable impact on request latency, then sure, go for it! But, otherwise, making this kind of change is the textbook definition of premature optimization, never a good idea.
This is most useful for the "split once" version, which is genuinely improved:
scoreWithColor := strings.Split(score, " ")
scoreStr := scoreWithColor[0]
color := scoreWithColor[1]
becomes scoreStr, color, _ := strings.Cut(score, " ")
The iteration is worse, but not to an irredeemable degree, rounds := strings.Split(game, "; ")
isPossible := false
for _, round := range rounds {
red, green, blue := findScores(round)
if isRoundPossible(red, green, blue) {
isPossible = true
} else {
isPossible = false
// If only one round is not possible, there is
// no need to process the other ones.
break
}
}
becomes: isPossible := false
for {
round, tail, hasNext := strings.Cut(game, ";")
game = tail
red, green, blue := findScores(round)
if isRoundPossible(red, green, blue) {
isPossible = true
} else {
isPossible = false
// If only one round is not possible, there is
// no need to process the other ones.
break
}
if !hasNext {
break
}
}Sure, if you're writing an inner loop for some heavy workload, e.g. in graphics or maybe parsing terabytes of JSON, it makes sense. But outside of these rarely occurring situations, you should always prefer readability over low-level optimizations.
Tangentially related:
Not really necessary here but lately I've been using buffer pools like the one by the fasthttp author to avoid allocating in performance sensitive places. But the code is sometimes pretty ugly.
// get buffer from pool
body := bytebufferpool.Get()
// create request body
body.WriteString(`{"config_id":"`)
for i := range configID {
c := configID[i]
if c != '/' {
body.WriteByte(c)
} else {
// escape slash
body.WriteByte('\\')
body.WriteByte('/')
}
}
body.WriteString(`","commit":`)
if commit {
body.WriteByte('1')
} else {
body.WriteByte('0')
}
body.WriteString(`,"struct_items":[]}`)
// send request
...
// add back to pool
bytebufferpool.Put(body) w := jsonx.New(os.Stdout)
w.OpenObject()
w.ObjectKey("commit")
w.Int(0)
w.CloseObject()Also this code has many many problems please never use it to encode JSON :)
This morning we compared the performance of his solution in Rust to my solution in Go. His was running 2-4x faster than mine but we couldn't figure out why and it seemed so straight forward that we assumed Rust was just that much faster.
I had assumed strings.Split would return sub-slices of the original string until I read this post. Sure enough, I rewrite it using strings.Index and manually creating sub-slices and now it's comparable, albeit less readable.
Looking at the standard library source doesn't make it clear to me why this even works. strings.Split is appending sub-slices of the input string to the return value. I don't understand where the allocations are coming from, but I'm just not knowledgeable enough about go slices.
Most langages just have constructors or factory functions from iterator (or even iterable) to container. It’s a less generic (especially if constructors and functions are segregated) and / or efficient but otherwise works fine. No need for the iterator whatever (protocol, interface, trait) to have specific knowledge of individual collections.
Each iteration of this benchmark measures the aggregate performance of
- 1x ParseObject
- 3x AppendObject
- 3x MergeNodesWithPath
- 1x PrintNode
- 1x bytes.Equal comparison of two byte slices
So the benchmark isn't really measuring MergeNodesWithPath, it's measuring a much larger composite operation, which includes (multiple) calls to MergeNodesWithPath but also all of the above listed calls as well.
If you want to measure MergeNodesWithPath, you would need to have each iteration of the loop do a single MergeNodesWithPath call, on the same JSON method receiver, and with the same input parameters.
edit: similar issues exist in many/most of the other benchmark functions as well
// excerpted from Cut
if i := Index(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
Then why shouldn't this also not allocate? eoc = strings.Index(round, ", ")
if eoc == -1 {
currentScoreWithColor = round
} else {
currentScoreWithColor = round[:eoc]
round = round[eoc+2:]
}
Edit: oh, you're comparing it to Split. I was comparing it to what they ended up implementing.