Memory Profiling

Master comprehensive memory profiling techniques in Go to identify memory leaks, optimize allocations, and improve garbage collection performance.

Memory Profiling Overview

Memory profiling in Go provides insights into:

  • Heap allocation patterns - Where and how memory is allocated
  • Memory leaks - Objects that should be garbage collected but aren't
  • Allocation frequency - Functions causing frequent allocations
  • Memory usage trends - How memory usage changes over time

Heap Profiling

Basic Heap Profiling

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
    "os"
    "runtime"
    "runtime/pprof"
    "time"
)

func main() {
    // Enable pprof endpoint
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // Memory-intensive application simulation
    go simulateMemoryUsage()

    // Take heap snapshot programmatically
    takeHeapSnapshot("before")

    // Let application run
    time.Sleep(30 * time.Second)

    takeHeapSnapshot("after")

    // Keep server running
    select {}
}

func takeHeapSnapshot(name string) {
    runtime.GC() // Force garbage collection before snapshot

    f, err := os.Create(fmt.Sprintf("heap_%s.prof", name))
    if err != nil {
        log.Printf("Could not create heap profile: %v", err)
        return
    }
    defer f.Close()

    if err := pprof.WriteHeapProfile(f); err != nil {
        log.Printf("Could not write heap profile: %v", err)
    }

    log.Printf("Heap profile saved: heap_%s.prof", name)
}

func simulateMemoryUsage() {
    // Simulate different allocation patterns
    var data [][]byte

    for i := 0; ; i++ {
        // Allocate different sizes
        size := 1024 * (i%100 + 1)
        chunk := make([]byte, size)

        // Fill with data to prevent optimization
        for j := range chunk {
            chunk[j] = byte(j % 256)
        }

        data = append(data, chunk)

        // Occasionally clean up some data
        if i%50 == 0 && len(data) > 25 {
            data = data[25:] // Remove old allocations
        }

        time.Sleep(100 * time.Millisecond)
    }
}

Advanced Heap Analysis

// Memory-intensive data structures for profiling
type MemoryAnalyzer struct {
    cache       map[string]*CacheEntry
    bufferPool  sync.Pool
    activeConns map[int]*Connection
    metrics     *MemoryMetrics
    mu          sync.RWMutex
}

type CacheEntry struct {
    Key        string
    Value      []byte
    Timestamp  time.Time
    AccessCount int64
}

type Connection struct {
    ID       int
    Buffer   []byte
    Metadata map[string]interface{}
    Created  time.Time
}

type MemoryMetrics struct {
    AllocationsPerSecond int64
    TotalAllocatedBytes  int64
    ActiveObjects        int64
    GCCount             int64
}

func NewMemoryAnalyzer() *MemoryAnalyzer {
    ma := &MemoryAnalyzer{
        cache:       make(map[string]*CacheEntry),
        activeConns: make(map[int]*Connection),
        metrics:     &MemoryMetrics{},
        bufferPool: sync.Pool{
            New: func() interface{} {
                return make([]byte, 4096)
            },
        },
    }

    // Start memory monitoring
    go ma.monitorMemory()

    return ma
}

func (ma *MemoryAnalyzer) AddCacheEntry(key string, data []byte) {
    ma.mu.Lock()
    defer ma.mu.Unlock()

    entry := &CacheEntry{
        Key:       key,
        Value:     make([]byte, len(data)),
        Timestamp: time.Now(),
    }
    copy(entry.Value, data)

    ma.cache[key] = entry
    atomic.AddInt64(&ma.metrics.AllocationsPerSecond, 1)
}

func (ma *MemoryAnalyzer) GetCacheEntry(key string) *CacheEntry {
    ma.mu.RLock()
    entry, exists := ma.cache[key]
    ma.mu.RUnlock()

    if exists {
        atomic.AddInt64(&entry.AccessCount, 1)
    }

    return entry
}

