This is the first post in which I take an interesting topic I find while reading the awesome Ultimate Go Notebook by William (Bill) Kennedy and Hoanh An.
Today I’ll be exploring the differences in memory usage between value and pointer semantic iteration. When I was first learning Go, I didn’t notice the difference between the two syntax. It’s a subtle difference, and when you are first feeling your way around a new language and codebase, it’s easy to miss the difference between the two methods.
This article doesn’t aim to dive into the reasons why you should use either method in your own programs. What this article does want to highlight that there are two iteration methods, and to explore is how the memory usage differs between the two.
Software Versions
Before we get started, this was written with the following notable software versions:
- Go @ v1.17
Code referenced in this post can be found in this Github repo
Value Semantic Iteration
When we use value semantic iteration, the loop is getting a copy of the slice, and that slice is iterated over one at a time. Let’s look at an example function:
func greet(names []string) {
for i, name := range names {
fmt.Printf("[%d] Hello %s \n", i, name)
}
}
What makes this value semantic iteration is the two arguments being received from the range
syntax. You must use two arguments for it to be value semantic. If you don’t need the counting variable (i
in this example above), you replace it with an underscore _
. Here is an example of what that looks like:
func greet(names []string) {
for _, name := range names {
fmt.Printf("Hello %s \n", name)
}
}
Pointer Semantic Iteration
When we use pointer semantic iteration, we are looping over the actual slice itself, any changes to the data happen to the variable itself. Let’s take the same function from above and right it with pointer semantic iteration:
func greet(names []string) {
for i := range names {
fmt.Printf("Hello %s \n", names[i])
}
}
Notice how we only receive one argument from the range
syntax, the index value. This is where I think the beauty in the Go language design shines. When we are accessing the data directly (i.e. not a copy) we access it with the slice name itself, names[i]
in the example above. When we are using value semantic iteration, we access the data through the loop variable name we give it on the left side of the range
command.
Testing Memory Usage
To test the memory usage, we need to set up an example and write some tests. Let’s create an example where we have a struct of blog post called Post
. We need to write a function to encode a slice of Post
to JSON. We’ll write an encoding function for both types of iteration:
type Post struct {
ID int `json:"id"`
PostedAt time.Time `json:"posted_at"`
Author string `json:"author"`
Content string `json:"content"`
}
// value semantic iteration
func encodePostsWithValueSemanticIteration(posts []Post, w io.Writer) error {
e := json.NewEncoder(w)
for i, post := range posts {
if err := e.Encode(post); err != nil {
return fmt.Errorf("failed encoding post #%d - %w", i, err)
}
}
return nil
}
// pointer semantic iteration
func encodePostsWithPointerSemanticIteration(posts []Post, w io.Writer) error {
e := json.NewEncoder(w)
for i := range posts {
if err := e.Encode(posts[i]); err != nil {
return fmt.Errorf("failed encoding post #%d - %w", i, err)
}
}
return nil
}
Now that we have our two functions, lets start writing some tests. But before we do that, let’s write a helper function in our test file to create and populate a slice of Post
// initPosts creates a slice of Post the size of i and populates it with dummy test data
func initPosts(i int) []Post {
posts := make([]Post, i)
for x := 0; x < i; x++ {
posts[x] = Post{
ID: x + 1,
PostedAt: time.Now(),
Author: "Andrew Edison",
Content: bigIpsum, //1 megabyte of lorum ipsum content defined elsewhere
}
}
return posts
}
Now that we can create slices of Post
, lets write two functions that will run the two different encoding functions for us. We’ll make this pair of tests encode 1 million Post
elements:
// Value Semantics Tests
func Test_encodePostsWithValueSemanticIteration1000000(t *testing.T) {
posts := initPosts(1000000) // 1,000,000
w := io.Discard
if err := encodePostsWithValueSemanticIteration(posts, w); err != nil {
t.Error(err)
}
}
// Pointer Semantics Tests
func Test_encodePostsWithPointerSemanticIteration1000000(t *testing.T) {
posts := initPosts(1000000) // 1,000,000
w := io.Discard
if err := encodePostsWithPointerSemanticIteration(posts, w); err != nil {
t.Error(err)
}
}
Here is the test file now:
package article_code
import (
"io"
"testing"
"time"
)
//==================================================================================================
// helpers
// initPosts creates a slice of Post the size of `i` and populates it with dummy test data
func initPosts(i int) []Post {
posts := make([]Post, i)
for x := 0; x < i; x++ {
posts[x] = Post{
ID: x + 1,
PostedAt: time.Now(),
Author: "Andrew Edison",
//Content: ipsum, //8 paragraphs of lorum ipsum content
Content: bigIpsum, //1 megabyte of lorum ipsum content
}
}
return posts
}
//==================================================================================================
// Tests
// Value Semantics Tests
func Test_encodePostsWithValueSemanticIteration1000000(t *testing.T) {
posts := initPosts(1000000) // 1,000,000
w := io.Discard
if err := encodePostsWithValueSemanticIteration(posts, w); err != nil {
t.Error(err)
}
}
// Pointer Semantics Tests
func Test_encodePostsWithPointerSemanticIteration1000000(t *testing.T) {
posts := initPosts(1000000) // 1,000,000
w := io.Discard
if err := encodePostsWithPointerSemanticIteration(posts, w); err != nil {
t.Error(err)
}
}
Let us check and make sure our tests are passing:
==> go test -v -run Test_*
=== RUN Test_encodePostsWithValueSemanticIteration1000000
--- PASS: Test_encodePostsWithValueSemanticIteration1000000 (131.63s)
=== RUN Test_encodePostsWithPointerSemanticIteration1000000
--- PASS: Test_encodePostsWithPointerSemanticIteration1000000 (131.05s)
PASS
ok article-code 262.823s
We have passing tests! Now let’s take these two functions, and create memory profiles to examine with the pprof
tool.
Creating Profiles
Pointer profile:
==> go test -v -memprofile=pointer-mem.prof -o pointer-profile.test -run Test_encodePostsWithPointerSemanticIteration1000000
=== RUN Test_encodePostsWithPointerSemanticIteration1000000
--- PASS: Test_encodePostsWithPointerSemanticIteration1000000 (135.10s)
PASS
ok article-code 135.332s
Value profile:
==> go test -v -memprofile=value-mem.prof -o value-profile.test -run Test_encodePostsWithValueSemanticIteration1000000
=== RUN Test_encodePostsWithValueSemanticIteration1000000
--- PASS: Test_encodePostsWithValueSemanticIteration1000000 (158.10s)
PASS
ok article-code 158.447s
These commands leave us with the following *.test and *.prof files
==> ls *.test
pointer-profile.test value-profile.test
==> ls *.prof
pointer-mem.prof value-mem.prof
Time to put it all together. Let’s start with the pointer profiles.
NOTE: If this is your first time using
pprof
, you will want to installgraphviz
so you can generate different visualizations based off the profile data, even though this example we’ll be using the interactive mode ofpprof
. On a Mac and usingbrew
this can be done viabrew install graphviz
==> go tool pprof pointer-profile.test pointer-mem.prof
File: pointer-profile.test
Type: alloc_space
Time: Nov 6, 2021 at 8:47am (CDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
What we have done above is run the pprof
tool, using the test binary output pointer-profile.test
and using the profiling output file pointer-mem.prof
. From here we can execute different commands. The one we are going to focus on is top
(pprof) top
Showing nodes accounting for 180.05MB, 99.45% of 181.05MB total
Dropped 14 nodes (cum <= 0.91MB)
Showing top 10 nodes out of 17
flat flat% sum% cum cum%
67MB 37.01% 37.01% 118.51MB 65.46% article-code.encodePostsWithPointerSemanticIteration
61.04MB 33.71% 70.72% 61.04MB 33.71% article-code.initPosts
51MB 28.17% 98.89% 51MB 28.17% time.Time.MarshalJSON
1MB 0.55% 99.45% 1MB 0.55% runtime.allocm
0 0% 99.45% 179.55MB 99.17% article-code.Test_encodePostsWithPointerSemanticIteration1000000
0 0% 99.45% 51.50MB 28.45% encoding/json.(*Encoder).Encode
0 0% 99.45% 51.50MB 28.45% encoding/json.(*encodeState).marshal
0 0% 99.45% 51.50MB 28.45% encoding/json.(*encodeState).reflectValue
0 0% 99.45% 51MB 28.17% encoding/json.condAddrEncoder.encode
0 0% 99.45% 51MB 28.17% encoding/json.marshalerEncoder
Let’s break down the above pointer iteration memory profile:
- Line 2 tells us that we have a total of
181.05MB
memory used article-code.encodePostsWithPointerSemanticIteration
used the most memory of any methods, with67MB
article-code.initPosts
used the second most memory, coming in at61.04MB
- Coming in third in memory usage with
51MB
is the time.Time.MarshalJSON method.
Before we try and find any takeaways from this, let’s run the value iteration profile and break that down.
==> go tool pprof value-profile.test value-mem.prof
File: value-profile.test
Type: alloc_space
Time: Nov 6, 2021 at 9:02am (CDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 165.04MB, 98.19% of 168.09MB total
Dropped 35 nodes (cum <= 0.84MB)
Showing top 10 nodes out of 11
flat flat% sum% cum cum%
61.04MB 36.31% 36.31% 61.04MB 36.31% article-code.initPosts
57.50MB 34.21% 70.52% 105.03MB 62.49% article-code.encodePostsWithValueSemanticIteration
46.50MB 27.67% 98.19% 46.50MB 27.67% time.Time.MarshalJSON
0 0% 98.19% 166.07MB 98.80% article-code.Test_encodePostsWithValueSemanticIteration1000000
0 0% 98.19% 47.53MB 28.28% encoding/json.(*Encoder).Encode
0 0% 98.19% 47.03MB 27.98% encoding/json.(*encodeState).marshal
0 0% 98.19% 47.03MB 27.98% encoding/json.(*encodeState).reflectValue
0 0% 98.19% 46.50MB 27.67% encoding/json.condAddrEncoder.encode
0 0% 98.19% 46.50MB 27.67% encoding/json.marshalerEncoder
0 0% 98.19% 47.03MB 27.98% encoding/json.structEncoder.encode
This tells us the following about the value iteration profile:
- Line 2 tells us that we have a total of
168.09MB
memory used - This time,
article-code.initPosts
used the most memory, coming in at61.04MB
- Second in memory usage with
57.50MB
is thearticle-code.encodePostsWithValueSemanticIteration
- And third again is
time.Time.MarshalJSON
with46.50MB
.
Conclusion
The biggest conclusion for me is the value iteration test using approximately 13 MB more than the pointer iteration test. Before I began this, my initial instincts led me to believe that the pointer iteration test would use less, because it isn’t making any copies of the []Post
for the loop.
It took working with 1 million elements in a slice to realize a 13 MB difference. When deciding between using value and pointer semantic iteration, you should take other things into consideration (ex: data safety) rather than how much memory one method or another may use.
This also proves that we should be careful with our assumptions, and Go provides us with excellent tools to test and verify our assumptions.