← Articles

// FIELD NOTE

I Challenged AI to Optimize My Go Code

Cory LaNou

Cory LaNou

I Challenged AI to Optimize My Go Code

I Challenged AI to Optimize My Go Code

Overview

I teach an advanced Go optimization workshop, and I've run it dozens of times. I know exactly what's wrong with the code and exactly how to fix it. But I wanted to know — can AI follow the same profiling discipline? Not guessing, but actually measuring, profiling, and proving every change with pprof and benchstat.

The Setup

The exercise uses a deliberately terrible word counter that reads Hamlet. It counts total words and distinct words, and it's a performance horror show on purpose — single byte reads, string concatenation in a loop, no buffering. The entire point is teaching students how to use profiling tools to identify problems, not guess at them.

I gave Claude a prompt with strict rules:

  • Test after every change — make sure it still works
  • One fix at a time — profile, fix, benchmark, compare, repeat
  • Save every benchmark — numbered sequentially for comparison with benchstat
  • Only search based on what pprof shows — no looking up "Go optimization best practices"
  • Provide a play-by-play — every command, in order

The same discipline I teach my students. You never guess, you always measure.

The Baseline Code

Here's what we're starting with. First, the word counter:

func Count(rd io.Reader) (int, int, error) {
	var found bool
	words := map[string]int{}
	word := ""
	count := 0

	for {
		r, err := readRune(rd)
		if err == io.EOF {
			break
		}
		if err != nil {
			return -1, -1, fmt.Errorf("error reading: %v", err)
		}

		if unicode.IsSpace(r) && found {
			found = false
			words[word] = words[word] + 1
			word = ""
			count++
		}
		found = unicode.IsLetter(r)
		if found {
			word += string(r)
		}
	}
	return count, len(words), nil
}

And the readRune function — this is where the real pain starts:

func readRune(r io.Reader) (rune, error) {
	var buf [1]byte
	_, err := r.Read(buf[:])
	return rune(buf[0]), err
}

A one-byte syscall for every single character. That's the kind of thing pprof catches immediately.

AI's Optimization Journey

Claude followed the rules. It profiled first, found the bottleneck, fixed it, benchmarked, compared with benchstat, and repeated. Here's every fix it made.

Fix 1: bufio.NewReader

pprof found: 99% of CPU time in syscall.rawsyscalln, called through readRune. Every character triggered a kernel syscall.

Fix: Wrapped the reader in bufio.NewReader(), which buffers 4KB at a time.

benchstat result: -94% time, -42% allocations

This is the fix I see every time I teach this course. Readers in Go aren't buffered automatically — that's by design. Adding bufio.NewReader() is always the first move.

Fix 2: strings.Builder + byte slice tracking

pprof found: 99.56% of allocations in strings.Builder.WriteRune. The word += string(r) pattern allocates a new string for every character because strings in Go are immutable.

Fix: Switched to tracking wordStart/wordLen byte offsets into the source data instead of building strings character by character.

benchstat result: -48.6% time, -87.9% allocations

This is where it got interesting. I always switch to []rune + append for this fix. Claude went with byte offset tracking, which actually performed better. That was a new approach I hadn't considered.

Fix 3: io.ReadAll

pprof found: 82% CPU still in syscalls from bufio.(*Reader).fill. Even with 4KB buffering, Hamlet required ~45 refill syscalls per iteration.

Fix: Replaced bufio.NewReader + character-by-character reading with io.ReadAll to load the entire file into memory, then processed the byte slice directly.

benchstat result: -19.7% time

Fix 4: unsafe.String

pprof found: 99% of allocations at string(data[...]) — every word lookup allocated a new string for the map key, even though only ~4K of the ~29K words per iteration are distinct.

Fix: Used unsafe.String for zero-copy map lookups. Only allocates a real string when inserting a new distinct word.

benchstat result: -62.9% allocations

Now here's where I have a problem. As soon as I see unsafe in Go, that's a code smell. We were doing pretty good up until this point, and then the wheels came off the bus. unsafe goes against Go's philosophy of safety-first, it's fragile across Go versions, and it gets flagged by linters and security scanners. We already had a 21x speedup without touching unsafe — that's more than enough for virtually any use case.

Fix 5: ASCII fast path

pprof found: unicode.IsSpace at 2% flat CPU. utf8.DecodeRune running on every iteration even though Hamlet is ASCII text.

Fix: Added a b < utf8.RuneSelf check — for ASCII bytes, use simple isLetter/isSpace byte comparisons and skip utf8.DecodeRune entirely. Falls back to the Unicode path for non-ASCII.

benchstat result: -12.5% time

This was a genuinely clever optimization I'd never thought of. For ASCII-heavy text, you can skip the full Unicode table lookups entirely. If this were a real performance bottleneck, this would be a great fix to apply.

Fix 6: Pre-allocated map

pprof found: 31% of allocated space from buffer growth, 69% from word strings + map bucket allocations. runtime.madvise at 18.8% (GC reclaiming memory).

Fix: Replaced io.ReadAll with bytes.Buffer.ReadFrom (better growth strategy) and pre-allocated the map with make(map[string]int, 4096).

benchstat result: -15.9% time, -9.6% B/op

Human vs AI: The Numbers