func (ma *MemoryAnalyzer) AddConnection(id int) {
    ma.mu.Lock()
    defer ma.mu.Unlock()

    conn := &Connection{
        ID:       id,
        Buffer:   make([]byte, 8192),
        Metadata: make(map[string]interface{}),
        Created:  time.Now(),
    }

    // Add some metadata
    conn.Metadata["user_id"] = fmt.Sprintf("user_%d", id)
    conn.Metadata["session"] = fmt.Sprintf("session_%d_%d", id, time.Now().Unix())

    ma.activeConns[id] = conn
    atomic.AddInt64(&ma.metrics.ActiveObjects, 1)
}

func (ma *MemoryAnalyzer) RemoveConnection(id int) {
    ma.mu.Lock()
    defer ma.mu.Unlock()

    if _, exists := ma.activeConns[id]; exists {
        delete(ma.activeConns, id)
        atomic.AddInt64(&ma.metrics.ActiveObjects, -1)
    }
}

func (ma *MemoryAnalyzer) ProcessData(data []byte) []byte {
    // Get buffer from pool
    buffer := ma.bufferPool.Get().([]byte)
    defer ma.bufferPool.Put(buffer)

    // Process data (simulate work)
    processed := make([]byte, len(data)*2)
    for i, b := range data {
        processed[i*2] = b
        processed[i*2+1] = b ^ 0xFF
    }

    return processed
}

func (ma *MemoryAnalyzer) monitorMemory() {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)

        log.Printf("Memory Stats:")
        log.Printf("  Alloc: %d KB", m.Alloc/1024)
        log.Printf("  TotalAlloc: %d KB", m.TotalAlloc/1024)
        log.Printf("  Sys: %d KB", m.Sys/1024)
        log.Printf("  NumGC: %d", m.NumGC)
        log.Printf("  HeapObjects: %d", m.HeapObjects)
        log.Printf("  Cache entries: %d", len(ma.cache))
        log.Printf("  Active connections: %d", len(ma.activeConns))
    }
}

// Simulate memory leak scenario
func (ma *MemoryAnalyzer) SimulateMemoryLeak() {
    // This creates a memory leak by keeping references
    leakedData := make(map[int][]byte)

    for i := 0; ; i++ {
        // Allocate but never clean up
        data := make([]byte, 1024*1024) // 1MB allocation
        leakedData[i] = data

        time.Sleep(time.Second)

        // Simulate some cleanup (but not enough)
        if i%100 == 0 && len(leakedData) > 50 {
            // Only remove a few entries
            for j := 0; j < 5; j++ {
                delete(leakedData, i-50+j)
            }
        }
    }
}

Memory Allocation Profiling

// Allocation profiling for specific functions
func BenchmarkMemoryAllocations(b *testing.B) {
    b.Run("SliceAppend", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var slice []int
            for j := 0; j < 1000; j++ {
                slice = append(slice, j)
            }
        }
    })

    b.Run("SlicePrealloc", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            slice := make([]int, 0, 1000)
            for j := 0; j < 1000; j++ {
                slice = append(slice, j)
            }
        }
    })

    b.Run("StringConcat", func(b *testing.B) {
        b.ReportAllocs()
        parts := []string{"hello", "world", "golang"}
        for i := 0; i < b.N; i++ {
            result := ""
            for _, part := range parts {
                result += part
            }
            _ = result
        }
    })

    b.Run("StringBuilder", func(b *testing.B) {
        b.ReportAllocs()
        parts := []string{"hello", "world", "golang"}
        for i := 0; i < b.N; i++ {
            var builder strings.Builder
            for _, part := range parts {
                builder.WriteString(part)
            }
            _ = builder.String()
        }
    })
}

// Custom allocation tracking
type AllocationTracker struct {
    allocations map[string]*AllocationInfo
    mu          sync.RWMutex
}

type AllocationInfo struct {
    Count     int64
    TotalSize int64
    LastSeen  time.Time
}

var globalTracker = &AllocationTracker{
    allocations: make(map[string]*AllocationInfo),
}

func TrackAllocation(category string, size int64) {
    globalTracker.mu.Lock()
    defer globalTracker.mu.Unlock()

    info, exists := globalTracker.allocations[category]
    if !exists {
        info = &AllocationInfo{}
        globalTracker.allocations[category] = info
    }

    info.Count++
    info.TotalSize += size
    info.LastSeen = time.Now()
}

