<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://solomon-os.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://solomon-os.github.io/" rel="alternate" type="text/html" /><updated>2026-07-09T16:44:26+00:00</updated><id>https://solomon-os.github.io/feed.xml</id><title type="html">Solomon’s Blog</title><subtitle>A personal blog</subtitle><author><name>Solomon Oladiran</name></author><entry><title type="html">Memory Profiling and Optimisation in Go: How One Line Allocated 687GB</title><link href="https://solomon-os.github.io/blog/how-one-line-of-go-allocated-687gb/" rel="alternate" type="text/html" title="Memory Profiling and Optimisation in Go: How One Line Allocated 687GB" /><published>2026-07-07T10:00:00+00:00</published><updated>2026-07-07T10:00:00+00:00</updated><id>https://solomon-os.github.io/blog/how-one-line-of-go-allocated-687gb</id><content type="html" xml:base="https://solomon-os.github.io/blog/how-one-line-of-go-allocated-687gb/"><![CDATA[<p>I built a multi-threaded <a href="https://github.com/solomon-os/redis-server">Redis clone in Go</a> as part of the <a href="https://codecrafters.io">CodeCrafters challenge</a>. It supports a subset of Redis features; you can check the project page for details.</p>

<p>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.</p>

<p>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.</p>

<h2 id="run-the-profiler">Run the profiler</h2>

<p>To start tuning the program, we have to enable profiling. I used <code class="language-plaintext highlighter-rouge">net/http/pprof</code> so I could profile the server in real time while it handles traffic (<a href="https://github.com/solomon-os/redis-server/commit/ae2b96bc3248a17f903c03f876d238fbbad3e8c4">see the diff</a>).</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code>	<span class="s">"net/http"</span>
	<span class="n">_</span> <span class="s">"net/http/pprof"</span>

	<span class="s">"github.com/codecrafters-io/redis-starter-go/internal/server"</span>
<span class="p">)</span>

<span class="k">var</span> <span class="n">pprofAddr</span> <span class="o">=</span> <span class="n">flag</span><span class="o">.</span><span class="n">String</span><span class="p">(</span>
	<span class="s">"pprof-addr"</span><span class="p">,</span>
	<span class="s">""</span><span class="p">,</span>
	<span class="s">"serve live pprof endpoints on this address (e.g. localhost:6060)"</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">flag</span><span class="o">.</span><span class="n">Parse</span><span class="p">()</span>

	<span class="k">if</span> <span class="o">*</span><span class="n">pprofAddr</span> <span class="o">!=</span> <span class="s">""</span> <span class="p">{</span>
		<span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
			<span class="n">log</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="n">http</span><span class="o">.</span><span class="n">ListenAndServe</span><span class="p">(</span><span class="o">*</span><span class="n">pprofAddr</span><span class="p">,</span> <span class="no">nil</span><span class="p">))</span>
		<span class="p">}()</span>
	<span class="p">}</span>

	<span class="n">server</span> <span class="o">:=</span> <span class="n">server</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"6379"</span><span class="p">)</span>

	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">server</span><span class="o">.</span><span class="n">ListenAndAccept</span><span class="p">();</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">log</span><span class="o">.</span><span class="n">Fatalln</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>

<p>Then we run the application with the profiler enabled:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>go build ./app/main.go
<span class="nv">$ </span>./main <span class="nt">-pprof-addr</span><span class="o">=</span>:3000
</code></pre></div></div>

<h2 id="benchmark-first">Benchmark first</h2>

<p>With the profiler and application running, we need load; profiles of an idle server show nothing interesting. I wrote <code class="language-plaintext highlighter-rouge">stress.sh</code>, a small script that wraps <code class="language-plaintext highlighter-rouge">redis-benchmark</code> with the command below and prints a summary table of the results:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>redis-benchmark <span class="nt">-p</span> 6379 <span class="nt">-t</span> <span class="nb">set</span>,get,incr,lpush,lpop,lrange <span class="nt">-n</span> 300000 <span class="nt">-r</span> 1000000 <span class="nt">-c</span> 100 <span class="nt">-d</span> 128 <span class="nt">-q</span>
</code></pre></div></div>

