<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Posts on kakkoyun</title><link>https://kakkoyun.me/posts/</link><description>Recent content in Posts on kakkoyun</description><image><title>kakkoyun</title><url>https://kakkoyun.me/img/android-chrome-512x512.png</url><link>https://kakkoyun.me/img/android-chrome-512x512.png</link></image><generator>Hugo</generator><language>en</language><managingEditor>kakkoyun@gmail.com (Kemal Akkoyun)</managingEditor><webMaster>kakkoyun@gmail.com (Kemal Akkoyun)</webMaster><lastBuildDate>Mon, 05 Oct 2026 08:45:13 +0200</lastBuildDate><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><atom:link href="https://kakkoyun.me/posts/index.xml" rel="self" type="application/rss+xml"/><item><title>New world as an ADHD person</title><link>https://kakkoyun.me/posts/adhd-new-world/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/adhd-new-world/</guid><description>On being a late-diagnosed ADHD engineer in the age of AI agents: the unexpected fit, the familiar traps, and the question nobody tells you to ask yourself.</description><content:encoded><![CDATA[<p>Six hours. I had planned to spend thirty minutes on a proof-of-concept.</p>
<p>Engineers overrun estimates <strong>all</strong> the time. But this wasn&rsquo;t a planning failure, it was something I&rsquo;d spent my whole adult life recognizing too late: hyperfocus. The kind where time doesn&rsquo;t slow down, it simply stops existing. My coffee went cold. I missed lunch. I blew past my 6pm hard stop with more working code than I&rsquo;d shipped in the previous three days combined.</p>
<p>The twist: I was working with Claude Code. And I hadn&rsquo;t been writing much code at all.</p>
<hr>
<p>I was first told I had ADHD as a teenager. My mother, a pharmacist, disagreed; not with the diagnosis exactly, but with what the diagnosis implied: medication. So that chapter closed as quickly as it opened. I went on to do what a lot of undiagnosed ADHD people do: I built a career by outrunning my own brain. Long (inefficient) hours, hyperfocus weaponized as a coping mechanism, elaborate external systems to compensate for the executive dysfunction I didn&rsquo;t have a name for yet.</p>
<p>In 2021, in my early thirties, I got the diagnosis again. Properly this time, with medication, with a therapist, with actual context for the previous three decades. It felt less like a revelation and more like finally seeing the subtitles to a film I&rsquo;d been watching my whole life.</p>
<p>Late diagnosis doesn&rsquo;t fix anything. But it does give you a map.</p>
<hr>
<p>So here I am in 2026, holding this map, and the territory has changed completely.</p>
<p>AI coding agents arrived and immediately did something strange for my brain: they fit. Not perfectly. But better than almost any other tool I&rsquo;d used.</p>
<p>Think about how executive dysfunction actually works. The bridge between &ldquo;I know what to do&rdquo; and &ldquo;I am doing it&rdquo; is missing or unreliable. A structural gap in the signaling, not laziness. You can stare at a task for forty minutes, completely aware of every step required, completely unable to begin the first one. The friction of starting is <em>enormous</em>.</p>
<p>These tools lowered that friction to almost nothing.</p>
<p>&ldquo;Hey, I want to explore this problem. Start here.&rdquo; You don&rsquo;t have to have the architecture worked out. You don&rsquo;t have to have the perfect name for the function yet. The act of <em>beginning</em> (which ADHD makes so hard) becomes nearly trivial. The conversation is the scaffold. The model fills in the gaps you&rsquo;d normally spend executive resources on. You can start badly, vaguely, and iterate forward.</p>
<p>This is not how most software development tools work. Most tools assume you&rsquo;ve already started.</p>
<hr>
<p>But here&rsquo;s the part I didn&rsquo;t see coming.</p>
<p>ADHD brains are famously bad at dopamine regulation. The reason tasks are hard to start is the same reason they&rsquo;re hard to stop: we don&rsquo;t have a reliable internal signal that says <em>enough</em>. The dopamine hit from making progress is chemically addictive for a brain that doesn&rsquo;t produce it reliably otherwise. Normally, the friction of the work itself acts as a natural brake: it gets hard, you pause, time passes, you come up for air.<span class="sidenote-ref" id="ref-sn-1" role="doc-noteref">
  <sup><a href="#sn-1" aria-describedby="sn-1">1</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-1" role="note" aria-label="Sidenote 1 (right margin)">
  <span class="sidenote-label">1</span>
  <span class="sidenote-content">&ldquo;air&rdquo; here is also a wink at certain habits (smoking, vaping, etc.) that a lot of ADHD brains, mine included, get hooked into. Not the chemistry so much as the loop.</span>
  <a class="sidenote-back" href="#ref-sn-1" aria-label="Back to reference">↩</a>
</aside>
</p>
<p>When the friction is gone, the brake is gone too.</p>
<p>Steve Yegge wrote about this <a href="https://steve-yegge.medium.com/the-ai-vampire-eda6e4f07163">in February</a> as a vampire dynamic: AI makes you 10x more productive (his number, not mine), companies capture all of that 10x, and the worker burns out at 10x speed. That&rsquo;s a real structural problem. But for ADHD people, the vampire was already inside the house. I don&rsquo;t need anyone to extract my productivity at scale. I&rsquo;ll do it to myself. I already do it to myself. The exhaustion isn&rsquo;t new, the acceleration is.</p>
<p>What I need is a new way to answer a question my brain has always been bad at: <em>when is enough?</em></p>
<hr>
<p>Here&rsquo;s what I&rsquo;ve learned, imperfectly, in the past few years (feels like a decade).</p>
<p>Hyperfocus is a superpower with an invoice attached. AI amplifies it in both directions. A task that would have taken hours of overhead to warm up now lets me get to the interesting part immediately, and the interesting part is exactly where my brain thrives. I have lost more evenings, more &ldquo;just thirty minutes&rdquo; that became five hours. The superpower doesn&rsquo;t come with a volume knob. You have to install one yourself.</p>
<p>The invoice used to be paid differently. In my twenties I worked without a stop signal — long hours, contract gigs stacked onto the weekends, no life outside the work. Every two or three weeks the bill would arrive all at once: a Sunday I couldn&rsquo;t get out of bed. That doesn&rsquo;t work anymore. There&rsquo;s a kid now, and a 6pm hard cutoff, and the invoice can&rsquo;t be settled in one Sunday-shaped lump. Which means I have to see it building, sooner.</p>
<p>Time blindness hasn&rsquo;t gone anywhere (my wife has a thing or two to say about this). Timers help. What actually works for me is externalizing time the same way I externalize memory: make it visible, make it loud, make it impossible to ignore. The agent tools won&rsquo;t do this for you. They have no concept of your dinner getting cold.</p>
<p>Executive dysfunction loves these tools, and that relief is also a trap. When starting is easier, I start more things. More half-finished contexts, more &ldquo;I&rsquo;ll just switch over to this for a second&rdquo;, more fragmented attention. One bottleneck removed reveals the one behind it. I&rsquo;m faster at beginning; I&rsquo;m not automatically better at finishing.</p>
<p>None of this is a reason not to use these tools. It&rsquo;s a reason to use them with a clearer picture of your own brain.</p>
<hr>
<p>What the map tells me now (the one I waited until my thirties to find) is that my brain was never broken. It was mismatched: to school structures, to open-plan offices, to 9am standups, to the way most software development was expected to happen. I built compensating systems for all of it and called the effort &ldquo;discipline.&rdquo;</p>
<p>Some of those compensating systems are obsolete now. The friction I trained myself to push through is genuinely gone. The new question is: what does discipline mean when the friction is gone?</p>
<p>I don&rsquo;t fully know yet. I suspect it means learning to stop in a world that no longer stops you.</p>
<p>After thirty-something years of a brain that felt like it was fighting itself, having a problem that&rsquo;s about <em>too much</em> flow instead of <em>not enough</em> is a strange and mostly welcome inversion. I&rsquo;ll take it.</p>
<p>But I&rsquo;m also tired.</p>
]]></content:encoded></item><item><title>My Second Brain System: PARA, Readwise, and an LLM captures my thoughts</title><link>https://kakkoyun.me/posts/second-brain-system/</link><pubDate>Fri, 21 Aug 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/second-brain-system/</guid><description>What I built, what does most of the work, and why the LLM never edits a source note.</description><content:encoded><![CDATA[<p>At 9:14 a.m. on a Wednesday, I open the laptop. The daily briefing for today is already there.</p>
<p>Above the fold: a Whoop recovery score of 62 (fine), a Wakatime row showing 4h 21m on the OpenTelemetry Go compile-time instrumentation repo yesterday, three GitHub PRs waiting on review, and a Things 3 task that has been sliding forward for nine days: <em>Review that thing from X</em>. Each previous day the task migrated, and the journal entry from that day is one click away. The reason it kept slipping is in those entries somewhere.</p>
<p>The system did not make the task less unpleasant. It made the unpleasantness traceable. That is the smaller of the two things this setup buys me. The larger one is the synthesis layer: an LLM reads what I capture and writes connections back into the vault without editing the original notes.</p>
<p>This is what I built, plus the one design choice I think makes the whole thing work. I am going to lead with the boring parts on purpose.</p>
<h2 id="the-80-that-does-the-work">The 80% that does the work</h2>
<p>Three workhorses. None of them are interesting.</p>
<p>The first is <a href="https://www.buildingasecondbrain.com/para">PARA</a> (Projects, Areas, Resources, Archives). It gives roughly 4,000 Markdown files in an Obsidian vault an obvious home, which is its only job. People who prefer <a href="https://johnnydecimal.com/">Johnny Decimal</a>, <a href="https://www.linkingyourthinking.com/">ACCESS</a>, or their own taxonomies are correct that PARA has rough edges. They are also wrong that the rough edges matter. The point of PARA is to stop you from re-litigating where things go.</p>
<p>The second is <a href="https://readwise.io">Readwise</a>. It syncs highlights from Kindle, Reader, Twitter (yes, still Twitter for me), and any podcast clipping I tag into <code>curation/readwise/</code>. I do not curate at this layer. Everything lands. A highlight from a book I read in 2023 is still there.</p>
<p>The third is <a href="https://github.com/Vinzent03/obsidian-git"><code>obsidian-git</code></a>. It commits the vault every 70 minutes and pushes every 7. I have not thought about backups in months.</p>
<p>The Whoop score, the Wakatime row, and the PR queue in that morning&rsquo;s daily note came from a Python CLI I wrote called <a href="https://github.com/kakkoyun/pkm-tool"><code>pkm-tool</code></a>. It pulled GitHub, Jira, Wakatime, Whoop, Apple Calendar, Things 3, and Google Docs into one report. The experiment worked, but it also turned my daily note into a small integration platform I had to maintain.</p>
<p>I no longer use it. Today I prefer small deterministic scripts, Obsidian cron jobs, and official connectors where they exist. A handful of small capture jobs now assemble the note instead of one aggregator.</p>
<p>That is the substrate. Other people have described it before. If the post ended here, it would not be worth your time.</p>
<h2 id="the-one-design-choice">The one design choice</h2>
<p>The part I care about most is the boundary between source material and synthesis.</p>
<p><strong>The LLM never edits a source.</strong></p>
<p>Most AI-PKM tools work by chewing on your notes and offering to rewrite, tag, or restructure them. <a href="https://github.com/logancyang/obsidian-copilot">Copilot for Obsidian</a> edits selected text in-editor. Notion AI is happy to rewrite a page. The implicit contract is: your notes are a substrate the AI improves.</p>
<p>I do not want that. I want my raw captures to stay exactly as they were when they hit the disk. That includes Readwise highlights, meeting notes I scribbled in a hurry, and half-baked journal entries. Those are the historical record. If a synthesis is wrong, I want to look at the source and see <em>why</em> it went wrong, without finding that the LLM has already &ldquo;improved&rdquo; the source out from under me.</p>
<p>The vault has two halves with a wall between them. On one side are raw sources such as <code>curation/</code>, <code>journal/</code>, <code>devlog/</code>, and <code>meetings/</code>. On the other are pages written and maintained by an LLM (Claude Code in my case): <code>zettelkasten/</code>, <code>resources/concepts/</code>, <code>decisions/</code>, and <code>reflections/</code>.</p>
<p>The synthesis layer can be wrong, and I can regenerate it. The raw layer remains the source of truth.</p>
<p>This is the <a href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f">Karpathy LLM Wiki pattern</a> applied to a PARA vault. The schema lives in <code>system/wiki/SCHEMA.md</code> and declares three operations the LLM is allowed to perform. <strong>Ingest</strong> reads a new source and writes synthesis pages without touching the source. <strong>Query</strong> answers a question and files the answer back as new wiki content if it produced new insight. <strong>Lint</strong> walks the synthesis graph looking for orphans, stale references, and contradictions to flag for me to resolve.</p>
<p>If I had to defend the architecture in one sentence: it lets knowledge accumulate across LLM sessions without letting the LLM rewrite the past. Almost every other choice in the setup followed from that.</p>
<h2 id="where-it-paid-off">Where it paid off</h2>
<p>The retrieval engine is <a href="https://github.com/tobi/qmd">qmd</a>, Tobi Lütke&rsquo;s local-first CLI. It combines BM25, vector embeddings, and local models for query expansion and reranking without sending my notes to an API.</p>
<p>On July 21, while preparing a GopherCon UK talk, I ran:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>qmd query <span style="color:#e6db74">&#34;benchmark overhead auto-instrumentation Go latency allocations&#34;</span> -n <span style="color:#ae81ff">8</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>It found an older GopherCon proposal in <code>creation/</code>, a raw auto-instrumentation note imported from iCloud, a saved article about measuring Go performance, and work notes on auto-instrumentation and code origins. None of that material was new. I had forgotten some of those notes existed. The agent used those documents as the starting material for the talk instead of rebuilding the context from scratch.</p>
<p>On August 3, the synthesis side did something different. A connection pass updated <code>resources/concepts/Go Programming Expertise.md</code> from a July 24 daily note about Go 1.24 and 1.25 and an August 3 work devlog about benchmark internals. The page links the release-tracking habit to the compiler and benchmarking work behind the talk, cites both source notes, and labels its confidence as low.</p>
<p>That combination is why I trust the design: qmd finds old material, and the wiki records connections without changing the sources. I can follow every claim back to the note that produced it.</p>
<h2 id="if-you-are-starting-from-zero">If you are starting from zero</h2>
<p>If I rebuilt it, I would do these in this order.</p>
<p><strong>Get retrieval working before synthesis.</strong> Run <code>qmd</code> or another local search over whatever notes you have today. If you cannot reliably find what you have, the synthesis layer will not save you. Most of the value of a &ldquo;second brain&rdquo; is retrieval, not generation.</p>
<p><strong>Do not sync everything immediately.</strong> Resist the urge to dump every Twitter favorite, web clip, and podcast highlight into one folder on day one. Start with one capture channel, such as books, articles you actually read, or your daily journal, and live with it until &ldquo;I captured something today and I can find it later&rdquo; feels routine. Add channels once that rhythm exists.</p>
<p><strong>Be honest about what the wiki layer needs from you.</strong> The LLM Wiki pattern only improves if you actually run it. My ingestion logs are busy now, but the Readwise backlog is still waiting. The structure works. The practice is still catching up. If you are skeptical of &ldquo;second brain&rdquo; hype because most setups die at the synthesis layer, you are not wrong. Mine has not proven itself over the long term either.</p>
<p>The capture layer still has failure modes. The live jobs run inside Obsidian through Cron, Shell Commands, Templater, and connectors, so they depend on Obsidian being open. A failed capture can leave a section empty without making the note look broken. I still do not have one health check for the whole pipeline.</p>
<h2 id="what-i-cannot-tell-you-yet">What I cannot tell you yet</h2>
<p>When the LLM maintains the synthesis pages, and I read one of those pages six months from now, will it feel like <em>my</em> thinking or like a summary someone handed me?</p>
<p>The Karpathy pattern makes that trade: citations point back to raw sources, and the synthesis can be regenerated as the wiki changes. <a href="https://fortelabs.com/blog/progressive-summarization-a-practical-technique-for-designing-discoverable-notes/">Tiago Forte describes progressive summarization</a> as &ldquo;opportunistic compression&rdquo; that makes notes discoverable later. I am testing what changes when the summarizer is not me.</p>
<p>I am not yet sure I agree. The synthesis pages in my vault from the last month read fluently and cite real sources and make connections I would not have made unaided. They also do not always feel like me. Something about the shape of the paragraphs. Something about which details get kept and which get smoothed away.</p>
<p>I do not know how this resolves. It is possible that in six months the synthesis layer will feel like a colleague I trust, and that is fine. It is possible it will feel like reading someone else&rsquo;s notes about my own life, and that is not fine. I am betting the answer is closer to the first, with limited evidence.</p>
<p>If you build something like this and end up on the other side of the question, I would genuinely like to hear about it.</p>
]]></content:encoded></item><item><title>podcast: Cup o' Go — Instrumentation and Blast Radius</title><link>https://kakkoyun.me/posts/cup-o-go-podcast/</link><pubDate>Mon, 17 Aug 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/cup-o-go-podcast/</guid><description>I joined Jonathan Hall and Shay Nehmad on Cup o&amp;#39; Go to talk about what APM actually measures, the three ways to instrument Go, and why blast radius is the trade-off that decides between them.</description><content:encoded><![CDATA[<p>Jonathan Hall and Shay Nehmad had me on Cup o&rsquo; Go for episode 167, straight after their GopherCon US recap. We spent about forty minutes on instrumentation: what an APM tool is actually measuring, and the three ways you can get telemetry out of a Go program without asking every team to rewrite their handlers.</p>
<p>The part I enjoyed arguing about most was blast radius. Manual instrumentation is precise and tedious, and it never finishes. eBPF buys you one agent covering every language on the box, at the cost of running code in the kernel, where a bad program can take the whole host down with it. Compile-time instrumentation rewrites the syntax tree behind <code>go build</code> through the <code>-toolexec</code> hook, which keeps failures scoped to one service and works anywhere Go compiles. People love this dark magic, which I find either reassuring or alarming depending on the day.</p>
<p>We also got into why a trace and a pprof profile answer different questions, how Orchestrion ended up under OpenTelemetry governance, and the least intimidating route into a SIG if you want to start contributing. Shay&rsquo;s bicycle makes an appearance. I was speaking for myself rather than reading from a Datadog brochure, which I hope comes through.</p>
<h4 id="listen">Listen</h4>
<iframe width="560" height="315" src="https://www.youtube.com/embed/EVpax0L5GgQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
<p><strong>Episode</strong></p>
<ul>
<li><a href="https://cupogo.dev/episodes/quick-gophercon-recap-and-interview-with-kemal-of-datadog">Cup o&rsquo; Go E167 — Quick GopherCon recap, and interview with Kemal of Datadog</a>
<ul>
<li><a href="https://www.youtube.com/watch?v=EVpax0L5GgQ">Watch on YouTube</a></li>
<li><a href="https://cupogo.dev/episodes/quick-gophercon-recap-and-interview-with-kemal-of-datadog/transcript">Transcript</a></li>
</ul>
</li>
<li><a href="https://podcasts.apple.com/us/podcast/cup-o-go/id1665967724">Apple Podcasts</a></li>
<li><a href="https://open.spotify.com/show/1mVIbuzr22V6fgZwxPj3uv">Spotify</a></li>
</ul>
<p><strong>Topics</strong></p>
<ul>
<li>GopherCon US recap, and Austin&rsquo;s talk on fixing the proposal process</li>
<li>What APM measures, and how request tracing differs from pprof sampling</li>
<li>Manual, eBPF, and compile-time instrumentation, and what each one costs you</li>
<li>Orchestrion moving to OpenTelemetry, plus how to find your way into a SIG</li>
<li>Checklocks, the gVisor static lock checker I keep recommending to people</li>
</ul>
<p><strong>Related</strong></p>
<ul>
<li><a href="/talks/instrument-go-without-changing-a-single-line/">talk: Instrument Go Without Changing a Single Line</a> — GopherCon UK 2026</li>
<li><a href="/talks/how-to-instrument-go-without-changing-code/">talk: How to Instrument Go Without Changing a Single Line of Code</a> — FOSDEM 2026</li>
<li><a href="/talks/unleashing-the-go-toolchain/">talk: Unleashing the Go Toolchain</a> — what <code>-toolexec</code> makes possible</li>
<li><a href="/posts/fosdem-2026-auto-instrumenting-go/">Auto-Instrumenting Go: From eBPF to USDT Probes</a></li>
<li><a href="/posts/go-compile-time-instrumentation-v1/">OpenTelemetry Go Compile-time Instrumentation v1</a></li>
</ul>
]]></content:encoded></item><item><title>Making zero-touch Go observability agent-actionable</title><link>https://kakkoyun.me/posts/zero-touch-go-observability-agent-actionable/</link><pubDate>Thu, 06 Aug 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/zero-touch-go-observability-agent-actionable/</guid><description>Turn the OBI vs. otelc decision into an agent-readable runbook, then make the Kubernetes path boring enough to run with one command.</description><content:encoded><![CDATA[<p>You can&rsquo;t reproduce the bug locally, so you add a log line and redeploy. Wrong place. You add another one and redeploy again. That is the debugging loop from <a href="/talks/instrument-go-without-changing-a-single-line/">my GopherCon UK talk</a>, and in this last part we&rsquo;re going to shorten it: a decision rule for two zero-touch tools, then a runbook an AI coding agent (not a Java-style one) can execute.</p>
<p>The runbook starts with one boring question: is this Go service already running, or can I rebuild it? If the service has to stay untouched, OBI (OpenTelemetry eBPF Instrumentation) is the default. If I can rebuild it and need function-level spans, otelc (OpenTelemetry Go compile-time instrumentation) is the default. The rest of the decision tree is checking privileges, Go version, collector endpoint, and whether RED metrics are enough.</p>
<p>We&rsquo;ll keep that loop in view the whole way (yes, that&rsquo;s foreshadowing 👀) and see whether the series promise survives contact with it. This is part 6 of 6 in the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series. <a href="/posts/go-runtime-futures-flight-recording-usdt/">Part 5</a> looked at what Go itself might add, and parts 2 to 4 covered the tools (<a href="/posts/obi-ebpf-auto-instrumentation-go/">OBI</a>, <a href="/posts/otelc-compile-time-go-traces/">otelc</a>, the <a href="/posts/continuous-profiling-go-without-code-changes/">eBPF profiler</a>). Disclosure: I&rsquo;m one of otelc&rsquo;s maintainers, and <a href="https://github.com/kakkoyun/zeroins/tree/v0.2.0">zeroins</a>, <code>kubectl-obi</code> and the skill in this part are my own projects. The skill and plugins below live in the zeroins repository.</p>
<h2 id="the-routing-logic">The routing logic</h2>
<p>The series follows three intervention points: build time, process start and the kernel. For a Go service, two of them are real routes. We reach for OBI when the service is already running (production, staging, Kubernetes, Docker Compose) and rebuilding or redeploying isn&rsquo;t acceptable, when the fleet is polyglot, or when HTTP/gRPC RED metrics (rate, errors, duration) and library-level spans are all we need. We reach for otelc when we control the build (a laptop, CI or the release pipeline), want granular spans for specific functions or one slow path, and are willing to rebuild with <code>otelc go build</code>. If neither fits cleanly, the tiebreaker is one question: <em>can this service be rebuilt and redeployed (otelc), or does it have to stay untouched (OBI)?</em></p>
<p>The profiler from <a href="/posts/continuous-profiling-go-without-code-changes/">part 4</a> isn&rsquo;t a third route but a layer beside either one, adding whole-node CPU profiles. A practical stack is otelc in the build for semantic spans, OBI for service boundaries and other languages, and the profiler for the node; add a layer when its signal earns the cost. The flight recorder lives in your code, so it stays out of the runbook. Orchestrion is not in this tree, since the zeroins lookups cover only OBI and otelc (<a href="/posts/otelc-compile-time-go-traces/#where-it-fits-relative-to-obi">part 3</a> still points to it for Datadog APM).</p>
<p>Both routes need somewhere to send the output, such as an OTel Collector or Jaeger. OBI needs a BTF-enabled Linux kernel and a specific capability set (<a href="/posts/obi-ebpf-auto-instrumentation-go/#what-it-requires">part 2</a> has the list), and otelc needs Go 1.25 or newer.</p>
<p>What happened to process start? We left it out on purpose. The <a href="https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/README.md">OpenTelemetry host injector</a> preloads agents for Java, Node.js, .NET, Ruby, and Python (disabled by default), not Go, and a pure-Go build is static, so <a href="https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/DESIGN.md"><code>LD_PRELOAD</code> has nothing to attach to</a>. The OpenTelemetry Operator&rsquo;s <a href="https://opentelemetry.io/docs/platforms/kubernetes/operator/automatic/"><code>inject-go</code> annotation</a> doesn&rsquo;t change that: it adds a per-pod sidecar running the Go auto-instrumentation agent (<code>go.opentelemetry.io/auto</code>, not OBI), off by default, that needs a privileged root container. A fleet-wide injector can keep the other runtimes while Go services route to OBI, or to otelc if you can rebuild (<a href="/posts/why-go-cant-be-monkey-patched/#process-start-the-injector">part 1</a> has the background). Two routes remain. Let&rsquo;s see what each one buys us, starting with the one that leaves our binary alone.</p>
<h2 id="what-obi-actually-covers">What OBI actually covers</h2>
<p>OBI instruments <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/SUPPORT_MATRIX.md">13 Go libraries</a> out of the box, including <code>net/http</code>, gin, gRPC and <code>database/sql</code> (<a href="/posts/obi-ebpf-auto-instrumentation-go/#what-it-actually-instruments-in-go">the list is in part 2</a>). It attaches from outside the process: no <code>LD_PRELOAD</code>, no rebuild, and in DaemonSet mode no restart. It also skips services it sees publishing their own OpenTelemetry telemetry (<code>exclude_otel_instrumented_services</code>, on by default), so an OTel Java or Python agent shouldn&rsquo;t double up; the docs describe this for OpenTelemetry SDK calls, so test a pod with a vendor agent first. On Kubernetes that DaemonSet comes from a Helm chart, and the defaults of chart 0.10.0 (<code>open-telemetry/opentelemetry-ebpf-instrumentation</code>) are enthusiastic: every namespace, privileged, exporting to a collector on the node IP (<code>${HOST_IP}:4317</code> for traces, <code>:4318</code> for metrics). Point it elsewhere before installing it on a cluster you care about.</p>
<p>The cost is span depth. Custom spans and business-logic events still need code changes when they are not at the HTTP/gRPC boundary. OBI observes what crosses library boundaries and can&rsquo;t see inside our logic, so when the slow part is in our own functions, we need the other route.</p>
<h2 id="what-otelc-covers">What otelc covers</h2>
<p>otelc takes the compile-time path: <a href="https://opentelemetry.io/docs/zero-code/go/compile-time/"><code>otelc go build</code></a> is a drop-in for <code>go build</code>. It instruments your code, its dependencies, and parts of the standard library at the AST level, and the injected spans cost the same as manually written OTel code. Here is the entire change: install the tool, swap the build command, run the binary with an endpoint and a service name.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go install go.opentelemetry.io/otelc/tool/cmd/otelc@v1.1.0
</span></span><span style="display:flex;"><span>otelc go build -o ./myapp .
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>OTEL_EXPORTER_OTLP_ENDPOINT<span style="color:#f92672">=</span>http://localhost:4318 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>OTEL_SERVICE_NAME<span style="color:#f92672">=</span>my-go-service <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>./myapp
</span></span></code></pre></td></tr></table>
</div>
</div><p>That is what &ldquo;without a single line&rdquo; means in practice: not one line of application source, one changed line in the build script. The endpoint says 4318 on purpose. otelc&rsquo;s runtime exports OTLP over HTTP (<code>http/protobuf</code>) by default, and 4317 is the gRPC port. Point the default protocol at 4317 and you get <code>malformed HTTP response</code> errors instead of traces (wrong door, polite knock); to use 4317, set <code>OTEL_EXPORTER_OTLP_PROTOCOL=grpc</code>.</p>
<p>The tradeoff is build-time coupling: we can&rsquo;t attach otelc to a running binary, or instrument services we are not rebuilding. We can put it in the release pipeline, though. The <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/docs/getting-started.md">getting-started guide</a> covers CI/CD and calls it stable and ready for production use as of v1.0.0. Two tools, two sets of costs, and a choice an impatient human will get wrong at 3 a.m. Let&rsquo;s write it down so an agent can follow it.</p>
<h2 id="making-the-decision-executable">Making the decision executable</h2>
<p>A decision tree in a blog post doesn&rsquo;t shorten anyone&rsquo;s loop. As the <a href="/talks/instrument-go-without-changing-a-single-line/">talk</a> puts it, an agent on its own still has to pick a mechanism and know what it costs. I put the routing logic into an agent skill called <a href="https://github.com/kakkoyun/zeroins/blob/v0.2.0/skills/collect-go-telemetry/SKILL.md"><code>collect-go-telemetry</code></a>, installed with <code>npx skills add kakkoyun/zeroins --all</code>. It encodes the decision tree and wraps it in a fixed workflow: preflight, coverage lookup, dry-run plan, confirmation gate, bounded attach, confirm traces, session audit. The tree is fixed; the context (which service, which libraries, which environment) is what an agent inspects.</p>
<p>The coverage step is the clever bit. Pasting OBI&rsquo;s support matrix and otelc&rsquo;s library catalog into context for every request is wasteful, so the skill uses per-library lookups (<code>zeroins obi lookup &lt;library&gt;</code>, <code>zeroins otelc lookup &lt;import-path&gt;</code>) that return only the relevant rows. They are offline, reading a snapshot embedded in the binary (OBI v0.10.0 and otelc v1.0.1 in zeroins v0.2.0), so they change only when zeroins does. Treat documentation as a retrieval problem, not a context-stuffing problem. The skill can now choose and look things up, but on Kubernetes something still has to do the attaching.</p>
<h2 id="zeroins-and-kubectl-obi-the-kubernetes-path">zeroins and kubectl-obi: the Kubernetes path</h2>
<p>That something is <code>kubectl-obi</code>, one of two experimental <code>kubectl</code> plugins in zeroins, which I wrote for the talk. <code>go install</code> puts an executable named <code>kubectl-obi</code> on your <code>PATH</code>, and <code>kubectl</code> discovers it as <code>kubectl obi</code>. It deploys privileged eBPF observers and is not a production installer, so the first thing we do is read the dry-run. It shells out to <code>helm</code> and <code>kubectl</code> (zeroins asks for helm v4), and <code>--endpoint</code> is required. Here is the lifecycle, dry-run first:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go install github.com/kakkoyun/zeroins/cmd/...@v0.2.0
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Review the plan first (changes nothing)</span>
</span></span><span style="display:flex;"><span>kubectl obi attach --dry-run --endpoint<span style="color:#f92672">=</span>https://otel-collector.observability.svc:4318
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Instrument everything on every node, detach automatically after 15 minutes</span>
</span></span><span style="display:flex;"><span>kubectl obi attach --duration<span style="color:#f92672">=</span>15m --endpoint<span style="color:#f92672">=</span>https://otel-collector.observability.svc:4318
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Check what&#39;s being instrumented</span>
</span></span><span style="display:flex;"><span>kubectl obi status --all-namespaces
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Stop instrumenting</span>
</span></span><span style="display:flex;"><span>kubectl obi detach
</span></span></code></pre></td></tr></table>
</div>
</div><p>The endpoint must be an OTLP HTTP(S) base URL, so no <code>/v1/traces</code> suffix. In DaemonSet mode the plugin installs the pinned OBI chart (0.10.0, OBI v0.10.0, the release pinned for the talk on 13 August 2026) into the <code>obi-system</code> namespace with Helm. Sidecar mode also exists; it patches one deployment and restarts it, so the &ldquo;no restart&rdquo; promise holds only for the DaemonSet. Sidecar mode needs its own <code>kubectl obi detach &lt;deployment&gt; --mode=sidecar</code>; a bare <code>detach</code> removes only the DaemonSet. The whole thing is experimental: CI runs unit tests and Helm contract checks, and the real-eBPF integration test on a disposable K3s cluster last passed shortly before the v0.2.0 tag.</p>
<p>Handing an agent a command that attaches privileged eBPF programs to a cluster is exactly as fun as it sounds. Hence the guardrails.</p>
<h2 id="guardrails-before-attach">Guardrails before attach</h2>
<p>The messy operational checks deserve names: kernel version, capabilities, collector endpoint, rebuild access, Go version, and span depth. I still would not let a skill silently attach eBPF to production, so the skill doesn&rsquo;t. It produces a plan first (<code>attach --dry-run</code> prints the cluster context, the privileges, the exact commands and the rendered values), then stops at a confirmation gate until a human approves. Exploratory attaches take <code>--duration</code> and detach themselves, and <code>zeroins sessions list</code> and <code>zeroins sessions reap</code> show and clean up what is still attached. Boring, reversible, logged. Observability needs more of that kind of magic trick. That&rsquo;s the whole runbook, so let&rsquo;s run the safe parts of it.</p>
<h2 id="try-it">Try it</h2>
<p>Nothing here touches a cluster. First, a local trace backend for the otelc example above, using <a href="https://www.jaegertracing.io/docs/2.21/getting-started/">Jaeger</a>. Jaeger only accepts traces, so the metrics flush logs a 404 when the process shuts down. The traces arrive anyway:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker run -d --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:2.21.0
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># build and run any small net/http service as shown in &#34;What otelc covers&#34;, send a few requests, then:</span>
</span></span><span style="display:flex;"><span>curl -s localhost:16686/api/v3/services
</span></span></code></pre></td></tr></table>
</div>
</div><p>The services list should include <code>my-go-service</code>. Then the zeroins side, which only looks things up and prints a plan:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>zeroins obi lookup net/http
</span></span><span style="display:flex;"><span>zeroins otelc lookup net/http
</span></span><span style="display:flex;"><span>kubectl obi attach --dry-run --endpoint<span style="color:#f92672">=</span>https://otel-collector.observability.svc:4318
</span></span></code></pre></td></tr></table>
</div>
</div><p>The two lookups say which route covers <code>net/http</code>, and the dry-run prints the plan a human would be asked to approve.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="where-to-go-from-here">Where to go from here</h2>
<p>Back to the loop. The bug that won&rsquo;t reproduce locally used to cost a log line and a redeploy, then another when the first guess landed in the wrong place. Now the first move is the boring question from the top of this post. If the service has to stay untouched, the plan is one dry-run away and, in DaemonSet mode, nothing restarts. If the answer lives in our own functions, the question sends us to otelc, and the rebuild carries no source edit. Either way, we stop guessing where the log line goes.</p>
<p>That is the series. The <a href="/talks/instrument-go-without-changing-a-single-line/">talk page</a> has the slides and links, and the <a href="/posts/fosdem-2026-auto-instrumenting-go/">FOSDEM 2026 post</a> has the write-up of the earlier version of the talk. To help out, OBI, otelc and the eBPF profiler each have an OpenTelemetry SIG (see the <a href="https://github.com/open-telemetry/community">community repository</a>), and Ollygarden&rsquo;s <a href="https://github.com/ollygarden/opentelemetry-agent-skills">agent skills</a> are a good next stop for more OpenTelemetry skills.</p>
<p>Two routes, one boring question, no source edits. Go break the loop. 🔧</p>
]]></content:encoded></item><item><title>Three Questions Before You Trust a Benchmark</title><link>https://kakkoyun.me/posts/go-benchmarks-lying-three-questions/</link><pubDate>Tue, 04 Aug 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-lying-three-questions/</guid><description>A CI regression that turned out to be a speedup, and three questions, each backed by a small CLI, that tell you whether a Go benchmark result deserves your trust.</description><content:encoded><![CDATA[<p>A benchmark bot once told me that one of my pull requests made a benchmark 6–9% slower. A same-machine comparison said the pull request made it faster. Both results were stable, and they disagreed. In this last part we&rsquo;ll find out how that happens, and leave with three questions and three small Go tools that tell us how far to trust a number.</p>
<h2 id="a-loose-cable">A loose cable</h2>
<p>Physicists have been fooled the same way, at a much larger scale. In September 2011, the OPERA collaboration <a href="https://web.archive.org/web/20140222165941/http://press-archived.web.cern.ch/press-archived/PressReleases/Releases2011/PR19.11E.html">announced</a> that muon neutrinos appeared to travel faster than the speed of light. Months of rechecking found nothing wrong. The root cause, eventually, was an improperly seated fibre-optic connector in the GPS timing chain, which introduced a ~73 ns bias that made neutrinos appear to arrive early (<a href="https://doi.org/10.1126/science.335.6072.1027">Science</a> called it a loose cable). A second fault, an oscillator defect, pushed the other way and partly masked the first. Once both were corrected, the 2012 re-measurements showed neutrino speed consistent with the speed of light.</p>
<p>A systematic measurement error can hide in plain sight, look exactly like signal, and survive review by people far more careful than we are. Our Go benchmarks have <code>testing.B</code>, a laptop, and background Chrome tabs. They have cables too: the compiler, the statistics, and the machine with its OS scheduler. We&rsquo;ll follow one loose cable through this post. I&rsquo;ll show you mine first, and then we&rsquo;ll see which question would have caught it.</p>
<p>This is part 5 of 5 in the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, the written companion to the <a href="/talks/why-your-go-benchmarks-are-lying/">GopherCon UK 2026 talk</a>. <a href="/posts/go-benchmarks-lying-ci/">Part 4</a> built benchmark CI that holds up (with the companions <a href="/posts/go-benchmarks-pr-gate-that-fails/">A PR gate that actually fails</a> and <a href="/posts/go-benchmarks-ab-is-the-wrong-model/">A/B is the wrong model for CI</a>); this part asks whether we should believe it. The <a href="/posts/fosdem-2026-measuring-software-performance/">FOSDEM 2026 post on measuring software performance</a> is the prerequisite. The tools live in <a href="https://github.com/kakkoyun/benchlab/tree/v0.1.0">benchlab</a> and the talk&rsquo;s demo results in <a href="https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying">gopherconuk-26</a>. Disclosure: I work at Datadog, which maintains dd-trace-go, so the story is about my employer&rsquo;s CI bot. The lesson holds for any repository.</p>
<p>Enough physics. Here is my loose cable.</p>
<h2 id="the-ci-regression-that-was-a-speedup">The CI regression that was a speedup</h2>
<p>In June 2026 I pushed a change touching <code>context.go</code> in the <code>ddtrace/tracer</code> package, which landed as <a href="https://github.com/DataDog/dd-trace-go/pull/4891">dd-trace-go #4891</a>. The change was compile-time instrumentation plumbing. Shortly after the push, the benchmark bot commented that <code>BenchmarkOTLPProtoSize</code> was <strong>6–9% slower than main</strong>.</p>
<p>My first instinct was to suspect my change. The better move is to read what the benchmark measures first, so here is its timed loop:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// The entire timed loop inside BenchmarkOTLPProtoSize:</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">Loop</span>() {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">proto</span>.<span style="color:#a6e22e">Size</span>(<span style="color:#a6e22e">tracesData</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>That is a protobuf size computation on a struct assembled entirely before the loop (the <a href="https://github.com/DataDog/dd-trace-go/blob/1a0c5e19b611f34f14c14531304973c89d2fb055/ddtrace/tracer/otlp_writer_bench_test.go#L144">real benchmark</a> still calls <code>b.ResetTimer()</code> there, which <code>b.Loop</code> makes redundant). It never calls <code>ContextWithSpan</code>, <code>SpanFromContext</code>, or any code the PR modified, so the diff had no believable path to the number. Hold that thought, because the likely mechanism is stranger.</p>
<p>Did the regression show up on my own machine? Running the benchmark repeatedly there gave a <strong>coefficient of variation (CV) under 0.1%</strong>, so a 6–9% gap was not ordinary variance. I built <code>main</code> and <code>#4891</code> on that same machine, an Apple M4 Max (darwin/arm64), and compared them with <code>benchstat</code>. The table shows the medians:</p>
<table>
	<thead>
			<tr>
					<th>Build</th>
					<th>1 span</th>
					<th>10 spans</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>main</td>
					<td>883.3 ns/op</td>
					<td>7115 ns/op</td>
			</tr>
			<tr>
					<td>#4891</td>
					<td>840.7 ns/op</td>
					<td>6775 ns/op</td>
			</tr>
	</tbody>
</table>
<p><strong>#4891 was faster.</strong> CI had flagged a regression, and the same-machine A/B showed the opposite.</p>
<p>The likely mechanism is code layout. Changing <code>context.go</code> shifted function addresses in the test binary, which moved the hot <code>proto.Size</code> loop relative to cache-line and branch-target-buffer boundaries. At the sub-microsecond scale of the one-span case, a small alignment shift can swing a result by a few percent in either direction, enough to flip the verdict from &ldquo;improvement&rdquo; to &ldquo;regression&rdquo;. Emery Berger&rsquo;s <a href="https://www.youtube.com/watch?v=r-TLSBdHe1A">Performance Matters</a> (Strange Loop 2019) puts code layout alone at ±10%.</p>
<p>The evidence is circumstantial. A later comparison of symbol addresses in linux/amd64 test binaries, built from the base commit and from the PR&rsquo;s merge commit, shows the hot protobuf size functions (<code>proto.MarshalOptions.size</code>, <code>impl.(*MessageInfo).sizePointer</code>) each moved by 96 bytes, and the benchmark closure by 160. Those are amd64 binaries, not necessarily what CI ran, so the layout is indicative only, and no hardware-counter measurement ties the shift to the delta.</p>
<p>The bot&rsquo;s own history on the PR fits the layout reading. From 12 June, each update of its comment said four regressions, 6–9% at every span count. On 19 June, after <code>main</code> had moved, the same comment said four improvements of 6–8% for the same benchmark. That is not noise around zero. The bias was stable and changed sign when <code>main</code> moved, the same shape as OPERA: a systematic error that looks like signal.</p>
<p>The resolution: nothing. No code change for the benchmark. A speculative &ldquo;fix&rdquo; to quiet it would have been chasing shadows.</p>
<p>At the time, these benchmarks ran on shared CI runners. We have since moved them to dedicated bare-metal machines, which takes the noisy neighbours out of the picture. It doesn&rsquo;t take code layout out, and neither does a same-machine A/B, since the two binaries still differ in layout. It removes the machine. CI said +6 to 9% and my machine about −5% for a benchmark whose loop the PR never touched; a sign that flips between machines points at layout more than at code. Neither machine is authoritative by default; the flip is the tell to go and look at layout. The same benchmark tripped again on <a href="https://github.com/DataDog/dd-trace-go/pull/4926">#4926</a>, eleven days after #4891 was opened, with +6.5–8.5% on the same four sub-benchmarks. That time the flag was dismissed on sight as a known false positive: a code-layout artifact, with a local A/B of about +0.3%.</p>
<p>OPERA and #4891 teach the same thing. A number can be reproducible and still be directionally wrong, and a gate that is directionally wrong blocks good changes and waves bad ones through. One loose cable is bad luck. Knowing which cables to check is not, and the series gave us three.</p>
<h2 id="three-questions">Three questions</h2>
<p>Each earlier part went after one way a Go benchmark can mislead us. Side by side, they make a checklist to run before merging anything on a hot path:</p>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>Question</th>
					<th>Part</th>
					<th>What to verify</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Is the compiler measuring real work?</td>
					<td><a href="/posts/go-benchmarks-lying-compiler-honesty/">Compiler honesty</a></td>
					<td>Sink pattern present; no discarded results; <code>allocs/op</code> &gt; 0 when allocation is expected</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Is my sample stable enough?</td>
					<td><a href="/posts/go-benchmarks-lying-statistics/">Statistics</a></td>
					<td>CV &lt; ~5%; at least <code>-count=10</code></td>
			</tr>
			<tr>
					<td>3</td>
					<td>Is the difference large relative to the noise?</td>
					<td><a href="/posts/go-benchmarks-lying-local-reproduction/">Local reproduction</a> &amp; <a href="/posts/go-benchmarks-lying-ci/">CI</a></td>
					<td><code>benchstat</code> p-value &lt; 0.05 and an effect that matters; environment diagnosed; A/B on the same machine, since a sub-10% micro delta can be code layout; CI used for detection, not as the primary measurement</td>
			</tr>
	</tbody>
</table>
<p>Each question gates the next. A benchmark the compiler has optimised away answers question 2 with noise, and a noisy environment makes question 3 unanswerable whatever the sample size. A checklist nobody runs is decoration, so each question gets a tool.</p>
<h2 id="wire-it-up-this-afternoon">Wire it up this afternoon</h2>
<p>The three CLIs from the talk are in <a href="https://github.com/kakkoyun/benchlab/tree/v0.1.0">benchlab</a>, my own project, one per question. Everything here describes <code>v0.1.0</code>, the tag pinned for the talk on 12 August 2026. They share one stdlib-only Go module (<code>go 1.24</code>). Pin the tag, because unreleased work on <code>main</code> changes some of these flags:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go install github.com/kakkoyun/benchlab/cmd/...@v0.1.0
</span></span></code></pre></td></tr></table>
</div>
</div><p>We&rsquo;ll take them in question order, starting with the compiler.</p>
<h3 id="honestbench"><code>honestbench</code></h3>
<p><code>honestbench</code> answers question 1. It walks <code>*_test.go</code> files with <code>go/ast</code> and flags results discarded after computation (dead-code elimination candidates), missing sink patterns, <code>StopTimer</code>/<code>StartTimer</code> misordering, and <code>b.N</code> loops that should migrate to <code>testing.B.Loop</code>, introduced in Go 1.24. It exits 1 on findings, so it works as a CI gate:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>honestbench -r ./...
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>-r</code> recurses into subdirectories, <code>-json</code> prints machine-readable output and <code>-q</code> prints findings only. Exit codes are <code>0</code> for clean, <code>1</code> for findings and <code>2</code> for an error. We run it before reading a single <code>ns/op</code>, because a finding on a <code>b.N</code> loop means the benchmark probably measures something other than what we think.</p>
<p>One caveat: <code>v0.1.0</code> also reports discarded results inside <code>for b.Loop()</code> bodies, where the <code>testing</code> package <a href="https://pkg.go.dev/testing#B.Loop">documents</a> that call results are kept alive. That includes the <code>proto.Size</code> loop from my story. Treat a finding there as a prompt to look, not as proof. (Yes, my own linter flags my own story. Tools are measurements too.) Once the compiler is honest, the next question is whether the sample is.</p>
<h3 id="benchgate"><code>benchgate</code></h3>
<p><code>benchgate</code> answers question 2. It runs benchmarks N times, computes the coefficient of variation (CV) per benchmark, and fails if any exceeds a threshold. It can also diff against a saved baseline through <code>benchstat</code>, which must be on your <code>PATH</code> (see <a href="/posts/go-benchmarks-lying-statistics/">part 2</a>):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>benchgate -pkg ./... -count <span style="color:#ae81ff">10</span> -cv-threshold 5.0
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>-pkg</code> (default <code>./...</code>) and <code>-bench</code> (default <code>.</code>) pick what to run, <code>-count</code> (default 10) says how many times, and <code>-cv-threshold</code> (default 5.0, in percent) sets the bar. <code>-baseline</code> takes a saved file to diff against, <code>-save</code> writes one, and <code>-json</code> prints machine-readable output. A gate at 5% catches environments too noisy for a reliable A/B before we waste time interpreting numbers. <a href="/posts/go-benchmarks-lying-ci/#why-shared-runners-lie">Part 4</a> shows what SMT and frequency scaling do to CV.</p>
<p>To capture a baseline on the current branch and compare after a change, we run it twice:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>benchgate -pkg ./... -count <span style="color:#ae81ff">10</span> -save old.txt
</span></span><span style="display:flex;"><span><span style="color:#75715e"># make your change</span>
</span></span><span style="display:flex;"><span>benchgate -pkg ./... -count <span style="color:#ae81ff">10</span> -baseline old.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p>The second run calls <code>benchstat old.txt &lt;new-output&gt;</code> for us and prints the comparison. The exit code comes from the CV check alone, so a +40% delta on a quiet sample still exits 0; gating on the delta is a step you write. A stable sample still says nothing about the machine that produced it, which is what the third tool is for.</p>
<h3 id="benchenv"><code>benchenv</code></h3>
<p><code>benchenv</code> helps with question 3. It diagnoses the measurement environment: SMT state, CPU frequency governor, Turbo Boost, system load average, and which of <code>perflock</code>, <code>benchstat</code> and <code>benchdiff</code> are installed. It works across platforms and degrades gracefully on macOS, where sysfs controls are unavailable. Its only flag is <code>-json</code>.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>benchenv
</span></span></code></pre></td></tr></table>
</div>
</div><p>Here is one run, on one machine: an Apple M4 Max (darwin/arm64, Go 1.27.1) with <code>benchenv</code> from <code>v0.1.0</code>, on 2026-10-02. The load-average warning depends on what else the machine was doing, so yours will differ.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>benchenv: benchmarking environment diagnosis (darwin/arm64, 16 CPUs)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  [unavailable]   SMT control — macOS does not expose SMT control via sysfs — use a Linux machine or bare-metal CI runner for publication-quality numbers
</span></span><span style="display:flex;"><span>  [unavailable]   CPU frequency governor — macOS does not expose a CPU frequency governor — use a Linux machine or bare-metal CI runner for publication-quality numbers
</span></span><span style="display:flex;"><span>  [unavailable]   Turbo Boost — macOS does not expose Turbo Boost control from user space — use a Linux machine or bare-metal CI runner for publication-quality numbers
</span></span><span style="display:flex;"><span>  [warn]          load average — close background applications before benchmarking
</span></span><span style="display:flex;"><span>  [unavailable]   thermal pressure — macOS thermal state is not accessible from user space — watch for CPU throttling on sustained benchmark runs
</span></span><span style="display:flex;"><span>  [warn]          perflock not installed — go install github.com/aclements/perflock@latest
</span></span><span style="display:flex;"><span>  [ok]            benchstat installed — benchstat found on PATH
</span></span><span style="display:flex;"><span>  [warn]          benchdiff not installed — go install github.com/willabides/benchdiff/cmd/benchdiff@latest
</span></span><span style="display:flex;"><span>  [ok]            GOMAXPROCS / NumCPU — NumCPU=16 GOMAXPROCS=16
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Summary: 2 ok, 3 warn, 4 unavailable. Fix warnings before trusting benchmark numbers.
</span></span></code></pre></td></tr></table>
</div>
</div><p>Every <code>[warn]</code> line is a noise source or a missing tool, and the <code>[unavailable]</code> lines are macOS declining to say. The install hints print <code>@latest</code>; pin <code>perflock</code> and <code>benchdiff</code> the way <a href="/posts/go-benchmarks-lying-local-reproduction/">part 3</a> does. We run <code>benchenv</code> once at the start of a benchmarking session, fix the warnings, then run <code>benchgate</code>, then compare with <code>benchstat</code>.</p>
<h2 id="the-minimum-viable-discipline">The minimum viable discipline</h2>
<p>If we keep one thing from this series, it should be this: run benchmarks ten times, not once. The whole loop needs only the Go toolchain and <code>benchstat</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Baseline on the current branch</span>
</span></span><span style="display:flex;"><span>go test -bench<span style="color:#f92672">=</span>. -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> ./... | tee old.txt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># After your change</span>
</span></span><span style="display:flex;"><span>go test -bench<span style="color:#f92672">=</span>. -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> ./... | tee new.txt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Compare</span>
</span></span><span style="display:flex;"><span>benchstat old.txt new.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>benchstat</code> then tells us whether the difference clears the noise. That is the floor. For anything you report, use <code>-count=20</code> (plus <code>-benchtime=2s</code> on a noisy Mac, <a href="/posts/go-benchmarks-lying-local-reproduction/">part 3</a>), and for CI baselines consider a fixed count (<code>-benchtime=Nx</code>, <a href="/posts/go-benchmarks-lying-statistics/">part 2</a>) over part 4&rsquo;s time-based 2s and 5s. Around that loop go all three tools: <code>honestbench -r ./...</code> before reading any numbers, <code>benchenv</code> on any new machine or CI runner, and <code>benchgate -cv-threshold 5.0</code> as a gate that fails early when the environment is too noisy for a reliable signal.</p>
<p>Three tools, under an hour to wire up, for any Go project. Time to check them against the cable from the start.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="where-to-go-from-here">Where to go from here</h2>
<p>Back to the loose cable. Run #4891 through the questions. Question 1 passes: the loop sizes a real protobuf message. Question 2 passes too, with a CV under 0.1%, and that is the trap, because a stable sample is not the same as a right one. Question 3 is the one that failed. The CI delta said slower, the A/B on one machine said faster, and the sign flipped when <code>main</code> moved. The A/B on one machine took the machine out of the story, with CI as the smoke alarm rather than the judge.</p>
<p>That is the series. The <a href="/talks/why-your-go-benchmarks-are-lying/">talk page</a> has the slides and links, the CLIs are in <a href="https://github.com/kakkoyun/benchlab/tree/v0.1.0">benchlab</a>, and the demo results are in <a href="https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying">gopherconuk-26</a>.</p>
<p>Go find your loose cable. Preferably before anyone calls a press conference.</p>
]]></content:encoded></item><item><title>A/B is the wrong model for CI</title><link>https://kakkoyun.me/posts/go-benchmarks-ab-is-the-wrong-model/</link><pubDate>Sat, 01 Aug 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-ab-is-the-wrong-model/</guid><description>A per-PR benchmark A/B compares two runs. A benchmark&amp;#39;s history is a time series. A synthetic change-point toy, how the Go project tracks it, and a false-positive ledger.</description><content:encoded><![CDATA[<p>We&rsquo;re going to take one benchmark and stop looking at it as a pair of numbers. First we&rsquo;ll see why a pair misleads, then we&rsquo;ll build a small time series and read it.</p>
<p>Start with a pair that misled me. On <a href="https://github.com/DataDog/dd-trace-go/pull/4891#issuecomment-4690941739">dd-trace-go #4891</a>, the benchmark bot&rsquo;s comment now says <code>BenchmarkOTLPProtoSize/1span</code> is 7.6% to 8.0% <em>faster</em> than its baseline. Eleven days after #4891 was opened, on <a href="https://github.com/DataDog/dd-trace-go/pull/4926#issuecomment-4779621296">#4926</a>, the bot said the same sub-benchmark was 8.1% to 8.5% <em>slower</em>. Both comments are correct arithmetic over two sets of runs, and the baselines differ: <code>05d9b8a</code> on the first, <code>1e830f6</code> on the second. Same benchmark, opposite verdicts. 🔀</p>
<p>This is a companion to the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, between <a href="/posts/go-benchmarks-lying-ci/">part 4</a> and <a href="/posts/go-benchmarks-lying-three-questions/">part 5</a>. Part 5 tells the #4891 story in full; here we ask what it looks like over time. Disclosure: I work at Datadog, which maintains dd-trace-go.</p>
<h2 id="a-pair-carries-an-assumption">A pair carries an assumption</h2>
<p>The bot says what it does in its own comment: &ldquo;This is an A/B test comparing a candidate commit&rsquo;s performance against that of a baseline commit.&rdquo; For a question about one change, that is fair, and <a href="/posts/go-benchmarks-pr-gate-that-fails/">A PR gate that actually fails</a> builds one.</p>
<p>The pair quietly assumes the baseline is a fixed, neutral reference. It isn&rsquo;t. <code>main</code> moves, and the binary&rsquo;s layout and the machine&rsquo;s mood move with it. Change the baseline and the same benchmark gets a different verdict, as above; on #4891 alone the sign changed between bot updates (<a href="/posts/go-benchmarks-lying-three-questions/#the-ci-regression-that-was-a-speedup">part 5</a>). A pair can&rsquo;t show that, because it has one point on each side.</p>
<p>What does the same benchmark look like when we keep every night instead of comparing two? Let&rsquo;s make one up.</p>
<h2 id="sixty-nights-of-one-benchmark">Sixty nights of one benchmark</h2>
<p>Everything in this section is <strong>synthetic</strong>: a Go program generated it, and no real benchmark did. The benchmark is a pretend 1000 ns/op function measured once a night, with 0.5% Gaussian noise. Three times, at nights 20, 35 and 50, a &ldquo;PR&rdquo; lands and adds 1.5%. On night 42 a noisy neighbour adds 8% for one night only. We&rsquo;ll read it two ways: a 5% gate comparing each night with the one before, standing in for a PR check against one baseline run, and a change-point detector we&rsquo;ll write after that.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">main</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> (
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;fmt&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;math&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;math/rand/v2&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;slices&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> (
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">nights</span>    = <span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">minLen</span>    = <span style="color:#ae81ff">5</span>
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">threshold</span> = <span style="color:#ae81ff">6.0</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">main</span>() {
</span></span><span style="display:flex;"><span>	<span style="color:#75715e">// Synthetic: 1000 ns/op, 0.5% noise, three +1.5% steps, one noisy night.</span>
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">rng</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">rand</span>.<span style="color:#a6e22e">New</span>(<span style="color:#a6e22e">rand</span>.<span style="color:#a6e22e">NewPCG</span>(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>))
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">series</span> <span style="color:#f92672">:=</span> make([]<span style="color:#66d9ef">float64</span>, <span style="color:#a6e22e">nights</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">i</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">series</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">level</span> <span style="color:#f92672">:=</span> <span style="color:#ae81ff">1000.0</span>
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">step</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> []<span style="color:#66d9ef">int</span>{<span style="color:#ae81ff">20</span>, <span style="color:#ae81ff">35</span>, <span style="color:#ae81ff">50</span>} {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">if</span> <span style="color:#a6e22e">i</span> <span style="color:#f92672">&gt;=</span> <span style="color:#a6e22e">step</span> {
</span></span><span style="display:flex;"><span>				<span style="color:#a6e22e">level</span> <span style="color:#f92672">*=</span> <span style="color:#ae81ff">1.015</span>
</span></span><span style="display:flex;"><span>			}
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">series</span>[<span style="color:#a6e22e">i</span>] = <span style="color:#a6e22e">level</span> <span style="color:#f92672">*</span> (<span style="color:#ae81ff">1</span> <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.005</span><span style="color:#f92672">*</span><span style="color:#a6e22e">rng</span>.<span style="color:#a6e22e">NormFloat64</span>())
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">series</span>[<span style="color:#ae81ff">42</span>] <span style="color:#f92672">*=</span> <span style="color:#ae81ff">1.08</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">i</span> <span style="color:#f92672">:=</span> <span style="color:#ae81ff">1</span>; <span style="color:#a6e22e">i</span> &lt; <span style="color:#a6e22e">nights</span>; <span style="color:#a6e22e">i</span><span style="color:#f92672">++</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> <span style="color:#a6e22e">d</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">series</span>[<span style="color:#a6e22e">i</span>]<span style="color:#f92672">/</span><span style="color:#a6e22e">series</span>[<span style="color:#a6e22e">i</span><span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>] <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>; <span style="color:#a6e22e">math</span>.<span style="color:#a6e22e">Abs</span>(<span style="color:#a6e22e">d</span>) &gt; <span style="color:#ae81ff">0.05</span> {
</span></span><span style="display:flex;"><span>			<span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Printf</span>(<span style="color:#e6db74">&#34;night %2d vs night %2d: %+.1f%%  &lt;- a 5%% gate fires\n&#34;</span>, <span style="color:#a6e22e">i</span>, <span style="color:#a6e22e">i</span><span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">100</span><span style="color:#f92672">*</span><span style="color:#a6e22e">d</span>)
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#e6db74">&#34;change points (night where a new level starts):&#34;</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">cp</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">split</span>(<span style="color:#a6e22e">series</span>, <span style="color:#ae81ff">0</span>, <span style="color:#a6e22e">nights</span>) {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Printf</span>(<span style="color:#e6db74">&#34;  night %d\n&#34;</span>, <span style="color:#a6e22e">cp</span>)
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>The first loop is the per-PR model. Here is the first half of the output, from Go 1.27.1:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>night 42 vs night 41: +9.3%  &lt;- a 5% gate fires
</span></span><span style="display:flex;"><span>night 43 vs night 42: -8.3%  &lt;- a 5% gate fires
</span></span></code></pre></td></tr></table>
</div>
</div><p>The gate fires twice, both times on the noisy neighbour: night 42 looks like a regression, night 43 like a recovery. It never fires on the three real steps, because 1.5% is well under its threshold, yet together they made the benchmark about 4.6% slower. The gate blames the innocent night and lets the guilty ones through. <a href="/posts/go-benchmarks-lying-ci/#two-patterns-that-actually-work">Part 4</a> said a change costing 1% per PR won&rsquo;t trip a per-PR gate. Here are the numbers.</p>
<h2 id="reading-the-series">Reading the series</h2>
<p>Change-point detection scans the whole history for places where the results shift, and stays quiet about everything else. <a href="https://arxiv.org/abs/2003.00584">MongoDB engineers</a> moved to an E-Divisive means detector after threshold-based detection, and report that it &ldquo;dramatically dropped our false positive rate&rdquo;.</p>
<p>Our toy reader is much cruder and does <em>not</em> use E-Divisive. It does binary segmentation: find the night with the largest median shift, and if that beats a threshold, cut there and repeat on both halves. Here is the rest of the program:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// split does binary segmentation: cut at the strongest shift, recurse on both halves.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">split</span>(<span style="color:#a6e22e">x</span> []<span style="color:#66d9ef">float64</span>, <span style="color:#a6e22e">lo</span>, <span style="color:#a6e22e">hi</span> <span style="color:#66d9ef">int</span>) []<span style="color:#66d9ef">int</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">best</span>, <span style="color:#a6e22e">bestT</span> <span style="color:#f92672">:=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0.0</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">k</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">lo</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">minLen</span>; <span style="color:#a6e22e">k</span> <span style="color:#f92672">&lt;=</span> <span style="color:#a6e22e">hi</span><span style="color:#f92672">-</span><span style="color:#a6e22e">minLen</span>; <span style="color:#a6e22e">k</span><span style="color:#f92672">++</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> <span style="color:#a6e22e">t</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">math</span>.<span style="color:#a6e22e">Abs</span>(<span style="color:#a6e22e">shift</span>(<span style="color:#a6e22e">x</span>[<span style="color:#a6e22e">lo</span>:<span style="color:#a6e22e">k</span>], <span style="color:#a6e22e">x</span>[<span style="color:#a6e22e">k</span>:<span style="color:#a6e22e">hi</span>])); <span style="color:#a6e22e">t</span> &gt; <span style="color:#a6e22e">bestT</span> {
</span></span><span style="display:flex;"><span>			<span style="color:#a6e22e">best</span>, <span style="color:#a6e22e">bestT</span> = <span style="color:#a6e22e">k</span>, <span style="color:#a6e22e">t</span>
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">if</span> <span style="color:#a6e22e">bestT</span> &lt; <span style="color:#a6e22e">threshold</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">return</span> <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> append(append(<span style="color:#a6e22e">split</span>(<span style="color:#a6e22e">x</span>, <span style="color:#a6e22e">lo</span>, <span style="color:#a6e22e">best</span>), <span style="color:#a6e22e">best</span>), <span style="color:#a6e22e">split</span>(<span style="color:#a6e22e">x</span>, <span style="color:#a6e22e">best</span>, <span style="color:#a6e22e">hi</span>)<span style="color:#f92672">...</span>)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// shift is the difference of medians in units of its standard error, with</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// the noise scale taken from the median absolute deviation so that a single</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// spike cannot inflate it.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">shift</span>(<span style="color:#a6e22e">a</span>, <span style="color:#a6e22e">b</span> []<span style="color:#66d9ef">float64</span>) <span style="color:#66d9ef">float64</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">ma</span>, <span style="color:#a6e22e">mb</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">median</span>(<span style="color:#a6e22e">a</span>), <span style="color:#a6e22e">median</span>(<span style="color:#a6e22e">b</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">dev</span> <span style="color:#f92672">:=</span> make([]<span style="color:#66d9ef">float64</span>, <span style="color:#ae81ff">0</span>, len(<span style="color:#a6e22e">a</span>)<span style="color:#f92672">+</span>len(<span style="color:#a6e22e">b</span>))
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">e</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">a</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">dev</span> = append(<span style="color:#a6e22e">dev</span>, <span style="color:#a6e22e">math</span>.<span style="color:#a6e22e">Abs</span>(<span style="color:#a6e22e">e</span><span style="color:#f92672">-</span><span style="color:#a6e22e">ma</span>))
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">e</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">b</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">dev</span> = append(<span style="color:#a6e22e">dev</span>, <span style="color:#a6e22e">math</span>.<span style="color:#a6e22e">Abs</span>(<span style="color:#a6e22e">e</span><span style="color:#f92672">-</span><span style="color:#a6e22e">mb</span>))
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">sigma</span> <span style="color:#f92672">:=</span> <span style="color:#ae81ff">1.4826</span> <span style="color:#f92672">*</span> <span style="color:#a6e22e">median</span>(<span style="color:#a6e22e">dev</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> (<span style="color:#a6e22e">mb</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">ma</span>) <span style="color:#f92672">/</span> (<span style="color:#a6e22e">sigma</span> <span style="color:#f92672">*</span> <span style="color:#a6e22e">math</span>.<span style="color:#a6e22e">Sqrt</span>(<span style="color:#ae81ff">1</span><span style="color:#f92672">/</span>float64(len(<span style="color:#a6e22e">a</span>))<span style="color:#f92672">+</span><span style="color:#ae81ff">1</span><span style="color:#f92672">/</span>float64(len(<span style="color:#a6e22e">b</span>))))
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">median</span>(<span style="color:#a6e22e">x</span> []<span style="color:#66d9ef">float64</span>) <span style="color:#66d9ef">float64</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">s</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">slices</span>.<span style="color:#a6e22e">Sorted</span>(<span style="color:#a6e22e">slices</span>.<span style="color:#a6e22e">Values</span>(<span style="color:#a6e22e">x</span>))
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> (<span style="color:#a6e22e">s</span>[(len(<span style="color:#a6e22e">s</span>)<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)<span style="color:#f92672">/</span><span style="color:#ae81ff">2</span>] <span style="color:#f92672">+</span> <span style="color:#a6e22e">s</span>[len(<span style="color:#a6e22e">s</span>)<span style="color:#f92672">/</span><span style="color:#ae81ff">2</span>]) <span style="color:#f92672">/</span> <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>split</code> tries every cut at least <code>minLen</code> nights from either end and keeps the strongest. The <code>threshold</code> of 6 is a knob I picked by eye for this data, and a real deployment has to set its own. The last part of the output:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>change points (night where a new level starts):
</span></span><span style="display:flex;"><span>  night 21
</span></span><span style="display:flex;"><span>  night 33
</span></span><span style="display:flex;"><span>  night 49
</span></span></code></pre></td></tr></table>
</div>
</div><p>It finds three level changes and ignores the night-42 spike. The planted steps were at 20, 35 and 50, so it lands within two nights of each. On a real series the output is a list of candidate nights, and the next job is <code>git log</code> between them. Part 4 lists <a href="/posts/go-benchmarks-lying-ci/#tool-survey-and-a-recommendation">tools that ship change-point detection</a>.</p>
<h2 id="how-the-go-project-reads-its-own">How the Go project reads its own</h2>
<p>The Go wiki&rsquo;s <a href="https://go.dev/wiki/PerformanceMonitoring">PerformanceMonitoring</a> page states the principle plainly: &ldquo;We never report performance numbers in isolation, and only relative to some baseline&rdquo;, because &ldquo;comparing performance data taken far apart in time, even on the same hardware, can result in a lot of noise that goes unaccounted for&rdquo;. Before merge, <a href="https://go.dev/wiki/SlowBots">SlowBot</a> <code>perf_vs_parent</code> and <code>perf_vs_tip</code> run a pair on purpose. After merge, the <a href="https://perf.golang.org/dashboard/">performance dashboard</a> gives &ldquo;continuous monitoring of benchmark performance for every commit&rdquo;, and because the tip-of-tree baseline &ldquo;is always the latest overall release of Go&rdquo;, &ldquo;on every minor release of Go, the baseline shifts&rdquo;. Read that next to our opening pair: the sign on #4891 flipped when <code>main</code> moved, and Go&rsquo;s dashboard puts baseline changes where you can see them.</p>
<h2 id="a-ledger-for-alarms-that-werent">A ledger for alarms that weren&rsquo;t</h2>
<p>Bot comments will keep arriving, and some will be old friends. A mute regex silences a benchmark and forgets why. I&rsquo;d add a ledger entry per dismissed alarm, in the repository, next to the benchmark. This one is illustrative, written from the public bot comments and part 5:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>BenchmarkOTLPProtoSize (all four sub-benchmarks)
</span></span><span style="display:flex;"><span>  seen:    dd-trace-go #4891 (12 Jun), #4926 (23 Jun)
</span></span><span style="display:flex;"><span>  symptom: 6-9% in one direction; sign flipped when main moved
</span></span><span style="display:flex;"><span>  cause:   code layout, suspected, not proven (see part 5)
</span></span><span style="display:flex;"><span>  check:   same-machine A/B of base vs head before anyone edits code
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>seen</code> turns one surprise into a count, <code>cause</code> says how sure we are, and <code>check</code> names the decisive test, so the next person doesn&rsquo;t improvise one.</p>
<h2 id="back-to-the-one-benchmark">Back to the one benchmark</h2>
<p>The pair told us −8% on one PR and +8% on another. Our sixty nights told us which night was a neighbour and which were the code. Keep the nightly series from <a href="/posts/go-benchmarks-lying-ci/">part 4</a>, use pairs for &ldquo;did this change do it&rdquo;, and write down the alarms that turned out to be nothing.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="try-it">Try it</h2>
<p>The two Go blocks above are one program: paste the second under the first in a <code>main.go</code>, run <code>go mod init cp</code> and <code>go run .</code>. I ran that with Go 1.27.1 and got the output shown. Change the seed or <code>threshold</code> and watch the detector miss a step or invent one. Then ask what the baseline was in your own bot&rsquo;s last three comments.</p>
<p>A benchmark is a story told over time. Stop reading only the last sentence. 📈</p>
]]></content:encoded></item><item><title>A PR gate that actually fails</title><link>https://kakkoyun.me/posts/go-benchmarks-pr-gate-that-fails/</link><pubDate>Fri, 31 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-pr-gate-that-fails/</guid><description>One slow commit, one workflow, two real GitHub Actions runs: how a Go PR gate turns a benchstat table into a red check, and what it can&amp;#39;t promise.</description><content:encoded><![CDATA[<p>Part 4 ended with a promise: the PR gate blocks only when <code>benchstat</code> says the delta is real and bigger than a floor we chose. A fair reader question is how. <code>benchstat</code> prints a table, and I checked that it exits 0 even when the table says <code>+199.00%</code> (two made-up result files, the pinned version, Go 1.27.1). Who, then, turns that table into a red check?</p>
<p>We&rsquo;ll build that job together. We&rsquo;ll write one deliberately slow commit, let the job judge it, revert the commit, and let the job judge the revert. Two runs, two verdicts, both on a GitHub-hosted runner:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Ints-4   2.579µ ± 1%   7.700µ ± 1%  +198.57% (p=0.000 n=10)
</span></span><span style="display:flex;"><span>Ints-4   2.579µ ± 2%   2.576µ ± 1%  ~ (p=0.782 n=10)
</span></span></code></pre></td></tr></table>
</div>
</div><p>The first line is the slow commit, and the second is its revert. This is a companion to the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, between <a href="/posts/go-benchmarks-lying-ci/">part 4</a> and <a href="/posts/go-benchmarks-lying-three-questions/">part 5</a>, the written companion of my <a href="/talks/why-your-go-benchmarks-are-lying/">GopherCon UK talk</a>. GitHub deletes Actions logs after a retention period, so this post carries the key log lines itself. The <code>benchgate</code> tool is <a href="https://github.com/kakkoyun/benchlab/tree/v0.1.0"><code>kakkoyun/benchlab</code> at v0.1.0</a>, and the workflow and both runs live on <a href="https://github.com/kakkoyun/benchlab/pull/5">PR #5</a>, a closed demo.</p>
<h2 id="the-slow-commit">The slow commit</h2>
<p>The patient is a small benchmark, <code>BenchmarkInts</code>, which sums a slice of integers. The slow commit routes every addition through a helper marked <code>//go:noinline</code>, so each <code>total = add(total, x)</code> becomes a real function call. I made it slow on purpose, and by a lot: roughly three times.</p>
<p>A PR against <code>main</code> has no benchmark on its base side, so the base binary couldn&rsquo;t even build. The demo PR targets a branch that already holds the workflow and the benchmark, and the diff under test is the slow commit alone. First run, slow commit. Second run, I push the revert to the same PR.</p>
<p>Now the judge. Where should it run, and against what?</p>
<h2 id="why-base-and-head-share-a-job">Why base and head share a job</h2>
<p>Part 4 keeps the baseline from <code>main</code> in a cache, which suits a pinned runner. On a hosted runner, a cached baseline was measured on whatever VM ran the earlier job, and nothing promises that VM is the one judging your PR. This gate builds both binaries in one job and runs them on the same VM, taking turns: base, head, base, head. That interleaving (ABAB) spreads slow drift evenly over both sides. It does not remove noisy neighbours, and we&rsquo;ll come back to that.</p>
<p>Here is the whole workflow. Read the comments, then we&rsquo;ll walk through it step by step.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">  9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 29
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 30
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 31
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 32
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 33
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 34
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 35
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 36
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 37
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 38
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 39
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 40
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 41
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 42
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 43
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 44
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 45
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 46
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 47
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 48
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 49
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 50
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 51
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 52
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 53
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 54
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 55
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 56
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 57
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 58
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 59
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 60
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 61
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 62
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 63
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 64
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 65
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 66
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 67
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 68
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 69
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 70
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 71
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 72
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 73
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 74
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 75
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 76
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 77
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 78
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 79
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 80
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 81
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 82
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 83
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 84
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 85
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 86
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 87
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 88
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 89
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 90
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 91
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 92
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 93
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 94
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 95
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 96
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 97
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 98
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 99
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">100
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">101
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">102
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">103
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">104
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">105
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">106
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">107
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">108
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">109
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">110
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">111
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">112
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">113
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">114
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">115
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">116
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">117
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">118
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">119
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">120
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">121
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">122
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">123
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">124
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">125
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">126
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">127
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">128
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">129
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">130
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">131
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">132
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">133
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">134
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">135
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># A PR gate that actually fails: companion to part 4 of the Go benchmarks series.</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># One job builds the base and head benchmark binaries, runs them interleaved</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># (ABAB), and fails on a regression that benchstat calls real. benchgate rules</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># out an unstable sample first.</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">bench-gate</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">on</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">pull_request</span>:
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">permissions</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">contents</span>: <span style="color:#ae81ff">read</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">env</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">BENCHSTAT_VERSION</span>: <span style="color:#ae81ff">v0.0.0-20260929162123-406019bb8b68</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">BENCHGATE_VERSION</span>: <span style="color:#ae81ff">v0.1.0</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">BENCH_PKG</span>: <span style="color:#ae81ff">./internal/sum</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># ROUNDS is both the number of ABAB rounds and the sample count per side (n).</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ROUNDS</span>: <span style="color:#e6db74">&#34;10&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">BENCHTIME</span>: <span style="color:#ae81ff">200ms</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># Block only when benchstat says the delta is real (p &lt; 0.05) AND at least this large.</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">MIN_DELTA_PCT</span>: <span style="color:#e6db74">&#34;5&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># benchgate fails the sample when any benchmark&#39;s coefficient of variation is above this.</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">CV_THRESHOLD</span>: <span style="color:#e6db74">&#34;10&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">jobs</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">bench-gate</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">runs-on</span>: <span style="color:#ae81ff">ubuntu-latest</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">timeout-minutes</span>: <span style="color:#ae81ff">10</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">steps</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># pull_request checks out the merge commit, so HEAD^1 is the base tip and</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># HEAD is what would land. Depth 2 is enough to reach both.</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803</span> <span style="color:#75715e"># v6</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">with</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">fetch-depth</span>: <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">persist-credentials</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16</span> <span style="color:#75715e"># v6</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">with</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">go-version</span>: <span style="color:#e6db74">&#34;1.27.1&#34;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">cache</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Install benchstat and benchgate</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          export GOBIN=&#34;$RUNNER_TEMP/bin&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          go install &#34;golang.org/x/perf/cmd/benchstat@${BENCHSTAT_VERSION}&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          go install &#34;github.com/kakkoyun/benchlab/cmd/benchgate@${BENCHGATE_VERSION}&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Build base and head benchmark binaries</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          if [ &#34;$(git rev-list --parents -n 1 HEAD | wc -w)&#34; -ne 3 ]; then
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            echo &#34;::error::HEAD is not a merge commit; expected the pull_request merge ref&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            exit 2
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          fi
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          mkdir -p &#34;$RUNNER_TEMP/bench&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          git worktree add --detach &#34;$RUNNER_TEMP/base&#34; HEAD^1
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          echo &#34;base: $(git rev-parse --short HEAD^1)  head: $(git rev-parse --short HEAD)&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          go test -c -o &#34;$RUNNER_TEMP/bench/head.test&#34; &#34;$BENCH_PKG&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          (cd &#34;$RUNNER_TEMP/base&#34; &amp;&amp; go test -c -o &#34;$RUNNER_TEMP/bench/base.test&#34; &#34;$BENCH_PKG&#34;)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Run base and head interleaved (ABAB)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          pkgdir=&#34;${BENCH_PKG#./}&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          : &gt; &#34;$RUNNER_TEMP/bench/base.txt&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          : &gt; &#34;$RUNNER_TEMP/bench/head.txt&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          for round in $(seq 1 &#34;$ROUNDS&#34;); do
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            for side in base head; do
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              root=&#34;$RUNNER_TEMP/base&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              if [ &#34;$side&#34; = head ]; then root=&#34;$GITHUB_WORKSPACE&#34;; fi
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              echo &#34;round $round: $side&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              (cd &#34;$root/$pkgdir&#34; &amp;&amp; &#34;$RUNNER_TEMP/bench/$side.test&#34; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                -test.run=&#39;^$&#39; -test.bench=. -test.benchtime=&#34;$BENCHTIME&#34; -test.count=1) \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &gt;&gt; &#34;$RUNNER_TEMP/bench/$side.txt&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            done
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          done</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Compare with benchstat</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          cd &#34;$RUNNER_TEMP/bench&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          benchstat=&#34;$RUNNER_TEMP/bin/benchstat&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          &#34;$benchstat&#34; base.txt head.txt
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          &#34;$benchstat&#34; -format csv base.txt head.txt &gt; compare.csv
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          # Rows are &#34;name,base,CI,head,CI,delta,P&#34;. Gate on the sec/op table only; the delta
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          # column holds &#34;~&#34; when benchstat sees noise, so only a signed percentage can match.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          awk -F, -v floor=&#34;$MIN_DELTA_PCT&#34; &#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            $1 == &#34;&#34; &amp;&amp; NF &gt; 1 { gate = ($2 == &#34;sec/op&#34;); next }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            gate &amp;&amp; $1 != &#34;geomean&#34; &amp;&amp; $4 != &#34;&#34; { compared++ }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            gate &amp;&amp; $1 != &#34;geomean&#34; &amp;&amp; $6 ~ /^\+[0-9.]+%$/ &amp;&amp; substr($6, 2) + 0 &gt;= floor {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              printf &#34;%s %s (%s)\n&#34;, $1, $6, $7 &gt; &#34;regressions.txt&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            END { print compared + 0 &gt; &#34;compared.count&#34; }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          &#39; compare.csv
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          touch regressions.txt</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Check sample stability with benchgate</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          cd &#34;$RUNNER_TEMP/bench&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          # Exit codes: 0 stable, 1 unstable (CV over threshold), 2 error. Record it
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          # instead of failing here, so the verdict step can report everything at once.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          rc=0
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          (cd &#34;$GITHUB_WORKSPACE&#34; &amp;&amp; &#34;$RUNNER_TEMP/bin/benchgate&#34; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            -pkg &#34;$BENCH_PKG&#34; -count &#34;$ROUNDS&#34; -benchtime &#34;$BENCHTIME&#34; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            -cv-threshold &#34;$CV_THRESHOLD&#34;) || rc=$?
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          echo &#34;$rc&#34; &gt; benchgate.rc</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Gate verdict</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          cd &#34;$RUNNER_TEMP/bench&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          compared=&#34;$(cat compared.count)&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          benchgate_rc=&#34;$(cat benchgate.rc)&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          # Exit codes: 0 pass, 1 regression, 2 tool error, 3 unstable sample.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          if [ &#34;$compared&#34; -eq 0 ]; then
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            echo &#34;::error title=Nothing compared::benchstat found no benchmark present in both base and head&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            exit 2
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          fi
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          status=0
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          case &#34;$benchgate_rc&#34; in
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            0) ;;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            1)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              echo &#34;::error title=Unstable sample::benchgate exit 1: a benchmark&#39;s CV is above ${CV_THRESHOLD}%; the run cannot support a verdict&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              status=3
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              ;;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            *)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              echo &#34;::error title=benchgate failed::benchgate exit ${benchgate_rc}&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              exit 2
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              ;;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          esac
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          if [ -s regressions.txt ]; then
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            while read -r line; do
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              echo &#34;::error title=Benchmark regression::${line}&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            done &lt; regressions.txt
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            status=1
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          fi
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          echo &#34;verdict: exit ${status}&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          exit &#34;$status&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="walking-through-it">Walking through it</h2>
<p>The <code>env</code> block holds every number we might argue about later. <code>ROUNDS</code> is both the number of ABAB rounds and the sample count per side, so <code>n=10</code>. <code>MIN_DELTA_PCT</code> and <code>CV_THRESHOLD</code> are the two thresholds: a regression floor and a noise ceiling.</p>
<p>The checkout step uses <code>fetch-depth: 2</code>. On <code>pull_request</code>, GitHub checks out a merge commit, so <code>HEAD^1</code> is the tip of the base branch and <code>HEAD</code> is what would land. Depth 2 reaches both. The build step refuses to continue if <code>HEAD</code> doesn&rsquo;t have two parents, and it prints both short hashes, which is how the logs told me the comparison was the right one (<code>base: 021b688  head: fb2ae9c</code> in run 1). It then runs <code>go test -c</code> once per side, so we have two frozen test binaries and no rebuild between rounds.</p>
<p>The ABAB loop runs each binary ten times with <code>-test.count=1</code>, appending to <code>base.txt</code> and <code>head.txt</code>. The flags are a budget. Ten samples per side is the floor the <code>benchstat</code> docs recommend, and 200 ms is plenty for a benchmark that takes a few microseconds, so the whole job took about 35 seconds. Part 4&rsquo;s nightly suite can afford <code>-count=20 -benchtime=5s</code> on controlled hardware, and this gate cannot afford that on every push.</p>
<p>The compare step is where the exit code gets made. It runs <code>benchstat</code> twice, once for the human-readable table and once with <code>-format csv</code>. The <code>awk</code> script reads rows shaped like <code>name,base,CI,head,CI,delta,P</code>, looks only at the <code>sec/op</code> table, and keeps a row only if its delta is a signed <code>+N%</code> with N at or above the floor. When <code>benchstat</code> sees noise, the delta column holds <code>~</code>, so noise can never match. A faster result starts with a minus and is ignored too. The gate counts compared rows and writes the survivors to <code>regressions.txt</code>.</p>
<p>Then <code>benchgate</code>. At tag v0.1.0, <code>-baseline</code> only <a href="https://github.com/kakkoyun/benchlab/blob/v0.1.0/cmd/benchgate/main.go#L146-L167">prints the <code>benchstat</code> output</a> and the exit code never looks at it. The exit code answers a different question: is the sample stable enough to trust? It exits 1 when any benchmark&rsquo;s coefficient of variation is above the threshold. That makes <code>benchgate</code> the noise gate and <code>benchstat</code> the regression gate. Also, <code>benchgate</code> runs its own <code>go test</code> on the head tree, one after another, so it measures how noisy the runner was, not the interleaved samples. The <code>±</code> column in the <code>benchstat</code> table is the better noise indicator for the comparison itself.</p>
<p>Last, the verdict step folds everything into one exit code: 0 pass, 1 regression, 2 tool or setup error (nothing compared, <code>benchgate</code> failed, not a merge commit), 3 unstable sample. A regression wins over &ldquo;unstable&rdquo;, and each finding becomes an <code>::error</code> annotation on the PR. Enough reading. Let&rsquo;s watch it judge.</p>
<h2 id="run-1-the-slow-commit">Run 1: the slow commit</h2>
<p><a href="https://github.com/kakkoyun/benchlab/actions/runs/37187167739">Run 1</a> failed, and it failed in the right step. The <code>benchstat</code> step printed this (the log also names the CPU, an AMD EPYC 7763):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>       │  base.txt   │               head.txt               │
</span></span><span style="display:flex;"><span>       │   sec/op    │   sec/op     vs base                 │
</span></span><span style="display:flex;"><span>Ints-4   2.579µ ± 1%   7.700µ ± 1%  +198.57% (p=0.000 n=10)
</span></span></code></pre></td></tr></table>
</div>
</div><p>Ints went from 2.579 µs to 7.700 µs, and the <code>±</code> columns are small. Then the stability check:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>benchgate: go test -bench=. -count=10 -benchtime=200ms ./internal/sum
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  BenchmarkInts                                 mean=  7816.3 ns/op  cv=  3.1%  ✓
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>VERDICT: PASS — all 1 benchmarks within CV threshold 10.0%
</span></span></code></pre></td></tr></table>
</div>
</div><p>A CV of 3.1% is under the 10% ceiling, so the sample was trusted. The verdict step did the rest:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>##[error]Ints-4 +198.57% (p=0.000 n=10)
</span></span><span style="display:flex;"><span>verdict: exit 1
</span></span><span style="display:flex;"><span>##[error]Process completed with exit code 1.
</span></span></code></pre></td></tr></table>
</div>
</div><p>The <code>##[error]</code> line is the annotation from the awk output, and the last line is the red check. Was it a fluke? Let&rsquo;s judge the revert.</p>
<h2 id="run-2-the-revert">Run 2: the revert</h2>
<p>The revert makes the head tree identical to the base, so <a href="https://github.com/kakkoyun/benchlab/actions/runs/37187216023">run 2</a> is a null test: there is nothing to find, and the gate should find nothing.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>       │  base.txt   │           head.txt            │
</span></span><span style="display:flex;"><span>       │   sec/op    │   sec/op     vs base          │
</span></span><span style="display:flex;"><span>Ints-4   2.579µ ± 2%   2.576µ ± 1%  ~ (p=0.782 n=10)
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>~</code> means <code>benchstat</code> could not tell the sides apart, and <code>benchgate</code> reported <code>cv=  3.4%  ✓</code>. The verdict step closed with:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>verdict: exit 0
</span></span></code></pre></td></tr></table>
</div>
</div><p>The gate went red for the slow commit and green for the revert, on the same kind of hosted runner, a minute apart.</p>
<h2 id="what-this-does-not-prove">What this does not prove</h2>
<p>Part 4&rsquo;s argument stands: <code>ubuntu-latest</code> is a shared machine, and I can&rsquo;t turn off SMT or frequency scaling from inside it. These runs don&rsquo;t show that the runner is quiet. They show that a 3x change is far outside whatever noise was there. I&rsquo;d call a hosted gate weaker than one on bare metal, not worthless. It catches the obvious regressions, and it can&rsquo;t promise to catch a 5–10% one. With <code>n=10</code> on a shared runner, a change that small can flap: pass once, fail the next time. <a href="/posts/go-benchmarks-ab-is-the-wrong-model/">A/B is the wrong model for CI</a> looks at what one pair per PR misses.</p>
<p>The CV numbers give a partial answer to a question readers asked, whether <code>-cv-threshold 5</code> always fails on hosted runners. In these two runs it would not have (3.1% and 3.4%). I set 10, and two runs are a sample of two. If a noisy run does trip the ceiling, the job fails closed with exit 3, a red check that is not a regression. I saw that path only on my laptop, where noise tripped it. I never saw exit 2 on GitHub.</p>
<p>The runs are also a single benchmark on one machine type, captured on 4 October 2026. If you adopt this, calibrate <code>BENCHTIME</code>, <code>ROUNDS</code> and the two thresholds on your own benchmarks first. The gate is only worth what that calibration says.</p>
<p>Versions, links and commands checked on 2 October 2026; the CI runs shown were captured on 4 October 2026.</p>
<h2 id="try-it">Try it</h2>
<p>Copy the workflow into a repository with a benchmark package, point <code>BENCH_PKG</code> at it, and push a branch holding the workflow and the benchmark. Open a PR against that branch with a deliberately slow commit, watch the check go red, then push a revert and watch it go green. That is exactly what I did in the demo, and <code>actionlint</code> had no complaints about the file.</p>
<p>A red check only means something if we trust what it compares. The slow commit was easy to catch: at about 3x, nothing about the hardware could hide it. The dangerous deltas are small ones that look real, and a green or red check that is really measuring code layout. <a href="/posts/go-benchmarks-lying-three-questions/">Part 5</a> opens with a CI regression that turned out to be a speedup. 🎟️</p>
]]></content:encoded></item><item><title>Go runtime futures: flight recording, USDT, and the instrumentation hook problem</title><link>https://kakkoyun.me/posts/go-runtime-futures-flight-recording-usdt/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-runtime-futures-flight-recording-usdt/</guid><description>Go 1.25&amp;#39;s flight recorder shipped and HTTP client tracing has gaps. My tracing and USDT forks are experiments, and context propagation is unsolved.</description><content:encoded><![CDATA[<p>Every route in this series goes around the Go runtime: rewrite the source at build time, inject something at process start, or watch from the kernel, because Go gives us no agent API to plug into. This time we ask the runtime what it could hand us. The honest summary for 2026: one long-awaited feature shipped, two open proposals document gaps that have caused real production problems for years, and two experiments of mine are aspirations, not proposals.</p>
<p>This is part 5 of 6 in the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, the written companion to my <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK 2026 talk</a>. <a href="/posts/continuous-profiling-go-without-code-changes/">Part 4</a> left us in the kernel with a profiler. Now we step outside the three intervention points and knock on the runtime&rsquo;s door four times (the <a href="/posts/fosdem-2026-auto-instrumenting-go/">FOSDEM 2026 post</a> covers the last knock at more length).</p>
<p>Disclosure: I work at Datadog, which maintains Orchestrion, and I&rsquo;m one of otelc&rsquo;s maintainers. The <code>poc_flight_recorder</code> and <code>poc_usdt</code> forks below are my own experiments.</p>
<h2 id="flight-recording-shipped-in-go-125">Flight recording shipped in Go 1.25</h2>
<p>Let&rsquo;s start with the door that opened. <a href="https://github.com/golang/go/issues/63185">golang/go#63185</a>, the flight recorder proposal, is closed. It shipped.</p>
<p>Go 1.25 includes a circular buffer tracer in <code>runtime/trace</code>, in the spirit of Java Flight Recorder (which has been around for a while, so allow Go a small victory lap). It records Go&rsquo;s execution trace, not OpenTelemetry spans: goroutine, syscall and GC events, read with <code>go tool trace</code>, not Jaeger. Instead of streaming trace data to a file, it keeps the last N seconds in memory and snapshots on demand. Here&rsquo;s a sketch of asking for it, imports left out. The API is a config struct, not setter methods:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">fr</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">trace</span>.<span style="color:#a6e22e">NewFlightRecorder</span>(<span style="color:#a6e22e">trace</span>.<span style="color:#a6e22e">FlightRecorderConfig</span>{
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">MinAge</span>:   <span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Second</span>,
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">MaxBytes</span>: <span style="color:#ae81ff">64</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>,
</span></span><span style="display:flex;"><span>})
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">fr</span>.<span style="color:#a6e22e">Start</span>(); <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">log</span>.<span style="color:#a6e22e">Fatal</span>(<span style="color:#a6e22e">err</span>)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Later, on a slow request or error:</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">buf</span> <span style="color:#a6e22e">bytes</span>.<span style="color:#a6e22e">Buffer</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">fr</span>.<span style="color:#a6e22e">WriteTo</span>(<span style="color:#f92672">&amp;</span><span style="color:#a6e22e">buf</span>); <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">log</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#e6db74">&#34;flight recorder:&#34;</span>, <span style="color:#a6e22e">err</span>)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#75715e">// buf now holds at least the last 2 seconds of trace data</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// (MinAge is a lower bound; MaxBytes wins if they conflict)</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>Start</code> begins recording and <code>WriteTo</code> copies the buffer out when something goes wrong. Notice that every line of it lives in our service&rsquo;s code. Hold that thought.</p>
<p>OBI (OpenTelemetry eBPF Instrumentation) tells you something is slow; flight recording tells you what the runtime was doing at the time. &ldquo;On demand&rdquo; has a catch: the recorder traces continuously, so you pay for tracing and for the in-memory buffer all the time (<a href="https://go.dev/blog/flight-recorder">the Go blog post</a> puts a busy service at around 10 MB/s of trace data, and suggests a <code>MinAge</code> of about 2x the event window). Only writing and keeping the data is on demand: wire <code>WriteTo</code> to your error handler or a debug HTTP endpoint (<code>net/http/pprof</code>-style; only one goroutine may call <code>WriteTo</code> at a time, and a concurrent call returns an error), and you keep a trace only when something goes wrong.</p>
<p>I pushed on this API too. My <a href="https://github.com/kakkoyun/go/tree/1f6836932a219381e4088d61cafdfefadd0dd03c"><code>poc_flight_recorder</code> fork</a> adds an <code>EventFilter</code> bitmask (<code>FilterHTTP</code>, <code>FilterSQL</code>, <code>FilterNet</code> and friends) with <code>SetEventFilter</code>, <code>SetSampleRate</code>, W3C <code>TraceContext</code> parsing, and <code>HTTPServerRequest</code> and <code>HTTPClientRequest</code> spans to <code>runtime/trace</code>, aiming at always-on distributed tracing. That&rsquo;s an aspiration. I don&rsquo;t expect the Go team to extend the flight recorder this way. The <a href="/posts/fosdem-2026-auto-instrumenting-go/">FOSDEM post</a> has the longer write-up.</p>
<p>The runtime handed us a way to watch itself. The libraries built on it are another story, and the HTTP client is where it hurts.</p>
<h2 id="the-httptrace-gap-that-breaks-http2-spans">The httptrace gap that breaks HTTP/2 spans</h2>
<p><a href="https://github.com/golang/go/issues/75654">golang/go#75654</a> is an open proposal for a hook called <code>GotResponseEnd</code> on <code>httptrace.ClientTrace</code>. No hook reliably tells the client, on every protocol, that a response body has been fully consumed. <code>PutIdleConn</code> is the workaround OTel Go uses, but the standard library documents that hook as not used for HTTP/2, so on HTTP/2 OTel Go&rsquo;s <code>http.receive</code> client span is never ended. That&rsquo;s tracked in <a href="https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4876">OpenTelemetry Go contrib issue #4876</a>, which was still open in October 2026. In the sources I read, otelc&rsquo;s <code>net/http</code> client hook (v1.1.0) doesn&rsquo;t reference <code>httptrace</code>, and OBI watches <code>net/http</code> with uprobes instead, so neither should hit this, though I haven&rsquo;t tested HTTP/2 spans from either.</p>
<p>As filed, the fix is simple. This is the proposal&rsquo;s pseudo-code, not something <code>httptrace</code> has today:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// Add to httptrace.ClientTrace:</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">GotResponseEnd</span> <span style="color:#66d9ef">func</span>(<span style="color:#a6e22e">err</span> <span style="color:#66d9ef">error</span>)
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Fires exactly once per request when resp.Body.Read returns io.EOF,</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// a non-nil error, or the body is closed early.</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>One callback, fired once. The shape is still being argued over, with commenters asking about responses with no body and bodies the application never reads, and there&rsquo;s no acceptance decision yet.</p>
<p>That&rsquo;s one hook a library can&rsquo;t get. The next ask is about how the tools from part 3 get between us and the compiler.</p>
<h2 id="why-compile-time-instrumentation-has-rough-edges">Why compile-time instrumentation has rough edges</h2>
<p>Orchestrion and otelc (OpenTelemetry Go compile-time instrumentation) intercept the compilation pipeline with <code>-toolexec</code>, as we saw in <a href="/posts/otelc-compile-time-go-traces/">part 3</a>. <a href="https://github.com/golang/go/issues/69887">golang/go#69887</a> is Romain Marcadier&rsquo;s proposal to improve it. Two of its gaps explain why these tools have sharper edges than they should.</p>
<p>First, a <code>-toolexec</code> tool can&rsquo;t influence the build cache per package. The only hook point is intercepting the <code>-V=full</code> version probe, which works at the level of the complete build, so the issue describes excessively frequent cache invalidation for untransformed packages. otelc changes every tool&rsquo;s version line, so instrumented builds never share cache entries with plain <code>go build</code>; its own cache keeps warm rebuilds fast. My <a href="https://internals-for-interns.com/posts/hooking-into-the-go-toolchain/#the-cache-will-lie-to-you">guest post</a> shows what the cache does to <code>-toolexec</code> tools, and <a href="https://internals-for-interns.com/posts/hooking-into-the-go-toolchain/#back-to-the-stopwatch">what that costs</a>. Second, the toolchain doesn&rsquo;t tell <code>-toolexec</code> tools the full build arguments, so Orchestrion crawls its own process tree looking for a parent <code>go build</code> invocation and parses its arguments. That&rsquo;s the workaround you write when the API won&rsquo;t give you what you need.</p>
<p>Did the Go team agree? Two members replied, leaning different ways. Michael Matloob said dedicated support for source-rewriting tools would add a lot of complexity to the <code>go</code> command and that he doesn&rsquo;t think the costs are worth it. Austin Clements called it a useful problem report and line-information handling a known weakness, and suggested <code>go list</code> plus <code>-overlay</code> as a way around the build-graph changes. As of October 2026 it&rsquo;s still at Incoming on the proposals project. A neighbouring ask, a hook on goroutine start (<a href="https://github.com/golang/go/issues/73798">golang/go#73798</a>), was closed as not planned on the day of filing, with a first reply that pointed at <code>toolexec</code> rewrites.</p>
<p>Both asks are about things Go already has. Our last stop is something it doesn&rsquo;t: probes.</p>
<h2 id="usdt-an-experiment-toward-tracepoints">USDT: an experiment toward tracepoints</h2>
<p>USDT (User Statically-Defined Tracing) probes are named sites compiled into a binary as NOP instructions, which do nothing. When a consumer such as bpftrace, perf, or a custom eBPF program attaches, the NOP is replaced with an INT3 interrupt and the kernel delivers the event, the same kernel path as uprobes.</p>
<p>What does that buy us over a bare uprobe? Not speed: a USDT site is a uprobe on a NOP, so it still costs a trap per hit, and my fork computes its arguments even with nothing attached, which I haven&rsquo;t benchmarked. It buys less work for the tool, which reads site addresses and argument locations from the note instead of hunting for every <code>RET</code> (no uretprobes, which Go&rsquo;s moving stacks can&rsquo;t survive), and a probe name that can outlive refactors; <a href="/posts/go-uprobe-vs-usdt/">What a uprobe costs, and what USDT buys</a> has the sources and the numbers.</p>
<p>The Go runtime doesn&rsquo;t ship USDT probes. The closest thing to a discussion is in the notes for the 2024-12-19 performance and diagnostics sync, recorded in <a href="https://github.com/golang/go/issues/57175">golang/go#57175</a>, where Felix Geisendörfer asked whether the Go team had considered USDT support. That&rsquo;s a brainstorm, not a plan, and I&rsquo;m not aware of a proposal filed since.</p>
<p>Asking nicely only goes so far, so I built one. My <a href="https://github.com/kakkoyun/go/tree/c739f4e38a792c85c22ab79ec6b44c9bcd0ca691"><code>poc_usdt</code> fork, pinned at commit <code>c739f4e</code></a>, adds USDT probes to <code>net/http</code>, <code>database/sql</code>, <code>crypto/tls</code>, and <code>net</code> via a <code>go tool usdt</code> subcommand. Once we build a binary from the fork, we can list its probes and generate a bpftrace script:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>$ go tool usdt list ./myserver
</span></span><span style="display:flex;"><span>PROVIDER   NAME                  ADDRESS     ARGUMENTS
</span></span><span style="display:flex;"><span>net_http   server_request_start  0x63296c    8@%rsi -8@%r8 8@%rdx -8@%r9
</span></span><span style="display:flex;"><span>net_http   server_request_end    0x631c5c    -4@%ecx
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$ go tool usdt bpftrace ./myserver &gt; trace.bt
</span></span><span style="display:flex;"><span>$ sudo bpftrace trace.bt
</span></span></code></pre></td></tr></table>
</div>
</div><p>That listing is abridged from the fork&rsquo;s <a href="https://github.com/kakkoyun/go/blob/c739f4e38a792c85c22ab79ec6b44c9bcd0ca691/src/runtime/trace/usdt/README.md">README</a>.</p>
<p>On Linux, a binary built with the fork and the internal linker gets standard library instrumentation automatically, with no SDK import and no application code changes. (On darwin the probes compile to nothing, and external linking currently drops them.) The probes are standard <code>.note.stapsdt</code> ELF notes, so <code>readelf -n</code> can read them. bpftrace&rsquo;s <code>usdt:</code> attach doesn&rsquo;t find the probes in a static Go binary, so the script <code>go tool usdt bpftrace</code> generates uses <code>uprobe:</code> probes at the probe addresses instead.</p>
<p>ARM64 argument parsing in bpftrace also has issues with the probe argument notation the fork emits. Nothing here is proposed upstream.</p>
<p>The idea I&rsquo;d argue for is bigger than USDT: generic tracepoints. Safe, named instrumentation points that the standard library and runtime expose on purpose, with stable arguments external tools can consume. USDT is one way to build them. It needs more work from me (the open items above) and the Go runtime team would have to accept it.</p>
<p>That&rsquo;s all four doors. Only one of them opened. 🚪</p>
<h2 id="observing-isnt-propagating">Observing isn&rsquo;t propagating</h2>
<p>Tracepoints let a tool see a request. Distributed tracing also has to carry trace headers from the incoming connection to the outgoing ones. Without threading <code>context.Context</code> by hand, that needs some goroutine-associated storage, and it must not lose what the request&rsquo;s context already carries: deadlines, cancellation, and values the application attached. <a href="/posts/why-go-cant-be-monkey-patched/#the-goroutine-problem">Part 1</a> showed tools hacking a field into <code>g</code> for this. My fork&rsquo;s <code>WithSpan</code> and <code>SpanFromContext</code> show the gap from the other side: they ride on <code>context.Context</code>, so the context still has to be threaded. <a href="/posts/go-context-across-goroutines/">Context across goroutines and connections</a> walks that gap with a small program. I have no solution. This is the unsolved part.</p>
<h2 id="what-is-already-usable">What is already usable</h2>
<p>Back to our question: what could the runtime hand us? Shipped: flight recording, on Go 1.25 or newer. Experimental, and mine: the tracing fork and the USDT probes. Unsolved: propagation without threading the context.</p>
<p>Remember the thought we held on to? The answer that shipped wants a few lines in our service, and the probes that need no application changes exist only in my fork. For a series about not changing a single line, that&rsquo;s the gap in a sentence. It&rsquo;s narrowing, just slowly.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/zero-touch-go-observability-agent-actionable/">Part 6, Making zero-touch Go observability agent-actionable</a>, pulls the tools together into a decision rule and a skill an agent can run. The runtime may get there on its own schedule. Part 6 is for those of us with a service to debug today.</p>
]]></content:encoded></item><item><title>Benchmark CI That Doesn't Lie</title><link>https://kakkoyun.me/posts/go-benchmarks-lying-ci/</link><pubDate>Tue, 28 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-lying-ci/</guid><description>Shared CI runners add noise that swamps real regressions. Build a two-tier benchmark CI: a fast PR gate plus a nightly suite on bare metal.</description><content:encoded><![CDATA[<p>Say we put a benchmark gate on every pull request: if performance regresses, the check fails. After a week, something strange shows up. A PR that does nothing performance-sensitive fails the gate. Another PR, one that rewrites a hot path, sails through with a green check.</p>
<p>Nobody is imagining it. Both outcomes are correct given the data, and the data is just wrong. A shared CI runner is a multi-tenant machine, so our benchmark lands on a host that also runs other teams&rsquo; compile jobs, Docker builds and test suites. A real 10% regression can vanish into that noise, and a phantom one can appear from nowhere. The gate isn&rsquo;t measuring our code. It&rsquo;s measuring the lottery of what happens to be running next door. 🎰</p>
<p>I&rsquo;ll call this the machine that changes its mind, and it will keep turning up as we go. Better statistics can&rsquo;t compensate for an unstable environment, so we&rsquo;ll change the environment.</p>
<p>This is part 4 of 5 in the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, the written companion to the <a href="/talks/why-your-go-benchmarks-are-lying/">GopherCon UK 2026 talk</a>. <a href="/posts/go-benchmarks-lying-local-reproduction/">Part 3</a> kept one machine honest. Here we answer the series&rsquo; third question, whether the difference is large relative to the noise, once the machine isn&rsquo;t ours. We&rsquo;ll start with a number from FOSDEM, two CPU-bound tasks sharing a core with a coefficient of variation around 23%, to see what shared runners do to a measurement. Then we&rsquo;ll split the gate into a fast PR check and a slow nightly suite, wire the nightly one into GitHub Actions on bare metal, and pick a tool for the history. <a href="/posts/go-benchmarks-lying-three-questions/">Part 5</a> closes the series with a checklist.</p>
<p>The prerequisite is <a href="/posts/fosdem-2026-measuring-software-performance/">the FOSDEM post</a>, which covers environment control in language-agnostic terms. Its experiments ran on a dedicated AWS <code>m5.metal</code> instance, with slides and results in <a href="https://github.com/igoragoli/fosdem-2026-software-performance/tree/2cea1c2a854119f48d124daf2a8157480cd4450a"><code>igoragoli/fosdem-2026-software-performance</code></a>, and we reuse those numbers here. What exactly makes a shared machine change its mind?</p>
<h2 id="why-shared-runners-lie">Why shared runners lie</h2>
<p>Three mechanisms compound. Another tenant&rsquo;s CPU-bound job contends with ours for memory bandwidth and last-level cache (LLC), which is shared across the cores of a die, and for execution units if it lands on a sibling hardware thread. Dynamic frequency scaling (DFS, or Turbo Boost on Intel) adjusts the CPU clock based on thermal load and power headroom, and a shared VM cannot disable it from inside the guest, so a benchmark that ran at 3.5 GHz in one CI run may run at 3.1 GHz the next because a neighbouring workload raised the thermal floor.</p>
<p>Then there&rsquo;s the hypervisor. Even without tenant competition, it can pause our vCPU to serve another tenant&rsquo;s interrupt, and from inside the guest that looks like our benchmark stalled.</p>
<p>The FOSDEM experiments put numbers on what controlling these variables buys. Start with <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-3">SMT</span>
  <span role="tooltip" id="tt-3" class="tooltip-bubble">Simultaneous Multithreading, also marketed as Hyper-Threading on Intel. Two hardware threads share one physical core&rsquo;s execution resources.</span>
</span>
. With it enabled, two CPU-bound tasks sharing a core (DFS off) showed a coefficient of variation (CV, the standard deviation as a share of the mean) around 23%. With it disabled, the same two tasks ran on separate cores:</p>
<table>
	<thead>
			<tr>
					<th>Configuration</th>
					<th>Mean</th>
					<th>CV</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>SMT enabled, task 1</td>
					<td>1537.64 ± 367.29 ms</td>
					<td>23.887%</td>
			</tr>
			<tr>
					<td>SMT enabled, task 2</td>
					<td>1536.88 ± 366.84 ms</td>
					<td>23.869%</td>
			</tr>
			<tr>
					<td>SMT disabled, task 1</td>
					<td>737.37 ± 0.32 ms</td>
					<td>0.044%</td>
			</tr>
			<tr>
					<td>SMT disabled, task 2</td>
					<td>737.93 ± 1.74 ms</td>
					<td>0.235%</td>
			</tr>
	</tbody>
</table>
<p>Turning SMT off cut CV by roughly a hundredfold, and the tasks ran faster too, because they stopped fighting over one core&rsquo;s execution units. Next the clock. With SMT already disabled on the same machine, the <a href="https://github.com/igoragoli/fosdem-2026-software-performance/blob/2cea1c2a854119f48d124daf2a8157480cd4450a/presentation.md">slides</a> show DFS on and off for a single task:</p>
<table>
	<thead>
			<tr>
					<th>Configuration</th>
					<th>Mean</th>
					<th>CV</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>DFS on, 1 task</td>
					<td>533.97 ± 2.046 ms</td>
					<td>0.383%</td>
			</tr>
			<tr>
					<td>DFS off, 1 task</td>
					<td>738.18 ± 0.306 ms</td>
					<td>0.041%</td>
			</tr>
	</tbody>
</table>
<p>The DFS-off row is the SMT-disabled configuration again (SMT and DFS off), hence the same ~738 ms. Turning DFS off cut CV roughly tenfold. Absolute runtime goes up, because the CPU runs at base frequency instead of boosting, but the measurement is stable: slow and steady beats fast and moody.</p>
<p>How do those numbers compare with a setup we might already have? <a href="/posts/go-benchmarks-lying-local-reproduction/">Part 3</a> showed that a pinned container on a macOS developer machine bottoms out near 5.25% CV. A 5% CV puts a 5% real regression inside the noise floor of any single run, and resolving it takes more samples than a PR gate can afford. On bare metal with SMT and DFS disabled, the FOSDEM runs drop below 0.25%, well below any regression worth caring about.</p>
<p>The machine can be tamed, then, but only if we own the whole box. What we run on it comes next, because a PR gate and a nightly history want opposite things.</p>
<h2 id="two-patterns-that-actually-work">Two patterns that actually work</h2>
<p>A PR gate needs to finish in minutes, while thorough statistics need many samples over a stable environment. A nightly suite can run for an hour on controlled hardware, but most teams can&rsquo;t require that hardware on every PR. No single job serves both, so we build two.</p>
<h3 id="pattern-a-pr-gate">Pattern A: PR gate</h3>
<p>The gate runs on every pull request and catches unambiguous regressions before they merge. It targets five minutes or less on a pinned runner, running a curated subset: benchmarks that have regressed before, or that the PR directly exercises. I&rsquo;d start with <code>-count=6 -benchtime=2s</code>, the lowest count that works, because <a href="https://pkg.go.dev/golang.org/x/perf@v0.0.0-20260929162123-406019bb8b68/cmd/benchstat"><code>benchstat</code></a> needs six samples to print a 95% confidence interval at all, and its docs recommend at least ten. The baseline comes from <code>main</code>, kept in a GitHub Actions cache rather than an artifact, which by default is only visible to the run that produced it. The gate blocks only when <code>benchstat</code> reports a delta instead of <code>~</code> (p &lt; 0.05) and that delta is larger than a floor we choose.</p>
<p>Two caveats. <code>benchstat</code> exits 0 whatever it finds, so a step must read its <code>vs base</code> column and fail the job on a significant delta. A cached baseline was also measured earlier, maybe elsewhere: <a href="https://go.dev/wiki/PerformanceMonitoring">data taken far apart in time, even on the same hardware, can result in a lot of noise</a>, says the Go wiki, so building base and head in one job and interleaving them is stronger; <a href="/posts/go-benchmarks-pr-gate-that-fails/">A PR gate that actually fails</a> shows that job catching a slow commit. On GitHub-hosted runners the gate is weaker, not worthless: a smoke alarm, as <a href="/posts/go-benchmarks-lying-three-questions/">part 5</a> says, not the judge.</p>
<span class="sidenote-ref" id="ref-sn-3" role="doc-noteref">
  <sup><a href="#sn-3" aria-describedby="sn-3">threshold</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-3" role="note" aria-label="Sidenote threshold (right margin)">
  <span class="sidenote-label">threshold</span>
  <span class="sidenote-content">A raw percentage threshold (&ldquo;block on a &gt;5% change&rdquo;) is dangerous in both directions: it blocks a 5.1% improvement and passes a 4.9% regression. With <code>benchstat</code>&rsquo;s significance test alongside it, <code>~ (p=0.3)</code> should not block and <code>+8.00% (p=0.001 n=6)</code> should.</span>
  <a class="sidenote-back" href="#ref-sn-3" aria-label="Back to reference">↩</a>
</aside>

<h3 id="pattern-b-nightly-full-suite">Pattern B: nightly full suite</h3>
<p>The nightly suite runs on a schedule against dedicated bare-metal hardware, and it watches for the slow regressions that accumulate across many PRs. It can afford <code>-count=20 -benchtime=5s</code>, with the full environment controls applied at the runner level. Results go into our own time-series store (Bencher or Nyrkiö, for example, from the survey below), compared against a rolling 30-day window, with change-point detection flagging regressions. Alerts go to Slack or email, and nightly data never blocks a PR.</p>
<p>A change that costs 1% per PR, landing five times over a sprint, won&rsquo;t trip a per-PR gate, but change-point detection on the time series can; <a href="/posts/go-benchmarks-ab-is-the-wrong-model/">A/B is the wrong model for CI</a> builds a small detector to show it. The nightly suite is only as trustworthy as the box under it, though, so let&rsquo;s see what that box needs.</p>
<h2 id="bare-metal-versus-vm">Bare metal versus VM</h2>
<p>The controls are the ones from <a href="/posts/go-benchmarks-lying-local-reproduction/#the-linux-toolbox">part 3</a>: SMT off, the performance governor with turbo off, and <code>taskset</code>. Together they remove the three largest sources of benchmark variance, but only where they are physically real. Inside a shared VM the hypervisor manages SMT, so the guest cannot disable it, and frequency controls in a guest, where they exist at all, do not change the physical CPU clock. And <code>echo off &gt; /sys/devices/system/cpu/smt/control</code> may succeed (it offlines the guest&rsquo;s own sibling vCPUs) without changing how the hypervisor schedules them. A control that succeeds and changes nothing is the worst kind: it makes us feel better and measures the same.</p>
<p>Where do we find a machine with real controls? AWS EC2 bare-metal instances (<code>m5.metal</code>, <code>m7i.metal-24xl</code>) are dedicated physical hosts with no hypervisor layer, at roughly $4-5/hour on demand for Linux in us-east-1. Hetzner rents dedicated servers with full root access. Past an hour or so of nightly runtime, one persistent bare-metal machine is likely to beat cloud instances on cost: an <code>m5.metal</code> running two hours nightly costs around $280/month, so a dedicated server, rented or owned, can pay for itself within months.</p>
<p>Suppose we have the box. How do we point a workflow at it, and only at it?</p>
<h2 id="github-actions-wiring">GitHub Actions wiring</h2>
<p>The nightly workflow below runs on a self-hosted bare-metal runner and applies the controls before the benchmarks. The critical detail is <code>runs-on: [self-hosted, linux, x64, bare-metal]</code>. GitHub adds <code>self-hosted</code>, <code>linux</code> and <code>x64</code> when we register the runner; <code>bare-metal</code> is a custom label we add, and the combination routes only to those runners. Writing <code>ubuntu-latest</code> here would silently route to shared VMs and defeat the entire exercise, which is a lot of YAML to end up exactly where we started.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># .github/workflows/benchmark-nightly.yml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">Nightly Benchmarks</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">on</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">schedule</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">cron</span>: <span style="color:#e6db74">&#39;0 2 * * *&#39;</span>  <span style="color:#75715e"># 2am UTC</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">jobs</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">benchmark</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">runs-on</span>: [<span style="color:#ae81ff">self-hosted, linux, x64, bare-metal]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">steps</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/checkout@v7</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">with</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">fetch-depth</span>: <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/setup-go@v7</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">with</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">go-version-file</span>: <span style="color:#ae81ff">go.mod</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Restore baseline cache</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/cache/restore@v6</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">with</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">path</span>: <span style="color:#ae81ff">bench-baseline.txt</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">key</span>: <span style="color:#ae81ff">bench-baseline-${{ github.run_id }}</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">restore-keys</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            bench-baseline-</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Apply environment controls</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          echo off | sudo tee /sys/devices/system/cpu/smt/control &gt;/dev/null
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor &gt;/dev/null
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo &gt;/dev/null</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Run benchmarks</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">shell</span>: <span style="color:#ae81ff">bash </span> <span style="color:#75715e"># runs with pipefail, so a failing go test fails the step</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          taskset -c 0 go test \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            -bench=. -benchmem \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            -count=20 -benchtime=5s \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            ./... | tee bench-new.txt</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Compare with baseline</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          if [ -f bench-baseline.txt ]; then
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            go run golang.org/x/perf/cmd/benchstat@v0.0.0-20260929162123-406019bb8b68 \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              bench-baseline.txt bench-new.txt
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          else
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            echo &#34;No cached baseline yet. This run establishes one.&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Promote new baseline</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">run</span>: <span style="color:#ae81ff">cp bench-new.txt bench-baseline.txt</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Save baseline cache</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">if</span>: <span style="color:#ae81ff">always()</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/cache/save@v6</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">with</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">path</span>: <span style="color:#ae81ff">bench-baseline.txt</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">key</span>: <span style="color:#ae81ff">bench-baseline-${{ github.run_id }}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Top to bottom, it restores last night&rsquo;s baseline, applies the sysfs controls, pins the run to CPU 0, compares with <code>benchstat</code>, then promotes tonight&rsquo;s results as the new baseline. <code>taskset -c 0</code> makes <code>GOMAXPROCS</code> 1, so it suits single-threaded benchmarks; parallel ones need a wider CPU set. It never undoes the controls, fine for a box that only runs benchmarks; on a shared machine, add an <code>if: always()</code> step that writes <code>on</code> to the SMT control. It only prints the comparison. The time-series store and the alerting come from the tools in the survey below.</p>
<p>The PR gate uses the same structure with a dedicated (but lighter) pinned runner in <code>runs-on</code>, a lower count and benchtime to fit the time budget, and only the curated benchmark subset. One warning before copying any of this: GitHub advises that <a href="https://docs.github.com/en/actions/reference/security/secure-use">self-hosted runners should almost never be used for public repositories</a>, because anyone who can open a pull request can run code on them, and this workflow uses <code>sudo</code>. On a private repository, put the gate behind an environment with required reviewers.</p>
<p>The workflow leans on <code>benchstat</code> and promises a time series it doesn&rsquo;t keep. Where do those come from? The Go team ships the first one.</p>
<h2 id="the-golangorgxperf-toolchain">The golang.org/x/perf toolchain</h2>
<p><a href="https://pkg.go.dev/golang.org/x/perf@v0.0.0-20260929162123-406019bb8b68"><code>golang.org/x/perf</code></a> is the Go team&rsquo;s official performance measurement toolchain, and its tools read the standard <code>go test -bench</code> output with no adapters or code changes. <code>benchstat</code> is the one we&rsquo;ve been leaning on. It computes the median, a non-parametric confidence interval for the median, and a Mann-Whitney U-test across two result sets. It prints <code>~</code> when the difference is indistinguishable from noise, and otherwise a delta with its p-value and sample count. That makes it the primary tool for A/B comparisons, locally and in CI:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260929162123-406019bb8b68
</span></span><span style="display:flex;"><span>benchstat bench-baseline.txt bench-new.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p>The version pin matches the one in the workflow, and the old results go first, so the delta reads as the new run against the baseline. The <code>benchsave</code> sibling uploads results to a <code>perfdata</code> server, but its default one is the Go project&rsquo;s, behind an interactive Google sign-in that publicly records your email address. Unattended CI needs its own store.</p>
<p><code>benchstat</code> is a point-in-time comparison. It keeps no time series and alerts on no drift. The tools from part 3 carry over; the controls need bare metal. What CI adds is the historical baseline, the automated gate and the cross-PR comparison that no human maintains by hand. The storage layer is what&rsquo;s left to choose, and the field is crowded.</p>
<h2 id="tool-survey-and-a-recommendation">Tool survey and a recommendation</h2>
<p>The tools that tackle continuous Go benchmarking differ in statistical model, hosting model, and how much infrastructure they need. Here is the field as of the talk (12 August 2026):</p>
<table>
	<thead>
			<tr>
					<th>Tool</th>
					<th>Go native</th>
					<th>Hosting</th>
					<th>Statistics</th>
					<th>PR gate</th>
					<th>Maintained</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong><a href="https://bencher.dev">bencher.dev</a></strong></td>
					<td>Yes</td>
					<td>Both</td>
					<td>t-test, z-score, IQR, log-normal, percentage</td>
					<td>First-class</td>
					<td>Yes</td>
			</tr>
			<tr>
					<td><strong><a href="https://github.com/benchmark-action/github-action-benchmark/tree/v1.22.1"><code>github-action-benchmark</code></a></strong></td>
					<td>Yes</td>
					<td>GitHub Pages</td>
					<td>Percentage threshold</td>
					<td>Yes</td>
					<td>Yes (v1.22.1, May 2026)</td>
			</tr>
			<tr>
					<td><strong>gobenchdata</strong></td>
					<td>Yes</td>
					<td>GitHub Pages</td>
					<td>User-defined expression</td>
					<td>Yes</td>
					<td>Maintenance-mode (last release Jan 2023)</td>
			</tr>
			<tr>
					<td><strong>cob</strong></td>
					<td>Yes</td>
					<td>None (ephemeral)</td>
					<td>Percentage threshold</td>
					<td>Via exit code</td>
					<td>Borderline (Oct 2023)</td>
			</tr>
			<tr>
					<td><strong>chronologer</strong></td>
					<td>No, uses <a href="https://github.com/sharkdp/hyperfine">hyperfine</a>, not <code>go test</code></td>
					<td>None (local HTML)</td>
					<td>None</td>
					<td>No</td>
					<td>Minimal</td>
			</tr>
			<tr>
					<td><strong><a href="https://github.com/nyrkio/change-detection/tree/v2.0.2">Nyrkiö</a> / <a href="https://otava.apache.org/">Apache Otava</a></strong></td>
					<td>Yes</td>
					<td>Both</td>
					<td>E-divisive change-point detection</td>
					<td>Yes</td>
					<td>Yes (Nyrkiö 2.0.0, Feb 2026)</td>
			</tr>
			<tr>
					<td><strong>codespeed</strong> (Django; not CodSpeed)</td>
					<td>No, custom uploader required</td>
					<td>Self-hosted (Django)</td>
					<td>Visualization only</td>
					<td>No</td>
					<td>Unmaintained (last release Feb 2019)</td>
			</tr>
			<tr>
					<td><strong>golang.org/x/perf benchstat</strong></td>
					<td>Yes (official)</td>
					<td>perf.golang.org (Go team only)</td>
					<td>Median + CI, significance test</td>
					<td>None built-in</td>
					<td>Yes</td>
			</tr>
	</tbody>
</table>
<p><a href="https://codspeed.io/docs/benchmarks/go">CodSpeed</a> is not in the table: its Go integration is in early development and supports only wall-time measurement, not the CPU simulation it offers elsewhere. One of these deserves a warning label. <strong>cob</strong> has a subtle footgun: it internally runs <code>git reset</code>, so all changes must be committed before running it, which rules it out for pre-commit hooks or any CI step with a dirty working tree.</p>
<p>Which would I pick? That depends on who &ldquo;we&rdquo; are.</p>
<p><strong>Small OSS project on GitHub.</strong> Use <code>benchmark-action/github-action-benchmark</code>. It needs no external accounts and stores results on GitHub Pages. The default 200% threshold is too loose to be useful. The <code>alert-threshold</code> input is a ratio of current to previous, so tighten it to <code>110%</code>-<code>120%</code> (a 10-20% slowdown) and add a <code>benchstat</code> step alongside it, because the action has no concept of significance. With <code>-count=10</code> the <code>benchstat</code> step gives honest numbers in the log, but the action treats each sample line as a separate result and does not average them.</p>
<p><strong>Team with a dedicated CI runner.</strong> Use <strong>bencher.dev self-hosted</strong>, on a noise-controlled machine. Bencher&rsquo;s <a href="https://bencher.dev/pricing/">pricing page</a> says you can run the self-hosted binary without a Bencher Plus license, at the feature level of the Cloud Free plan, so check it against your private-project needs. Configure the <code>t_test</code> threshold model; unlike <code>z_score</code>, its docs do not ask for 30 historical samples. Wire <code>--error-on-alert</code> to block PRs. The <code>go_bench</code> adapter needs no changes to your benchmark code, though it reports only the mean.</p>
<p><strong>Large org wanting change-point detection.</strong> Use <strong>Nyrkiö with Apache Otava</strong> as the detection backend. The <a href="https://arxiv.org/abs/2003.00584">e-divisive approach, as applied at MongoDB</a> finds persistent shifts across the series instead of comparing each run with a fixed baseline, and adapts to each benchmark&rsquo;s noise through its significance test. Apache Otava entered incubation in November 2024. Nyrkiö 2.0.0 (February 2026) added Nyrkiö Runners for GitHub, which its release notes describe as configured for stable, repeatable benchmark results. For fully on-premises operation, Apache Otava can run against a self-hosted time-series store.</p>
<p>In every scenario, keep <code>benchstat</code> for local comparisons and PR descriptions. Of the tools here, only <code>benchstat</code> gives a confidence interval and a p-value for a direct comparison of two sets of samples, so trust it when the question is whether a change is real. Which brings us back to the gate we started with.</p>
<h2 id="back-to-the-machine-that-changes-its-mind">Back to the machine that changes its mind</h2>
<p>Remember the gate that failed a harmless PR and waved a hot-path rewrite through? It faithfully reported a machine that kept changing its mind. With the setup we just built, the nightly suite runs on bare metal with SMT off, the performance governor set and turbo off, and the PR gate blocks only when <code>benchstat</code> says the delta is real and larger than a floor we chose. The lottery next door is out of the draw. 🎟️</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/go-benchmarks-lying-three-questions/">Part 5, Three Questions Before You Trust a Benchmark</a>, closes the series: a CI regression that turned out to be a speedup, and a three-question checklist backed by three small CLIs. Even on perfect hardware, a benchmark can still lie to us. Part 5 shows how.</p>
]]></content:encoded></item><item><title>Context across goroutines and connections</title><link>https://kakkoyun.me/posts/go-context-across-goroutines/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-context-across-goroutines/</guid><description>Three different jobs hide behind the word propagation in Go: copying goroutine storage, keeping context.Context, and carrying trace headers between connections.</description><content:encoded><![CDATA[<p>We&rsquo;re going to follow one request, <code>GET /orders/42</code>, and ask what &ldquo;propagation&rdquo; means at each hop. Three different jobs hide behind that word, the tools in this series do different ones, and the request&rsquo;s own data falls into the gaps between them. The cheapest way to see a gap is a short Go program.</p>
<p>This is a companion to the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, between <a href="/posts/continuous-profiling-go-without-code-changes/">Part 4</a> and <a href="/posts/go-runtime-futures-flight-recording-usdt/">Part 5</a>. The series accompanies my <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK talk</a>. <a href="/posts/why-go-cant-be-monkey-patched/#the-goroutine-problem">Part 1</a> showed tools hacking a field into <code>g</code>. Here we look at what that field holds, what it can&rsquo;t, and who does the rest.</p>
<p>Disclosure: I work at Datadog and I&rsquo;m one of the maintainers of otelc, the OpenTelemetry compile-time instrumentation tool.</p>
<h2 id="what-falls-out-of-a-goroutine">What falls out of a goroutine</h2>
<p>Our request arrives with a context: a trace ID stored as a value, a one-second deadline, and, because the client hung up, a cancellation. The program below hands that context to one goroutine and <code>context.Background()</code> to another. The second is what a function ends up with when its signature has no <code>ctx</code> parameter and nothing else to go on. Each goroutine prints what it can learn about the request:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">main</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> (
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;context&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;fmt&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;time&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">traceKey</span> <span style="color:#66d9ef">struct</span>{}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// inGoroutine runs the report in a new goroutine and waits for it.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">inGoroutine</span>(<span style="color:#a6e22e">name</span> <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">ctx</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Context</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">done</span> <span style="color:#f92672">:=</span> make(<span style="color:#66d9ef">chan</span> <span style="color:#66d9ef">struct</span>{})
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">go</span> <span style="color:#66d9ef">func</span>() {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">defer</span> close(<span style="color:#a6e22e">done</span>)
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">hasDeadline</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">ctx</span>.<span style="color:#a6e22e">Deadline</span>()
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Printf</span>(<span style="color:#e6db74">&#34;%-12s value=%v deadline=%v err=%v\n&#34;</span>,
</span></span><span style="display:flex;"><span>			<span style="color:#a6e22e">name</span>, <span style="color:#a6e22e">ctx</span>.<span style="color:#a6e22e">Value</span>(<span style="color:#a6e22e">traceKey</span>{}), <span style="color:#a6e22e">hasDeadline</span>, <span style="color:#a6e22e">ctx</span>.<span style="color:#a6e22e">Err</span>())
</span></span><span style="display:flex;"><span>	}()
</span></span><span style="display:flex;"><span>	<span style="color:#f92672">&lt;-</span><span style="color:#a6e22e">done</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">main</span>() {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">ctx</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">WithValue</span>(<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Background</span>(), <span style="color:#a6e22e">traceKey</span>{}, <span style="color:#e6db74">&#34;trace-abc123&#34;</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">ctx</span>, <span style="color:#a6e22e">cancel</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">WithTimeout</span>(<span style="color:#a6e22e">ctx</span>, <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Second</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">cancel</span>() <span style="color:#75715e">// the client hung up</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">inGoroutine</span>(<span style="color:#e6db74">&#34;request ctx:&#34;</span>, <span style="color:#a6e22e">ctx</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">inGoroutine</span>(<span style="color:#e6db74">&#34;fresh ctx:&#34;</span>, <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Background</span>())
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>Go 1.27.1 prints this (one run, one machine, though the output doesn&rsquo;t depend on timing):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>request ctx: value=trace-abc123 deadline=true err=context canceled
</span></span><span style="display:flex;"><span>fresh ctx:   value=&lt;nil&gt; deadline=false err=&lt;nil&gt;
</span></span></code></pre></td></tr></table>
</div>
</div><p>The first goroutine knows the lot because we handed it the context. The second knows nothing: no value, no deadline, and <code>ctx.Err()</code> is nil even though the client left. Threading <code>ctx</code> through by hand fixes it, and that&rsquo;s the code change this series is trying to avoid. A tool that can&rsquo;t ask us to do that needs somewhere else to keep the request.</p>
<h2 id="job-one-copy-a-slot-when-a-goroutine-starts">Job one: copy a slot when a goroutine starts</h2>
<p>Orchestrion (Datadog&rsquo;s <code>-toolexec</code> tool) keeps that somewhere else in a <code>__dd_gls_v2</code> field on <code>runtime.g</code>, which <a href="/posts/why-go-cant-be-monkey-patched/#the-goroutine-problem">part 1</a> walks through. Its <a href="https://github.com/DataDog/dd-trace-go/blob/v2.9.1/internal/orchestrion/gls.orchestrion.yml">aspect</a> adds the field and clears it in <code>goexit1</code>, and neither rule touches the code that starts a goroutine, so the slot does not follow <code>go</code>. The <code>TODO</code> part 1 quotes from the context stack is still there at v2.9.1. I read both files at v2.0.0 and v2.9.1, and they match.</p>
<p>otelc copies on <code>go</code>. The <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/runtime/otelc.yaml">runtime rule at v1.1.0</a> (the <code>instrumentation/runtime/</code> directory is identical at v1.0.1, the series pin) injects a deferred function into <code>newproc1</code>, the runtime function that builds a new goroutine, to copy the two fields it adds to <code>g</code>, <code>otel_trace_context</code> and <code>otel_baggage_container</code>. Re-indented the way gofmt would print it, the injected code is:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">defer</span> <span style="color:#66d9ef">func</span>() {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">_unnamedRetVal0</span>.<span style="color:#a6e22e">otel_trace_context</span> = <span style="color:#a6e22e">propagateOtelContext</span>(<span style="color:#a6e22e">callergp</span>.<span style="color:#a6e22e">otel_trace_context</span>)
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">_unnamedRetVal0</span>.<span style="color:#a6e22e">otel_baggage_container</span> = <span style="color:#a6e22e">propagateOtelContext</span>(<span style="color:#a6e22e">callergp</span>.<span style="color:#a6e22e">otel_baggage_container</span>)
</span></span><span style="display:flex;"><span>}()
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>_unnamedRetVal0</code> is the new goroutine and <code>callergp</code> is its creator. <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/runtime/runtime_gls.go#L28-L36"><code>propagateOtelContext</code></a> returns nil for nil, calls <code>Clone()</code> when the value has one, and otherwise hands over the same reference. The one <code>Clone</code> in the SDK instrumentation is on the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/go.opentelemetry.io/otel/sdk/trace/otel_trace_context.go#L264">span stack</a>: it keeps the innermost live span and drops the rest, so the child starts with a stack of one. The baggage slot is copied too, but in the v1.1.0 tree the only write I found is a reset to nil, so I can&rsquo;t tell you what it carries today.</p>
<p>Reading it back, a <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/go.opentelemetry.io/otel/trace/hook.go#L15">hook on <code>trace.SpanFromContext</code></a> swaps in the goroutine&rsquo;s span when the context has no valid one. What crosses a <code>go</code> statement is the parent&rsquo;s current span, and a span is not the request.</p>
<h2 id="job-two-keep-the-requests-own-context">Job two: keep the request&rsquo;s own context</h2>
<p>Our program already showed the difference. A <code>context.Context</code> carries values, a deadline and a cancellation signal, and the goroutine with the fresh context lost all three. Give that goroutine an otelc-style slot and, going by the hook&rsquo;s code, <code>SpanFromContext(context.Background())</code> returns the right parent span, so the trace tree stays whole. <code>ctx.Err()</code> is still nil and the deadline is still missing. The trace repaired itself and the request didn&rsquo;t.</p>
<p>My opinion: tools have been solving two problems as one. &ldquo;Which span is my parent?&rdquo; is a question a goroutine slot can answer. &ldquo;Is my caller still waiting, and what did it attach to the request?&rdquo; needs the context itself. The <a href="https://github.com/golang/go/blob/go1.27.1/src/context/context.go#L50"><code>context</code> package</a> says values are for &ldquo;request-scoped data that transits processes and APIs&rdquo;, which is precisely the data a span-only copy drops.</p>
<p>Let&rsquo;s leave the process and follow the request onto the wire.</p>
<h2 id="job-three-incoming-connection-to-outgoing-connection">Job three: incoming connection to outgoing connection</h2>
<p>At library level the job is spelled out in code. The <a href="https://github.com/open-telemetry/opentelemetry-go-contrib/blob/instrumentation/net/http/otelhttp/v0.72.0/instrumentation/net/http/otelhttp/handler.go#L98">otelhttp handler</a> extracts trace headers from the incoming request into its context. The <a href="https://github.com/open-telemetry/opentelemetry-go-contrib/blob/instrumentation/net/http/otelhttp/v0.72.0/instrumentation/net/http/otelhttp/transport.go#L266">transport</a> injects them into the outgoing request from <code>r.Context()</code>. Every link in that chain needs the context threaded. As I read it, that is the gap the <code>SpanFromContext</code> fallback from job one is built for.</p>
<p>OBI (OpenTelemetry eBPF Instrumentation) does it from outside the process, and here it helps to separate two modes. The <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/devdocs/context-propagation.md">v0.10.0 design doc</a> describes kernel-side propagation, off by default (<code>OTEL_EBPF_BPF_CONTEXT_PROPAGATION</code>). With <code>headers</code> it injects a <code>Traceparent</code> header into plaintext HTTP, and with <code>tcp</code> it adds a TCP option to any TCP traffic. On the way in, it parses whichever one arrived. The <a href="https://opentelemetry.io/docs/zero-code/obi/security/">security page</a> (unversioned) lists <code>CAP_NET_ADMIN</code> for the trace-context-propagation programs.</p>
<p>For Go there is a second mode. The same design doc says a uprobe tries <code>bpf_probe_write_user</code> to write the header straight into Go&rsquo;s HTTP buffer and leaves the header to the kernel-side injector if that fails. The security page lists <code>CAP_SYS_ADMIN</code> for &ldquo;library-level Go trace-context propagation&rdquo;.</p>
<p>How does OBI know which outgoing call belongs to which incoming request? It hooks the same function otelc does. <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/bpf/gotracer/go_runtime.c#L114-L185">Two uprobes on <code>runtime.newproc1</code></a> record each new goroutine&rsquo;s parent in a BPF map, keyed by goroutine address instead of a field in <code>g</code>. When an HTTP client call starts, OBI <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/bpf/gotracer/go_common.h#L151-L186">walks up that chain</a> until it finds a goroutine serving a request.</p>
<p>OBI carries trace and span IDs in BPF maps keyed by goroutine. In the Go probes I read (v0.10.0) it uses no request-context values, so the deadline, cancellation and values stay where they were. For a tool that changes nothing that&rsquo;s a fair deal, and it leaves that data behind. Which brings us to the one place neither route can reach: the runtime.</p>
<h2 id="what-a-runtime-would-need">What a runtime would need</h2>
<p>What follows is my opinion, start to finish. Part 5 looks at <a href="/posts/go-runtime-futures-flight-recording-usdt/#observing-isnt-propagating">the same gap</a> from another side: my tracing fork rides on <code>context.Context</code>, so the context still has to be threaded.</p>
<p>The runtime already copies one slot when a goroutine starts: <a href="https://github.com/golang/go/blob/go1.27.1/src/runtime/proc.go#L5403"><code>g.labels</code></a>, which <a href="https://github.com/golang/go/blob/go1.27.1/src/runtime/pprof/runtime.go#L39"><code>runtime/pprof</code> documents</a> as &ldquo;A new goroutine inherits the labels of the goroutine that created it&rdquo;. It holds string pairs for the profiler, and the <a href="https://github.com/open-telemetry/opentelemetry-specification/blob/bd83138a95b6ca31384f0cb2490b2069bdec26f5/oteps/profiles/4947-thread-ctx.md#alternative-for-go-support">OTel profiling OTEP</a> says &ldquo;we foresee Go readers will directly read pprof labels&rdquo;. That is precedent for an inherited slot, but the slot belongs to the profiler.</p>
<p>What I&rsquo;d want is goroutine-associated storage that a new goroutine inherits and that does not lose the request&rsquo;s context data: the values, the deadline and the cancellation, not only a span. That raises questions I can&rsquo;t answer: copy or share on <code>go</code>, who clears it when the goroutine exits, what happens when two tools want the one slot, and what it costs on every <code>go</code> statement. I have no design, and anything touching <code>newproc1</code> needs the Go team&rsquo;s agreement. Until someone builds that, propagation stays unsolved, and we keep choosing which of the three jobs to lose. Enough opinion from me, though. Time to run something.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="try-it">Try it</h2>
<p>Save the program above as <code>main.go</code>, then run it:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go mod init demo
</span></span><span style="display:flex;"><span>go run .
</span></span></code></pre></td></tr></table>
</div>
</div><p>To read what otelc copies, print the rule yourself:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>gh api <span style="color:#e6db74">&#39;repos/open-telemetry/opentelemetry-go-compile-instrumentation/contents/instrumentation/runtime/otelc.yaml?ref=v1.1.0&#39;</span> --jq .content | base64 -d
</span></span></code></pre></td></tr></table>
</div>
</div><p>Then go hunting in your own services for the function three frames down that never got a <code>ctx</code>. It&rsquo;s there. 🔍</p>
]]></content:encoded></item><item><title>See it run: OBI and the eBPF profiler without Kubernetes</title><link>https://kakkoyun.me/posts/go-instrumentation-see-it-run/</link><pubDate>Sat, 25 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-instrumentation-see-it-run/</guid><description>Run OBI and the OpenTelemetry eBPF profiler against one untouched Go server with Docker Compose: a trace, CPU samples, and the errors I hit.</description><content:encoded><![CDATA[<p>Parts 2 and 4 of this series describe what OBI (OpenTelemetry eBPF Instrumentation) and the OpenTelemetry eBPF profiler can do. Here we make them do it with Docker Compose and no Kubernetes: one Go server nobody touches, OBI watching its requests, the profiler watching its CPU. We&rsquo;ll follow one request, <code>GET /orders/42</code>, and see it twice, first as a trace and then as CPU samples.</p>
<p>This is a companion to the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, between <a href="/posts/continuous-profiling-go-without-code-changes/">Part 4</a> and <a href="/posts/go-runtime-futures-flight-recording-usdt/">Part 5</a>, and the hands-on side of <a href="/talks/instrument-go-without-changing-a-single-line/">my GopherCon UK talk</a>. It all ran on one machine: a Colima VM (Ubuntu 24.04, kernel 6.8.0, linux/arm64, 2 CPUs, 2 GB) on an M4 Max laptop, not Docker Desktop. It&rsquo;s one run, a demo and not a benchmark.</p>
<h2 id="a-server-with-nothing-in-it">A server with nothing in it</h2>
<p>Here is the whole service. Its handler hashes the order ID 200,000 times so the profiler has CPU to sample, and <code>//go:noinline</code> keeps <code>lookupOrder</code> on the stack, as <code>checkout</code> does in Part 4.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">main</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> (
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;crypto/sha256&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;fmt&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;log&#34;</span>
</span></span><span style="display:flex;"><span>	<span style="color:#e6db74">&#34;net/http&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">//go:noinline</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">lookupOrder</span>(<span style="color:#a6e22e">id</span> <span style="color:#66d9ef">string</span>) <span style="color:#66d9ef">string</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">sum</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">sha256</span>.<span style="color:#a6e22e">Sum256</span>([]byte(<span style="color:#a6e22e">id</span>))
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#66d9ef">range</span> <span style="color:#ae81ff">200_000</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">sum</span> = <span style="color:#a6e22e">sha256</span>.<span style="color:#a6e22e">Sum256</span>(<span style="color:#a6e22e">sum</span>[:])
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> <span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Sprintf</span>(<span style="color:#e6db74">&#34;order %s, checksum %x&#34;</span>, <span style="color:#a6e22e">id</span>, <span style="color:#a6e22e">sum</span>[:<span style="color:#ae81ff">4</span>])
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">main</span>() {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">HandleFunc</span>(<span style="color:#e6db74">&#34;GET /orders/{id}&#34;</span>, <span style="color:#66d9ef">func</span>(<span style="color:#a6e22e">w</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">ResponseWriter</span>, <span style="color:#a6e22e">r</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Request</span>) {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Fprintln</span>(<span style="color:#a6e22e">w</span>, <span style="color:#a6e22e">lookupOrder</span>(<span style="color:#a6e22e">r</span>.<span style="color:#a6e22e">PathValue</span>(<span style="color:#e6db74">&#34;id&#34;</span>)))
</span></span><span style="display:flex;"><span>	})
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">log</span>.<span style="color:#a6e22e">Fatal</span>(<span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">ListenAndServe</span>(<span style="color:#e6db74">&#34;:8080&#34;</span>, <span style="color:#66d9ef">nil</span>))
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>No OpenTelemetry, no pprof. The <code>GET /orders/{id}</code> pattern is <a href="https://go.dev/blog/routing-enhancements">Go 1.22 routing</a>. The Dockerfile builds with Go 1.27.1 and strips the binary, as in Part 4, into an empty image:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">golang:1.27.1</span> <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">build</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">WORKDIR</span> <span style="color:#e6db74">/src</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">COPY</span> go.mod main.go ./<span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">RUN</span> CGO_ENABLED<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span> go build -ldflags<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;-s -w&#39;</span> -o /orders .<span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">scratch</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">COPY</span> --from<span style="color:#f92672">=</span>build /orders /orders<span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">ENTRYPOINT</span> [<span style="color:#e6db74">&#34;/orders&#34;</span>]<span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Did the stripping work? I copied the binary out with <code>docker cp</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go tool nm orders-linux-arm64
</span></span><span style="display:flex;"><span>reading orders-linux-arm64: no symbol section
</span></span><span style="display:flex;"><span>reading orders-linux-arm64: no symbols
</span></span><span style="display:flex;"><span>$ strings orders-linux-arm64 | grep -E <span style="color:#e6db74">&#39;^main\.&#39;</span>
</span></span><span style="display:flex;"><span>main.lookupOrder
</span></span><span style="display:flex;"><span>main.main
</span></span><span style="display:flex;"><span>main.main.func1
</span></span></code></pre></td></tr></table>
</div>
</div><p>No symbols, yet the function names are still in the file. That is <code>.gopclntab</code> again, and the profiler will read it later. (Yes, foreshadowing. 👀) First, the cast.</p>
<h2 id="the-compose-file">The compose file</h2>
<p>Four more services join the server: <a href="https://www.jaegertracing.io/docs/2.21/getting-started/">Jaeger 2.21.0</a> for traces (as in Part 6), a <code>curl</code> loop sending <code>GET /orders/42</code> a few times a second, <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/releases/tag/v0.10.0">OBI v0.10.0</a> and the profiler. Here is the whole file:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">59
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">60
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">61
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">62
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">63
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">64
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">see-it-run</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">orders</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">build</span>: <span style="color:#ae81ff">./app</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">jaeger</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">jaegertracing/jaeger:2.21.0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;16686:16686&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">load</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">curlimages/curl:8.11.1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">entrypoint</span>: [<span style="color:#e6db74">&#34;/bin/sh&#34;</span>, <span style="color:#e6db74">&#34;-c&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#39;while true; do curl -s http://orders:8080/orders/42 &gt; /dev/null; sleep 0.2; done&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">depends_on</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">orders</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">obi</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">otel/ebpf-instrument:v0.10.0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">privileged</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">pid</span>: <span style="color:#e6db74">&#34;service:orders&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">OTEL_EBPF_OPEN_PORT</span>: <span style="color:#e6db74">&#34;8080&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">OTEL_EXPORTER_OTLP_ENDPOINT</span>: <span style="color:#ae81ff">http://jaeger:4318</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">depends_on</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">orders</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">jaeger</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">profiler</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">otel/opentelemetry-collector-ebpf-profiler:0.160.0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">privileged</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">pid</span>: <span style="color:#ae81ff">host</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>:
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">config=/etc/profiler.yaml</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">feature-gates=+service.profilesSupport</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/sys/kernel/debug:/sys/kernel/debug:ro</span> <span style="color:#75715e"># tracepoints are looked up here</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">profiles:/out</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">configs</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">source</span>: <span style="color:#ae81ff">profiler</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">target</span>: <span style="color:#ae81ff">/etc/profiler.yaml</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">depends_on</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">orders</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">configs</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">profiler</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">content</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      receivers:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        profiling:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      exporters:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        debug:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          verbosity: detailed
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        file:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          path: /out/profiles.json
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      service:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        pipelines:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          profiles:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            receivers: [profiling]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            exporters: [debug, file]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">profiles</span>:
</span></span></code></pre></td></tr></table>
</div>
</div><p>OBI&rsquo;s <code>pid: &quot;service:orders&quot;</code> puts it in the server&rsquo;s PID namespace, so the server is PID 1 to it. <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/pkg/obi/config.go#L381"><code>OTEL_EBPF_OPEN_PORT</code></a> tells it to instrument whichever process has port 8080 open. The <a href="https://opentelemetry.io/docs/zero-code/obi/setup/docker/">OBI Docker docs</a> ask for the host PID namespace; sharing only the server&rsquo;s was enough here.</p>
<p>The profiler gets <code>pid: host</code> to sample the whole node, and the feature-gate flag its <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/README.md">README</a> uses. Collector <code>0.160.0</code> is the newest that <a href="https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.160.0/distributions/otelcol-ebpf-profiler/manifest.yaml">bundles profiler v0.0.202633</a>, the tag Part 4 cites. Its config is inline because bind-mounting a file from <code>/tmp</code> into my VM gave me an empty directory.</p>
<p>First, what OBI makes of the server.</p>
<h2 id="obi-finds-the-server">OBI finds the server</h2>
<p>Compose builds and starts everything. Then we read OBI&rsquo;s log:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ docker-compose up -d --build
</span></span><span style="display:flex;"><span>$ docker-compose logs obi | grep <span style="color:#e6db74">&#34;instrumenting process&#34;</span>
</span></span><span style="display:flex;"><span>obi-1  | time=2026-10-04T07:50:01.412Z level=INFO msg=&#34;instrumenting process&#34; component=discover.traceAttacher cmd=/orders pid=1 ino=1071739 type=go service=&#34;&#34; logenricher=false
</span></span></code></pre></td></tr></table>
</div>
</div><p>OBI found <code>/orders</code>, recognised a Go binary (<code>type=go</code>) and attached, with no rebuild. The server never noticed: I restarted OBI 90 seconds later, it attached again, and the server&rsquo;s start time stayed at <code>07:49:59</code>. Now Jaeger&rsquo;s query API, condensed to the fields that matter:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ curl -s <span style="color:#e6db74">&#39;localhost:16686/api/v3/traces?query.service_name=orders&amp;query.num_traces=1&amp;query.start_time_min=...&amp;query.start_time_max=...&#39;</span>
</span></span><span style="display:flex;"><span>span &#39;in queue&#39; kind=1 parent=&#39;88d93c2068b37f04&#39; duration=0.02ms
</span></span><span style="display:flex;"><span>span &#39;processing&#39; kind=1 parent=&#39;88d93c2068b37f04&#39; duration=11.61ms
</span></span><span style="display:flex;"><span>span &#39;GET /orders/{id}&#39; kind=2 parent=&#39;-&#39; duration=11.63ms
</span></span><span style="display:flex;"><span>    http.request.method = GET
</span></span><span style="display:flex;"><span>    http.response.status_code = 200
</span></span><span style="display:flex;"><span>    http.route = /orders/{id}
</span></span><span style="display:flex;"><span>    url.path = /orders/42
</span></span></code></pre></td></tr></table>
</div>
</div><p>One server span (kind 2) with method, status and path, plus two internal spans, <code>in queue</code> and <code>processing</code>. <code>http.route</code> is the registered pattern; <code>url.path</code> is the raw path. Give traces 15 to 20 seconds to show up. On my first attempt nothing appeared in 45 seconds, though OBI&rsquo;s log looked fine. Recreating the container (with debug logging on) fixed it, and I never found the cause.</p>
<p>A caller&rsquo;s trace is continued too. I sent a <code>traceparent</code> header and asked Jaeger for the trace ID I had chosen: the server span carried it, and its parent was the span ID I sent. Passing context on to outgoing calls needs the extra capabilities <a href="/posts/obi-ebpf-auto-instrumentation-go/#what-it-requires">Part 2</a> lists. I didn&rsquo;t turn that on, so it&rsquo;s untested here.</p>
<p>That&rsquo;s the trace. The same request also burned CPU, and a trace can&rsquo;t tell us where.</p>
<h2 id="the-profiler-reads-a-stripped-binary">The profiler reads a stripped binary</h2>
<p>Why not <code>net/http/pprof</code>? It needs an import and a redeploy. The eBPF profiler already watches every process on the node. Its first start on my VM failed:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>Error: cannot start pipelines: failed to start &#34;profiling&#34; receiver: failed to attach scheduler monitor: failed to configure tracepoint on tracer.hookPoint{group:&#34;sched&#34;, name:&#34;sched_process_free&#34;}: neither debugfs nor tracefs are mounted
</span></span></code></pre></td></tr></table>
</div>
</div><p>The VM has debugfs, but a privileged container can&rsquo;t see it, hence the <code>/sys/kernel/debug</code> mount in the compose file. With it the profiler reports &ldquo;Everything is ready&rdquo; and prints a profile every five seconds. Here are two lines of the output, our server and the profiler&rsquo;s tag:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>     -&gt; process.executable.path: Str(/orders)
</span></span><span style="display:flex;"><span>InstrumentationScope go.opentelemetry.io/ebpf-profiler v0.0.202633
</span></span></code></pre></td></tr></table>
</div>
</div><p>Our server is in there, next to <code>dockerd</code>, <code>containerd</code>, OBI and the profiler itself. The debug output refers to frames by index, so we read the <code>file</code> exporter&rsquo;s JSON. This script (<code>lookup-share.py</code>) prints the first <code>orders</code> stack containing <code>main.lookupOrder</code>, then counts how many samples do:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> sys
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>hit <span style="color:#f92672">=</span> total <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>first <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>]):
</span></span><span style="display:flex;"><span>    doc <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>loads(line)
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> doc[<span style="color:#e6db74">&#34;dictionary&#34;</span>]
</span></span><span style="display:flex;"><span>    s <span style="color:#f92672">=</span> d[<span style="color:#e6db74">&#34;stringTable&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">frames</span>(stack):
</span></span><span style="display:flex;"><span>        locs <span style="color:#f92672">=</span> d[<span style="color:#e6db74">&#34;stackTable&#34;</span>][stack][<span style="color:#e6db74">&#34;locationIndices&#34;</span>]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [s[d[<span style="color:#e6db74">&#34;functionTable&#34;</span>][ln[<span style="color:#e6db74">&#34;functionIndex&#34;</span>]][<span style="color:#e6db74">&#34;nameStrindex&#34;</span>]]
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> locs <span style="color:#66d9ef">for</span> ln <span style="color:#f92672">in</span> d[<span style="color:#e6db74">&#34;locationTable&#34;</span>][i]<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;lines&#34;</span>, [])]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> rp <span style="color:#f92672">in</span> doc[<span style="color:#e6db74">&#34;resourceProfiles&#34;</span>]:
</span></span><span style="display:flex;"><span>        res <span style="color:#f92672">=</span> {s[a[<span style="color:#e6db74">&#34;keyStrindex&#34;</span>]]: a[<span style="color:#e6db74">&#34;value&#34;</span>] <span style="color:#66d9ef">for</span> a <span style="color:#f92672">in</span> rp[<span style="color:#e6db74">&#34;resource&#34;</span>][<span style="color:#e6db74">&#34;attributes&#34;</span>]}
</span></span><span style="display:flex;"><span>        exe <span style="color:#f92672">=</span> res<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;process.executable.name&#34;</span>, {})<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;stringValueStrindex&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> exe <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">or</span> s[exe] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;orders&#34;</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> sp <span style="color:#f92672">in</span> rp[<span style="color:#e6db74">&#34;scopeProfiles&#34;</span>]:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">for</span> prof <span style="color:#f92672">in</span> sp[<span style="color:#e6db74">&#34;profiles&#34;</span>]:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">for</span> sample <span style="color:#f92672">in</span> prof<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;samples&#34;</span>, []):
</span></span><span style="display:flex;"><span>                    total <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>                    names <span style="color:#f92672">=</span> frames(sample[<span style="color:#e6db74">&#34;stackIndex&#34;</span>])
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;main.lookupOrder&#34;</span> <span style="color:#f92672">in</span> names:
</span></span><span style="display:flex;"><span>                        hit <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>                        first <span style="color:#f92672">=</span> first <span style="color:#f92672">or</span> names
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span><span style="color:#f92672">.</span>join(first))
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>hit<span style="color:#e6db74">}</span><span style="color:#e6db74"> of </span><span style="color:#e6db74">{</span>total<span style="color:#e6db74">}</span><span style="color:#e6db74"> samples from orders have main.lookupOrder on the stack&#34;</span>)
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ python3 lookup-share.py profiles.json
</span></span><span style="display:flex;"><span>crypto/internal/fips140/sha256.blockSHA2
</span></span><span style="display:flex;"><span>crypto/internal/fips140/sha256.(*Digest).Write
</span></span><span style="display:flex;"><span>crypto/internal/fips140/sha256.(*Digest).checkSum
</span></span><span style="display:flex;"><span>crypto/internal/fips140/sha256.(*Digest).Sum
</span></span><span style="display:flex;"><span>crypto/sha256.Sum256
</span></span><span style="display:flex;"><span>main.lookupOrder
</span></span><span style="display:flex;"><span>main.main.func1
</span></span><span style="display:flex;"><span>net/http.HandlerFunc.ServeHTTP
</span></span><span style="display:flex;"><span>net/http.(*ServeMux).ServeHTTP
</span></span><span style="display:flex;"><span>net/http.serverHandler.ServeHTTP
</span></span><span style="display:flex;"><span>net/http.(*conn).serve
</span></span><span style="display:flex;"><span>net/http.(*Server).Serve.gowrap3
</span></span><span style="display:flex;"><span>runtime.goexit
</span></span><span style="display:flex;"><span>100 of 106 samples from orders have main.lookupOrder on the stack
</span></span></code></pre></td></tr></table>
</div>
</div><p>There&rsquo;s the thought we held on to. The binary has no symbol table, yet the stack reads <code>main.lookupOrder</code>, <code>net/http.(*conn).serve</code> and <code>runtime.goexit</code>, because <code>.gopclntab</code> is still in the file. Read from the top, that&rsquo;s a text flame graph: 100 of 106 samples were in our hashing loop, called from our handler, called from <code>net/http</code>. Drawing it needs an OTLP profiles backend; the README offers <a href="https://github.com/elastic/devfiler">devfiler</a> (a development tool, it says) and Pyroscope.</p>
<h2 id="what-this-doesnt-show">What this doesn&rsquo;t show</h2>
<p>A Compose file is a Kubernetes DaemonSet in costume, and <a href="/posts/zero-touch-go-observability-agent-actionable/">Part 6</a> has the dry-run route for a cluster. The signal is Alpha, and this was one request shape on an idle VM. It shows the plumbing works. Your fleet&rsquo;s verdict is a separate experiment.</p>
<p>Versions, links and commands checked on 4 October 2026.</p>
<h2 id="try-it">Try it</h2>
<p>I used the standalone <code>docker-compose</code> 5.6.0 binary because my <code>docker compose</code> plugin link was broken; either spelling works. Put <code>compose.yaml</code> next to an <code>app/</code> directory with <code>main.go</code>, the Dockerfile, and a <code>go.mod</code> (<code>module example.com/orders</code>, <code>go 1.27</code>). Then:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d --build
</span></span><span style="display:flex;"><span>docker compose logs obi | grep <span style="color:#e6db74">&#34;instrumenting process&#34;</span>
</span></span><span style="display:flex;"><span>docker compose logs profiler | grep <span style="color:#e6db74">&#34;Everything is ready&#34;</span>
</span></span><span style="display:flex;"><span>docker compose down -v
</span></span></code></pre></td></tr></table>
</div>
</div><p>Jaeger&rsquo;s UI is on <code>localhost:16686</code>. Try one of your own stripped binaries and see what names come back.</p>
<p>One request, two signals, zero edits to the server. It&rsquo;s still the same 24 lines, and it doesn&rsquo;t know anyone is watching. Go see what yours looks like from the outside. 🔭</p>
]]></content:encoded></item><item><title>The laptop said 230%</title><link>https://kakkoyun.me/posts/go-benchmarks-laptop-said-230/</link><pubDate>Fri, 24 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-laptop-said-230/</guid><description>One otelc change, two overhead numbers: 230% on my loaded machine and 81% in CI. The scenario that could not have moved is what gave the laptop away.</description><content:encoded><![CDATA[<p>Here is a pull request with two numbers for the same change. My machine said the <code>multi</code> scenario cost 230% over a plain build. CI said 81%. Both numbers were honest readings of a stopwatch. I think only one of them was measuring the pull request. We&rsquo;ll work out which, and find the one question that would have told me before CI did.</p>
<p>This is a companion to the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, between <a href="/posts/go-benchmarks-lying-local-reproduction/">Part 3</a> (the laptop) and <a href="/posts/go-benchmarks-lying-ci/">Part 4</a> (CI). Those parts use microbenchmarks. This story is about a macrobenchmark, a whole build timed end to end, and it didn&rsquo;t fit my <a href="/talks/why-your-go-benchmarks-are-lying/">GopherCon UK talk</a>. Disclosure: I work at Datadog and I&rsquo;m one of otelc&rsquo;s maintainers.</p>
<p>A caveat on sourcing. The CI numbers and the PR discussion are public. The two laptop numbers are first-hand: they aren&rsquo;t recorded in the PR, so you have my word for them and nothing else. As of 2 October the CI job logs have also expired (GitHub answers HTTP 410), so everything from CI below comes from comments on the PR.</p>
<h2 id="what-the-check-measures">What the check measures</h2>
<p><a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation">otelc</a> instruments Go programs at compile time, through <code>go build -toolexec</code>. Every package in a build spawns an otelc process, even when no rule matches it. The <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/docs/benchmarking.md">benchmarking doc at v1.1.0</a> calls that overhead &ldquo;the primary UX metric to track&rdquo;, and the check times three scenarios:</p>
<table>
	<thead>
			<tr>
					<th>Scenario</th>
					<th>What it builds</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>baseline</code></td>
					<td>stdlib only (<code>fmt</code>), no rule matches</td>
			</tr>
			<tr>
					<td><code>multi</code></td>
					<td><code>net/http</code>, gRPC, <code>database/sql</code>, Redis and client-go, with 6–8 instrumented packages</td>
			</tr>
			<tr>
					<td><code>largeidle</code></td>
					<td>heavy stdlib plus many third-party libraries, zero instrumented packages</td>
			</tr>
	</tbody>
</table>
<p>The overhead is <code>(otelc − plain) / plain</code>, so 230% means the instrumented build took about 3.3 times as long. The ceiling is 150% for every scenario except <code>baseline</code>, which gets 500%. Here is the heart of the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/5911ac776cd58d0ba98d57eb3084e522bb0ddb44/test/bench/overhead_test.go">test as it stood when #643 merged</a>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">plain</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">minBuildTime</span>(<span style="color:#a6e22e">t</span>, <span style="color:#a6e22e">dir</span>, <span style="color:#a6e22e">plainArgs</span>, <span style="color:#a6e22e">name</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">otelcTime</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">minBuildTime</span>(<span style="color:#a6e22e">t</span>, <span style="color:#a6e22e">dir</span>, <span style="color:#a6e22e">otelcArgs</span>, <span style="color:#a6e22e">name</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">pct</span> <span style="color:#f92672">:=</span> (<span style="color:#a6e22e">otelcTime</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">plain</span>) <span style="color:#f92672">/</span> <span style="color:#a6e22e">plain</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">100</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>minBuildTime</code> runs a full rebuild (<code>go build -a</code>) three times and keeps the fastest, a common way to cut noise. Look at the order, though. All three plain builds run first, then all three otelc builds. If the machine changes its mood between the two halves, the percentage absorbs the whole difference. That detail will matter in a minute.</p>
<h2 id="two-numbers-for-one-change">Two numbers for one change</h2>
<p>The pull request, <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pull/643">#643</a>, consolidated a pile of dependency bumps, among them <code>go.opentelemetry.io/otel</code> core to v1.44.0. On 3 July I ran the overhead check on my own machine. I&rsquo;ll keep calling it &ldquo;the laptop&rdquo;, since the title is already committed. I&rsquo;d been running parallel builds and integration tests on it all session. CI ran the same check that day.</p>
<table>
	<thead>
			<tr>
					<th>Scenario</th>
					<th>Laptop</th>
					<th>CI</th>
					<th>Ceiling</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>multi</code></td>
					<td>~230%</td>
					<td>+81%</td>
					<td>150%</td>
			</tr>
			<tr>
					<td><code>largeidle</code></td>
					<td>~212%</td>
					<td>+105%</td>
					<td>150%</td>
			</tr>
			<tr>
					<td><code>baseline</code></td>
					<td>not recorded</td>
					<td>+514.9%, then +521.9% on a rerun</td>
					<td>500%</td>
			</tr>
	</tbody>
</table>
<p>That&rsquo;s one run on each side, from different machines, and I can&rsquo;t promise both ran the identical commit. The laptop column is my recollection of the terminal, hence the tildes.</p>
<p>Read cold, the laptop column says the bump broke two scenarios. The CI column says only <code>baseline</code> fails. Which one do we believe?</p>
<h2 id="the-scope-tell">The scope tell</h2>
<p>Ask what the change could have touched. The PR edited <code>multi</code>&rsquo;s own <code>go.mod</code> and <code>go.sum</code>, and nothing in <code>largeidle</code>&rsquo;s module. Yet <code>largeidle</code> came out at 212%, about as far over its ceiling as <code>multi</code>. When a scenario the change didn&rsquo;t touch moves by the same amount as one it did, suspect the machine before the diff.</p>
<p>I should be fair to the scenario, though. It wasn&rsquo;t a perfectly clean control. The description of the later fix, <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pull/650">#650</a>, says otelc always injects the OpenTelemetry SDK initialization package, so an SDK bump can reach every scenario, <code>largeidle</code> included. CI settles it. On CI <code>largeidle</code> sat at +105%, a hundred points under the laptop&rsquo;s reading, with the SDK bump already in. Whatever the bump cost, it doesn&rsquo;t explain 212%.</p>
<p>I waited for CI instead of hunting for a regression in code that hadn&rsquo;t changed. I never reran the check on a quiet machine and I didn&rsquo;t log CPU pressure, so &ldquo;load did it&rdquo; is my attribution. It fits every number above. I can&rsquo;t prove it.</p>
<p>Now the detail from a few paragraphs ago. <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pull/643#issuecomment-4875740809">In the rerun on the PR</a>, <code>baseline</code> went from 2.146s plain and 13.197s with otelc to 3.432s and 21.344s. Both times grew by about 60%, and the overhead moved from +514.9% to +521.9%. A machine that is uniformly slower cancels out of a ratio, which is why CI&rsquo;s failure repeated so steadily. What doesn&rsquo;t cancel is load that arrives between the plain half and the otelc half, or that hits one build harder than the other. A full <code>-a</code> rebuild fans out across every core, so parallel builds on the same machine are a likely source. That part is my guess too.</p>
<p>CI&rsquo;s failure was real, though. Only <code>baseline</code> crossed its ceiling, and <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pull/643#issuecomment-4875740809">my comment on the PR</a> and the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pull/650">#650</a> description both put it down to the SDK bump. My reading of why <code>baseline</code> feels it most: it compiles a program with one <code>fmt</code> call, so a fixed cost for compiling the SDK dominates its percentage. #650 merged on 6 July and raised that ceiling from 500% to 550%. The maintainers loosened the limit rather than hold back the dependency bump. Judging a real measurement is a different job from catching a lying one.</p>
<p>Next, a confession about CI.</p>
<h2 id="ci-is-a-second-opinion-not-the-truth">CI is a second opinion, not the truth</h2>
<p>We could end with &ldquo;trust CI&rdquo;. The pinned workflow doesn&rsquo;t back that up. At the commit where #643 merged, the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/5911ac776cd58d0ba98d57eb3084e522bb0ddb44/.github/workflows/benchmark.yaml">overhead job</a> declares <code>runs-on: ubuntu-latest</code>. That label names an image. It doesn&rsquo;t tell us how busy the host was. The workflow&rsquo;s other job, the CodSpeed walltime run, names a bare-metal machine instead. I don&rsquo;t know what else was running next to the overhead check, and I won&rsquo;t pretend to.</p>
<p>What CI gave us was a second measurement with different noise, and the two disagreed in a pattern we could read. That is <a href="/posts/go-benchmarks-lying-ci/">Part 4&rsquo;s</a> territory: shared runners add their own kind of weather. Two readings and a scope argument beat one reading and a hunch.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="before-you-trust-a-macrobenchmark">Before you trust a macrobenchmark</h2>
<p>Next time a number looks alarming, ask the cheap question first: what scenario in this run could not have changed? If it moved too, the diff is innocent until proven otherwise. Then check how the test orders its halves, since a ratio only protects you from noise that lands on both sides.</p>
<p>The laptop said 230%. CI said 81%. The 230% told me more about my machine than about the pull request, and the question that showed it cost nothing to ask. 🌡️</p>
]]></content:encoded></item><item><title>The fourth signal: continuous profiling without code changes</title><link>https://kakkoyun.me/posts/continuous-profiling-go-without-code-changes/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/continuous-profiling-go-without-code-changes/</guid><description>Stripped Go binaries still carry enough runtime metadata for eBPF profiling. Here is why, and where OpenTelemetry&amp;#39;s profiles signal fits in a zero-touch stack.</description><content:encoded><![CDATA[<p>Many tools that profile Go binaries resolve function names from the ELF symbol table or DWARF debug info, and production builds strip both. Point one of those profilers at a stripped binary and you get addresses, not names. The <code>opentelemetry-ebpf-profiler</code> doesn&rsquo;t have this problem, and we&rsquo;re going to find out why.</p>
<p>Let&rsquo;s start with a binary rather than a profiler. Here is the whole program, with <code>//go:noinline</code> so the compiler can&rsquo;t fold <code>checkout</code> into <code>main</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">main</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> <span style="color:#e6db74">&#34;fmt&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">//go:noinline</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">checkout</span>(<span style="color:#a6e22e">n</span> <span style="color:#66d9ef">int</span>) <span style="color:#66d9ef">int</span> { <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">n</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">main</span>() { <span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#a6e22e">checkout</span>(<span style="color:#ae81ff">21</span>)) }
</span></span></code></pre></td></tr></table>
</div>
</div><p>We&rsquo;ll build it for Linux, strip it at link time, and ask what&rsquo;s left:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ GOOS<span style="color:#f92672">=</span>linux GOARCH<span style="color:#f92672">=</span>arm64 CGO_ENABLED<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span> go build -ldflags<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;-s -w&#39;</span> -o stripped .
</span></span><span style="display:flex;"><span>$ go tool nm stripped
</span></span><span style="display:flex;"><span>reading stripped: no symbol section
</span></span><span style="display:flex;"><span>reading stripped: no symbols
</span></span><span style="display:flex;"><span>$ llvm-readelf -S stripped | grep -E <span style="color:#e6db74">&#39;symtab|debug|gopclntab&#39;</span>
</span></span><span style="display:flex;"><span>  [ 5] .gopclntab        PROGBITS        00000000000bbf28 0abf28 097cf1 00   A  0   0  8
</span></span><span style="display:flex;"><span>$ strings stripped | grep -E <span style="color:#e6db74">&#39;^main\.&#39;</span>
</span></span><span style="display:flex;"><span>main.checkout
</span></span><span style="display:flex;"><span>main.main
</span></span></code></pre></td></tr></table>
</div>
</div><p>One build, one machine: Go 1.27.1 on macOS, cross-compiling for linux/arm64. The symbol table and every <code>.debug_*</code> section are gone (plain <code>readelf -S</code> works too if <code>llvm-readelf</code> isn&rsquo;t on your PATH), yet <code>.gopclntab</code> is still there and <code>strings</code> can read our function names out of the file. We&rsquo;ll keep pulling on that: a stripped binary that still knows its function names, and a profiler that knows whom to ask.</p>
<p>This is part 4 of 6 in the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, the written companion to my <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK 2026 talk</a>. <a href="/posts/otelc-compile-time-go-traces/">Part 3</a> moved the intervention to build time. Now we go back to the kernel for profiles, the fourth OpenTelemetry signal after traces, metrics and logs. Disclosure: I work at Datadog and I&rsquo;m one of otelc&rsquo;s maintainers, so read my comparisons with that in mind.</p>
<h2 id="gopclntab-the-debug-info-go-cant-remove">.gopclntab: the debug info Go can&rsquo;t remove</h2>
<p>Why did <code>.gopclntab</code> survive? Because the Go runtime can&rsquo;t live without it. The runtime needs to walk the call stack at any time, for panics, for the garbage collector, for goroutine dumps, and it does that with this program-counter-to-line-number table. It maps raw instruction addresses to function names, file names and line numbers, and since the runtime always needs it, <code>strip</code> leaves it alone.</p>
<p>The profiler&rsquo;s docs make the same point: the <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/doc/gopclntab.md">data stays present even in static, stripped executables</a>, which is what lets it symbolize and unwind production executables.</p>
<p>A stripped Go binary can be read from the outside, then. Who does the reading?</p>
<h2 id="what-the-profiler-actually-is">What the profiler actually is</h2>
<p>The reader is the <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/tree/v0.0.202633"><code>opentelemetry-ebpf-profiler</code></a>. It started as the Elastic Universal Profiling Agent. Elastic <a href="https://github.com/open-telemetry/community/issues/1918">proposed the donation</a> in January 2024, and OpenTelemetry <a href="https://github.com/open-telemetry/community/issues/1918#issuecomment-2153125431">approved it</a> that June. Run the official Collector distribution, <code>otelcol-ebpf-profiler</code> (started with <code>--feature-gates=+service.profilesSupport</code>), from <code>opentelemetry-collector-releases</code>, not the same-named test binary in the repo&rsquo;s own <code>cmd/</code> directory, which is for internal use only. The latest tag as of the talk (13 August 2026) is <code>v0.0.202633</code> (which reads as ISO week 33 of 2026). The project publishes tags, not GitHub Releases.</p>
<p>An eBPF program fires on CPU sample events in the kernel and reads process-internal data structures from outside the target process. No <code>LD_PRELOAD</code>, no ptrace attach, and no injector (<a href="/posts/why-go-cant-be-monkey-patched/#process-start-the-injector">part 1</a> covers why that last one is hard for Go). The README states it plainly: <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/README.md">&ldquo;100% non-intrusive: there&rsquo;s no need to load agents or libraries into the processes that are being profiled.&rdquo;</a> Our <code>stripped</code> binary would never know anyone had read it. You typically run it as a DaemonSet with <code>hostPID: true</code>.</p>
<p>Why not <code>net/http/pprof</code>? It needs a blank import and a redeploy to switch on, which is the change this series avoids. In my view it still has the edge on profile types and detail, and it needs no kernel privileges.</p>
<p>Nothing gets loaded into the application. The fine print is on the node.</p>
<h2 id="what-zero-code-changes-actually-means">What &ldquo;zero code changes&rdquo; actually means</h2>
<p>The profiled applications need nothing: no rebuild, no restart, no SDK import. The node pays instead.</p>
<p>The <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/cmd/otelcol-ebpf-profiler/local.example.yaml">example config</a> says to run the Collector as root, or to grant specific capabilities (it names <code>CAP_SYS_ADMIN</code>, <code>CAP_PERFMON</code>, and/or <code>CAP_BPF</code>, plus access to <code>/proc</code>). The <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/README.md">README</a> also says current code may require Linux 5.10 or greater.</p>
<p>The bill arrives per node, not per service, which is what makes it useful at scale. You still need a backend that takes OTLP profiles, and the README says mature production-ready ones have yet to emerge (Pyroscope and devfiler are its open-source options, and devfiler is Elastic&rsquo;s desktop viewer for experiments, not a production backend).</p>
<p>A privileged agent on every node buys us CPU profiles. How far can we trust the signal?</p>
<h2 id="how-mature-is-it">How mature is it?</h2>
<p>Young. These are the statuses as of October 2026, from the <a href="https://opentelemetry.io/docs/concepts/signals/profiles/">profiles concepts page</a> and the <a href="https://opentelemetry.io/docs/specs/otel/profiles/">specification</a> (spec v1.61.0, OTLP 1.11.0):</p>
<table>
	<thead>
			<tr>
					<th>Layer</th>
					<th>Status</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>OTel specification (<code>/docs/specs/otel/profiles/</code>)</td>
					<td><strong>Alpha</strong></td>
			</tr>
			<tr>
					<td>OTLP wire format (OTLP 1.11.0)</td>
					<td><strong>Development</strong></td>
			</tr>
			<tr>
					<td>Collector receiver (<code>ebpf-profiler</code>)</td>
					<td><strong>Development</strong></td>
			</tr>
			<tr>
					<td>Collector feature gate <code>service.profilesSupport</code></td>
					<td><strong>Alpha</strong> (off by default, enable it explicitly)</td>
			</tr>
			<tr>
					<td>Traces / Metrics / Logs (for comparison)</td>
					<td>Stable / Stable / Stable</td>
			</tr>
	</tbody>
</table>
<p>The last row is the only one with a comfortable word in it.</p>
<p>The README is explicit: &ldquo;Implements the Alpha OTel Profiles signal.&rdquo; It calls its own implementation &ldquo;functional but work-in-progress / evolving&rdquo;, and Alpha means evolving, not backward-compatibility-guaranteed.</p>
<p>What works today, reliably, is on-CPU stacks sampled per OS thread, with correct Go function names via <code>.gopclntab</code>, the trick we started with. It also <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/support/ebpf/go_labels.ebpf.c">reads Go pprof labels</a> by following the thread&rsquo;s current <code>g</code> to its goroutine, so samples carry whatever labels the application set. The README also lists Java, Python and other runtimes it can unwind, though I only checked the Go path. <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/design-docs/00001-off-cpu-profiling/README.md">Off-CPU profiling</a> is in development and <a href="https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/v0.0.202633/design-docs/00003-memory-profiling/README.md">allocation profiling</a> is a design proposal.</p>
<p>Alpha, then, with a working core: always-on CPU flame graphs for production Go services. Where does that sit next to the tools from the last two parts?</p>
<h2 id="where-the-fourth-signal-fits">Where the fourth signal fits</h2>
<p><a href="/posts/obi-ebpf-auto-instrumentation-go/">OBI</a> gives us traces and RED metrics for HTTP and gRPC from the kernel, <a href="/posts/otelc-compile-time-go-traces/">otelc</a> gives us function-level spans for the price of a rebuild, and the profiler adds always-on CPU profiles for the whole node. Together they cover three of the four signals (traces, metrics, profiles) without a single source change in the application. Logs are only partly covered: <a href="https://opentelemetry.io/docs/zero-code/obi/trace-log-correlation/">OBI can add trace context to application logs</a> (Linux 6.0 or newer), but it enriches them and does not export them.</p>
<p>The useful move is to jump from a slow trace to the flame graph for the same time window: traces tell you a request was slow, profiles tell you where the CPU went. Lining them up by request is where zero-touch has a gap. The data model lets samples carry trace and span IDs, but for Go the profiler can only read request context from pprof labels that the application sets, the path <a href="https://github.com/open-telemetry/opentelemetry-specification/blob/v1.61.0/oteps/profiles/4947-thread-ctx.md#alternative-for-go-support">OTEP 4947</a> describes. A compile-time tool could set span-ID labels for the profiler to read, but that is proposed work, and I haven&rsquo;t verified that otelc or dd-trace-go do it. Zero-touch profiling alone correlates by resource and time window, not by request, so treat trace-to-profile pivoting as where this is heading.</p>
<p>Back to our stripped binary. We never rebuilt it, restarted it or added an import, and it still carries <code>main.checkout</code> in <code>.gopclntab</code> for <code>strings</code> or a privileged profiler to read. The signal is Alpha, but Elastic <a href="https://github.com/open-telemetry/community/issues/1918">ran the same agent in customer production</a> before donating it, so the capability is real: continuous CPU profiling with correct symbolization of stripped Go binaries. The commands at the top of this post check that any Go binary you ship still has its names; running the profiler itself takes a Linux host and the Collector command in its README, and <a href="/posts/go-instrumentation-see-it-run/">See it run: OBI and the eBPF profiler without Kubernetes</a> does that in a VM. Try the check on yours, then go see where your CPU time goes. 🔍</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/go-runtime-futures-flight-recording-usdt/">Part 5, Go runtime futures: flight recording, USDT, and the instrumentation hook problem</a>, looks at what Go itself could add: flight recording, which shipped, and the hooks that are still missing. Three tools have worked around Go so far. Time to ask Go for help.</p>
]]></content:encoded></item><item><title>OpenTelemetry Go Compile-time Instrumentation v1</title><link>https://kakkoyun.me/posts/go-compile-time-instrumentation-v1/</link><pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-compile-time-instrumentation-v1/</guid><description>OpenTelemetry ships v1 of Go compile-time instrumentation — zero-code telemetry injected via the Go toolchain&amp;#39;s -toolexec hook.</description><content:encoded><![CDATA[
<blockquote class="admonition admonition-note">
  <p class="admonition-title"><svg class="admonition-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg><span>Note</span></p>
  <div class="admonition-content"><p>This post was originally published on the <a href="https://opentelemetry.io/blog/2026/go-compile-time-instrumentation-v1/">OpenTelemetry blog</a> on 2026-07-16. I wrote it for the OpenTelemetry community; this is a mirror on my personal blog with some added commentary.</p></div>
</blockquote>
<hr>
<h2 id="why-this-milestone-matters-to-me">Why this milestone matters to me</h2>
<p>I have been involved with the Go Compile-Time Instrumentation SIG since it kicked off at the start of 2025, when Alibaba and Datadog decided to pool their efforts into a single vendor-neutral project. Watching it reach v1 last week felt genuinely satisfying — not because &ldquo;v1&rdquo; is a magic number, but because it marks the point where we stopped calling it experimental and started calling it something people should actually run in their builds.</p>
<p>The Go situation has always been a little awkward for OpenTelemetry. Every other language has a story: attach a Java agent, install a Python auto-instrumentation package, load a Node.js module. Go compiles to a static binary with no runtime hook points. The options were either manual instrumentation (correct but tedious) or an eBPF agent running outside the process (zero-code but with its own caveats). Compile-time instrumentation is the third option: use Go&rsquo;s own toolchain machinery (specifically the <code>-toolexec</code> flag) to inject instrumentation at build time, not at startup.</p>
<p>The reason I find this bet interesting is that it sits exactly where Go&rsquo;s constraints become an asset. Because we operate during compilation, we have the full type information and package graph available. We can instrument code you didn&rsquo;t write and wouldn&rsquo;t want to modify: the <code>net/http</code> standard library, <code>database/sql</code>, gRPC, Redis drivers. The binary you ship has the telemetry baked in, so there is no separate process to manage, no LD_PRELOAD tricks, no eBPF programs to load. It is just a Go binary, compiled slightly differently.</p>
<p>I am proud of what the SIG shipped and grateful to the maintainers and contributors who drove it across the finish line. The acknowledgments section below names them properly. v1 is a foundation — coverage will grow, the registry integration will land, and the performance story will get tighter. If you run Go services and want traces and metrics without touching application code, now is a good time to try it.</p>
<hr>
<h2 id="the-announcement">The announcement</h2>
<p><em>What follows is the original post as published on the OpenTelemetry blog.</em></p>
<hr>
<p>If you write Java, Python, Node.js, or .NET, you have been able to add
OpenTelemetry to an application without editing its code for years: attach an
agent at startup and telemetry starts flowing. Go has been the exception. A Go
program compiles to a single static binary with no runtime to hook into at
startup, so Go developers have had to instrument by hand or reach for an
out-of-process eBPF agent.</p>
<p>That gap is closing. The OpenTelemetry community is announcing the first stable
release of
<a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation">OpenTelemetry Go Compile-Time Instrumentation</a>.
When we <a href="https://opentelemetry.io/blog/2025/go-compile-time-instrumentation/">announced this SIG</a> at the
start of 2025, Alibaba and Datadog joined forces to build one unified,
vendor-neutral way to instrument Go at build time. v1 is that project&rsquo;s first
stable release.</p>
<p>If you build and run Go services, you can change a single line in how you build
your binary or container image and get OpenTelemetry traces and metrics for your
application and its dependencies, with no code changes. For a platform engineer
or an SRE, that means you can add observability to services across your fleet
without waiting for every team to instrument their own code.</p>
<h2 id="what-is-go-compile-time-instrumentation">What is Go Compile-time Instrumentation?</h2>
<p>Go compiles to a single static binary, which has long made automatic
instrumentation harder than in interpreted languages. This project hooks into
the standard Go toolchain during the build (through its <code>-toolexec</code> mechanism)
and injects OpenTelemetry instrumentation into your code, its dependencies, and
the standard library as they are compiled. There is no separate agent and
nothing to attach at runtime.</p>
<p>For you, that means telemetry with no source-code changes: the instrumentation
is compiled directly into your binary. Your application code stays free of
instrumentation concerns, and you get coverage for third-party libraries you
don&rsquo;t own.</p>
<h2 id="key-capabilities-in-v1">Key capabilities in v1</h2>
<ul>
<li><strong>Zero-code instrumentation</strong>: instrument an application and its dependencies
without manual code changes.</li>
<li><strong>Compile-time injection, no added runtime overhead</strong>: instrumentation is
built into the binary instead of attached at runtime.</li>
<li><strong>Third-party and standard-library coverage</strong>: instrument dependencies and
standard-library packages you don&rsquo;t own.</li>
<li><strong>Supported instrumentations in v1</strong>: common libraries and frameworks
including <code>net/http</code>, <code>database/sql</code>, gRPC, Redis, and Go runtime metrics,
with more added regularly. See the
<a href="https://opentelemetry.io/docs/zero-code/go/compile-time/supported-libraries/">supported libraries</a>
for the full, current list.</li>
<li><strong>Rule-based and extensible</strong>: add support for new libraries through the SIG&rsquo;s
instrumentation-rule format. See the
<a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.0/docs/instrument-guide.md">instrumentation guide</a>
and the
<a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.0/docs/rules.md">rules reference</a>.</li>
<li><strong>Semantic-convention compliance</strong>: emitted telemetry follows current
OpenTelemetry semantic conventions.</li>
<li><strong>CI/CD friendly</strong>: run the tool at development time or drop it into your
build pipeline.</li>
</ul>
<h2 id="getting-started">Getting started</h2>
<p>The project ships a command-line tool called <code>otelc</code> that wraps the standard Go
toolchain. The change to your build is a single line: run <code>otelc go build</code> where
you used to run <code>go build</code>. Everything after <code>go</code> is forwarded to the toolchain,
so the rest of your build stays the same.</p>
<p>Install it with <code>go install</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>go install go.opentelemetry.io/otelc/tool/cmd/otelc@latest
</span></span></code></pre></td></tr></table>
</div>
</div><p>Then build your application through it:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>otelc go build -o myapp .
</span></span></code></pre></td></tr></table>
</div>
</div><p>If you&rsquo;d rather not change your build command, run <code>otelc setup</code> once to prepare
the module, then point the Go toolchain at <code>otelc</code> through <code>GOFLAGS</code> and keep
running <code>go build</code> as usual:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>otelc setup
</span></span><span style="display:flex;"><span>export GOFLAGS<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>GOFLAGS<span style="color:#e6db74">}</span><span style="color:#e6db74"> &#39;-toolexec=otelc toolexec&#39;&#34;</span>
</span></span><span style="display:flex;"><span>go build -o myapp .
</span></span></code></pre></td></tr></table>
</div>
</div><p>By default, <code>otelc</code> discovers the supported libraries in your module and
instruments them automatically, with no configuration and no code changes. The
same swap works in a container build: install <code>otelc</code> in your build stage and
replace the <code>go build</code> line in your <code>Dockerfile</code> with <code>otelc go build</code>. For the
full walkthrough, see the
<a href="https://opentelemetry.io/docs/zero-code/go/compile-time/">compile-time instrumentation documentation</a>.</p>
<h2 id="when-should-you-use-it">When should you use it?</h2>
<p>If you write or operate Go services, you now have three complementary ways to
get OpenTelemetry telemetry, and compile-time instrumentation is the third
option promised in the founding post:</p>
<ul>
<li><strong>Compile-time instrumentation (this project)</strong>: best when you can rebuild the
application and want no code changes, no added runtime overhead, and coverage
of dependencies and the standard library.</li>
<li><strong>eBPF instrumentation
(<a href="https://opentelemetry.io/docs/zero-code/obi/">OpenTelemetry eBPF Instrumentation, or OBI</a>)</strong>: best
when you can&rsquo;t rebuild the binary, or want zero-code, multi-language
instrumentation from outside the process.</li>
<li><strong>Manual instrumentation with the
<a href="https://opentelemetry.io/docs/languages/go/">OpenTelemetry Go API</a></strong>: best for custom spans and
domain-specific telemetry, and it composes with the other two.</li>
</ul>
<p>v1 ships a focused set of instrumentations rather than the full breadth of the
Go ecosystem, and coverage will grow with each release. If a library you depend
on isn&rsquo;t covered yet, you can add a rule for it or combine compile-time
instrumentation with manual spans. The three approaches above solve overlapping
problems in different ways, so we&rsquo;re working with the OBI and Go SIGs on
follow-up posts that compare them in more depth.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>v1 covers the core of the compile-time approach. Our priorities from here:</p>
<ul>
<li><strong>More instrumentations</strong>: broaden coverage across popular Go libraries and
frameworks so more applications work the moment you swap in <code>otelc</code>.</li>
<li><strong>Registry-based discovery</strong>: use the existing
<a href="https://opentelemetry.io/ecosystem/registry/">OpenTelemetry Registry</a> to discover and distribute
instrumentations, so you can pick up support for new libraries without waiting
for a new <code>otelc</code> release.</li>
<li><strong>Performance</strong>: keep driving down both build-time and runtime cost.</li>
<li><strong>Adoption and awareness</strong>: invest in docs, examples, and outreach so teams
know the tool exists and how to fit it into their build.</li>
</ul>
<h2 id="get-involved">Get involved</h2>
<p>Compile-time instrumentation is v1, and the best way to shape where it goes next
is to use it and get involved.</p>
<ul>
<li><strong>Try it and tell us how it went.</strong> Add <code>otelc</code> to a build and share what
worked and what didn&rsquo;t in the project&rsquo;s
<a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation">GitHub discussions and issues</a>.
Real-world feedback drives the roadmap.</li>
<li><strong>Instrument a library you use.</strong> Coverage grows through the SIG&rsquo;s rule
format; the
<a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.0/docs/instrument-guide.md">instrumentation guide</a>
walks through adding one.</li>
<li><strong>Join the SIG.</strong> Find us in
<a href="https://cloud-native.slack.com/archives/C088D8GSSSF">#otel-go-compile-instrumentation</a>
on the CNCF Slack and at our SIG meetings.</li>
</ul>
<h2 id="acknowledgments">Acknowledgments</h2>
<p>Reaching v1 is a milestone for the whole Go Compile-Time Instrumentation SIG.
Thank you to the maintainers who drove the release through to stable, including
<a href="https://github.com/txabman42">Xabier Martinez</a> (Cabify),
<a href="https://github.com/y1yang0">Yi Yang</a> (Alibaba),
<a href="https://github.com/NameHaibinZhang">Haibin Zhang</a> (Alibaba), and
<a href="https://github.com/darccio">Dario Castañé</a> (Datadog), along with everyone who
contributed code, rules, documentation, and feedback.</p>
<p>A special thank-you to <a href="https://github.com/amazingakai">Azhar Momin</a>, who joined
the project through the
<a href="https://mentorship.lfx.linuxfoundation.org/project/3e530f5c-12f3-4321-836a-39de799a4d15">LFX Mentorship program</a>
and has become one of its most active contributors and an approver.</p>
<hr>
<h2 id="references">References</h2>
<ul>
<li><a href="https://opentelemetry.io/blog/2026/go-compile-time-instrumentation-v1/">Original post on the OpenTelemetry blog</a> (2026-07-16)</li>
<li><a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation">opentelemetry-go-compile-instrumentation</a> — the project on GitHub</li>
<li><a href="https://pkg.go.dev/go.opentelemetry.io/otelc">otelc module</a> — <code>go.opentelemetry.io/otelc</code></li>
<li><a href="https://opentelemetry.io/docs/zero-code/go/compile-time/supported-libraries/">Supported libraries</a></li>
<li><a href="https://opentelemetry.io/ecosystem/registry/">OpenTelemetry Registry</a></li>
<li><a href="https://opentelemetry.io/blog/2025/go-compile-time-instrumentation/">Original SIG announcement post</a> (2025)</li>
<li><a href="https://github.com/open-telemetry/opentelemetry.io/pull/10760">PR #10760</a> — the community blog post PR</li>
</ul>
]]></content:encoded></item><item><title>Before CI: Can You Trust a Benchmark on Your Own Laptop?</title><link>https://kakkoyun.me/posts/go-benchmarks-lying-local-reproduction/</link><pubDate>Tue, 21 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-lying-local-reproduction/</guid><description>Same benchmark, three conditions: an idle Mac, a saturated one, and a container pinned to one vCPU. The numbers show what local isolation buys you and where it stops.</description><content:encoded><![CDATA[<p>Every benchmark number starts life on somebody&rsquo;s laptop. We write the function, run it, like what we see and open the pull request, long before CI or a pinned runner gets a say. That makes it the least controlled measurement in the pipeline, and the one we act on. If it lies, everything downstream inherits the lie. Sending a noisy benchmark to CI doesn&rsquo;t fix the noise. It industrialises it.</p>
<p>Today we&rsquo;ll interrogate one laptop with one benchmark. <code>BenchmarkMakeBuffer_Correct</code> costs 11.46 ns/op on an idle Apple M4 Max. Start sixteen busy loops next to it and the same code costs 34.97 ns/op. Pin it to a single CPU inside a container and it settles at 16.28 ns/op, with its variance back near the idle level. Hold on to those three conditions; we&rsquo;ll keep coming back to them.</p>
<p>This is part 3 of 5 in the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, the written companion to the <a href="/talks/why-your-go-benchmarks-are-lying/">GopherCon UK 2026 talk</a>. The series asks three questions of a benchmark: does the compiler let it do real work (<a href="/posts/go-benchmarks-lying-compiler-honesty/">part 1</a>), is the sample stable enough to mean anything (<a href="/posts/go-benchmarks-lying-statistics/">part 2</a>, which also defined CV), and is the difference large relative to the noise. This part takes the last question down to the machine in front of us, the noise floor under any difference: we&rsquo;ll see what a pinned container on a Mac buys and where it stops, then borrow the Linux controls that go further. The <a href="/posts/fosdem-2026-measuring-software-performance/">FOSDEM 2026 post</a> is the prerequisite for the series, and every number below comes from the demo in <a href="https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying/demo">gopherconuk-26 at fdce88c</a>.</p>
<p>Can we trust the laptop? Sometimes. Let&rsquo;s find out when.</p>
<h2 id="one-benchmark-three-conditions">One benchmark, three conditions</h2>
<p>We ran the benchmark with <code>-count=20 -benchtime=1s</code> on an Apple M4 Max (darwin/arm64, 16 logical CPUs) under three conditions. The &ldquo;noisy&rdquo; one starts 16 background spinners, pure busy-loops with no I/O and no allocation that saturate every logical core. The third reruns the benchmark on that same noisy host inside a <code>golang:1.26</code> container started with <a href="https://docs.docker.com/engine/containers/resource_constraints/"><code>--cpuset-cpus=0 --cpus=1 --memory=512m</code></a>. The spinners never stop.</p>
<table>
	<thead>
			<tr>
					<th>Condition</th>
					<th>Mean ns/op</th>
					<th>Stddev</th>
					<th>CV%</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Idle host</td>
					<td>11.46</td>
					<td>0.54</td>
					<td>4.75</td>
			</tr>
			<tr>
					<td>Host with 16 background spinners</td>
					<td>34.97</td>
					<td>6.60</td>
					<td>18.88</td>
			</tr>
			<tr>
					<td>Container pinned to vCPU 0, same 16 spinners</td>
					<td>16.28</td>
					<td>0.85</td>
					<td>5.25</td>
			</tr>
	</tbody>
</table>
<p>That&rsquo;s one run of 20 samples per condition on one machine. The host&rsquo;s Go version wasn&rsquo;t recorded, which is a little embarrassing for a post about reproducibility. We&rsquo;ll come back to that.</p>
<p>Idle to noisy: the benchmark slowed by 3× and CV jumped from 4.75% to 18.88%. Both effects most likely share a cause: the spinners and the benchmark compete for the same 16 cores, so the scheduler preempts the benchmark and moves it between cores, including the slower efficiency cores on this chip. This experiment does not separate those effects.</p>
<p>Noisy to pinned is the interesting one. CV drops to 5.25%, nearly back to the idle noise floor. The absolute runtime stays elevated (16.28 ns/op against 11.46 idle), which is unsurprising for a single pinned vCPU (<code>GOMAXPROCS=1</code>, judging by the missing <code>-16</code> suffix in the pinned run&rsquo;s output) inside a VM running a different OS, though this experiment does not isolate which of those accounts for it. The variance is the point, and it collapses, though at one run per condition the gap between 4.75% and 5.25% is within what sampling error alone could produce.</p>
<p>Pinning works, then. Sort of. 5.25% is not a triumph, and why it isn&rsquo;t tells us what a container can&rsquo;t do.</p>
<h2 id="what-a-container-on-a-mac-can-and-cant-pin">What a container on a Mac can and can&rsquo;t pin</h2>
<p>On a bare-metal Linux runner with SMT disabled and CPU frequency pinned, the <a href="https://github.com/igoragoli/fosdem-2026-software-performance">FOSDEM 2026 experiments</a> measured around 0.05% CV on a CPU-bound task. That is roughly a hundred times tighter, across different workloads, than Docker on a Mac gave us.</p>
<p>Docker Desktop on macOS runs containers inside a Linux VM (here, Apple&rsquo;s Virtualization.framework). When we pass <code>--cpuset-cpus=0</code>, we pin to vCPU 0 inside that VM, not to a physical core on the host. The host scheduler can migrate that vCPU between physical cores at will, and nothing inside the container can pin the host CPU clock frequency. It&rsquo;s a quiet room in a building whose thermostat someone else controls.</p>
<p>What the pinning does buy is a stable CPU for the benchmark&rsquo;s threads, which stop migrating across the VM&rsquo;s virtual CPUs, and a VM with nothing else running in it. The table shows that matters. Frequency scaling, thermal variation and the host&rsquo;s choice of physical core for the VM&rsquo;s threads all remain in play.</p>
<p>The Mac numbers are good for catching obvious regressions during development, not for publication. Results that need to hold up to scrutiny belong on bare-metal Linux with SMT and frequency scaling disabled, with a note in the writeup saying so. Let&rsquo;s see what that environment is made of.</p>
<h2 id="the-linux-toolbox">The Linux toolbox</h2>
<p>On Linux we can reach the knobs the Mac&rsquo;s VM hides. We&rsquo;ll go from free to fussy.</p>
<h3 id="cpu-affinity">CPU affinity</h3>
<p>The OS scheduler migrates processes between cores to balance load, and each migration evicts warm cache lines. <code>taskset</code> stops that at zero setup cost:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>taskset -c <span style="color:#ae81ff">0</span> go test -bench<span style="color:#f92672">=</span>. -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> -benchtime<span style="color:#f92672">=</span>2s ./...
</span></span></code></pre></td></tr></table>
</div>
</div><p>The <code>-c 0</code> pins the whole run to core 0. It&rsquo;s worth it on Linux whenever we run <code>-count</code> of 10 or more. One side effect: the Go runtime reads the affinity mask, so this also sets <code>GOMAXPROCS=1</code> for the benchmark, which changes GC scheduling and any parallel benchmarks. Free isn&rsquo;t the same as harmless. When a pin isn&rsquo;t enough, we can take cores away from the scheduler entirely.</p>
<h3 id="core-isolation">Core isolation</h3>
<p>With <code>isolcpus</code> on the kernel command line, specific cores leave the scheduler&rsquo;s load balancing, so only tasks we pin to them run there:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span># Add to kernel command line
</span></span><span style="display:flex;"><span>isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3
</span></span></code></pre></td></tr></table>
</div>
</div><p>After a reboot we pin benchmarks to the isolated cores with <code>taskset -c 2</code>. That suits a dedicated CI runner, since it needs a reboot and root. (<code>nohz_full</code> stops the scheduler tick on those cores while only one task runs there, and <code>rcu_nocbs</code> moves RCU callbacks off them; see the <a href="https://docs.kernel.org/timers/no_hz.html">kernel NO_HZ docs</a>.)</p>
<p>For a softer version that needs no reboot, <code>cset shield</code> achieves similar isolation at runtime, but only on hosts that still use cgroup v1:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo cset shield --cpu<span style="color:#f92672">=</span>2,3 --kthread<span style="color:#f92672">=</span>on
</span></span><span style="display:flex;"><span>sudo cset shield --exec -- go test -bench<span style="color:#f92672">=</span>. -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> ./...
</span></span><span style="display:flex;"><span>sudo cset shield --reset
</span></span></code></pre></td></tr></table>
</div>
</div><p>Create the shield, run inside it, tear it down. The <code>--kthread=on</code> flag is critical: without it, kernel threads still run on the shielded cores.</p>
<h3 id="process-priority">Process priority</h3>
<p><code>nice</code> raises the scheduling priority of the benchmark relative to other processes:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo nice -n -5 go test -bench<span style="color:#f92672">=</span>. -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> ./...
</span></span></code></pre></td></tr></table>
</div>
</div><p>The effect is modest on a quiet machine but meaningful under moderate load. On dedicated machines, <code>chrt -f</code> (SCHED_FIFO) goes real-time:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo chrt -f <span style="color:#ae81ff">50</span> go test -bench<span style="color:#f92672">=</span>. -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> ./...
</span></span></code></pre></td></tr></table>
</div>
</div><p>SCHED_FIFO can starve normal processes, the display server included, if the benchmark misbehaves (the kernel&rsquo;s default real-time throttling leaves them about 5%). That&rsquo;s a fine way to lose your terminal, so keep it to dedicated machines or a resource-capped container. Isolation and priority decide who gets the CPU. Frequency decides how fast it runs.</p>
<h3 id="cpu-frequency-control">CPU frequency control</h3>
<p>Dynamic frequency scaling (DFS) is a large source of hidden variance on Linux: the CPU boosts when thermals allow and throttles when they do not.</p>
<p><a href="https://github.com/aclements/perflock"><code>perflock</code></a>, written by Austin Clements of the Go team, locks the CPU to a stable frequency for the duration of the run. It talks to a small daemon that holds the lock, so the setup has two steps:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>GOBIN<span style="color:#f92672">=</span>$PWD go install github.com/aclements/perflock/cmd/perflock@b67f3f23152f
</span></span><span style="display:flex;"><span>sudo install ./perflock /usr/bin/perflock
</span></span><span style="display:flex;"><span>sudo -b perflock -daemon
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>perflock go test -bench<span style="color:#f92672">=</span>. -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> -benchtime<span style="color:#f92672">=</span>2s ./...
</span></span></code></pre></td></tr></table>
</div>
</div><p>The first three lines build it at a pinned commit, install it and start the daemon. The last runs the benchmark under the lock. On Linux it pins the frequency by writing <code>scaling_min_freq</code> and <code>scaling_max_freq</code> through the cpufreq sysfs interface, which makes it one of the higher-value local tools after <code>benchstat</code>, daemon and all.</p>
<span class="sidenote-ref" id="ref-sn-3" role="doc-noteref">
  <sup><a href="#sn-3" aria-describedby="sn-3">perflock-mac</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-3" role="note" aria-label="Sidenote perflock-mac (right margin)">
  <span class="sidenote-label">perflock-mac</span>
  <span class="sidenote-content"><code>perflock</code> builds on macOS, but its frequency pinning relies on the Linux cpufreq sysfs interface. On a Mac the governor step fails inside the daemon and the error is discarded, leaving only the mutual-exclusion lock: no racing benchmark runs, and no help with frequency noise.</span>
  <a class="sidenote-back" href="#ref-sn-3" aria-label="Back to reference">↩</a>
</aside>

<p>If we&rsquo;d rather apply the sysfs controls by hand, here they are:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Performance governor</span>
</span></span><span style="display:flex;"><span>echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disable Intel Turbo Boost (intel_pstate driver)</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#ae81ff">1</span> | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disable frequency boost (acpi-cpufreq or amd-pstate drivers)</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#ae81ff">0</span> | sudo tee /sys/devices/system/cpu/cpufreq/boost
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disable SMT (caution: halves logical CPU count)</span>
</span></span><span style="display:flex;"><span>echo off | sudo tee /sys/devices/system/cpu/smt/control
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Re-enable when done</span>
</span></span><span style="display:flex;"><span>echo on | sudo tee /sys/devices/system/cpu/smt/control
</span></span></code></pre></td></tr></table>
</div>
</div><p>The bare-metal SMT and DFS numbers behind these controls are in <a href="/posts/go-benchmarks-lying-ci/#why-shared-runners-lie">part 4</a>. Even a locked frequency leaves one thing we can&rsquo;t lock: how hot the chip is when the run starts.</p>
<h2 id="thermal-steady-state">Thermal steady state</h2>
<p>CPUs boost when cool and throttle when hot. A benchmark that starts on a cold chip and ends on a warm one shows a performance <em>decrease</em> across the run, which has nothing to do with the code. In raw <code>go test</code> output, the first few runs of a <code>-count=20</code> sequence are typically faster than the last few (the expected pattern, not one we measured here).</p>
<p>A throwaway run before capturing helps, as does waiting 30 to 60 seconds between major runs (a rule of thumb, not a measured number). Holding the frequency steady with <code>perflock</code> or by disabling DFS helps regardless of thermals. Laptops are worse than desktops here: ambient temperature, prior CPU workload and fan behavior all affect how long steady state takes. Skip the warmup and the early samples are biased warm. With the environment as quiet as it&rsquo;s going to get, we can finally compare two versions of the code.</p>
<h2 id="the-go-native-ab-loop">The Go-native A/B loop</h2>
<p>Controls reduce noise, but we also need a structured way to compare two versions. The manual approach is to capture a baseline, apply the change, capture again and let <code>benchstat</code> compare:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Capture baseline</span>
</span></span><span style="display:flex;"><span>go test -bench<span style="color:#f92672">=</span>BenchmarkMyFunc -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> -benchtime<span style="color:#f92672">=</span>2s . &gt; old.txt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Apply change, then capture</span>
</span></span><span style="display:flex;"><span>go test -bench<span style="color:#f92672">=</span>BenchmarkMyFunc -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> -benchtime<span style="color:#f92672">=</span>2s . &gt; new.txt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Compare</span>
</span></span><span style="display:flex;"><span>benchstat old.txt new.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p>The flags and machine conditions have to match on both sides, or we end up benchmarking the room instead of the code. Captured minutes apart, the two files can also see different machines. The <a href="https://pkg.go.dev/golang.org/x/perf@v0.0.0-20260929162123-406019bb8b68/cmd/benchstat"><code>benchstat</code> docs</a> recommend interleaving the before and after runs, and <code>go test -c</code> makes that cheap: build both test binaries once, then alternate them.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>GOOS<span style="color:#f92672">=</span>linux go test -c -o old.test   <span style="color:#75715e"># on the base commit</span>
</span></span><span style="display:flex;"><span>GOOS<span style="color:#f92672">=</span>linux go test -c -o new.test   <span style="color:#75715e"># with the change</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> i in <span style="color:#66d9ef">$(</span>seq 10<span style="color:#66d9ef">)</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>  ./old.test -test.run<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;^$&#39;</span> -test.bench<span style="color:#f92672">=</span>. -test.count<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> &gt;&gt; old.txt
</span></span><span style="display:flex;"><span>  ./new.test -test.run<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;^$&#39;</span> -test.bench<span style="color:#f92672">=</span>. -test.count<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> &gt;&gt; new.txt
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>benchstat old.txt new.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p>On a Mac, the <code>GOOS=linux</code> builds run inside the pinned container (<code>docker run --cpuset-cpus=0 -v &quot;$PWD&quot;:/w -w /w ...</code>, with the loop as the command) so both sides share its conditions. For the inner development loop, <a href="https://github.com/willabides/benchdiff"><code>benchdiff</code></a> automates the cycle, with the base ref running in a temporary git worktree:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go install github.com/willabides/benchdiff/cmd/benchdiff@v0.9.1
</span></span><span style="display:flex;"><span>benchdiff --base-ref<span style="color:#f92672">=</span>main --benchmem --count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> --benchtime<span style="color:#f92672">=</span>2s
</span></span></code></pre></td></tr></table>
</div>
</div><p>It runs both sides under the same conditions and compares them with a bundled, older copy of <code>benchstat</code>.</p>
<p>The toolchain is a condition too, and the one we forgot to record in our own table. Since Go 1.21 the <code>go</code> directive in <code>go.mod</code> is a minimum, not a pin: a newer local toolchain runs the module anyway. Different Go versions produce different numbers, so pin with <code>GOTOOLCHAIN=go1.26.5</code> and mention the version in any result you share. The last round of fixes needs no tools at all.</p>
<h2 id="cheap-wins-that-take-five-minutes">Cheap wins that take five minutes</h2>
<p>Close the browser, IDE and Slack, the most expensive background processes on most laptops. On macOS, turn off Spotlight indexing with <code>sudo mdutil -a -i off</code> so the indexer doesn&rsquo;t spike mid-run, and undo it afterwards with <code>sudo mdutil -a -i on</code>. Switch off Wi-Fi and Bluetooth (airplane mode, if your OS has one) to cut NIC interrupts and background traffic. On a noisy machine, closing the browser alone can noticeably lower CV before you touch anything else. Nobody has ever regretted closing Slack.</p>
<h2 id="back-to-the-three-rows">Back to the three rows</h2>
<p>Back to the rows from the top: 4.75% CV on an idle Mac, 18.88% with the spinners running, 5.25% in the pinned container. On a Mac, we run <code>-count=20 -benchtime=2s</code>, watch the <code>±</code> column in <code>benchstat</code> (a 95% confidence interval, not CV), compute CV as in <a href="/posts/go-benchmarks-lying-statistics/#what-benchstat-doesnt-tell-you">part 2</a>, and treat a CV above 5% as a reason to investigate the environment and above 10% as a reason not to trust the comparison at all (the bands from part 2).</p>
<p>Everywhere, that means <code>-count</code> of 10 or more, <code>benchstat</code>, <code>benchdiff</code> and closed apps. On Linux we add <code>taskset</code>, <code>perflock</code> and <code>nice</code>. On a dedicated machine or runner we add SMT and frequency control, <code>isolcpus</code> and <code>cset shield</code>. On a Mac, a pinned container helps with variance, not absolute numbers. Time to check our own numbers.</p>
<h2 id="try-it">Try it</h2>
<p>To reproduce the three conditions, we clone the demo at the pinned commit. This needs Docker, and the container step mounts the current directory, so run it from a path your Docker VM can see:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git clone https://github.com/kakkoyun/gopherconuk-26
</span></span><span style="display:flex;"><span>cd gopherconuk-26
</span></span><span style="display:flex;"><span>git checkout fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb
</span></span><span style="display:flex;"><span>cd talks/go-benchmarks-lying/demo
</span></span><span style="display:flex;"><span>make bench-docker
</span></span><span style="display:flex;"><span>make cv
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>make bench-docker</code> re-runs all three conditions (expect a minute or two) and overwrites <code>results/</code>, so your numbers will differ from ours. <code>make cv</code> recomputes the summary from whatever is in <code>results/</code>.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/go-benchmarks-lying-ci/">Part 4, Benchmark CI That Doesn&rsquo;t Lie</a>, takes the same discipline to CI: why shared runners lie, a two-tier gate, and the tools that back it. If sixteen spinners felt rude, wait until we meet other teams&rsquo; Docker builds. Between the two sits a companion, <a href="/posts/go-benchmarks-laptop-said-230/">The laptop said 230%</a>, where a loaded laptop and CI disagreed about one change.</p>
]]></content:encoded></item><item><title>otelc: zero-touch Go traces at compile time</title><link>https://kakkoyun.me/posts/otelc-compile-time-go-traces/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/otelc-compile-time-go-traces/</guid><description>otelc is the OpenTelemetry SIG&amp;#39;s compile-time instrumentation tool for Go: separate from Orchestrion, built from scratch, and stable since v1.0.1.</description><content:encoded><![CDATA[<p>We&rsquo;re going to instrument a Go program without touching its source, and the whole change fits in one build command. Here&rsquo;s the before and after:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go build -o myapp .
</span></span><span style="display:flex;"><span>otelc go build -o myapp .
</span></span></code></pre></td></tr></table>
</div>
</div><p>Same flags, same package, one extra word in front. The binary that comes out has OpenTelemetry instrumentation compiled in for the libraries otelc knows about, <code>net/http</code> and gRPC among them, and nobody opened a handler file.</p>
<p>That extra word is what we&rsquo;ll follow through this post. This is part 3 of 6 in the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, the written companion to my <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK 2026 talk</a>. <a href="/posts/obi-ebpf-auto-instrumentation-go/">Part 2</a> covered the kernel route. Here we move the intervention to build time and follow that one command: who built it and what it does to <code>go build</code>, then what it sees, what it costs and where it fits, and whether &ldquo;without a single line&rdquo; survives the trip.</p>
<p>Disclosure: I work at Datadog, which maintains Orchestrion and dd-trace-go, and I&rsquo;m one of otelc&rsquo;s maintainers. Read my comparisons with that in mind.</p>
<h2 id="why-there-are-two-tools">Why there are two tools</h2>
<p>Before we open the hood, a mix-up to clear: <code>otelc</code> has a lookalike. Naming is hard, and naming two tools that play the same trick is harder.</p>
<p>Orchestrion is Datadog&rsquo;s tool, CLI binary <code>orchestrion</code>. The release pinned for the talk (13 August 2026) is <a href="https://github.com/DataDog/orchestrion/releases/tag/v1.12.0">v1.12.0</a> (2026-07-30). Its <a href="https://github.com/DataDog/orchestrion/blob/v1.12.0/README.md">README</a> says other vendors, OpenTelemetry among them, may provide alternate integrations, but the ones it defaults to (dd-trace-go/v2) are Datadog&rsquo;s.</p>
<p>otelc is the OpenTelemetry Go compile-time instrumentation tool, CLI binary <code>otelc</code>. Why a second tool? The <a href="https://opentelemetry.io/blog/2025/go-compile-time-instrumentation/">SIG announcement</a> (January 2025) says Alibaba and Datadog had each proposed donating a tool, Alibaba&rsquo;s <code>opentelemetry-go-auto-instrumentation</code> and Datadog&rsquo;s Orchestrion. Then the two organizations &ldquo;decided to join forces&rdquo; and set up a new SIG, with further contributions from Quesma. The goal was a unified, vendor-neutral approach that &ldquo;picks the best aspects of each solution&rdquo;, and it &ldquo;won&rsquo;t be Alibaba&rsquo;s or Datadog&rsquo;s solution that &lsquo;wins&rsquo;&rdquo;. The SIG built otelc from scratch. Orchestrion was never donated and stays at <code>github.com/DataDog/orchestrion</code> as Datadog&rsquo;s own product.</p>
<p><a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/releases/tag/v1.0.1">v1.0.0 and v1.0.1</a> both went out on 2026-07-14, four weeks before the talk. The <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/releases/tag/v1.0.0">v1.0.0 release notes</a> retract it because <code>otelc pin</code> wrote incorrect module paths, so v1.0.1 is the first usable stable release and the one pinned for the talk. <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/releases/tag/v1.1.0">v1.1.0</a> followed on 2026-08-24. The OpenTelemetry blog&rsquo;s <a href="https://opentelemetry.io/blog/2026/go-compile-time-instrumentation-v1/">v1 announcement</a> tells the milestone story (I <a href="/posts/go-compile-time-instrumentation-v1/">cross-posted</a> it).</p>
<p>The two tools share a mechanism and differ in codebase and default tracer, so choosing is mostly about which tracer SDK you&rsquo;re committing to. The trick underneath is the same either way. Let&rsquo;s see what that extra word does to <code>go build</code>.</p>
<h2 id="the-mechanism">The mechanism</h2>
<p>Go&rsquo;s build system has a <code>-toolexec</code> flag. Normally <code>go build</code> invokes the compiler directly. With <code>-toolexec</code>, the go command runs each toolchain program (<code>compile</code>, <code>asm</code>, <code>link</code>, <code>vet</code>) through a wrapper binary first. <code>go help build</code> calls it &ldquo;a program to use to invoke toolchain programs like vet and asm.&rdquo; That generic hook turns out to be exactly the right one for compile-time AOP: Orchestrion&rsquo;s maintainers describe their tool as, in some ways, a &ldquo;compile-time-woven Aspect-oriented Programming (AoP) framework&rdquo; (<a href="https://github.com/golang/go/issues/69887">golang/go#69887</a>). My <a href="https://internals-for-interns.com/posts/hooking-into-the-go-toolchain/">guest post</a> builds this <code>-toolexec</code> mechanism up step by step, cache included.</p>
<p>Our extra word plugs into that hook. <code>otelc go build</code> adds the flag for us and registers <code>otelc</code> as the wrapper. When the wrapper intercepts a compile for a package one of its rules matches, it parses that package&rsquo;s <code>.go</code> files into an AST, applies the rules (which functions to wrap, which spans to inject, how to propagate context), and hands the rewritten source to the real compiler. Everything else passes through untouched. The compiler never sees the original, so as far as it knows you wrote the instrumentation yourself, and it&rsquo;s too polite to ask.</p>
<p>How far does the rewriting reach? The <a href="https://opentelemetry.io/blog/2026/go-compile-time-instrumentation-v1/">OpenTelemetry blog</a> says:</p>
<blockquote>
<p>&ldquo;hooks into the standard Go toolchain during the build (through its <code>-toolexec</code> mechanism) and injects OpenTelemetry instrumentation into your code, its dependencies, and the standard library as they are compiled.&rdquo;</p>
</blockquote>
<p>If <code>net/http</code> needs instrumentation, otelc can inject it as <code>net/http</code> gets compiled into your binary. The result has OTel spans baked in: no runtime agent, no sidecar, no dynamic injection. The rewrite injects a small trampoline (a generated stub in the target function) that calls a hook function, linked in with <code>//go:linkname</code>. The hooks are ordinary Go in otelc&rsquo;s instrumentation packages, and they call the OTel API and SDK, so at runtime the spans look like ones you&rsquo;d have written by hand.</p>
<p>That&rsquo;s the whole trick, and it comes with a price tag. Let&rsquo;s read it before we buy.</p>
<h2 id="the-constraint-you-need-to-know">The constraint you need to know</h2>
<p>otelc needs a Go 1.25 or newer toolchain (its <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.1/go.mod"><code>go.mod</code></a> says <code>go 1.25.0</code>). The toolchain is what counts: a service whose <code>go.mod</code> says <code>go 1.24</code> still builds with a newer toolchain, and otelc raises the directive with a warning. Builds pinned to a Go 1.23 or 1.24 toolchain can&rsquo;t use otelc, nor Orchestrion v1.12.0 (<a href="https://github.com/DataDog/orchestrion/blob/v1.12.0/go.mod"><code>go.mod</code></a>). For those, OBI (<a href="/posts/obi-ebpf-auto-instrumentation-go/">part 2</a>) needs no rebuild.</p>
<p>When I prepared the talk, Go 1.26 (February 2026) was current, and Go 1.27 arrived on 2026-08-19, which ends upstream support for 1.25. Toolchain pinning is a fine habit until a tool wants a newer one. In slow-upgrade environments that&rsquo;s the first question to answer. If the toolchain clears, the next question is what the rebuild buys us.</p>
<h2 id="what-it-instruments">What it instruments</h2>
<p>The default rules cover a list of libraries; for your own functions you write a custom rule. A rule is a YAML entry naming a target package, a selector and an action such as <code>inject_hooks</code> (<a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/docs/rules.md">schema</a>); its hooks are Go in a package of their own. At v1.0.1 the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.1/docs/getting-started.md">getting-started guide</a> listed <code>net/http</code> (client and server), gRPC, <code>database/sql</code>, gin, go-redis v9, mongo-driver, Kubernetes client-go, the OpenAI Go SDK (v1 to v3) and segmentio/kafka-go. v1.1.0 added Anthropic, AWS SDK v2, linodego and mongo-driver v2; the <a href="https://opentelemetry.io/docs/zero-code/go/compile-time/supported-libraries/">docs&rsquo; supported-libraries page</a> has the current list.</p>
<p>Orchestrion with dd-trace-go has its own list. Its <a href="https://github.com/DataDog/dd-trace-go/blob/v2.9.1/contrib/supported_integrations.md">supported-integrations table</a> in dd-trace-go v2.9.1 (the version Orchestrion v1.12.0 pins) marks which integrations work with Orchestrion. They include HTTP frameworks (net/http, gin, gorilla/mux, chi, echo, fiber), gRPC, <code>database/sql</code> and the layers above it (pgx, gorm, mongo-driver), Redis (go-redis v6 to v9, redigo, rueidis), Kafka, AWS SDK v1 and v2, and Kubernetes client-go. Compare both lists against your own dependencies before choosing.</p>
<p>Coverage is one axis. The other is whether to rebuild at all, so let&rsquo;s put otelc next to OBI.</p>
<h2 id="where-it-fits-relative-to-obi">Where it fits relative to OBI</h2>
<p>OBI and otelc attack the same problem, zero source code changes, from opposite ends. Which end do we pick?</p>
<p>OBI attaches from outside the process at runtime. It needs no rebuild, works on deployed services, and covers other languages too. But it&rsquo;s bounded by what eBPF can observe at library boundaries: RED metrics and library-level spans for the Go libraries it supports (<a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/SUPPORT_MATRIX.md">13 in the OBI v0.10.0 support matrix</a>, the release pinned for the talk). It cannot generate custom spans or instrument business logic.</p>
<p>otelc works at build time. It requires a rebuild and Go 1.25 or newer. In exchange, it can reach stdlib internals, your own functions, dependencies and business logic, with a custom rule for anything the default set doesn&rsquo;t cover. The injected spans have the fidelity of hand-written OTel calls, because the hooks call the same OTel API and SDK you would.</p>
<p>My decision rule: deployed and can&rsquo;t rebuild, need baseline visibility across services? OBI. Building or rebuilding, want granular spans, on Go 1.25 or newer? otelc. Running Datadog APM with the widest framework coverage? Orchestrion. <a href="/posts/zero-touch-go-observability-agent-actionable/">Part 6</a> turns this into a runbook. For now, time to run that command.</p>
<h2 id="try-it">Try it</h2>
<p>Install otelc, pinned to a version. I ran v1.1.0; its runtime rules match v1.0.1, the version pinned for the talk:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go install go.opentelemetry.io/otelc/tool/cmd/otelc@v1.1.0
</span></span></code></pre></td></tr></table>
</div>
</div><p>Then build the service with otelc wrapping the build command, exactly as in the opening:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>otelc go build -o myapp .
</span></span></code></pre></td></tr></table>
</div>
</div><p>That&rsquo;s the complete usage change. To see traces, run the binary with <code>OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318</code> and <code>OTEL_SERVICE_NAME=my-go-service</code>. otelc exports OTLP over HTTP by default, so use 4318, not the gRPC port 4317 (<a href="/posts/zero-touch-go-observability-agent-actionable/#what-otelc-covers">part 6</a> explains). In CI, put <code>otelc</code> in <code>$PATH</code> and prefix your existing build command. If you can&rsquo;t prefix it, the getting-started guide describes a second route: run <code>otelc setup</code> once, then set <code>GOFLAGS=&quot;'-toolexec=otelc toolexec'&quot;</code> and use plain <code>go build</code>.</p>
<p>&ldquo;Without a single line&rdquo; sounds absolute, so here&rsquo;s the fine print. You don&rsquo;t edit <code>go.mod</code> yourself, but otelc does touch it. During the build it adds its hook modules as <code>replace</code> directives, runs <code>go mod tidy</code>, and may raise the <code>go</code> directive (it prints <code>Bumped go version</code> when it does), then restores <code>go.mod</code> and <code>go.sum</code> when the build finishes. A <code>.otelc-build/</code> directory stays behind. If a build is interrupted, the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.1/docs/troubleshooting.md">troubleshooting guide</a> says to run <code>otelc cleanup</code> and <code>git restore go.mod go.sum</code>.</p>
<p>Already using <code>otelhttp</code> and your own spans? I built a small server with <code>otelhttp.NewHandler</code> and one manual span using <code>otelc go build</code> (v1.1.0, Go 1.27.1, macOS arm64) and sent three requests to Jaeger. Each request was one trace: otelc&rsquo;s server span at the root, the <code>otelhttp</code> server span under it, and my manual span under that. Nothing is orphaned, but you get two server spans per request. I didn&rsquo;t test dd-trace-go.</p>
<p>Back to our one command, then. &ldquo;Without a single line&rdquo; really means one build command substitution and Go 1.25 on your toolchain. I&rsquo;ll take that trade most days. 🔧</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/continuous-profiling-go-without-code-changes/">Part 4, The fourth signal: continuous profiling without code changes</a>, goes back to the kernel to add profiles, and asks what a stripped binary still remembers. Next time, nobody rebuilds anything.</p>
]]></content:encoded></item><item><title>A Single Benchmark Number Is a Lie</title><link>https://kakkoyun.me/posts/go-benchmarks-lying-statistics/</link><pubDate>Tue, 14 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-lying-statistics/</guid><description>One benchmark run is one sample from a distribution you have not seen. benchstat and a short awk script tell you whether the result, and the machine, can be trusted.</description><content:encoded><![CDATA[<p>Here is one benchmark that gave us two different answers for the same code. We&rsquo;re going to take that run apart: see what <code>benchstat</code> can and can&rsquo;t say about it, add the one number it doesn&rsquo;t report, and decide how many runs it takes before a result earns any trust. Let&rsquo;s call it the run that lied.</p>
<p>The benchmark is <code>BenchmarkMakeBuffer_Correct</code>, run on a machine with sixteen CPU-bound background processes competing for the same cores. Here are two of its twenty runs (<code>-16</code> is the <code>GOMAXPROCS</code> suffix), with the <code>B/op</code> and <code>allocs/op</code> columns dropped:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>BenchmarkMakeBuffer_Correct-16    41877204    39.39 ns/op
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer_Correct-16    52521198    27.54 ns/op
</span></span></code></pre></td></tr></table>
</div>
</div><p>Those are runs 1 and 9 of one capture on an Apple M4 Max (darwin/arm64) from the <a href="https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying/demo">companion repository</a>. Same code, same binary, eight runs apart, a 43% swing. Run it once, file the PR, and you could have reported either number. (I would love to say I&rsquo;ve never done that.) Scheduler preemption, cache evictions and stolen CPU time are the likely culprits. The benchmark measured what the machine was doing; it just wasn&rsquo;t what we thought we were measuring. Any single result is one draw from a distribution whose shape, center and spread we haven&rsquo;t seen.</p>
<p>This is part 2 of 5 in the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, the written companion to the <a href="/talks/why-your-go-benchmarks-are-lying/">GopherCon UK 2026 talk</a>. In <a href="/posts/go-benchmarks-lying-compiler-honesty/">part 1</a> we asked whether the compiler lets a benchmark do real work. Here we ask the second of the three questions: is the sample stable enough to mean anything? We also pick up the statistics for the third (p-value and effect size), whether the difference is large relative to the noise, and <a href="/posts/go-benchmarks-lying-local-reproduction/">part 3</a> and <a href="/posts/go-benchmarks-lying-ci/">part 4</a> then deal with the machine. The prerequisite is the <a href="/posts/fosdem-2026-measuring-software-performance/">FOSDEM 2026 post on measuring software performance</a>, which covers the statistics in language-agnostic terms. Here we go one level deeper, into the Go tooling.</p>
<p>If one run can say 39 or 27, the first move is to stop trusting one run.</p>
<h2 id="from-runs-to-a-distribution-with-benchstat">From runs to a distribution with <code>benchstat</code></h2>
<p>The cure for a noisy measurement is more samples, not better luck. Let&rsquo;s collect twenty runs and let <code>benchstat</code> summarize them:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go test -bench<span style="color:#f92672">=</span>BenchmarkMakeBuffer_Correct -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span> -benchtime<span style="color:#f92672">=</span>1s . <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  | tee results.txt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260929162123-406019bb8b68
</span></span><span style="display:flex;"><span>benchstat results.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p>We&rsquo;ll start with the idle machine as our control. This is the <code>benchstat</code> output for the idle capture (same <code>-count=20 -benchtime=1s</code>, same M4 Max), trimmed to the <code>sec/op</code> table because the tool also prints <code>B/op</code> and <code>allocs/op</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>goos: darwin
</span></span><span style="display:flex;"><span>goarch: arm64
</span></span><span style="display:flex;"><span>pkg: github.com/kakkoyun/gopherconuk-26/demo
</span></span><span style="display:flex;"><span>cpu: Apple M4 Max
</span></span><span style="display:flex;"><span>                      │ results/idle.txt │
</span></span><span style="display:flex;"><span>                      │      sec/op      │
</span></span><span style="display:flex;"><span>MakeBuffer_Correct-16        11.32n ± 5%
</span></span></code></pre></td></tr></table>
</div>
</div><p>Two numbers here. <code>11.32n</code> is the <a href="https://pkg.go.dev/golang.org/x/perf@v0.0.0-20260929162123-406019bb8b68/cmd/benchstat"><strong>median</strong></a> of the 20 runs, and <code>± 5%</code> is how far the 95% confidence interval around that median reaches, as a percentage of it: a range built to contain the true median 95% of the time.</p>
<span class="sidenote-ref" id="ref-sn-3" role="doc-noteref">
  <sup><a href="#sn-3" aria-describedby="sn-3">median</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-3" role="note" aria-label="Sidenote median (right margin)">
  <span class="sidenote-label">median</span>
  <span class="sidenote-content">Real latency data is rarely a bell curve: <a href="https://www.brendangregg.com/FrequencyTrails/outliers.html">Brendan Gregg&rsquo;s frequency trails</a> show disk I/O latency distributions that are combinations of bimodal and log-normal. A GC pause or a scheduler preemption can drag one benchmark run well above the rest, and the arithmetic mean follows it up. The median ignores those outliers.</span>
  <a class="sidenote-back" href="#ref-sn-3" aria-label="Back to reference">↩</a>
</aside>

<p>Now let&rsquo;s put the loaded machine next to it.</p>
<h3 id="comparing-two-runs">Comparing two runs</h3>
<p>Comparison is where <code>benchstat</code> earns its keep. We collect one file before a change and one after, then hand it both:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go test -bench<span style="color:#f92672">=</span>. -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span> -benchtime<span style="color:#f92672">=</span>1s . &gt; old.txt
</span></span><span style="display:flex;"><span><span style="color:#75715e"># make your change</span>
</span></span><span style="display:flex;"><span>go test -bench<span style="color:#f92672">=</span>. -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span> -benchtime<span style="color:#f92672">=</span>1s . &gt; new.txt
</span></span><span style="display:flex;"><span>benchstat old.txt new.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p>Here the &ldquo;change&rdquo; is the load, so we compare the idle capture against the loaded one (<code>sec/op</code> only; allocations were identical):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>                      │ results/idle.txt │           results/noisy.txt           │
</span></span><span style="display:flex;"><span>                      │      sec/op      │    sec/op     vs base                 │
</span></span><span style="display:flex;"><span>MakeBuffer_Correct-16        11.32n ± 5%   37.40n ± 25%  +230.34% (p=0.000 n=20)
</span></span></code></pre></td></tr></table>
</div>
</div><p>The delta is +230.34%, so the loaded machine took about 3.3 times as long, and a p-value of effectively zero, the chance that noise alone would produce a difference this large, says the difference is real. When the p-value exceeds 0.05, <code>benchstat</code> prints <code>~</code> instead of a delta, which is the right answer: &ldquo;no measurable difference with this sample size.&rdquo; Report it as such. (The <a href="https://pkg.go.dev/golang.org/x/perf@v0.0.0-20260929162123-406019bb8b68/cmd/benchstat">test underneath</a> is Mann-Whitney U on the samples, which compares ranks and needs no bell curve, not the Welch&rsquo;s t-test from the FOSDEM post.)</p>
<p><code>benchstat</code> says the two machines differ. It doesn&rsquo;t say whether the loaded one is a place where any comparison means anything, and that is the gap we fill next.</p>
<h2 id="what-benchstat-doesnt-tell-you">What benchstat doesn&rsquo;t tell you</h2>
<p>Look at the <code>± 25%</code> on the loaded side. The <code>±</code> figures are 95% confidence intervals on the median, and they hint at spread, but they don&rsquo;t say whether the <em>environment</em> is stable enough to trust a comparison run in it. The interval narrows as runs are added; CV does not. For that we need a different number: the <strong>coefficient of variation</strong>.</p>
<p>CV = σ / μ, as a percentage. Where <code>benchstat</code> answers &ldquo;is A different from B?&rdquo;, CV answers &ldquo;is this machine a reliable place to ask?&rdquo; <code>benchstat</code> compares two distributions; it doesn&rsquo;t characterize the environment producing them, much as a referee doesn&rsquo;t inspect the pitch. A short awk script does that pass:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>make cv
</span></span><span style="display:flex;"><span><span style="color:#75715e"># or directly:</span>
</span></span><span style="display:flex;"><span>awk -f cv.awk results/idle.txt
</span></span><span style="display:flex;"><span>awk -f cv.awk results/noisy.txt
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>cv.awk</code> reads any <code>go test -bench</code> output and computes the mean, standard deviation and CV per benchmark. Over the idle and loaded captures (same <code>-count=20 -benchtime=1s</code> on the same M4 Max) it gives this table. <a href="/posts/go-benchmarks-lying-local-reproduction/">Part 3</a> adds a third condition.</p>
<table>
	<thead>
			<tr>
					<th>Condition</th>
					<th>Mean ns/op</th>
					<th>Stddev</th>
					<th>CV</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Idle host</td>
					<td>11.46</td>
					<td>0.54</td>
					<td>4.75%</td>
			</tr>
			<tr>
					<td>16 background spinners</td>
					<td>34.97</td>
					<td>6.60</td>
					<td>18.88%</td>
			</tr>
	</tbody>
</table>
<p>The loaded machine is three times slower and four times noisier. A CV of 18.88% puts the standard deviation at nearly one-fifth of the mean, so we are measuring scheduling interference at least as much as the code. Any A/B comparison collected there is uninterpretable.</p>
<p>My rule of thumb for reading it, not <code>benchstat</code>&rsquo;s:</p>
<table>
	<thead>
			<tr>
					<th>CV</th>
					<th>Interpretation</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>&lt; 2%</td>
					<td>Results are reliable</td>
			</tr>
			<tr>
					<td>2 to 5%</td>
					<td>Acceptable for most comparisons</td>
			</tr>
			<tr>
					<td>5 to 10%</td>
					<td>Noisy; investigate the environment</td>
			</tr>
			<tr>
					<td>&gt; 10%</td>
					<td>Fix the environment; do not trust comparisons</td>
			</tr>
	</tbody>
</table>
<p>At 18.88% the loaded condition misses the 10% line by nearly a factor of two. More samples would not help, because the environment is biased and averaging does not remove a bias (noise shrinks with more runs; bias does not). What kind of bias? For that we go back to the raw runs.</p>
<h3 id="back-to-the-run-that-lied">Back to the run that lied</h3>
<p>The mean of 34.97 ns/op looks unremarkable until we read the twenty runs. The first seven land between 38 and 44 ns/op. Run eight is 37. Runs nine through sixteen drop to 25 to 29, roughly a third lower. Then the last four climb back: 34, then 38 to 39. The mean sits in the gap between the two clusters, where only a couple of runs land. It describes a machine that was almost never in that state.</p>
<p>Now our two runs make sense. Run 1 (39.39) sits in the first cluster, run 9 (27.54) in the second. The machine shifted between two regimes during the capture, and we happened to sample one of each. Mann-Whitney assumes one distribution per sample, which these runs are not. One dot per measurement, the strip plot from the FOSDEM post, shows this at once. A boxplot would obscure it, and a mean buries it entirely.</p>
<p>I did not measure why the regimes differ. Scheduler interference is the likely explanation, though P/E-core placement or frequency scaling would look similar on this machine. More statistics cannot recover that; a pinned container is the start of the answer, and part 3 measures what isolation buys. First, a question we&rsquo;ve been dodging: even on a good machine, how many runs do we collect?</p>
<h2 id="how-many-runs-are-enough">How many runs are enough?</h2>
<p>Ten is the practical floor: <a href="https://pkg.go.dev/golang.org/x/perf@v0.0.0-20260929162123-406019bb8b68/cmd/benchstat"><code>benchstat</code>&rsquo;s own documentation</a> says to run &ldquo;at least 10, ideally 20&rdquo;. Below six samples it can&rsquo;t compute a confidence interval at all and prints <code>± ∞</code>, so five is a sanity check and nothing more. Ten gives a reliable local A/B comparison, and 20 is for a CI nightly suite or anything we intend to report. <a href="https://dl.acm.org/doi/10.1145/2464157.2464160">Kalibera and Jones (ISMM 2013)</a> argue for repetition counts based on measured variance, and 20 is a reasonable engineering compromise between rigor and machine time. Fifty or more is for environments we can&rsquo;t quiet down. It tightens the interval and keeps the same bias, so for the run that lied it would only give a sharper picture of two moods.</p>
<p>As a rule of thumb, resolving a change of Δ% at a CV of c% takes about 16·(c/Δ)² runs per side. At 5% CV, a 5% change needs 16 to 20 runs; at the loaded machine&rsquo;s 18.88%, more than 200. (This is Lehr&rsquo;s rule, 16σ²/Δ² per group for about 80% power at α=0.05. It assumes normal noise, and two regimes break it.)</p>
<p>By default <code>-benchtime=1s</code> lets Go calibrate <code>b.N</code> to fill one second per run. For CI baselines I prefer a fixed count such as <code>-benchtime=1000000x</code> (a million iterations per run, no calibration) with <code>-count=20</code>, because a calibration loop that reacts to momentary system load is variance nobody asked for. Pick N so each run lasts milliseconds, not microseconds.</p>
<p>We now know how many runs to collect. One habit can still waste every one of them.</p>
<h2 id="the-p-hacking-trap">The p-hacking trap</h2>
<p>The most common way to get a benchmark result you like is to keep running until you see one. I have done this, and I suspect you have too.</p>
<p>That habit is p-hacking, and it invalidates the p-values <code>benchstat</code> reports, which are calibrated for a pre-specified number of experiments, not an open-ended search. <code>benchstat</code>&rsquo;s documentation calls <a href="https://pkg.go.dev/golang.org/x/perf@v0.0.0-20260929162123-406019bb8b68/cmd/benchstat">rerunning until it reports a change</a> a common statistical error: at the default α of 0.05, about one comparison in twenty shows a difference when nothing changed, whatever your <code>-count</code>. Across 40 benchmarks that is about two false flags per run: re-measure a flag with a pre-committed N, and keep a ledger of the ones that do not reproduce. Select for the runs that clear the threshold and you have selected flukes.</p>
<p>The discipline is to decide N before running, run once, and report what <code>benchstat</code> says, <code>~</code> results included. If it matters enough to look again, a larger run is a new experiment: fix its N up front and label it a second look in the report. What we can&rsquo;t do is rerun after a disappointing p-value and keep whichever result we like. &ldquo;It only improved on the third attempt&rdquo; is p-hacking. &ldquo;It improved when I closed my editor&rdquo; is environmental confounding, which is equally problematic.</p>
<p>Suppose we resisted all that and the p-value is low. Are we done?</p>
<h2 id="effect-size-versus-statistical-significance">Effect size versus statistical significance</h2>
<p>Not yet. A low p-value says the difference is unlikely to be zero. It doesn&rsquo;t say the difference matters.</p>
<p>With 100 samples on a quiet machine (CV around 1% or less), a 0.3% change can clear p=0.05. On an HTTP handler running at 200 µs that saves 600 nanoseconds per request: statistically real, practically irrelevant. The inverse also holds. With five samples a 15% regression might not reach significance, but 15% on a critical path deserves a look anyway, and a larger pre-committed N can settle it as a new experiment.</p>
<p>We report both numbers. Here is my rule of thumb, not <code>benchstat</code>&rsquo;s:</p>
<table>
	<thead>
			<tr>
					<th>Delta</th>
					<th>p-value</th>
					<th>Action</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>&lt; 2%</td>
					<td>any</td>
					<td>No action needed</td>
			</tr>
			<tr>
					<td>2 to 10%</td>
					<td>&gt; 0.05</td>
					<td>Treat as no measurable difference; pre-commit to a larger N if it matters</td>
			</tr>
			<tr>
					<td>2 to 10%</td>
					<td>&lt; 0.05</td>
					<td>Investigate; may be real</td>
			</tr>
			<tr>
					<td>&gt; 10%</td>
					<td>&gt; 0.05</td>
					<td>Investigate; the noise may be hiding a real change</td>
			</tr>
			<tr>
					<td>&gt; 10%</td>
					<td>&lt; 0.05</td>
					<td>Real; act on it</td>
			</tr>
	</tbody>
</table>
<p>Regressions that creep in at 1 to 2% per commit never trip these thresholds; the FOSDEM post&rsquo;s change-point detection section covers them.</p>
<p>That is the whole kit for the sample: a distribution, a stability number, a run count and a discipline. Time to check our arithmetic.</p>
<h2 id="try-it">Try it</h2>
<p>To recompute the CV table from the captured results in the companion repository:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git clone https://github.com/kakkoyun/gopherconuk-26
</span></span><span style="display:flex;"><span>cd gopherconuk-26/talks/go-benchmarks-lying/demo
</span></span><span style="display:flex;"><span>git checkout fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>make cv            <span style="color:#75715e"># recompute the CV table from results/</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>make cv</code> prints all three conditions, the two above and the pinned container from part 3. Re-running them is in <a href="/posts/go-benchmarks-lying-local-reproduction/#try-it">part 3</a>.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/go-benchmarks-lying-local-reproduction/">Part 3, Before CI: Can You Trust a Benchmark on Your Own Laptop?</a> measures what container pinning and the Linux controls buy you locally, and where it stops. The run that lied is about to get a third condition. Let&rsquo;s see if it behaves.</p>
]]></content:encoded></item><item><title>What a uprobe costs, and what USDT buys</title><link>https://kakkoyun.me/posts/go-uprobe-vs-usdt/</link><pubDate>Sat, 11 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-uprobe-vs-usdt/</guid><description>One Go function, two ways to probe it: OBI&amp;#39;s uprobes on entry and every RET, or a USDT site. What a hit costs, what OBI does for Go today, and what USDT would change.</description><content:encoded><![CDATA[<p>We&rsquo;re going to put a probe on one small Go function, twice. First the way OBI (OpenTelemetry eBPF Instrumentation) does it today, then the way a USDT site would. On the way we&rsquo;ll price a single probe hit, and find that USDT&rsquo;s pitch is not a discount.</p>
<p>This is a companion to the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, between <a href="/posts/obi-ebpf-auto-instrumentation-go/">Part 2</a> and <a href="/posts/otelc-compile-time-go-traces/">Part 3</a>. The series accompanies my <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK talk</a>. Part 2 showed what OBI sees. Here we look at how it gets its probes onto Go code, and why USDT keeps coming up. Our function is below. The <code>//go:noinline</code> keeps the compiler from folding it into <code>main</code>, so there is something left to probe.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">main</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> <span style="color:#e6db74">&#34;fmt&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">//go:noinline</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">handle</span>(<span style="color:#a6e22e">n</span> <span style="color:#66d9ef">int</span>) <span style="color:#66d9ef">int</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">if</span> <span style="color:#a6e22e">n</span> &lt; <span style="color:#ae81ff">0</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">return</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">if</span> <span style="color:#a6e22e">n</span> <span style="color:#f92672">==</span> <span style="color:#ae81ff">0</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">return</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> len(<span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Sprint</span>(<span style="color:#a6e22e">n</span>))
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">main</span>() {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#a6e22e">handle</span>(<span style="color:#ae81ff">42</span>))
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="one-function-three-exits">One function, three exits</h2>
<p>Let&rsquo;s build it for <code>linux/amd64</code> with Go 1.27.1 and ask <code>go tool objdump</code> for <code>main.handle</code>. The listing is trimmed to the lines we need (<code>…</code> marks what I cut), and the rest is byte for byte what the tool printed:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>TEXT main.handle(SB) ./main.go
</span></span><span style="display:flex;"><span>  main.go:6		0x49a080		493b6610		CMPQ SP, 0x10(R14)
</span></span><span style="display:flex;"><span>  main.go:6		0x49a084		765b			JBE 0x49a0e1
</span></span><span style="display:flex;"><span>  …
</span></span><span style="display:flex;"><span>  main.go:11		0x49a09c		c3			RET
</span></span><span style="display:flex;"><span>  …
</span></span><span style="display:flex;"><span>  main.go:13		0x49a0d2		c3			RET
</span></span><span style="display:flex;"><span>  …
</span></span><span style="display:flex;"><span>  main.go:8		0x49a0e0		c3			RET
</span></span><span style="display:flex;"><span>  …
</span></span><span style="display:flex;"><span>  main.go:6		0x49a0e6		e8b52afeff		CALL runtime.morestack_noctxt.abi0(SB)
</span></span><span style="display:flex;"><span>  main.go:6		0x49a0f0		eb8e			JMP main.handle(SB)
</span></span></code></pre></td></tr></table>
</div>
</div><p>The entry is <code>CMPQ SP, 0x10(R14)</code>. <a href="https://github.com/golang/go/blob/go1.27.1/src/cmd/compile/abi-internal.md">R14 holds the current goroutine</a>, so this compares the stack pointer with the goroutine&rsquo;s stack guard. If the stack is too small, <code>JBE</code> jumps to <code>runtime.morestack</code>, which grows the stack (a copy, so it moves) and restarts the function with <code>JMP main.handle</code>. The body then leaves through three <code>RET</code> instructions, one per <code>return</code> in the source.</p>
<p>Watching <code>handle</code> start is easy, since we know where it starts. Watching it finish is where Go makes life hard.</p>
<h2 id="way-one-an-entry-uprobe-and-a-uprobe-on-every-ret">Way one: an entry uprobe and a uprobe on every RET</h2>
<p>The textbook way to catch a return is a uretprobe. The kernel rewrites the return address on the stack so the function returns into a trampoline. After poking through the uretprobe implementation in 2018, Austin Clements <a href="https://github.com/golang/go/issues/27077#issuecomment-414460569">wrote</a>: &ldquo;It&rsquo;s not clear to me how the runtime (or anything that unwinds the stack) could account for this.&rdquo; Ian Lance Taylor had <a href="https://github.com/golang/go/issues/22008#issuecomment-331773872">already explained the other half</a>: Go&rsquo;s stacks &ldquo;grow, and therefore move&rdquo;. A return address the kernel wrote into a stack that then moves points nowhere good.</p>
<p>OBI skips uretprobes. Its code comment says why: <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/pkg/internal/goexec/instructions.go#L97">&ldquo;since go linkage is non-standard we can&rsquo;t use uretprobe&rdquo;</a>. Instead it <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/pkg/internal/goexec/instructions_amd64.go#L25-L48">decodes the function&rsquo;s machine code with <code>x86asm</code> and records every <code>RET</code></a>, then <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/pkg/ebpf/instrumenter.go#L644">attaches a plain uprobe at each one</a>. That is v0.10.0, the release pinned for the talk and for part 2. For <code>handle</code> it means one entry probe and three return probes, found by reading disassembly. In <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/pkg/ebpf/instrumenter.go#L995">v0.14.0</a> the same idea is still there, now batched into one attachment.</p>
<p>The entry probe has its own wart. The v0.14.0 eBPF code (the talk pins v0.10.0) says that when Go grows the stack it restarts the function, <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/bpf/gotracer/go_obi_ctx.h#L74-L76">so the entry probe fires twice</a>. That is our <code>JMP main.handle</code>, and OBI&rsquo;s eBPF code has to allow for it.</p>
<p>Then there are the arguments. Reading a struct field from a probe needs its offset, and some offsets move between Go releases. OBI keeps <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/pkg/internal/goexec/offsets.json">a table of them</a> and refreshes it with a <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/.github/workflows/update-offsets.yml">daily workflow</a>. By my count it covers 59 types and 96 fields in <code>offsets.json</code> at v0.10.0 (1,510 lines), and 91 types and 183 fields at v0.14.0 (2,746 lines). One field, <code>net/http.http2ClientConn.fr</code>, sits at offset 208 in Go 1.17.0 and 368 from Go 1.26.0.</p>
<p>That is a lot of work to do before the first trap. What does a trap cost?</p>
<h2 id="what-one-hit-costs">What one hit costs</h2>
<p>The kernel&rsquo;s own BPF selftests can tell us. When Jiri Olsa <a href="https://github.com/torvalds/linux/commit/0c4fc6bd61054a9378bce149b3758f9b6e8fb5ab">added USDT rows to the uprobe benchmark</a>, his commit message recorded one run on an x86_64 kernel. The benchmark hits a probe from a single producer thread and runs a trivial BPF counter program. The CPU is not stated. In millions of hits per second, with my 1/x in brackets:</p>
<table>
	<thead>
			<tr>
					<th>Probe target</th>
					<th>M/s</th>
					<th>Per hit</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>No probe, just the loop (<code>usermode-count</code>)</td>
					<td>152.507</td>
					<td>6.6 ns</td>
			</tr>
			<tr>
					<td><code>uprobe-nop</code></td>
					<td>3.190</td>
					<td>313 ns</td>
			</tr>
			<tr>
					<td><code>usdt-nop</code></td>
					<td>3.235</td>
					<td>309 ns</td>
			</tr>
			<tr>
					<td><code>uprobe-ret</code>, a probe on a bare <code>ret</code></td>
					<td>1.095</td>
					<td>913 ns</td>
			</tr>
	</tbody>
</table>
<p>A USDT site is a NOP with the same trap put on it, and it costs what a uprobe on a NOP costs. The table has no row for a Go entry, which starts with <code>CMPQ</code>, not a NOP. The <code>ret</code> row is the slowest one, and I don&rsquo;t know whether a Go <code>RET</code> lands in that regime, because I haven&rsquo;t measured it.</p>
<p>The disabled case is not free either. PostgreSQL&rsquo;s docs say that with <code>--enable-dtrace</code>, probe arguments <a href="https://www.postgresql.org/docs/18/dynamic-trace.html">&ldquo;will be evaluated whenever control passes through the macro, even if no tracing is being done&rdquo;</a>. libbpf&rsquo;s own documentation says a USDT <a href="https://github.com/torvalds/linux/blob/v7.2/tools/lib/bpf/usdt.c#L52-L54">&ldquo;normally has zero overhead, unless it is being traced by some external entity&rdquo;</a>.</p>
<p>USDT is not zero overhead. If the price is the same trap, the case for it has to rest on something else.</p>
<h2 id="way-two-a-named-site-in-the-body">Way two: a named site in the body</h2>
<p>A USDT site puts a NOP in the function body and a note in the <code>.note.stapsdt</code> ELF section. Per <a href="https://github.com/torvalds/linux/blob/v7.2/tools/lib/bpf/usdt.c#L66-L105">libbpf&rsquo;s description</a>, the note carries a provider, a name, the site&rsquo;s address and, per argument, a size and a location such as <code>-4@%edi</code>. Three things follow.</p>
<p>A tool attaches with less work. It reads the address and the argument locations from the note, and it does not disassemble anything. For <code>handle</code> that would be one site instead of an entry probe plus three <code>RET</code> probes. If the site&rsquo;s author computes the arguments, the tool also has no struct offsets to track. That last part is my opinion, based on what OBI carries today.</p>
<p>Go&rsquo;s moving stacks stop mattering. A site in the body is a plain uprobe, so there is no return hijack for a moving stack to break.</p>
<p>It can be a stable contract. The name outlives a refactor that moves the code around, and a tool asking for <code>net_http:server_request_start</code> doesn&rsquo;t care where the compiler put it. The caveat is that the project has to keep its probes. CPython&rsquo;s docs are blunt: <a href="https://github.com/python/cpython/blob/v3.14.0/Doc/howto/instrumentation.rst#L27-L30">&ldquo;No guarantees are made about probe compatibility between versions of CPython&rdquo;</a>.</p>
<p>Go has none of this natively today. <a href="/posts/go-runtime-futures-flight-recording-usdt/">Part 5</a> covers my <code>poc_usdt</code> fork, which is an experiment. The idea I&rsquo;d argue for is generic tracepoints that the standard library exposes on purpose. USDT is one way to build them, and it needs more work from me and acceptance from the Go runtime team.</p>
<p>Back to <code>handle</code>, then.</p>
<h2 id="two-probes-one-function">Two probes, one function</h2>
<p>With way one, <code>handle</code> gets an entry uprobe and three <code>RET</code> uprobes, found by decoding the function, plus a per-release table of struct offsets kept current by a daily job. With way two, it gets a named site, with the argument locations stated in the binary. Both cost a trap per hit. The difference is who does the work: a tool&rsquo;s maintainers, again for every Go release, or a runtime, once, if it accepts the idea. Neither helps with carrying trace context from an incoming request to an outgoing call. That is still unsolved.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="try-it">Try it</h2>
<p>You can repeat the listing. Save the function above as <code>main.go</code>, then build and count the exits:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>GOOS<span style="color:#f92672">=</span>linux GOARCH<span style="color:#f92672">=</span>amd64 go build -trimpath -o handle main.go
</span></span><span style="display:flex;"><span>go tool objdump -s <span style="color:#e6db74">&#39;^main.handle$&#39;</span> handle | grep -c RET
</span></span></code></pre></td></tr></table>
</div>
</div><p>With Go 1.27.1 I get <code>3</code>. Addresses may differ on another toolchain.</p>
<p>One function, two ways to watch it. One asks the tool to learn every habit Go has. The other asks Go to introduce itself. 🔭</p>
]]></content:encoded></item><item><title>OBI: eBPF auto-instrumentation for Go in production</title><link>https://kakkoyun.me/posts/obi-ebpf-auto-instrumentation-go/</link><pubDate>Thu, 09 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/obi-ebpf-auto-instrumentation-go/</guid><description>OBI (OpenTelemetry eBPF Instrumentation) instruments Go services with no code changes, inside a defined scope. Here is that scope, what it requires and what it costs.</description><content:encoded><![CDATA[<p>Take one HTTP request, <code>GET /orders/42</code>, headed for a Go service nobody is going to rebuild this quarter. We want a span for it, another for its database call, and the trace context handed on to the next service, all without touching the service. &ldquo;Zero code changes&rdquo; travels far in conference talks and vendor docs. Today we watch that one request from the kernel and find out where the phrase holds.</p>
<p>This is part 2 of 6 in the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, the written companion to my <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK 2026 talk</a>. <a href="/posts/why-go-cant-be-monkey-patched/">Part 1</a> named three places to intervene: build time, process start and the kernel. We take the kernel route now, with OBI (OpenTelemetry eBPF Instrumentation): what it sees, what it costs, where it fits. The <a href="/posts/fosdem-2026-auto-instrumenting-go/">FOSDEM 2026 post</a> has a wider first look at OBI next to the other eBPF options.</p>
<p>Disclosure: I work at Datadog and I&rsquo;m one of otelc&rsquo;s maintainers; I don&rsquo;t contribute to OBI, so read my comparisons with that in mind.</p>
<h2 id="what-obi-is">What OBI is</h2>
<p>OBI is the direct successor to Grafana Beyla. Grafana Labs donated Beyla to the CNCF OpenTelemetry project in 2025, renamed it OBI, and moved its core development to the <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/tree/v0.10.0"><code>opentelemetry-ebpf-instrumentation</code></a> repository. Beyla lives on as Grafana Labs&rsquo; distribution of OBI.</p>
<p>The release pinned for the talk (13 August 2026) is <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/releases/tag/v0.10.0">v0.10.0</a> (2026-06-30), still in Development status. The project itself notes that breaking changes between minor releases are expected while it stays at <code>v0</code>, which is semver for &ldquo;mind the gap&rdquo;.</p>
<p>How does OBI get a look at our request without being invited?</p>
<h2 id="how-it-works">How it works</h2>
<p>OBI places eBPF uprobes (probes on user-space binaries) in the target binaries and kprobes (the same for kernel code) in the kernel, and the kernel JIT-compiles those eBPF programs to the host architecture (x86-64 or ARM64). For Go, OBI hooks specific library functions, not generic network traffic, which is what gives it library-level span context rather than just raw packets. A probe already sits on the <code>net/http</code> code that will serve <code>GET /orders/42</code>. A uprobe on <code>runtime.newproc1</code> records which goroutine started which, and OBI walks up to six parents to find the request.</p>
<p>For Go HTTP/gRPC RED metrics (rate, errors, duration), OBI attaches without source changes, recompilation, restarts, or an in-process agent. You deploy it as a DaemonSet (or a sidecar, or a host process), and spans and metrics start flowing to your OTel collector. Our service never gets a say, which is rather the point.</p>
<p>Being watched is one thing. What does the watcher write down?</p>
<h2 id="what-it-actually-instruments-in-go">What it actually instruments in Go</h2>
<p>For Go, OBI documents 13 library-level baselines as of v0.10.0. The list includes <code>net/http</code>, <code>golang.org/x/net/http2</code>, <code>gorilla/mux</code>, <code>gin-gonic/gin</code>, <code>google.golang.org/grpc</code>, <code>net/rpc/jsonrpc</code>, <code>database/sql</code> (with the <code>go-sql-driver/mysql</code> and <code>lib/pq</code> drivers), <code>redis/go-redis/v9</code>, Kafka (<code>segmentio/kafka-go</code> and <code>IBM/sarama</code>), and <code>go.mongodb.org/mongo-driver</code> v1 and v2. Full version constraints are in the <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/SUPPORT_MATRIX.md"><code>SUPPORT_MATRIX.md</code></a> file in the v0.10.0 tag.</p>
<p>Say our service answers with <code>net/http</code>, queries MySQL through <code>database/sql</code>, and calls the next hop over gRPC. All three are on that list. Here is what &ldquo;zero code changes&rdquo; buys at each step:</p>
<table>
	<thead>
			<tr>
					<th>Step of the request</th>
					<th>Zero code changes?</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>The request arrives: HTTP RED metrics</td>
					<td>Yes</td>
			</tr>
			<tr>
					<td>The handler queries the database: a library-level span</td>
					<td>Yes</td>
			</tr>
			<tr>
					<td>That span carries the SQL statement text</td>
					<td>Yes, opt-in via <code>db.query.text</code>; bound parameters are not captured</td>
			</tr>
			<tr>
					<td>The gRPC call to the next service carries the trace context (for supported protocols)</td>
					<td>Yes, once enabled (off by default, needs extra privileges)</td>
			</tr>
			<tr>
					<td>A span for &ldquo;checkout&rdquo;, or an attribute like the customer&rsquo;s plan</td>
					<td>No; requires SDK or compile-time tool</td>
			</tr>
	</tbody>
</table>
<p>The last row is the library-bound visibility limit of kernel-level instrumentation. The kernel sees function arguments and return values at library boundaries, and can&rsquo;t synthesize business context that isn&rsquo;t there. The Limitations section of the <a href="https://opentelemetry.io/docs/zero-code/obi/">official docs</a> says so plainly:</p>
<blockquote>
<p>&ldquo;Use language agents or manual instrumentation when you need custom spans, application-specific attributes, business events, or other in-process telemetry that eBPF-based instrumentation cannot derive automatically.&rdquo;</p>
</blockquote>
<p>For business context you still need manual OTel SDK usage or a compile-time tool like <a href="/posts/otelc-compile-time-go-traces/">otelc</a>. The view is good. What does it charge for the ticket?</p>
<h2 id="what-it-requires">What it requires</h2>
<p>The &ldquo;zero changes to your application&rdquo; claim moves the bill from the app team to the node. (The docs pages I link are unversioned; I read them on 2026-10-02.)</p>
<p>The kernel comes first: the <a href="https://opentelemetry.io/docs/zero-code/obi/">docs</a> ask for Linux 5.8+, with a RHEL exception of 4.18+ for RHEL 8-family distros with the required eBPF backports. It must also expose BTF (BPF Type Format) information. Most current mainstream distros ship with it; check that <code>/sys/kernel/btf/vmlinux</code> exists. On macOS, eBPF is a Linux party, so bring a VM; <a href="/posts/go-instrumentation-see-it-run/">See it run: OBI and the eBPF profiler without Kubernetes</a> does exactly that.</p>
<p>Then the capabilities. OBI has no single capability set, because the required access grows with the features you turn on. For plain application observability it needs six Linux capabilities when it runs unprivileged: <code>CAP_BPF</code>, <code>CAP_SYS_PTRACE</code>, <code>CAP_NET_RAW</code>, <code>CAP_CHECKPOINT_RESTORE</code>, <code>CAP_DAC_READ_SEARCH</code> and <code>CAP_PERFMON</code> (plus <code>CAP_SYS_RESOURCE</code> on kernels before 5.11, which the 5.8 floor still allows). The <a href="https://opentelemetry.io/docs/zero-code/obi/security/">security page</a> explains why each one is needed and has the per-feature breakdown.</p>
<p>More features need more capabilities. <code>CAP_NET_ADMIN</code> is added when context propagation is enabled, which is off by default: a socket-level eBPF program then adds a <code>traceparent</code> header to outgoing HTTP. <code>CAP_SYS_ADMIN</code> is required for Go library-level propagation, where a uprobe writes the header into Go&rsquo;s own request buffer with <code>bpf_probe_write_user</code>, and it stands in for <code>CAP_PERFMON</code> when <code>kernel.perf_event_paranoid</code> is set high (the AKS and EKS defaults need it). Running with <code>privileged: true</code> in Kubernetes works, but it&rsquo;s the sledgehammer option. Security teams will scrutinize this list, and that review is the operational cost to factor in.</p>
<p>On <a href="https://opentelemetry.io/docs/zero-code/obi/setup/kubernetes/">Kubernetes</a>, the preferred model for covering many services is a DaemonSet: one OBI pod per node, with <code>hostPID: true</code> so it can see every process and no changes to application pods. The sidecar model (one OBI container per pod, with <code>shareProcessNamespace: true</code> and <code>privileged: true</code>) gives finer control at the cost of efficiency at scale. I would start with the DaemonSet: deploy once, and every service on every node, ours included, gets baseline observability.</p>
<p>What does it cost while running? I have no measurements of OBI v0.10.0 itself; the <a href="/posts/fosdem-2026-auto-instrumenting-go/">FOSDEM post</a>&rsquo;s benchmark is one demo workload. Every hit on an attached uprobe traps into the kernel, so a hot function pays per call (<a href="/posts/go-uprobe-vs-usdt/">What a uprobe costs, and what USDT buys</a> takes that cost apart). The kernel&rsquo;s own BPF selftests recorded about 313 ns per hit on an uprobe over a NOP (3.190 M/s, one x86_64 run, CPU not stated, <a href="https://github.com/torvalds/linux/commit/0c4fc6bd61054a9378bce149b3758f9b6e8fb5ab">commit 0c4fc6bd6105</a>); a <a href="https://arxiv.org/pdf/2311.07923">2023 bpftime paper</a> measured 3,224 ns on older, unstated hardware. The two sources differ tenfold, and I haven&rsquo;t measured OBI&rsquo;s. I&rsquo;m measuring it, with a reproduction kit, for a follow-up post. The rest is upkeep. OBI looks up Go struct-field offsets by Go and library version (<code>offsets.json</code>), and Go 1.26 removing <code>pcHeader.textStart</code> broke its symbol resolution in PIE and cgo binaries until <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/pull/1851">PR #1851</a>.</p>
<p>Is that enough, or just a start?</p>
<h2 id="where-it-fits">Where it fits</h2>
<p>Back to <code>GET /orders/42</code>. With nobody touching the service, we got RED metrics for the request, library-level spans for its database and gRPC calls, and, with the right opt-ins and privileges, SQL text and trace context across services. We did not get what the on-call engineer asks first: which customer, which cart, which business step.</p>
<p>That split shows where OBI fits: baseline observability across services you don&rsquo;t own or can&rsquo;t rebuild right now. Ship the DaemonSet, then decide which services deserve more.</p>
<p>OBI and compile-time tools are complementary. OBI gives breadth without touching CI pipelines. Compile-time tools like otelc give depth (custom spans, business logic, stdlib instrumentation) at the price of a rebuild and, for otelc, Go 1.25+. <a href="/posts/zero-touch-go-observability-agent-actionable/">Part 6</a> turns the choice into a decision rule.</p>
<p>The caveat worth repeating: the feature set is real, since OBI is the same codebase as Beyla, which reached 1.0 in November 2023. The API stability guarantees, though, are explicitly not there yet, and the <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/releases/tag/v0.10.0">v0.10.0 release notes</a> carry a long security-hardening section. Budget for minor release changes, and canary before a fleet-wide rollout.</p>
<p>&ldquo;Zero code changes&rdquo;, then? For our request: yes, inside a defined scope, with a defined price list.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/otelc-compile-time-go-traces/">Part 3, otelc: zero-touch Go traces at compile time</a>, moves the intervention to build time. The service gets rebuilt; in exchange, we get depth. Same promise, new vantage point. 👀</p>
]]></content:encoded></item><item><title>The Go Benchmark That Measured Nothing: Compiler Honesty in testing.B</title><link>https://kakkoyun.me/posts/go-benchmarks-lying-compiler-honesty/</link><pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-lying-compiler-honesty/</guid><description>Dead-code elimination, constant folding and inlining can silently empty a Go benchmark loop. Here is how to catch each one, and how testing.B.Loop removes most of them.</description><content:encoded><![CDATA[<p>We&rsquo;re going to chase down one benchmark result that looks suspiciously good. The function under test calls <code>make([]byte, 64)</code> every time it runs, and the benchmark reports <code>0.34 ns/op</code>, <code>0 B/op</code>, <code>0 allocs/op</code>. That is almost three billion heap allocations per second on a laptop, and the allocator counted none of them. (It comes from a real run, more on that below.) Keep it in your pocket; we&rsquo;ll follow it the whole way.</p>
<p>Where did the allocations go? They were never there. The compiler looked at the benchmark loop, noticed that the return value was unused, and, once the call was inlined, could prove that the body had no observable effect. It removed the body. The loop still ran <code>b.N</code> times. It just ran empty. The fastest code is the code that never runs, and the least useful to benchmark. This is not a compiler bug: the optimizer did its job, and nobody told it we wanted to watch. The transformations that make production code fast make microbenchmarks adversarial.</p>
<p>This is part 1 of 5 in the <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a> series, the written companion to the <a href="/talks/why-your-go-benchmarks-are-lying/">GopherCon UK 2026 talk</a> of the same name. The series asks three questions of every benchmark number: did the compiler let it measure real work, is the sample stable, and is the difference large relative to the noise? This part answers the first. We&rsquo;ll catch the compiler emptying a loop in two ways, find the one column of <code>go test</code> output it cannot fool, trip over the benchmark timer ourselves, and finish with <code>testing.B.Loop</code>, the standard library&rsquo;s fix for most of it. Part 2 takes on the sample, parts 3 and 4 the machine, and part 5 turns the three questions into a checklist. New to <code>testing.B</code>? The primer <a href="/posts/go-benchmarks-quickly/">Benchmarking Go, quickly</a> covers writing and reading one first. A benchmark bot once flagged a pull request of mine as 6 to 9% slower; hold that thought for part 5.</p>
<p>The prerequisite is <a href="/posts/fosdem-2026-measuring-software-performance/">Measuring Software Performance: Why Your Benchmarks Are Probably Lying</a>, which covers why hardware noise and statistical method matter in any language. Here we deal with what the Go compiler does before the benchmark ever reaches a CPU. All the code comes from the <a href="https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying/demo">demo repository</a> (<code>talks/go-benchmarks-lying/demo/</code>, at commit <code>fdce88c</code>).</p>
<h2 id="the-compiler-is-not-a-neutral-observer">The compiler is not a neutral observer</h2>
<p>Our suspect is dead-code elimination, with inlining as its accomplice. Its cousin, constant folding, does the same damage by another route. We start with the suspect.</p>
<h3 id="dead-code-elimination">Dead-code elimination</h3>
<p>Dead-code elimination (DCE) follows a simple rule: if a computation produces a value that nothing ever reads, the compiler can remove it. In a benchmark we typically call a function and throw away the return value. Inlining is the accomplice: the compiler replaces a call to a small function with a copy of its body, nearly always a win in production. In a benchmark, once the body sits inside the loop, the compiler can see the result is unused and delete the lot (a call it cannot inline is kept).</p>
<p>Here is the pair behind our suspect, excerpted from <code>dce_bench_test.go</code> (long comments left out):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">makeBuffer</span>(<span style="color:#a6e22e">n</span> <span style="color:#66d9ef">int</span>) []<span style="color:#66d9ef">byte</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> make([]<span style="color:#66d9ef">byte</span>, <span style="color:#a6e22e">n</span>) <span style="color:#75715e">// heap-escaping allocation</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkMakeBuffer_DCE</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">N</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">makeBuffer</span>(<span style="color:#ae81ff">64</span>) <span style="color:#75715e">// result discarded → compiler removes the call</span>
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>makeBuffer</code> really does put its buffer on the heap when called on its own. The benchmark discards the result, and because <code>makeBuffer</code> is small enough to inline, the compiler sees a <code>make</code> nobody reads and deletes it, allocation included.</p>
<p>This reproduces on Go 1.25 and later (I tested 1.25.0, 1.26.0 and 1.27.1). On Go 1.24 the inlined <code>make</code> still escapes to the heap, so it allocates even with the result discarded. And the trick needs inlining: put <code>//go:noinline</code> on <code>makeBuffer</code> and the same benchmark reports <code>64 B/op, 1 allocs/op</code>.</p>
<p>The fix is the <strong>sink pattern</strong>: assign the result to a local variable inside the loop, then write that local to a package-level variable after the loop. The compiler treats a store to a package-level variable as observable, so it must keep the computation behind it. Same file again:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// sink is the package-level variable that defeats DCE.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">sink</span> []<span style="color:#66d9ef">byte</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkMakeBuffer_Correct</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">s</span> []<span style="color:#66d9ef">byte</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">N</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">s</span> = <span style="color:#a6e22e">makeBuffer</span>(<span style="color:#ae81ff">64</span>)
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">sink</span> = <span style="color:#a6e22e">s</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>The two-variable idiom matters: writing to <code>sink</code> inside the loop would add a global write per iteration, which is measurable, while one write after the loop costs almost nothing and still keeps the whole chain alive. Every iteration still runs: with the last buffer stored in <code>sink</code>, the <code>make</code> escapes, so each one allocates. <code>runtime.KeepAlive</code> is callable too, but forces no escape.</p>
<p>A sink keeps a result alive, but it cannot help if the compiler already knows the answer before the program runs.</p>
<h3 id="constant-folding">Constant folding</h3>
<p>If every input to an expression is a compile-time constant, the compiler evaluates the expression at compile time and replaces it with a literal. The benchmark then loads a constant, and the compiler already did the work. The <code>bits.OnesCount</code> pair in the same file shows it: the first function feeds in a constant, the second a package-level variable, which the compiler cannot constant-propagate because its value can change at run time:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">sinkInt</span> <span style="color:#66d9ef">int</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkOnesCount_ConstantFolded</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">s</span> <span style="color:#66d9ef">int</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">N</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">s</span> = <span style="color:#a6e22e">bits</span>.<span style="color:#a6e22e">OnesCount</span>(<span style="color:#ae81ff">0b10110</span>) <span style="color:#75715e">// constant → evaluated at compile time</span>
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">sinkInt</span> = <span style="color:#a6e22e">s</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// onesInput breaks the constant chain — compiler cannot prove this is 0b10110.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">onesInput</span> <span style="color:#66d9ef">uint</span> = <span style="color:#ae81ff">0b10110</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkOnesCount_Correct</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">s</span> <span style="color:#66d9ef">int</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">N</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">s</span> = <span style="color:#a6e22e">bits</span>.<span style="color:#a6e22e">OnesCount</span>(<span style="color:#a6e22e">onesInput</span>)
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">sinkInt</span> = <span style="color:#a6e22e">s</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>On Apple Silicon both versions report similar timings, because <code>bits.OnesCount</code> compiles to a four-instruction NEON sequence that runs near the timer floor regardless. The timing can&rsquo;t tell them apart, so we ask the assembly instead (<code>make asm-dce</code> runs this). <code>XXX</code> matches nothing, so no benchmark runs, and plain <code>go build</code> would print nothing because it never compiles <code>_test.go</code> files:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go test -gcflags<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;-S&#39;</span> -run XXX -bench XXX . 2&gt;&amp;<span style="color:#ae81ff">1</span> | grep -A14 <span style="color:#e6db74">&#39;OnesCount_ConstantFolded(SB)&#39;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The constant-folded version shows <code>MOVD $3, Rxx</code>: the compiler substituted the literal 3 for the entire <code>bits.OnesCount</code> call. The correct version shows <code>VCNT</code> and <code>VUADDLV</code>, an actual popcount sequence (arm64 names; on amd64, widen the grep to <code>-A30</code> and look for <code>MOVL $3</code> against <code>POPCNTQ</code>). Similar timing, entirely different code.</p>
<p>Both tricks leave <code>ns/op</code> looking perfectly believable. If the time column can&rsquo;t be trusted, what can?</p>
<h2 id="the-honest-signal">The honest signal</h2>
<p>Back to our suspect. <code>0.34 ns/op</code> sits right next to the measurement floor on Apple Silicon, around 0.25 ns, about one loop iteration per cycle. An empty loop and a blazing-fast function look identical down there. <code>allocs/op</code> has no such floor. An allocation is a discrete event: the testing framework reads <code>runtime.ReadMemStats</code> at the start and end of the run, takes the delta, and divides by <code>b.N</code>. Either a heap allocation happened or it did not.</p>
<p>Let&rsquo;s run the DCE pair side by side (<code>make bench-dce</code> does it, after a one-time <code>make tools</code> to install <code>benchstat</code>):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go test -run XXX -bench<span style="color:#f92672">=</span>BenchmarkMakeBuffer -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span> .
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>benchstat</code> summarizes the ten runs as medians. This is one run on one machine: an Apple M4 Max (darwin/arm64) under background load (load average 8 to 11), Go 1.27.1. The <code>ns/op</code> column is noisy and <code>allocs/op</code> is exact, which is the point.</p>
<table>
	<thead>
			<tr>
					<th>Benchmark</th>
					<th>ns/op</th>
					<th>B/op</th>
					<th>allocs/op</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>BenchmarkMakeBuffer_DCE</code></td>
					<td>0.3444 ± 96%</td>
					<td>0</td>
					<td><strong>0</strong></td>
			</tr>
			<tr>
					<td><code>BenchmarkMakeBuffer_Correct</code></td>
					<td>15.43 ± 29%</td>
					<td>64</td>
					<td><strong>1</strong></td>
			</tr>
	</tbody>
</table>
<p>I&rsquo;m recapturing these on a quiet machine with a reproduction kit, and I&rsquo;ll update the table when the new numbers are in.</p>
<p><code>BenchmarkMakeBuffer_DCE</code> reports zero bytes and zero allocations. Not &ldquo;almost none&rdquo;: zero. The <code>make([]byte, 64)</code> in the function body never executed, and no timer resolution or clock speed can change that column. <code>BenchmarkMakeBuffer_Correct</code> reports <code>64 B/op, 1 allocs/op</code>: the allocation happened, so that measurement is trustworthy. Mystery solved: the <code>0.34 ns/op</code> was an empty loop, and the honest cost is about 15 ns and one allocation. Remember that zero, though. It has one more trick left.</p>
<p>This is why <code>-benchmem</code> belongs in every Go benchmark invocation (<code>go test -bench=. -benchmem ./...</code>). <code>0 allocs/op</code> for a function you know calls <code>make</code> is a red flag, and a result under 1 ns/op for anything non-trivial is the strongest hint that DCE or constant folding has struck. Two caveats apply. <code>allocs/op</code> is an integer average, so a loop that hits the heap on every fourth iteration rounds down to <code>0</code>, and <code>B/op</code> gives it away. And a zero can also mean the buffer stayed on the stack, so check <code>-gcflags=-m</code>: <code>inlining call to X</code> means it was inlined; <code>can inline X</code> only means it could be. And for a function that never allocates, <code>allocs/op</code> stays silent; check the assembly instead.</p>
<p>The compiler is not the only thing that can make a benchmark measure the wrong thing. We can do it to ourselves, with the timer.</p>
<h2 id="timer-traps">Timer traps</h2>
<p>First, how <code>testing.B</code> keeps time. It runs the benchmark with a growing iteration count until one run fills <code>-benchtime</code>; <code>ns/op</code> is that run&rsquo;s duration divided by the count. Go compiles ahead of time, so there is no JIT warmup, and each <code>-count</code> line is one such mean, not a distribution.</p>
<p>The <code>testing.B</code> timer starts when the benchmark function is called, so everything that runs before the loop is measured unless we reset it.</p>
<p><code>b.ResetTimer()</code> zeros the elapsed time and the allocation counters, so we call it after one-time setup, just before the loop. It does not stop the timer: a running timer keeps running, from zero.</p>
<h3 id="per-iteration-setup-stoptimer-and-starttimer">Per-iteration setup: <code>StopTimer</code> and <code>StartTimer</code></h3>
<p>When each iteration needs its own setup, we bracket the setup with <code>b.StopTimer()</code> and <code>b.StartTimer()</code>, and the order matters. This one is from <code>timer_bench_test.go</code>, where <code>buildFixture</code>, <code>fixtureSize</code>, <code>processString</code> and <code>sinkStr</code> are helpers:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkProcess_PerIterSetup_Correct</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">s</span> <span style="color:#66d9ef">string</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">N</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">StopTimer</span>()
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">input</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">buildFixture</span>(<span style="color:#a6e22e">fixtureSize</span>)
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">StartTimer</span>() <span style="color:#75715e">// ← timer restarts; only processString is measured</span>
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">s</span> = <span style="color:#a6e22e">processString</span>(<span style="color:#a6e22e">input</span>)
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">sinkStr</span> = <span style="color:#a6e22e">s</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>The timer stops before the fixture is built and restarts before the work, so only <code>processString</code> is timed. Call <code>StartTimer</code> after the work instead, as <code>BenchmarkProcess_TimerOrder_BUG</code> in the same file does, and the timer measures fixture construction while <code>processString</code> runs with the timer off. The reported ns/op is then the cost of the wrong thing.</p>
<h3 id="the-benchmark-that-reports-nonsense">The benchmark that reports nonsense</h3>
<p>Worse: <code>StopTimer</code> with no <code>StartTimer</code> at all. The testing framework decides how long to run a benchmark by accumulating timed duration until it reaches the target time (default 1 second). If the timer is stopped and never restarted, the accumulated duration holds only the time up to the first <code>StopTimer</code>. The framework keeps growing <code>b.N</code> until it hits its cap of 1e9. For a trivial body with a single <code>StopTimer</code>, it reaches that cap in about 20 seconds and prints something like <code>0.0000007 ns/op</code>. Another number that looks suspiciously good.</p>
<p>The demo repository describes this case rather than demonstrating it. The comment in <code>timer_bench_test.go</code> reads &ldquo;Don&rsquo;t run that live.&rdquo; We found out the direct way. If one of your own benchmarks seems to hang, look for a missing <code>b.StartTimer()</code>, or a stop/start pair left at the default <code>-benchtime</code>. <code>Ctrl-C</code>, fix it, run again. (Each <code>StopTimer</code>/<code>StartTimer</code> call runs <code>runtime.ReadMemStats</code>, which stops the world, which is why the demo pins these benchmarks to <code>-benchtime=50000x</code>, a fixed count with no ramp.)</p>
<p>Empty loops, forgotten resets, timers left off: all mistakes the testing package could make impossible. Since Go 1.24, it covers most of them.</p>
<h2 id="testingbloop-in-go-124-removes-most-of-this"><code>testing.B.Loop</code> in Go 1.24 removes most of this</h2>
<p>Austin Clements proposed <code>testing.B.Loop</code> in <a href="https://github.com/golang/go/issues/61515">Go issue #61515</a> because the <code>b.N</code> pattern has failure modes that are easy to hit and hard to detect statically. It shipped in <a href="https://go.dev/doc/go1.24">Go 1.24</a>. Here is the demo&rsquo;s hashing benchmark in the new form, from <code>bloop_bench_test.go</code> (<code>payload</code> is a package-level <code>[]byte</code> fixture):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkHash_BLoop</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#75715e">// Setup: excluded from timing automatically.</span>
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">data</span> <span style="color:#f92672">:=</span> make([]<span style="color:#66d9ef">byte</span>, <span style="color:#ae81ff">1024</span>)
</span></span><span style="display:flex;"><span>	copy(<span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">payload</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">s</span> [<span style="color:#ae81ff">32</span>]<span style="color:#66d9ef">byte</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">Loop</span>() { <span style="color:#75715e">// ← each call to Loop() is one measured iteration</span>
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">s</span> = <span style="color:#a6e22e">sha256</span>.<span style="color:#a6e22e">Sum256</span>(<span style="color:#a6e22e">data</span>)
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">_</span> = <span style="color:#a6e22e">s</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>Notice what is missing: no <code>b.ResetTimer()</code>. <a href="https://pkg.go.dev/testing#B.Loop">The first call to <code>Loop</code> resets the timer and the call that returns false stops it</a>, so setup and cleanup are not measured. The benchmark function is called exactly once per <code>-count</code> value, so expensive setup does not re-execute while the framework ramps <code>b.N</code> up. And the compiler recognizes loops whose condition is syntactically <code>b.Loop()</code> and keeps the arguments, results and assigned variables of calls in the loop body alive (it wraps them in a <code>runtime.KeepAlive</code>), so inlining can no longer leave a dead body behind. Go 1.24 and 1.25 did this by <a href="https://go.dev/blog/testing-b-loop">refusing to inline into the body</a>, which could add heap allocations that production code would not have. <a href="https://go.dev/doc/go1.26">Go 1.26 keeps inlining and keeps the values alive instead</a>.</p>
<p>The DCE protection has limits. It had a bug in Go 1.26.0 to 1.26.2, where assigning a call&rsquo;s result to <code>_</code> inside the loop still let the compiler drop the body (<a href="https://github.com/golang/go/issues/77654">#77654</a>, fixed in Go 1.26.3 and 1.27). It applies only when the loop condition is written exactly as <code>b.Loop()</code>: assigning the method to a variable first (<code>loop := b.Loop; for loop()</code>) does not trigger the compiler transformation.</p>
<p><code>b.Loop</code> protects the call, not its inputs: <code>bits.OnesCount(0b10110)</code> in a <code>for b.Loop()</code> body still folds to <code>MOVL $3</code> on amd64 (Go 1.27.1), so keep a non-constant input. <code>_ = s</code> after the loop only silences &ldquo;declared and not used&rdquo;; <code>-benchmem</code> remains the check.</p>
<p>A <code>StartTimer</code> that never comes is a fatal error with <code>b.Loop</code> (&ldquo;B.Loop called with timer stopped&rdquo;), not a hang.</p>
<p>For new benchmarks, prefer <code>b.Loop</code>. Migrating an old one is mechanical: swap <code>for range b.N</code> for <code>for b.Loop()</code> and drop any <code>b.ResetTimer()</code> that only excluded setup. Code that reads <code>b.N</code> inside the loop needs a second look: <code>b.N</code> is 0 until the loop ends.</p>
<p>Now back to our number one last time. <code>0.34 ns/op</code> started as an empty loop, and the sink turned it into 15 ns and one allocation. On Go 1.26 and later, <code>for b.Loop() { makeBuffer(64) }</code> reports <code>0 B/op, 0 allocs/op</code> for the same buffer, and this time the zero is honest: the body runs, but the inlined buffer no longer escapes and lives on the stack, as it would in production code. Same digit, opposite meaning. Which <code>makeBuffer</code> number do we report? The <code>b.Loop</code> one. The sink version measures a heap escape that production code may not have, and <code>b.Loop</code> on Go 1.26 or later measures the function as the compiler would really treat it. If an allocation count surprises you, <code>-gcflags=-m</code> tells you which case you are in.</p>
<p>Enough reading. Let&rsquo;s run it.</p>
<h2 id="try-it">Try it</h2>
<p>Everything above runs from the <a href="https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying/demo">demo repository</a>. The module declares <code>go 1.26.5</code>, so Go fetches that toolchain if yours is older. The assembly step shows arm64 instructions.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>git clone https://github.com/kakkoyun/gopherconuk-26
</span></span><span style="display:flex;"><span>cd gopherconuk-26
</span></span><span style="display:flex;"><span>git checkout fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb
</span></span><span style="display:flex;"><span>cd talks/go-benchmarks-lying/demo
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span># DCE and the sink pattern: compare allocs/op
</span></span><span style="display:flex;"><span>go test -run XXX -bench=&#39;BenchmarkMakeBuffer&#39; -benchmem -count=10 .
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span># Constant folding: MOVD $3 against VCNT and VUADDLV
</span></span><span style="display:flex;"><span>go test -gcflags=&#39;-S&#39; -run XXX -bench XXX . 2&gt;&amp;1 | grep -A14 &#39;OnesCount_ConstantFolded(SB)&#39;
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span># Timer ordering <span style="color:#f92672">(</span>fixed iterations, as make bench-timer does<span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>go test -run XXX -bench=&#39;BenchmarkProcess&#39; -benchmem -count=3 -benchtime=50000x .
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>BenchmarkMakeBuffer_DCE</code> should report <code>0 allocs/op</code>, and <code>BenchmarkMakeBuffer_Correct</code> should report <code>1</code>. Expect the ns/op column to wobble with whatever else your machine is doing. That wobble is the next part of the story.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/go-benchmarks-lying-statistics/">Part 2, A Single Benchmark Number Is a Lie</a>, assumes the benchmark does real work and asks what one <code>ns/op</code> number is worth: how to read the distribution behind it, how to compare commits with <code>benchstat</code>, and when a difference is real. The compiler is out of the way. The statistics are not. 📊</p>
]]></content:encoded></item><item><title>Why Go can't be monkey-patched (and what people do about it)</title><link>https://kakkoyun.me/posts/why-go-cant-be-monkey-patched/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/why-go-cant-be-monkey-patched/</guid><description>Go has no bytecode, no classloader and no runtime hook. That is why zero-touch instrumentation happens at build time, at process start or in the kernel.</description><content:encoded><![CDATA[<p>Start a Java service with one extra flag and it gets traced, with no source change and no rebuild:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>java -javaagent:agent.jar -jar app.jar
</span></span></code></pre></td></tr></table>
</div>
</div><p>The JVM offers each class&rsquo;s bytes to the agent&rsquo;s <a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html"><code>ClassFileTransformer</code></a> before it defines the class, so the agent can rewrite a method on its way in. Python lets you swap a method at runtime, which is the monkey-patching of the title. That flag is Java&rsquo;s agent slot, and Python doesn&rsquo;t even need one. Go has no such slot, and that follows from how Go was designed.</p>
<p>This is part 1 of 6 in the <a href="/series/how-to-instrument-go-without-changing-a-single-line-of-code/">How to Instrument Go Without Changing a Single Line of Code</a> series, the written companion to my <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK 2026 talk</a>. We&rsquo;ll hunt for that missing slot the whole way. First we&rsquo;ll see why it isn&rsquo;t there, then visit the three places people stand instead (build time, process start and the kernel) and watch how far each bends to fake it. Parts 2 to 4 take the tools one at a time, part 5 asks what Go itself might add, and part 6 ends with a runbook an agent can follow.</p>
<p>Disclosure: I work at Datadog, which maintains Orchestrion and dd-trace-go, and I&rsquo;m one of otelc&rsquo;s maintainers, so read my comparisons with that in mind.</p>
<h2 id="the-structural-problem">The structural problem</h2>
<p>Where would an agent hook in? Go compiles to native machine code, and <code>go build</code> gives you a self-contained binary: no bytecode, no classloader, nothing between your code and the CPU. With no bytecode there is nothing to rewrite at load time, and with no classloader there is no moment between &ldquo;file on disk&rdquo; and &ldquo;code executing&rdquo; to grab. <code>LD_PRELOAD</code>, the classic way to push a shared library into any process, needs the dynamic linker, and Go links pure-Go programs statically by default (<a href="#process-start-the-injector">more below</a>). And Go has no general runtime hook API. The internal <code>exithook</code> covers only program termination.</p>
<p>What about <code>go:linkname</code>, which lets a package reach unexported runtime symbols? Tracers use it, but listen to how the runtime talks about it. Above <code>gopark</code> in <a href="https://github.com/golang/go/blob/go1.26.0/src/runtime/proc.go#L435-L442"><code>proc.go</code></a>:</p>
<blockquote>
<p>&ldquo;gopark should be an internal detail, but widely used packages access it using linkname. Notable members of the hall of shame include…&rdquo;</p>
</blockquote>
<p>The Go team added those annotations reluctantly. The comment says &ldquo;Do not remove or change the type signature&rdquo; and points at <a href="https://github.com/golang/go/issues/67401">golang/go#67401</a>, which locked down future linkname uses. That is an accommodation, not a hook. A hall of shame is an odd place to look for a feature.</p>
<p>No slot, then. If nobody will hand us one, where do people stand instead?</p>
<h2 id="three-places-to-intervene">Three places to intervene</h2>
<p>At build time, we intercept the build instead of the program. Go&rsquo;s <code>-toolexec</code> flag puts a wrapper in front of every <code>go tool compile</code> call. The wrapper parses each source file into a syntax tree, injects instrumentation, and hands the modified source to the real compiler, which thinks you wrote it yourself. The wrapper itself adds no runtime overhead and needs no kernel privilege, but you have to rebuild. I wrote a <a href="https://internals-for-interns.com/posts/hooking-into-the-go-toolchain/">guest post</a> that builds this mechanism up from a stopwatch.</p>
<p>At process start, we load a library before <code>main</code>, with no rebuild. That&rsquo;s the closest cousin of <code>-javaagent</code>, and for Go the hardest.</p>
<p>In the kernel, Linux&rsquo;s eBPF subsystem can attach probes to function entry and exit points in a running binary without modifying it. A privileged agent on the node attaches and the Go binary never knows. The price is visibility: you see function boundaries (arguments, return values, call counts), and each tool brings its own kernel and privilege requirements. <a href="/posts/obi-ebpf-auto-instrumentation-go/#what-it-requires">Part 2</a> has OBI&rsquo;s.</p>
<p>None of them wins outright, so the series treats them as layers. The middle one is the agent slot in its purest form, so we try it first.</p>
<h2 id="process-start-the-injector">Process start: the injector</h2>
<p>An injector is a shared library that the dynamic loader maps in before <code>main</code>, so it acts before any of your code does. On a host, Datadog&rsquo;s <a href="https://docs.datadoghq.com/tracing/trace_collection/single-step-apm/linux/">Single Step Instrumentation</a> arms itself through <code>/etc/ld.so.preload</code>, and so does the <a href="https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/README.md">OpenTelemetry injector</a>. The OTel one is a smaller trick than it sounds: it sets variables like <code>JAVA_TOOL_OPTIONS</code> and <code>NODE_OPTIONS</code>, and the runtime loads its own agent. It types our <code>-javaagent</code> flag for us. It covers Java, Node.js, .NET, Ruby, and Python (disabled by default), and its <a href="https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/DESIGN.md">design notes</a> say statically linked executables &ldquo;will not be affected by <code>LD_PRELOAD</code> at all.&rdquo; Go has no agent slot for such a variable to point at.</p>
<p>Why can&rsquo;t Go take <code>LD_PRELOAD</code>? A pure-Go build is a static ELF with no interpreter (the dynamic loader a binary names in its headers), so the loader never runs. With cgo disabled there is no escape hatch, because <code>-linkmode=external</code>, which hands the final link to the system C linker, needs cgo. (cgo binaries, including those using the default <code>net</code> resolver on Linux, are dynamically linked and take <code>LD_PRELOAD</code> on glibc.) <a href="https://github.com/golang/go/issues/28909">golang/go#28909</a> is still open as of October 2026, and Ian Lance Taylor was <a href="https://github.com/golang/go/issues/28909#issuecomment-441823877">&ldquo;perfectly comfortable in saying that anybody who wants to use <code>LD_PRELOAD</code> must force the use of external linking.&rdquo;</a> External linking is supported, but forcing it just for <code>LD_PRELOAD</code> is a workaround with sharp edges.</p>
<p>To check yours, run <code>file ./myapp</code>. For a small <code>net/http</code> program on Go 1.27.1 (linux/arm64), <code>CGO_ENABLED=0</code> printed <code>statically linked</code> and <code>CGO_ENABLED=1</code> printed <code>dynamically linked, interpreter /lib/ld-linux-aarch64.so.1</code>. Even the dynamic one offers no slot: a preloaded library interposes on dynamic symbols such as libc&rsquo;s <code>read</code>, and <code>readelf --dyn-syms</code> shows no <code>net/http</code> function to wrap.</p>
<p>Datadog has a Go injector hook in development, which I&rsquo;ll show on a slide at GopherCon UK. It isn&rsquo;t released, so I&rsquo;m not going into how it works yet.</p>
<p>For a pure-Go binary the slot stays empty. That leaves build time, and even that route has to fake something Java ships with: a place to keep the current request&rsquo;s context.</p>
<h2 id="the-goroutine-problem">The goroutine problem</h2>
<p>Distributed tracing is where it bites. A request often hops goroutines (Go&rsquo;s lightweight threads), and its trace context has to hop with it. Go has no goroutine-local storage, so after <code>go func() { ... }()</code> there is no built-in way to inherit arbitrary values from the parent (pprof labels aside, which are string pairs for profiles). The usual answer is to pass a <code>context.Context</code> down by hand, which is the code change this series is trying to avoid. Code injected into a library function often has no <code>ctx</code> in scope anyway, so the tracer needs somewhere else to keep the context.</p>
<p>Orchestrion, Datadog&rsquo;s <code>-toolexec</code> tool from <a href="/posts/otelc-compile-time-go-traces/">part 3</a>, answers with something striking: give the tracer somewhere to keep it. dd-trace-go <a href="https://github.com/DataDog/dd-trace-go/blob/v2.0.0/internal/orchestrion/gls.orchestrion.yml">v2.0.0 ships an aspect</a> that injects a synthetic field into the runtime&rsquo;s internal <code>g</code> struct, the one that represents each goroutine. The start of it, abridged:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">aspects</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">id</span>: <span style="color:#ae81ff">__dd_gls_v2</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">join-point</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">struct-definition</span>: <span style="color:#ae81ff">runtime.g</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">advice</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">add-struct-field</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">name</span>: <span style="color:#ae81ff">__dd_gls_v2</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">type</span>: <span style="color:#ae81ff">any</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The join point says where to cut and the advice says what to add: a field <code>__dd_gls_v2 any</code> on <code>runtime.g</code>. Orchestrion rewrites the <code>runtime</code> source before the compiler sees it, so every goroutine gets one. The same aspect injects a getter and a setter as <code>go:linkname</code>d function variables. Here is that template, re-indented the way gofmt would print it:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">//go:linkname __dd_orchestrion_gls_get __dd_orchestrion_gls_get.V2</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">__dd_orchestrion_gls_get</span> = <span style="color:#66d9ef">func</span>() <span style="color:#66d9ef">any</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> <span style="color:#a6e22e">getg</span>().<span style="color:#a6e22e">m</span>.<span style="color:#a6e22e">curg</span>.<span style="color:#a6e22e">__dd_gls_v2</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">//go:linkname __dd_orchestrion_gls_set __dd_orchestrion_gls_set.V2</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">__dd_orchestrion_gls_set</span> = <span style="color:#66d9ef">func</span>(<span style="color:#a6e22e">val</span> <span style="color:#66d9ef">any</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">getg</span>().<span style="color:#a6e22e">m</span>.<span style="color:#a6e22e">curg</span>.<span style="color:#a6e22e">__dd_gls_v2</span> = <span style="color:#a6e22e">val</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>getg()</code> asks the runtime for the current goroutine, and <code>.m.curg</code> goes through the <code>m</code> (the OS thread running it) to make sure it&rsquo;s yours and not a system one, per the runtime&rsquo;s <a href="https://github.com/golang/go/blob/go1.26.0/src/runtime/HACKING.md"><code>HACKING.md</code></a>. A second aspect sets the field to nil at the top of <code>goexit1</code>, so finished goroutines don&rsquo;t leak their context.</p>
<p>The field doesn&rsquo;t follow a <code>go</code> statement by itself: dd-trace-go still carries a <a href="https://github.com/DataDog/dd-trace-go/blob/v2.0.0/internal/orchestrion/context_stack.go#L10"><code>TODO: handle cross-goroutine context values</code></a> in its context stack. <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.1/instrumentation/runtime/otelc.yaml">otelc goes further</a>. It adds its own fields to <code>g</code> (<code>otel_trace_context</code> and <code>otel_baggage_container</code>) and copies them in <code>newproc1</code> when a goroutine starts, so trace context and baggage follow <code>go</code> statements. That is still not the request&rsquo;s <code>context.Context</code>: its values, deadline and cancellation stay behind, and carrying trace context from an incoming connection to an outgoing one is unsolved; <a href="/posts/go-context-across-goroutines/">Context across goroutines and connections</a> shows where it breaks in a small program. <a href="/posts/go-runtime-futures-flight-recording-usdt/#observing-isnt-propagating">Part 5</a> comes back to it.</p>
<p>Back to the agent slot. Other runtimes have a designed hook point for this: thread-local storage in Java, <code>contextvars</code> in Python, <code>AsyncLocalStorage</code> in Node.js. Go&rsquo;s answer is to patch the runtime&rsquo;s struct layout at build time and hope the runtime keeps allowing it. Impressive engineering, and a fair measure of how far you bend to fake what Java ships with.</p>
<p>Who is standing on those three spots?</p>
<h2 id="the-tools-in-this-series">The tools in this series</h2>
<p>Four tools. OBI (OpenTelemetry eBPF Instrumentation, which started life as Grafana Beyla) is the kernel path with no rebuild: <a href="/posts/obi-ebpf-auto-instrumentation-go/">part 2</a>. otelc (OpenTelemetry Go compile-time instrumentation) takes the <code>-toolexec</code> path and needs Go 1.25 or newer, and Orchestrion uses the same mechanism with the goroutine hack above: <a href="/posts/otelc-compile-time-go-traces/">part 3</a>. The OpenTelemetry eBPF profiler is the kernel path for profiles: <a href="/posts/continuous-profiling-go-without-code-changes/">part 4</a>.</p>
<p>None of them is clean. They&rsquo;re workarounds for a runtime built for simplicity, not observability, but they work. The kernel route needs no rebuild, so that&rsquo;s where we go next.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="up-next">Up next</h2>
<p><a href="/posts/obi-ebpf-auto-instrumentation-go/">Part 2, OBI: eBPF auto-instrumentation for Go in production</a>, takes the kernel route: what OBI covers, what it needs, and what it costs. Java gets a flag. Go gets a kernel probe and some paperwork.</p>
]]></content:encoded></item><item><title>Benchmarking Go, quickly</title><link>https://kakkoyun.me/posts/go-benchmarks-quickly/</link><pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/go-benchmarks-quickly/</guid><description>A primer before the Go benchmarks series: write one small benchmark, run it, and read every column, flag and compiler hint the next five posts take for granted.</description><content:encoded><![CDATA[<p>We&rsquo;re going to write one small benchmark, run it, and read every word of what comes back. By the end you&rsquo;ll know where <code>ns/op</code> comes from, what <code>-count</code> and <code>-benchtime</code> change, what the <code>-16</code> is, and how to ask the compiler and the profiler what the benchmark really did. Here is the line we&rsquo;ll be decoding:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>BenchmarkMakeBuffer-16	99632604	        18.53 ns/op	      64 B/op	       1 allocs/op
</span></span></code></pre></td></tr></table>
</div>
</div><p>This is a primer before <a href="/posts/go-benchmarks-lying-compiler-honesty/">part 1</a> of <a href="/series/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a>, the written companion to my GopherCon UK talk, <a href="/talks/why-your-go-benchmarks-are-lying/">Why Your Go Benchmarks Are Lying</a>. The series argues about numbers like the one above, so it helps to know how they are made first. If you have run <code>go test -bench</code> before, skim.</p>
<h2 id="write-it-run-it">Write it, run it</h2>
<p>Our benchmark allocates a 64-byte buffer and keeps it. It&rsquo;s the one the series uses as its running suspect, and it lives in a scratch module (<code>go mod init tiny</code>, one file, <code>tiny_test.go</code>):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">tiny</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> <span style="color:#e6db74">&#34;testing&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">makeBuffer</span>(<span style="color:#a6e22e">n</span> <span style="color:#66d9ef">int</span>) []<span style="color:#66d9ef">byte</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> make([]<span style="color:#66d9ef">byte</span>, <span style="color:#a6e22e">n</span>)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">sink</span> []<span style="color:#66d9ef">byte</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkMakeBuffer</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">s</span> []<span style="color:#66d9ef">byte</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">N</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">s</span> = <span style="color:#a6e22e">makeBuffer</span>(<span style="color:#ae81ff">64</span>)
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#a6e22e">sink</span> = <span style="color:#a6e22e">s</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>A benchmark is a function named <code>BenchmarkXxx</code> that takes a <code>*testing.B</code> and does its work <code>b.N</code> times. The package-level <code>sink</code> is there so the compiler can&rsquo;t throw the work away; <a href="/posts/go-benchmarks-lying-compiler-honesty/">part 1</a> is about what happens without it. Let&rsquo;s run it:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -run XXX -bench . -benchmem
</span></span><span style="display:flex;"><span>goos: darwin
</span></span><span style="display:flex;"><span>goarch: arm64
</span></span><span style="display:flex;"><span>pkg: tiny
</span></span><span style="display:flex;"><span>cpu: Apple M4 Max
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-16	99632604	        18.53 ns/op	      64 B/op	       1 allocs/op
</span></span><span style="display:flex;"><span>PASS
</span></span><span style="display:flex;"><span>ok  	tiny	2.105s
</span></span></code></pre></td></tr></table>
</div>
</div><p>Every number in this post is from one run on one machine: an Apple M4 Max, Go 1.27.1, with a load average near 28 when I checked. A busy laptop is a fine teacher here, as you&rsquo;ll see. I&rsquo;m recapturing these outputs on a quiet machine with a reproduction kit, and I&rsquo;ll update them when the new numbers are in.</p>
<p>Now the flags. <a href="https://pkg.go.dev/cmd/go#hdr-Testing_flags"><code>-bench .</code></a> selects benchmarks by regular expression, and by default none run. <code>-run XXX</code> does the same for tests; nothing is named <code>XXX</code>, so no test runs and the timing isn&rsquo;t muddied by them. <code>-benchmem</code> adds the last two columns: bytes and heap allocations per iteration. The first number is how many times the loop ran, and the second, <code>ns/op</code>, is the time per iteration. We&rsquo;ll start with the first.</p>
<h2 id="where-does-99632604-come-from">Where does 99,632,604 come from?</h2>
<p>Nobody chose it. The testing package <a href="https://github.com/golang/go/blob/go1.27.1/src/testing/benchmark.go#L304-L326">runs the function once with <code>b.N = 1</code>, then predicts how many iterations would fill the benchmark time, adds 20% headroom, grows by at most 100× per step and stops at a billion</a>. To watch it happen, I temporarily added a <code>fmt.Fprintln(os.Stderr, &quot;b.N =&quot;, b.N)</code> to a copy of the benchmark:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -run XXX -bench Ramp 2&gt;&amp;<span style="color:#ae81ff">1</span> | grep -E <span style="color:#e6db74">&#39;b\.N|ns/op&#39;</span>
</span></span><span style="display:flex;"><span>b.N = 1
</span></span><span style="display:flex;"><span>BenchmarkRamp-16	b.N = 100
</span></span><span style="display:flex;"><span>b.N = 10000
</span></span><span style="display:flex;"><span>b.N = 1000000
</span></span><span style="display:flex;"><span>b.N = 28893382
</span></span><span style="display:flex;"><span>b.N = 47900115
</span></span><span style="display:flex;"><span>47900115	        22.76 ns/op
</span></span></code></pre></td></tr></table>
</div>
</div><p>The function ran six times, and the printed line comes from the last run alone. The result is <a href="https://github.com/golang/go/blob/go1.27.1/src/testing/benchmark.go#L555">its total time divided by <code>b.N</code></a>, which is why <code>ns/op</code> is a mean. The ramp isn&rsquo;t a fixed ladder: on the earlier run the last <code>b.N</code> was 99,632,604. The target is time, not a count, and <code>-benchtime</code> sets it. The default is <code>1s</code>; a value like <code>100x</code> means a fixed number of iterations.</p>
<p>Every <code>-count</code> repetition is its own ramp and its own mean:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -run XXX -bench . -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-16	96041296	        23.08 ns/op	      64 B/op	       1 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-16	61289114	        23.37 ns/op	      64 B/op	       1 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-16	100000000	        17.26 ns/op	      64 B/op	       1 allocs/op
</span></span></code></pre></td></tr></table>
</div>
</div><p>Same binary, same code, and the slowest sample is about 35% slower than the fastest. Each line is one mean, so each is one sample. <a href="/posts/go-benchmarks-lying-statistics/">Part 2</a> is about what to do with a handful of them.</p>
<p>Go is compiled ahead of time, so there is no JIT warmup for the ramp to wait out. One iteration still isn&rsquo;t representative. With <code>-benchtime=1x</code> the same benchmark reported 2500, 1667 and 2000 ns/op. I haven&rsquo;t chased down why (cold caches, the first allocation and timer granularity are my suspects).</p>
<p>That leaves the suffix. The <code>-16</code> is <a href="https://github.com/golang/go/blob/go1.27.1/src/testing/benchmark.go#L671"><code>GOMAXPROCS</code></a>, and the suffix is dropped when the value is 1. With <code>-cpu 1,4</code> we get both forms:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -run XXX -bench <span style="color:#e6db74">&#39;MakeBuffer$&#39;</span> -cpu 1,4
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer     	83583531	        18.06 ns/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-4   	100000000	        20.72 ns/op
</span></span></code></pre></td></tr></table>
</div>
</div><p>A missing suffix in someone else&rsquo;s output means they ran with <code>GOMAXPROCS=1</code>. We can read the whole line now. What we can&rsquo;t tell yet is whether the loop did what we think it did.</p>
<h2 id="the-newer-loop-and-a-suspicious-zero">The newer loop, and a suspicious zero</h2>
<p>Go 1.24 added <a href="https://go.dev/doc/go1.24"><code>testing.B.Loop</code></a> as a replacement for the <code>b.N</code> loop. We add a second benchmark that uses it, with no sink:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">BenchmarkMakeBufferLoop</span>(<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">B</span>) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">for</span> <span style="color:#a6e22e">b</span>.<span style="color:#a6e22e">Loop</span>() {
</span></span><span style="display:flex;"><span>		<span style="color:#a6e22e">makeBuffer</span>(<span style="color:#ae81ff">64</span>)
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p><a href="https://pkg.go.dev/testing#B.Loop"><code>Loop</code> resets the timer on its first call and stops it when it returns false</a>, so setup before it isn&rsquo;t measured. The benchmark function runs once per <code>-count</code>, and the compiler keeps the arguments and results of calls in the body alive, so it can&rsquo;t optimize away the whole loop body. (It has had bugs; <a href="/posts/go-benchmarks-lying-compiler-honesty/">part 1</a> lists them.) <a href="https://go.dev/doc/go1.26">Go 1.26 changed how it keeps them alive</a>: inlining in the body is no longer blocked. Here are both benchmarks together:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -run XXX -bench . -benchmem -count<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-16    	92055165	        21.90 ns/op	      64 B/op	       1 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-16    	88833778	        19.98 ns/op	      64 B/op	       1 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBuffer-16    	100000000	        23.30 ns/op	      64 B/op	       1 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBufferLoop-16	494076908	         3.145 ns/op	       0 B/op	       0 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBufferLoop-16	548045512	         2.975 ns/op	       0 B/op	       0 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkMakeBufferLoop-16	562811305	         2.071 ns/op	       0 B/op	       0 allocs/op
</span></span></code></pre></td></tr></table>
</div>
</div><p>Same function, yet the loop version reports about 3 ns against about 20, and zero allocations. The series spends a post on numbers like that. Let&rsquo;s ask the compiler directly.</p>
<h2 id="ask-the-compiler">Ask the compiler</h2>
<p><code>-gcflags=-m</code> prints the compiler&rsquo;s inlining and escape-analysis decisions. The code lives in a <code>_test.go</code> file, so it has to be <code>go test</code>, and <code>-run XXX -bench XXX</code> compiles everything without running anything. I kept the two lines about our buffer:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -gcflags<span style="color:#f92672">=</span>-m -run XXX -bench XXX . 2&gt;&amp;<span style="color:#ae81ff">1</span> | grep <span style="color:#e6db74">&#39;make(\[\]byte, 64)&#39;</span>
</span></span><span style="display:flex;"><span>./tiny_test.go:14:17: make([]byte, 64) escapes to heap
</span></span><span style="display:flex;"><span>./tiny_test.go:21:13: make([]byte, 64) does not escape
</span></span></code></pre></td></tr></table>
</div>
</div><p>Line 14 is the <code>b.N</code> benchmark: the buffer goes to the heap, hence <code>1 allocs/op</code>. Line 21 is the <code>b.Loop</code> one: the buffer stays on the stack, so the zero is honest. <code>-S</code> goes one level lower and prints the assembly. The <code>b.N</code> benchmark calls the allocator (I trimmed the file path; the same grep on <code>BenchmarkMakeBufferLoop STEXT</code> prints nothing):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -gcflags<span style="color:#f92672">=</span>-S -run XXX -bench XXX . 2&gt;&amp;<span style="color:#ae81ff">1</span> | grep -A40 <span style="color:#e6db74">&#39;tiny.BenchmarkMakeBuffer STEXT&#39;</span> | grep <span style="color:#e6db74">&#39;CALL.*makeslice&#39;</span>
</span></span><span style="display:flex;"><span>0x0040 00064 (tiny_test.go:6)	CALL	runtime.makeslice(SB)
</span></span></code></pre></td></tr></table>
</div>
</div><p>That is enough compiler for now; <a href="/posts/go-benchmarks-lying-compiler-honesty/">part 1</a> reads these outputs in anger.</p>
<h2 id="where-the-profiler-fits">Where the profiler fits</h2>
<p>A benchmark tells you how long; a profile tells you where. Our buffer is a poor subject, since in my run its profile was dominated by runtime frames, so let&rsquo;s use the series demo&rsquo;s hashing benchmark, <code>BenchmarkHash_BLoop</code>, from the <a href="https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying/demo">companion repository</a>. We add <code>-cpuprofile</code> to the run and read it with <code>go tool pprof -top</code>, keeping three rows:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ go test -run XXX -bench BenchmarkHash_BLoop -benchmem -benchtime<span style="color:#f92672">=</span>2s -cpuprofile hash.prof .
</span></span><span style="display:flex;"><span>$ go tool pprof -top hash.prof
</span></span><span style="display:flex;"><span>      flat  flat%   sum%        cum   cum%
</span></span><span style="display:flex;"><span>     1.95s 92.86% 92.86%      1.95s 92.86%  crypto/internal/fips140/sha256.blockSHA2
</span></span><span style="display:flex;"><span>     0.09s  4.29% 97.14%      0.09s  4.29%  runtime.pthread_cond_signal
</span></span><span style="display:flex;"><span>     0.02s  0.95% 98.10%      1.97s 93.81%  crypto/internal/fips140/sha256.block
</span></span></code></pre></td></tr></table>
</div>
</div><p>The benchmark spent 92.86% of its CPU in <code>blockSHA2</code>, the SHA-256 block function, which is what a hash benchmark should be doing. If the profile of your own benchmark is dominated by <code>runtime</code> and <code>testing</code> frames instead, you may be timing the test framework.</p>
<p>Versions, links and commands checked on 2 October 2026.</p>
<h2 id="try-it">Try it</h2>
<p><code>b.Loop</code> needs Go 1.24, but the zero <code>allocs/op</code> result needs Go 1.26. On 1.24 and 1.25 the loop version shows 1 alloc/op too, which is the inlining change from earlier. Make a directory, run <code>go mod init tiny</code>, put the two benchmarks in <code>tiny_test.go</code> and run <code>go test -run XXX -bench . -benchmem -count=3</code>. Your numbers will differ from mine; on Go 1.26 or later your <code>allocs/op</code> column should not. Then add <code>-benchtime=1x</code>, <code>-cpu 1,4</code> and <code>-gcflags=-m</code> one at a time and watch which column moves.</p>
<p>We wrote a dozen lines of Go, got a line of output, and know where every field came from: the ramp, the mean, the sample, the suffix and the allocator. What we can&rsquo;t yet say is whether a given <code>ns/op</code> deserves trust. That&rsquo;s the question the series asks, and <a href="/posts/go-benchmarks-lying-compiler-honesty/">part 1</a> starts by catching the compiler emptying a loop. 🔎</p>
]]></content:encoded></item><item><title>Mentorship in Open Source — Part 3: Stewardship Inside OpenTelemetry</title><link>https://kakkoyun.me/posts/mentorship-in-open-source-part-3-stewardship/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/mentorship-in-open-source-part-3-stewardship/</guid><description>Closing the series with a live example — what mentorship looks like inside the OpenTelemetry project today, and why it&amp;#39;s the mechanism that turns dependency management into stewardship.</description><content:encoded><![CDATA[<p><a href="/posts/mentorship-in-open-source/">The first post in this thread</a> was about why I keep saying yes to mentorship. <a href="/posts/mentorship-in-open-source-part-2-mentee-playbook/">The second</a> was the playbook for mentees. This one is for the people on the other side of the table: the maintainers, the SIG leads, the engineers in companies that <em>depend on</em> a project and are starting to wonder whether dependency is enough.</p>
<p>The frame I&rsquo;m borrowing comes from a piece my colleagues at Bloomberg published with the CNCF in March: <em><a href="https://www.cncf.io/blog/2026/03/31/sustaining-opentelemetry-moving-from-dependency-management-to-stewardship/">Sustaining OpenTelemetry: Moving from Dependency Management to Stewardship</a></em> (also on <a href="https://www.bloomberg.com/company/stories/sustaining-opentelemetry-cncf-moving-from-dependency-management-to-stewardship/">Bloomberg&rsquo;s company stories</a>). The phrase has stuck with me. It names something I&rsquo;ve been trying to articulate for years, and gives me a concrete vocabulary for talking about what mentorship is <em>for</em> inside a project the size of <a href="https://opentelemetry.io/">OpenTelemetry</a>.</p>
<h2 id="from-dependency-management-to-stewardship">From dependency management to stewardship</h2>
<p>Most companies use open source the same way they use electricity. <span class="sidenote-ref" id="ref-sn-1" role="doc-noteref">
  <sup><a href="#sn-1" aria-describedby="sn-1">1</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-1" role="note" aria-label="Sidenote 1 (right margin)">
  <span class="sidenote-label">1</span>
  <span class="sidenote-content">You don&rsquo;t think about the grid until the lights go out.</span>
  <a class="sidenote-back" href="#ref-sn-1" aria-label="Back to reference">↩</a>
</aside>
 You depend on it, you patch when it breaks, you grumble when an upgrade is painful, you upstream a fix when you absolutely have to. That&rsquo;s <em>dependency management</em>: a relationship in which the project is something you consume.</p>
<p>Stewardship is the opposite stance. You don&rsquo;t just consume the project; you take responsibility for the version of it that exists ten years from now. You pay for that version not just with code but with <strong>time spent growing the people who will maintain it</strong>. <span class="sidenote-ref" id="ref-sn-2" role="doc-noteref">
  <sup><a href="#sn-2" aria-describedby="sn-2">2</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-2" role="note" aria-label="Sidenote 2 (right margin)">
  <span class="sidenote-label">2</span>
  <span class="sidenote-content">This is the part most &ldquo;open-source strategy&rdquo; decks omit. Code contributions are the easy half.</span>
  <a class="sidenote-back" href="#ref-sn-2" aria-label="Back to reference">↩</a>
</aside>
</p>
<p>The Bloomberg piece is making this case at the level of an organization. I want to make it at the level of a single SIG meeting.</p>
<h2 id="a-glimpse-from-the-sig-room">A glimpse from the SIG room</h2>
<p>In <a href="/posts/otel-unplugged-eu-2026/#the-maintainer-crisis">my field notes from OTel Unplugged EU 2026</a>, the maintainer crisis came up in at least three rooms:</p>
<blockquote>
<p>&ldquo;Not enough maintainers, too many PRs, codeowners disappearing. The JavaScript <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-1">SIG</span>
  <span role="tooltip" id="tt-1" class="tooltip-bubble">Special Interest Group — OpenTelemetry&rsquo;s organizational unit, roughly one per language or component (e.g. SIG Go, SIG JavaScript, SIG Collector).</span>
</span>
 has an automated script to move inactive maintainers to emeritus after three months. […] Some SIGs have tried a buddy/mentor system for onboarding new contributors — it helps, but it doesn&rsquo;t scale across all SIGs when the existing maintainers barely have time to review PRs.&rdquo;</p>
</blockquote>
<p>And the line that stuck with me from those rooms:</p>
<blockquote>
<p>&ldquo;Maintainership is privilege AND responsibility.&rdquo;</p>
</blockquote>
<p>That tension (privilege, responsibility, not enough hours) is the texture of stewardship in practice. Mentorship is the mechanism by which a project produces <em>more</em> maintainers, not just more PRs. <span class="sidenote-ref" id="ref-sn-3" role="doc-noteref">
  <sup><a href="#sn-3" aria-describedby="sn-3">3</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-3" role="note" aria-label="Sidenote 3 (left margin)">
  <span class="sidenote-label">3</span>
  <span class="sidenote-content">This is why I keep saying that the work is unglamorous. It does not show up in your release notes. It shows up two cohorts later, when the person you mentored opens a PR you would have had to write yourself.</span>
  <a class="sidenote-back" href="#ref-sn-3" aria-label="Back to reference">↩</a>
</aside>
</p>
<h2 id="where-mentorship-plugs-in">Where mentorship plugs in</h2>
<p>OpenTelemetry&rsquo;s contribution surface is enormous: protocol specs, language SDKs, the Collector, instrumentation libraries, semantic conventions, and an <a href="https://github.com/open-telemetry/community#special-interest-groups">exhaustive list of SIGs</a>. New contributors look at that surface and freeze. Maintainers look at it and triage by exhaustion.</p>
<p>Structured mentorship (<a href="https://lfx.linuxfoundation.org/tools/mentorship/">LFX</a>, <a href="https://summerofcode.withgoogle.com/">GSoC</a>, <a href="https://www.outreachy.org/">Outreachy</a>) works because it converts that two-way frozenness into a contract. The mentee gets a scoped problem, a stipend, and a guaranteed reviewer. The SIG gets a contributor whose entire job for 12 weeks is to make their problem smaller. Both sides agree to spend time on each other.</p>
<p>What I&rsquo;ve seen work, and what I&rsquo;ve seen fail:</p>
<h3 id="what-works">What works</h3>
<ul>
<li><strong>Tying mentorship slots to SIG roadmap items.</strong> The strongest LFX projects I&rsquo;ve mentored were ones a SIG genuinely wanted done. The mentee&rsquo;s work landed in a release. The mentee got reviewers who were already invested.</li>
<li><strong>Pairing every mentee with two mentors.</strong> One technical, one navigational. The technical mentor reviews code; the navigational mentor explains why a particular working group meets on Tuesdays and how to ask a question without it sounding like a bug report.</li>
<li><strong>Promoting good mentees into reviewers, not just contributors.</strong> The fastest way to grow your maintainer pool is to give the people you&rsquo;ve already mentored a reviewer bit before they think they&rsquo;re ready. <span class="sidenote-ref" id="ref-sn-4" role="doc-noteref">
  <sup><a href="#sn-4" aria-describedby="sn-4">4</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-4" role="note" aria-label="Sidenote 4 (right margin)">
  <span class="sidenote-label">4</span>
  <span class="sidenote-content">They are always more ready than they think.</span>
  <a class="sidenote-back" href="#ref-sn-4" aria-label="Back to reference">↩</a>
</aside>
</li>
</ul>
<h3 id="what-doesnt">What doesn&rsquo;t</h3>
<ul>
<li><strong>Treating LFX as a free-labor faucet.</strong> A mentee is not a contractor. If the SIG isn&rsquo;t prepared to spend a few hours a week on review, the mentorship will fail and the project will get a worse reputation in the next cohort.</li>
<li><strong>Vague project descriptions.</strong> &ldquo;Improve the X SDK&rdquo; is not a project. &ldquo;Add the W3C Baggage propagator to the Foo SDK with conformance tests against the upstream test suite&rdquo; is.</li>
<li><strong>Disappearing reviewers.</strong> I&rsquo;ve watched cohorts stall because the listed mentor was on a release crunch and the buddy system never kicked in. Buddies need buddies.</li>
</ul>
<h2 id="why-this-is-the-hard-part">Why this is the hard part</h2>
<p>Many ways exist to &ldquo;support open source&rdquo; that look great on a slide and produce little. Sponsorship dollars. Logos at conferences. PRs filed by an engineer who already had everything they needed to file the PR.</p>
<p>Mentorship is the one that produces compounding returns and almost no easy metrics. It&rsquo;s slow. It&rsquo;s lossy: most cohorts produce one strong maintainer-track contributor, not five. It does not show up in the release notes. It does not have a nice ROI graph. It will not survive the first round of cuts in a product-led quarter unless someone with budget authority decides it&rsquo;s a strategic choice.</p>
<p>And yet it&rsquo;s the mechanism. The reason the projects you depend on still exist in five years is that someone, somewhere, in 2024, mentored the person who will be on call the night your service crashes in 2030.</p>
<h2 id="call-to-action--for-maintainers-and-the-companies-they-work-for">Call to action — for maintainers and the companies they work for</h2>
<p>If you maintain a CNCF project, or you work for a company that depends heavily on one, here is what I&rsquo;d ask:</p>
<ol>
<li><strong>List your project on the next <a href="https://mentorship.lfx.linuxfoundation.org/">LFX cohort</a>.</strong> The intake portal is in <a href="https://github.com/cncf/mentoring"><code>cncf/mentoring</code></a>. It takes a few hours to write up three good project ideas.</li>
<li><strong>Write project descriptions that name a real artifact.</strong> Not &ldquo;improve X.&rdquo; Name the file, the test suite, the missing feature, the open issue number.</li>
<li><strong>Commit reviewer time <em>before</em> the cohort starts.</strong> Block the calendar. Tell the buddy. The mentee will know within two weeks whether the project is real or a slot to be ignored.</li>
<li><strong>Promote your strong mentees into reviewer roles.</strong> The most consequential move a maintainer can make is making the next maintainer.</li>
<li><strong>If you&rsquo;re an engineering leader at a company that depends on the project, fund the time.</strong> Stewardship that lives only on engineers&rsquo; weekends is fragile. Make it part of someone&rsquo;s quarter, with a roadmap row and a name. <span class="sidenote-ref" id="ref-sn-5" role="doc-noteref">
  <sup><a href="#sn-5" aria-describedby="sn-5">5</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-5" role="note" aria-label="Sidenote 5 (right margin)">
  <span class="sidenote-label">5</span>
  <span class="sidenote-content">This is, in part, what the <a href="https://www.cncf.io/blog/2026/03/31/sustaining-opentelemetry-moving-from-dependency-management-to-stewardship/">Bloomberg / CNCF piece</a> is making the corporate case for. Steal their language. It works on executives.</span>
  <a class="sidenote-back" href="#ref-sn-5" aria-label="Back to reference">↩</a>
</aside>
</li>
</ol>
<p>If you read this far and you&rsquo;re already doing this: drop me a line. I&rsquo;d love to compare notes on what&rsquo;s working in your project and what isn&rsquo;t. The most useful thing the CNCF mentorship community has produced isn&rsquo;t the formal program structure; it&rsquo;s the back-channel of mentors comparing notes after a bad cohort. That&rsquo;s how the program gets better.</p>
<h2 id="ps-doing-my-part">P.S. Doing my part</h2>
<p>Since the call to action above was directed at everyone except me, a postscript on what I&rsquo;m putting into this on my own end.</p>
<p>The current chapter: I work at Datadog APM on compile-time instrumentation, runtime profiling, and performance tooling for Go services. Most of that work happens in the open in <a href="https://github.com/Datadog/Orchestrion"><code>Datadog/Orchestrion</code></a>, which is basically melting into the upstream <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation"><code>opentelemetry-go-compile-instrumentation</code></a> project. As I write this, <a href="https://github.com/darccio">Dario Castañé</a> and I are co-mentoring an LFX 2026 Jun–Aug cohort on exactly that surface; the framing lives in <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/issues/446">issue #446</a>, and <a href="/posts/mentorship-in-open-source/">Part 1</a> and <a href="/posts/mentorship-in-open-source-part-2-mentee-playbook/">Part 2</a> already covered the project itself, so I&rsquo;ll stop pitching it.</p>
<p>What matters more is the lineage. Before Datadog, I co-mentored on <a href="https://github.com/thanos-io/thanos">Thanos</a> for a few years through the early LFX Mentorship cohorts (back when the platform was still branded CommunityBridge): the <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2020/q2/selected_projects.md">2020 Q2 query-limits project</a> and <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2020/q3-q4/selected_projects.md">2020 Q3–Q4 UI components</a> with <a href="https://www.bwplotka.dev/">Bartek Płotka</a> and <a href="https://github.com/onprem">Prem Saraswat</a>; <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2021/01-Spring/README.md">2021 Spring multi-tenancy</a> with <a href="https://github.com/yashrsharma44">@yashrsharma44</a>; the <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2021/03-Fall/README.md">2021 Fall protobuf migration</a> with <a href="https://github.com/squat">Lucas Servén Marín</a> and <a href="https://github.com/GiedriusS">Giedrius Statkevičius</a>; plus a <a href="https://github.com/cncf/mentoring/blob/main/programs/summerofcode/2021.md">GSoC 2021 Thanos TLS project</a>. More recently on Prometheus: <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2024/01-Mar-May/README.md"><code>client_golang</code> instrumentation work in LFX 2024 Mar–May</a> with <a href="https://github.com/ArthurSens">Arthur Sens</a>, and <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2024/03-Sep-Nov/README.md"><code>prometheus/test-infra</code> in LFX 2024 Sep–Nov</a> with <a href="https://github.com/bboreham">Bryan Boreham</a>. Each was a different SIG, a different co-mentor, a different mentee picking up something that became a real piece of the project. They are all on <a href="https://github.com/cncf/mentoring"><code>cncf/mentoring</code></a> if you want to read the project framings up close. <span class="sidenote-ref" id="ref-sn-6" role="doc-noteref">
  <sup><a href="#sn-6" aria-describedby="sn-6">6</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-6" role="note" aria-label="Sidenote 6 (right margin)">
  <span class="sidenote-label">6</span>
  <span class="sidenote-content">Bartek is the same maintainer I credited in <a href="/posts/mentorship-in-open-source/">Part 1</a> for showing me what a maintainer relationship is supposed to feel like. He&rsquo;d been my mentor on Thanos a year earlier; by 2020 we were co-mentoring together. That&rsquo;s the loop running once, on a five-year cycle.</span>
  <a class="sidenote-back" href="#ref-sn-6" aria-label="Back to reference">↩</a>
</aside>
</p>
<p>What sticks with me from those years isn&rsquo;t any single PR. Some of those mentees are now maintainers themselves. That&rsquo;s the loop closing: the thing Part 1 was really about.</p>
<hr>
<p><em>This closes the series. <a href="/posts/mentorship-in-open-source/">Part 1</a> was the why. <a href="/posts/mentorship-in-open-source-part-2-mentee-playbook/">Part 2</a> was the mentee playbook. If any of the three landed for you, share it with the person you&rsquo;ve been meaning to nudge into open source. That&rsquo;s the mechanism.</em></p>
]]></content:encoded></item><item><title>Mentorship in Open Source — Part 2: The Mentee Playbook</title><link>https://kakkoyun.me/posts/mentorship-in-open-source-part-2-mentee-playbook/</link><pubDate>Fri, 29 May 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/mentorship-in-open-source-part-2-mentee-playbook/</guid><description>A practical guide to getting into open-source through structured mentorship — LFX, GSoC, Outreachy, and GoBridge. What works, what doesn&amp;#39;t, and how to write a proposal that gets picked.</description><content:encoded><![CDATA[<p><a href="/posts/mentorship-in-open-source/">Last time</a> I wrote about why I keep saying yes to open-source mentorship. This post is the <em>how</em>: specifically, how to be a mentee in 2026.</p>
<p>I&rsquo;ve spent the last few years on the mentor side of CNCF <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-1">LFX</span>
  <span role="tooltip" id="tt-1" class="tooltip-bubble">LFX Mentorship — the Linux Foundation&rsquo;s structured mentorship program. Cohorts run quarterly, projects are scoped to ~12 weeks, and mentees receive a stipend.</span>
</span>
, <a href="https://summerofcode.withgoogle.com/">Google Summer of Code</a> (<span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-2">GSoC</span>
  <span role="tooltip" id="tt-2" class="tooltip-bubble">A Google-funded program where students contribute to open-source projects over the summer, paired with mentors from those projects.</span>
</span>
), and <a href="https://gobridge.org/">GoBridge</a>, through <a href="https://github.com/thanos-io/thanos">Thanos</a>, <a href="https://github.com/prometheus/prometheus">Prometheus</a>, and now <a href="https://github.com/open-telemetry">OpenTelemetry</a> projects. Every cohort I&rsquo;ve run, the same questions keep coming up from people who <em>want</em> to apply but don&rsquo;t know how to start.</p>
<p>This is the post I wish I could send those folks.</p>
<h2 id="who-this-is-for">Who this is for</h2>
<p>You&rsquo;ve heard about open-source mentorship programs and you want in. You might be:</p>
<ul>
<li>A student looking for a structured summer project (GSoC&rsquo;s bullseye).</li>
<li>A working engineer wanting to break into a CNCF or <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-3">OSS</span>
  <span role="tooltip" id="tt-3" class="tooltip-bubble">Open-source software.</span>
</span>
 community you respect (LFX is your friend).</li>
<li>An engineer from a background under-represented in tech (Outreachy is built for you).</li>
<li>Someone who&rsquo;s tried &ldquo;just contribute to open source&rdquo; and bounced off the gravitational well of unread Slack channels (you want a structured program with a mentor whose job is to talk to you).</li>
</ul>
<p>If any of those is you, keep reading.</p>
<h2 id="who-this-is-not-for">Who this is not for</h2>
<p>Before you spend a weekend writing a proposal, please read the program&rsquo;s eligibility documentation carefully. For LFX, that&rsquo;s <a href="https://docs.linuxfoundation.org/lfx/mentorship/mentees"><code>docs.linuxfoundation.org/lfx/mentorship/mentees</code></a>. Each program has its own version, and they are gold mines: eligibility, expectations, time commitment, conflict-of-interest rules, how the stipend works. Outreachy&rsquo;s <a href="https://www.outreachy.org/apply/eligibility/">eligibility page</a> is written for a specific reason and is worth reading slowly. Skipping this step is the single biggest reason proposals never reach a mentor.</p>
<p>Beyond the eligibility check, three patterns make me say &ldquo;please don&rsquo;t apply this cohort.&rdquo;</p>
<p>If you can scope the work, set up the dev environment, and ship the first milestone in two weekends by yourself, you don&rsquo;t need a mentor. Someone with less experience does. These programs exist to help people get into open source who otherwise can&rsquo;t break in. Taking a slot to add a stipend and a credential to your CV when you could have just opened the PRs yourself is taking it from someone who needs it more. <span class="sidenote-ref" id="ref-sn-1" role="doc-noteref">
  <sup><a href="#sn-1" aria-describedby="sn-1">1</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-1" role="note" aria-label="Sidenote 1 (right margin)">
  <span class="sidenote-label">1</span>
  <span class="sidenote-content">Not hypothetical. I have rejected proposals from engineers who could have shipped the project alone, and felt good about it, because the next in the queue came from someone who needed the structure.</span>
  <a class="sidenote-back" href="#ref-sn-1" aria-label="Back to reference">↩</a>
</aside>
</p>
<p>If you have exam season halfway through the cohort, an internship that overlaps, or this is the third thing on your list behind two more important commitments: don&rsquo;t apply this round. Apply next time. A part-time mentee is worse than no mentee, because the mentor&rsquo;s hours are already committed and now they&rsquo;re spent waiting for messages that don&rsquo;t come. Mentors put in volunteer hours, in our personal time, on top of day jobs. Wasted mentor time is the failure mode we feel most. Plenty of other people are ready to commit fully; let them have the slot.</p>
<p>And if your goal is a CV line, please pick a different path. &ldquo;LFX mentee&rdquo; looks good on a résumé; &ldquo;LFX mentee who became a maintainer of $project&rdquo; looks much better, and only one of those two requires you to still be around three months after the program ends. If the line on the CV is the point, mentorship is not the cheapest way to get it.</p>
<p>The mentorship is about helping and enabling. If you fit, apply enthusiastically. If you don&rsquo;t, apply later, or apply somewhere else.</p>
<h2 id="the-four-programs">The four programs</h2>
<p>I&rsquo;ve personally mentored through three of these: LFX, GSoC, and GoBridge. Outreachy I include because it&rsquo;s the door I most want under-represented contributors to know about; the Outreachy section below is description from the outside, not first-hand experience. Other programs exist; these are the ones I can speak to.</p>
<h3 id="cncf-lfx-mentorship">CNCF LFX Mentorship</h3>
<p>A structured cohort-based program run by the Linux Foundation. Each project is scoped for one cohort (~12 weeks), assigned 1–2 mentors, and pays a stipend. Cohorts run a few times a year; the <a href="https://mentorship.lfx.linuxfoundation.org/">active terms page</a> lists what&rsquo;s open right now, and the project list lives in the <a href="https://github.com/cncf/mentoring"><code>cncf/mentoring</code></a> repo under <code>programs/lfx-mentorship/</code>. The structure rewards reading: each project has a description, required skills, mentor names, and (often) example issues. Every mentor on LFX has agreed to commit a meaningful chunk of time; that&rsquo;s a contract you don&rsquo;t get from &ldquo;show up in the GitHub issues.&rdquo;</p>
<h3 id="google-summer-of-code">Google Summer of Code</h3>
<p>Google&rsquo;s long-running OSS internship. Originally student-only; for a few years now it&rsquo;s been open to &ldquo;beginners to open source,&rdquo; not just students. Applications open in spring, work happens over summer. Each participating org publishes its own ideas list, and CNCF orgs collect theirs in <a href="https://github.com/cncf/mentoring/tree/main/programs/summerofcode"><code>cncf/mentoring</code></a>. GSoC has the strongest &ldquo;first-time mentee&rdquo; track record I&rsquo;ve seen: the structure is forgiving enough that someone with rough Git fundamentals can still ship something real by August.</p>
<h3 id="outreachy">Outreachy</h3>
<p>A program for people from groups traditionally under-represented in tech, including those affected by systemic bias. Two cohorts per year (May–August and December–March). The intake is more rigorous than the others: there&rsquo;s a contribution period before the formal proposal, and it&rsquo;s <a href="https://www.outreachy.org/">run from outreachy.org directly</a>. Outreachy has the lowest tolerance for the &ldquo;open-source as boys&rsquo; club&rdquo; failure mode of any program I&rsquo;ve seen. If you&rsquo;ve felt locked out of OSS communities, this is the door.</p>
<h3 id="gobridge">GoBridge</h3>
<p>Less structured than the above. <a href="https://gobridge.org/">GoBridge</a> runs workshops, scholarships, and mentor pairings focused on the Go community. I list it because not every door is a 12-week cohort. Sometimes a one-week workshop or a 1:1 office-hours pairing is exactly the right starting size. <span class="sidenote-ref" id="ref-sn-2" role="doc-noteref">
  <sup><a href="#sn-2" aria-describedby="sn-2">2</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-2" role="note" aria-label="Sidenote 2 (right margin)">
  <span class="sidenote-label">2</span>
  <span class="sidenote-content">If you&rsquo;ve seen references to <strong>CommunityBridge</strong> in older blog posts or my About page, that&rsquo;s the platform CNCF LFX Mentorship used to run on; the Linux Foundation rebranded it to LFX Mentorship around 2020. Same plumbing, different name.</span>
  <a class="sidenote-back" href="#ref-sn-2" aria-label="Back to reference">↩</a>
</aside>
</p>
<h2 id="what-i-look-for-in-a-proposal">What I look for in a proposal</h2>
<p>Most proposals get rejected for the same reasons. Most accepted proposals share the same shape. Here&rsquo;s what I look for as a mentor. Your mileage may vary, but ask any LFX/GSoC mentor and you&rsquo;ll hear most of this.</p>
<ul>
<li><input disabled="" type="checkbox"> <strong>You have read the project&rsquo;s recent issues, PRs, and design docs.</strong> Not all, but enough that you can name two open issues by number and explain why they matter.</li>
<li><input disabled="" type="checkbox"> <strong>Your scope fits in 12 weeks.</strong> Not 12 weeks of full-time senior-engineer effort. 12 weeks of <em>your</em> effort, with reasonable assumptions about ramp-up. <span class="sidenote-ref" id="ref-sn-3" role="doc-noteref">
  <sup><a href="#sn-3" aria-describedby="sn-3">3</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-3" role="note" aria-label="Sidenote 3 (right margin)">
  <span class="sidenote-label">3</span>
  <span class="sidenote-content">A great heuristic: if you can&rsquo;t draft a one-paragraph milestone for week 4 <em>right now</em>, your scope is too vague.</span>
  <a class="sidenote-back" href="#ref-sn-3" aria-label="Back to reference">↩</a>
</aside>
</li>
<li><input disabled="" type="checkbox"> <strong>You&rsquo;ve made one small PR already.</strong> Even a typo fix in the README. It tells me you can find your way around the codebase, run the tests, and follow the contribution guide.</li>
<li><input disabled="" type="checkbox"> <strong>You&rsquo;ve named the trade-offs.</strong> Every interesting project has at least one. If your proposal reads like a feature spec with no tensions, you haven&rsquo;t engaged with the problem deeply enough.</li>
<li><input disabled="" type="checkbox"> <strong>You&rsquo;ve written the proposal <em>as a collaboration plan</em>, not a job application.</strong> I want to know <em>how we&rsquo;re going to work together</em>, not how impressive you are.</li>
</ul>
<p>That last bullet is the one I see violated most often. A mentorship proposal is not a CV. It&rsquo;s a draft of the conversation we&rsquo;re going to have for the next three months.</p>
<p>One more thing worth flagging, now that the agentic coding tools are everywhere: use AI to do reconnaissance in the codebase, summarize design docs, get up to speed on the project, learn unfamiliar concepts. All fine. Don&rsquo;t use it to write the proposal. We will ask questions in selection. We hold you accountable for the understanding the proposal claims. A proposal you can&rsquo;t defend in a thirty-minute conversation won&rsquo;t be picked, and the AI-drafted ones tend to be obvious within two.</p>
<h2 id="what-makes-a-good-first-contribution">What makes a good first contribution</h2>
<p>If you&rsquo;re not yet at proposal stage and just want to do <em>something</em>, start here.</p>
<ol>
<li><strong>Pick the project before the program.</strong> Find a CNCF or Go project whose docs you&rsquo;ve actually read for fun. Whose maintainers you&rsquo;ve watched on a podcast or at a meet-up. <span class="sidenote-ref" id="ref-sn-4" role="doc-noteref">
  <sup><a href="#sn-4" aria-describedby="sn-4">4</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-4" role="note" aria-label="Sidenote 4 (right margin)">
  <span class="sidenote-label">4</span>
  <span class="sidenote-content">If no project meets that bar yet, your first task is to find one. Start with the <a href="https://landscape.cncf.io/">CNCF Landscape</a> and look at which projects are listed in the current LFX, GSoC, or Outreachy cohorts. That tells you who is actively running mentorships. Even better: some of the best applicants I&rsquo;ve seen reached out to a project they loved and asked the maintainers whether they&rsquo;d consider running a cohort. Brilliant move, every time.</span>
  <a class="sidenote-back" href="#ref-sn-4" aria-label="Back to reference">↩</a>
</aside>
 Programs are the scaffolding. The project is the building.</li>
<li><strong>Read the issue tracker for an hour.</strong> Not to pick an issue; to understand the <em>vocabulary</em>. What does &ldquo;OBI&rdquo; mean to this project? What&rsquo;s a &ldquo;sidecar&rdquo; here? Who&rsquo;s <code>@nickname</code> and why do they have opinions?</li>
<li><strong>Find a &ldquo;good first issue&rdquo; or a <code>help-wanted</code> that hasn&rsquo;t been touched in a few weeks.</strong> If it&rsquo;s been claimed and abandoned, comment asking if you can pick it up. If it&rsquo;s untouched, comment with your understanding of the problem and a sketch of an approach <em>before</em> you write code.</li>
<li><strong>Submit a small, working PR.</strong> Tests included. Description that explains the <em>why</em>, not the <em>what</em>. Ask for review explicitly.</li>
<li><strong>Stay in the thread.</strong> This is the most under-rated step. Half of first-time contributors disappear after their PR gets review comments. The half that stay through one round of revisions become contributors.</li>
</ol>
<h2 id="the-five-mistakes-i-see-most">The five mistakes I see most</h2>
<p>I&rsquo;ve watched these patterns play out across cohorts. They&rsquo;re easy traps, not character flaws. Knowing about them will not stop you from falling into one of them, but it might shorten the climb out.</p>
<h3 id="1-picking-a-project-youre-not-actually-curious-about">1. Picking a project you&rsquo;re not actually curious about</h3>
<p>The single best predictor of whether a mentee finishes their project is whether they <em>want to use the thing they&rsquo;re building</em>. If your project is &ldquo;add a feature to a database I&rsquo;ve never touched,&rdquo; you will run out of motivation by week 6. If your project is &ldquo;make this tool I rely on do the thing I keep wishing it did,&rdquo; you will finish.</p>
<h3 id="2-scoping-too-big">2. Scoping too big</h3>
<p>Almost every first-draft proposal I review is 2–3× too ambitious. <span class="sidenote-ref" id="ref-sn-5" role="doc-noteref">
  <sup><a href="#sn-5" aria-describedby="sn-5">5</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-5" role="note" aria-label="Sidenote 5 (left margin)">
  <span class="sidenote-label">5</span>
  <span class="sidenote-content">I&rsquo;ve done this myself. As a mentor I now reflexively halve my mentees&rsquo; first-draft scopes before we publish.</span>
  <a class="sidenote-back" href="#ref-sn-5" aria-label="Back to reference">↩</a>
</aside>
 The tell is when you can describe your &ldquo;milestones&rdquo; only as topic areas: <em>&ldquo;week 1–4: research; week 5–8: implementation; week 9–12: polish.&rdquo;</em> Real milestones name the artifact. <em>&ldquo;By end of week 4, the parser handles X; PR open for early review.&rdquo;</em></p>
<h3 id="3-ghosting">3. Ghosting</h3>
<p>If you don&rsquo;t hear from your mentor for a week, send a message. If you don&rsquo;t hear from yourself for a week (that is, if you&rsquo;ve gone quiet on your own project), send a message anyway. <em>&ldquo;I&rsquo;m stuck&rdquo;</em> is always a better message than no message. Mentors are not graders; we cannot help you if we don&rsquo;t know what&rsquo;s happening.</p>
<h3 id="4-treating-reviews-as-criticism">4. Treating reviews as criticism</h3>
<p>I once spent twenty minutes writing a careful review comment explaining why a particular abstraction was the wrong fit, and got back a one-line <em>&ldquo;ok will fix&rdquo;</em>. Three weeks later the mentee told me they&rsquo;d been demoralized by that review. Reviews are the actual product of mentorship. The PR is the medium; the review is the message. If a review confuses you, ask. If it stings, sit with it for a day, then ask. The mentor who took twenty minutes to write a careful review wants you to push back when they&rsquo;re wrong.</p>
<h3 id="5-disappearing-after-the-cohort-ends">5. Disappearing after the cohort ends</h3>
<p>This one cuts both ways. Mentors do it too. The cohort has a hard end date; the relationship doesn&rsquo;t have to.</p>
<p>The mentees I&rsquo;m proudest of are the ones who kept poking me with PR links and SIG-meeting questions for <em>years</em> after their LFX project closed. The ones I&rsquo;m least proud of are the ones who disappeared the day the certificate landed and resurfaced two years later asking for a reference letter for a job application. <span class="sidenote-ref" id="ref-sn-6" role="doc-noteref">
  <sup><a href="#sn-6" aria-describedby="sn-6">6</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-6" role="note" aria-label="Sidenote 6 (right margin)">
  <span class="sidenote-label">6</span>
  <span class="sidenote-content">I write the reference letter anyway. But I notice.</span>
  <a class="sidenote-back" href="#ref-sn-6" aria-label="Back to reference">↩</a>
</aside>
</p>
<p>Don&rsquo;t be that person. Don&rsquo;t be selfish. We do this in the hope that the next generation of maintainers comes from somewhere, which requires you to stick around long enough to <em>become</em> the next generation. The official program is a starter loan; the real currency is the ongoing connection. Repay it by being present.</p>
<h2 id="concrete-examples-from-my-own-cohorts">Concrete examples (from my own cohorts)</h2>
<p>I&rsquo;ve mentored on Thanos, Prometheus, and OpenTelemetry projects through these programs. The most concrete current example, while this post goes live: <a href="https://github.com/darccio">Dario Castañé</a> and I are about to start co-mentoring <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2026/02-Jun-Aug/README.md#expanding-go-compile-time-instrumentation-support-and-improving-otelc-tooling">Expanding Go Compile-Time Instrumentation Support and Improving otelc Tooling</a> in the LFX 2026 Jun–Aug cohort. The framing lives in <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/issues/446"><code>opentelemetry-go-compile-instrumentation#446</code></a>. Applications closed before this post published; we&rsquo;re choosing a mentee now, with the cohort itself starting in June. The next cohort&rsquo;s listings tend to land in <a href="https://github.com/cncf/mentoring"><code>cncf/mentoring</code></a> a few weeks before each term opens, so the next round to apply to will go up there. <span class="sidenote-ref" id="ref-sn-7" role="doc-noteref">
  <sup><a href="#sn-7" aria-describedby="sn-7">7</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-7" role="note" aria-label="Sidenote 7 (left margin)">
  <span class="sidenote-label">7</span>
  <span class="sidenote-content">I&rsquo;ll add older mentee credits and merged-PR links here as I get clearance from the folks involved. Naming people in a blog post without asking is exactly the kind of thing I&rsquo;m trying to teach mentees not to do.</span>
  <a class="sidenote-back" href="#ref-sn-7" aria-label="Back to reference">↩</a>
</aside>
</p>
<p>A handful of patterns that show up across cohorts:</p>
<ul>
<li>The strongest proposals always referenced specific open issues by number, not abstract feature ideas.</li>
<li>The mentees who finished early <em>all</em> did one small &ldquo;warm-up&rdquo; PR before the cohort officially started.</li>
<li>The mentees who became maintainers were the ones who kept showing up to community meetings <em>during</em> their project, then kept showing up after it closed.</li>
</ul>
<p>If you&rsquo;re a former mentee of mine and you&rsquo;d like me to credit your work in a follow-up post, drop me a line; happy to.</p>
<h2 id="call-to-action--for-mentees">Call to action — for mentees</h2>
<p>If you&rsquo;re thinking about applying:</p>
<ol>
<li><strong>Pick a current cohort.</strong> Start with <a href="https://github.com/cncf/mentoring"><code>cncf/mentoring</code></a>. For LFX, the <a href="https://mentorship.lfx.linuxfoundation.org/">active terms page</a> lists what&rsquo;s open right now. For Outreachy, <a href="https://www.outreachy.org/">outreachy.org</a> has the next intake. GSoC&rsquo;s calendar is at <a href="https://summerofcode.withgoogle.com/">summerofcode.withgoogle.com</a>.</li>
<li><strong>Read three project descriptions, fully.</strong> Not five. Three. Pick the one whose problem you find most interesting, not the one whose description is shortest.</li>
<li><strong>Write a draft proposal <em>before</em> you contact a mentor.</strong> A 500-word draft with one concrete milestone is enough.</li>
<li><strong>Send it to me, with one caveat.</strong> If I&rsquo;m on the selection committee for your target program, I can&rsquo;t review your draft; that would be unfair to other applicants and would turn selection into a spam channel. For LFX 2026 Jun–Aug on <code>opentelemetry-go-compile-instrumentation</code> (the cohort <a href="https://github.com/darccio">Dario</a> and I are running): please don&rsquo;t send me a draft. For any other program, on any other CNCF project where I&rsquo;m an outsider, my DMs are on the <a href="/about/#where-to-find-me">usual places</a>. <span class="sidenote-ref" id="ref-sn-8" role="doc-noteref">
  <sup><a href="#sn-8" aria-describedby="sn-8">8</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-8" role="note" aria-label="Sidenote 8 (right margin)">
  <span class="sidenote-label">8</span>
  <span class="sidenote-content">I&rsquo;d rather review your draft for an hour than read your rejection email later. Just not on programs where I have a vote.</span>
  <a class="sidenote-back" href="#ref-sn-8" aria-label="Back to reference">↩</a>
</aside>
 I&rsquo;ll tell you honestly whether the scope fits, what the mentor will be looking for, and where the proposal needs work.</li>
</ol>
<p>There&rsquo;s no admission test for asking. The only failure mode is not asking.</p>
<hr>
<p><em>Part 3 of this series, <a href="/posts/mentorship-in-open-source-part-3-stewardship/">Stewardship Inside OpenTelemetry</a>, is for the people on the other side of the table: the maintainers thinking about whether to sign up to mentor at all.</em></p>
]]></content:encoded></item><item><title>Why I Keep Mentoring in Open Source</title><link>https://kakkoyun.me/posts/mentorship-in-open-source/</link><pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/mentorship-in-open-source/</guid><description>A few words on why I keep saying yes to mentorship in open source.</description><content:encoded><![CDATA[<p>There&rsquo;s a moment that keeps happening to me. Someone I mentored two or three years ago shows up in a SIG call, on a maintainers&rsquo; list, on a stage at KubeCon. They&rsquo;ve shipped something I couldn&rsquo;t have shipped alone. They&rsquo;re answering questions I once answered for them. And the part that gets me: they&rsquo;re already mentoring someone else.</p>
<p>That moment is the thing. <span class="sidenote-ref" id="ref-sn-1" role="doc-noteref">
  <sup><a href="#sn-1" aria-describedby="sn-1">why</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-1" role="note" aria-label="Sidenote why (right margin)">
  <span class="sidenote-label">why</span>
  <span class="sidenote-content">It&rsquo;s also the only honest answer to &ldquo;why do you keep doing this when it&rsquo;s not your job?&rdquo; The loop closes, and you get to watch.</span>
  <a class="sidenote-back" href="#ref-sn-1" aria-label="Back to reference">↩</a>
</aside>
 Everything else I write here is a footnote to that moment.</p>
<h3 id="the-line-on-my-about-page">The line on my About page</h3>
<p>If you&rsquo;ve ever read <a href="/about/">my About page</a>, you&rsquo;ve seen this sentence:</p>
<blockquote>
<p>&ldquo;I&rsquo;ve mentored through CNCF <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-1">LFX</span>
  <span role="tooltip" id="tt-1" class="tooltip-bubble">LFX Mentorship — the Linux Foundation&rsquo;s structured open-source mentorship program, run in cohorts with stipends.</span>
</span>
, Google Summer of Code, CommunityBridge, and GoBridge, helping others find their way into open source.&rdquo;</p>
</blockquote>
<p>It has been there for years. I&rsquo;ve never written about what it actually means. That&rsquo;s a strange omission for someone who blogs about almost everything else they do at work, and the reason is simple: mentorship is hard to talk about without it sounding either like humble-bragging (&ldquo;look at me, generously donating my time&rdquo;) or like a program brochure (&ldquo;apply today, terms and conditions apply&rdquo;). Both are boring. Neither is what mentorship feels like from the inside.</p>
<h3 id="i-was-a-mentee-first">I was a mentee first</h3>
<p>Before I was anybody&rsquo;s mentor, I was somebody&rsquo;s mentee.</p>
<p>When I was learning to maintain <a href="https://github.com/thanos-io/thanos">Thanos</a>, <a href="https://www.bwplotka.dev/">Bartek Płotka</a> walked me through review after review. He showed me what good API design looked like by writing it out in PR comments. He told me when an idea was wrong, and stayed in the thread until I understood why. He let me make mistakes in patches I was proud of, then explained, in writing, exactly why the mistake was a mistake. I learned how a maintainer relationship is supposed to feel by being on the receiving end of one done well.</p>
<p>I would not have the career I have without that period. Open source is rare in that the apprenticeship is built into the medium: every PR is a teaching moment if both parties want it to be. The trick is finding people who do.</p>
<p>I&rsquo;ve since made every mistake a mentor can make from the other side of that table. <span class="sidenote-ref" id="ref-sn-2" role="doc-noteref">
  <sup><a href="#sn-2" aria-describedby="sn-2">2</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-2" role="note" aria-label="Sidenote 2 (right margin)">
  <span class="sidenote-label">2</span>
  <span class="sidenote-content">Top three: scoping projects too ambitiously, ghosting someone for three weeks during a release crunch, and confusing &ldquo;I would do it this way&rdquo; with &ldquo;this is the right way.&rdquo;</span>
  <a class="sidenote-back" href="#ref-sn-2" aria-label="Back to reference">↩</a>
</aside>
 I have lost touch with people I should have kept in touch with. I have written reference letters too late. So this post is field notes, not advice from on high.</p>
<h3 id="why-i-keep-saying-yes">Why I keep saying yes</h3>
<p>A few honest reasons, in no particular order.</p>
<p>The first is that mentoring is selfish. The best way I know to learn something in depth is to write about it and to teach it. Mentorship is the second of those, on a real project, with real stakes, on someone else&rsquo;s time. It forces me to articulate things I&rsquo;ve stopped noticing. <em>Why</em> do we test this way? <em>Why</em> did we name the thing this way and not that way? When you have to explain something to a smart person who hasn&rsquo;t yet absorbed your team&rsquo;s folklore, you discover how much of what you &ldquo;know&rdquo; is actually superstition. Every cohort makes me a better engineer.</p>
<p>The second is that the projects need it. I lifted this line from my <a href="/posts/otel-unplugged-eu-2026/#the-maintainer-crisis">own field notes at OTel Unplugged</a>:</p>
<blockquote>
<p>&ldquo;Maintainership is privilege AND responsibility.&rdquo;</p>
</blockquote>
<p>CNCF projects are running on tired maintainers. <span class="sidenote-ref" id="ref-sn-3" role="doc-noteref">
  <sup><a href="#sn-3" aria-describedby="sn-3">3</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-3" role="note" aria-label="Sidenote 3 (left margin)">
  <span class="sidenote-label">3</span>
  <span class="sidenote-content">This is the unspoken background to half the project meetings I attend. It&rsquo;s also why &ldquo;stewardship&rdquo; is the word of the moment.</span>
  <a class="sidenote-back" href="#ref-sn-3" aria-label="Back to reference">↩</a>
</aside>
 The dependency-management model, <em>&ldquo;we use this, we patch it when it breaks&rdquo;</em>, does not produce the next generation of maintainers. Mentorship does. Slowly, one human at a time. There is no other mechanism I know of that reliably converts someone from a user of a project into a steward of it.</p>
<p>The last reason is the one I&rsquo;d rather not admit, because it sounds saccharine. The people are the best part. Every cohort of mentees I&rsquo;ve worked with through CNCF <a href="https://lfx.linuxfoundation.org/tools/mentorship/">LFX Mentorship</a>, <a href="https://summerofcode.withgoogle.com/">Google Summer of Code</a>, <a href="https://gobridge.org/">GoBridge</a>, and <a href="https://lfx.linuxfoundation.org/">CommunityBridge</a> has been more interesting than most of my coworkers in any given quarter. They show up with weird backgrounds, sharp questions, and an almost embarrassing amount of energy. They make me want to keep going.</p>
<p>Underneath all of these is something simpler. I love learning. Open source is one of the few fields where learning is the work itself, yours and the project&rsquo;s at the same time. Sharing what you&rsquo;ve picked up is the cheapest way to shorten the next person&rsquo;s curve. Sharing is caring; it also happens to be how the field gets better.</p>
<h3 id="whats-next">What&rsquo;s next</h3>
<p>If this lands for you, there are two follow-ups in mind: a practical playbook for mentees, and a closing post on what mentorship looks like inside OpenTelemetry right now.</p>
<p>The timing isn&rsquo;t accidental. As I write this, I&rsquo;m about to start a new cohort: co-mentoring <a href="https://github.com/cncf/mentoring/blob/main/programs/lfx-mentorship/2026/02-Jun-Aug/README.md#expanding-go-compile-time-instrumentation-support-and-improving-otelc-tooling">Expanding Go Compile-Time Instrumentation Support and Improving otelc Tooling</a> on <code>opentelemetry-go-compile-instrumentation</code> with <a href="https://github.com/darccio">Dario Castañé</a> for the LFX 2026 Jun–Aug round. Applications closed a few weeks ago; we&rsquo;re picking a mentee now, and the cohort itself kicks off in June.</p>
<p>If you&rsquo;re a mentee looking at a future cohort and want a second pair of eyes on a draft proposal, or if you&rsquo;re already mentoring and want to compare notes: my DMs are open on the <a href="/about/#where-to-find-me">usual places</a>.</p>
]]></content:encoded></item><item><title>From talk to docs: The Zen of Prometheus</title><link>https://kakkoyun.me/posts/from-talk-to-docs-the-zen-of-prometheus/</link><pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/from-talk-to-docs-the-zen-of-prometheus/</guid><description>A talk I gave at PromCon Online 2020 has been adopted as part of the official Prometheus documentation. A short, grateful reflection on the journey from conference slides to canonical guidance.</description><content:encoded><![CDATA[<p>Every now and then a project surprises you by remembering something you said years ago. This week was one of those weeks. A talk I gave at <a href="https://kakkoyun.me/talks/the-zen-of-prometheus/">PromCon Online 2020</a> — <em>The Zen of Prometheus</em> — has quietly become part of the <a href="https://prometheus.io/docs/practices/the_zen/">official Prometheus documentation</a>.</p>
<p>I am still sitting with it.</p>
<h3 id="where-it-started">Where it started</h3>
<p>The talk was born in the strangest year of my career. PromCon 2020 was online, like everything else. I was a few years deep into running Prometheus in anger, collecting scars from instrumenting services that didn&rsquo;t want to be instrumented and writing alerts that kept me up at night for the wrong reasons. I wanted a way to package those lessons that wasn&rsquo;t another forty-slide deck of bullet points.</p>
<p>I borrowed a frame from somewhere I love: <a href="https://peps.python.org/pep-0020/">PEP 20, <em>The Zen of Python</em></a>, the small, almost-koan list that has shaped how an entire community talks to itself. <em>Beautiful is better than ugly. Simple is better than complex.</em> I wondered if the same shape could carry the lessons we keep relearning about metrics, instrumentation, and alerting. The result was <em>The Zen of Prometheus</em>: a handful of aphorisms, a <a href="https://the-zen-of-prometheus.netlify.app/">companion site</a>, some <a href="https://github.com/kakkoyun/the-zen-of-prometheus">slides</a>, and a <a href="https://www.youtube.com/watch?v=Nqp4fjw_omU">recording</a> for anyone who wanted to follow along.</p>
<p>I thought it would live the typical conference-talk life: watched a few times, cited in a Slack thread, then politely forgotten.</p>
<p><picture><source type="image/webp" srcset="/uploads/the-zen-then_hu_550e369f1b84f290.webp 480w, /uploads/the-zen-then_hu_7f262ce8fc2f072.webp 768w, /uploads/the-zen-then_hu_18176e217d604cc0.webp 1024w, /uploads/the-zen-then_hu_c92758b643221b9c.webp 1366w, /uploads/the-zen-then_hu_72202a61f043ba7a.webp 1920w, /uploads/the-zen-then_hu_5a470e3f1c87e60b.webp 2940w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/the-zen-then_hu_eaecc5bc6cbfe00c.png 480w, /uploads/the-zen-then_hu_690f184346f0535c.png 768w, /uploads/the-zen-then_hu_7ae1fd217c4862a9.png 1024w, /uploads/the-zen-then_hu_d175292066a80eb6.png 1366w, /uploads/the-zen-then_hu_52153d987468918e.png 1920w, /uploads/the-zen-then.png 2940w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/the-zen-then.png" alt="Screenshot of the original Zen of Prometheus companion site, hosted at the-zen-of-prometheus.netlify.app, with the &ldquo;Heads up — this is the original draft&rdquo; banner pointing readers to the official docs."
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="2940" height="1508">
  </picture></p>
<h3 id="what-it-became">What it became</h3>
<p>It didn&rsquo;t get forgotten. Other people kept linking to it and kept finding it useful — and now it has <a href="https://prometheus.io/docs/practices/the_zen/">a home in the official Prometheus docs</a>.</p>
<p>That word, <em>home</em>, is the one that keeps coming back to me. A talk is a moment. A docs page is a place. When something moves from the first into the second, it stops being <em>Kemal&rsquo;s slides from a 2020 PromCon</em> and becomes something a newcomer reads on day one without ever needing to know who wrote it. That is exactly what I would have wanted, and I&rsquo;m not sure I would have had the nerve to ask for it.</p>
<p>I won&rsquo;t reproduce the principles here. They live on the official page now, and that&rsquo;s where they should be read, alongside the rest of the practices, maintained by people whose role is to keep them honest as the project evolves.</p>
<h3 id="looking-back">Looking back</h3>
<p>The version of me that wrote those lines was not trying to be authoritative. He was trying to write down enough hard-won lessons that the next person would not have to learn them at three in the morning. Most of what ended up in <em>The Zen of Prometheus</em> came from mistakes (mine, and the teams I worked with), and from the slow realisation that good instrumentation is a discipline of restraint.</p>
<p>It&rsquo;s a strange and quietly emotional thing to see those notes outlast the moment they were written for. The talk was a small gesture. The fact that it found its way into the docs says less about the talk and more about the Prometheus community: a project still willing to listen to its users, fold their field notes back into its canon, and treat best practices as a living document rather than a finished one. That&rsquo;s rare, and it&rsquo;s why I keep coming back to this project.</p>
<h3 id="thanks">Thanks</h3>
<p>Thank you to the Prometheus maintainers, who steward the project and keep its documentation alive enough that community contributions can grow into something canonical. A special thanks to <a href="https://github.com/beorn7">@beorn7</a> for the careful reviews on <a href="https://github.com/prometheus/docs/pull/1783">the docs PR</a> that brought <em>The Zen</em> in. Thank you to everyone who asked sharp questions about the talk over the years and pushed back where I was wrong. A particular nod to <a href="https://github.com/jessicalins">@jessicalins</a>, my partner in crime on the <a href="https://kakkoyun.me/talks/best-practices-and-pitfalls-of-instrumenting-your-cloud-native-application/">follow-up at PromCon EU 2022</a>, who carried the same thread further with me and made it sharper than I could have alone.</p>
<p>A talk only ever belongs to its speaker for an hour. After that, it belongs to whoever finds it useful.</p>
<h3 id="read-it-improve-it">Read it, improve it</h3>
<p>If you have not yet, <a href="https://prometheus.io/docs/practices/the_zen/">read the page</a>. And if your own production scars suggest a principle that is missing, propose it. The docs are alive. <em>Same team, different companies</em> — and the canon gets better when more of us write into it.</p>
]]></content:encoded></item><item><title>Measuring Software Performance: Why Your Benchmarks Are Probably Lying</title><link>https://kakkoyun.me/posts/fosdem-2026-measuring-software-performance/</link><pubDate>Fri, 06 Mar 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/fosdem-2026-measuring-software-performance/</guid><description>A practical guide to reliable software benchmarking — from controlling hardware noise to statistical analysis, with experiments and actionable advice.</description><content:encoded><![CDATA[<h3 id="a-loose-cable-that-broke-physics">A Loose Cable That Broke Physics</h3>
<p>In 2006, a team of physicists began building the <a href="https://en.wikipedia.org/wiki/OPERA_experiment">OPERA experiment</a> — a 730-kilometer underground tunnel from CERN in Switzerland to Gran Sasso in Italy, designed to measure the speed of neutrinos. Five years of construction. Roughly 100 million euros. The most rigorous experimental physics on the planet.</p>
<p>In September 2011, the results came back. Neutrinos were traveling <a href="https://profmattstrassler.com/articles-and-posts/particle-physics-basics/neutrinos/neutrinos-faster-than-light/opera-what-went-wrong/">faster than the speed of light</a>. The team had just broken the laws of physics.</p>
<p>Except they hadn&rsquo;t. After months of rechecking the math, the sensors, and the calibration, they found the root cause: a single fiber-optic cable that wasn&rsquo;t fully plugged in. A loose connector had introduced a 73-nanosecond timing error — enough to make neutrinos appear superluminal.</p>
<p>Most of us aren&rsquo;t building 730-kilometer tunnels. But we deal with &ldquo;loose cables&rdquo; every day when measuring software performance. A benchmark that shows a 5% speedup might be measuring thermal throttling, CPU frequency scaling, or a noisy neighbor on a shared cloud instance. The signal is real, but so is the noise — and telling them apart requires discipline.</p>
<p><picture><source type="image/webp" srcset="/uploads/fosdem26_crowd_hu_95e585dd3afeaf43.webp 480w, /uploads/fosdem26_crowd_hu_79c245707f313c04.webp 768w, /uploads/fosdem26_crowd_hu_8b28556fd2cdea93.webp 1024w, /uploads/fosdem26_crowd_hu_6b84fd6e224c8c9d.webp 1366w, /uploads/fosdem26_crowd_hu_43d53c1843c98182.webp 1920w, /uploads/fosdem26_crowd_hu_43d00c2655d5280e.webp 2048w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/fosdem26_crowd_hu_7737653091933911.jpeg 480w, /uploads/fosdem26_crowd_hu_5e3963eac7910763.jpeg 768w, /uploads/fosdem26_crowd_hu_2116c5442a7c8717.jpeg 1024w, /uploads/fosdem26_crowd_hu_9cefc2d54a2ff8c0.jpeg 1366w, /uploads/fosdem26_crowd_hu_ef79a8c38460e8c3.jpeg 1920w, /uploads/fosdem26_crowd.jpeg 2048w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/fosdem26_crowd.jpeg" alt="Software Performance Devroom audience at FOSDEM 2026"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="2048" height="1152">
  </picture></p>
<p>This post expands on the talk <a href="https://github.com/igoragoli">Augusto de Oliveira</a> and I gave at the <a href="/talks/how-to-reliably-measure-software-performance/">FOSDEM 2026 Software Performance Devroom</a>. The <a href="https://github.com/igoragoli/fosdem-2026-software-performance">slides and experiments</a> are all open source.</p>
<hr>
<h3 id="why-benchmarking-is-hard">Why Benchmarking Is Hard</h3>
<p>Measuring software performance is a specialized version of a more general problem: finding a signal in a world full of noise.</p>
<p>Modern systems have layers of non-determinism that conspire against repeatable measurements. The CPU dynamically adjusts its clock frequency based on load and temperature. The OS scheduler moves threads between cores. Caches warm and cool. Background processes steal cycles. VMs share physical resources with other tenants. Memory layout changes between runs due to address space layout randomization (ASLR).</p>
<p>Any one of these factors can shift your numbers by a few percent. Stack them up, and a benchmark that reports a 5% improvement might just be measuring random variation. You run it again and the improvement vanishes — or reverses.</p>
<p>The gap between &ldquo;I ran a quick benchmark on my laptop&rdquo; and &ldquo;this measurement is reliable enough to make decisions on&rdquo; is enormous. Closing that gap requires controlling the environment, designing the benchmark properly, interpreting results with statistical rigor, and integrating the whole process into your development workflow.</p>
<hr>
<h3 id="environment-control">Environment Control</h3>
<p>This is the foundation. No amount of statistical sophistication will compensate for a noisy measurement environment. The sources of noise come from every layer of the stack:</p>
<table>
	<thead>
			<tr>
					<th>Layer</th>
					<th>Sources of Noise</th>
					<th>Mitigations</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>External</strong></td>
					<td>Network, temperature, vibration, virtualization</td>
					<td>Bare metal instances, dedicated hardware</td>
			</tr>
			<tr>
					<td><strong>Application</strong></td>
					<td>Memory layout, compilation/linking</td>
					<td>Fixed builds, disable ASLR</td>
			</tr>
			<tr>
					<td><strong>Kernel</strong></td>
					<td>Scheduling, caching</td>
					<td>CPU affinity, process priority, cache management</td>
			</tr>
			<tr>
					<td><strong>CPU</strong></td>
					<td>SMT contention, dynamic frequency scaling</td>
					<td>Disable SMT, disable DFS</td>
			</tr>
	</tbody>
</table>
<h4 id="noisy-neighbors-and-bare-metal">Noisy Neighbors and Bare Metal</h4>
<p>If you&rsquo;re running benchmarks on a shared cloud VM, you&rsquo;re sharing physical CPU cores, memory bandwidth, and last-level cache with other tenants. Their workload affects your numbers. This is the classic noisy neighbor problem.</p>
<p>The fix: use bare metal cloud instances (e.g., AWS <code>m5.metal</code>). They cost more, but they give you exclusive access to the underlying hardware. Just as importantly, bare metal access lets you apply the kernel-level and CPU-level mitigations below — none of which are possible on shared VMs.</p>
<p><a href="https://www.mongodb.com/company/blog/engineering/reducing-variability-performance-tests-ec2-setup-key-results">MongoDB&rsquo;s engineering team documented this well</a> — their work on reducing variability in EC2 performance tests is an excellent reference for anyone setting up cloud-based benchmarking infrastructure.</p>
<h4 id="cpu-affinity-and-process-priority">CPU Affinity and Process Priority</h4>
<p>The OS scheduler moves processes between CPU cores to balance load. Each migration can evict warm cache lines and introduce jitter. Pinning your benchmark to specific cores with <code>taskset</code> eliminates this:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Pin benchmark to CPU 0</span>
</span></span><span style="display:flex;"><span>taskset -c <span style="color:#ae81ff">0</span> ./benchmark
</span></span></code></pre></td></tr></table>
</div>
</div><p>Similarly, raising process priority with <code>nice</code> reduces scheduling interference from other processes:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Higher priority (niceness -5, where -20 is highest)</span>
</span></span><span style="display:flex;"><span>nice -n -5 ./benchmark
</span></span></code></pre></td></tr></table>
</div>
</div><h4 id="cache-management">Cache Management</h4>
<p>If your benchmark touches the filesystem, cold vs. warm page cache can dramatically change results. Either warm the cache deliberately before measurement, or drop it to start from a known state:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Drop all caches (requires root)</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#ae81ff">3</span> &gt; /proc/sys/vm/drop_caches <span style="color:#f92672">&amp;&amp;</span> sync
</span></span></code></pre></td></tr></table>
</div>
</div><h4 id="simultaneous-multithreading-smt">Simultaneous Multithreading (SMT)</h4>
<p>SMT (marketed as Hyper-Threading on Intel CPUs) allows two hardware threads to share a single physical core. They share execution resources — ALUs, caches, branch predictors — while maintaining separate architectural state.</p>
<p>For I/O-bound workloads, this is fine: one thread executes while the other waits for I/O. But for CPU-bound benchmarks, SMT introduces severe contention. Two threads fight over the same execution units, and the resulting interference shows up as variance in your measurements.</p>
<p>We ran a simple experiment on an AWS <code>m5.metal</code> instance with DFS disabled, measuring two CPU-bound tasks running on the same core (SMT enabled) vs. separate cores (SMT disabled):</p>
<table>
	<thead>
			<tr>
					<th>Configuration</th>
					<th>Mean</th>
					<th>Coeff. of Variation</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>SMT enabled, task 1</td>
					<td>1537.64 +/- 367.29 ms</td>
					<td><strong>23.887%</strong></td>
			</tr>
			<tr>
					<td>SMT enabled, task 2</td>
					<td>1536.88 +/- 366.84 ms</td>
					<td><strong>23.869%</strong></td>
			</tr>
			<tr>
					<td>SMT disabled, task 1</td>
					<td>737.37 +/- 0.32 ms</td>
					<td><strong>0.044%</strong></td>
			</tr>
			<tr>
					<td>SMT disabled, task 2</td>
					<td>737.93 +/- 1.74 ms</td>
					<td><strong>0.235%</strong></td>
			</tr>
	</tbody>
</table>
<p>That&rsquo;s <strong>100x less variance</strong> with SMT disabled. The tasks also run twice as fast because they&rsquo;re no longer contending for shared execution resources.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Disable SMT</span>
</span></span><span style="display:flex;"><span>echo off &gt; /sys/devices/system/cpu/smt/control
</span></span></code></pre></td></tr></table>
</div>
</div><h4 id="dynamic-frequency-scaling-dfs">Dynamic Frequency Scaling (DFS)</h4>
<p>Modern CPUs adjust their clock frequency dynamically based on workload, thermals, and power budgets. Intel calls the upward scaling &ldquo;Turbo Boost.&rdquo; This is great for general-purpose computing but terrible for benchmarking — the frequency varies based on how many cores are active, the ambient temperature, and the power headroom.</p>
<p>A single-threaded benchmark might run at 3.5 GHz. Start another workload on a neighboring core and the frequency drops to 3.1 GHz. Your benchmark just got 11% slower, and the code didn&rsquo;t change.</p>
<p>We measured this on the same <code>m5.metal</code> instance with SMT disabled, varying the number of concurrent CPU-bound tasks:</p>
<table>
	<thead>
			<tr>
					<th>Configuration</th>
					<th>Mean</th>
					<th>Coeff. of Variation</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>DFS on, 1 task</td>
					<td>533.97 +/- 2.046 ms</td>
					<td><strong>0.383%</strong></td>
			</tr>
			<tr>
					<td>DFS on, 8 tasks</td>
					<td>578.67 +/- 0.287 ms</td>
					<td>0.050%</td>
			</tr>
			<tr>
					<td>DFS off, 1 task</td>
					<td>738.18 +/- 0.306 ms</td>
					<td><strong>0.041%</strong></td>
			</tr>
			<tr>
					<td>DFS off, 8 tasks</td>
					<td>739.18 +/- 0.351 ms</td>
					<td>0.047%</td>
			</tr>
	</tbody>
</table>
<p>With DFS enabled, the single-task case shows ~10x more variance than with DFS disabled. The absolute runtime is higher with DFS off (the CPU runs at its base frequency rather than boosting), but the measurements are rock-solid. When benchmarking, consistency matters more than raw speed.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Pin clock rate to base frequency</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#ae81ff">2500000</span> &gt; /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Set scaling governor to &#34;performance&#34;</span>
</span></span><span style="display:flex;"><span>echo performance &gt; /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disable Turbo Boost (Intel CPUs)</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#ae81ff">1</span> &gt; /sys/devices/system/cpu/intel_pstate/no_turbo
</span></span></code></pre></td></tr></table>
</div>
</div><p>Denis Bakhvalov&rsquo;s <a href="https://github.com/dendibakh/perf-book">Performance Analysis and Tuning on Modern CPUs</a> covers CPU-level tuning in depth and is the definitive reference on this topic.</p>
<hr>
<h3 id="benchmark-design">Benchmark Design</h3>
<p>Environment control reduces noise. Good benchmark design ensures the signal you&rsquo;re measuring is actually meaningful.</p>
<h4 id="representative-workloads">Representative Workloads</h4>
<p>A benchmark is only useful if it measures something that matters. What does your application actually do?</p>
<table>
	<thead>
			<tr>
					<th>Archetype</th>
					<th>Pattern</th>
					<th>Characteristics</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Idle</strong></td>
					<td>Background workers, minimal load</td>
					<td>Low RPS, minimal CPU</td>
			</tr>
			<tr>
					<td><strong>Latency</strong></td>
					<td>Microservices, APIs</td>
					<td>High RPS, low CPU per request</td>
			</tr>
			<tr>
					<td><strong>Throughput</strong></td>
					<td>Queue workers, batch processing</td>
					<td>Moderate RPS, high CPU</td>
			</tr>
			<tr>
					<td><strong>Enterprise</strong></td>
					<td>Business apps with DB/API calls</td>
					<td>Moderate RPS, mixed CPU/IO</td>
			</tr>
	</tbody>
</table>
<p>Your benchmark workload should match your production workload. A microbenchmark that measures a tight loop in isolation won&rsquo;t tell you much about how your API server handles realistic traffic patterns.</p>
<p>That said, microbenchmarks have their place. They&rsquo;re invaluable for comparing algorithms, validating specific optimizations, and catching regressions in hot paths. The key is knowing which type fits your question:</p>
<table>
	<thead>
			<tr>
					<th>Use Case</th>
					<th>Benchmark Type</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Comparing algorithms</td>
					<td>Micro</td>
			</tr>
			<tr>
					<td>Validating optimizations</td>
					<td>Micro</td>
			</tr>
			<tr>
					<td>Regression detection</td>
					<td>Both</td>
			</tr>
			<tr>
					<td>Capacity planning</td>
					<td>Macro</td>
			</tr>
			<tr>
					<td>User experience</td>
					<td>Macro</td>
			</tr>
	</tbody>
</table>
<p>Best practice: use both in your pipeline.</p>
<h4 id="the-coordinated-omission-problem">The Coordinated Omission Problem</h4>
<p>If your load generator waits for each response before sending the next request, it&rsquo;s probably lying to you. When the system under test slows down, the generator slows down too — sending fewer requests per second, which artificially improves the measured latencies.</p>
<p>Gil Tene&rsquo;s talk <a href="https://www.youtube.com/watch?v=lJ8ydIuPFeU">&ldquo;How NOT to Measure Latency&rdquo;</a> is the definitive explanation of this problem. The short version: use load generators that maintain a constant request rate regardless of response time. Tools like <a href="https://k6.io/">k6</a> and <a href="https://github.com/giltene/wrk2">wrk2</a> handle this correctly.</p>
<h4 id="warm-up-and-steady-state">Warm-Up and Steady State</h4>
<p>We learned this the hard way with a Java benchmark. The goal: measure instrumentation overhead on a Spring application. Initial setup: 20-second warmup, 15 seconds of measurements, collecting one sample per second.</p>
<p>The coefficient of variation was <strong>11.80%</strong> — far too noisy to detect real changes.</p>
<p>The problem was warmup. The JVM compiles methods on the fly (JIT compilation). Each method needs to be called enough times to hit the compilation threshold, then you wait for the compiler to finish. Twenty seconds wasn&rsquo;t nearly enough. By extending the warmup to 160 seconds and the measurement period to match, the picture changed completely.</p>
<p>From the experiments:</p>
<p><strong>Tip 1</strong>: Run benchmarks long enough to uncover perturbations like warmup effects.</p>
<p><strong>Tip 2</strong>: Collect enough samples to reduce intra-run variation. N &gt;= 30 is a reasonable minimum.</p>
<p><strong>Tip 3</strong>: Rerun benchmarks multiple times to reduce inter-run variation. M &gt;= 5 runs helps account for <a href="https://link.springer.com/chapter/10.1007/11758525_26">random initial state effects</a> (cache layout, memory placement).</p>
<p>Applying all three tips reduced the coefficient of variation from <strong>11.80% to 2.94%</strong> — a 4x improvement from benchmark design alone, before any environment control.</p>
<p><strong>Tip 4</strong>: Use deterministic inputs. Non-deterministic data leads to non-deterministic measurements.</p>
<hr>
<h3 id="statistical-methods">Statistical Methods</h3>
<p>You&rsquo;ve controlled the environment and designed a good benchmark. Now you have data. The question is: is the difference you&rsquo;re seeing real, or noise?</p>
<h4 id="why-averages-lie">Why Averages Lie</h4>
<p>Consider a throughput benchmark run before and after a code change. The &ldquo;before&rdquo; mean is 102.7 req/s. The &ldquo;after&rdquo; mean is 105.0 req/s. That&rsquo;s a 2.3% improvement. Ship it?</p>
<p>Not so fast. Each of those means summarizes a distribution of individual measurements. If those distributions overlap significantly, the difference between the means might not be statistically significant — it could easily arise from random variation alone.</p>
<h4 id="hypothesis-testing">Hypothesis Testing</h4>
<p>The intuition is straightforward: compare the size of the difference to the size of the noise.</p>
<p>The <a href="https://en.wikipedia.org/wiki/Welch%27s_t-test">Welch&rsquo;s t-test</a> formalizes this. It computes a test statistic <em>t</em> that is essentially the ratio of the mean difference to the standard error. If <em>t</em> exceeds a critical value (determined by your chosen false positive rate, alpha), you can conclude the difference is statistically significant.</p>
<p>The key insight: <strong>a statistically significant result tells you the difference is unlikely to be zero, but not that the difference is large or practically meaningful.</strong> Always pair hypothesis testing with effect size estimates. A 0.1% improvement might be statistically significant with enough samples — but not worth the code complexity.</p>
<h4 id="change-point-detection">Change Point Detection</h4>
<p>Hypothesis testing works well when you have a clear &ldquo;before&rdquo; and &ldquo;after.&rdquo; But what about continuous benchmarking, where you&rsquo;re tracking performance across hundreds of commits?</p>
<p>Change point detection algorithms scan a time series and identify where the underlying distribution shifts. The <a href="https://aakinshin.net/posts/edpelt/">e-divisive method</a> (ED-PELT) is particularly effective for benchmark data. It handles non-normal distributions, detects multiple change points, and works well with the kind of noisy data that benchmarks produce.</p>
<p>Netflix&rsquo;s engineering team wrote an excellent post on <a href="https://netflixtechblog.com/fixing-performance-regressions-before-they-happen-eab2602b86fe">fixing performance regressions before they happen</a>, which covers their use of change point detection in continuous benchmarking.</p>
<p><a href="https://blog.nyrkio.com/2025/06/12/slides-from-presentation-to-spec-devops-performance-wg/">Henrik Ingo</a> (who spoke in the same Software Performance Devroom at FOSDEM) has published extensively on applying these methods in practice.</p>
<h4 id="visualization-strip-plots-over-boxplots">Visualization: Strip Plots Over Boxplots</h4>
<p>Boxplots hide too much. They show quartiles and a median, but they obscure the actual distribution shape — bimodality, outlier clusters, and gaps all disappear into a box.</p>
<p>Strip plots (dot plots of every individual measurement) are better for benchmark data. They make outliers obvious, reveal distribution shape at a glance, and scale well for the sample sizes typical in benchmarking (30-200 points).</p>
<p>Brendan Gregg&rsquo;s work on <a href="https://www.brendangregg.com/FrequencyTrails/outliers.html#Causes">frequency trails</a> is excellent on this topic — showing how visualization choices affect your ability to detect real patterns in performance data.</p>
<hr>
<h3 id="integrating-into-development-workflows">Integrating Into Development Workflows</h3>
<p>Reliable measurement is only half the problem. The other half is making performance a first-class part of the development process.</p>
<h4 id="the-feedback-loop">The Feedback Loop</h4>
<p>The ideal: a developer opens a pull request, benchmarks run automatically, and within minutes they see whether their changes have performance implications. If there&rsquo;s a regression, they know about it before the code merges — not weeks later when a customer notices.</p>
<p>This requires:</p>
<ol>
<li><strong>Automated benchmark execution</strong> triggered by code changes</li>
<li><strong>Statistical analysis</strong> to distinguish real regressions from noise</li>
<li><strong>Clear reporting</strong> that developers can act on — not a wall of numbers, but a concise &ldquo;this got 3% slower, here&rsquo;s the data&rdquo;</li>
<li><strong>Local reproducibility</strong> so developers can investigate and fix regressions on their own machines</li>
</ol>
<h4 id="performance-quality-gates">Performance Quality Gates</h4>
<p>Beyond PR-level feedback, performance quality gates can block releases that don&rsquo;t meet defined SLOs. The philosophy is the same as any other quality gate — you wouldn&rsquo;t ship without passing tests, so don&rsquo;t ship without passing performance benchmarks.</p>
<h4 id="when-to-benchmark">When to Benchmark</h4>
<p>The answer depends on your resources and risk tolerance:</p>
<table>
	<thead>
			<tr>
					<th>Strategy</th>
					<th>Cost</th>
					<th>Coverage</th>
					<th>Best For</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Every PR</td>
					<td>High</td>
					<td>Complete</td>
					<td>Critical paths, performance-sensitive libraries</td>
			</tr>
			<tr>
					<td>Periodic (nightly/weekly)</td>
					<td>Medium</td>
					<td>Trend detection</td>
					<td>General regression catching</td>
			</tr>
			<tr>
					<td>On-demand</td>
					<td>Low</td>
					<td>Targeted</td>
					<td>Investigation, optimization validation</td>
			</tr>
	</tbody>
</table>
<p>For most teams, a combination works best: lightweight benchmarks on every PR, comprehensive macrobenchmarks nightly, and on-demand deep dives when investigating specific issues.</p>
<h4 id="open-source-tools">Open Source Tools</h4>
<p>You don&rsquo;t need to build a benchmarking platform from scratch. Several open source projects can get you started:</p>
<ul>
<li><a href="https://bencher.dev/"><strong>bencher.dev</strong></a> — Continuous benchmarking as a service. Tracks benchmark results over time, detects regressions, and integrates with CI/CD.</li>
<li><a href="https://github.com/sharkdp/hyperfine"><strong>hyperfine</strong></a> — A CLI benchmarking tool for comparing command execution times. Handles warmup, statistical analysis, and parameterized runs.</li>
<li><a href="https://github.com/benchmark-action/github-action-benchmark"><strong>github-action-benchmark</strong></a> — GitHub Action for running benchmarks and tracking results over time, with support for Go, Python, Rust, and other language-specific benchmark formats.</li>
<li><a href="https://github.com/dandavison/chronologer"><strong>chronologer</strong></a> — Benchmark tracking focused on Go benchmarks with historical comparison.</li>
<li><a href="https://blog.nyrkio.com/2025/05/08/welcome-apache-otava-incubating-project/"><strong>Apache Otava</strong></a> (formerly Nyrkio, incubating) — Performance change point detection service, built on the e-divisive algorithm.</li>
<li><a href="https://github.com/aclements/perflock"><strong>perflock</strong></a> — A tool for locking CPU frequency and other system settings during benchmarks. Useful for local development.</li>
</ul>
<p>The right tool depends on your language, CI system, and how much you want to self-host vs. use a managed service.</p>
<hr>
<h3 id="key-takeaways">Key Takeaways</h3>
<p>Four things to remember:</p>
<ol>
<li>
<p><strong>Control your benchmarking environment.</strong> Bare metal instances, CPU isolation, disable SMT, disable dynamic frequency scaling. Environment noise is the single largest source of unreliable measurements.</p>
</li>
<li>
<p><strong>Design your benchmarks to be representative and repeatable.</strong> Match your production workload. Run long enough. Collect enough samples. Rerun multiple times.</p>
</li>
<li>
<p><strong>Interpret results with statistical rigor.</strong> Don&rsquo;t trust averages. Use hypothesis testing or change point detection. Always ask: is this difference real, or noise?</p>
</li>
<li>
<p><strong>Integrate benchmarks into your development workflow.</strong> Run continuously. Catch regressions on PRs. Make performance feedback as fast as test feedback.</p>
</li>
</ol>
<hr>
<h3 id="performance-matters">Performance Matters</h3>
<p>Performance is not always the first thing we think about when building software. We focus on features, correctness, security. And those are right to come first. But in the end, performance is what users experience.</p>
<p>Low latency means your users aren&rsquo;t waiting. High throughput means your system handles the load. Cost-efficient performance means you&rsquo;re not burning money (and energy) on infrastructure that could be halved with the right optimization. A <a href="https://www.brendangregg.com/blog/2020-07-15/systems-performance-2nd-edition.html">500ms delay costs Google 20% of their traffic</a>. A 400ms improvement gave Yahoo 5-9% more traffic. The numbers are real.</p>
<blockquote>
<p>&ldquo;Not all fast software is world-class, but all world-class software is fast.&rdquo;
&ndash; Tobi Lutke, CEO of Shopify</p>
</blockquote>
<p>So write benchmarks. Run them continuously. Catch regressions before your users do.</p>
<p>And don&rsquo;t shout in the datacenter.</p>
<hr>
<h3 id="resources">Resources</h3>
<ul>
<li><a href="https://github.com/igoragoli/fosdem-2026-software-performance">Slides and experiments (GitHub)</a></li>
<li><a href="https://www.youtube.com/watch?v=8211fNI_nc4">Talk recording (YouTube)</a></li>
<li><a href="/talks/how-to-reliably-measure-software-performance/">Talk page</a></li>
<li><a href="/posts/fosdem-2026/">FOSDEM 2026 recap</a></li>
<li><a href="/posts/otel-unplugged-eu-2026/">OTel Unplugged EU 2026: Field Notes</a></li>
<li>Bakhvalov, D. — <a href="https://github.com/dendibakh/perf-book">Performance Analysis and Tuning on Modern CPUs</a></li>
<li>Gregg, B. — <a href="https://www.brendangregg.com/blog/2020-07-15/systems-performance-2nd-edition.html">Systems Performance: Enterprise and the Cloud</a>, 2nd ed.</li>
<li>Tene, G. — <a href="https://www.youtube.com/watch?v=lJ8ydIuPFeU">How NOT to Measure Latency</a></li>
<li>Kalibera, T. et al. — <a href="https://link.springer.com/chapter/10.1007/11758525_26">Benchmark Precision and Random Initial State</a></li>
<li>Leiserson, C. et al. — <a href="https://science.sciencemag.org/content/368/6495/eaam9744">There&rsquo;s Plenty of Room at the Top</a> (Science, 2020)</li>
<li>Netflix Engineering — <a href="https://netflixtechblog.com/fixing-performance-regressions-before-they-happen-eab2602b86fe">Fixing Performance Regressions Before They Happen</a></li>
<li>Ingo, H. — <a href="https://blog.nyrkio.com/2025/06/12/slides-from-presentation-to-spec-devops-performance-wg/">Change Point Detection for Performance</a></li>
<li>Gregg, B. — <a href="https://www.brendangregg.com/FrequencyTrails/outliers.html#Causes">Frequency Trails: Outliers</a></li>
<li><a href="https://www.mongodb.com/company/blog/engineering/reducing-variability-performance-tests-ec2-setup-key-results">MongoDB: Reducing Variability in EC2 Performance Tests</a></li>
</ul>
]]></content:encoded></item><item><title>Auto-Instrumenting Go: From eBPF to USDT Probes</title><link>https://kakkoyun.me/posts/fosdem-2026-auto-instrumenting-go/</link><pubDate>Fri, 27 Feb 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/fosdem-2026-auto-instrumenting-go/</guid><description>A deep dive into approaches for instrumenting Go applications without changing source code — comparing compile-time, eBPF, runtime injection, and USDT methods with benchmarks.</description><content:encoded><![CDATA[<p>This post expands on the <a href="/talks/how-to-instrument-go-without-changing-code/">FOSDEM 2026 Go Devroom talk</a> I co-presented with <a href="https://hannahkm.github.io">Hannah S. Kim</a>. The talk, demo code, and all benchmark scenarios are available in the <a href="https://github.com/kakkoyun/fosdem-2026">fosdem-2026 repository</a>.</p>
<hr>
<h3 id="the-problem">The Problem</h3>
<p>Go is one of the best languages for building production backend services. It compiles to native binaries, has excellent concurrency primitives, and produces predictable performance characteristics. But when it comes to auto-instrumentation — adding observability without modifying source code — Go is uniquely difficult.</p>
<p>In the JVM world, bytecode manipulation gives you powerful hooks. Java agents can intercept method calls, inject tracing, and propagate context without the application developer knowing. Python and Node.js have similar dynamic capabilities. Go has none of this.</p>
<p>The reasons are structural:</p>
<ul>
<li><strong>Static compilation.</strong> Go compiles to a single native binary. There is no intermediate bytecode to rewrite at load time, no classloader to intercept, no dynamic linking by default.</li>
<li><strong>No <code>LD_PRELOAD</code>.</strong> Go&rsquo;s default static linking means the <code>LD_PRELOAD</code> trick that works for C/C++ applications (and that the <a href="https://github.com/open-telemetry/opentelemetry-injector">OTel Injector</a> uses for Java, .NET, and Node.js) doesn&rsquo;t apply.</li>
<li><strong>Unique calling convention.</strong> Go&rsquo;s ABI passes arguments in registers with a convention different from the platform C ABI. This makes dynamic hooking with tools like Frida or ptrace significantly harder — you can&rsquo;t just read standard frame pointers.</li>
<li><strong>goroutine stack management.</strong> goroutines use segmented, growable stacks that the runtime can move at any time. Traditional stack-walking assumptions break.</li>
</ul>
<p>The gap between &ldquo;Go is great for production&rdquo; and &ldquo;Go is hard to auto-instrument&rdquo; is real. This is the gap we set out to map.</p>
<hr>
<h3 id="the-comparison-framework">The Comparison Framework</h3>
<p>We built a <a href="https://github.com/kakkoyun/fosdem-2026">demo repository</a> with the same Go HTTP server implemented across seven scenarios, each using a different instrumentation approach. The application is deliberately simple — an HTTP server with configurable CPU load, memory allocation, and off-CPU time — so that instrumentation overhead is isolated and measurable.</p>
<h4 id="the-seven-scenarios">The Seven Scenarios</h4>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>Scenario</th>
					<th>Approach</th>
					<th>What It Does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td><code>default</code></td>
					<td>None</td>
					<td>Baseline. No instrumentation of any kind.</td>
			</tr>
			<tr>
					<td>2</td>
					<td><code>manual</code></td>
					<td>OTel SDK</td>
					<td>Manual OpenTelemetry SDK integration — explicit tracer initialization, span creation via <code>otelhttp</code>, and context propagation. The &ldquo;standard&rdquo; way.</td>
			</tr>
			<tr>
					<td>3</td>
					<td><code>obi</code></td>
					<td>eBPF (OBI)</td>
					<td><a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation">OpenTelemetry eBPF Instrumentation</a>. Network-level eBPF hooks. Runs as a sidecar, attaches to the running process. No code changes.</td>
			</tr>
			<tr>
					<td>4</td>
					<td><code>ebpf</code></td>
					<td>eBPF (Auto)</td>
					<td><a href="https://github.com/open-telemetry/opentelemetry-go-instrumentation">OpenTelemetry Go auto-instrumentation</a>. uprobe-based eBPF hooks targeting Go runtime functions. No code changes.</td>
			</tr>
			<tr>
					<td>5</td>
					<td><code>orchestrion</code></td>
					<td>Compile-time</td>
					<td><a href="https://github.com/datadog/orchestrion">Datadog Orchestrion</a> with OTel SDK. AST transformation via <code>-toolexec</code> at compile time. Requires a rebuild but no source changes.</td>
			</tr>
			<tr>
					<td>6</td>
					<td><code>libstabst</code></td>
					<td>USDT (salp)</td>
					<td>USDT probes via <a href="https://github.com/mmcshane/salp">salp</a>/<a href="https://github.com/linux-usdt/libstapsdt">libstapsdt</a>, consumed by a bpftrace sidecar that exports to OTLP. Proof of concept.</td>
			</tr>
			<tr>
					<td>7</td>
					<td><code>usdt</code></td>
					<td>USDT (native)</td>
					<td>Native USDT probes via a <a href="https://github.com/kakkoyun/go/tree/poc_usdt">custom Go fork</a> that adds probe points to <code>net/http</code>, <code>database/sql</code>, <code>crypto/tls</code>, and <code>net</code>. Proof of concept.</td>
			</tr>
	</tbody>
</table>
<p>Each scenario runs in Docker with an identical observability stack (OTel Collector, Jaeger, Prometheus) and is load-tested with identical parameters.</p>
<h4 id="evaluation-axes">Evaluation Axes</h4>
<p>We compared the approaches across three dimensions:</p>
<ul>
<li><strong>Performance overhead</strong> — latency, CPU, memory (RSS), throughput</li>
<li><strong>Robustness</strong> — stability across Go versions, container environments, failure modes</li>
<li><strong>Operational friction</strong> — deployment complexity, privilege requirements, debugging</li>
</ul>
<hr>
<h3 id="manual-otel-sdk-baseline-for-comparison">Manual OTel SDK (Baseline for Comparison)</h3>
<p>The manual scenario is not auto-instrumentation — it is the standard way to instrument a Go service. You import the OTel SDK, initialize a tracer provider, wrap your HTTP handler with <code>otelhttp.NewHandler</code>, and create spans explicitly.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">setupHandlers</span>(<span style="color:#a6e22e">inputs</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Input</span>) <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Handler</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">mux</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">NewServeMux</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">mux</span>.<span style="color:#a6e22e">HandleFunc</span>(<span style="color:#e6db74">&#34;/health&#34;</span>, <span style="color:#a6e22e">HealthHandler</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">mux</span>.<span style="color:#a6e22e">HandleFunc</span>(<span style="color:#e6db74">&#34;/load&#34;</span>, <span style="color:#a6e22e">inputs</span>.<span style="color:#a6e22e">LoadHandler</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">otelhttp</span>.<span style="color:#a6e22e">NewHandler</span>(<span style="color:#a6e22e">mux</span>, <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Input</span>) <span style="color:#a6e22e">LoadHandler</span>(<span style="color:#a6e22e">w</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">ResponseWriter</span>, <span style="color:#a6e22e">r</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Request</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tracer</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">otel</span>.<span style="color:#a6e22e">Tracer</span>(<span style="color:#e6db74">&#34;manual&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">span</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">tracer</span>.<span style="color:#a6e22e">Start</span>(<span style="color:#a6e22e">r</span>.<span style="color:#a6e22e">Context</span>(), <span style="color:#e6db74">&#34;manual.handler&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">defer</span> <span style="color:#a6e22e">span</span>.<span style="color:#a6e22e">End</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... business logic</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>This gives you full control — custom span attributes, context propagation, error recording. But it requires code changes in every service, and those changes accumulate. Multiply by a hundred microservices and you understand why auto-instrumentation matters.</p>
<hr>
<h3 id="compile-time-orchestrion-and-otel-compile-time-instrumentation">Compile-time: Orchestrion and OTel Compile-time Instrumentation</h3>
<p>Orchestrion uses Go&rsquo;s <code>-toolexec</code> flag to intercept the compilation pipeline. During the AST transformation phase, it injects instrumentation code — adding OTel spans, wrapping handlers, propagating context — without the developer modifying source files.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go build -toolexec <span style="color:#e6db74">&#39;orchestrion toolexec&#39;</span> -o myapp .
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Or equivalently:</span>
</span></span><span style="display:flex;"><span>orchestrion go build -o myapp .
</span></span></code></pre></td></tr></table>
</div>
</div><p>The mechanism is aspect-oriented: you declare join points (e.g., &ldquo;any function in package <code>main</code> named <code>LoadHandler</code>&rdquo;) and advice (e.g., &ldquo;prepend a span creation statement&rdquo;). The transformation happens at the AST level before the compiler emits machine code.</p>
<p>Orchestrion supports OpenTelemetry natively — it is not Datadog-specific. In January 2025, Datadog and Alibaba began merging their compile-time instrumentation efforts into a unified solution under the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation">OpenTelemetry Compile-time Instrumentation SIG</a>.</p>
<p><strong>Trade-offs:</strong></p>
<ul>
<li>Requires a rebuild. You cannot instrument already-deployed binaries.</li>
<li>Deepest instrumentation of all approaches — it can instrument stdlib and dependencies.</li>
<li>Zero runtime overhead from the instrumentation mechanism itself (the injected OTel code has the same cost as manual instrumentation).</li>
<li>Stable across Go versions (the toolexec interface is stable).</li>
<li>No kernel privileges required.</li>
</ul>
<p>For a deeper dive into the <code>-toolexec</code> mechanism, see my earlier <a href="/talks/unleashing-the-go-toolchain/">Unleashing the Go Toolchain</a> talk from GopherCon UK 2025.</p>
<hr>
<h3 id="ebpf-approaches">eBPF Approaches</h3>
<h4 id="obi-opentelemetry-ebpf-instrumentation">OBI (OpenTelemetry eBPF Instrumentation)</h4>
<p><a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation">OBI</a> takes a network-level approach. It uses eBPF programs to hook into kernel-level network operations, intercepting HTTP/S and gRPC traffic. It is multi-language — Go, Java, .NET, Python, Node.js, Ruby, Rust — because it operates at the protocol layer rather than the language runtime layer.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker run --privileged <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --pid<span style="color:#f92672">=</span>container:myapp <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -e OTEL_EXPORTER_OTLP_ENDPOINT<span style="color:#f92672">=</span>http://collector:4318 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  otel/ebpf-instrumentation:latest
</span></span></code></pre></td></tr></table>
</div>
</div><p>OBI runs as a sidecar container. It attaches to the target process&rsquo;s PID namespace and loads eBPF programs that intercept network system calls. No source code modification, no recompilation, no restart.</p>
<p><strong>Trade-offs:</strong></p>
<ul>
<li>Requires <code>CAP_SYS_ADMIN</code> or privileged containers. Security teams push back on this.</li>
<li>Limited to what eBPF can observe at the network level. Application-internal spans are not visible.</li>
<li>Protocol coverage is growing: HTTP/S, gRPC, TLS visibility.</li>
<li>Excellent for topology mapping and network observability beyond just tracing.</li>
</ul>
<h4 id="otel-go-auto-instrumentation">OTel Go auto-instrumentation</h4>
<p>The <a href="https://github.com/open-telemetry/opentelemetry-go-instrumentation">OpenTelemetry Go auto-instrumentation</a> project uses uprobe-based eBPF hooks that target specific Go runtime functions. Unlike OBI&rsquo;s network-level approach, this hooks directly into Go function prologues.</p>
<p>This project is effectively in maintenance mode. Several of its contributors have moved to OBI. At <a href="/posts/otel-unplugged-eu-2026/">OTel Unplugged EU 2026</a>, the frank assessment was: the people moved to where the momentum is.</p>
<hr>
<h3 id="runtime-injection-frida-and-ptrace">Runtime Injection: Frida and ptrace</h3>
<p>The <code>injector</code> scenario explores dynamic instrumentation via <a href="https://frida.re/">Frida</a>, a ptrace-based toolkit for runtime function hooking. The idea is conceptually simple: attach to a running process, find the function you want to hook, and replace its prologue with a trampoline that calls your instrumentation code.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// The application code uses //go:noinline to keep functions hookable.</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">//go:noinline</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Input</span>) <span style="color:#a6e22e">LoadHandler</span>(<span style="color:#a6e22e">w</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">ResponseWriter</span>, <span style="color:#a6e22e">_</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Request</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... business logic</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>In practice, this is extremely hard for Go binaries. <a href="https://blog.quarkslab.com/lets-go-into-the-rabbit-hole-part-1-the-challenges-of-dynamically-hooking-golang-program.html">Quarkslab&rsquo;s excellent three-part series</a> documents the challenges in detail: Go&rsquo;s register-based calling convention, goroutine stack relocation, and compiler optimizations (inlining, dead code elimination) all conspire against reliable dynamic hooking.</p>
<p>The demo&rsquo;s injector scenario includes a helper tool that uses <code>unsafe.Offsetof</code> to find <code>http.Request</code> struct field offsets — information you need just to read the HTTP method and path from a hooked function&rsquo;s arguments.</p>
<p><strong>Trade-offs:</strong></p>
<ul>
<li>Works with existing binaries. No rebuild required.</li>
<li>Requires <code>-gcflags=&quot;all=-N -l&quot;</code> to disable optimizations, which defeats the purpose for production.</li>
<li>Fragile across Go versions — struct layouts and calling conventions change.</li>
<li>Limited applicability for Go&rsquo;s statically linked binaries.</li>
</ul>
<p>For Go, this approach is more useful as a debugging tool than a production instrumentation strategy.</p>
<hr>
<h3 id="usdt-probes-the-novel-part">USDT Probes: The Novel Part</h3>
<p>USDT (User Statically-Defined Tracing) probes are a mechanism from the DTrace/SystemTap world. They are marker points compiled into a binary that external tooling (bpftrace, perf, DTrace) can attach to at runtime. The key properties: <strong>the sites are stable and named, so a tool attaches without disassembling functions, and no uretprobe is needed</strong>, which matters for Go because uretprobes and Go&rsquo;s moving stacks don&rsquo;t mix. When no consumer is attached, the probe site itself is a NOP instruction. The arguments it would pass can still cost something to compute, which the fork below hasn&rsquo;t benchmarked yet.</p>
<p>We built two proof-of-concept implementations.</p>
<h4 id="libstabst-usdt-via-salp-and-bpftrace">libstabst: USDT via salp and bpftrace</h4>
<p>The <code>libstabst</code> scenario uses <a href="https://github.com/mmcshane/salp">salp</a>, a Go binding to <a href="https://github.com/linux-usdt/libstapsdt">libstapsdt</a>, to create USDT probes at runtime. The application defines probe points for request start and end:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">probes</span> = <span style="color:#a6e22e">salp</span>.<span style="color:#a6e22e">NewProvider</span>(<span style="color:#e6db74">&#34;fosdem&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">reqStart</span>, <span style="color:#a6e22e">_</span> = <span style="color:#a6e22e">probes</span>.<span style="color:#a6e22e">AddProbe</span>(<span style="color:#e6db74">&#34;request_start&#34;</span>, <span style="color:#a6e22e">salp</span>.<span style="color:#a6e22e">String</span>, <span style="color:#a6e22e">salp</span>.<span style="color:#a6e22e">Int64</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">reqEnd</span>, <span style="color:#a6e22e">_</span> = <span style="color:#a6e22e">probes</span>.<span style="color:#a6e22e">AddProbe</span>(<span style="color:#e6db74">&#34;request_end&#34;</span>, <span style="color:#a6e22e">salp</span>.<span style="color:#a6e22e">String</span>, <span style="color:#a6e22e">salp</span>.<span style="color:#a6e22e">Int64</span>, <span style="color:#a6e22e">salp</span>.<span style="color:#a6e22e">Int64</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">probes</span>.<span style="color:#a6e22e">Load</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// In the handler:</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#a6e22e">reqStart</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">reqStart</span>.<span style="color:#a6e22e">Enabled</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">reqStart</span>.<span style="color:#a6e22e">Fire</span>(<span style="color:#a6e22e">reqID</span>, <span style="color:#a6e22e">startTime</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>A bpftrace sidecar attaches to these probes and exports events as OTLP traces via a custom exporter bridge.</p>
<p><strong>Known limitations:</strong> The salp library has compatibility issues with Go 1.25+, pinning this scenario to Go 1.23.x. It also needs <code>/proc/self/fd/</code> access for mmap, which fails in many container environments. On bare metal Linux or in a Lima VM, it works.</p>
<p><picture><source type="image/webp" srcset="/uploads/fosdem26_go_usdt_hu_dd72fc791e6abd4b.webp 480w, /uploads/fosdem26_go_usdt_hu_91a977e5c42abdff.webp 768w, /uploads/fosdem26_go_usdt_hu_a564435b653279fb.webp 1024w, /uploads/fosdem26_go_usdt_hu_c24d1a39f6c4118d.webp 1366w, /uploads/fosdem26_go_usdt_hu_ae9fd1d515b94be7.webp 1920w, /uploads/fosdem26_go_usdt_hu_8c458d52d9d2037.webp 3000w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/fosdem26_go_usdt_hu_fe0bc3ea7a2335bb.png 480w, /uploads/fosdem26_go_usdt_hu_d929d706cd123001.png 768w, /uploads/fosdem26_go_usdt_hu_7a68cd86f0d234ae.png 1024w, /uploads/fosdem26_go_usdt_hu_843ccc1f3e7b20a6.png 1366w, /uploads/fosdem26_go_usdt_hu_6b98f8361baad60b.png 1920w, /uploads/fosdem26_go_usdt.png 3000w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/fosdem26_go_usdt.png" alt="Presenting the USDT &#43; eBPF proof of concept at the Go Devroom"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="3000" height="2250">
  </picture></p>
<h4 id="native-usdt-custom-go-fork">Native USDT: Custom Go Fork</h4>
<p>The more ambitious PoC is a <a href="https://github.com/kakkoyun/go/tree/poc_usdt">custom Go fork</a> that adds USDT probe points directly to the Go standard library — <code>net/http</code>, <code>database/sql</code>, <code>crypto/tls</code>, and <code>net</code>.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">import</span> <span style="color:#e6db74">&#34;runtime/trace/usdt&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">handleRequest</span>(<span style="color:#a6e22e">w</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">ResponseWriter</span>, <span style="color:#a6e22e">r</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Request</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">status</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">StatusOK</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">usdt</span>.<span style="color:#a6e22e">Probe</span>(<span style="color:#e6db74">&#34;myapp&#34;</span>, <span style="color:#e6db74">&#34;request_start&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">defer</span> <span style="color:#66d9ef">func</span>() { <span style="color:#a6e22e">usdt</span>.<span style="color:#a6e22e">Probe1</span>(<span style="color:#e6db74">&#34;myapp&#34;</span>, <span style="color:#e6db74">&#34;request_end&#34;</span>, int32(<span style="color:#a6e22e">status</span>)) }()
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... handle request, setting status as needed</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>The fork includes a <code>go tool usdt</code> command for listing probes in a binary and generating bpftrace scripts:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>$ go tool usdt list ./myserver
</span></span><span style="display:flex;"><span>PROVIDER   NAME                  ADDRESS     ARGUMENTS
</span></span><span style="display:flex;"><span>net_http   server_request_start  0x63296c    8@%rsi -8@%r8
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$ go tool usdt bpftrace ./myserver &gt; trace.bt
</span></span><span style="display:flex;"><span>$ sudo bpftrace trace.bt
</span></span></code></pre></td></tr></table>
</div>
</div><p>This PoC proves that native USDT support in Go is technically feasible. The standard library instrumentation is automatically available in any binary built with the fork — no application code changes, no SDK imports.</p>
<p><strong>Known limitations:</strong> ARM64 argument parsing in bpftrace has issues with the probe argument notation emitted by the fork. The fork is strictly a proof of concept and not suitable for production.</p>
<hr>
<h3 id="go-runtime-pocs-flight-recording">Go Runtime PoCs: Flight Recording</h3>
<p>Beyond USDT, we explored a <a href="https://github.com/kakkoyun/go/tree/1f6836932a219381e4088d61cafdfefadd0dd03c">flight recorder PoC</a> based on <a href="https://github.com/golang/go/issues/63185">golang/go#63185</a>. The concept: always-on distributed tracing built into the Go runtime, as an extension of <code>runtime/trace</code> and its flight recorder. The fork adds an event filter, W3C Trace Context types, and HTTP, SQL, TLS and network span hooks, switched on through <code>GODEBUG</code> settings (<code>tracehttp</code>, <code>tracesql</code>, <code>tracetls</code>, <code>tracenet</code>).</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">import</span> <span style="color:#e6db74">&#34;runtime/trace&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">trace</span>.<span style="color:#a6e22e">SetEventFilter</span>(<span style="color:#a6e22e">trace</span>.<span style="color:#a6e22e">FilterHTTP</span> | <span style="color:#a6e22e">trace</span>.<span style="color:#a6e22e">FilterSQL</span> | <span style="color:#a6e22e">trace</span>.<span style="color:#a6e22e">FilterNet</span>)
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>SetEventFilter</code> picks which event categories the runtime records. My hope was that this could become a low-friction path for Go; the note below says where I&rsquo;ve landed.</p>

<blockquote class="admonition admonition-note">
  <p class="admonition-title"><svg class="admonition-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg><span>Update, October 2026</span></p>
  <div class="admonition-content"><p>Go 1.25 shipped the flight recorder as a diagnostics API you call from your own code. This fork is an experiment, and I don&rsquo;t expect the Go team to take it upstream. The direction I&rsquo;d argue for instead is generic tracepoints that the standard library and runtime expose on purpose. The <a href="/talks/instrument-go-without-changing-a-single-line/">GopherCon UK 2026 talk page</a> links the follow-up series.</p></div>
</blockquote>
<hr>
<h3 id="benchmark-results">Benchmark Results</h3>
<p>We ran each scenario under identical load conditions using a Docker-based observability stack with 5-minute sustained load tests.</p>
<table>
	<thead>
			<tr>
					<th>Approach</th>
					<th>CPU</th>
					<th>Memory (RSS)</th>
					<th>Max Latency</th>
					<th>Max Throughput</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Baseline (no instrumentation)</td>
					<td>10.2%</td>
					<td>202 MiB</td>
					<td>4.50 ms</td>
					<td>3.1k req/sec</td>
			</tr>
			<tr>
					<td>Manual OTel SDK</td>
					<td>10.3% (+0.1%)</td>
					<td>210 MiB (+8 MiB)</td>
					<td>3.02 ms</td>
					<td>13.97k req/sec</td>
			</tr>
			<tr>
					<td>eBPF auto-instrumentation</td>
					<td>10.0% (-0.3%)</td>
					<td>204 MiB (+2 MiB)</td>
					<td>3.07 ms</td>
					<td>4.57k req/sec</td>
			</tr>
			<tr>
					<td>Compile-time (Orchestrion)</td>
					<td>9.8% (-0.4%)</td>
					<td>210 MiB (+8 MiB)</td>
					<td>2.59 ms</td>
					<td>27.8k req/sec</td>
			</tr>
	</tbody>
</table>
<p>A few things stand out. The CPU and memory overhead across all approaches is negligible for this workload. The throughput differences are more interesting — Orchestrion&rsquo;s compile-time approach achieved the highest throughput, likely because the OTel code injected at compile time benefits from the same optimizations as the rest of the application. The eBPF approach showed lower throughput, consistent with the overhead of crossing the kernel boundary for each intercepted call.</p>
<p>The USDT scenarios (<code>libstabst</code> and <code>usdt</code>) are not included in the table because they are proof-of-concept implementations with different exporter architectures. The probe sites stay NOPs when nothing is attached, but the cost of computing their arguments hasn&rsquo;t been measured, and end-to-end benchmarking against the other approaches requires further work.</p>
<p>Full benchmark data and reproduction instructions are in the <a href="https://github.com/kakkoyun/fosdem-2026">demo repository</a>.</p>
<hr>
<h3 id="how-the-approaches-fit-together">How the Approaches Fit Together</h3>
<p>The most grounded take from the <a href="/posts/otel-unplugged-eu-2026/">OTel Unplugged EU 2026</a> OBI/eBPF session:</p>
<blockquote>
<p>&ldquo;Document the trade-offs between OBI, compile-time, injector, and SDK. Let people choose. Make them aware of each other and let them work together.&rdquo;</p>
</blockquote>
<p>These approaches are not competing. They serve different deployment scenarios and can coexist.</p>
<table>
	<thead>
			<tr>
					<th>Approach</th>
					<th>Mechanism</th>
					<th>Best For</th>
					<th>Limitation</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Compile-time</strong> (Orchestrion)</td>
					<td>AST transformation via <code>-toolexec</code></td>
					<td>Deepest instrumentation, security-sensitive environments</td>
					<td>Requires rebuild</td>
			</tr>
			<tr>
					<td><strong>eBPF/OBI</strong></td>
					<td>Kernel-level network hooks</td>
					<td>Runtime flexibility, multi-language, no restart</td>
					<td>Needs kernel privileges</td>
			</tr>
			<tr>
					<td><strong>eBPF Auto</strong></td>
					<td>uprobe hooks on Go functions</td>
					<td>Go-specific deep tracing without code changes</td>
					<td>Maintenance mode, fragile across Go versions</td>
			</tr>
			<tr>
					<td><strong>Injector/SSI</strong></td>
					<td>K8s operator + <code>LD_PRELOAD</code></td>
					<td>Lowest friction onboarding</td>
					<td>Does not work for Go&rsquo;s static binaries</td>
			</tr>
			<tr>
					<td><strong>USDT</strong></td>
					<td>Compiled probe points + bpftrace</td>
					<td>Stable named sites, simpler attach, no uretprobes (an unattached site is a NOP; probe arguments are still computed and not yet benchmarked)</td>
					<td>Proof of concept, tooling still young</td>
			</tr>
	</tbody>
</table>
<p>The vision articulated at OTel Unplugged — <code>apt install opentelemetry</code> and everything works — requires all these layers coordinating. OBI detecting the Injector and backing off. Compile-time instrumentation detecting existing SDK usage. USDT probes coexisting with eBPF hooks. We are not there yet, but the direction is clear.</p>
<hr>
<h3 id="future-directions">Future Directions</h3>
<p>Several threads from the talk and surrounding conversations point forward:</p>
<ul>
<li>
<p><strong>OTel Compile-time SIG.</strong> The merger between Datadog&rsquo;s Orchestrion and Alibaba&rsquo;s compile-time instrumentation under the OpenTelemetry umbrella is the most significant near-term development. A vendor-neutral, community-maintained compile-time instrumentation tool for Go would change the adoption curve.</p>
</li>
<li>
<p><strong>W3C context propagation in runtimes.</strong> If language runtimes and compilers understand trace context natively, the instrumentation story simplifies fundamentally. This was a recurring theme at OTel Unplugged.</p>
</li>
<li>
<p><strong>eBPF Tokens.</strong> <a href="https://fosdem.org/2026/schedule/event/3LLHG9-bpf-tokens-safe-userspace-ebpf/">BPF Tokens</a> could significantly reduce the privilege requirements for eBPF-based instrumentation. Instead of <code>CAP_SYS_ADMIN</code>, a token-based trust model would lower the bar for security teams.</p>
</li>
<li>
<p><strong>Native USDT in Go.</strong> The PoC fork demonstrates feasibility, but it needs a lot more work from me, and it would need the Go runtime team to accept it. Nothing is proposed upstream. The pattern exists elsewhere — Postgres, MySQL, and the JVM all have static tracepoints behind flags.</p>
</li>
<li>
<p><strong>Flight recording.</strong> The <code>golang/go#63185</code> proposal shipped in Go 1.25 as a diagnostics API. Extending it into always-on distributed tracing is my own experiment, and I don&rsquo;t expect it upstream.</p>
</li>
</ul>
<hr>
<h3 id="closing">Closing</h3>
<p>The instrumentation tax is real and unavoidable. The question is not whether to pay it, but how to manage it. For Go, the answer is increasingly &ldquo;you have options&rdquo; — and those options are getting better.</p>
<p>The slides are available as <a href="https://github.com/kakkoyun/fosdem-2026/blob/main/presentation.md">Markdown</a> in the repository. The demo code, Docker setup, and benchmark scripts are all in the <a href="https://github.com/kakkoyun/fosdem-2026">FOSDEM-2026 repository</a>. The <a href="https://www.youtube.com/watch?v=0TvrSebuDPk">recording is on YouTube</a>.</p>
<p>If you want to get involved: the <a href="https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation">OTel Compile-time Instrumentation SIG</a>, <a href="https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation">OBI</a>, and <a href="https://github.com/open-telemetry/opentelemetry-go">OTel Go</a> repositories all accept contributions. The <code>#otel-go</code> and <code>#otel-ebpf-sig</code> channels on <a href="https://slack.cncf.io/">CNCF Slack</a> are where the discussions happen.</p>
<p>See also: <a href="/posts/otel-unplugged-eu-2026/">OTel Unplugged EU 2026 field notes</a> for the broader community context.</p>
]]></content:encoded></item><item><title>OTel Unplugged EU 2026: Field Notes from the Instrumentation Frontier</title><link>https://kakkoyun.me/posts/otel-unplugged-eu-2026/</link><pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/otel-unplugged-eu-2026/</guid><description>Conference field notes from OTel Unplugged EU 2026 in Brussels — covering Prometheus convergence, the injector vision, eBPF trade-offs, and the future of Go auto-instrumentation.</description><content:encoded><![CDATA[<h3 id="brussels-again-but-make-it-unplugged">Brussels Again, But Make It Unplugged</h3>
<p>The day after FOSDEM, about a hundred of us gathered at <strong>Sparks Meeting</strong> on Rue Ravenstein in Brussels for <a href="https://opentelemetry.io/blog/2025/otel-unplugged-fosdem/">OTel Unplugged EU 2026</a> — an unconference dedicated entirely to OpenTelemetry.
Purple stage lights, a mid-century auditorium with wood paneling, and the familiar buzz of people who spend their days thinking about telemetry pipelines. If you know, you know.</p>
<p><picture><source type="image/webp" srcset="/uploads/otel_unplugged_2026_agenda_hu_fd846a621cf5da73.webp 480w, /uploads/otel_unplugged_2026_agenda_hu_a59aa4705bb756c1.webp 768w, /uploads/otel_unplugged_2026_agenda_hu_57e5cd6ddcbcb85.webp 1024w, /uploads/otel_unplugged_2026_agenda_hu_bae568def94e5d40.webp 1280w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/otel_unplugged_2026_agenda_hu_8edeea3cd1e6663b.jpeg 480w, /uploads/otel_unplugged_2026_agenda_hu_eb4a6684164edd7e.jpeg 768w, /uploads/otel_unplugged_2026_agenda_hu_57e21224b9236c0b.jpeg 1024w, /uploads/otel_unplugged_2026_agenda.jpeg 1280w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/otel_unplugged_2026_agenda.jpeg" alt="OTel Unplugged agenda projected on stage"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="1280" height="1707">
  </picture></p>
<p>The format is simple: no prepared talks, no slides. Morning session brainstorming, dot-voting on topics, then self-organizing into <strong>nine rooms across four breakout slots</strong>.
You vote with your feet. If a conversation isn&rsquo;t working, you move. It&rsquo;s chaotic, it&rsquo;s honest, and it produces the kind of discussions that polished conference talks rarely achieve.</p>
<p>I spent the day bouncing between sessions on <strong>Prometheus and OpenTelemetry convergence</strong>, the <strong>Injector and Operator</strong>, <strong>OBI/eBPF</strong>, and <strong>auto-instrumentation for Go</strong>.
Four rooms, one thread connecting them all: <em>how do we make applications observable without asking developers to change their code?</em></p>
<p>Here&rsquo;s what I learned.</p>
<hr>
<h3 id="prometheus-loves-opentelemetry-its-complicated">Prometheus Loves OpenTelemetry (It&rsquo;s Complicated)</h3>
<p>Prometheus and OpenTelemetry had <strong>two sessions</strong> — one in the morning with end users and contributors, and a follow-up in the afternoon specifically for maintainers. Both were packed.
The relationship between these two projects is the kind you&rsquo;d describe as &ldquo;it&rsquo;s complicated&rdquo; on social media.</p>
<h4 id="the-resource-attributes-mess">The Resource Attributes Mess</h4>
<p>The biggest pain point? Getting OTLP data into Prometheus. <strong>Resource attributes</strong> are the central headache. OTLP has a rich hierarchy — resource, scope, and metric attributes. Prometheus is flat.
Bridging these two models means choosing between promoting all attributes, promoting some, or relying on <code>target_info</code>. There are too many config options, no consistency across deployments, and the <code>info</code> function (using <code>target_info</code>) helps but adoption is uneven.</p>
<p>One person described running an observability platform for <strong>over a thousand developers</strong>, most using Prometheus <code>remote_write</code>. Some teams want a single OTLP endpoint for logs and metrics, but that just shifts the same &ldquo;what to promote, what to drop&rdquo; problem. The frustration was palpable — someone put it bluntly: <em>&ldquo;OTel is rewriting everything again.&rdquo;</em> Different conventions (<code>.</code> vs <code>_</code>), hard <code>target_info</code> joins, and the sense that mature Prometheus semantics (<code>cluster</code>, <code>namespace</code>) are being duplicated under different names (<code>k8s.*</code>).</p>
<h4 id="migration-resistance">Migration Resistance</h4>
<p>Teams recognize the value of OTel&rsquo;s semantic conventions, but the migration path is painful. <strong>Naming inconsistencies</strong> (<code>.</code> vs <code>_</code>), hard <code>target_info</code> joins, and the cognitive overhead of moving from Prometheus&rsquo;s world view to OTel&rsquo;s. Several people mentioned that <code>PromQL IS AWESOME</code> (their emphasis, not mine) and that transformation adds overhead that people who come from a Prometheus background don&rsquo;t want to pay.</p>
<p>On the SDK side, OTel measurements require a <strong>hashmap lookup</strong> while Prometheus doesn&rsquo;t. Too many concepts — meter, instrument, aggregation — versus Prometheus&rsquo;s closer alignment to mechanical sympathy.
The performance direction being pursued? <strong>Zero allocations, no lookups</strong> — the bound instruments PoC is the concrete step toward closing that gap. Nobody in the room uses delta temporality.</p>
<blockquote>
<p>&ldquo;People care about observability, not query languages.&rdquo;</p>
</blockquote>
<h4 id="the-afternoon-maintainers-chart-a-path">The Afternoon: Maintainers Chart a Path</h4>
<p>The afternoon session brought Prometheus and OTel maintainers together. The mood was constructive. <strong>OTel SDK v2</strong> was discussed as an opportunity for the kind of breaking changes that could simplify the metrics API — a simplified, more performant, but less flexible API. The <strong>Prometheus 3.0</strong> experience was instructive: the maintainers planned for major breakage but ended up with almost none.</p>
<p>Concrete progress: <strong>David Ashpole&rsquo;s <a href="https://github.com/open-telemetry/opentelemetry-go/pull/7790">bound instruments PoC in Go</a></strong> — instruments pre-bound to specific attribute sets, eliminating the hashmap lookup.
People in the room care about Go and C++ performance, and this could shift the math significantly.</p>
<p>On the receiver/exporter convergence front: <strong>cAdvisor is considering archiving its Prometheus exporter</strong> and moving all code into the OTel collector. OTel Kubernetes monitoring is broadly adopted, with near-parity to kube-state-metrics.
The idea of Prometheus carrying an OTel Collector distribution was floated.</p>
<p>The messaging problem came into sharp focus: as one Prometheus maintainer put it, <em>&ldquo;Having joint statements helps towards the perception of working together.&rdquo;</em> The gap isn&rsquo;t just technical — it&rsquo;s about perception.
End users see two projects that look like they&rsquo;re competing, even when the maintainers are collaborating.</p>
<p><strong>Action items</strong>: use the <code>#otel-prometheus</code> Slack channel, meet again in <strong>Amsterdam</strong>, and produce <strong>joint messaging</strong> — &ldquo;this is built together and is compatible.&rdquo; Who owns that messaging? That&rsquo;s the open question.</p>
<hr>
<p><picture><source type="image/webp" srcset="/uploads/otel_unplugged_2026_topics_hu_f7a839ee7854605f.webp 480w, /uploads/otel_unplugged_2026_topics_hu_47ce88976a1c3f3f.webp 768w, /uploads/otel_unplugged_2026_topics_hu_15825b2a69a64a44.webp 1024w, /uploads/otel_unplugged_2026_topics_hu_2d6f8796d8e27d70.webp 1366w, /uploads/otel_unplugged_2026_topics_hu_7b85c669bbeb7572.webp 1600w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/otel_unplugged_2026_topics_hu_5c85196d6a62c3b3.jpeg 480w, /uploads/otel_unplugged_2026_topics_hu_4eb65bdc327858e.jpeg 768w, /uploads/otel_unplugged_2026_topics_hu_6afeb3bdd4419b1a.jpeg 1024w, /uploads/otel_unplugged_2026_topics_hu_866d2dd9150fd900.jpeg 1366w, /uploads/otel_unplugged_2026_topics.jpeg 1600w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/otel_unplugged_2026_topics.jpeg" alt="Emerging topics sorted on sticky notes during morning brainstorming"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="1600" height="1200">
  </picture></p>
<h3 id="the-injector-from-ld_preload-to-apt-install-opentelemetry">The Injector: From LD_PRELOAD to <code>apt install opentelemetry</code></h3>
<p>Two sessions covered the <strong>Injector and Operator</strong> — one focused on the general architecture, the other specifically on <strong>OBI and Injector coordination for Go</strong>.
The framing that stuck with me came early: <em>&ldquo;OTel instrumentation feels more like a collection of tools than a product.&rdquo;</em> That&rsquo;s why the Injector exists — to close the gap between what OTel offers and what users expect to just work.</p>
<h4 id="injector-vs-operator">Injector vs Operator</h4>
<p>The <strong>Injector</strong> is opinionated and out-of-the-box. It aims for <strong>80% coverage with zero configuration</strong>. The <strong>Operator</strong> is for power users who want fine-grained control. Both end users and OTel maintainers were in the room, and more people knew about the Operator than the Injector.</p>
<p>The Injector works via <code>LD_PRELOAD</code> — it hooks into process loading to activate SDK instrumentation for Java, .NET, Node.js, and soon Python. It&rsquo;s being used <strong>in production at scale</strong> on Kubernetes. It can detect libc vs musl. Blocking system start during injection? Not perceived as a problem by anyone in the room.</p>
<p>The inevitable question came up: <strong>&ldquo;What about Go?&rdquo;</strong> For Go&rsquo;s statically linked binaries, there&rsquo;s no <code>LD_PRELOAD</code> equivalent. The answer is either eBPF or compile-time instrumentation. Go remains the special case that requires different thinking.</p>
<h4 id="beyond-kubernetes">Beyond Kubernetes</h4>
<p>There&rsquo;s clear demand for the Injector <strong>outside of Kubernetes</strong> — EC2, bare metal, traditional VMs. Users not on K8s or Docker <em>&ldquo;end up using custom Ansibles&rdquo;</em> — the packaging gap is real and concrete. System packages (Debian, RPM) are needed, but hosting for them doesn&rsquo;t exist yet. <strong>Red Hat is looking into packaging OTel components.</strong> Multiple projects are independently solving the same packaging problems — signatures, distribution, hosting — which led to a proposal for a <strong>new SIG on OS packaging</strong>.</p>
<h4 id="the-vision-one-package-to-rule-them-all">The Vision: One Package to Rule Them All</h4>
<p>The afternoon session on OBI and Injector for Go articulated a bold vision:</p>
<blockquote>
<p>Run <code>apt install opentelemetry</code> and get everything running — SDKs, Injector, OBI, all coordinated.</p>
</blockquote>
<p>This would require massive coordination between instrumentation providers — and it would include the <strong>OTel profiler</strong> alongside the SDKs and Injector. The group discussed how to avoid <strong>double instrumentation</strong> when both OBI and the Injector are present — OBI should detect the Injector and back off (similar to how it already detects other SDKs). A creative proposal emerged: <strong>OBI injecting the Injector</strong> instead of the Operator, since eBPF can intercept process loading natively.</p>
<p>The reality is that <strong>OTel declarative configuration</strong> doesn&rsquo;t cleanly fit either project&rsquo;s model yet. The Injector has its own config format. OBI instruments many applications from a single daemon, which doesn&rsquo;t map neatly to per-application YAML. This is a design problem that needs solving before the <code>apt install</code> dream becomes real.</p>
<p>And the question that kept coming back in both sessions — <em>&ldquo;What about Go?&rdquo;</em> — led naturally into the next room.</p>
<hr>
<h3 id="ebpf-and-the-instrumentation-tax">eBPF and the Instrumentation Tax</h3>
<p>The <strong>OBI/eBPF session</strong> drew a crowd interested in the promise and the trade-offs of non-invasive auto-instrumentation.</p>
<p><picture><source type="image/webp" srcset="/uploads/otel_unplugged_2026_stickies_hu_e79c0799025f1664.webp 480w, /uploads/otel_unplugged_2026_stickies_hu_426984788fc9817e.webp 768w, /uploads/otel_unplugged_2026_stickies_hu_3bbfb6aae21f83f5.webp 1024w, /uploads/otel_unplugged_2026_stickies_hu_b91a33eb4d7d4f10.webp 1366w, /uploads/otel_unplugged_2026_stickies_hu_18fcb2e80d0b1e70.webp 1600w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/otel_unplugged_2026_stickies_hu_89692086118c0468.jpeg 480w, /uploads/otel_unplugged_2026_stickies_hu_fa940f49821cdf93.jpeg 768w, /uploads/otel_unplugged_2026_stickies_hu_5d27bda976af6710.jpeg 1024w, /uploads/otel_unplugged_2026_stickies_hu_d5028704a2a56b0b.jpeg 1366w, /uploads/otel_unplugged_2026_stickies.jpeg 1600w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/otel_unplugged_2026_stickies.jpeg" alt="Session brainstorming — Go instrumentation topics cluster together"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="1600" height="1200">
  </picture></p>
<p><a href="https://github.com/open-telemetry/opentelemetry-go-instrumentation">OBI</a> (eBPF-based auto-instrumentation) uses <strong>uprobes</strong> to hook into application functions at the kernel level. No source code modification, no recompilation, no SDK integration. The trade-off? You need <strong>privileges</strong>. <code>CAP_SYS_ADMIN</code> or root access is a hard sell for security teams, and the discussion around reducing privilege requirements was lively.</p>
<p>The operational reality came through early: someone described a case where <em>&ldquo;the instrumentation was bringing down the pod&rdquo;</em> — auto-injection and sidecars destabilizing the very workloads they&rsquo;re supposed to observe. That anecdote set the tone for the rest of the session and led directly to the quote that stuck with me most.</p>
<p>A bright spot: <strong><a href="https://fosdem.org/2026/schedule/event/3LLHG9-bpf-tokens-safe-userspace-ebpf/">eBPF Tokens</a></strong>, a newer Linux facility for safer userspace eBPF, could significantly lower the trust bar. There was optimism in the room about this direction.</p>
<p>OBI isn&rsquo;t just about application tracing. It shines in <strong>network observability</strong> — topology mapping, correlating network stack behavior with application layer events. Someone asked about lock observability — <em>&ldquo;Maybe profiling&rdquo;</em> was the answer, hinting at the breadth of what people want from eBPF beyond just tracing. And there&rsquo;s an underexplored opportunity around <strong>USDTs</strong> (user-defined static tracepoints). Postgres and MySQL already have them behind flags. Rust makes them easy to add. But we need to convince <strong>popular libraries across more languages</strong> to adopt them.</p>
<p>A broader point was raised: <strong>W3C context propagation</strong> should be pushed into language runtimes and compilers, not just libraries. If the runtime itself understands trace context, the instrumentation story becomes fundamentally simpler.</p>
<p>The most grounded take came during the Go discussion:</p>
<blockquote>
<p>&ldquo;Document the trade-offs between OBI, compile-time, injector, and SDK. Let people choose. Make them aware of each other and let them work together.&rdquo;</p>
</blockquote>
<p>And the reality check:</p>
<blockquote>
<p>&ldquo;Instrumentation tax is inevitable. Manage it, don&rsquo;t pretend it&rsquo;s free.&rdquo;</p>
</blockquote>
<p>The consensus across multiple sessions: <strong>stop treating these approaches as competing camps</strong>.
They&rsquo;re complementary layers for different deployment scenarios:</p>
<table>
	<thead>
			<tr>
					<th>Approach</th>
					<th>Mechanism</th>
					<th>Trade-off</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Compile-time</strong></td>
					<td>AST transformation via <code>-toolexec</code></td>
					<td>Deepest instrumentation, zero runtime overhead, requires rebuild</td>
			</tr>
			<tr>
					<td><strong>eBPF/OBI</strong></td>
					<td>Kernel-level uprobe hooking</td>
					<td>No app modification, needs kernel privileges</td>
			</tr>
			<tr>
					<td><strong>Injector/SSI</strong></td>
					<td>K8s operator triggering instrumentation</td>
					<td>Lowest friction onboarding, abstracts complexity</td>
			</tr>
	</tbody>
</table>
<p>On the Kubernetes operations side, there was a concrete proposal: a <strong>CRD for OTel-operator</strong> to deploy OBI daemonsets — with config validation and selective node deployment via workload labels. Not theoretical; the group was sketching the API surface.</p>
<hr>
<p><picture><source type="image/webp" srcset="/uploads/otel_unplugged_2026_community_hu_8fcc9c089608cad6.webp 480w, /uploads/otel_unplugged_2026_community_hu_3e4db26fffd9ec76.webp 768w, /uploads/otel_unplugged_2026_community_hu_74113979bafda509.webp 1024w, /uploads/otel_unplugged_2026_community_hu_20fbb2ee530149b1.webp 1366w, /uploads/otel_unplugged_2026_community_hu_d15875cf95ad7147.webp 1600w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/otel_unplugged_2026_community_hu_4768cf1f4d296733.jpeg 480w, /uploads/otel_unplugged_2026_community_hu_757604672f5f43f6.jpeg 768w, /uploads/otel_unplugged_2026_community_hu_b9202d9049d9dd2f.jpeg 1024w, /uploads/otel_unplugged_2026_community_hu_aa2f93b5b25ba4e.jpeg 1366w, /uploads/otel_unplugged_2026_community.jpeg 1600w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/otel_unplugged_2026_community.jpeg" alt="Community and ecosystem topics — the other half of the brainstorming table"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="1600" height="1200">
  </picture></p>
<h3 id="patterns-across-the-day">Patterns Across the Day</h3>
<p>Beyond the sessions I attended, three themes kept surfacing throughout the unconference.</p>
<h4 id="ship-faster-vs-stable-by-default">Ship Faster vs Stable by Default</h4>
<p>Two rooms, opposite tensions. One group argued: <em>&ldquo;We discourage people from trying. Processes feel rigid. We can only learn if we actually build something.&rdquo;</em> The Prometheus model — experiment first, let things mature, specify later — was held up as the better feedback loop. The other group was laser-focused on <strong>stability</strong>: feature gates, opt-in experimental features, the pain of breaking changes in semantic conventions. The impatience was clear: <em>&ldquo;Less bike-shedding, more doing.&rdquo;</em></p>
<p>Both are right. The community is threading a needle between moving fast enough to stay relevant and being stable enough that enterprises trust the project. The gap between these two positions is where a lot of energy gets spent.</p>
<h4 id="the-maintainer-crisis">The Maintainer Crisis</h4>
<p>This came up in at least three rooms. <strong>Not enough maintainers, too many PRs, codeowners disappearing.</strong> The JavaScript SIG has an automated script to move inactive maintainers to emeritus after three months.
Other SIGs handle it manually. Some SIGs have tried a buddy/mentor system for onboarding new contributors — it helps, but it doesn&rsquo;t scale across all SIGs when the existing maintainers barely have time to review PRs. The phrase that stuck with me:</p>
<blockquote>
<p>&ldquo;Maintainership is privilege AND responsibility.&rdquo;</p>
</blockquote>
<p>And a new problem: as one maintainer put it directly, <em>&ldquo;AI slop creates a lot of work for maintainers.&rdquo;</em> Low-quality AI-generated PRs need review just like everything else, but they rarely lead to productive outcomes — creating a treadmill of review work that burns out the very people the project can&rsquo;t afford to lose.</p>
<h4 id="opentelemetry-go-auto-quietly-fading">OpenTelemetry-go-auto: Quietly Fading</h4>
<p>During the Go-focused session, someone asked about <code>opentelemetry-go-auto</code> — the eBPF-based Go auto-instrumentation project.
The answer was frank: the project <strong>&ldquo;seems in maintenance mode, some of their maintainers are already contributing to OBI.&rdquo;</strong> The group decided to keep it out of the discussions unless those maintainers want to participate.
No drama, just the natural evolution of open-source projects. The people moved to where the momentum is.</p>
<hr>
<h3 id="what-comes-next">What Comes Next</h3>
<p>The unconference produced concrete next steps across every thread:</p>
<ul>
<li><strong>Prometheus + OTel</strong>: Convergence work continues. Joint messaging, Amsterdam meetup, bound instruments moving forward.</li>
<li><strong>Injector</strong>: Merge functionality into the Operator starting with one language. System packages for non-K8s environments.</li>
<li><strong>OBI</strong>: Gradual protocol expansion, packaging SIG proposal, exploration of an eBPF-based OTel Collector.</li>
<li><strong>Go auto-instrumentation</strong>: Coordinate all three approaches, document trade-offs clearly for end users.</li>
</ul>
<hr>
<h3 id="unplugged">Unplugged</h3>
<p>The unconference format works because <strong>the hardest problems in observability right now are not technical</strong> — they&rsquo;re social.
Governance, maintenance burden, convergence between projects that grew up independently, vendor-neutrality when vendors are the primary contributors.
You can&rsquo;t solve these with a slide deck. You need a room, a whiteboard, and honest conversation.</p>
<p>As I always say — <strong>the hallway track is the real conference.</strong> OTel Unplugged is an entire day of hallway track, and it&rsquo;s exactly what the community needs.</p>
<p>If you want to get involved: join the <a href="https://communityinviter.com/apps/cloud-native/cncf">CNCF Slack</a> and find the <code>#otel-prometheus</code>, <code>#otel-ebpf-instrumentation</code>, and <code>#otel-go-instrumentation</code> channels.
The SIG meetings are open and listed on the <a href="https://github.com/open-telemetry/community">OTel community repo</a>.
Show up, contribute, and help shape the future of observability.</p>
<p>Already looking forward to the next one.</p>
]]></content:encoded></item><item><title>FOSDEM 2026: Even Bigger, Even Better</title><link>https://kakkoyun.me/posts/fosdem-2026/</link><pubDate>Fri, 13 Feb 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/fosdem-2026/</guid><description>FOSDEM 2026 highlights — OTel Unplugged, Go Devroom, Software Performance Devroom, and the hallway track that never disappoints.</description><content:encoded><![CDATA[<h3 id="another-year-another-fosdem">Another Year, Another FOSDEM</h3>
<p><strong>FOSDEM</strong> — the annual Brussels pilgrimage. If you&rsquo;ve been, you know the drill: too many talks, too little time, questionable coffee, and the kind of conversations that only happen when you pack thousands of open-source developers into a university campus in the dead of winter.</p>
<p>This year was different for me, though. Two talks in two devrooms, three sessions at OTel Unplugged — and this time, I brought the whole family. My wife and our toddler (who has graduated from &ldquo;can barely walk&rdquo; to &ldquo;can absolutely destroy a hotel room in under four minutes&rdquo;) came along, and we turned it into a proper trip — FOSDEM, then a few days exploring <strong>Ghent</strong> and <strong>Antwerp</strong> before heading home.</p>
<p>The conference part was incredible. The journey home&hellip; well, we&rsquo;ll get to that.</p>
<h3 id="saturday-morning-ebpf-devroom">Saturday Morning: eBPF Devroom</h3>
<p>Last year the eBPF Devroom was impenetrable — nobody leaves, nobody gets in. This year I made it in early and spent the morning there.</p>
<p>Three sessions stood out:</p>
<ul>
<li>
<p><strong>&quot;<a href="https://fosdem.org/2026/schedule/event/8GVBN7-ebpf-hooks-gotchas/">eBPF Hookpoint Gotchas: Why Your Program Fires (or Fails) in Unexpected Ways</a>&quot;</strong> — Donia Chaiehloudj and Chris Tarazi walked through the subtle behaviors of kprobes, fentry, tracepoints, and uprobes that catch everyone off guard. The kind of talk where half the room is nodding along because they&rsquo;ve hit these exact edge cases in production. If you write eBPF programs, this is required viewing.</p>
</li>
<li>
<p><strong>&quot;<a href="https://fosdem.org/2026/schedule/event/H3LM7G-performance_and_reliability_pitfalls_of_ebpf/">Performance and Reliability Pitfalls of eBPF</a>&quot;</strong> — Usama Saqib shared hard-won lessons from running eBPF at scale: kprobe performance varying across kernel versions, fentry stability issues, and the challenges of scaling uprobes. Directly relevant to anyone using eBPF-based auto-instrumentation — the kind of detail you don&rsquo;t find in documentation.</p>
</li>
<li>
<p><strong>&quot;<a href="https://fosdem.org/2026/schedule/event/VTXQSK-oomprof/">OOMProf: Profiling Go Heap Memory at OOM Time</a>&quot;</strong> — Tommy Reilly presented OOMProf, a Go library that uses eBPF to hook into Linux OOM tracepoints and capture heap profiles right before the kernel kills your process. Exports to pprof or Parca. The intersection of Go, eBPF, and profiling — three things I care deeply about.</p>
</li>
</ul>
<p>The eBPF Devroom continues to be one of the most technically dense tracks at FOSDEM. Every talk assumes you already know the basics and goes straight to the edge cases and production realities.</p>
<h3 id="sunday-two-talks-two-devrooms">Sunday: Two Talks, Two Devrooms</h3>
<p>Sunday was a double-header. Two talks in two devrooms.</p>
<p><strong>Augusto de Oliveira</strong> and I co-presented <strong>&ldquo;How to Reliably Measure Software Performance&rdquo;</strong> in the <strong>Software Performance Devroom</strong>.</p>
<p><picture><source type="image/webp" srcset="/uploads/fosdem26_perf_talk_close_hu_489a33e146985a6a.webp 480w, /uploads/fosdem26_perf_talk_close_hu_7d345c8d429396e1.webp 768w, /uploads/fosdem26_perf_talk_close_hu_bbb3fa63db414ca3.webp 1000w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/fosdem26_perf_talk_close_hu_4cd1560cc27595b9.jpeg 480w, /uploads/fosdem26_perf_talk_close_hu_328466510e202f33.jpeg 768w, /uploads/fosdem26_perf_talk_close.jpeg 1000w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/fosdem26_perf_talk_close.jpeg" alt="Kemal and Augusto presenting at the Software Performance Devroom"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="1000" height="753">
  </picture></p>
<p>The talk opened with one of my favorite stories in science: the OPERA experiment that appeared to show neutrinos traveling faster than the speed of light, only for the root cause to be a single fiber-optic cable that wasn&rsquo;t fully plugged in. That&rsquo;s benchmarking in a nutshell — a world where loose cables are everywhere and your numbers are lying to you until you prove otherwise.</p>
<p>We covered the full stack of what it takes to measure reliably. <strong>Environment control</strong>: bare metal instances, disabling SMT, CPU affinity, cache management. <strong>Benchmark design</strong>: making measurements representative and repeatable. <strong>Statistical rigor</strong>: because if you&rsquo;re not thinking about variance, you&rsquo;re not thinking. And then the part I&rsquo;m most excited about — <strong>integrating benchmarks into development workflows</strong>. Performance quality gates on PRs, auto-generated regression comments, continuous benchmarking infrastructure. We showed what we&rsquo;ve built at Datadog and pointed to the open-source alternatives available today.</p>
<blockquote>
<p>&ldquo;Performance matters. It&rsquo;s not always the first thing we think about when building software. But in the end, performance is what users experience.&rdquo;</p>
</blockquote>
<p>The Performance Devroom had a strong lineup all day. The audience was deeply technical — people who care about p99 latencies and can argue for an hour about whether your benchmark harness is introducing measurement bias. My kind of crowd.</p>
<p>The <a href="/posts/fosdem-2026-measuring-software-performance/">technical blog post</a> goes deeper, and the <a href="/talks/how-to-reliably-measure-software-performance/">talk page</a> has the slides and recording.</p>
<p>Then I crossed campus to the <strong>Go Devroom</strong>. My kind of room.</p>
<p><strong>Hannah S. Kim</strong> and I presented <strong>&ldquo;How to Instrument Go Without Changing a Single Line of Code&rdquo;</strong> — a talk comparing every strategy available today for zero-touch Go observability.</p>
<p><picture><source type="image/webp" srcset="/uploads/fosdem26_go_crowd_hu_3df6249ffc50673a.webp 480w, /uploads/fosdem26_go_crowd_hu_db0a9b36ac91f963.webp 768w, /uploads/fosdem26_go_crowd_hu_47389f353c793189.webp 1024w, /uploads/fosdem26_go_crowd_hu_dc02064c2acc628e.webp 1366w, /uploads/fosdem26_go_crowd_hu_3fd965282ed08e70.webp 1920w, /uploads/fosdem26_go_crowd_hu_c3fb9456ad40a7e0.webp 2048w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/fosdem26_go_crowd_hu_3bd2d22e0b8776ac.jpeg 480w, /uploads/fosdem26_go_crowd_hu_4710a14155e87dc1.jpeg 768w, /uploads/fosdem26_go_crowd_hu_8d735bcd6980e9b6.jpeg 1024w, /uploads/fosdem26_go_crowd_hu_a72009ec8fc8532.jpeg 1366w, /uploads/fosdem26_go_crowd_hu_46d6604d12119882.jpeg 1920w, /uploads/fosdem26_go_crowd.jpeg 2048w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/fosdem26_go_crowd.jpeg" alt="Hannah and Kemal presenting at the Go Devroom"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="2048" height="1536">
  </picture></p>
<p>We walked through eBPF-based auto-instrumentation with OBI, compile-time manipulation with tools like Orchestrion and the OTel Go compile-time instrumentation project, runtime injection via LD_PRELOAD, and the emerging world of USDTs for Go.</p>
<p>The core of the talk was practical: benchmark results and small realistic services, compared along three axes — <strong>performance overhead</strong>, <strong>robustness across Go versions</strong>, and <strong>operational friction</strong>. We showed the trade-offs honestly. eBPF gives you zero code changes but needs kernel privileges. Compile-time rewriting gives you the deepest instrumentation but requires a rebuild. The Injector abstracts complexity but is currently Kubernetes-only. There&rsquo;s no silver bullet, just choices with different costs.</p>
<p>We also looked forward at how Go runtime work such as the flight recorder, and my own experiment with USDT probe generation, could unlock cleaner hooks for future instrumentation. The room was full. The questions were sharp. Hannah handled the eBPF deep-dives while I covered the compile-time and operational integration angles. It worked.</p>
<p>If you want the full technical breakdown, I wrote a <a href="/posts/fosdem-2026-auto-instrumenting-go/">companion blog post</a> and the <a href="/talks/how-to-instrument-go-without-changing-code/">talk page has the slides and recording</a>.</p>
<h3 id="monday-otel-unplugged">Monday: OTel Unplugged</h3>
<p>The day after FOSDEM, about a hundred of us gathered at <strong>Sparks Meeting</strong> on Rue Ravenstein for <a href="https://opentelemetry.io/blog/2025/otel-unplugged-fosdem/">OTel Unplugged EU 2026</a> — an unconference dedicated entirely to OpenTelemetry. No slides, no prepared talks, just session brainstorming, dot-voting, and then splitting into nine rooms across four breakout slots.</p>
<p>I led or co-led <strong>three sessions</strong>: one on <strong>Prometheus and OTel convergence</strong>, one on <strong>OBI/eBPF-based auto-instrumentation</strong>, and one on <strong>the Injector and OBI coordination for Go</strong>. The thread connecting all three was the same question that keeps me up at night: <em>how do we make applications observable without asking developers to change their code?</em></p>
<p>I wrote a <a href="/posts/otel-unplugged-eu-2026/">dedicated post covering the full day</a>, so I won&rsquo;t repeat it here. The short version: the community is converging. Prometheus and OTel maintainers are charting a path together, the Injector vision is expanding beyond Kubernetes, and the various auto-instrumentation approaches for Go are finally being treated as complementary layers rather than competing camps. Read the post for the details.</p>
<h3 id="the-hallway-track">The Hallway Track</h3>
<p>As always — <strong>the hallway track is the real conference.</strong></p>
<p>Some of the best conversations happened between sessions, over coffee, during the frantic sprints between ULB buildings. Catching up with <strong>Prometheus maintainers</strong> about v3 adoption and the road ahead. Talking auto-instrumentation strategy with OTel contributors who I&rsquo;d only known through GitHub issues. Comparing notes on performance engineering practices with people running infrastructure at wildly different scales.</p>
<p>The informal <strong>Prometheus maintainers gathering</strong> was a highlight. Getting the people who build and maintain the project into the same room, away from structured agendas, just talking about what&rsquo;s working and what isn&rsquo;t — that&rsquo;s where real alignment happens. No Zoom call will ever replicate that.</p>
<p>I&rsquo;m incredibly grateful for the people I managed to see this year. And as always, slightly heartbroken about the ones I missed. FOSDEM is four thousand developers in one place for a weekend, and no matter how fast you move, you can&rsquo;t see everyone.</p>
<h3 id="travel-and-logistics-the-sequel-nobody-asked-for">Travel and Logistics: The Sequel Nobody Asked For</h3>
<p>A FOSDEM without travel drama is, apparently, not something the universe allows for me.</p>
<p>We flew <strong>Berlin to Brussels</strong> on Friday and had a great time — FOSDEM all weekend, OTel Unplugged on Monday, then a few days of family time in <strong>Ghent</strong> and <strong>Antwerp</strong>. Belgian frites, Belgian waffles, Belgian everything. The toddler approved.</p>
<p>Then came Thursday. Our flight home from Brussels to Berlin: first delayed, then cancelled. <strong>Berlin airport shut down.</strong> The coldest winter in twenty years had frozen the city solid. We ended up at a hotel near Brussels airport with a very tired toddler and no plan B.</p>
<p>Friday morning we flew to <strong>Frankfurt</strong> instead. Surely a train from Frankfurt to Berlin would be straightforward? Of course not. The Frankfurt-to-Berlin flight: also cancelled. We rebooked on a train, but our checked luggage was&hellip; somewhere. The airline couldn&rsquo;t tell us where. We waited two hours at the airport, watching the carousel go around empty, then gave up and headed to the train station.</p>
<p>Four and a half hours of train ride later, we were finally home. <strong>Antwerp to Berlin: 29.5 hours, door to door.</strong> With a toddler. In the coldest week Germany had seen in two decades.</p>
<p>The luggage? It arrived ten days later. Intact, thankfully. But ten days.</p>
<p>Last year&rsquo;s transport chaos was cute by comparison.</p>
<h3 id="looking-forward">Looking Forward</h3>
<p><strong>FOSDEM 2026</strong> was my best one yet. Two talks across the weekend, three unconference sessions on Monday, and more hallway track conversations than I can count. The open-source observability community is in a remarkable place right now — Prometheus and OpenTelemetry converging, auto-instrumentation maturing across multiple approaches, and performance engineering finally getting the attention it deserves.</p>
<p>Already thinking about next year. If you&rsquo;re into open source and haven&rsquo;t experienced FOSDEM, just go. You won&rsquo;t regret it.</p>
]]></content:encoded></item><item><title>Fix Go Module Downloads Behind a Corporate VPN</title><link>https://kakkoyun.me/posts/goproxy-fallback-behind-vpn/</link><pubDate>Thu, 12 Feb 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/goproxy-fallback-behind-vpn/</guid><description>How to use the GOPROXY pipe separator to gracefully fall back to the public proxy when your corporate Go module proxy is unreachable behind a VPN.</description><content:encoded><![CDATA[<p>If you work at a company that runs its own Go module proxy and you connect through a VPN, you&rsquo;ve probably seen this:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>Get &#34;https://binaries.example.com/google.golang.org/grpc/@v/v1.77.0.mod&#34;:
</span></span><span style="display:flex;"><span>  dial tcp 172.27.5.36:443: i/o timeout
</span></span></code></pre></td></tr></table>
</div>
</div><p>The module has nothing to do with your company. It&rsquo;s a public dependency. Yet Go refuses to fetch it from the public proxy and just dies with a timeout. The frustrating part: you know <code>proxy.golang.org</code> has the module, and your config lists it as a fallback. So why doesn&rsquo;t it fall through?</p>
<h2 id="the-comma-trap">The comma trap</h2>
<p>A typical corporate Go setup looks like this:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export GOPROXY<span style="color:#f92672">=</span>corp-proxy.internal,https://proxy.golang.org,direct
</span></span></code></pre></td></tr></table>
</div>
</div><p>The comma separator between proxies looks harmless, but it controls exactly when Go tries the next proxy in the chain. With commas, Go only falls through on <strong>HTTP 404 or 410</strong> — meaning the proxy responded and said &ldquo;I don&rsquo;t have this module.&rdquo; Any other error, including TCP timeouts, DNS failures, and 5xx server errors, is treated as a <strong>hard failure</strong>. Go stops and reports the error.</p>
<p>When your VPN is disconnected, the corporate proxy is unreachable. That&rsquo;s a TCP timeout, not a 404. Go never tries <code>proxy.golang.org</code>.</p>
<h2 id="the-pipe-fix">The pipe fix</h2>
<p>Go 1.15 introduced the pipe separator (<code>|</code>) as an alternative to commas. With a pipe, Go falls through on <strong>any error</strong>, including network failures:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export GOPROXY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;corp-proxy.internal|https://proxy.golang.org,direct&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Notice the mix of separators. The pipe between the corporate proxy and the public proxy means &ldquo;if the corporate proxy is unreachable, try the public one.&rdquo; The comma between the public proxy and <code>direct</code> means &ldquo;only go direct if the public proxy returns 404&rdquo; — which is the safer default for the last hop.</p>
<h2 id="why-not-use-pipes-everywhere">Why not use pipes everywhere?</h2>
<p>The comma separator exists for a reason: <strong>privacy</strong>. When Go tries to fetch a module from a proxy, it reveals the module path in the request URL. If your corporate proxy is down and you use pipes everywhere, Go would send your private module paths (<code>github.com/your-company/secret-service</code>) to <code>proxy.golang.org</code> before finally trying to fetch them directly.</p>
<p>The <code>GOPRIVATE</code> and <code>GONOPROXY</code> environment variables mitigate this. Modules matching those patterns bypass the proxy chain entirely and are fetched directly from source. If you set <code>GOPRIVATE</code> correctly, the pipe separator is safe for your use case:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export GOPRIVATE<span style="color:#f92672">=</span>github.com/your-company
</span></span><span style="display:flex;"><span>export GONOPROXY<span style="color:#f92672">=</span>github.com/your-company
</span></span><span style="display:flex;"><span>export GONOSUMDB<span style="color:#f92672">=</span>github.com/your-company,go.internal.example.com
</span></span><span style="display:flex;"><span>export GOPROXY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;corp-proxy.internal|https://proxy.golang.org,direct&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>With this setup, private modules never touch any proxy. Public modules try the corporate proxy first (fast, cached, available on VPN), fall back to the public proxy on failure, and go direct as a last resort.</p>
<h2 id="the-full-picture">The full picture</h2>
<p>Here&rsquo;s how Go resolves a module with this configuration:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>go get google.golang.org/grpc@v1.77.0
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>1. Does &#34;google.golang.org/grpc&#34; match GOPRIVATE? No.
</span></span><span style="display:flex;"><span>2. Try corp-proxy.internal -&gt; TCP timeout (VPN off)
</span></span><span style="display:flex;"><span>3. Separator is &#34;|&#34; -&gt; fall through on any error
</span></span><span style="display:flex;"><span>4. Try proxy.golang.org -&gt; 200 OK, module found
</span></span><span style="display:flex;"><span>5. Done.
</span></span></code></pre></td></tr></table>
</div>
</div><p>And for a private module:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>go get github.com/your-company/secret-service@latest
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>1. Does &#34;github.com/your-company/secret-service&#34; match GOPRIVATE? Yes.
</span></span><span style="display:flex;"><span>2. Skip proxy chain entirely.
</span></span><span style="display:flex;"><span>3. Fetch directly from github.com via git.
</span></span><span style="display:flex;"><span>4. Done.
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="one-line-fix">One-line fix</h2>
<p>If you&rsquo;re in this situation, the fix is a single character change in your shell config:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-diff" data-lang="diff"><span style="display:flex;"><span><span style="color:#f92672">- export GOPROXY=corp-proxy.internal,https://proxy.golang.org,direct
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">+ export GOPROXY=&#34;corp-proxy.internal|https://proxy.golang.org,direct&#34;
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Reload your shell (<code>source ~/.zshrc</code>) and Go will gracefully fall back to the public proxy whenever your corporate proxy is unreachable. No more waiting for timeouts to tell you what you already know.</p>
]]></content:encoded></item><item><title>Stop Putting API Keys in Your Shell Config</title><link>https://kakkoyun.me/posts/stop-putting-api-keys-in-shell-config/</link><pubDate>Thu, 12 Feb 2026 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/stop-putting-api-keys-in-shell-config/</guid><description>The agentic coding boom has developers pasting API keys into .zshrc in plaintext. Use the 1Password CLI to manage secrets properly in five minutes.</description><content:encoded><![CDATA[<p>We all know better. Don&rsquo;t hardcode secrets. Use a vault. Rotate your keys. We&rsquo;ve been saying this for years.</p>
<p>And then the <strong>agentic coding boom</strong> happened.</p>
<p>Suddenly every tool wants an API key. OpenAI, Anthropic, Gemini, Groq, Mistral, Replicate—the list grows weekly. And where do those keys end up? Right there in <code>.zshrc</code>, in plain text, because you needed it working <em>right now</em> and you were going to fix it later.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># The &#34;I&#39;ll fix this later&#34; hall of shame</span>
</span></span><span style="display:flex;"><span>export OPENAI_API_KEY<span style="color:#f92672">=</span>sk-proj-abc123...
</span></span><span style="display:flex;"><span>export ANTHROPIC_API_KEY<span style="color:#f92672">=</span>sk-ant-xyz789...
</span></span><span style="display:flex;"><span>export GEMINI_API_KEY<span style="color:#f92672">=</span>AIzaSy...
</span></span></code></pre></td></tr></table>
</div>
</div><p>I caught myself doing exactly this. Two API keys, sitting in my dotfiles, probably backed up to Time Machine, possibly in shell history, definitely in my terminal scrollback. Let&rsquo;s fix this properly.</p>
<h2 id="the-problem">The Problem</h2>
<p>Plain text API keys in shell configs are bad for reasons you already know:</p>
<ol>
<li><strong>Shell history</strong> — <code>~/.zsh_history</code> records commands, and sometimes you <code>echo $OPENAI_API_KEY</code> to debug something</li>
<li><strong>Backup snapshots</strong> — Time Machine, cloud backups, dotfile repos all capture the file</li>
<li><strong>Shoulder surfing</strong> — <code>cat ~/.zshrc</code> during a screen share or a pairing session</li>
<li><strong>Terminal scrollback</strong> — the key is sitting in your terminal buffer right now</li>
</ol>
<p>And this isn&rsquo;t just a theoretical risk. Attackers actively scan repos and backups for unprotected credentials — and when they find stolen API keys, they rack up thousands of dollars in charges. The platform bills the original owner.</p>
<p>The &ldquo;I&rsquo;ll rotate it later&rdquo; never comes. Meanwhile these keys have billing attached to them.</p>
<h2 id="the-fix-1password-cli">The Fix: 1Password CLI</h2>
<p>If you use 1Password, you already have a secret manager with biometric unlock, audit logging, and team sharing. The <code>op</code> CLI lets you pull secrets into your shell without ever writing them to disk.</p>
<h3 id="step-1-install-the-cli">Step 1: Install the CLI</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>brew install --cask 1password-cli
</span></span></code></pre></td></tr></table>
</div>
</div><p>Enable the CLI integration in 1Password desktop app: <strong>Settings &gt; Developer &gt; Connect with 1Password CLI</strong>. This lets the CLI authenticate via the desktop app (Touch ID on Mac) instead of requiring a separate login.</p>
<h3 id="step-2-store-your-keys">Step 2: Store Your Keys</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>op item create <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --category<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;API Credential&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --title<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;OpenAI API Key&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --vault<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Private&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;credential=sk-proj-your-key-here&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>op item create <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --category<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;API Credential&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --title<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Gemini API Key&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --vault<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Private&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;credential=AIzaSy-your-key-here&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="step-3-replace-hardcoded-values">Step 3: Replace Hardcoded Values</h3>
<p>In your <code>.zshrc</code> (or <code>.bashrc</code>, <code>.profile</code>, whatever you use):</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-diff" data-lang="diff"><span style="display:flex;"><span><span style="color:#f92672">- export OPENAI_API_KEY=sk-proj-abc123...
</span></span></span><span style="display:flex;"><span><span style="color:#f92672">- export GEMINI_API_KEY=AIzaSy...
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">+ export OPENAI_API_KEY=$(op read &#34;op://Private/OpenAI API Key/credential&#34; --no-newline 2&gt;/dev/null)
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">+ export GEMINI_API_KEY=$(op read &#34;op://Private/Gemini API Key/credential&#34; --no-newline 2&gt;/dev/null)
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>That&rsquo;s it. Three steps. The keys now live in 1Password, protected by your master password and biometric auth.</p>
<p>One catch: this triggers a 1Password biometric prompt every time you open a terminal. If that bothers you (it bothered me), see <a href="#what-about-shell-startup-speed">Shell Startup Speed</a> for the lazy-loading version that only prompts when you actually run a command.</p>
<h3 id="step-4-rotate-the-old-keys">Step 4: Rotate the Old Keys</h3>
<p>This is the step people skip. <strong>Do it now.</strong> The old keys have been in plaintext. Assume they&rsquo;re compromised.</p>
<ul>
<li>OpenAI: <a href="https://platform.openai.com/api-keys">platform.openai.com/api-keys</a></li>
<li>Google AI: <a href="https://aistudio.google.com/apikey">aistudio.google.com/apikey</a></li>
<li>Anthropic: <a href="https://platform.claude.com/settings/keys">console.anthropic.com/settings/keys</a></li>
</ul>
<p>Generate new keys, update the 1Password items with <code>op item edit</code>, and you&rsquo;re done.</p>
<h2 id="the-details-worth-knowing">The Details Worth Knowing</h2>
<h3 id="why---no-newline">Why <code>--no-newline</code>?</h3>
<p><code>op read</code> appends a trailing newline by default. API keys with a stray newline cause cryptic authentication failures—the kind where the key &ldquo;looks right&rdquo; but every request returns 401. The <code>--no-newline</code> flag strips it.</p>
<h3 id="why-2devnull">Why <code>2&gt;/dev/null</code>?</h3>
<p>If 1Password is locked or the CLI isn&rsquo;t authenticated, <code>op read</code> writes an error to stderr. The redirect silences that so you don&rsquo;t get a wall of errors every time you open a terminal without 1Password unlocked. The variable simply becomes empty.</p>
<p>The tradeoff: a misconfigured vault path also fails silently. Test it once after setup, and you&rsquo;re fine.</p>
<h3 id="what-about-shell-startup-speed">What About Shell Startup Speed?</h3>
<p>The eager approach above runs <code>op read</code> at shell init, which means every new terminal triggers a 1Password biometric prompt. If you open terminals frequently, this gets old fast.</p>
<p>The fix is lazy loading with command-specific triggers. In zsh, the <code>preexec</code> hook fires right before a command executes and receives the command string — perfect for deciding <em>which</em> secrets to load <em>when</em>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Map: env var → 1Password secret reference</span>
</span></span><span style="display:flex;"><span>typeset -A _op_refs<span style="color:#f92672">=(</span>
</span></span><span style="display:flex;"><span>  OPENAI_API_KEY  <span style="color:#e6db74">&#34;op://Private/OpenAI API Key/credential&#34;</span>
</span></span><span style="display:flex;"><span>  GEMINI_API_KEY  <span style="color:#e6db74">&#34;op://Private/Gemini API Key/credential&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Map: command → which keys it needs</span>
</span></span><span style="display:flex;"><span>typeset -A _op_cmd_keys<span style="color:#f92672">=(</span>
</span></span><span style="display:flex;"><span>  codex   <span style="color:#e6db74">&#34;OPENAI_API_KEY&#34;</span>
</span></span><span style="display:flex;"><span>  aider   <span style="color:#e6db74">&#34;OPENAI_API_KEY GEMINI_API_KEY&#34;</span>
</span></span><span style="display:flex;"><span>  gemini  <span style="color:#e6db74">&#34;GEMINI_API_KEY&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>_maybe_load_op_secrets<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  local cmd<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>1%% *<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>     <span style="color:#75715e"># extract first word</span>
</span></span><span style="display:flex;"><span>  cmd<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>cmd##*/<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>         <span style="color:#75715e"># strip path prefix</span>
</span></span><span style="display:flex;"><span>  local keys<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>_op_cmd_keys[$cmd]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">[[</span> -z <span style="color:#e6db74">&#34;</span>$keys<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> key in <span style="color:#e6db74">${</span>=keys<span style="color:#e6db74">}</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">[[</span> -n <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>(P)key<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#66d9ef">continue</span>   <span style="color:#75715e"># already loaded</span>
</span></span><span style="display:flex;"><span>    export <span style="color:#e6db74">&#34;</span>$key<span style="color:#e6db74">=</span><span style="color:#66d9ef">$(</span>op read <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>_op_refs[$key]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> --no-newline 2&gt;/dev/null<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span><span style="display:flex;"><span>preexec_functions<span style="color:#f92672">+=(</span>_maybe_load_op_secrets<span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Manual fallback: load everything</span>
</span></span><span style="display:flex;"><span>load-secrets<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> key ref in <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>(@kv)_op_refs<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    export <span style="color:#e6db74">&#34;</span>$key<span style="color:#e6db74">=</span><span style="color:#66d9ef">$(</span>op read <span style="color:#e6db74">&#34;</span>$ref<span style="color:#e6db74">&#34;</span> --no-newline 2&gt;/dev/null<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>This gives you three properties:</p>
<ul>
<li><strong>No startup cost</strong> — terminal opens instantly, no biometric prompt</li>
<li><strong>Least privilege</strong> — <code>codex</code> only loads <code>OPENAI_API_KEY</code>, not every secret you have</li>
<li><strong>Load once</strong> — each key is fetched at most once per session (the <code>${(P)key}</code> guard skips keys that are already set)</li>
</ul>
<p>Adding a new tool is one line in <code>_op_cmd_keys</code>. Adding a new key is one line in <code>_op_refs</code>.</p>
<p>If you have multiple 1Password accounts (personal + work), add <code>--account=my.1password.com</code> to the <code>op read</code> calls to avoid vault name collisions.</p>
<p>For even more granularity:</p>
<ol>
<li><strong><code>op run</code></strong> — inject secrets into a specific command rather than the global environment:</li>
</ol>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Only injects the key for this one command</span>
</span></span><span style="display:flex;"><span>op run --env-file<span style="color:#f92672">=</span>.env.1password -- python train.py
</span></span></code></pre></td></tr></table>
</div>
</div><ol start="2">
<li><strong><code>op inject</code></strong> — when you have a dozen keys, individual <code>op read</code> calls add up. With <code>op inject</code>, you define all your secrets in a single template and load them in one shot:</li>
</ol>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># ~/.env.op (template — safe to commit, contains no secrets)</span>
</span></span><span style="display:flex;"><span>export OPENAI_API_KEY<span style="color:#f92672">={{</span> op://Private/OpenAI API Key/credential <span style="color:#f92672">}}</span>
</span></span><span style="display:flex;"><span>export GEMINI_API_KEY<span style="color:#f92672">={{</span> op://Private/Gemini API Key/credential <span style="color:#f92672">}}</span>
</span></span><span style="display:flex;"><span>export ANTHROPIC_API_KEY<span style="color:#f92672">={{</span> op://Private/Anthropic API Key/credential <span style="color:#f92672">}}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># In .zshrc — one CLI call loads everything</span>
</span></span><span style="display:flex;"><span>eval <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>op inject --in-file ~/.env.op<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>This is substantially faster than N individual <code>op read</code> calls — the CLI resolves all references in a single authentication round-trip.</p>
<ol start="3">
<li><strong>Scoped injection</strong> — skip the global environment entirely and inject a key for exactly one command&rsquo;s lifetime:</li>
</ol>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>OPENAI_API_KEY<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>op read <span style="color:#e6db74">&#34;op://Private/OpenAI API Key/credential&#34;</span> --no-newline<span style="color:#66d9ef">)</span> python train.py
</span></span></code></pre></td></tr></table>
</div>
</div><p>The key exists only in that command&rsquo;s process environment. Nothing touches your shell, nothing lingers after the process exits. This is the most paranoid option, and it&rsquo;s great for CI scripts or one-off runs.</p>
<h3 id="what-about-macos-keychain">What About macOS Keychain?</h3>
<p>macOS Keychain (<code>security find-generic-password</code>) works too and has zero startup overhead since it&rsquo;s always unlocked when you&rsquo;re logged in. I use it for some tokens:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export GITLAB_TOKEN<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>security find-generic-password -a <span style="color:#e6db74">${</span>USER<span style="color:#e6db74">}</span> -s gitlab_token -w<span style="color:#66d9ef">)</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The advantage of 1Password over Keychain: cross-device sync, team sharing, audit logs, and a UI that doesn&rsquo;t make you question your life choices. Use whichever fits your workflow. The point is to stop storing secrets in plain text.</p>
<h2 id="the-agentic-boom-made-this-worse">The Agentic Boom Made This Worse</h2>
<p>A year ago, most developers had maybe one or two API keys. Now? I know people with <strong>six or more</strong> AI service keys in their shell config. Coding agents need them. MCP servers need them. Every new tool in this space asks you to &ldquo;just export your API key&rdquo; and the docs always show the hardcoded version because it&rsquo;s simpler to explain.</p>
<p>MCP servers are the newest vector here. Tools like Claude Code, Cursor, and Windsurf use configuration files (<code>claude_desktop_config.json</code>, <code>mcp.json</code>) that store API keys for tool servers. The LLM itself never sees the secret values — the MCP server process does — but only if you inject them properly. Hardcoding keys in MCP configs is the same mistake as hardcoding them in <code>.zshrc</code>, just in a newer file. The <code>op</code> CLI works here too: use <code>op run</code> or environment variable references in your MCP server configs instead of raw keys.</p>
<p>This is a tooling culture problem. The default getting-started experience for almost every AI API is:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export MAGIC_AI_KEY<span style="color:#f92672">=</span>your-key-here  <span style="color:#75715e"># don&#39;t do this</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>We should normalize showing the secure version in documentation. Until that happens, take five minutes and move your keys to a vault. Your future self (and your billing page) will thank you.</p>
<h2 id="tldr">TL;DR</h2>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Before: plain text keys in .zshrc</span>
</span></span><span style="display:flex;"><span>export OPENAI_API_KEY<span style="color:#f92672">=</span>sk-proj-...
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># After: lazy-loaded from 1Password, per-command per-key</span>
</span></span><span style="display:flex;"><span>typeset -A _op_refs<span style="color:#f92672">=(</span>
</span></span><span style="display:flex;"><span>  OPENAI_API_KEY  <span style="color:#e6db74">&#34;op://Private/OpenAI API Key/credential&#34;</span>
</span></span><span style="display:flex;"><span>  GEMINI_API_KEY  <span style="color:#e6db74">&#34;op://Private/Gemini API Key/credential&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>typeset -A _op_cmd_keys<span style="color:#f92672">=(</span>
</span></span><span style="display:flex;"><span>  codex  <span style="color:#e6db74">&#34;OPENAI_API_KEY&#34;</span>
</span></span><span style="display:flex;"><span>  aider  <span style="color:#e6db74">&#34;OPENAI_API_KEY GEMINI_API_KEY&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>_maybe_load_op_secrets<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  local cmd<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>1%% *<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>; cmd<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>cmd##*/<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  local keys<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>_op_cmd_keys[$cmd]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">[[</span> -z <span style="color:#e6db74">&#34;</span>$keys<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> key in <span style="color:#e6db74">${</span>=keys<span style="color:#e6db74">}</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">[[</span> -n <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>(P)key<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>    export <span style="color:#e6db74">&#34;</span>$key<span style="color:#e6db74">=</span><span style="color:#66d9ef">$(</span>op read <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>_op_refs[$key]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> --no-newline 2&gt;/dev/null<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span><span style="display:flex;"><span>preexec_functions<span style="color:#f92672">+=(</span>_maybe_load_op_secrets<span style="color:#f92672">)</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Install <code>op</code>, store your keys, replace the exports, rotate the old keys. Five minutes. Zero excuses.</p>
<h2 id="further-reading">Further Reading</h2>
<ul>
<li><a href="https://1password.com/blog/securing-mcp-servers-with-1password-stop-credential-exposure-in-your-agent">Securing MCP Servers with 1Password</a> — 1Password&rsquo;s take on stopping credential exposure in agent configurations</li>
<li><a href="https://williamcallahan.com/blog/secure-environment-variables-1password-doppler-llms-mcps-ai-tools">Secure Environment Variables for LLMs, MCPs, and AI Tools</a> — William Callahan&rsquo;s walkthrough of using 1Password CLI and Doppler for AI tool secrets</li>
<li><a href="https://1password.com/blog/where-mcp-fits-and-where-it-doesnt">Where MCP Fits and Where It Doesn&rsquo;t</a> — 1Password on the security model of MCP and credential boundaries</li>
<li><a href="https://www.1password.dev/cli/secret-references">1Password CLI: Secret References</a> — official docs on the <code>op://</code> URI scheme</li>
<li><a href="https://www.1password.dev/cli/reference/commands/inject">1Password CLI: <code>op inject</code></a> — batch-load secrets from template files</li>
<li><a href="https://www.1password.dev/cli/shell-plugins">1Password Shell Plugins</a> — native integrations for CLI tools like <code>gh</code>, <code>aws</code>, and <code>stripe</code></li>
</ul>
]]></content:encoded></item><item><title>Vibe Coding with Cursor: My R&amp;D Week Adventure 🚀</title><link>https://kakkoyun.me/posts/2024-03-21-vibe-coding-with-cursor/</link><pubDate>Wed, 12 Mar 2025 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/2024-03-21-vibe-coding-with-cursor/</guid><description>Discovering the joy of AI-powered development and note-taking with Cursor during R&amp;amp;D week</description><content:encoded><![CDATA[<blockquote>
<p>TL;DR: Spent a week building cool stuff with <a href="https://cursor.com">Cursor</a>, an AI-powered IDE. Found it surprisingly effective for both coding and managing my <a href="https://www.buildingasecondbrain.com/">second brain</a>. When your requirements are clear, it&rsquo;s almost magical! ✨ <span class="sidenote-ref" id="ref-sn-1" role="doc-noteref">
  <sup><a href="#sn-1" aria-describedby="sn-1">1</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-1" role="note" aria-label="Sidenote 1 (right margin)">
  <span class="sidenote-label">1</span>
  <span class="sidenote-content">&ldquo;Magic&rdquo; here = fast iteration because the AI had unambiguous intent + cohesive context windows.</span>
  <a class="sidenote-back" href="#ref-sn-1" aria-label="Back to reference">↩</a>
</aside>
</p>
</blockquote>
<h2 id="the-setup-rd-week-vibes">The Setup: R&amp;D Week Vibes</h2>
<p>You know that feeling when R&amp;D week rolls around, and you&rsquo;re caught between &ldquo;I should learn something useful&rdquo; and &ldquo;I want to have fun&rdquo;? Well, this time I decided to combine both by digging into <a href="https://cursor.com">Cursor</a>, an AI-powered code editor that&rsquo;s been making waves in the developer community.</p>
<p>The mission was simple: Use Cursor for    <strong>everything</strong> - from managing my notes to building small task-specific projects. And by everything, I mean <em>everything</em>. <span class="sidenote-ref" id="ref-sn-2" role="doc-noteref">
  <sup><a href="#sn-2" aria-describedby="sn-2">why</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-2" role="note" aria-label="Sidenote why (left margin)">
  <span class="sidenote-label">why</span>
  <span class="sidenote-content">Deliberate constraint: forcing one environment exposes friction you normally gloss over.</span>
  <a class="sidenote-back" href="#ref-sn-2" aria-label="Back to reference">↩</a>
</aside>
</p>
<h2 id="what-makes-cursor-different">What Makes Cursor Different?</h2>
<p>Unlike traditional IDEs that just help you write code, Cursor feels more like having a pair programmer who actually gets your <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-1">context</span>
  <span role="tooltip" id="tt-1" class="tooltip-bubble">The active project knowledge the AI uses (rules, open files, repo structure).</span>
</span>
. It&rsquo;s built on top of VSCode (so you get all the good stuff you&rsquo;re used to) but adds a layer of <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-2">AI-powered</span>
  <span role="tooltip" id="tt-2" class="tooltip-bubble">Augmented by model-assisted completions, edits, refactors, and semantic search.</span>
</span>
 features that make development feel more&hellip; vibey? 😎</p>
<h3 id="the-good-parts">The Good Parts</h3>
<ol>
<li>
<p><strong>Context-Aware AI</strong>: The AI understands your project structure and can help with everything from code completion to refactoring. For example, when working on a React component, it automatically suggested appropriate hooks and state management patterns based on my component&rsquo;s purpose. When your requirements are clear, it&rsquo;s almost magical how it can <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-3">scaffold</span>
  <span role="tooltip" id="tt-3" class="tooltip-bubble">Generate initial file / function structures &amp; boilerplate quickly.</span>
</span>
 projects and implement patterns! <span class="sidenote-ref" id="ref-sn-3" role="doc-noteref">
  <sup><a href="#sn-3" aria-describedby="sn-3">3</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-3" role="note" aria-label="Sidenote 3 (right margin)">
  <span class="sidenote-label">3</span>
  <span class="sidenote-content">The payoff: spend 5 min articulating intent; save 30+ min of boilerplate churn.</span>
  <a class="sidenote-back" href="#ref-sn-3" aria-label="Back to reference">↩</a>
</aside>
</p>
</li>
<li>
<p><strong><a href="https://cursor.com/docs">Rules Feature</a></strong>: This is where things get interesting. You can create custom rules and context for different types of work, both at the project and global level. Think project-specific coding standards, documentation patterns, and even architecture guidelines. <span class="tooltip" data-placement="right">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-4">Rules</span>
  <span role="tooltip" id="tt-4" class="tooltip-bubble">Small YAML/Markdown-like spec files in <code>.cursor/rules</code> shaping AI behavior &amp; attached context.</span>
</span>
</p>
</li>
<li>
<p><strong><a href="https://cursor.com/docs">Notepads</a></strong>: Quick thoughts? Code snippets? The notepad feature is like having a smart scratchpad that understands code and can share context between different parts of your development workflow. <span class="sidenote-ref" id="ref-sn-4" role="doc-noteref">
  <sup><a href="#sn-4" aria-describedby="sn-4">4</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-4" role="note" aria-label="Sidenote 4 (left margin)">
  <span class="sidenote-label">4</span>
  <span class="sidenote-content">They become living design docs when you link them with @ mentions.</span>
  <a class="sidenote-back" href="#ref-sn-4" aria-label="Back to reference">↩</a>
</aside>
</p>
</li>
</ol>
<h2 id="second-brain-management-a-pleasant-surprise">Second Brain Management: A Pleasant Surprise</h2>
<p>One of my unexpected discoveries was how well Cursor handles note-taking and <a href="https://www.buildingasecondbrain.com/">second brain</a> management. Here&rsquo;s what made it click for me:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Markdown support is top-notch
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> AI understands context across files
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Easy to maintain structure with rules
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Quick navigation between related notes
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> File attachments for enhanced documentation
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Dynamic references using @ mentions
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="the-rules-feature-actually-the-best-part">The Rules Feature: Actually the Best Part</h3>
<p>The rules feature deserves its own spotlight. Cursor offers two powerful ways to customize AI behavior (note that the older <code>.cursorrules</code> file is being deprecated in favor of this new system):</p>
<ol>
<li>
<p><strong>Project Rules</strong> (<code>.cursor/rules</code> directory):</p>
<ul>
<li>Semantic descriptions for specific use cases</li>
<li>File pattern matching with glob patterns</li>
<li>Automatic attachment when matching files are referenced</li>
<li>Chain multiple rules using @file references</li>
<li>Version controlled with your project</li>
<li>Create new rules via command palette (<code>Cmd + Shift + P</code> &gt; <code>New Cursor Rule</code>)</li>
</ul>
</li>
<li>
<p><strong>Global Rules</strong> (Cursor Settings):</p>
<ul>
<li>Applied across all projects</li>
<li>Perfect for consistent preferences</li>
<li>Control output language and response style</li>
<li>Set universal development guidelines <span class="tooltip" data-placement="bottom">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-5">universal</span>
  <span role="tooltip" id="tt-5" class="tooltip-bubble">Baseline defaults before project-specific specialization.</span>
</span>
</li>
</ul>
</li>
</ol>
<p>Pro tip: Use project rules whenever possible - they&rsquo;re more flexible, can be version controlled, and provide better granular control over different parts of your project. <span class="sidenote-ref" id="ref-sn-5" role="doc-noteref">
  <sup><a href="#sn-5" aria-describedby="sn-5">5</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-5" role="note" aria-label="Sidenote 5 (right margin)">
  <span class="sidenote-label">5</span>
  <span class="sidenote-content">Global rules = broad tone; project rules = domain-specific precision.</span>
  <a class="sidenote-back" href="#ref-sn-5" aria-label="Back to reference">↩</a>
</aside>
</p>
<p>I&rsquo;ve set up different contexts for various types of work:</p>
<ul>
<li>Technical blog posts (with specific writing guidelines)</li>
<li>Project documentation (with architecture patterns)</li>
<li>Personal notes (with custom templates)</li>
<li>Code standards (with framework-specific rules)</li>
</ul>
<p>Each context comes with its own set of rules and AI behavior. It&rsquo;s like having multiple specialized assistants at your disposal.</p>
<h3 id="notepads-beyond-simple-notes">Notepads: Beyond Simple Notes</h3>
<p>The Notepads feature (currently in beta) has been a revelation. Think of them as enhanced reference documents that go beyond regular <code>.cursorrules</code>. I use them for:</p>
<ol>
<li>
<p><strong>Dynamic Boilerplate Generation</strong>:</p>
<ul>
<li>Templates for common code patterns</li>
<li>Project-specific scaffolding rules</li>
<li>Consistent code structure templates</li>
</ul>
</li>
<li>
<p><strong>Architecture Documentation</strong>:</p>
<ul>
<li>Frontend specifications</li>
<li>Backend design patterns</li>
<li>Data model documentation</li>
</ul>
</li>
<li>
<p><strong>Development Guidelines</strong>:</p>
<ul>
<li>Team conventions</li>
<li>Best practices</li>
<li>Project-specific rules <span class="tooltip" data-placement="left">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-6">scaffolding</span>
  <span role="tooltip" id="tt-6" class="tooltip-bubble">Automated generation of repeatable structure so humans focus on edge cases &amp; design.</span>
</span>
</li>
</ul>
</li>
</ol>
<p>The ability to share context between composers and chat interactions makes them incredibly powerful. Plus, you can attach files and use @ mentions to create a web of connected knowledge. <span class="sidenote-ref" id="ref-sn-6" role="doc-noteref">
  <sup><a href="#sn-6" aria-describedby="sn-6">6</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-6" role="note" aria-label="Sidenote 6 (left margin)">
  <span class="sidenote-label">6</span>
  <span class="sidenote-content">This starts resembling a lightweight knowledge graph.</span>
  <a class="sidenote-back" href="#ref-sn-6" aria-label="Back to reference">↩</a>
</aside>
</p>
<h2 id="small-projects-big-impact">Small Projects, Big Impact</h2>
<p>During the week, I worked on several small, task-specific projects. The workflow typically went like this:</p>
<ol>
<li>Create a new project with clear requirements</li>
<li>Set up project-specific rules and templates</li>
<li>Let the AI handle boilerplate and routine coding</li>
<li>Focus on architecture and edge cases</li>
</ol>
<p>The AI handled a lot of the repetitive work, letting me focus on the creative aspects of each project. The clearer my requirements were, the more magical the results became. ✨ <span class="sidenote-ref" id="ref-sn-7" role="doc-noteref">
  <sup><a href="#sn-7" aria-describedby="sn-7">7</a></sup>
</span>
<aside class="sidenote sidenote--right" id="sn-7" role="note" aria-label="Sidenote 7 (right margin)">
  <span class="sidenote-label">7</span>
  <span class="sidenote-content">Clarity compounds: every precise artifact feeds back into higher-quality suggestions.</span>
  <a class="sidenote-back" href="#ref-sn-7" aria-label="Back to reference">↩</a>
</aside>
</p>
<h2 id="lessons-learned">Lessons Learned</h2>
<ol>
<li>
<p><strong>AI-Powered Doesn&rsquo;t Mean AI-Dependent</strong>: Cursor enhances your workflow without taking over.</p>
</li>
<li>
<p><strong>Rules Are Your Friend</strong>: Taking time to set up proper rules pays off immensely.</p>
</li>
<li>
<p><strong>Context is King</strong>: The more context you provide, the better the AI assistance becomes. <span class="tooltip" data-placement="top">
  <span class="tooltip-term" tabindex="0" aria-describedby="tt-7">context</span>
  <span role="tooltip" id="tt-7" class="tooltip-bubble">The total of rules, open buffers, selected text, and repo semantics the model sees.</span>
</span>
</p>
</li>
<li>
<p><strong>Second Brain Benefits</strong>: It&rsquo;s not just for coding; it&rsquo;s a genuine knowledge management tool.</p>
</li>
<li>
<p><strong>Clear Requirements = Magic</strong>: The more precise your task definition, the better the results. <span class="sidenote-ref" id="ref-sn-8" role="doc-noteref">
  <sup><a href="#sn-8" aria-describedby="sn-8">8</a></sup>
</span>
<aside class="sidenote sidenote--left" id="sn-8" role="note" aria-label="Sidenote 8 (left margin)">
  <span class="sidenote-label">8</span>
  <span class="sidenote-content">Most &ldquo;AI failed&rdquo; stories trace back to vague prompts, not model limits.</span>
  <a class="sidenote-back" href="#ref-sn-8" aria-label="Back to reference">↩</a>
</aside>
</p>
</li>
</ol>
<h2 id="whats-next">What&rsquo;s Next?</h2>
<p>I&rsquo;m planning to:</p>
<ul>
<li>Expand my rule sets for different types of work</li>
<li>Create more structured templates for common architectural patterns</li>
<li>Explore advanced AI features like multi-file refactoring</li>
<li>Share my rules and templates with the community</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>R&amp;D weeks are about trying new things and finding better ways to work. This experiment with Cursor turned out to be more than just playing with a new tool - it&rsquo;s changed how I think about IDE capabilities and knowledge management.</p>
<p>The combination of familiar VSCode features with AI assistance, especially the rules system, makes it a powerful tool for both coding and knowledge work. It&rsquo;s not perfect (what is?), but it&rsquo;s definitely earned its place in my daily toolkit.</p>
<blockquote>
<p>Remember: The best tools are the ones that enhance your natural workflow rather than forcing you to adapt to them. Cursor does this surprisingly well. 👍</p>
</blockquote>
]]></content:encoded></item><item><title>FOSDEM 2025: Blimey, What a Weekend!</title><link>https://kakkoyun.me/posts/fosdem-2025/</link><pubDate>Tue, 04 Feb 2025 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/fosdem-2025/</guid><description>FOSDEM 2025 highlights from the Go and Observability devrooms, featuring talks on Swiss Maps, GC-friendly code, and Prometheus 3. Plus the inevitable hallway track conversations and travel chaos.</description><content:encoded><![CDATA[<h3 id="another-year-another-fosdem">Another Year, Another FOSDEM</h3>
<p><strong>FOSDEM</strong>—the annual pilgrimage to <strong>Brussels</strong> for a weekend of open-source brilliance, hallway track magic, and the inevitable sleep deprivation. This year&rsquo;s <strong>Free and Open Source Software Developers&rsquo; European Meeting</strong> was, as always, a whirlwind of ideas, people, and tech so sharp it practically needed bandages.</p>
<p>But for me? It was all about <strong>seeing friends</strong>. Catching up, syncing, and squeezing in as many conversations as humanly possible. As we always say—the <strong>hallway track is the real conference</strong>. I&rsquo;m beyond grateful for the people I managed to see, and equally bummed about those I missed. But with a toddler waiting at home, even carving out this limited time was a logistical miracle.</p>
<h3 id="saturday-go-go-go-and-the-ebpf-black-hole">Saturday: Go, Go, Go&hellip; and the eBPF Black Hole</h3>
<p>Saturday kicked off in the <strong>Go DevRoom</strong>, before a (failed) mission to infiltrate the <strong>eBPF</strong> talks.</p>
<h4 id="go-goodness"><strong>Go Goodness</strong></h4>
<p>The <strong>Go DevRoom</strong> delivered as expected:</p>
<ul>
<li><strong>&quot;<a href="https://archive.fosdem.org/2025/schedule/event/fosdem-2025-5353-the-state-of-go/">The State of Go</a>&quot;</strong> – Maartje Eyskens gave a solid rundown on where Go is headed.</li>
<li><strong>&quot;<a href="https://archive.fosdem.org/2025/schedule/event/fosdem-2025-6049-swiss-maps-in-go/">Swiss Maps in Go</a>&quot;</strong> – Bryan Boreham took us through these lightning-fast maps.
<picture><source type="image/webp" srcset="/uploads/fosdem25_swissmaps_hu_ac945946e203f5f5.webp 480w, /uploads/fosdem25_swissmaps_hu_4be6946dabe42c28.webp 768w, /uploads/fosdem25_swissmaps_hu_e533ea4fa27eac61.webp 1024w, /uploads/fosdem25_swissmaps_hu_414093f8fae1c5b1.webp 1366w, /uploads/fosdem25_swissmaps_hu_aaedc715871c7503.webp 1920w, /uploads/fosdem25_swissmaps_hu_44f19232d00f1c2e.webp 4032w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/fosdem25_swissmaps_hu_247246bb9e585f62.jpeg 480w, /uploads/fosdem25_swissmaps_hu_25342e37f39969d1.jpeg 768w, /uploads/fosdem25_swissmaps_hu_82585f4f97ff8f28.jpeg 1024w, /uploads/fosdem25_swissmaps_hu_f7c754dd150d0847.jpeg 1366w, /uploads/fosdem25_swissmaps_hu_32c076130faa27e2.jpeg 1920w, /uploads/fosdem25_swissmaps.jpeg 4032w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/fosdem25_swissmaps.jpeg" alt="Swiss Maps in Go talk"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="4032" height="3024">
  </picture></li>
<li><strong>&quot;<a href="https://archive.fosdem.org/2025/schedule/event/fosdem-2025-5343-go-ing-easy-on-memory-writing-gc-friendly-code/">Go-ing Easy on Memory: Writing GC-Friendly Code</a>&quot;</strong> – Sümer Cip’s talk was a timely reminder that, yes, your garbage collection problems are (probably) your fault.</li>
</ul>
<h4 id="ebpf-fail"><strong>eBPF Fail</strong></h4>
<p>The <strong>eBPF DevRoom</strong>? Packed. Absolutely impenetrable. As someone put it on Twitter:</p>
<blockquote>
<p>&ldquo;Nobody leaves #eBPF room at #FOSDEM, so nobody gets in. 🥲&rdquo;</p>
</blockquote>
<p>Next year, I&rsquo;m bringing a tent and camping outside the door.</p>
<h3 id="sunday-monitoring-metrics-and-maybe-too-many-frites">Sunday: Monitoring, Metrics, and Maybe Too Many Frites</h3>
<p>Sunday was all about observability, performance, and squeezing every bit of insight from running systems.</p>
<h4 id="observability-overload"><strong>Observability Overload</strong></h4>
<p>The <strong>Monitoring and Observability DevRoom</strong> had a strong lineup:</p>
<ul>
<li>Richard &ldquo;RichiH&rdquo; Hartmann <a href="https://archive.fosdem.org/2025/schedule/event/fosdem-2025-6715-monitoring-and-observability-devroom-opening/">set the stage</a>.</li>
<li><strong>&quot;<a href="https://archive.fosdem.org/2025/schedule/event/fosdem-2025-5502-the-performance-impact-of-auto-instrumentation/">The Performance Impact of auto-instrumentation</a>&quot;</strong> – James Belchamber gave a fantastic talk on the hidden costs of auto-instrumentation.</li>
<li><strong>&quot;<a href="https://archive.fosdem.org/2025/schedule/event/fosdem-2025-6571-prometheus-version-3/">Prometheus Version 3</a>&quot;</strong> – Jan Fajerski and Bryan Boreham gave us the lowdown on what’s next for Prometheus.
<picture><source type="image/webp" srcset="/uploads/fosdem25_prometheus_hu_a5c6c435b0e3cf68.webp 480w, /uploads/fosdem25_prometheus_hu_467a906536b83dea.webp 768w, /uploads/fosdem25_prometheus_hu_5c33687ceaa5c3ee.webp 1024w, /uploads/fosdem25_prometheus_hu_c781ddaad88c6316.webp 1366w, /uploads/fosdem25_prometheus_hu_4434f51d4511cd54.webp 1920w, /uploads/fosdem25_prometheus_hu_fab32bb7da0366ef.webp 4032w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/fosdem25_prometheus_hu_3c8ec38896cfbbd8.jpeg 480w, /uploads/fosdem25_prometheus_hu_860848e60694b3af.jpeg 768w, /uploads/fosdem25_prometheus_hu_c8adee47ccdf463c.jpeg 1024w, /uploads/fosdem25_prometheus_hu_2516203da5c14a5d.jpeg 1366w, /uploads/fosdem25_prometheus_hu_a2c99d1d411da823.jpeg 1920w, /uploads/fosdem25_prometheus.jpeg 4032w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/fosdem25_prometheus.jpeg" alt="Prometheus 3 talk"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="4032" height="3024">
  </picture></li>
</ul>
<h3 id="community-vibes">Community Vibes</h3>
<p>Like I said—<strong>FOSDEM</strong> is really about the people. The talks are great, but the real magic happens in the hallway track. Some of the best conversations weren’t planned; they just happened over coffee, between sessions, or during a frantic sprint between buildings.</p>
<p>I’m incredibly happy for the folks I got to see, and at the same time, I wish I had more time to catch up with everyone I missed. But life is about balance, and with a little one waiting at home, I had to make every moment count.</p>
<p>Oh, and the <strong>frites</strong>? Still undefeated.</p>
<h3 id="bonus-trains-chaos-and-a-race-against-time">Bonus: Trains, Chaos, and a Race Against Time</h3>
<p>Because no trip is complete without <strong>public transport drama</strong>, my journey back home came with an extra dose of stress. Trains? Cancelled. Schedule? A mess. Plane? Hanging by a thread.
<picture><source type="image/webp" srcset="/uploads/fosdem25_train_hu_dff3337f7edf33bd.webp 480w, /uploads/fosdem25_train_hu_8d0a83c6390c3fba.webp 768w, /uploads/fosdem25_train_hu_21e6b2fd79350297.webp 1024w, /uploads/fosdem25_train_hu_fff93f47c9c7047f.webp 1366w, /uploads/fosdem25_train_hu_33d4f270e5ac48d8.webp 1920w, /uploads/fosdem25_train_hu_92bbf06aa0fee9d0.webp 4032w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/uploads/fosdem25_train_hu_e3cf855dd409758e.jpeg 480w, /uploads/fosdem25_train_hu_46581ace3134b369.jpeg 768w, /uploads/fosdem25_train_hu_92b1678497b7eac4.jpeg 1024w, /uploads/fosdem25_train_hu_ad3f1a1e9e59b491.jpeg 1366w, /uploads/fosdem25_train_hu_2bbdff442ab92fc4.jpeg 1920w, /uploads/fosdem25_train.jpeg 4032w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/uploads/fosdem25_train.jpeg" alt="Train chaos while trying to catch my flight"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="4032" height="3024">
  </picture></p>
<p>Somehow, I made it. But FOSDEM weekend wouldn’t be complete without at least one unexpected adventure.</p>
<h3 id="final-thoughts">Final Thoughts</h3>
<p><strong>FOSDEM 2025</strong> delivered. Again. Already looking forward to next year. If you&rsquo;re into open source and haven’t experienced <strong>FOSDEM</strong>, sort it out.</p>
]]></content:encoded></item><item><title>When Hustle Culture and Personal Values Collide: Lessons from My AI/ML Startup Journey</title><link>https://kakkoyun.me/posts/hustle-culture-startup-lessons/</link><pubDate>Wed, 16 Oct 2024 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/hustle-culture-startup-lessons/</guid><description>Reflections on navigating misaligned priorities, overwork, and personal growth during a brief stint at a fast-paced startup.</description><content:encoded><![CDATA[<p>Startups can be exciting arenas of innovation, filled with ambitious goals, rapid development cycles, and the allure of shaping the future.
But when the pace becomes unsustainable, and personal values clash with company culture, the dream can quickly lose its luster.
My recent experience at a machine learning inference startup taught me invaluable lessons about overwork, alignment, and the balance between idealism and pragmatism.</p>
<h2 id="why-i-decided-to-leave">Why I Decided to Leave</h2>
<p>The decision to leave wasn’t easy, but it became necessary when I realized that the environment was not compatible with my personal and professional priorities.</p>
<ol>
<li>
<p><strong>Overwork as a Default</strong>
The company embraced a hustle culture where working over 10 hours a day and being on-call 24/7 was normalized.
This wasn’t limited to crunch times—it was the baseline expectation.
For someone with a newborn at home, this level of overwork was unsustainable and detrimental to my family life.</p>
</li>
<li>
<p><strong>Lack of Empathy and Transparency</strong>
Despite knowing about my life situation, the company struggled to adjust its expectations.
With no parents on the team, there was little understanding of what it meant to balance work and family.
Additionally, expectations around work hours and deliverables weren’t clearly communicated during onboarding.</p>
</li>
<li>
<p><strong>Misaligned Values</strong>
I joined with the goal of building resilient, scalable, high-performance systems while contributing to open-source projects—a passion of mine.
However, the company prioritized rapid feature delivery and short-term metrics over reliability, sustainability, or open-source contributions.
This fundamental misalignment created constant friction.</p>
</li>
</ol>
<h2 id="my-mistakes">My Mistakes</h2>
<p>While the cultural mismatch played a significant role, I also made mistakes that compounded the challenges:</p>
<ol>
<li>
<p><strong>Over-Optimizing Instead of Delivering</strong>
I leaned into finding ideal solutions rather than delivering quick, practical implementations. In a fast-paced startup, speed often outweighs perfection.</p>
</li>
<li>
<p><strong>Focusing Too Much on Learning</strong>
My desire to deeply understand and control every detail of the platform slowed me down.
While this mindset works well in some roles, it was counterproductive in a high-pressure, delivery-focused environment.</p>
</li>
<li>
<p><strong>Prioritizing Reliability Over Features</strong>
I instinctively gravitated toward improving system reliability and long-term sustainability, even when it was clear the company valued rapid feature delivery instead.
This misalignment of priorities made my efforts less impactful in their eyes.</p>
</li>
<li>
<p><strong>Spending Time on Open Source</strong>
I worked on improving and contributing to open-source tools, which I saw as valuable.
However, the company didn’t share this enthusiasm, and my efforts were viewed as misaligned with their goals.</p>
</li>
</ol>
<h2 id="lessons-learned">Lessons Learned</h2>
<p>Reflecting on this experience, I’ve taken away several key lessons:</p>
<ol>
<li>
<p><strong>Cultural Fit is Critical</strong>
No matter how exciting the technology or mission, if the company’s culture doesn’t align with your values, frustrations will inevitably arise.
Startups that glorify overwork are not sustainable for someone who values balance.</p>
</li>
<li>
<p><strong>Clarify Expectations Early</strong>
Misaligned expectations around priorities and success metrics can derail even the most skilled engineers.
Asking detailed questions during interviews and onboarding is essential to ensure alignment.</p>
</li>
<li>
<p><strong>Balance Idealism with Pragmatism</strong>
Striking a balance between delivering quick wins and building sustainable systems is key, especially in startups.
Knowing when to prioritize speed over perfection is a crucial skill.</p>
</li>
<li>
<p><strong>Stay True to Your Priorities</strong>
For me, being present for my family and maintaining a balanced life outweighs any professional ambition.
Leaving the role wasn’t easy, but it was the right decision for my well-being and values.</p>
</li>
</ol>
<h2 id="final-thoughts">Final Thoughts</h2>
<p>This experience was a humbling reminder of the importance of alignment—between personal values, company culture, and role expectations.
While I’ve always thrived at the intersection of systems engineering and challenging problems, this chapter underscored the need for environments that respect the individual, not just the output.</p>
<p>Startups can be transformative experiences for those who thrive on rapid growth and ambiguity. But for those who prioritize balance and long-term thinking, it’s critical to choose an organization that values these traits. For me, this experience reaffirmed the importance of staying true to my values, even when the professional stakes are high.</p>
]]></content:encoded></item><item><title>I left Polar Signals or A new chapter in my professional journey</title><link>https://kakkoyun.me/posts/i-left-polar-signals/</link><pubDate>Mon, 18 Mar 2024 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/i-left-polar-signals/</guid><description>Reflections on leaving Polar Signals as the second employee and joining Datadog to continue working on observability, profiling, and performance engineering in a new environment.</description><content:encoded><![CDATA[<p>A new chapter in my professional journey</p>
<p>As the flowers bloom and the world awakens to the bright colors of spring, a season of renewal and growth, I find myself making a significant transition in my professional journey. (Too cheesy? I know, but bear with me.)</p>
<p>This year, I find myself absent from the familiar buzz of KubeCon, a place of learning and connection that I hold dear. Instead, I&rsquo;m on a different kind of duty — one that involves embracing new roles and responsibilities in life.</p>
<p>In the spirit of transitions, I have some personal news to share. Last week was my final week at <a href="https://www.polarsignals.com/">@PolarSignals</a>, marking the end of a chapter that has been incredibly fulfilling and challenging in equal measure. The decision to move on wasn&rsquo;t made lightly. It stemmed from much introspection about my personal and professional growth, as well as the realization that alignment between values and actions within an organization plays a pivotal role in one&rsquo;s journey.</p>
<h3 id="the-challenge-of-letting-go">The Challenge of Letting Go</h3>
<p>Leaving <a href="https://www.polarsignals.com/">@PolarSignals</a> was challenging for several reasons. As the second full-time employee, I&rsquo;ve had the unique opportunity to be woven into the fabric of the company&rsquo;s culture, and, in turn, it has become a part of me. The bonds formed and lessons learned from working closely with the same team for years are invaluable, though I found myself yearning for an environment that would offer greater growth opportunities and align with my aspirations. Admittedly, I&rsquo;ve never been good at letting go, making this transition even more poignant.</p>
<h3 id="the-right-decision">The Right Decision</h3>
<p>However, I firmly believe that to continue growing, sometimes you must step out of your comfort zone and challenge the status quo. My passion for observability, performance, and profiling remains undiminished, and it&rsquo;s this curiosity that propels me forward into new adventures. Ultimately, I came to understand that professional growth requires not just hard work and passion, but also an environment that nurtures and supports it — something I realized I had to seek elsewhere.</p>
<h3 id="gratitude-and-looking-forward">Gratitude and Looking Forward</h3>
<p>I extend my deepest gratitude to everyone at <a href="https://www.polarsignals.com/">@PolarSignals</a> who made it a remarkable place to work. The camaraderie, challenges, and triumphs we shared will always be cherished memories, even as I reflect on moments that tested my patience and resilience.</p>
<p>As I start this new chapter, I&rsquo;m reminded of the ethos that permeates the open-source community — &ldquo;Same team, different companies.&rdquo; It&rsquo;s a reminder that while our paths may diverge, the bonds we&rsquo;ve built and the collective pursuit of innovation and excellence continue to unite us.</p>
<p>Here&rsquo;s to new beginnings, the unknown adventures that lie ahead, and the continuous pursuit of growth and learning.</p>
<p>Let&rsquo;s see what the future brings.</p>
]]></content:encoded></item><item><title>Profiling Python with eBPF: A New Frontier in Performance Analysis</title><link>https://kakkoyun.me/posts/profiling-python-with-ebpf/</link><pubDate>Mon, 12 Feb 2024 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/profiling-python-with-ebpf/</guid><description>Discover how eBPF and Parca are transforming Python profiling, enabling continuous, efficient, and non-intrusive performance analysis directly in production.</description><content:encoded><![CDATA[<h1 id="profiling-python-with-ebpf-a-new-frontier-in-performance-analysis">Profiling Python with eBPF: A New Frontier in Performance Analysis</h1>
<p>Profiling Python applications can be challenging, especially in scenarios involving high-performance requirements or complex workloads. Existing tools often require code instrumentation, making them impractical for certain use cases. Enter <a href="https://ebpf.io/">eBPF</a> (Extended Berkeley Packet Filter)—a versatile Linux technology—and the open-source project <a href="https://www.parca.dev">Parca</a>, which together are reshaping how Python profiling works.</p>
<p>I’ll cover how eBPF enables continuous profiling, the challenges of stack unwinding in Python, and what modern profiling tools can do with all of this.</p>
<p>You can also watch my <a href="https://youtu.be/nNbU26CoMWA?si=t3Mh1z6XfNwa5r7M">full talk here</a> or refer to the <a href="https://kakkoyun.me/notes/presentations/FOSDEM24+-+Profiling+Python+with+eBPF+-+A+New+Frontier+in+Performance+Analysis">slides from the presentation</a>.</p>
<hr>
<h2 id="why-do-we-need-profiling">Why Do We Need Profiling?</h2>
<p>Profiling helps optimize performance and troubleshoot issues, such as CPU spikes, memory leaks, or out-of-memory (OOM) events. For instance:</p>
<ul>
<li><strong>Performance optimization:</strong> Identifying bottlenecks in code.</li>
<li><strong>Incident resolution:</strong> Determining which function or component caused a memory spike or CPU overload.</li>
</ul>
<p>Traditional Python profiling tools, like <a href="https://docs.python.org/3/library/profile.html"><code>cProfile</code></a> or <a href="https://github.com/benfred/py-spy"><code>py-spy</code></a>, require application instrumentation, which isn&rsquo;t always feasible—especially in production environments where code access might be restricted. This is where eBPF shines, offering non-intrusive, external profiling.</p>
<hr>
<h2 id="existing-profiling-solutions-in-python">Existing Profiling Solutions in Python</h2>
<p>Python has several profiling tools, each with unique strengths:</p>
<ul>
<li><a href="https://docs.python.org/3/library/profile.html"><code>cProfile</code></a>: A built-in module for deterministic profiling.</li>
<li><a href="https://github.com/joerick/pyinstrument"><code>pyinstrument</code></a>: A call stack profiler for Python.</li>
<li><a href="https://github.com/benfred/py-spy"><code>py-spy</code></a>: A sampling profiler for Python programs.</li>
<li><a href="https://github.com/sumerc/yappi"><code>yappi</code></a>: Yet Another Python Profiler, supports multithreaded programs.</li>
<li><a href="https://pyflame.readthedocs.io/en/latest/"><code>Pyflame</code></a>: A ptracing profiler for Python.</li>
<li><a href="https://github.com/plasma-umass/scalene"><code>Scalene</code></a>: A high-performance CPU and memory profiler.</li>
</ul>
<p>While these tools are valuable, many require code instrumentation or introduce significant overhead, making them less suitable for continuous profiling in production environments.</p>
<hr>
<h2 id="what-is-ebpf">What Is eBPF?</h2>
<p>Originally designed for network packet filtering, <a href="https://ebpf.io/">eBPF</a> has evolved into a versatile event-driven system. It enables safe execution of custom programs inside the Linux kernel, using:</p>
<ul>
<li><strong>Performance Monitoring Units (PMUs):</strong> Efficient hardware units built into CPUs that track performance events like CPU cycles, cache misses, and branch predictions.</li>
<li><strong><a href="https://perfwiki.github.io/">Perf subsystem</a>:</strong> A Linux facility for hooking into kernel and user-space events, such as CPU activity, memory allocation, or I/O.</li>
</ul>
<p>By combining eBPF with PMUs, profiling becomes faster and more efficient than traditional approaches.</p>
<hr>
<h2 id="continuous-profiling-with-parca">Continuous Profiling with Parca</h2>
<p><a href="https://www.parca.dev">Parca</a> is an open-source project enabling continuous profiling. Its eBPF agent hooks into <a href="https://perfwiki.github.io/">perf events</a>, collects stack traces, and aggregates data for visualization. The process involves:</p>
<ol>
<li><strong>Hooking into CPU events</strong> to monitor active functions.</li>
<li><strong>Stack unwinding</strong> to trace function calls.</li>
<li><strong>Data aggregation and visualization</strong> in a web-based UI.</li>
</ol>
<p>Unlike traditional profilers, Parca introduces minimal runtime overhead, making it ideal for production workloads.</p>
<hr>
<h2 id="stack-unwinding-a-key-challenge">Stack Unwinding: A Key Challenge</h2>
<h3 id="native-code">Native Code</h3>
<p>Profiling native code is straightforward: we unwind the stack by reading memory addresses from the CPU and resolving them into human-readable symbols using debug information (e.g., <a href="https://dwarfstd.org/">DWARF</a>).</p>
<h3 id="python-code">Python Code</h3>
<p>For Python, stack unwinding is complex due to its interpreter-based execution. Python maintains execution state in custom data structures, such as:</p>
<ul>
<li><strong>Interpreter state:</strong> Tracks threads and their execution context.</li>
<li><strong>Thread state:</strong> A linked list of threads running in the interpreter.</li>
<li><strong>Frame state:</strong> Represents the current execution frame.</li>
</ul>
<p>To unwind Python stacks, we must traverse these structures, extract relevant information, and map them to human-readable symbols.</p>
<hr>
<h2 id="how-parca-profiles-python">How Parca Profiles Python</h2>
<p>Here’s how Parca handles Python profiling:</p>
<ol>
<li>
<p><strong>Reverse Engineering the Python Runtime:</strong></p>
<ul>
<li>Analyze Python’s internal structures (e.g., thread and frame states).</li>
<li>Identify offsets and symbols using tools like <a href="https://www.gnu.org/software/gdb/">GDB</a> or DWARF debuggers.</li>
</ul>
</li>
<li>
<p><strong>Unwinding Python Stacks:</strong></p>
<ul>
<li>Traverse thread states to locate the active <a href="https://wiki.python.org/moin/GlobalInterpreterLock">Global Interpreter Lock (GIL)</a> holder.</li>
<li>Walk through execution frames to collect function call data.</li>
</ul>
</li>
<li>
<p><strong>Mapping Symbols:</strong></p>
<ul>
<li>Resolve function addresses to readable symbols.</li>
<li>Encode line numbers and function names for better traceability.</li>
</ul>
</li>
<li>
<p><strong>Efficient Data Handling:</strong></p>
<ul>
<li>Use eBPF maps for kernel-to-user space communication.</li>
<li>Optimize symbol resolution by caching frequently seen traces.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="python-313-a-step-forward-for-profiling">Python 3.13: A Step Forward for Profiling</h2>
<p>The upcoming Python 3.13 release introduces a debug offset structure that simplifies stack unwinding. It provides precomputed offsets for key runtime fields, eliminating much of the manual reverse engineering required for earlier versions. This improvement marks a significant leap forward for tools like Parca.</p>
<hr>
<h2 id="visualizing-profiles-with-parca">Visualizing Profiles with Parca</h2>
<p>Parca’s UI provides a comprehensive view of application performance:</p>
<ul>
<li><strong>Flame graphs</strong>: Visualize stack traces over time, highlighting bottlenecks.</li>
<li><strong>Filtering and Metadata</strong>: Focus on specific languages (e.g., Python) or layers (e.g., C libraries).</li>
<li><strong>Continuous Insights</strong>: Compare profiles across deployments to monitor performance regressions.</li>
</ul>
<p>For example, a flame graph might reveal inefficient recursion in a Python function, enabling developers to pinpoint and optimize the problematic code.</p>
<hr>
<h2 id="supported-python-versions">Supported Python Versions</h2>
<p>Parca supports profiling for Python versions from 2.7 to 3.11, with ongoing work for 3.12 and full support anticipated for 3.13. The project’s modular design allows quick adaptation to new Python runtime changes.</p>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>Profiling Python applications with eBPF and Parca represents a new frontier in performance analysis. By using eBPF and continuous profiling, we can gain invaluable insights into our applications, enabling effective performance optimization. I encourage you to explore Parca, provide feedback, and contribute to the project—it’s a collaborative effort that can benefit us all as we tackle the challenges of modern software development.</p>
<h3 id="get-started">Get Started</h3>
<p>Watch my <a href="https://youtu.be/nNbU26CoMWA?si=t3Mh1z6XfNwa5r7M">full talk</a> or check out the <a href="https://kakkoyun.me/notes/presentations/FOSDEM24+-+Profiling+Python+with+eBPF+-+A+New+Frontier+in+Performance+Analysis">presentation slides</a>. Explore Parca on <a href="https://github.com/parca-dev/parca">GitHub</a> and join the community. Your feedback helps improve the tooling and shape the future of observability.</p>
]]></content:encoded></item><item><title>Profiling Python and Ruby using eBPF</title><link>https://kakkoyun.me/posts/profiling-python-and-ruby-using-ebpf/</link><pubDate>Wed, 04 Oct 2023 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/profiling-python-and-ruby-using-ebpf/</guid><description>Dive into the internals of profiling interpreted Python and Ruby code using eBPF</description><content:encoded>&lt;p>&lt;a href="https://www.polarsignals.com/blog/posts/2023/10/04/profiling-python-and-ruby-with-ebpf">https://www.polarsignals.com/blog/posts/2023/10/04/profiling-python-and-ruby-with-ebpf/&lt;/a>&lt;/p>
</content:encoded></item><item><title>Ice and Fire: How to read icicle and flame graphs</title><link>https://kakkoyun.me/posts/ice-and-fire/</link><pubDate>Tue, 28 Mar 2023 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/ice-and-fire/</guid><description>A comprehensive guide to reading and understanding icicle and flame graphs for performance profiling and analysis. Learn the differences, when to use each, and how to interpret them effectively.</description><content:encoded><![CDATA[<p>I am too lazy now a days to re-post the blog post with all its assets and animations here. So until I get to it, I have put a link to it here. Enjoy :)</p>
<p><a href="https://www.polarsignals.com/blog/posts/2023/03/28/how-to-read-icicle-and-flame-graphs">https://www.polarsignals.com/blog/posts/2023/03/28/how-to-read-icicle-and-flame-graphs</a></p>
]]></content:encoded></item><item><title>Fantastic Symbols and Where to Find Them - Part 2</title><link>https://kakkoyun.me/posts/fantastic-symbols-and-where-to-find-them-part-2/</link><pubDate>Thu, 27 Jan 2022 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/fantastic-symbols-and-where-to-find-them-part-2/</guid><description>Part 2 explores symbolization in interpreted and JIT-compiled languages including Python, Ruby, JavaScript, JVM, and .NET, covering how each runtime handles symbol information differently.</description><content:encoded><![CDATA[
<blockquote class="admonition admonition-important">
  <p class="admonition-title"><svg class="admonition-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M12 7v2"/><path d="M12 13h.01"/></svg><span>Important</span></p>
  <div class="admonition-content"><p>This is a blog post series. If you haven’t read <a href="https://www.polarsignals.com/blog/posts/2022/01/13/fantastic-symbols-and-where-to-find-them">Part 1</a> we recommend you to do so first!</p></div>
</blockquote>
<p>In <a href="https://www.polarsignals.com/blog/posts/2022/01/13/fantastic-symbols-and-where-to-find-them">the first blog post</a>, we learned about the fantastic symbols (<a href="https://en.wikipedia.org/wiki/Debug_symbol">debug symbols</a>), how the symbolization process works and lastly, how to find the symbolic names of addresses in a compiled binary.</p>
<p>The actual location of the symbolic information depends on the programming language implementation the program is written in.
We can categorize the programming language implementations into three groups: compiled languages (with or without a runtime), interpreted languages, and <a href="https://en.wikipedia.org/wiki/Just-in-time_compilation">JIT-compiled</a> languages.</p>
<p>In this post, we will continue our journey to find fantastic symbols. And we will look into where to find them for the other types of programming language implementations.</p>
<h2 id="jit-compiled-language-implementations">JIT-compiled language implementations</h2>
<p>Examples of JIT-compiled languages include Java, .NET, Erlang, JavaScript (Node.js) and many others.</p>
<p><a href="https://en.wikipedia.org/wiki/Just-in-time_compilation">Just-In-Time</a> compiled languages compile the source code into <a href="https://en.wikipedia.org/wiki/Bytecode">bytecode</a>, which is then compiled into <a href="https://en.wikipedia.org/wiki/Machine_code">machine code</a> at runtime,
often using direct feedback from runtime to guide compiler optimizations on the fly.</p>
<p>Because functions are compiled on the fly, there is no pre-built, discoverable symbol table in any object files. Instead, the symbol table is created on the fly.
The symbol mappings (location to symbol) are usually stored in the <em>memory</em> of the <a href="https://en.wikipedia.org/wiki/Runtime_(program_lifecycle_phase)">runtime</a> or <a href="https://en.wikipedia.org/wiki/Virtual_machine">virtual machine</a>
and used for rendering human-readable stack traces when it is needed <em>, e. g.</em> when an exception occurs, the runtime will use the symbol mappings to render a human-readable stack trace.</p>
<p>The good thing is that most of the runtimes provide supplemental symbol mappings for the just-in-time compiled code for Linux to use <code>perf</code>.</p>
<p><code>perf</code> defines <a href="https://github.com/torvalds/linux/blob/master/tools/perf/Documentation/jit-interface.txt">an interface</a> to resolve symbols for dynamically generated code by a JIT compiler.
These files usually can be found in <code>/tmp/perf-$PID.map</code>, where <code>$PID</code> is the process ID of the process of the runtime that is running on the system.</p>
<p>The runtimes usually don&rsquo;t enable providing symbol mappings by default.
You might need to change a configuration, run the virtual machine with a specific flag/environment variable or run an additional program to obtain these mappings.
For example, JVM needs an agent to provide supplemental symbol mapping files, called <a href="https://github.com/jvm-profiling-tools/perf-map-agent">perf-map-agent</a>.</p>
<p>Let&rsquo;s see an example <code>perf map</code> file for NodeJS. The runtimes out there output this file with <em>more or less</em> the same format, <a href="https://github.com/parca-dev/parca-agent/issues/139">more or less!</a></p>
<p>To generate a similar file for <a href="https://en.wikipedia.org/wiki/Node.js">Node.js</a>, we need to run <code>node</code> with <code>--perf-basic-prof</code> option.</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># With Node.js &gt;=v0.11.15 the following command will create a map file for NodeJS:</span>
</span></span><span style="display:flex;"><span>node --perf-basic-prof your-app.js
</span></span></code></pre></td></tr></table>
</div>
</div><p>This will create a map file at <code>/tmp/perf-&lt;pid&gt;.map</code> that looks like this:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>3ef414c0 398 RegExp:[{(]
</span></span><span style="display:flex;"><span>3ef418a0 398 RegExp:[})]
</span></span><span style="display:flex;"><span>59ed4102 26 LazyCompile:~REPLServer.self.writer repl.js:514
</span></span><span style="display:flex;"><span>59ed44ea 146 LazyCompile:~inspect internal/util/inspect.js:152
</span></span><span style="display:flex;"><span>59ed4e4a 148 LazyCompile:~formatValue internal/util/inspect.js:456
</span></span><span style="display:flex;"><span>59ed558a 25f LazyCompile:~formatPrimitive internal/util/inspect.js:768
</span></span><span style="display:flex;"><span>59ed5d62 35 LazyCompile:~formatNumber internal/util/inspect.js:761
</span></span><span style="display:flex;"><span>59ed5fca 5d LazyCompile:~stylizeWithColor internal/util/inspect.js:267
</span></span><span style="display:flex;"><span>4edd2e52 65 LazyCompile:~Domain.exit domain.js:284
</span></span><span style="display:flex;"><span>4edd30ea 14b LazyCompile:~lastIndexOf native array.js:618
</span></span><span style="display:flex;"><span>4edd3522 35 LazyCompile:~online internal/repl.js:157
</span></span><span style="display:flex;"><span>4edd37f2 ec LazyCompile:~setTimeout timers.js:388
</span></span><span style="display:flex;"><span>4edd3cca b0 LazyCompile:~Timeout internal/timers.js:55
</span></span><span style="display:flex;"><span>4edd40ba 55 LazyCompile:~initAsyncResource internal/timers.js:45
</span></span><span style="display:flex;"><span>4edd42da f LazyCompile:~exports.active timers.js:151
</span></span><span style="display:flex;"><span>4edd457a cb LazyCompile:~insert timers.js:167
</span></span><span style="display:flex;"><span>4edd4962 50 LazyCompile:~TimersList timers.js:195
</span></span><span style="display:flex;"><span>4edd4cea 37 LazyCompile:~append internal/linkedlist.js:29
</span></span><span style="display:flex;"><span>4edd4f12 35 LazyCompile:~remove internal/linkedlist.js:15
</span></span><span style="display:flex;"><span>4edd5132 d LazyCompile:~isEmpty internal/linkedlist.js:44
</span></span><span style="display:flex;"><span>4edd529a 21 LazyCompile:~ok assert.js:345
</span></span><span style="display:flex;"><span>4edd555a 68 LazyCompile:~innerOk assert.js:317
</span></span><span style="display:flex;"><span>4edd59a2 27 LazyCompile:~processTimers timers.js:220
</span></span><span style="display:flex;"><span>4edd5d9a 197 LazyCompile:~listOnTimeout timers.js:226
</span></span><span style="display:flex;"><span>4edd6352 15 LazyCompile:~peek internal/linkedlist.js:9
</span></span><span style="display:flex;"><span>4edd66ca a1 LazyCompile:~tryOnTimeout timers.js:292
</span></span><span style="display:flex;"><span>4edd6a02 86 LazyCompile:~ontimeout timers.js:429
</span></span><span style="display:flex;"><span>4edd7132 d7 LazyCompile:~process.kill internal/process/per_thread.js:173
</span></span></code></pre></td></tr></table>
</div>
</div><blockquote>
<p>Each line has <code>START</code>, <code>SIZE</code> and <code>symbolname</code> fields, separated with spaces. <code>START</code> and <code>SIZE</code> are hex numbers without 0x.
<code>symbolname</code> is the rest of the line, so it could contain special characters.</p>
</blockquote>
<p>With the help of this mapping file, we have everything we need to symbolize the addresses in the stack trace. Of course, as always, this is just an oversimplification.</p>
<p>For example, these mappings might change as the runtime decides to recompile the bytecode. So we need to keep an eye on these files and keep track of the changes to resolve the address correctly with their most recent mapping.</p>
<p>Each runtime and virtual machine has its peculiarities that we need to adapt. But those are out of the scope of this post.</p>
<h2 id="interpreted-language-implementations">Interpreted language implementations</h2>
<p>Examples of interpreted languages include Python, Ruby, and again many others.
There are also languages that commonly use interpretation as a stage before <a href="https://en.wikipedia.org/wiki/Just-in-time_compilation">JIT compilation</a>, e. g. Java.
Symbolization for this stage of compilation is similar to interpreted languages.</p>
<p>Interpreted language runtimes do not compile the program to machine code.
Instead, interpreters and virtual machines parse and execute the source code using their <a href="https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop">REPL</a> routines.
Or execute their own virtual processor. So they have their own way of executing functions and managing stacks.</p>
<p>If you observe (profile or debug) these runtimes using something like <code>perf</code>,
you will see symbols for the runtime. However, you won&rsquo;t see the language-level context you might be expecting.</p>
<p>Moreover, the interpreter itself is probably written in a more low-level language like C or C++.
And when you inspect the object file of the runtime/interpreter, the symbol table that you would find would show the internals of the interpreter, not the symbols from the provided source code.</p>
<h3 id="finding-the-symbols-for-our-runtime">Finding the symbols for our runtime</h3>
<p>The runtime symbols are useful because they allow you to see the internal routines of the interpreter. e. g. how much time your program spends on garbage collection.
And it&rsquo;s mostly like the stack traces you would see in the debugger or profiler will have calls to the internals of the runtime.
So these symbols are also helpful for debugging.</p>
<p><picture><source type="image/webp" srcset="/node_stack_trace_2093540235078310910_hu_7bd3a0eca916a298.webp 480w, /node_stack_trace_2093540235078310910_hu_eaba8b6ed57ba04c.webp 768w, /node_stack_trace_2093540235078310910_hu_4e4e468a649e42f4.webp 794w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/node_stack_trace_2093540235078310910_hu_4b7201b17bb75993.png 480w, /node_stack_trace_2093540235078310910_hu_d17d0a26e877b5ca.png 768w, /node_stack_trace_2093540235078310910.png 794w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/node_stack_trace_2093540235078310910.png" alt="Node Stack Trace"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="794" height="1042">
  </picture></p>

<blockquote class="admonition admonition-warning">
  <p class="admonition-title"><svg class="admonition-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg><span>Warning</span></p>
  <div class="admonition-content"><p>Most of the runtimes are compiled with <code>production</code> mode, and they most likely lack the debug symbols in their release binaries.
You might need to manually compile your runtime in <code>debug mode</code> to actually have them in the resulting binary.
Some runtimes, such as Node.js, already have them in their <code>production</code> distributions.</p></div>
</blockquote>
<p>Lastly, to completely resolve the stack traces of the runtime, we might need to obtain the debug information for the linked libraries.
If you remember from <a href="https://www.polarsignals.com/blog/posts/2022/01/13/fantastic-symbols-and-where-to-find-them">the first blog post</a>, debuginfo files can help us.
Debuginfo files for software packages are available through package managers in Linux distributions.
Usually for an available package called <code>mypackage</code> there exists a <code>mypackage-dbgsym</code>, <code>mypackage-dbg</code> or <code>mypackage-debuginfo</code> package.
There are also <a href="https://sourceware.org/elfutils/Debuginfod.html">public servers</a> that serve debug information.
So we need to find the debuginfo files for the runtime we are using and all the linked libraries.</p>
<h3 id="finding-the-symbols-for-our-target-program">Finding the symbols for our target program</h3>
<p>The symbols that we look for in our own program likely are stored in a memory table that is specific to the runtime.
For example, in Python, the symbol mappings can be accessed using <a href="https://docs.python.org/3/library/symtable.html"><code>symtable</code></a>.</p>
<p>As a result, you need to craft a specific routine for each interpreter runtime (in some cases, each version of that runtime) to obtain symbol information.
Educated eyes might have already noticed, it&rsquo;s not an easy undertaking considering the sheer amount of interpreted languages out there.
For example, a very well known Ruby profiler, <a href="https://github.com/rbspy/rbspy/blob/main/ARCHITECTURE.md">rbspy</a>, generates code for reading internal structs of the Ruby runtime for each version.</p>
<p>If you were to write a general-purpose profiler, <em>like us</em>, you would need to write a special subroutine in your profiler for each runtime that you want to support.</p>
<h2 id="again-dont-worry-we-got-you-covered"><em>Again</em>, don&rsquo;t worry, we got you covered</h2>
<p>The good news is we got you covered. If you are using <a href="https://github.com/parca-dev/parca-agent">Parca Agent</a>, we already do <a href="https://www.parca.dev/docs/symbolization/">the heavy lifting</a> for you to symbolize captured stack traces.
And we keep extending our support for the different languages and runtimes.
For example, Parca has already support for parsing <code>perf</code> JIT interface to resolve the symbols for collected stack traces.</p>
<p>Check <a href="https://www.parca.dev/">Parca</a> out and let us know what you think, on <a href="https://discord.com/invite/ZgUpYgpzXy">Discord</a> channel.</p>
<h3 id="further-reading">Further reading</h3>
<ul>
<li><a href="https://github.com/torvalds/linux/blob/master/tools/perf/Documentation/jit-interface.txt">perf JIT Interface</a></li>
<li><a href="https://www.brendangregg.com/perf.html#JIT_Symbols">perf JIT Symbols</a></li>
<li><a href="https://joyeecheung.github.io/blog/2018/12/31/tips-and-tricks-node-core/">Node.js profiling tips and tricks</a></li>
<li><a href="https://www.brendangregg.com/blog/2014-09-17/node-flame-graphs-on-linux.html">Node.js Flamegraphs</a></li>
</ul>
]]></content:encoded></item><item><title>Fantastic Symbols and Where to Find Them - Part 1</title><link>https://kakkoyun.me/posts/fantastic-symbols-and-where-to-find-them/</link><pubDate>Thu, 27 Jan 2022 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/fantastic-symbols-and-where-to-find-them/</guid><description>Deep dive into symbolization: how debuggers and profilers translate memory addresses to human-readable function names using ELF, DWARF, and symbol tables in compiled languages.</description><content:encoded><![CDATA[<p>Symbolization is a technique that allows you to translate machine memory addresses to human-readable symbol information (symbols).</p>
<p>Why do we need to read what programs do anyways? We usually do not need to translate everything to a human-readable format when things run smoothly. But when things go south, we need to understand what is going on under the hood.
Symbolization is needed by introspection tools like <a href="https://en.wikipedia.org/wiki/Debugger">debuggers</a>, <a href="https://en.wikipedia.org/wiki/Profiling_(computer_programming)">profilers</a> and <a href="https://en.wikipedia.org/wiki/Core_dump">core dumps</a> or any other program that needs to trace the execution of another program.
While a target program is executing on a machine, these types of programs capture the stack traces of the program that is being executed.</p>
<blockquote>
<p>A <a href="https://en.wikipedia.org/wiki/Stack_trace">stack trace</a> (also called stack backtrace or stack traceback) is a report of the active stack frames at a certain point in time during the execution of a program.</p>
</blockquote>
<p><img src="/call_stack_layout_6771222810470269298.svg" alt="Call Stack Layout"
    loading="lazy" decoding="async" width="34250" height="27940"></p>
<p>In raw stack traces, the addresses of the functions that are being called are recorded. The addresses are hexadecimal numbers representing the memory return addresses of the functions. Symbols are needed to translate memory addresses into function and variable names precisely as in the program’s source code to be read by us humans.
Without symbols, all we see are hexadecimal numbers representing the memory addresses that we have captured.</p>
<p><picture><source type="image/webp" srcset="/unsymbolized_stack_12122283806604612117_hu_dbf0e59ff59b65fd.webp 480w, /unsymbolized_stack_12122283806604612117_hu_369bce30996bf91d.webp 768w, /unsymbolized_stack_12122283806604612117_hu_b24bcad613634b12.webp 772w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/unsymbolized_stack_12122283806604612117_hu_e75e22f415cdc250.png 480w, /unsymbolized_stack_12122283806604612117_hu_afb07d2757d37138.png 768w, /unsymbolized_stack_12122283806604612117.png 772w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/unsymbolized_stack_12122283806604612117.png" alt="Unsymbolized Stack"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="772" height="70">
  </picture></p>
<p>It sounds simple enough, right? Well, it&rsquo;s not. As with everything else about computers, it&rsquo;s a bit of sorcery. It has its challenges, such as associating them with correct symbols, transforming addresses, and most importantly, actually finding the symbols!
The strategies to get symbol information varies depending on the platform and the programming language implementation that the program is written in.</p>
<p><em>For the sake of simplicity, we will be focusing on Linux as the target platform and ignore Windows, macOS and many other platforms. Otherwise, I could end up writing a small size book in here :)</em></p>
<h2 id="fantastic-symbols-">Fantastic Symbols &hellip;</h2>
<p>A symbol (or debug symbol, to be precise) is a special kind of <a href="https://en.wikipedia.org/wiki/Symbol_(programming)">symbol</a> that attaches additional information to the symbol table of a program.
This symbol information allows a debugger or a profiler to gain access to information from the program&rsquo;s source code, such as the names of identifiers, including variables and functions.
But where can we find these symbols?</p>
<h2 id="-and-where-to-find-them">&hellip; and Where to Find Them</h2>
<p>The actual location of the symbolic information depends on the programming language implementation the program is written in.
We can categorize the programming language implementations into three groups: compiled languages (with or without a runtime), interpreted languages, and <a href="https://en.wikipedia.org/wiki/Just-in-time_compilation">JIT-compiled</a> languages.</p>
<p>If the program is a compiled one, these may be compiled together with the binary file, distributed in a separate file, or discarded during the compilation and/or linking.
Or, if the program is interpreted, these may be stored in the program itself. Let&rsquo;s briefly look at where and how we can find these symbols depending on the programming language implementation.</p>
<h3 id="compiled-language-implementations">Compiled language implementations</h3>
<p>Examples of compiled languages include C, C++, Go, Rust and many others.</p>
<p>The compiled languages usually have a <a href="https://en.wikipedia.org/wiki/Symbol_table">symbol table</a> that contains all the symbols used in the program.
The symbol table is usually compiled in the executable binary file. And the binary file is typically in the <a href="https://en.wikipedia.org/wiki/Executable_and_Linkable_Format">ELF</a> format (for Linux systems).
Symbol tables are included in the ELF binary file, specifically for mapping the addresses to function names and object names.
In rare cases, it is stored in a separate file, usually with the same name as the binary file, but with a different extension.</p>
<p><picture><source type="image/webp" srcset="/elf_11012298258453376778_hu_b81ee3da4a62890a.webp 480w, /elf_11012298258453376778_hu_93aec17133eebdbb.webp 768w, /elf_11012298258453376778_hu_7abe6920d286e67e.webp 1024w, /elf_11012298258453376778_hu_8e1e9aa3bde61b34.webp 1366w, /elf_11012298258453376778_hu_2f2ec0cb6b935643.webp 1576w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/elf_11012298258453376778_hu_cfd805a4d388bfdf.png 480w, /elf_11012298258453376778_hu_33521b858d7ae906.png 768w, /elf_11012298258453376778_hu_4e69c281b2f53d85.png 1024w, /elf_11012298258453376778_hu_5c5b407443a75465.png 1366w, /elf_11012298258453376778.png 1576w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/elf_11012298258453376778.png" alt="ELF"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="1576" height="1129">
  </picture></p>
<p>The ELF format is not an easy one to describe in a couple of sentences. For the purpose of this article, we will focus on what we need to know about the ELF format.
Each ELF file is made up of one ELF header, followed by file data. The ELF header is a fixed size and contains information about the data sections.
The relevant part for us is the symbols can live in a special section called <code>.symtab</code> and <code>.dynsym</code>.
<code>.dynsym</code> is the “dynamic symbol table” and it is a smaller version of the <code>.symtab</code> that only contains global symbols.</p>
<p>Contents of <code>.dynsym</code> and <code>.symtab</code> section using <code>readelf -s /bin/go</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Symbol table &#39;.dynsym&#39; contains 38 entries:
</span></span><span style="display:flex;"><span>   Num: Value Size Type Bind Vis Ndx Name
</span></span><span style="display:flex;"><span>     0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
</span></span><span style="display:flex;"><span>     1: 00000000006355e0 99 FUNC GLOBAL DEFAULT 1 crosscall2
</span></span><span style="display:flex;"><span>     2: 00000000006355a0 55 FUNC GLOBAL DEFAULT 1 _cgo_panic
</span></span><span style="display:flex;"><span>     3: 0000000000465560 25 FUNC GLOBAL DEFAULT 1 _cgo_topofstack
</span></span><span style="display:flex;"><span>     4: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (6)
</span></span><span style="display:flex;"><span>     5: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (4)
</span></span><span style="display:flex;"><span>     6: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (4)
</span></span><span style="display:flex;"><span>     7: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (4)
</span></span><span style="display:flex;"><span>     8: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (4)
</span></span><span style="display:flex;"><span>     9: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (4)
</span></span><span style="display:flex;"><span>    10: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (4)
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>Symbol table &#39;.symtab&#39; contains 13199 entries:
</span></span><span style="display:flex;"><span>   Num: Value Size Type Bind Vis Ndx Name
</span></span><span style="display:flex;"><span>     0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
</span></span><span style="display:flex;"><span>     1: 0000000000000000 0 FILE LOCAL DEFAULT ABS go.go
</span></span><span style="display:flex;"><span>     2: 0000000000401000 0 FUNC LOCAL DEFAULT 1 runtime.text
</span></span><span style="display:flex;"><span>     3: 0000000000401000 214 FUNC LOCAL DEFAULT 1 net(.text)
</span></span><span style="display:flex;"><span>     4: 00000000004010e0 214 FUNC LOCAL DEFAULT 1 runtime/cgo(.text)
</span></span><span style="display:flex;"><span>     5: 00000000004011c0 601 FUNC LOCAL DEFAULT 1 runtime/cgo(.text)
</span></span><span style="display:flex;"><span>     6: 0000000000401420 480 FUNC LOCAL DEFAULT 1 runtime/cgo(.text)
</span></span><span style="display:flex;"><span>     7: 0000000000401420 47 FUNC LOCAL HIDDEN 1 threadentry
</span></span><span style="display:flex;"><span>     8: 0000000000401600 70 FUNC LOCAL DEFAULT 1 runtime/cgo(.text)
</span></span><span style="display:flex;"><span>     9: 0000000000401646 5 FUNC LOCAL DEFAULT 1 runtime/cgo(.tex[...]
</span></span><span style="display:flex;"><span>    10: 0000000000401646 5 FUNC LOCAL HIDDEN 1 x_cgo_munmap.cold
</span></span></code></pre></td></tr></table>
</div>
</div>
<blockquote class="admonition admonition-note">
  <p class="admonition-title"><svg class="admonition-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg><span>Note</span></p>
  <div class="admonition-content"><p>Go has a unique table (of course). It stores its symbols in a section called <a href="https://pkg.go.dev/debug/gosym#LineTable"><code>.gopclntab</code></a>. This is a table of functions, line numbers and addresses.
Go does this because it needs to be able to render human-readable stack traces when a panic occurs in runtime;</p></div>
</blockquote>
<p>Note that addresses in the symbol table do not move during execution so that they can be read any time during the execution of the program.
They can easily be loaded into memory independent of the running program and an observer can easily read them.</p>
<p>We assumed that the binary file is a statically linked executable until this point. However, this might not be the case. The binary file might be dynamically linked to other libraries.
From now on, we will refer to these shared library files and executables (both in ELF format) as <a href="https://en.wikipedia.org/wiki/Object_file">object files</a>. Each object file can have its own symbol table.</p>
<p>We need to note that when we take a snapshot of the stack (a.k.a stack trace), it could include addresses from linked shared libraries and Kernel functions.</p>
<blockquote>
<p>Kernel-level software differs as it has its own dynamic symbol table in <code>/proc/kallsyms</code>, which is a file that contains all the symbols that are used in the kernel. And it can grow as the kernel modules are loaded.</p>
</blockquote>
<p>We can read the object files by using binary utilities such as <a href="https://en.wikipedia.org/wiki/Objdump">objdump</a>, <a href="https://en.wikipedia.org/wiki/Readelf">readelf</a> and <a href="https://en.wikipedia.org/wiki/Nm_(Unix)">nm</a>.</p>
<p>To read the <code>.symtab</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>nm $FILE
</span></span><span style="display:flex;"><span><span style="color:#75715e"># or</span>
</span></span><span style="display:flex;"><span>objdump --syms $FILE
</span></span><span style="display:flex;"><span><span style="color:#75715e"># or</span>
</span></span><span style="display:flex;"><span>readelf -a $FILE
</span></span></code></pre></td></tr></table>
</div>
</div><p>To read the <code>.dynsym</code>:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>nm -D $FILE
</span></span><span style="display:flex;"><span><span style="color:#75715e"># or</span>
</span></span><span style="display:flex;"><span>objdump --dynamic-syms $FILE
</span></span><span style="display:flex;"><span><span style="color:#75715e"># or</span>
</span></span><span style="display:flex;"><span>readelf -a $FILE
</span></span></code></pre></td></tr></table>
</div>
</div><p>For the compiled languages, the symbol table is not the only source of symbols. There are also DWARFs!</p>
<h4 id="debuginfo">Debuginfo</h4>
<blockquote>
<p>ELFs and DWARFs, welcome to fairyland.</p>
</blockquote>
<p>Another way to obtain the symbols from an object file is to use the debug information or <code>debuginfo</code> in short.
Same as the symbol table, this information can be compiled in the binary file, formatted in the <a href="https://en.wikipedia.org/wiki/DWARF">DWARF(Debugging With Attributed Record Formats)</a> or in a separate file.</p>
<p>DWARF is the debug information format most commonly used with ELF. It’s not necessarily tied to ELF, but the two were developed in tandem and work very well together.
This information is split across different ELF sections (<code>.debug_*</code> and <code>.zdebug_*</code> for compressed ones), each with its own piece of information to relay.
For our specific needs, we need to use the <code>.debug_info</code> section to find corresponding functions and <code>.debug_line</code> section to corresponding line numbers.</p>
<p>Debuginfo files for software packages are available through package managers in Linux distributions.
Usually for an available package called <code>mypackage</code> there exists a <code>mypackage-dbgsym</code>, <code>mypackage-dbg</code> or <code>mypackage-debuginfo</code> package.
There are also <a href="https://sourceware.org/elfutils/Debuginfod.html">public servers</a> that serve debug information.</p>
<h4 id="one-program-to-bring-them-all-and-in-the-darkness-bind-them-addr2line">One Program to bring them all, and in the darkness bind them: <code>addr2line</code></h4>
<blockquote>
<p>Wait, what?! Isn&rsquo;t that from another fantasy book?</p>
</blockquote>
<p>Now that we have the symbol table or debug information, we can use <code>addr2line</code> (<em>address to line</em>) to get the source code location of a given address.
<a href="https://linux.die.net/man/1/addr2line"><code>addr2line</code></a> converts addresses back to function and line numbers.</p>
<p>Let&rsquo;s see it in action <code>addr2line -a 0x0000000000001154 -e &lt;objectFile&gt;</code>:</p>
<p><em>For addr2line <code>&lt;objectFile&gt;</code> can be any object file compiled with debug information or symbols. It can be an executable, a shared library or output of a strip operation.</em></p>
<p>Voilà!</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>0x0000000000001154
</span></span><span style="display:flex;"><span>main
</span></span><span style="display:flex;"><span>/home/newt/Sandbox/hello-c/hello.c:14
</span></span></code></pre></td></tr></table>
</div>
</div><p>I used a simple C executable for this example. And we have got our symbol and attached source information for the corresponding address 🎉</p>
<p><picture><source type="image/webp" srcset="/success_kid_8490891372742932204_hu_c635f31f259dc7c0.webp 480w, /success_kid_8490891372742932204_hu_b15b2f3a9537e4eb.webp 500w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/success_kid_8490891372742932204_hu_8a38c42e4a269d6e.jpg 480w, /success_kid_8490891372742932204.jpg 500w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/success_kid_8490891372742932204.jpg" alt="Success"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="500" height="500">
  </picture></p>
<p>I only wish we had compiled programming language implementations out there, then our job here could have been finished. But we are not. We need to keep digging.
But for that, you need to wait for another week. As we hinted at in the title of this post, there will be a part 2! All the best franchises are sequels, right?!
In part 2, we will see how interpreted languages and <a href="https://en.wikipedia.org/wiki/Just-in-time_compilation">Just-In-Time</a> compiled languages handle symbols.</p>
<p><em>Please stay tuned!</em></p>
<h2 id="dont-worry-we-got-you-covered">Don&rsquo;t worry we got you covered</h2>
<p>Even though we simplified things a bit here, if you want to write a program to utilize symbolization, you still have a lot of work to do.
Many open-source tools out there already handle nitty-gritty details of symbolization, like <a href="https://www.brendangregg.com/perf.html"><code>perf</code></a>.</p>
<p>The good news is we got you covered. If you are using <a href="https://github.com/parca-dev/parca-agent">Parca Agent</a>, we already do <a href="https://www.parca.dev/docs/symbolization/">the heavy lifting</a> for you to symbolize captured stack traces.
And we keep extending our support for the different languages and runtimes.</p>
<p>Check <a href="https://www.parca.dev/">Parca</a> out and let us know what you think, on <a href="https://discord.com/invite/ZgUpYgpzXy">Discord</a> channel.</p>
<h3 id="further-reading">Further reading</h3>
<ul>
<li><a href="https://en.wikipedia.org/wiki/Debug_symbol">https://en.wikipedia.org/wiki/Debug_symbol</a></li>
<li><a href="https://www.brendangregg.com/bpf-performance-tools-book.html">https://www.brendangregg.com/bpf-performance-tools-book.html</a></li>
<li><a href="https://github.com/DataDog/go-profiler-notes/blob/main/stack-traces.md">https://github.com/DataDog/go-profiler-notes/blob/main/stack-traces.md</a></li>
<li><a href="https://www.brendangregg.com/perf.html">https://www.brendangregg.com/perf.html</a></li>
<li><a href="https://jvns.ca/blog/2018/01/09/resolving-symbol-addresses/">https://jvns.ca/blog/2018/01/09/resolving-symbol-addresses/</a></li>
</ul>
<h3 id="sources">Sources</h3>
<ul>
<li><a href="https://en.wikipedia.org/wiki/File:Call_stack_layout.svg">Call Stack Layout</a></li>
<li><a href="https://github.com/corkami/pics/blob/28cb0226093ed57b348723bc473cea0162dad366/binary/elf101/elf101-64.svg">ELF Executable and Linkable Format diagram by Ange Albertini</a></li>
</ul>
]]></content:encoded></item><item><title>Making Drone Builds 10 Times Faster!</title><link>https://kakkoyun.me/posts/making-drone-builds-10-times-faster/</link><pubDate>Wed, 10 Apr 2019 00:00:00 +0000</pubDate><author>kakkoyun@gmail.com (Kemal Akkoyun)</author><guid>https://kakkoyun.me/posts/making-drone-builds-10-times-faster/</guid><description>How we built and open-sourced drone-cache, a Drone CI plugin for caching dependencies and interim files between builds to dramatically reduce build times.</description><content:encoded><![CDATA[<p>We open sourced <a href="https://github.com/MeltwaterArchive/drone-cache">drone-cache</a>, a plugin for the popular Continuous Delivery platform <a href="https://www.drone.io/">Drone</a>. It allows you to cache dependencies and interim files between builds to reduce your build times. This post explains why we are using Drone, why we needed a cache plugin, and what I learned while trying to release drone-cache as open source software.</p>
<p>Read on for the story behind drone-cache or if you want to jump into action directly, go to the <a href="https://github.com/MeltwaterArchive/drone-cache">github.com/meltwater/drone-cache</a>, and try it for yourself.</p>
<p><em>Originally published at</em> <a href="https://underthehood.meltwater.com/blog/2019/04/10/making-drone-builds-10-times-faster/"><em>underthehood.meltwater.com</em></a> <em>on April 10, 2019.</em></p>
<p><picture><source type="image/webp" srcset="/drone_gopher_1840067536720953450_hu_a59bab73f1396ad8.webp 480w, /drone_gopher_1840067536720953450_hu_149a818075c897fc.webp 768w, /drone_gopher_1840067536720953450_hu_9d0dba06fd68e2bc.webp 1024w, /drone_gopher_1840067536720953450_hu_725de2a6dbd36b3a.webp 1200w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/drone_gopher_1840067536720953450_hu_ae933cfb471d11a1.png 480w, /drone_gopher_1840067536720953450_hu_167d33ea787ffc39.png 768w, /drone_gopher_1840067536720953450_hu_17fdfb8e19c2e088.png 1024w, /drone_gopher_1840067536720953450.png 1200w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/drone_gopher_1840067536720953450.png" alt="Drone Cache Logo"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="1200" height="1200">
  </picture></p>
<h2 id="why-are-we-using-drone">Why are we using Drone?</h2>
<p>At Meltwater, we empower self-sufficient teams. Teams are free to choose their technology stacks. As a result, we have a diverse set of tools in our stack. In my team, we had been using a combination of <a href="https://www.travis-ci.com/">TravisCI</a>, <a href="https://circleci.com/">CircleCI</a> and <a href="https://www.jenkins.io/">Jenkins</a> as our <a href="https://en.wikipedia.org/wiki/CI/CD">CI/CD pipeline</a>.</p>
<p>In 2018, we decided to migrate to <a href="https://kubernetes.io/">Kubernetes</a>. In doing so, we wanted to simplify our toolchain and migrate to a more flexible, <a href="https://github.com/cncf/toc/blob/main/DEFINITION.md">cloud-native</a> and on-premise CI/CD pipeline solution. We ended up choosing <a href="https://www.drone.io/">Drone</a>, and with one year of experience under our belt, we are more than happy with it.</p>
<h2 id="how-we-made-the-builds-faster">How we made the builds faster</h2>
<p>My team lives and breaths the &ldquo;<a href="https://en.wikipedia.org/wiki/Release_early,_release_often">release early, release often</a>&rdquo; philosophy. We release and deploy our software to production several times a day. When we moved from CircleCI to Drone, our build times went up drastically.</p>
<p>Build times went up so much because, for each build, our package manager was downloading the Internet (you know, usual suspects are <a href="https://www.npmjs.com/">npm</a>, <a href="https://rubygems.org/">RubyGems</a>, etc.). This was not a problem with CircleCI because of their built-in caching facilities. So with our pace of continuous releases and increased build times, we got frustrated quickly.</p>
<p><picture><source type="image/webp" srcset="/diagram_11694864632854819727_hu_a4afeea42790740.webp 480w, /diagram_11694864632854819727_hu_57c6ee7228c51aa8.webp 602w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/diagram_11694864632854819727_hu_f12d4304ad62b356.png 480w, /diagram_11694864632854819727.png 602w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/diagram_11694864632854819727.png" alt="How does it work?"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="602" height="477">
  </picture></p>
<p>Since we had been spoiled with the wonderful caching features of CircleCI, we wanted the same features in Drone but they are not available by default. However, Drone offers <a href="https://plugins.drone.io/">plugins</a> which are &ldquo;special Docker containers used to drop preconfigured tasks into a Pipeline&rdquo;. We found tens of plugins related to caching in Drone.</p>
<p>We first tried <a href="https://github.com/Drillster/drone-volume-cache">drone-volume-cache</a>, but because volumes are local to the currently running Drone worker node, you cannot be sure your that next build will run on the same machine. Using a storage layer that could persist the cache between builds would be a better option. So we quickly abandoned this approach.</p>
<p>Our Drone deployment runs on <a href="https://aws.amazon.com/">AWS</a>, hence we looked for plugins that use <a href="https://aws.amazon.com/s3/">S3</a> as their storage. We found lots of them and decided to use <a href="https://github.com/bsm/drone-s3-cache">drone-s3-cache</a>. It’s a well-written, simple Go program which follows the Drone <a href="https://github.com/drone/drone-plugin-starter">plugin starter</a> conventions.</p>
<h2 id="why-did-we-decide-to-build-our-own-caching-plugin">Why did we decide to build our own caching plugin?</h2>
<p>After using <em>drone-s3-cache</em> for a couple of weeks, we needed to add another parameter to pass to S3. To do so we forked <a href="https://github.com/bsm/drone-s3-cache">drone-s3-cache</a> and modified it. We thought that nobody would need those minor changes. So rather than contributing back to upstream, we built a docker image of our own and pushed it to our private registry to use as a custom Drone plugin.</p>
<p><picture><source type="image/webp" srcset="/slack_comment_11998578500732327868_hu_50a15e1ca4957f82.webp 480w, /slack_comment_11998578500732327868_hu_ac0a02b87fd3cd07.webp 768w, /slack_comment_11998578500732327868_hu_4098cb76f1d8efb2.webp 848w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/slack_comment_11998578500732327868_hu_ecdebf76fbc08630.png 480w, /slack_comment_11998578500732327868_hu_d5fb1148ce4a853c.png 768w, /slack_comment_11998578500732327868.png 848w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/slack_comment_11998578500732327868.png" alt="Feature request for drone-cache from a colleague"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="848" height="576">
  </picture></p>
<p>Months later, I have received a feature request from one of my colleagues working in a different team, and I was surprised because I didn’t think other teams used drone-cache. When I checked, I realised that various teams throughout Meltwater heavily used it. Then we started to get similar messages and requests from other teams.</p>
<p>I received this message when I was looking for a problem to solve during our <a href="https://underthehood.meltwater.com/blog/2014/08/18/meltwhatever-innovation-day-at-meltwater/">internal</a> <a href="https://en.wikipedia.org/wiki/Hackathon">Hackathon</a>. What are the chances? So I decided to work on this plugin and add the requested feature. Building something to make life easy for fellow developers always gives me pure joy. Long story short, stars were aligned, and we decided to work on our fork and improve it.</p>
<p>I had not worked with Go much, but I always wanted to learn. Thanks to this plugin, I have also achieved this goal of mine. I changed, refactored and churned a lot of code. I experimented with a lot of different ideas. I have added features that nobody has asked for. I tried different things just for the sake of trying. That’s why when I decided to open source my changes, I realised I had re-written the plugin. So rather than sending a pull-request, I created a new repository. <a href="https://github.com/MeltwaterArchive/drone-cache">drone-cache</a> has born!</p>
<p><picture><source type="image/webp" srcset="/standards_5981186134467054768_hu_37dc0886472bb889.webp 480w, /standards_5981186134467054768_hu_da83b710c90bfdf1.webp 500w" sizes="(min-width: 720px) 672px, 100vw"><source srcset="/standards_5981186134467054768_hu_fe6644737228e66f.png 480w, /standards_5981186134467054768.png 500w" sizes="(min-width: 720px) 672px, 100vw">
    <img src="/standards_5981186134467054768.png" alt="Standards"
      loading="lazy" decoding="async"
      sizes="(min-width: 720px) 672px, 100vw" width="500" height="283">
  </picture></p>
<h2 id="how-does-it-work">How does it work?</h2>
<p>What does a Drone cache plugin actually have to accomplish? In Drone, each step in the build pipeline is a container which is thrown away after it serves its purpose. So a caching system has to persist current workspace files between builds. You can think of <a href="https://docs.drone.io/user-guide/pipeline/steps/">workspace</a> as the root of your git repository. It is a mounted volume shared by all steps in your Drone build pipeline.</p>
<p>With drone-cache, after your initial pipeline run, a snapshot of your current workspace will be stored. Then you can restore that snapshot in your next build, which saves you time.</p>
<p>The best example would be to use this plugin with your package managers such as <a href="https://www.npmjs.com/">npm</a>, <a href="https://elixir-lang.org/getting-started/mix-otp/introduction-to-mix.html">Mix</a>, <a href="https://bundler.io/">Bundler</a> or <a href="https://maven.apache.org/">Maven</a>. With restored dependencies from a cache, commands such as <em>npm install</em> would only need to download new dependencies, rather than re-download every package on each build.</p>
<h2 id="what-makes-drone-cache-different-from-other-drone-caching-solutions">What makes drone-cache different from other Drone caching solutions?</h2>
<p>The most useful feature of drone-cache is that you can provide <a href="https://github.com/MeltwaterArchive/drone-cache/blob/master/docs/cache_key_templates.md">your own custom cache key templates</a>. This means you can store your cached files under keys which prescribes your use cases. For example, with a custom key generated from a checksum of a file (say <em>package.json</em>), you keep your cached files until you actually touch that file again.</p>
<p>All other caching solutions for drone offer only a single storage form for your cache. drone-cache in contrast offers 2 storage forms out of the box: an <strong>S3 bucket</strong> or a <strong>mounted volume</strong>. Even better, drone-cache provides a pluggable backend system, so you can implement your own storage backend.</p>
<p>Last but not least, drone-cache is a small CLI program, written in Go without any external OS dependencies. So even if you are not using Drone as your build system, you can still fork and tinker with drone-cache to make it fit your needs.</p>
<h2 id="what-we-have-learned">What we have learned?</h2>
<p>Building a caching solution is hard. Especially, if every team in your company uses it every time they push something to their repositories. It is also fun because it means you have users who give you feedback from the beginning. With the help of my colleagues&rsquo; feedback and feature requests, we have crafted this plugin.</p>
<blockquote>
<p>There are only two hard things in Computer Science: cache invalidation and naming things.
&ndash; Phil Karlton</p>
</blockquote>
<p>What could we have done better? As I have mentioned before, rather than forking and modifying a new code base, we could have contributed back to the original project. We could have applied &ldquo;release early and often&rdquo; philosophy to open sourcing this repository, and we would have collected feedback from the outside world as well. However we didn’t, that’s mostly on me. This is the first time I actually open sourced a project and contributed back to the community. So next time I will know better :)</p>
<p>In Meltwater we are using <em>drone-cache</em> in 20 teams and 120 components now. It works and gets things done for us. We have learned a lot while we build it. We hope this also solves similar problems of yours.</p>
<p>Please <a href="https://github.com/MeltwaterArchive/drone-cache">try it</a> in your pipeline, give us feedback, feel free to open issues and send us pull-requests. Personally, I am also very interested to discuss your experiences with open sourcing in general, so if you have any thoughts on that, please share them in the comments below.</p>
<p><strong>Image Credits</strong></p>
<p>xkcd.com - How standards proliferate <a href="https://xkcd.com/927/">https://xkcd.com/927/</a></p>
<p><em>Originally published at</em> <a href="https://underthehood.meltwater.com/blog/2019/04/10/making-drone-builds-10-times-faster/"><em>underthehood.meltwater.com</em></a> <em>on April 10, 2019.</em></p>
]]></content:encoded></item></channel></rss>