func GetAllocationStats() map[string]*AllocationInfo {
    globalTracker.mu.RLock()
    defer globalTracker.mu.RUnlock()

    result := make(map[string]*AllocationInfo)
    for k, v := range globalTracker.allocations {
        result[k] = &AllocationInfo{
            Count:     v.Count,
            TotalSize: v.TotalSize,
            LastSeen:  v.LastSeen,
        }
    }

    return result
}

// Instrumented allocation functions
func TrackedMakeSlice(category string, size int) []byte {
    data := make([]byte, size)
    TrackAllocation(category, int64(size))
    return data
}

func TrackedMakeMap(category string, size int) map[string]interface{} {
    m := make(map[string]interface{}, size)
    TrackAllocation(category, int64(size*32)) // Estimate map overhead
    return m
}

Memory Leak Detection

Leak Detection Patterns

// Common memory leak scenarios and detection
type LeakDetector struct {
    snapshots []MemorySnapshot
    interval  time.Duration
    threshold float64 // Growth rate threshold
}

type MemorySnapshot struct {
    Timestamp   time.Time
    HeapAlloc   uint64
    HeapSys     uint64
    HeapObjects uint64
    NumGC       uint32
}

func NewLeakDetector(interval time.Duration, threshold float64) *LeakDetector {
    ld := &LeakDetector{
        interval:  interval,
        threshold: threshold,
    }

    go ld.monitor()
    return ld
}

func (ld *LeakDetector) monitor() {
    ticker := time.NewTicker(ld.interval)
    defer ticker.Stop()

    for range ticker.C {
        ld.takeSnapshot()
        ld.analyzeGrowth()
    }
}

func (ld *LeakDetector) takeSnapshot() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)

    snapshot := MemorySnapshot{
        Timestamp:   time.Now(),
        HeapAlloc:   m.Alloc,
        HeapSys:     m.Sys,
        HeapObjects: m.HeapObjects,
        NumGC:       m.NumGC,
    }

    ld.snapshots = append(ld.snapshots, snapshot)

    // Keep only last 100 snapshots
    if len(ld.snapshots) > 100 {
        ld.snapshots = ld.snapshots[1:]
    }
}

func (ld *LeakDetector) analyzeGrowth() {
    if len(ld.snapshots) < 10 {
        return
    }

    recent := ld.snapshots[len(ld.snapshots)-10:]
    growthRate := ld.calculateGrowthRate(recent)

    if growthRate > ld.threshold {
        log.Printf("MEMORY LEAK DETECTED: Growth rate %.2f%% exceeds threshold %.2f%%",
            growthRate*100, ld.threshold*100)

        ld.generateLeakReport()
    }
}

func (ld *LeakDetector) calculateGrowthRate(snapshots []MemorySnapshot) float64 {
    if len(snapshots) < 2 {
        return 0
    }

    first := snapshots[0]
    last := snapshots[len(snapshots)-1]

    if first.HeapAlloc == 0 {
        return 0
    }

    return float64(last.HeapAlloc-first.HeapAlloc) / float64(first.HeapAlloc)
}

func (ld *LeakDetector) generateLeakReport() {
    log.Println("=== MEMORY LEAK REPORT ===")

    if len(ld.snapshots) > 0 {
        latest := ld.snapshots[len(ld.snapshots)-1]
        log.Printf("Current heap allocation: %d bytes", latest.HeapAlloc)
        log.Printf("Current heap objects: %d", latest.HeapObjects)
        log.Printf("Total GC runs: %d", latest.NumGC)
    }

    // Take heap dump
    takeHeapDump()

    // Print allocation stats
    stats := GetAllocationStats()
    log.Println("Allocation breakdown:")
    for category, info := range stats {
        log.Printf("  %s: %d allocations, %d bytes total", 
            category, info.Count, info.TotalSize)
    }
}