<p>All benchmarks in this post were run on a 16-core M3 Max with 64GB of RAM.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>./stress.sh
running: <span class="nt">-t</span> <span class="nb">set</span>,get,incr,lpop,lrange_100 <span class="nt">-n</span> 300000 <span class="nt">-r</span> 1000000 <span class="nt">-c</span> 100 <span class="nt">-d</span> 128 against :6379
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>COMMAND</th>
      <th>REQUESTS</th>
      <th>TIME(s)</th>
      <th>RPS</th>
      <th>AVG(ms)</th>
      <th>P50(ms)</th>
      <th>P99(ms)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SET</td>
      <td>300000</td>
      <td>2.19</td>
      <td>137174.20</td>
      <td>0.386</td>
      <td>0.391</td>
      <td>0.583</td>
    </tr>
    <tr>
      <td>GET</td>
      <td>300000</td>
      <td>2.15</td>
      <td>139275.77</td>
      <td>0.374</td>
      <td>0.391</td>
      <td>0.471</td>
    </tr>
    <tr>
      <td>INCR</td>
      <td>300000</td>
      <td>2.16</td>
      <td>138696.25</td>
      <td>0.376</td>
      <td>0.391</td>
      <td>0.479</td>
    </tr>
    <tr>
      <td>RPUSH</td>
      <td>300000</td>
      <td>2.13</td>
      <td>140845.06</td>
      <td>0.371</td>
      <td>0.383</td>
      <td>0.479</td>
    </tr>
    <tr>
      <td>LPOP</td>
      <td>300000</td>
      <td>2.14</td>
      <td>139860.14</td>
      <td>0.373</td>
      <td>0.383</td>
      <td>0.471</td>
    </tr>
    <tr>
      <td>LPUSH (needed to benchmark LRANGE)</td>
      <td>300000</td>
      <td>71.49</td>
      <td>4196.16</td>
      <td>23.813</td>
      <td>24.511</td>
      <td>50.879</td>
    </tr>
    <tr>
      <td>LRANGE_100 (first 100 elements)</td>
      <td>300000</td>
      <td>4.95</td>
      <td>60642.81</td>
      <td>1.174</td>
      <td>0.647</td>
      <td>8.295</td>
    </tr>
    <tr>
      <td><strong>TOTAL</strong></td>
      <td>2100000</td>
      <td>87.21</td>
      <td>24079.81</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>(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.)</p>

<p>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 <strong>33x faster</strong>. Something is clearly wrong with LPUSH, and there has to be an explanation.</p>

<h2 id="profile-the-cpu">Profile the CPU</h2>

<p>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:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go tool pprof <span class="s2">"http://localhost:3000/debug/pprof/profile?seconds=60"</span>
</code></pre></div></div>

<p>The first thing I do with any profile is run <code class="language-plaintext highlighter-rouge">top10</code>, which ranks the biggest CPU consumers:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>File: main
Type: cpu
Time: 2026-07-07 20:16:52 WAT
Duration: 60.19s, Total samples <span class="o">=</span> 144.65s <span class="o">(</span>240.31%<span class="o">)</span>
Entering interactive mode <span class="o">(</span><span class="nb">type</span> <span class="s2">"help"</span> <span class="k">for </span>commands, <span class="s2">"o"</span> <span class="k">for </span>options<span class="o">)</span>
<span class="o">(</span>pprof<span class="o">)</span> top10
Showing nodes accounting <span class="k">for </span>140.50s, 97.13% of 144.65s total
Dropped 219 nodes <span class="o">(</span>cum &lt;<span class="o">=</span> 0.72s<span class="o">)</span>
Showing top 10 nodes out of 81
      flat  flat%   <span class="nb">sum</span>%        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
</code></pre></div></div>

<p>Interesting, and a dead end. The biggest CPU consumers are thread sleep/wake churn (<code class="language-plaintext highlighter-rouge">pthread_cond_signal</code>/<code class="language-plaintext highlighter-rouge">wait</code>) 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.</p>

