Memory Profiling and Optimization

Memory usage is often a critical factor in Go application performance, especially as it relates to garbage collection overhead.

Memory Profiling Techniques

Here’s how to effectively profile memory usage in Go applications:

package main

import (
	"flag"
	"fmt"
	"log"
	"os"
	"runtime"
	"runtime/pprof"
)

// Function that allocates memory
func allocateMemory() {
	// Allocate a large slice that will stay in memory
	data := make([]byte, 100*1024*1024) // 100 MB
	
	// Do something with the data to prevent compiler optimizations
	for i := range data {
		data[i] = byte(i % 256)
	}
	
	// Keep a reference to prevent garbage collection
	// In a real app, this might be stored in a global variable or cache
	keepReference(data)
}

// Prevent compiler from optimizing away our allocations
var reference []byte

func keepReference(data []byte) {
	reference = data
}

// Function that leaks memory
func leakMemory() {
	// This function simulates a memory leak by storing data in a global slice
	// without ever cleaning it up
	for i := 0; i < 1000; i++ {
		// Each iteration leaks 1MB
		leak := make([]byte, 1024*1024)
		for j := range leak {
			leak[j] = byte(j % 256)
		}
		leakedData = append(leakedData, leak)
	}
}

// Global variable to simulate a memory leak
var leakedData [][]byte

func main() {
	// Parse command line flags
	memprofile := flag.String("memprofile", "", "write memory profile to file")
	leakMode := flag.Bool("leak", false, "demonstrate memory leak")
	flag.Parse()
	
	// Allocate memory
	allocateMemory()
	
	// Optionally demonstrate a memory leak
	if *leakMode {
		fmt.Println("Demonstrating memory leak...")
		leakMemory()
	}
	
	// Force garbage collection to get accurate memory stats
	runtime.GC()
	
	// Print memory statistics
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
	fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
	fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
	fmt.Printf("\tNumGC = %v\n", m.NumGC)
	
	// Write memory profile if requested
	if *memprofile != "" {
		f, err := os.Create(*memprofile)
		if err != nil {
			log.Fatal("could not create memory profile: ", err)
		}
		defer f.Close()
		
		// Write memory profile
		if err := pprof.WriteHeapProfile(f); err != nil {
			log.Fatal("could not write memory profile: ", err)
		}
		fmt.Printf("Memory profile written to %s\n", *memprofile)
		fmt.Println("Analyze with: go tool pprof -http=:8080", *memprofile)
	}
	
	// Show how to use net/http/pprof for continuous memory profiling
	fmt.Println("\nFor continuous memory profiling in a web server:")
	fmt.Println("1. Import _ \"net/http/pprof\"")
	fmt.Println("2. Access heap profile at: http://localhost:8080/debug/pprof/heap")
	fmt.Println("3. Download and analyze with: go tool pprof http://localhost:8080/debug/pprof/heap")
	
	// Show how to use testing package for benchmark memory profiling
	fmt.Println("\nFor memory profiling during benchmarks:")
	fmt.Println("go test -bench=. -memprofile=mem.prof ./...")
	fmt.Println("Then analyze with: go tool pprof -http=:8080 mem.prof")
}

// Convert bytes to megabytes
func bToMb(b uint64) uint64 {
	return b / 1024 / 1024
}

Detecting and Fixing Memory Leaks

Memory leaks in Go often manifest as growing heap usage over time. Here’s how to detect and fix them:

package main

import (
	"fmt"
	"net/http"
	_ "net/http/pprof" // Import for side effects
	"os"
	"runtime"
	"time"
)

// Cache that doesn't clean up old entries (leak)
type LeakyCache struct {
	data map[string][]byte
	// Missing: expiration mechanism
}

func NewLeakyCache() *LeakyCache {
	return &LeakyCache{
		data: make(map[string][]byte),
	}
}

func (c *LeakyCache) Set(key string, value []byte) {
	c.data[key] = value
}

func (c *LeakyCache) Get(key string) []byte {
	return c.data[key]
}

// Fixed cache with expiration
type FixedCache struct {
	data       map[string]cacheEntry
	maxEntries int
}

type cacheEntry struct {
	value      []byte
	expiration time.Time
}