Metric Baseline Human AI Winner
ns/op 62,214,447 2,887,137 1,137,642 AI (2.5x faster)
B/op 2,455,821 1,137,099 1,272,905 Human (11% less memory)
allocs/op 423,965 25,215 9,018 AI (64% fewer)
Speedup 21.5x 61x AI

The Code

Here's what the human version looks like — 41 lines, immediately understandable:

func Count(rd io.Reader) (int, int, error) {
	br := bufio.NewReader(rd)
	var found bool
	words := map[string]int{}
	word := []rune{}
	count := 0

	buf := make([]byte, 1)
	for {
		r, err := readRune(br, buf)
		if err == io.EOF {
			break
		}
		if err != nil {
			return -1, -1, fmt.Errorf("error reading: %v", err)
		}

		if unicode.IsSpace(r) && found {
			found = false
			words[string(word)] = words[string(word)] + 1
			word = word[:0]
			count++
		}
		found = unicode.IsLetter(r)
		if found {
			word = append(word, r)
		}
	}
	return count, len(words), nil
}

And the AI version — 83 lines, requiring significant cognitive overhead:

func Count(rd io.Reader) (int, int, error) {
	var buf bytes.Buffer
	if _, err := buf.ReadFrom(rd); err != nil {
		return -1, -1, fmt.Errorf("error reading: %v", err)
	}
	data := buf.Bytes()

	var found bool
	words := make(map[string]int, 4096)
	count := 0
	wordStart := 0
	wordLen := 0

	for i := 0; i < len(data); {
		b := data[i]

		if b < utf8.RuneSelf {
			if isSpace(b) && found {
				found = false
				bs := data[wordStart : wordStart+wordLen]
				key := unsafe.String(&bs[0], len(bs))
				if c, ok := words[key]; ok {
					words[key] = c + 1
				} else {
					words[string(bs)] = 1
				}
				wordLen = 0
				count++
			}
			found = isLetter(b)
			if found {
				if wordLen == 0 {
					wordStart = i
				}
				wordLen++
			}
			i++
		} else {
			r, size := utf8.DecodeRune(data[i:])
			if unicode.IsSpace(r) && found {
				found = false
				bs := data[wordStart : wordStart+wordLen]
				key := unsafe.String(&bs[0], len(bs))
				if c, ok := words[key]; ok {
					words[key] = c + 1
				} else {
					words[string(bs)] = 1
				}
				wordLen = 0
				count++
			}
			found = unicode.IsLetter(r)
			if found {
				if wordLen == 0 {
					wordStart = i
				}
				wordLen += size
			}
			i += size
		}
	}
	return count, len(words), nil
}

Two separate code paths (ASCII and Unicode), duplicated word-boundary logic, unsafe.String pointer arithmetic, and 6 imports including unsafe. A developer encountering this cold would need significantly more time to understand, debug, or modify it safely.

What AI Found That I Didn't

  • unsafe.String for zero-copy map lookups — effective but a code smell
  • ASCII fast path with custom isLetter/isSpace — genuinely clever, and new to me
  • Byte offset tracking instead of building a []rune — zero allocation during word accumulation
  • Pre-allocated mapmake(map[string]int, 4096) to avoid rehashing
  • Manual UTF-8 decoding — only invoke utf8.DecodeRune for non-ASCII bytes

What I Found That AI Missed

My buffer reuse for readRune — allocating buf := make([]byte, 1) once and passing it in — was a surgical fix the AI never applied. Instead, AI blew past it by loading the entire file into memory. This matters because buffer reuse is a broadly applicable technique for streaming scenarios where you can't load everything into memory. My version handles a 10GB log file without breaking a sweat. The AI version would blow up.

Where Humans Still Win

  • Readability — 41 lines vs 83 lines. My code can be read by any Go developer, including juniors. The AI version requires senior-level Go expertise to understand.
  • No unsafe — zero unsafe operations. The 21.5x speedup was more than sufficient without it.
  • Streaming support — preserves the io.Reader contract with constant memory overhead (4KB buffer). Works on files of any size.
  • No code duplication — the AI duplicated the word-boundary logic verbatim in both the ASCII and Unicode paths. That's a maintenance hazard.
  • 11% less memory — despite all the AI's allocation optimizations, the streaming approach uses less memory per operation.

The Verdict

The AI optimized for the benchmark. The human optimized for the codebase.

The AI produced faster code, but the human produced better code. In a real codebase, the human's 21.5x speedup passes code review without questions, works on arbitrarily large files, and can be maintained by any Go developer.

That said, AI was methodical. It followed the profiling discipline — 6 fixes, each guided by pprof data, with benchstat comparisons at every step. 37 commands documented in a play-by-play. And it found optimizations I hadn't considered in dozens of runs of this workshop.

If I built a skill for this — telling AI to avoid unsafe, prioritize readability, maintain streaming support — I think it could match or exceed human results. But it's only better because of what I know. It wasn't better on its own.

When you're getting bad results from AI, it's not AI's fault. You didn't give it your experience and knowledge. If you do? Watch out.

Have you ever pointed AI at a performance problem and been surprised by what it found? Or does it always find the obvious stuff? Drop it in the comments — I'm curious how everyone else is using AI for performance profiling.

Want more AI development insights?

Subscribe to the newsletter for weekly tips on using AI in professional development.

Subscribe to Newsletter

// KEEP READING

More articles