<p>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:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>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 <span class="o">(</span><span class="nb">type</span> <span class="s2">"help"</span> <span class="k">for </span>commands, <span class="s2">"o"</span> <span class="k">for </span>options<span class="o">)</span>
<span class="o">(</span>pprof<span class="o">)</span> top10
Showing nodes accounting <span class="k">for </span>714828.96MB, 99.93% of 715300.94MB total
Dropped 60 nodes <span class="o">(</span>cum &lt;<span class="o">=</span> 3576.50MB<span class="o">)</span>
Showing top 10 nodes out of 13
      flat  flat%   <span class="nb">sum</span>%        cum   cum%
687904.78MB 96.17% 96.17% 687911.28MB 96.17%  github.com/codecrafters-io/redis-starter-go/internal/store.<span class="o">(</span><span class="k">*</span>Store<span class="o">)</span>.LPush
17938.07MB  2.51% 98.68% 17938.07MB  2.51%  strings.<span class="o">(</span><span class="k">*</span>Builder<span class="o">)</span>.WriteString <span class="o">(</span>inline<span class="o">)</span>
 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 <span class="o">(</span>inline<span class="o">)</span>
  212.03MB  0.03% 99.93% 715297.44MB   100%  github.com/codecrafters-io/redis-starter-go/internal/server.<span class="o">(</span><span class="k">*</span>Server<span class="o">)</span>.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.<span class="o">(</span><span class="k">*</span>Handler<span class="o">)</span>.Handle
         0     0% 99.93% 710595.88MB 99.34%  github.com/codecrafters-io/redis-starter-go/internal/handler.<span class="o">(</span><span class="k">*</span>Handler<span class="o">)</span>.handleCommand
         0     0% 99.93% 687929.29MB 96.17%  github.com/codecrafters-io/redis-starter-go/internal/handler.<span class="o">(</span><span class="k">*</span>Handler<span class="o">)</span>.handleLPush

</code></pre></div></div>

<p>Voila! There’s our problem. During the benchmark, LPUSH allocated a total of <strong>687GB</strong> of memory.</p>

<p>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: <code class="language-plaintext highlighter-rouge">alloc_space</code> measures <em>total allocated</em> memory, not <em>in-use</em> 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:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">(</span>pprof<span class="o">)</span> <span class="nv">sample_index</span><span class="o">=</span>inuse_space
<span class="o">(</span>pprof<span class="o">)</span> top10
Showing nodes accounting <span class="k">for </span>248.29MB, 99.60% of 249.29MB total
Dropped 15 nodes <span class="o">(</span>cum &lt;<span class="o">=</span> 1.25MB<span class="o">)</span>
Showing top 10 nodes out of 22
      flat  flat%   <span class="nb">sum</span>%        cum   cum%
  113.02MB 45.34% 45.34%   247.28MB 99.20%  github.com/codecrafters-io/redis-starter-go/internal/server.<span class="o">(</span><span class="k">*</span>Server<span class="o">)</span>.handleConnection
   55.97MB 22.45% 67.79%    55.97MB 22.45%  strings.<span class="o">(</span><span class="k">*</span>Builder<span class="o">)</span>.WriteString <span class="o">(</span>inline<span class="o">)</span>
   46.09MB 18.49% 86.28%    46.09MB 18.49%  github.com/codecrafters-io/redis-starter-go/internal/store.<span class="o">(</span><span class="k">*</span>Store<span class="o">)</span>.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.<span class="o">(</span><span class="k">*</span>Store<span class="o">)</span>.LPush
    3.50MB  1.40% 99.00%    18.50MB  7.42%  github.com/codecrafters-io/redis-starter-go/internal/resp.BulkString <span class="o">(</span>inline<span class="o">)</span>
    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.<span class="o">(</span><span class="k">*</span>Handler<span class="o">)</span>.Handle
         0     0% 99.60%   125.14MB 50.20%  github.com/codecrafters-io/redis-starter-go/internal/handler.<span class="o">(</span><span class="k">*</span>Handler<span class="o">)</span>.handleCommand
</code></pre></div></div>