func takeHeapDump() {
    runtime.GC()

    filename := fmt.Sprintf("leak_dump_%d.prof", time.Now().Unix())
    f, err := os.Create(filename)
    if err != nil {
        log.Printf("Could not create heap dump: %v", err)
        return
    }
    defer f.Close()

    if err := pprof.WriteHeapProfile(f); err != nil {
        log.Printf("Could not write heap dump: %v", err)
    } else {
        log.Printf("Heap dump saved: %s", filename)
    }
}

Goroutine Leak Detection

// Goroutine leak detection and monitoring
type GoroutineMonitor struct {
    baseline    int
    threshold   int
    checkInterval time.Duration
}

func NewGoroutineMonitor(threshold int, interval time.Duration) *GoroutineMonitor {
    gm := &GoroutineMonitor{
        baseline:     runtime.NumGoroutine(),
        threshold:    threshold,
        checkInterval: interval,
    }

    go gm.monitor()
    return gm
}

func (gm *GoroutineMonitor) monitor() {
    ticker := time.NewTicker(gm.checkInterval)
    defer ticker.Stop()

    for range ticker.C {
        current := runtime.NumGoroutine()
        growth := current - gm.baseline

        if growth > gm.threshold {
            log.Printf("GOROUTINE LEAK DETECTED: %d goroutines (baseline: %d, growth: %d)",
                current, gm.baseline, growth)

            gm.dumpGoroutines()
        }
    }
}

func (gm *GoroutineMonitor) dumpGoroutines() {
    filename := fmt.Sprintf("goroutines_%d.prof", time.Now().Unix())
    f, err := os.Create(filename)
    if err != nil {
        log.Printf("Could not create goroutine dump: %v", err)
        return
    }
    defer f.Close()

    if profile := pprof.Lookup("goroutine"); profile != nil {
        profile.WriteTo(f, 2) // 2 = debug level with stack traces
        log.Printf("Goroutine dump saved: %s", filename)
    }
}

// Simulate goroutine leak
func SimulateGoroutineLeak() {
    for i := 0; ; i++ {
        go func(id int) {
            // Goroutine that never exits
            ch := make(chan bool)
            <-ch // Blocks forever
        }(i)

        time.Sleep(100 * time.Millisecond)
    }
}

Memory Usage Optimization

Object Pool Implementation

// Advanced object pooling for memory optimization
type ObjectPool struct {
    pool     sync.Pool
    maxSize  int
    created  int64
    borrowed int64
    returned int64
}

func NewObjectPool(factory func() interface{}, maxSize int) *ObjectPool {
    return &ObjectPool{
        pool: sync.Pool{New: factory},
        maxSize: maxSize,
    }
}

func (op *ObjectPool) Get() interface{} {
    obj := op.pool.Get()
    atomic.AddInt64(&op.borrowed, 1)
    return obj
}

func (op *ObjectPool) Put(obj interface{}) {
    if atomic.LoadInt64(&op.created) < int64(op.maxSize) {
        op.pool.Put(obj)
        atomic.AddInt64(&op.returned, 1)
    }
}

func (op *ObjectPool) Stats() (created, borrowed, returned int64) {
    return atomic.LoadInt64(&op.created),
           atomic.LoadInt64(&op.borrowed),
           atomic.LoadInt64(&op.returned)
}

// Usage example: Buffer pool
var bufferPool = NewObjectPool(func() interface{} {
    return make([]byte, 4096)
}, 1000)

func ProcessWithPool(data []byte) []byte {
    buffer := bufferPool.Get().([]byte)
    defer bufferPool.Put(buffer)

    // Reset buffer
    buffer = buffer[:0]

    // Process data
    buffer = append(buffer, data...)
    buffer = append(buffer, []byte("_processed")...)

    // Return copy since buffer goes back to pool
    result := make([]byte, len(buffer))
    copy(result, buffer)
    return result
}

Memory-Efficient Data Structures

// Memory-optimized data structures
type CompactSlice struct {
    data     []uint32
    bitWidth int
    mask     uint32
    length   int
}

