Value vs Pointer Semantic Iteration

How is memory usage affected by iteration methods?

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 install graphviz so you can generate different visualizations based off the profile data, even though this example we’ll be using the interactive mode of pprof. On a Mac and using brew this can be done via brew 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, with 67MB
  • article-code.initPosts used the second most memory, coming in at 61.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 at 61.04MB
  • Second in memory usage with 57.50MB is the article-code.encodePostsWithValueSemanticIteration
  • And third again is time.Time.MarshalJSON with 46.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.