<p>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 <code class="language-plaintext highlighter-rouge">alloc_space</code> and ask pprof to annotate the function’s source, line by line:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">(</span>pprof<span class="o">)</span> <span class="nv">sample_index</span><span class="o">=</span>alloc_space
<span class="o">(</span>pprof<span class="o">)</span> list LPush
Total: 698.54GB
ROUTINE <span class="o">========================</span> github.com/codecrafters-io/redis-starter-go/internal/store.<span class="o">(</span><span class="k">*</span>Store<span class="o">)</span>.LPush <span class="k">in </span>internal/store/store.go
  671.78GB   671.79GB <span class="o">(</span>flat, cum<span class="o">)</span> 96.17% of Total
         <span class="nb">.</span>          <span class="nb">.</span>    140:func <span class="o">(</span>s <span class="k">*</span>Store<span class="o">)</span> LPush<span class="o">(</span>k string, v <span class="o">[]</span>string<span class="o">)</span> int <span class="o">{</span>
         <span class="nb">.</span>          <span class="nb">.</span>    141:	slices.Reverse<span class="o">(</span>v<span class="o">)</span>
         <span class="nb">.</span>     6.50MB    142:	s.Lock<span class="o">()</span>
         <span class="nb">.</span>          <span class="nb">.</span>    143:	defer s.Unlock<span class="o">()</span>
         <span class="nb">.</span>          <span class="nb">.</span>    144:
         <span class="nb">.</span>          <span class="nb">.</span>    145:	s.ensureList<span class="o">(</span>k<span class="o">)</span>
         <span class="nb">.</span>          <span class="nb">.</span>    146:
         <span class="nb">.</span>          <span class="nb">.</span>    147:	var popped int
         <span class="nb">.</span>          <span class="nb">.</span>    148:
         <span class="nb">.</span>          <span class="nb">.</span>    149:	// check <span class="k">for </span>listeners
         <span class="nb">.</span>          <span class="nb">.</span>    150:	listeners, exist :<span class="o">=</span> s.listListeners[k]
         <span class="nb">.</span>          <span class="nb">.</span>    151:	<span class="k">if </span>exist <span class="o">{</span>
         <span class="nb">.</span>          <span class="nb">.</span>    152:		<span class="k">for </span>i :<span class="o">=</span> 0<span class="p">;</span> i &lt; min<span class="o">(</span>len<span class="o">(</span>listeners<span class="o">)</span>, len<span class="o">(</span>v<span class="o">))</span><span class="p">;</span> i++ <span class="o">{</span>
         <span class="nb">.</span>          <span class="nb">.</span>    153:			listeners[i] &lt;- v[popped]
         <span class="nb">.</span>          <span class="nb">.</span>    154:			popped++
         <span class="nb">.</span>          <span class="nb">.</span>    155:		<span class="o">}</span>
         <span class="nb">.</span>          <span class="nb">.</span>    156:		s.listListeners[k] <span class="o">=</span> slices.Clone<span class="o">(</span>s.listListeners[k][popped:]<span class="o">)</span>
         <span class="nb">.</span>          <span class="nb">.</span>    157:		<span class="k">if </span>len<span class="o">(</span>s.listListeners[k]<span class="o">)</span> <span class="o">==</span> 0 <span class="o">{</span>
         <span class="nb">.</span>          <span class="nb">.</span>    158:			delete<span class="o">(</span>s.listListeners, k<span class="o">)</span>
         <span class="nb">.</span>          <span class="nb">.</span>    159:		<span class="o">}</span>
         <span class="nb">.</span>          <span class="nb">.</span>    160:	<span class="o">}</span>
         <span class="nb">.</span>          <span class="nb">.</span>    161:
  671.78GB   671.78GB    162:	s.kvList[k] <span class="o">=</span> append<span class="o">(</span>v[popped:], s.kvList[k]...<span class="o">)</span>
         <span class="nb">.</span>          <span class="nb">.</span>    163:
         <span class="nb">.</span>          <span class="nb">.</span>    164:	<span class="k">return </span>len<span class="o">(</span>s.kvList[k]<span class="o">)</span> + popped
         <span class="nb">.</span>          <span class="nb">.</span>    165:<span class="o">}</span>
         <span class="nb">.</span>          <span class="nb">.</span>    166:
         <span class="nb">.</span>          <span class="nb">.</span>    167:func <span class="o">(</span>s <span class="k">*</span>Store<span class="o">)</span> LRange<span class="o">(</span>k string, start, end int<span class="o">)</span> <span class="o">[]</span>string <span class="o">{</span>
<span class="o">(</span>pprof<span class="o">)</span>
</code></pre></div></div>

<p>There it is: one line directly responsible for 671GB of the 687GB. The remaining ~16GB comes from the smaller allocations around it.</p>

<h2 id="why-does-one-line-allocate-672gb">Why does one line allocate 672GB?</h2>

<p>Look at line 162 closely:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">s</span><span class="o">.</span><span class="n">kvList</span><span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">v</span><span class="p">[</span><span class="n">popped</span><span class="o">:</span><span class="p">],</span> <span class="n">s</span><span class="o">.</span><span class="n">kvList</span><span class="p">[</span><span class="n">k</span><span class="p">]</span><span class="o">...</span><span class="p">)</span>
</code></pre></div></div>