func NewCompactSlice(maxValue uint32, capacity int) *CompactSlice {
    // Calculate minimum bits needed
    bitWidth := 32 - bits.LeadingZeros32(maxValue)
    if bitWidth == 0 {
        bitWidth = 1
    }

    valuesPerUint32 := 32 / bitWidth
    arraySize := (capacity + valuesPerUint32 - 1) / valuesPerUint32

    return &CompactSlice{
        data:     make([]uint32, arraySize),
        bitWidth: bitWidth,
        mask:     (1 << bitWidth) - 1,
    }
}

func (cs *CompactSlice) Set(index int, value uint32) {
    if index >= cs.length {
        cs.length = index + 1
    }

    valuesPerUint32 := 32 / cs.bitWidth
    arrayIndex := index / valuesPerUint32
    bitOffset := (index % valuesPerUint32) * cs.bitWidth

    // Clear existing value
    cs.data[arrayIndex] &^= cs.mask << bitOffset

    // Set new value
    cs.data[arrayIndex] |= (value & cs.mask) << bitOffset
}

func (cs *CompactSlice) Get(index int) uint32 {
    if index >= cs.length {
        return 0
    }

    valuesPerUint32 := 32 / cs.bitWidth
    arrayIndex := index / valuesPerUint32
    bitOffset := (index % valuesPerUint32) * cs.bitWidth

    return (cs.data[arrayIndex] >> bitOffset) & cs.mask
}

func (cs *CompactSlice) MemoryUsage() int {
    return len(cs.data) * 4 // 4 bytes per uint32
}

// Regular slice for comparison
func (cs *CompactSlice) RegularSliceMemoryUsage() int {
    return cs.length * 4 // 4 bytes per uint32
}

Memory Profiling Best Practices

1. Profile Realistic Workloads

// Generate realistic memory load for profiling
func GenerateRealisticLoad() {
    // Simulate web server with caching
    cache := make(map[string][]byte)

    for i := 0; i < 10000; i++ {
        key := fmt.Sprintf("key_%d", i)
        value := make([]byte, rand.Intn(1024)+512) // 512-1536 bytes

        // Fill with realistic data
        for j := range value {
            value[j] = byte(rand.Intn(256))
        }

        cache[key] = value

        // Simulate cache cleanup
        if i%1000 == 0 && len(cache) > 5000 {
            // Remove random entries
            keysToRemove := make([]string, 0, 100)
            count := 0
            for k := range cache {
                keysToRemove = append(keysToRemove, k)
                count++
                if count >= 100 {
                    break
                }
            }

            for _, k := range keysToRemove {
                delete(cache, k)
            }
        }
    }
}

2. Continuous Monitoring

// Production memory monitoring
func StartProductionMemoryMonitoring() {
    go func() {
        ticker := time.NewTicker(30 * time.Second)
        defer ticker.Stop()

        for range ticker.C {
            var m runtime.MemStats
            runtime.ReadMemStats(&m)

            // Log key metrics
            log.Printf("Memory: Alloc=%dKB Sys=%dKB NumGC=%d HeapObjects=%d",
                m.Alloc/1024, m.Sys/1024, m.NumGC, m.HeapObjects)

            // Alert on memory growth
            if m.Alloc > 500*1024*1024 { // 500MB
                log.Printf("HIGH MEMORY USAGE ALERT: %d MB allocated", m.Alloc/(1024*1024))
            }
        }
    }()
}

3. Automated Leak Detection

// Automated leak detection in tests
func TestMemoryLeaks(t *testing.T) {
    // Get baseline
    runtime.GC()
    var m1 runtime.MemStats
    runtime.ReadMemStats(&m1)

    // Run test code
    for i := 0; i < 1000; i++ {
        // Code that might leak
        data := make([]byte, 1024)
        _ = data
    }

    // Force GC and check for leaks
    runtime.GC()
    runtime.GC() // Run twice to ensure cleanup

    var m2 runtime.MemStats
    runtime.ReadMemStats(&m2)

    growth := m2.Alloc - m1.Alloc
    if growth > 100*1024 { // 100KB threshold
        t.Errorf("Potential memory leak: %d bytes not freed", growth)
    }
}

Memory profiling is essential for building efficient Go applications and preventing memory-related performance issues in production environments.

results matching ""

    No results matching ""