Memory Profiling and Optimisation in Go: How One Line Allocated 687GB
I built a multi-threaded Redis clone in Go as part of the CodeCrafters challenge. It supports a subset of Redis features; you can check the project page for details.
I’ve been reading about profiling and optimisation in Go, so I decided to profile my Redis implementation and see how I could improve it. A note before we start: this is not production code. I made some deliberately lazy implementation choices, knowing faster alternatives existed.
There aren’t many good articles about profiling real Go programs, so I wanted to share what I learnt by actually doing it. Let’s jump straight in.
Run the profiler
To start tuning the program, we have to enable profiling. I used net/http/pprof so I could profile the server in real time while it handles traffic (see the diff).
"net/http"
_ "net/http/pprof"
"github.com/codecrafters-io/redis-starter-go/internal/server"
)
var pprofAddr = flag.String(
"pprof-addr",
"",
"serve live pprof endpoints on this address (e.g. localhost:6060)",
)
func main() {
flag.Parse()
if *pprofAddr != "" {
go func() {
log.Println(http.ListenAndServe(*pprofAddr, nil))
}()
}
server := server.New("6379")
if err := server.ListenAndAccept(); err != nil {
log.Fatalln(err)
}
}
Then we run the application with the profiler enabled:
$ go build ./app/main.go
$ ./main -pprof-addr=:3000
Benchmark first
With the profiler and application running, we need load; profiles of an idle server show nothing interesting. I wrote stress.sh, a small script that wraps redis-benchmark with the command below and prints a summary table of the results:
$ redis-benchmark -p 6379 -t set,get,incr,lpush,lpop,lrange -n 300000 -r 1000000 -c 100 -d 128 -q
All benchmarks in this post were run on a 16-core M3 Max with 64GB of RAM.
$ ./stress.sh
running: -t set,get,incr,lpop,lrange_100 -n 300000 -r 1000000 -c 100 -d 128 against :6379
| COMMAND | REQUESTS | TIME(s) | RPS | AVG(ms) | P50(ms) | P99(ms) |
|---|---|---|---|---|---|---|
| SET | 300000 | 2.19 | 137174.20 | 0.386 | 0.391 | 0.583 |
| GET | 300000 | 2.15 | 139275.77 | 0.374 | 0.391 | 0.471 |
| INCR | 300000 | 2.16 | 138696.25 | 0.376 | 0.391 | 0.479 |
| RPUSH | 300000 | 2.13 | 140845.06 | 0.371 | 0.383 | 0.479 |
| LPOP | 300000 | 2.14 | 139860.14 | 0.373 | 0.383 | 0.471 |
| LPUSH (needed to benchmark LRANGE) | 300000 | 71.49 | 4196.16 | 23.813 | 24.511 | 50.879 |
| LRANGE_100 (first 100 elements) | 300000 | 4.95 | 60642.81 | 1.174 | 0.647 | 8.295 |
| TOTAL | 2100000 | 87.21 | 24079.81 |
(The odd “needed to benchmark LRANGE” label is redis-benchmark’s doing: whenever LRANGE is tested, it first runs an LPUSH phase to fill the lists. That pre-fill reports a normal stats row, so it doubles as our LPUSH benchmark.)
One row ruins the party. LPUSH took roughly 70 of the 87 seconds and averaged ~4,100 rps, 14x slower than the next-slowest command (LRANGE). Worse, RPUSH is nearly the same operation, and it’s 33x faster. Something is clearly wrong with LPUSH, and there has to be an explanation.
Profile the CPU
The answer is to measure. We’re going to record a 60-second CPU profile while the benchmark runs. The command below asks the live server for a profile and drops us into pprof’s interactive terminal:
go tool pprof "http://localhost:3000/debug/pprof/profile?seconds=60"
The first thing I do with any profile is run top10, which ranks the biggest CPU consumers:
File: main
Type: cpu
Time: 2026-07-07 20:16:52 WAT
Duration: 60.19s, Total samples = 144.65s (240.31%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 140.50s, 97.13% of 144.65s total
Dropped 219 nodes (cum <= 0.72s)
Showing top 10 nodes out of 81
flat flat% sum% cum cum%
63.05s 43.59% 43.59% 63.05s 43.59% runtime.pthread_cond_signal
34.34s 23.74% 67.33% 34.34s 23.74% runtime.pthread_cond_wait
15.34s 10.60% 77.93% 15.34s 10.60% syscall.rawsyscalln
9.84s 6.80% 84.74% 9.84s 6.80% runtime.kevent
7.14s 4.94% 89.67% 7.14s 4.94% runtime.usleep
3.42s 2.36% 92.04% 3.79s 2.62% runtime.tryDeferToSpanScan
2.95s 2.04% 94.08% 2.95s 2.04% runtime.pthread_kill
2.14s 1.48% 95.55% 2.33s 1.61% runtime.typePointers.next
1.20s 0.83% 96.38% 3.18s 2.20% runtime.scanObject
1.08s 0.75% 97.13% 1.08s 0.75% runtime.wbBufFlush1
Interesting, and a dead end. The biggest CPU consumers are thread sleep/wake churn (pthread_cond_signal/wait) and network syscalls: the cost of a multi-threaded server juggling 100 concurrent clients. Our own functions don’t even make the top 10, because command handling is in-memory work that’s nearly invisible to a sampling profiler. Nothing here explains why LPUSH specifically is 33x slower than RPUSH; both would pay the same networking and scheduling costs.
So if LPUSH isn’t burning CPU on computation, maybe it’s doing something else expensive. Could memory allocation be the bottleneck? Let’s profile the memory:
$ go tool pprof http://localhost:3000/debug/pprof/allocs
File: main
Type: alloc_space
Time: 2026-07-07 20:51:55 WAT
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 714828.96MB, 99.93% of 715300.94MB total
Dropped 60 nodes (cum <= 3576.50MB)
Showing top 10 nodes out of 13
flat flat% sum% cum cum%
687904.78MB 96.17% 96.17% 687911.28MB 96.17% github.com/codecrafters-io/redis-starter-go/internal/store.(*Store).LPush
17938.07MB 2.51% 98.68% 17938.07MB 2.51% strings.(*Builder).WriteString (inline)
4166.50MB 0.58% 99.26% 4166.50MB 0.58% io.WriteString
4124.06MB 0.58% 99.84% 4135.08MB 0.58% fmt.Sprintf
476.01MB 0.067% 99.90% 4587.07MB 0.64% github.com/codecrafters-io/redis-starter-go/internal/resp.BulkString (inline)
212.03MB 0.03% 99.93% 715297.44MB 100% github.com/codecrafters-io/redis-starter-go/internal/server.(*Server).handleConnection
7.50MB 0.001% 99.93% 22474.64MB 3.14% github.com/codecrafters-io/redis-starter-go/internal/resp.BulkStringArray
0 0% 99.93% 710918.91MB 99.39% github.com/codecrafters-io/redis-starter-go/internal/handler.(*Handler).Handle
0 0% 99.93% 710595.88MB 99.34% github.com/codecrafters-io/redis-starter-go/internal/handler.(*Handler).handleCommand
0 0% 99.93% 687929.29MB 96.17% github.com/codecrafters-io/redis-starter-go/internal/handler.(*Handler).handleLPush
Voila! There’s our problem. During the benchmark, LPUSH allocated a total of 687GB of memory.
Hmm, that can’t be right. These benchmarks ran on a 64GB MacBook; there’s no way the application used 687GB of RAM. And it didn’t: alloc_space measures total allocated memory, not in-use memory. LPUSH really did allocate 687GB over the course of the run, but almost all of it became garbage moments after being allocated, and the garbage collector kept reclaiming it. We can confirm by switching to the in-use view:
(pprof) sample_index=inuse_space
(pprof) top10
Showing nodes accounting for 248.29MB, 99.60% of 249.29MB total
Dropped 15 nodes (cum <= 1.25MB)
Showing top 10 nodes out of 22
flat flat% sum% cum cum%
113.02MB 45.34% 45.34% 247.28MB 99.20% github.com/codecrafters-io/redis-starter-go/internal/server.(*Server).handleConnection
55.97MB 22.45% 67.79% 55.97MB 22.45% strings.(*Builder).WriteString (inline)
46.09MB 18.49% 86.28% 46.09MB 18.49% github.com/codecrafters-io/redis-starter-go/internal/store.(*Store).setUnlocked
14.50MB 5.82% 92.10% 15MB 6.02% fmt.Sprintf
9.12MB 3.66% 95.76% 9.12MB 3.66% io.WriteString
4.58MB 1.84% 97.59% 4.58MB 1.84% github.com/codecrafters-io/redis-starter-go/internal/store.(*Store).LPush
3.50MB 1.40% 99.00% 18.50MB 7.42% github.com/codecrafters-io/redis-starter-go/internal/resp.BulkString (inline)
1.50MB 0.6% 99.60% 1.50MB 0.6% runtime.mallocgc
0 0% 99.60% 125.14MB 50.20% github.com/codecrafters-io/redis-starter-go/internal/handler.(*Handler).Handle
0 0% 99.60% 125.14MB 50.20% github.com/codecrafters-io/redis-starter-go/internal/handler.(*Handler).handleCommand
Only 249MB is actually being held by the application, and LPUSH’s contribution to live memory is a whole 4.58MB. So LPUSH generated 687GB of pure garbage. To find where, we switch back to alloc_space and ask pprof to annotate the function’s source, line by line:
(pprof) sample_index=alloc_space
(pprof) list LPush
Total: 698.54GB
ROUTINE ======================== github.com/codecrafters-io/redis-starter-go/internal/store.(*Store).LPush in internal/store/store.go
671.78GB 671.79GB (flat, cum) 96.17% of Total
. . 140:func (s *Store) LPush(k string, v []string) int {
. . 141: slices.Reverse(v)
. 6.50MB 142: s.Lock()
. . 143: defer s.Unlock()
. . 144:
. . 145: s.ensureList(k)
. . 146:
. . 147: var popped int
. . 148:
. . 149: // check for listeners
. . 150: listeners, exist := s.listListeners[k]
. . 151: if exist {
. . 152: for i := 0; i < min(len(listeners), len(v)); i++ {
. . 153: listeners[i] <- v[popped]
. . 154: popped++
. . 155: }
. . 156: s.listListeners[k] = slices.Clone(s.listListeners[k][popped:])
. . 157: if len(s.listListeners[k]) == 0 {
. . 158: delete(s.listListeners, k)
. . 159: }
. . 160: }
. . 161:
671.78GB 671.78GB 162: s.kvList[k] = append(v[popped:], s.kvList[k]...)
. . 163:
. . 164: return len(s.kvList[k]) + popped
. . 165:}
. . 166:
. . 167:func (s *Store) LRange(k string, start, end int) []string {
(pprof)
There it is: one line directly responsible for 671GB of the 687GB. The remaining ~16GB comes from the smaller allocations around it.
Why does one line allocate 672GB?
Look at line 162 closely:
s.kvList[k] = append(v[popped:], s.kvList[k]...)
This prepends the new values by appending the old list onto them. append can’t grow v in place to fit the whole list, so every single LPUSH allocates a brand-new backing array big enough for new values + entire existing list, and copies every element of the old list into it. The old array becomes garbage immediately. The next LPUSH? Same thing again, except the list is now one element longer.
So the cost of each push grows with the size of the list. If a list grows by one element per push, the accounting looks like this:
push 1: allocate array of 1
push 2: allocate array of 2 (copy 1 old element)
push 3: allocate array of 3 (copy 2)
...
push n: allocate array of n (copy n-1)
────────────────────────────────────────
total: 1+2+3+...+n = n(n+1)/2 ≈ n²/2
The list itself only ever holds O(n) memory (that’s why the in-use numbers looked normal), but the total allocated over time is O(n²), and that’s exactly the quantity alloc_space measures. Our benchmark hammered 300k LPUSHes into a small set of lists, so the lists got long, every push paid to copy all of it, and n²/2 quietly integrated into 672GB. That number was never a size anything was; it’s the area under the churn curve, all of it allocated, copied once, and handed straight to the garbage collector.
Compare that with RPUSH, which was 33x faster:
s.kvList[k] = append(s.kvList[k], v...)
Same append, opposite behaviour. Appending to the existing slice lets Go’s append use its growth strategy: when capacity runs out, it allocates double, so reallocations only happen at sizes 1, 2, 4, 8, … n. Total copying across n pushes is about 2n, amortized O(1) per push. LPUSH can’t benefit from that, because the spare capacity append leaves is at the tail of the array, and prepending needs room at the head. Every LPUSH lands on an exact-fit array and pays full price, every time.
Two appends, six lines apart. One is O(1), the other is O(n²). I’d read this code a dozen times and never noticed. The profiler pointed at it in seconds.
The fix
We need LPUSH to stop reallocating on every push. What if we create one array with two indices, left and right, and start writing from the middle? LPUSH writes leftward, RPUSH writes rightward, and only when either index hits the end of the array do we reallocate one twice the size, copy the elements into the middle, and reset the indices.
There’s a name for this data structure: an array-backed deque, a close cousin of the ring buffer. Both ends now get the same amortized O(1) treatment that append was giving RPUSH. You can see the full diff here.
We run the benchmark again:
running: -t set,get,incr,rpush,lpop,lrange_100 -n 300000 -r 1000000 -c 100 -d 128 against :6379
| COMMAND | REQUESTS | TIME(s) | RPS | AVG(ms) | P50(ms) | P99(ms) |
|---|---|---|---|---|---|---|
| SET | 300000 | 2.16 | 139017.61 | 0.379 | 0.383 | 0.543 |
| GET | 300000 | 2.13 | 140845.06 | 0.371 | 0.383 | 0.471 |
| INCR | 300000 | 2.13 | 141110.08 | 0.370 | 0.383 | 0.471 |
| RPUSH | 300000 | 2.15 | 139729.84 | 0.374 | 0.391 | 0.471 |
| LPOP | 300000 | 2.15 | 139534.88 | 0.375 | 0.391 | 0.479 |
| LPUSH (needed to benchmark LRANGE) | 300000 | 2.13 | 140581.06 | 0.372 | 0.383 | 0.471 |
| LRANGE_100 (first 100 elements) | 300000 | 5.00 | 60024.01 | 1.190 | 0.639 | 8.823 |
| TOTAL | 2100000 | 17.85 | 117647.06 |
LPUSH went from 4,196 to 140,581 requests per second: 33x faster, now indistinguishable from RPUSH. The whole suite dropped from 87 to 18 seconds. Purely from fixing how memory was allocated. One more look at the allocation profile to confirm:
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 27244.29MB, 99.23% of 27456.18MB total
Dropped 53 nodes (cum <= 137.28MB)
Showing top 10 nodes out of 15
flat flat% sum% cum cum%
17959.79MB 65.41% 65.41% 17959.79MB 65.41% strings.(*Builder).WriteString (inline)
4188.57MB 15.26% 80.67% 4189.57MB 15.26% fmt.Sprintf
4077.85MB 14.85% 95.52% 4077.85MB 14.85% io.WriteString
465.51MB 1.70% 97.22% 4643.58MB 16.91% github.com/codecrafters-io/redis-starter-go/internal/resp.BulkString (inline)
236.53MB 0.86% 98.08% 236.53MB 0.86% strings.genSplit
220.03MB 0.8% 98.88% 27453.68MB 100% github.com/codecrafters-io/redis-starter-go/internal/server.(*Server).handleConnection
88.50MB 0.32% 99.20% 325.03MB 1.18% github.com/codecrafters-io/redis-starter-go/internal/parser.parseArray
7.50MB 0.027% 99.23% 22558.36MB 82.16% github.com/codecrafters-io/redis-starter-go/internal/resp.BulkStringArray
0 0% 99.23% 23154.79MB 84.33% github.com/codecrafters-io/redis-starter-go/internal/handler.(*Handler).Handle
0 0% 99.23% 22829.76MB 83.15% github.com/codecrafters-io/redis-starter-go/internal/handler.(*Handler).handleCommand
(pprof)
LPUSH is gone from the top 10 entirely. Total allocations for the same workload: 27GB, down from 715GB.
Takeaways
- An empty profile is still an answer. The CPU profile “failing” to show my code wasn’t a dead end. It was the profiler correctly telling me the cost wasn’t computation, which is what justified looking at memory.
alloc_spaceandinuse_spaceanswer different questions. In-use tells you what’s holding memory (footprint, leaks). Alloc tells you what’s generating garbage (GC pressure). My 687GB existed in one view and was 4.58MB in the other. Both numbers were true.- Cumulative allocation is an area, not a size. A quadratic algorithm can hide behind a perfectly normal live heap. The n²/2 sum is invisible to
top,htop, and your dashboard; only the allocation profile sees it.
The code is from my CodeCrafters Redis challenge implementation in Go. Follow me on GitHub for the full source.