<p>This prepends the new values by appending the <em>old list</em> onto them. <code class="language-plaintext highlighter-rouge">append</code> can’t grow <code class="language-plaintext highlighter-rouge">v</code> in place to fit the whole list, so every single LPUSH allocates a brand-new backing array big enough for <code class="language-plaintext highlighter-rouge">new values + entire existing list</code>, 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.</p>

<p>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:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>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
</code></pre></div></div>

<p>The list itself only ever <em>holds</em> O(n) memory (that’s why the in-use numbers looked normal), but the <em>total allocated over time</em> is O(n²), and that’s exactly the quantity <code class="language-plaintext highlighter-rouge">alloc_space</code> 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 <em>was</em>; it’s the area under the churn curve, all of it allocated, copied once, and handed straight to the garbage collector.</p>

<p>Compare that with RPUSH, which was 33x faster:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">s</span><span class="o">.</span><span class="n">kvList</span><span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">s</span><span class="o">.</span><span class="n">kvList</span><span class="p">[</span><span class="n">k</span><span class="p">],</span> <span class="n">v</span><span class="o">...</span><span class="p">)</span>
</code></pre></div></div>

<p>Same <code class="language-plaintext highlighter-rouge">append</code>, opposite behaviour. Appending to the <em>existing</em> slice lets Go’s <code class="language-plaintext highlighter-rouge">append</code> 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 <code class="language-plaintext highlighter-rouge">append</code> leaves is at the <em>tail</em> of the array, and prepending needs room at the <em>head</em>. Every LPUSH lands on an exact-fit array and pays full price, every time.</p>

<p>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.</p>

<h2 id="the-fix">The fix</h2>

<p>We need LPUSH to stop reallocating on every push. What if we create one array with two indices, <code class="language-plaintext highlighter-rouge">left</code> and <code class="language-plaintext highlighter-rouge">right</code>, 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.</p>

<p>There’s a name for this data structure: an <strong>array-backed deque</strong>, a close cousin of the ring buffer. Both ends now get the same amortized O(1) treatment that <code class="language-plaintext highlighter-rouge">append</code> was giving RPUSH. You can see the <a href="https://github.com/solomon-os/redis-server/commit/ed0d5d243bed174975d63187ff49c12750a3d2f8">full diff here</a>.</p>

<p>We run the benchmark again:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>running: <span class="nt">-t</span> <span class="nb">set</span>,get,incr,rpush,lpop,lrange_100 <span class="nt">-n</span> 300000 <span class="nt">-r</span> 1000000 <span class="nt">-c</span> 100 <span class="nt">-d</span> 128 against :6379
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>COMMAND</th>
      <th>REQUESTS</th>
      <th>TIME(s)</th>
      <th>RPS</th>
      <th>AVG(ms)</th>
      <th>P50(ms)</th>
      <th>P99(ms)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SET</td>
      <td>300000</td>
      <td>2.16</td>
      <td>139017.61</td>
      <td>0.379</td>
      <td>0.383</td>
      <td>0.543</td>
    </tr>
    <tr>
      <td>GET</td>
      <td>300000</td>
      <td>2.13</td>
      <td>140845.06</td>
      <td>0.371</td>
      <td>0.383</td>
      <td>0.471</td>
    </tr>
    <tr>
      <td>INCR</td>
      <td>300000</td>
      <td>2.13</td>
      <td>141110.08</td>
      <td>0.370</td>
      <td>0.383</td>
      <td>0.471</td>
    </tr>
    <tr>
      <td>RPUSH</td>
      <td>300000</td>
      <td>2.15</td>
      <td>139729.84</td>
      <td>0.374</td>
      <td>0.391</td>
      <td>0.471</td>
    </tr>
    <tr>
      <td>LPOP</td>
      <td>300000</td>
      <td>2.15</td>
      <td>139534.88</td>
      <td>0.375</td>
      <td>0.391</td>
      <td>0.479</td>
    </tr>
    <tr>
      <td>LPUSH (needed to benchmark LRANGE)</td>
      <td>300000</td>
      <td>2.13</td>
      <td>140581.06</td>
      <td>0.372</td>
      <td>0.383</td>
      <td>0.471</td>
    </tr>
    <tr>
      <td>LRANGE_100 (first 100 elements)</td>
      <td>300000</td>
      <td>5.00</td>
      <td>60024.01</td>
      <td>1.190</td>
      <td>0.639</td>
      <td>8.823</td>
    </tr>
    <tr>
      <td><strong>TOTAL</strong></td>
      <td>2100000</td>
      <td>17.85</td>
      <td>117647.06</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>LPUSH went from 4,196 to 140,581 requests per second: <strong>33x faster</strong>, 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:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Entering interactive mode <span class="o">(</span><span class="nb">type</span> <span class="s2">"help"</span> <span class="k">for </span>commands, <span class="s2">"o"</span> <span class="k">for </span>options<span class="o">)</span>