func NewFixedCache(maxEntries int) *FixedCache {
	cache := &FixedCache{
		data:       make(map[string]cacheEntry),
		maxEntries: maxEntries,
	}
	
	// Start cleanup goroutine
	go func() {
		ticker := time.NewTicker(5 * time.Minute)
		defer ticker.Stop()
		
		for range ticker.C {
			cache.cleanup()
		}
	}()
	
	return cache
}

func (c *FixedCache) Set(key string, value []byte, ttl time.Duration) {
	// Enforce maximum entries
	if len(c.data) >= c.maxEntries {
		c.evictOldest()
	}
	
	c.data[key] = cacheEntry{
		value:      value,
		expiration: time.Now().Add(ttl),
	}
}

func (c *FixedCache) Get(key string) ([]byte, bool) {
	entry, found := c.data[key]
	if !found {
		return nil, false
	}
	
	// Check if expired
	if time.Now().After(entry.expiration) {
		delete(c.data, key)
		return nil, false
	}
	
	return entry.value, true
}

func (c *FixedCache) cleanup() {
	now := time.Now()
	for key, entry := range c.data {
		if now.After(entry.expiration) {
			delete(c.data, key)
		}
	}
}

func (c *FixedCache) evictOldest() {
	var oldestKey string
	var oldestTime time.Time
	
	// Find the oldest entry
	first := true
	for key, entry := range c.data {
		if first || entry.expiration.Before(oldestTime) {
			oldestKey = key
			oldestTime = entry.expiration
			first = false
		}
	}
	
	// Remove oldest entry
	if oldestKey != "" {
		delete(c.data, oldestKey)
	}
}

// Simulate a memory leak
func simulateMemoryLeak() {
	// Create a leaky cache
	cache := NewLeakyCache()
	
	// Start HTTP server for pprof
	go func() {
		fmt.Println("Starting pprof server on :8080")
		http.ListenAndServe(":8080", nil)
	}()
	
	// Continuously add data to the cache without cleanup
	for i := 0; ; i++ {
		key := fmt.Sprintf("key-%d", i)
		value := make([]byte, 1024*1024) // 1MB per entry
		cache.Set(key, value)
		
		// Print memory stats every 100 iterations
		if i%100 == 0 {
			var m runtime.MemStats
			runtime.ReadMemStats(&m)
			fmt.Printf("Iteration %d: Alloc = %v MiB, Sys = %v MiB\n",
				i, bToMb(m.Alloc), bToMb(m.Sys))
			time.Sleep(100 * time.Millisecond)
		}
		
		// Exit after some iterations in this example
		if i >= 1000 {
			break
		}
	}
}

// Demonstrate fixed cache
func demonstrateFixedCache() {
	// Create a fixed cache with expiration
	cache := NewFixedCache(100) // Max 100 entries
	
	// Add data with expiration
	for i := 0; i < 200; i++ {
		key := fmt.Sprintf("key-%d", i)
		value := make([]byte, 1024*1024) // 1MB per entry
		cache.Set(key, value, 1*time.Minute)
		
		// Print memory stats every 50 iterations
		if i%50 == 0 {
			var m runtime.MemStats
			runtime.ReadMemStats(&m)
			fmt.Printf("Iteration %d: Alloc = %v MiB, Sys = %v MiB, Entries: %d\n",
				i, bToMb(m.Alloc), bToMb(m.Sys), len(cache.data))
			time.Sleep(100 * time.Millisecond)
		}
	}
	
	// Force GC to see the effect
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("After GC: Alloc = %v MiB, Sys = %v MiB, Entries: %d\n",
		bToMb(m.Alloc), bToMb(m.Sys), len(cache.data))
}

func main() {
	if len(os.Args) > 1 && os.Args[1] == "leak" {
		fmt.Println("Simulating memory leak...")
		simulateMemoryLeak()
	} else {
		fmt.Println("Demonstrating fixed cache...")
		demonstrateFixedCache()
	}
	
	fmt.Println("\nTo analyze memory usage:")
	fmt.Println("1. Run with leak simulation: go run main.go leak")
	fmt.Println("2. In another terminal: go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap")
}

Optimizing for Garbage Collection

Understanding and optimizing for Go’s garbage collector can significantly improve application performance:

package main

import (
	"fmt"
	"runtime"
	"runtime/debug"
	"time"
)

// Function that generates a lot of garbage
func generateGarbage() {
	for i := 0; i < 10; i++ {
		// Allocate 100MB
		_ = make([]byte, 100*1024*1024)
	}
}

// Function that demonstrates GC tuning
func demonstrateGCTuning() {
	// Print initial GC settings
	fmt.Println("Default GC settings:")
	printGCStats()
	
	// Run with default settings
	fmt.Println("\nRunning with default GC settings...")
	measureGCPause(func() {
		generateGarbage()
	})
	
	// Tune GC to be less aggressive (higher memory usage, fewer GC cycles)
	fmt.Println("\nSetting higher GC percentage (less frequent GC)...")
	debug.SetGCPercent(500) // Default is 100
	printGCStats()
	
	// Run with tuned settings
	fmt.Println("\nRunning with tuned GC settings...")
	measureGCPause(func() {
		generateGarbage()
	})
	
	// Reset GC to default
	debug.SetGCPercent(100)
	
	// Demonstrate manual GC control
	fmt.Println("\nDemonstrating manual GC control...")
	
	// Disable GC temporarily
	fmt.Println("Disabling GC...")
	debug.SetGCPercent(-1)
	
	// Allocate memory without GC
	fmt.Println("Allocating memory with GC disabled...")
	for i := 0; i < 5; i++ {
		_ = make([]byte, 100*1024*1024)
		printMemStats()
		time.Sleep(100 * time.Millisecond)
	}
	
	// Manually trigger GC
	fmt.Println("\nManually triggering GC...")
	runtime.GC()
	printMemStats()
	
	// Re-enable GC
	fmt.Println("\nRe-enabling GC...")
	debug.SetGCPercent(100)
	printGCStats()
}

// Function to measure GC pause times
func measureGCPause(fn func()) {
	// Get initial GC stats
	var statsBefore runtime.MemStats
	runtime.ReadMemStats(&statsBefore)
	numGCBefore := statsBefore.NumGC
	
	// Run the function
	start := time.Now()
	fn()
	elapsed := time.Since(start)
	
	// Force a GC to get accurate stats
	runtime.GC()
	
	// Get GC stats after
	var statsAfter runtime.MemStats
	runtime.ReadMemStats(&statsAfter)
	
	// Calculate GC stats
	numGC := statsAfter.NumGC - numGCBefore
	totalPause := time.Duration(0)
	
	// Calculate total pause time
	// Note: PauseNs is a circular buffer of recent GC pause times
	for i := numGCBefore; i < statsAfter.NumGC; i++ {
		idx := i % uint32(len(statsAfter.PauseNs))
		totalPause += time.Duration(statsAfter.PauseNs[idx])
	}
	
	// Print results
	fmt.Printf("Execution time: %v\n", elapsed)
	fmt.Printf("Number of GCs: %d\n", numGC)
	if numGC > 0 {
		fmt.Printf("Total GC pause: %v\n", totalPause)
		fmt.Printf("Average GC pause: %v\n", totalPause/time.Duration(numGC))
		fmt.Printf("GC pause percentage: %.2f%%\n",
			float64(totalPause)/float64(elapsed)*100)
	}
}

// Print current GC settings
func printGCStats() {
	fmt.Printf("GC Percentage: %d%%\n", debug.SetGCPercent(debug.SetGCPercent(-1)))
	
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("Next GC target: %v MiB\n", bToMb(m.NextGC))
}

// Print memory statistics
func printMemStats() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("Alloc: %v MiB, Sys: %v MiB, NumGC: %v\n",
		bToMb(m.Alloc), bToMb(m.Sys), m.NumGC)
}

func main() {
	// Set max memory to show GC behavior more clearly
	debug.SetMemoryLimit(1024 * 1024 * 1024) // 1GB
	
	// Demonstrate GC tuning
	demonstrateGCTuning()
	
	fmt.Println("\nGC Tuning Best Practices:")
	fmt.Println("1. Monitor GC frequency and pause times in production")
	fmt.Println("2. Use GOGC environment variable for system-wide tuning")
	fmt.Println("3. Consider debug.SetGCPercent for application-specific tuning")
	fmt.Println("4. For latency-sensitive applications, consider increasing GOGC")
	fmt.Println("5. For memory-constrained environments, consider decreasing GOGC")
}