<span class="o">(</span>pprof<span class="o">)</span> top10
Showing nodes accounting <span class="k">for </span>27244.29MB, 99.23% of 27456.18MB total
Dropped 53 nodes <span class="o">(</span>cum &lt;<span class="o">=</span> 137.28MB<span class="o">)</span>
Showing top 10 nodes out of 15
      flat  flat%   <span class="nb">sum</span>%        cum   cum%
17959.79MB 65.41% 65.41% 17959.79MB 65.41%  strings.<span class="o">(</span><span class="k">*</span>Builder<span class="o">)</span>.WriteString <span class="o">(</span>inline<span class="o">)</span>
 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 <span class="o">(</span>inline<span class="o">)</span>
  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.<span class="o">(</span><span class="k">*</span>Server<span class="o">)</span>.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.<span class="o">(</span><span class="k">*</span>Handler<span class="o">)</span>.Handle
         0     0% 99.23% 22829.76MB 83.15%  github.com/codecrafters-io/redis-starter-go/internal/handler.<span class="o">(</span><span class="k">*</span>Handler<span class="o">)</span>.handleCommand
<span class="o">(</span>pprof<span class="o">)</span>
</code></pre></div></div>

<p>LPUSH is gone from the top 10 entirely. Total allocations for the same workload: 27GB, down from 715GB.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li><strong>An empty profile is still an answer.</strong> 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.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">alloc_space</code> and <code class="language-plaintext highlighter-rouge">inuse_space</code> answer different questions.</strong> In-use tells you what’s <em>holding</em> memory (footprint, leaks). Alloc tells you what’s <em>generating garbage</em> (GC pressure). My 687GB existed in one view and was 4.58MB in the other. Both numbers were true.</li>
  <li><strong>Cumulative allocation is an area, not a size.</strong> A quadratic algorithm can hide behind a perfectly normal live heap. The n²/2 sum is invisible to <code class="language-plaintext highlighter-rouge">top</code>, <code class="language-plaintext highlighter-rouge">htop</code>, and your dashboard; only the allocation profile sees it.</li>
</ul>

<hr />

<p><em>The code is from my <a href="https://codecrafters.io">CodeCrafters Redis challenge</a> implementation in Go. Follow me on <a href="https://github.com/solomon-os">GitHub</a> for the full source.</em></p>]]></content><author><name>Solomon Oladiran</name></author><category term="golang" /><category term="performance" /><category term="go" /><category term="pprof" /><category term="profiling" /><category term="memory" /><category term="redis" /><category term="codecrafters" /><summary type="html"><![CDATA[A pprof deep-dive into my Go Redis clone: the CPU profile was a dead end, the allocation profile found one line generating 687GB of garbage, and an array-backed deque made LPUSH 33x faster.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://solomon-os.github.io/assets/images/cpu-pprof.png" /><media:content medium="image" url="https://solomon-os.github.io/assets/images/cpu-pprof.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>