<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://kokosa.dev/feed.xml" rel="self" type="application/atom+xml"/><link href="https://kokosa.dev/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-09-22T10:43:50+02:00</updated><id>https://kokosa.dev/feed.xml</id><title type="html">Konrad ‘Dev Nerd’ Kokosa</title><subtitle>Blog of Konrad Kokosa. </subtitle><entry><title type="html">dotFly - a fruit fly’s brain, simulated in C#</title><link href="https://kokosa.dev/blog/2026/dotfly/" rel="alternate" type="text/html" title="dotFly - a fruit fly’s brain, simulated in C#"/><published>2026-09-22T10:00:00+02:00</published><updated>2026-09-22T10:00:00+02:00</updated><id>https://kokosa.dev/blog/2026/dotfly</id><content type="html" xml:base="https://kokosa.dev/blog/2026/dotfly/"><![CDATA[<video autoplay="" loop="" muted="" playsinline="" poster="/assets/img/dotfly-hero-poster.jpg" class="img-fluid rounded z-depth-1" style="width: 100%; margin-bottom: 1.5rem;"> <source src="/assets/img/dotfly-hero.mp4" type="video/mp4"/> </video> <p>What you see above is a fruit fly flying around a living room, trying to follow a smell cloud, landing on a sugar bowl and extending its proboscis to eat. Which way to turn away from an obstacle, whether the smell is getting stronger or fading, which side the wind blows from, or whether the thing it landed on tastes of sugar is controlled by a <strong>complete central nervous system of a real fruit fly</strong> - 166,700 neurons and 24.9 million synapses, wired exactly as an electron microscope found them - simulated in real time, in pure C#, on a laptop.</p> <p>Well… sort of. And this “sort of” is what this post is about.</p> <h2 id="what-is-this-all-hype-about">What is this all hype about?</h2> <p>On September 3, 2026, Google Research and HHMI Janelia published the <a href="https://male-cns.janelia.org/">MaleCNS</a> <strong>connectome</strong> - the complete wiring diagram of the brain <em>and</em> the “ventral nerve cord” (which carries signals between the brain and the body) of one adult male <em>Drosophila</em> - a fruit fly. Within days the internet did what the internet does: the fly brain was <a href="https://github.com/nftechie/doomfly">playing Doom</a>, <a href="https://github.com/ornata/fly">Super Mario 64</a>, <a href="https://flydino.cobanov.dev/">jumping over cacti</a>, <a href="https://huggingface.co/spaces/Xenova/fruit-fly-simulation">walking in the browser</a>, and <a href="https://news.ycombinator.com/item?id=49353221">sitting on a Mac desktop</a>.</p> <p>I got hooked. Just like with <a href="/blog/2026/dotllm/">dotLLM</a>, I wanted to understand what is really going on there - and for me, with AI, the best way to really understand something is to build it. So I built <a href="https://github.com/kkokosa/dotFly">dotFly</a>: a native .NET “inference engine” of fruit fly-connectome, fast enough to sit inside a Godot game loop. No Python, no native lib wrappers, purely CPU-based.</p> <p>This post covers: what a connectome actually is and how it was made, what the “fly brain model” everyone runs really is (and even more importantly, what it is not), what all those demos are doing, how dotFly does it in C# with some benchmarks, ending with honest limits of the whole thing.</p> <h2 id="wait-is-this-ethical">Wait, is this ethical?</h2> <p>Before we go into the details, a question that pops up immediately: if this <em>is</em> simulating a real brain, should we be doing this at all? Is the simulated fly suffering when it bumps into a wall in Mario? People asked exactly that on <a href="https://www.reddit.com/r/AskScienceDiscussion/comments/1wfblbv/is_the_use_of_the_recent_fruit_fly_brain_model/">r/AskScienceDiscussion</a> and on <a href="https://news.ycombinator.com/item?id=49353221">Hacker News</a>, where one commenter asked: <em>“Is the utility of this program worth a 10% chance that we confidently create perhaps the most cruellest machine possible?”</em>. Do we create a “real living” that we torture in a virtual environment?!</p> <p>No matter how sci-fi those questions sound, I think it is fair to ask, and I don’t want to dismiss it. But the honest answer requires understanding what is being simulated, and the short version is: <strong>it is in some sense simulating the real brain</strong> - the wiring is measured from a real animal, neuron by neuron, synapse by synapse - <strong>and at the same time it is very far from a living brain</strong>. It leaves out much of the brain’s chemistry and detailed ways neurons communicate, and ignores what happens inside each neuron. There are no hormones, no internal state, and no learning. Just kind of “one weight for all 25 million synapses” simplification. The demos “play games” through hand-written adapters that would make <em>any</em> nonlinear dynamical system look like it plays. Nothing in this model establishes that the scanned individual, its memories, or anything resembling experience was preserved.</p> <p>So no, I don’t think we created “the cruellest experiment possible”. Well, yet. But I also think the discomfort is a useful reminder to be precise about what these demos are - which is what the rest of this post tries to be.</p> <h2 id="from-the-basics-what-is-a-connectome">From the basics: what is a connectome?</h2> <p>Three terms are enough to understand everything that follows:</p> <ul> <li><strong>connectome</strong> - the reconstructed “wiring” diagram: which neuron connects to which, with how many synaptic contacts, with what neurotransmitter</li> <li><strong>neural model</strong> - the equations that make that wiring <em>active</em> (the connectome itself doesn’t compute anything, it’s a graph)</li> <li><strong>embodiment</strong> - an engineered “interface” between the model and a body, a game, or a world</li> </ul> <blockquote class="block-info"> <p>The internet downloaded that wiring diagram (<strong>connectome</strong>) and added dynamics and interfaces. It did not download a frozen executable copy of a living fly.</p> </blockquote> <h3 id="how-it-was-made">How it was made</h3> <p>It is easy to compress the whole story into “Google mapped a fly brain in September”. The reality is a chain of achievements spanning many years of research:</p> <ul> <li><strong>2018</strong> - Zheng et al. publish the first complete electron microscopy volume of an adult <em>female</em> fly brain (the FAFB dataset). Just the images - a ~100 TB stack of slices</li> <li><strong>2024</strong> - the <a href="https://flywire.ai/">FlyWire</a> consortium publishes the full reconstruction of that brain: ~140,000 neurons, 50 M synapses (Dorkenwald et al., Nature)</li> <li><strong>2024</strong> - Shiu et al. (Nature) publish the first computational model of that whole brain and validate it against real experiments. This is the neural model everybody runs now</li> <li><strong>2025-2026</strong> - MaleCNS: one five-day-old adult <strong>male</strong> (specimen <code class="language-plaintext highlighter-rouge">Z0720-07m</code>) connectome, brain + neck + ventral nerve cord dissected together. Fixed with aldehydes, stained with heavy metals, embedded in resin, cut by a hot knife into 66 slabs ~20 µm thick, and imaged with enhanced focused-ion-beam SEM at 8×8×8 nm resolution: a gallium ion beam mills away a thin layer, the SEM images the new face, repeat. Seven customized instruments, about 160 teravoxels, <strong>~13 months of imaging</strong>. Then Google’s flood-filling networks segmented the neurons, automated detectors found the synapses, a ResNet50 classified the neurotransmitter from EM neighborhoods - and 29 expert proofreaders spent about <strong>44 person-years</strong> fixing and annotating it all. Result: 166,700 neurons, 11,710 cell types, 25.6 M directed edges, 124 M synaptic contacts. Published in Cell on September 3, 2026 (Berg, Beckett, Costa, Schlegel et al.), with the v1.0 data available since June.</li> </ul> <h3 id="what-it-is-not">What it is NOT</h3> <p>A connectome is a “photo” of a dead, sliced, metal-stained brain. It is <em>not</em> an fMRI or an electrode recording of a living brain. It does not measure:</p> <ul> <li>membrane voltages or any activity at all</li> <li>the functional strength of a synapse (only how many contacts there are)</li> <li>hormones impact</li> <li>anything that was learned, remembered, or felt at that time (well, the fly was dead…)</li> </ul> <p>It is a wiring diagram - a very, very good one - with a <em>predicted</em> neurotransmitter label per neuron.</p> <h3 id="what-are-the-inputs-and-outputs">What are the “inputs” and “outputs”?</h3> <p>If the graph is just 166,700 neurons with IDs, how do we know where the eyes are? This is where a <em>century</em> of Drosophila genetics and neuroanatomy comes in. Each neuron in the model carries an annotation - a cell type (<code class="language-plaintext highlighter-rouge">LC4</code>, <code class="language-plaintext highlighter-rouge">T4a</code>, <code class="language-plaintext highlighter-rouge">MN9</code>, <code class="language-plaintext highlighter-rouge">DNp04</code>…), a class, a side - assigned by proofreaders from its morphology, location and connectivity, matched to the literature. And the literature is huge, for example we already know that:</p> <ul> <li><code class="language-plaintext highlighter-rouge">LC4</code> neurons are known (from calcium imaging, optogenetics, behavior) to respond to <em>looming</em> objects (objects getting closer/bigger),</li> <li><code class="language-plaintext highlighter-rouge">T4a-d</code> to motion in four directions,</li> <li><code class="language-plaintext highlighter-rouge">ORN_DM1</code> to a specific odor/smell,</li> <li>the so-called sugar gustatory receptor neurons (<code class="language-plaintext highlighter-rouge">GRN</code>s) to sugar,</li> <li><code class="language-plaintext highlighter-rouge">MN9</code> is the motor neuron that extends the proboscis,</li> <li><code class="language-plaintext highlighter-rouge">DNp04</code> and the Giant Fiber are the escape descending neurons (‘outputs’) that go down to the nerve cord.</li> </ul> <p>So, the sensory neurons are the <strong>inputs</strong> for example: photoreceptors, smell- and taste-sensing neurons, movement- and touch-sensing neurons for the fly’s “antenas”.</p> <p>And the so-called descending and motor neurons are the <strong>outputs</strong>: ~1,300 descending neurons carry the brain’s commands to the nerve cord, motor neurons drive muscles. That’s not an assumption of the model - it is the anatomy.</p> <p>Let’s follow one signal through, the simplest reflex a fly has - <em>something is coming at me, get out</em>:</p> <pre><code class="language-mermaid">graph LR
    A["a dark spot grows&lt;br/&gt;in the left eye"] --&gt; B["photoreceptors&lt;br/&gt;R1–R6"]
    B --&gt; C["lamina, medulla&lt;br/&gt;&lt;small&gt;the optic lobe&lt;/small&gt;"]
    C --&gt; D["LC4 (left)&lt;br/&gt;&lt;small&gt;71 neurons:&lt;br/&gt;'something is looming'&lt;/small&gt;"]
    D --&gt; E["DNp04, DNp02, DNp11,&lt;br/&gt;Giant Fiber&lt;br/&gt;&lt;small&gt;descending neurons&lt;/small&gt;"]
    E --&gt; F["ventral nerve cord&lt;br/&gt;→ wing &amp; leg motor neurons"]
    F --&gt; G["take off,&lt;br/&gt;turn away"]
</code></pre> <p>Now the same thing as an experiment. I take the MaleCNS graph, drive the 71 left <code class="language-plaintext highlighter-rouge">LC4</code> neurons with random spikes at 120 Hz for one second - this is what “an object is expanding in the left eye” looks like to the brain - and record who fires. One <code class="language-plaintext highlighter-rouge">dotfly run --stimulate type:LC4@L --hz 120</code> later:</p> <table> <thead> <tr> <th>Neuron</th> <th>Type</th> <th>What it is</th> <th style="text-align: right">Rate</th> </tr> </thead> <tbody> <tr> <td>531898</td> <td><code class="language-plaintext highlighter-rouge">DNp04</code> (left)</td> <td>escape descending neuron</td> <td style="text-align: right">308 Hz</td> </tr> <tr> <td>10010</td> <td><code class="language-plaintext highlighter-rouge">DNp01</code></td> <td>the Giant Fiber - the fly’s “jump” command</td> <td style="text-align: right">276 Hz</td> </tr> <tr> <td>10197</td> <td><code class="language-plaintext highlighter-rouge">DNp02</code></td> <td>escape descending neuron</td> <td style="text-align: right">256 Hz</td> </tr> <tr> <td>10259</td> <td><code class="language-plaintext highlighter-rouge">DNp11</code></td> <td>escape descending neuron</td> <td style="text-align: right">244 Hz</td> </tr> <tr> <td>10752</td> <td><code class="language-plaintext highlighter-rouge">DNp03</code></td> <td>escape descending neuron</td> <td style="text-align: right">220 Hz</td> </tr> </tbody> </table> <p>4,792 neurons became active in total (out of 166,700), and the top of the list is exactly the escape pathway the literature describes - on the <em>same</em> side as the stimulated eye. Nobody told the model that <code class="language-plaintext highlighter-rouge">LC4</code> is wired to <code class="language-plaintext highlighter-rouge">DNp04</code>. The “wiring” is simply there, and activating the input lights the output. That’s the whole game: <strong>activate the inputs, observe the outputs</strong>.</p> <h2 id="the-model-everybody-runs-shiu-et-al-2024">The model everybody runs: Shiu et al. 2024</h2> <p>The graph does nothing by itself. <a href="https://doi.org/10.1038/s41586-024-07763-9">Shiu et al.</a> made it run with the simplest thing that could possibly work: every neuron is a <strong>leaky integrate-and-fire (LIF) point neuron</strong>. Forget the shape of the neuron, its dendrites and ion channels. What is left is a single number, its “membrane potential”. Incoming spikes push it up or down, it leaks back toward a resting value, and when it crosses a threshold the neuron emits a spike of its own and resets. That’s it:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/dotfly-lif-480.webp 480w,/assets/img/dotfly-lif-800.webp 800w,/assets/img/dotfly-lif-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/dotfly-lif.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Note how much it takes to fire one: three spikes from a neuron with 90 synaptic contacts, within a few milliseconds - a single spike from a single input neuron does nothing on its own (it would need about 162 contacts). Firing is <em>convergence</em>.</p> <p>How hard a spike pushes is the only place where the connectome enters the equations. Every edge from neuron $i$ to neuron $j$ has a weight:</p> \[w_{ij} = \operatorname{sign}(\text{NT}_i) \cdot n_{ij} \cdot w_{syn}\] <p>$n_{ij}$ is the number of synaptic contacts between the two neurons - the microscope counted them. $w_{syn}$ is the same value for all 25 million synapses: <code class="language-plaintext highlighter-rouge">0.275 mV</code> per contact. This is <strong>the single calibrated parameter</strong> of the whole model. Shiu et al. fitted it so that stimulating the sugar-sensing neurons produces a proboscis-extension response of realistic strength, and left it there. Everything else - time constants, threshold, refractory period, synaptic delay - came from previous electrophysiology.</p> <blockquote class="block-info"> <p>The $\operatorname{sign}(\text{NT}_i)$ needs a word of explanation for the non-biologists like me. A neuron talks to the next one by releasing a chemical, a <em>neurotransmitter</em>, at the synapse - and each neuron uses (mostly) one kind. Some of those chemicals <em>excite</em> the receiving neuron, pushing its potential up toward the threshold; others <em>inhibit</em> it, pushing it down. The connectome does not measure which chemical a neuron uses either - a classifier <em>predicts</em> it from how the synapse looks in the EM image. The model then reduces the whole chemistry to one bit: in the fly, acetylcholine is the main excitatory transmitter, so <code class="language-plaintext highlighter-rouge">+1</code>. GABA and glutamate are inhibitory, so <code class="language-plaintext highlighter-rouge">-1</code>. Three others - dopamine, octopamine, serotonin - are in reality <em>modulators</em>: they change how other synapses behave, they carry things like reward and arousal, and a LIF model <strong>has no way to express that</strong>. Shiu et al. simply treat them as excitatory, <code class="language-plaintext highlighter-rouge">+1</code>. It is a modelling convention, not physiology: assigning <code class="language-plaintext highlighter-rouge">+1</code> to a dopamine neuron does not implement reward or learning.</p> </blockquote> <p>Researchers used the model to make predictions - “if we activate these neurons, those should fire” - and tested 164 of them against real flies with optogenetics and calcium imaging. If paper says <strong>91% agreement</strong> - it means exactly that - 91% of those 164 predictions in <strong>feeding and grooming circuits</strong>. Not 91% of entire fly behavior.</p> <h3 id="the-connectome-is-fixed---we-do-not-train-it">The connectome is fixed - we do not train it</h3> <p>This is the crucial point. The <strong>weights are fixed</strong>, coming from the number of pairs between each neurons. Nobody trains this network to play Mario, and nobody should: the moment you start fitting the 25M weights to a game score, you no longer have “the fly’s brain” - you have some neural network that happens to have started from a fly-shaped graph. The original model has no plasticity rule at all.</p> <p>So the whole “game” is, again: <strong>activate the inputs, observe the outputs</strong>. Drive the sugar neurons with Poisson spikes at 150 Hz, and watch which of the other 127,000 neurons start to fire. Silence one neuron and see what changes. That’s also what makes it valuable - it is a programmable, testable model of neural circuitry, where a hypothesis becomes a run.</p> <h2 id="so-what-are-all-those-demos-doing">So what are all those demos doing?</h2> <p>Every “fly brain plays X” demo, including mine, is three things stacked:</p> <pre><code class="language-mermaid">graph LR
    W["World / game"] --&gt;|"encoder&lt;br/&gt;&lt;small&gt;engineered&lt;/small&gt;"| I["Input neurons&lt;br/&gt;&lt;small&gt;measured identity&lt;/small&gt;"]
    I --&gt; B["Connectome + LIF dynamics&lt;br/&gt;&lt;small&gt;measured wiring, one calibrated weight&lt;/small&gt;"]
    B --&gt; O["Readout neurons&lt;br/&gt;&lt;small&gt;measured identity&lt;/small&gt;"]
    O --&gt;|"decoder&lt;br/&gt;&lt;small&gt;engineered (fixed or trained)&lt;/small&gt;"| W
</code></pre> <p>The wiring and the dynamics are shared. The <strong>adapters</strong> are where the demos differ, and where much of the apparent intelligence lives: the <em>encoder</em> that turns pixels or some other state into firing rates (fe. of visual neurons - which ones, and how many Hz for what?), and the <em>decoder</em> that turns firing rates (of descending neurons - the brain’s “motor command” cells) into button presses or body motion. Look at a few:</p> <p><strong><a href="https://huggingface.co/spaces/Xenova/fruit-fly-simulation">Xenova’s Fruit Fly Simulation</a></strong> - the full 166,700-neuron MaleCNS LIF running in the browser on WebGPU, driving a 3D fly body. You <em>paint</em> stimulation on neurons, or use presets: “walk” stimulates <code class="language-plaintext highlighter-rouge">LC9</code> on both sides, “turn left” stimulates <code class="language-plaintext highlighter-rouge">LC9</code> on the left plus <code class="language-plaintext highlighter-rouge">DNa02</code> on the left, “fly” stimulates <code class="language-plaintext highlighter-rouge">LC4</code> on both sides. <code class="language-plaintext highlighter-rouge">LC4</code> and <code class="language-plaintext highlighter-rouge">LC9</code> are visual projection neurons - each <code class="language-plaintext highlighter-rouge">LC</code> type is a detector that reports one kind of visual event to the central brain (<code class="language-plaintext highlighter-rouge">LC4</code>: “something is expanding toward me”); <code class="language-plaintext highlighter-rouge">DNa02</code> is a descending neuron that experiments show drives turning. On the output side, the demo reads a handful of descending neurons: <code class="language-plaintext highlighter-rouge">DNp09</code>/<code class="language-plaintext highlighter-rouge">DNg100</code>/<code class="language-plaintext highlighter-rouge">DNg97</code> for walking speed, <code class="language-plaintext highlighter-rouge">DNa02</code>/<code class="language-plaintext highlighter-rouge">DNa11</code>/<code class="language-plaintext highlighter-rouge">DNg13</code> left vs right for turning, <code class="language-plaintext highlighter-rouge">MDN</code> (the “moonwalker” backing-up neuron) for a jump, <code class="language-plaintext highlighter-rouge">DNp01</code> (the Giant Fiber) for escape flight - each with a hand-chosen gain (<code class="language-plaintext highlighter-rouge">8 mm/s</code>, <code class="language-plaintext highlighter-rouge">3.8 rad/s</code>…) documented in its <code class="language-plaintext highlighter-rouge">model.json</code>, which I appreciate a lot. One thing to be aware of: the “turn” presets poke the <em>output</em> neuron <code class="language-plaintext highlighter-rouge">DNa02</code> directly, not just the visual input. The author says why - the visual input alone activates <code class="language-plaintext highlighter-rouge">DNa02</code> too weakly - but it means the fly would still turn even if you cut every connection inside the brain. Still, this is the tool that tries to show real wiring activity without heavy adapters around it.</p> <p><strong><a href="https://flydino.cobanov.dev/">Fly Dino</a></strong> - a different approach altogether. Instead of the whole brain, an <strong>80-neuron</strong> subcircuit: 32 visual detector neurons (<code class="language-plaintext highlighter-rouge">LC4</code>, <code class="language-plaintext highlighter-rouge">LC11</code>, <code class="language-plaintext highlighter-rouge">LPLC2</code>… the same “something is coming” family) and 16 descending neurons, picked because in the connectome they are the most strongly wired path from “visual event” to “motor command”, plus the cells in between. Run as a graded rate model (<code class="language-plaintext highlighter-rouge">tanh</code>), not Shiu’s spiking LIF. Eight game features (obstacle distance, size, speed… <em>not pixels</em>) are injected into the visual neurons. Then the decoder adapter: the activity of the 16 output neurons goes into a tiny neural network - 243 numbers in total - that picks Run, Jump or Duck. That tiny network was trained by trial and error over 15,680 games; the fly circuit itself never changed. So what’s happening here is that we trained output adapter to interpret some of the wiring activity and translate them into game actions. This is <em><a href="https://arxiv.org/abs/2412.13212">reservoir computing</a></em>: the brain is used as a fixed, complicated transformation, and only the last “which action” mapping is fitted from training. The nice part is the control experiment: switch the fly circuit off but keep the trained adapter, and the score drops from 2,886 to 41. So the circuit <strong>is</strong> doing something - it acts as a fixed, nonlinear transformation of the inputs that the small network learned to read.</p> <p><strong><a href="https://github.com/shantanugoel/fly-games">“Fly Mario”-style demos</a></strong> (<a href="https://github.com/ornata/fly">Fly64</a>, <a href="https://github.com/shantanugoel/fly-games">fly-games</a>) - the full MaleCNS LIF stepped at a coarse time-slices (20 ms), game frames turned into features for the visual detectors (<code class="language-plaintext highlighter-rouge">LC4</code>: threat, <code class="language-plaintext highlighter-rouge">LPLC2</code>: something approaching, <code class="language-plaintext highlighter-rouge">LC10a</code>: something to chase), and on the output side is neuron-to-button mapping: a dozen descending neuron groups - <code class="language-plaintext highlighter-rouge">DNa02</code> left/right for steering, <code class="language-plaintext highlighter-rouge">DNp09</code> for walking forward, and so on - each mapped to a button, with a threshold. In Fly64 that mapping is hand-made (“if left <code class="language-plaintext highlighter-rouge">DNa02</code> fires more than right, press Left”); in fly-games it is fitted by a small linear regression from recorded example play. Reservoir computing again. It predicts the right button 90% of the time on recorded episodes it never saw - and the fly still dies early in live play, because live situations drift away from the recordings. Mario walks into a wall and stays there.</p> <p>None of this is a criticism. Those are all honest experiments. The point is - <strong>the adapters matter enormously</strong>, and a moving body proves nothing about what the wiring contributed unless you also run the controls - switch off the connections inside the brain and/or switch off the input.</p> <h2 id="this-leads-us-to-dotfly">This leads us to dotFly</h2> <p>dotFly is, first of all, a <strong>library</strong>: a .NET inference engine for fly-connectome spiking models that Godot, console apps, tests and benchmarks link in-process. It builds memory-mapped checkpoints from the MaleCNS and FlyWire releases, runs the Shiu et al. model on them - exactly (the float64 backend matches Brian2 spike-for-spike) and fast (the float32 SIMD backend runs the <strong>whole MaleCNS in real time on a laptop</strong>) - and exposes stimuli in, spikes and rates out, recording and replay, and the causal switches. The room demo is a demo: the thing I built to see whether a <em>thin</em> embodiment, with the fly’s own senses still needs a lot of “adapter”.</p> <h3 id="the-room-demo">The room demo</h3> <p>A Godot 4 (.NET) 3D scene: a sunlit living room, a fly, sugar on a table, and “smell clouds” drifting downwind from few places. The MaleCNS connectome runs on dotFly as <strong>the only thing</strong> between her senses and her actions. Here is what every panel means:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/dotfly-room-annotated-480.webp 480w,/assets/img/dotfly-room-annotated-800.webp 800w,/assets/img/dotfly-room-annotated-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/dotfly-room-annotated.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p><strong>Senses → inputs (encoders, fixed)</strong> - two cameras, one per compound eye, render 32×32 luminance images. Per eye, the dark fraction and its growth become an <code class="language-plaintext highlighter-rouge">LC4</code> (looming) rate. Horizontal and vertical image shift become <code class="language-plaintext highlighter-rouge">T4a-d</code> (optic flow) rates. Odor concentration at the two antenas becomes an <code class="language-plaintext highlighter-rouge">ORN_DM1</code> rate, wind side a <code class="language-plaintext highlighter-rouge">JO-C/E</code> (Johnston’s organ) rate, and sugar contact when landed drives the fly’s taste-sensing neurons in its feet and mouth. 7,524 input neurons in total.</p> <p><strong>The connectome (measured)</strong> - all 166,700 neurons, drawn at their real positions and lit by their real spikes. Nothing here is drawn from a script.</p> <p><strong>Readouts (measured circuits)</strong> - every circuit read is one that I found <em>in the checkpoint</em> with the CLI (<code class="language-plaintext highlighter-rouge">dotfly inspect --upstream-of</code>, <code class="language-plaintext highlighter-rouge">dotfly run --stimulate</code>), not assumed - 38 neurons in 13 groups:</p> <ul> <li><code class="language-plaintext highlighter-rouge">LC4 → DNp04 L/R, Giant Fiber</code> - escape</li> <li><code class="language-plaintext highlighter-rouge">T4a → HS</code>, <code class="language-plaintext highlighter-rouge">T4b → H2</code>, <code class="language-plaintext highlighter-rouge">T4c → VS</code> - optic flow</li> <li><code class="language-plaintext highlighter-rouge">ORN_DM1 → DM1 projection neurons</code> - odor amount</li> <li><code class="language-plaintext highlighter-rouge">JO-C/E → AMMC012</code> - wind direction</li> <li><code class="language-plaintext highlighter-rouge">GRN → MN9</code> - feeding</li> </ul> <p><strong>The decoder (fixed, hand-written)</strong> - printed on screen next to the readouts it consumes, and every number in it is a choice:</p> <table> <thead> <tr> <th>Brain signal (readout)</th> <th>Rule</th> <th>Behavior</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">DNp04</code> left vs right</td> <td><code class="language-plaintext highlighter-rouge">yaw += 1.2 · (DNp04_L − DNp04_R)</code></td> <td>turn away from the looming side</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">HS</code> + <code class="language-plaintext highlighter-rouge">H2</code> left vs right</td> <td><code class="language-plaintext highlighter-rouge">yaw += 0.2 · ((HS_R + H2_L) − (HS_L + H2_R))</code></td> <td>counter-turn against rotation (optomotor)</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">HS</code> left vs right</td> <td><code class="language-plaintext highlighter-rouge">yaw −= 0.3 · (HS_R − HS_L)</code></td> <td>center between walls</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">DNp01</code> (Giant Fiber)</td> <td><code class="language-plaintext highlighter-rouge">speed = 120 + DNp01</code> body lengths/s</td> <td>cruise, burst on escape (a real fly: 100-170 BL/s)</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">DM1_lPN</code> (odor amount)</td> <td><code class="language-plaintext highlighter-rouge">&gt; 110 Hz</code> → surge while rising; falling → tumble; lost → U-turn, then casts; <code class="language-plaintext highlighter-rouge">&gt; 270 Hz</code> → descend, land</td> <td>odor search</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">AMMC012</code> left vs right (wind)</td> <td>while surging: <code class="language-plaintext highlighter-rouge">yaw −= 0.6 · (wind_L − wind_R)</code></td> <td>fly upwind</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">MN9</code> (smoothed 0.3 s)</td> <td><code class="language-plaintext highlighter-rouge">&gt; 15 Hz</code> → feed; stops → take off</td> <td>feeding</td> </tr> </tbody> </table> <p>Interesting finding is that <em>smell direction</em> is simply not derivable from any reading (both <code class="language-plaintext highlighter-rouge">DM1</code> candidate neurons respond alike whichever antenna is stimulated), so I was forced to put some “logic” into adapter that the fly searches by flying forward while the smell gets stronger, then turning around when it fades, with the wind signal coming from the brain.</p> <h2 id="dotfly-under-the-hood-c-performance">dotFly under the hood: C# performance</h2> <p>dotFly is the sibling of dotLLM in conventions and spirit: a memory-mapped model file, unmanaged state, SIMD kernels, zero allocations on the hot path. Same layered layout, each project a NuGet package:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>src/DotFly.Core      .dfb checkpoint reader (mmap), graph tables, NeuronModel, RNG, IBackend
src/DotFly.Data      MaleCNS / FlyWire release readers
src/DotFly.Cpu       SIMD LIF kernel, compute thread pool
src/DotFly.Engine    Simulation, input/output ports, real-time driver, recorder, adapters
src/DotFly.Adapters.*  ONNX Runtime / ML.NET readout adapters
src/DotFly.Cli       dotfly build | inspect | run | bench | explore
</code></pre></div></div> <p>And the API demos program against:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Neurons are addressed by their exact 64-bit IDs from the connectome release (FlyWire root IDs</span>
<span class="c1">// or MaleCNS body IDs)</span>
<span class="kt">ulong</span><span class="p">[]</span> <span class="n">sugarGrnIds</span> <span class="p">=</span> <span class="p">[</span><span class="m">720575940624963786</span><span class="p">,</span> <span class="m">720575940630233916</span><span class="p">,</span> <span class="cm">/* 21 sugar GRNs from Shiu et al. */</span><span class="p">];</span>
<span class="k">const</span> <span class="kt">ulong</span> <span class="n">Mn9</span> <span class="p">=</span> <span class="m">720575940660219265</span><span class="p">;</span>   <span class="c1">// MN9, the proboscis motor neuron (FlyWire v630 root ID)</span>

<span class="k">using</span> <span class="nn">Brain</span> <span class="n">brain</span> <span class="p">=</span> <span class="n">Brain</span><span class="p">.</span><span class="nf">Open</span><span class="p">(</span><span class="s">".data/shiu2024/flywire-v630.dfb"</span><span class="p">);</span>
<span class="k">using</span> <span class="nn">Simulation</span> <span class="n">sim</span> <span class="p">=</span> <span class="n">brain</span><span class="p">.</span><span class="nf">CreateSimulation</span><span class="p">(</span><span class="k">new</span> <span class="n">SimulationOptions</span> <span class="p">{</span> <span class="n">Seed</span> <span class="p">=</span> <span class="m">1</span> <span class="p">});</span>

<span class="n">NeuronSet</span> <span class="n">sugar</span> <span class="p">=</span> <span class="n">brain</span><span class="p">.</span><span class="nf">ByBodyIds</span><span class="p">(</span><span class="s">"sugar_GRN_R"</span><span class="p">,</span> <span class="n">sugarGrnIds</span><span class="p">);</span>
<span class="n">sim</span><span class="p">.</span><span class="nf">Input</span><span class="p">(</span><span class="n">sugar</span><span class="p">,</span> <span class="n">InputKind</span><span class="p">.</span><span class="n">PoissonToV</span><span class="p">).</span><span class="nf">Fill</span><span class="p">(</span><span class="m">150f</span><span class="p">);</span>                   <span class="c1">// Poisson drive, 150 Hz each</span>
<span class="n">OutputPort</span> <span class="n">mn9</span> <span class="p">=</span> <span class="n">sim</span><span class="p">.</span><span class="nf">Output</span><span class="p">(</span><span class="n">brain</span><span class="p">.</span><span class="nf">ByBodyId</span><span class="p">(</span><span class="n">Mn9</span><span class="p">),</span> <span class="n">OutputKind</span><span class="p">.</span><span class="nf">Rate</span><span class="p">(</span><span class="m">50</span><span class="p">.</span><span class="nf">Ms</span><span class="p">()));</span>

<span class="n">sim</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="m">1</span><span class="p">.</span><span class="nf">Seconds</span><span class="p">());</span>                                                 <span class="c1">// neural time</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"MN9 </span><span class="p">{</span><span class="n">mn9</span><span class="p">.</span><span class="n">Snapshot</span><span class="p">.</span><span class="nf">Mean</span><span class="p">():</span><span class="n">F1</span><span class="p">}</span><span class="s"> Hz at </span><span class="p">{</span><span class="n">sim</span><span class="p">.</span><span class="n">Clock</span><span class="p">.</span><span class="n">RealTimeFactor</span><span class="p">:</span><span class="n">F1</span><span class="p">}</span><span class="s">× real time"</span><span class="p">);</span>
</code></pre></div></div> <p>This is the Shiu et al. sugar experiment in ten lines: drive the 21 sugar-sensing gustatory neurons, read the proboscis motor neuron. The full sample (<code class="language-plaintext highlighter-rouge">samples/DotFly.Sample.SugarExperiment</code>) runs the paper’s 30 trials plus its silencing experiments and compares with the published results:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>flywire-v630: 127,400 neurons, 14,687,178 edges. Sugar GRNs: 21, MN9 index 127193.
30 trials × 1 s in 3.0 s wall (10.1 simulated s per wall s).

Active neurons (mean over trials): 433. Top 10:
  720575940621502051    152.3 Hz  (sugar GRN)
  720575940632425919    150.7 Hz  (sugar GRN)
  ...
MN9 (720575940660219265): 83.3 Hz mean over 30 trials.

Silencing experiments (outgoing synapses zeroed; the neuron still spikes):
  silence 720575940621502051: MN9 83.3 Hz (intact 83.3 Hz)
  silence 720575940632425919: MN9 78.7 Hz (intact 83.3 Hz)
  silence 720575940633143833: MN9 79.6 Hz (intact 83.3 Hz)

Comparison with published results/example/sugarR.parquet (Brian2 2.5.1, 30 trials):
  active neurons: 448 vs 433 (Jaccard 0.949); rate correlation 0.9946; spike-count ratio 0.803
  MN9: 93.3 Hz vs 83.3 Hz
</code></pre></div></div> <p>Thirty one-second trials of a 127,400-neuron brain in three seconds, and the answer the paper got: sugar in, proboscis motor neuron out at ~80 Hz. For populations you normally query by annotation instead - <code class="language-plaintext highlighter-rouge">brain.Query(type: "LC4", side: Side.Left)</code>, <code class="language-plaintext highlighter-rouge">brain.Query(cls: "Kenyon_Cell")</code> - and raw IDs are for reproducing a paper or reading a single named cell. For a game loop, <code class="language-plaintext highlighter-rouge">new RealtimeDriver(sim).Start()</code> runs the network on its own threads, the game thread writes <code class="language-plaintext highlighter-rouge">InputPort</code>s and reads <code class="language-plaintext highlighter-rouge">OutputPort.Snapshot</code> frames lock-free.</p> <p>Some of the design decisions that made the difference:</p> <ul> <li><strong>Checkpoint as a memory-mapped file</strong> (<code class="language-plaintext highlighter-rouge">.dfb</code>, the GGUF of dotFly) - the graph in Compressed Sparse Row (CSR) form - <code class="language-plaintext highlighter-rouge">rowPtr[N+1]</code>, <code class="language-plaintext highlighter-rouge">col[E]</code>, <code class="language-plaintext highlighter-rouge">w[E]</code> (<code class="language-plaintext highlighter-rouge">int16</code> contact counts, signed) - plus annotations, positions and a header recording the data version, filters and sign policy. Building it from the 1 GB Arrow release takes ~10 s, opening it takes milliseconds</li> <li><strong>Structure of Arrays state in unmanaged, 64-byte-aligned memory</strong> - <code class="language-plaintext highlighter-rouge">v[N]</code>, <code class="language-plaintext highlighter-rouge">g[N]</code> in <code class="language-plaintext highlighter-rouge">float32</code>, a refractory countdown, a ring of 19 spike lists. ~2 MB for the whole MaleCNS - it fits in L2</li> <li><strong>The LIF kernel is <code class="language-plaintext highlighter-rouge">Vector256&lt;float&gt;</code></strong> - spike indices compacted with <code class="language-plaintext highlighter-rouge">TrailingZeroCount</code>. Kernels are generic over <code class="language-plaintext highlighter-rouge">TFloat : IBinaryFloatingPointIeee754&lt;TFloat&gt;</code> so <code class="language-plaintext highlighter-rouge">double</code> (the reference that matches Brian2 to 1e-11 mV) and <code class="language-plaintext highlighter-rouge">float</code> (the fast path) share one implementation</li> <li><strong><code class="language-plaintext highlighter-rouge">[MethodImpl(MethodImplOptions.AggressiveOptimization)]</code></strong> on every per-step method - the single biggest surprise. Without it the tiered JIT left the kernel and the batch loop at tier-0/OSR for most of a short run: 21 µs/step instead of 6.</li> </ul> <h3 id="benchmarks">Benchmarks</h3> <p>End to end, <code class="language-plaintext highlighter-rouge">dotfly bench</code> on my 8-core laptop (Ryzen 7 5800HS, AVX2):</p> <table> <thead> <tr> <th>Scenario</th> <th style="text-align: right">Neurons</th> <th style="text-align: right">Real-time factor</th> <th style="text-align: right">Steps/s</th> </tr> </thead> <tbody> <tr> <td>FlyWire v630, 21 sugar GRNs @ 150 Hz (the Shiu experiment)</td> <td style="text-align: right">127,400</td> <td style="text-align: right">19.6×</td> <td style="text-align: right">196,000</td> </tr> <tr> <td>MaleCNS v1.0, no input</td> <td style="text-align: right">166,700</td> <td style="text-align: right">14.5×</td> <td style="text-align: right">145,000</td> </tr> <tr> <td>MaleCNS v1.0, 1,684 T4a neurons @ 150 Hz (0.5 M spikes/s, 0.9 G deliveries/s)</td> <td style="text-align: right">166,700</td> <td style="text-align: right">6.2×</td> <td style="text-align: right">62,000</td> </tr> </tbody> </table> <h3 id="python-javascript-c">Python, JavaScript, C#</h3> <p>I couldn’t resist checking how does that compare with what the others run? Three references are easy to pin down. The Python one is Brian2 - the general-purpose spiking simulator the published model runs on. The JavaScript and WebGPU ones are both Xenova’s browser demo: its CPU core, <code class="language-plaintext highlighter-rouge">BrainCPU</code> in <a href="https://huggingface.co/spaces/Xenova/fruit-fly-simulation/blob/main/src/brain.js"><code class="language-plaintext highlighter-rouge">src/brain.js</code></a>, is a hundred lines of clean, plain JS implementing the <em>same</em> model - exact LIF integration, <code class="language-plaintext highlighter-rouge">dt = 0.1 ms</code>, while <code class="language-plaintext highlighter-rouge">BrainGPU</code> runs the same thing as a WGSL compute shader . I ran the JavaScript core in Node 24, and the Xenova’s browser demo in headless Chrome, on the same MaleCNS graph (its own data files, 25.6 M edges) with the same stimulus as <code class="language-plaintext highlighter-rouge">dotfly bench</code>, back-to-back on the same laptop. Spike counts agree to within the RNG (534-539 k for the T4a scenario in every implementation), so it is the same work:</p> <table> <thead> <tr> <th>Implementation</th> <th>Scenario</th> <th style="text-align: right">Steps/s</th> <th style="text-align: right">Real-time factor</th> </tr> </thead> <tbody> <tr> <td>Shiu et al. paper, Brian2 (as reported, per CPU thread)</td> <td>v630, sugar GRNs @ 150 Hz</td> <td style="text-align: right">~33</td> <td style="text-align: right">0.003×</td> </tr> <tr> <td>Brian2 2.9, numpy target, Python (this laptop)</td> <td>v630, sugar GRNs @ 150 Hz</td> <td style="text-align: right">~400</td> <td style="text-align: right">0.04×</td> </tr> <tr> <td>Xenova <code class="language-plaintext highlighter-rouge">BrainCPU</code>, JavaScript in a Chrome worker, 1 thread</td> <td>MaleCNS, 1,684 T4a @ 150 Hz</td> <td style="text-align: right">628</td> <td style="text-align: right">0.06×</td> </tr> <tr> <td>Xenova <code class="language-plaintext highlighter-rouge">BrainCPU</code>, JavaScript in Node 24, 1 thread</td> <td>MaleCNS, 1,684 T4a @ 150 Hz</td> <td style="text-align: right">851</td> <td style="text-align: right">0.09×</td> </tr> <tr> <td>Xenova <code class="language-plaintext highlighter-rouge">BrainGPU</code>, WebGPU on the integrated Radeon (Vega 8)</td> <td>MaleCNS, 1,684 T4a @ 150 Hz</td> <td style="text-align: right">2,409</td> <td style="text-align: right">0.24×</td> </tr> <tr> <td>Xenova <code class="language-plaintext highlighter-rouge">BrainGPU</code>, WebGPU on the RTX 3050 Ti Laptop GPU</td> <td>MaleCNS, 1,684 T4a @ 150 Hz</td> <td style="text-align: right">5,383</td> <td style="text-align: right">0.54×</td> </tr> <tr> <td>dotFly, C#, 1 thread</td> <td>MaleCNS, 1,684 T4a @ 150 Hz</td> <td style="text-align: right">10,740</td> <td style="text-align: right">1.07×</td> </tr> <tr> <td>dotFly, C#, 8 threads</td> <td>MaleCNS, 1,684 T4a @ 150 Hz</td> <td style="text-align: right">43,656</td> <td style="text-align: right">4.37×</td> </tr> <tr> <td>dotFly, C#, 8 threads</td> <td>v630, sugar GRNs @ 150 Hz</td> <td style="text-align: right">196,000</td> <td style="text-align: right">19.6×</td> </tr> </tbody> </table> <p>Thread for thread, C# is about <strong>11× faster</strong> than the JS reference on the same work - not because JavaScript is slow at this (V8 does a fine job on typed arrays), but because of what a JIT cannot give you for free: eight neurons per instruction in <code class="language-plaintext highlighter-rouge">Vector256</code>, a state layout that fits in L2, no bounds checks on the scatter, and a delivery loop that touches only its own cache-resident slice of <code class="language-plaintext highlighter-rouge">g</code>.</p> <p>What this table does <em>not</em> include: the demos stepping at <code class="language-plaintext highlighter-rouge">dt = 20 ms</code> (Fly64, fly-games). With 200× fewer steps they are cheap by construction, at the cost of no longer being the Shiu model.</p> <h2 id="limitations---and-where-this-goes-next">Limitations - and where this goes next</h2> <p>Now the honest part. Two big limits of the model itself, which perfectly shows the trade offs between using connectome directcly versus filling the gaps in input/output decoders.</p> <p><strong>The visual boundary is in the wrong place</strong> - today C# computes what the “optic lobe” should compute: the retina encoder turns image shift and expansion directly into <code class="language-plaintext highlighter-rouge">T4</code> and <code class="language-plaintext highlighter-rouge">LC4</code> signals - it skips the first four boxes of the looming diagram shown earlier. A fly’s eye is made of about 800 tiny visual units and a whole optic lobe (a large share of all the neurons in the brain!) whose job is precisely to compute motion and looming from photoreceptor signals. The next milestone is to feed light straight into the photoreceptors and see whether the wiring in between computes motion and looming on its own - if it does, the encoder shrinks to “brightness per eye” and the connectome does the rest; if it doesn’t, the fallback is a hybrid with a <a href="https://github.com/TuragaLab/flyvis">Flyvis</a>-style trained optic lobe.</p> <p><strong>The fly’s own navigation circuit is silent</strong> - this is the big one. In the demo, the decision of where to go - surge upwind, tumble, cast, land - is made by my decoder. A real fly makes it in its brain, and that circuit is in the wiring: the neurons that keep track of the fly’s heading, combine it with wind and odor, and drive the steering neuron DNa02 left or right. With wind and odor inputs on, none of it fires. We get a fly that smells and feels the wind, but does not steer. The reason is that this circuit is recurrent - it feeds back on itself. Its activity stays alive only because active neurons keep exciting their neighbours while inhibiting the rest. Current Shiu model is not able to represent this and we need to support more complex “recurrent” neural model.</p> <h2 id="closing">Closing</h2> <p>Two things are true at once, again. The platform: .NET can run a 166,700-neuron spiking network with 25 M synapses in real time on a laptop, bit-identical across thread counts, matching the scientific reference - <code class="language-plaintext highlighter-rouge">Vector256</code>, <code class="language-plaintext highlighter-rouge">NativeMemory</code>, memory-mapped files and a hand-rolled thread pool are all you need (🔥). And the science: the connectome is a spectacular measurement, the LIF model is a spectacular <em>starting point</em>, and everything that makes a fly “play a game” today is an adapter that should be shown on screen and switched off in a control run.</p> <p>dotFly is <a href="https://github.com/kkokosa/dotFly/blob/main/LICENSE">GPLv3</a> - the data (MaleCNS, FlyWire) is CC BY 4.0 and downloaded separately. If you are a .NET developer who wants to poke a real brain from C#, or a neuroscientist who wants an inner loop three orders of magnitude faster than the reference, give it a try. Stimulate something. Silence something. Tell me what fires.</p> <ul> <li><strong>GitHub</strong>: <a href="https://github.com/kkokosa/dotFly">github.com/kkokosa/dotFly</a></li> <li><strong>Docs</strong>: <a href="https://kkokosa.github.io/dotFly/">kkokosa.github.io/dotFly</a> - guides, the room demo, the measured-vs-engineered inventory, findings, CLI and API reference</li> <li><strong>References</strong>: <a href="https://doi.org/10.1016/j.cell.2026.08.015">Berg et al. 2026 (MaleCNS)</a>, <a href="https://doi.org/10.1038/s41586-024-07763-9">Shiu et al. 2024</a>, <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC11446842/">Dorkenwald et al. 2024 (FlyWire)</a>, <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC11525180/">Lappalainen et al. 2024 (Flyvis)</a></li> </ul>]]></content><author><name></name></author><category term="ai"/><category term="neuroscience"/><category term="architecture"/><category term="performance"/><summary type="html"><![CDATA[Running the complete fruit fly connectome (166,700 neurons, 25 M synapses) as a spiking network in pure .NET, inside a game loop - and being honest about what that is and what it is not.]]></summary></entry><entry><title type="html">ZeroGC: an allocate-only garbage collector for .NET</title><link href="https://kokosa.dev/blog/2026/zero-gc/" rel="alternate" type="text/html" title="ZeroGC: an allocate-only garbage collector for .NET"/><published>2026-08-05T10:00:00+02:00</published><updated>2026-08-05T10:00:00+02:00</updated><id>https://kokosa.dev/blog/2026/zero-gc</id><content type="html" xml:base="https://kokosa.dev/blog/2026/zero-gc/"><![CDATA[<blockquote> <p><strong>A word on <code class="language-plaintext highlighter-rouge">runtimelab</code> first.</strong> ZeroGC lives on a feature branch of <a href="https://github.com/dotnet/runtimelab"><code class="language-plaintext highlighter-rouge">dotnet/runtimelab</code></a>, the .NET team’s playground <em>“for experimentation and exploring new ideas that may or may not make it into the main <code class="language-plaintext highlighter-rouge">dotnet/runtime</code> repo”</em>. Everything there - including this project - should be treated as unsupported by Microsoft. Nothing in this post implies any kind of production-grade support, and it is not a direction of .NET development. Even if I am part of .NET team, it’s a personal experiment. Treat it as one.</p> </blockquote> <p>With that said: if you ever want to write a new garbage collector, you have to start from <em>something</em>. And the smallest possible something is a GC that only allocates and never reclaims memory. Meet <a href="https://github.com/dotnet/runtimelab/tree/feature/ZeroGC"><strong>ZeroGC</strong></a>.</p> <h2 id="the-simplest-gc-that-can-exist">The simplest GC that can exist</h2> <p>ZeroGC is a standalone CoreCLR GC, loaded via <code class="language-plaintext highlighter-rouge">DOTNET_GCName</code>, that implements the full <code class="language-plaintext highlighter-rouge">IGCHeap</code> / <code class="language-plaintext highlighter-rouge">IGCHandleManager</code> ABI - but whose allocator is just a bump pointer over a large reserved arena. It never collects, never compacts, never promotes, never frees. Every object you allocate stays resident for the entire life of the process. It’s the .NET cousin of Java’s <a href="https://openjdk.org/jeps/318">Epsilon GC</a> - a “no-op” collector.</p> <p>Because it does <em>nothing</em>, it’s the perfect <strong>baseline</strong>: it introduces (almost) zero overhead. There are no pauses to measure, no background threads, no write-barrier bookkeeping to pay for.</p> <blockquote> <p>The “almost” is a fun sidenote. Even a do-nothing GC has to be <em>fast at doing nothing</em>. For example, ZeroGC configures the ephemeral range as empty (<code class="language-plaintext highlighter-rouge">ephemeral_low == ephemeral_high</code>) so the JIT-emitted write barriers become near-free, and it hands out per-thread arena slices with a single interlocked add so allocation stays lock-free under many threads. Skipping work correctly still takes engineering.</p> </blockquote> <p>Of course, there’s this “little” caveat:</p> <blockquote> <p>Memory is never reclaimed. Long-running or allocation-heavy processes will grow endlessly until the process is killed or the machine runs out of memory. <strong>This is a feature of this GC, not a bug.</strong></p> </blockquote> <p>Whether that’s a dealbreaker or a non-issue depends entirely on your app - and that’s precisely what makes it interesting to measure.</p> <h2 id="a-return-of-zeroupsilon-gc">A return of Zero/Upsilon GC</h2> <p>This isn’t a new idea for me. Years ago I built <a href="https://github.com/kkokosa/UpsilonGC"><strong>UpsilonGC</strong></a>, exploring the loosened GC ↔ Execution Engine coupling that .NET Core 2.0 introduced - a “Zero GC” that only allocates, and an “Upsilon GC” that actually reclaims. That work produced a couple of blog posts and a talk:</p> <ul> <li><a href="http://tooslowexception.com/zero-garbage-collector-for-net-core/">Zero Garbage Collector for .NET Core</a></li> <li><a href="http://tooslowexception.com/zero-garbage-collector-for-net-core-2-1-and-asp-net-core-2-1/">Zero Garbage Collector for .NET Core 2.1 and ASP.NET Core 2.1</a></li> <li><a href="https://www.youtube.com/watch?v=om8YFyTO5ik"><em>Make your custom .NET GC - “whys” and “hows”</em></a> (DotNext Moscow 2018)</li> </ul> <p>All of that is now comfortably outdated - the GC-EE interface has moved on - so ZeroGC is the modern rewrite against today’s CoreCLR.</p> <h2 id="why-do-this-at-all">Why do this at all?</h2> <p>Three reasons:</p> <ol> <li><strong>A code baseline</strong> - if you want to write a serious collector, you need somewhere to start, and “empty project” is a miserable place to start from. ZeroGC is a working codebase to grow from: the full <code class="language-plaintext highlighter-rouge">IGCHeap</code> / <code class="language-plaintext highlighter-rouge">IGCHandleManager</code> surface is already implemented and already loads into a real runtime, so the very first thing you do is <em>modify</em> a GC that works rather than debug one that doesn’t yet. It doubles as living documentation of the GC-EE interface - every method you have to implement, with the minimum viable body already written.</li> <li><strong>A performance baseline</strong> - this is what a workload looks like with GC overhead at (nearly) zero: no pauses, no background threads, no bookkeeping. That gives you a number to compare a real collector against. But read that number carefully. <strong>“GC overhead removed” is not the same as “runtime made as fast as possible”</strong>, and it would be wrong to conclude that any app with a GC plugged in will be always slower. A real collector does work that <em>earns its keep</em>: <ul> <li>compaction and moving objects around opens possibility to get better cache and TLB behaviour</li> <li>reusing memory keeps the hot working set small enough to actually stay in cache</li> <li>generational collection means fresh allocations land in memory you just touched</li> </ul> </li> <li><strong>For the curious (and slightly crazy)</strong> - you can drop it into your own app and watch what happens. It’s the cleanest way to answer “<em>how much of my app’s profile is actually GC-bound?</em>”</li> </ol> <h2 id="what-running-it-actually-looks-like">What running it actually looks like</h2> <p>The repo ships a <a href="https://github.com/dotnet/runtimelab/blob/feature/ZeroGC/docs/zerogc/report.html">benchmark report</a> comparing ZeroGC against Workstation and Server GC across eleven workloads, using <code class="language-plaintext highlighter-rouge">dotnet-counters</code> to capture GC pause time, memory and throughput second by second over ten-minute runs. The harness and its sample apps live in <a href="https://github.com/dotnet/runtimelab/tree/feature/ZeroGC/src/ZeroGC"><code class="language-plaintext highlighter-rouge">src/ZeroGC/</code></a>.</p> <p>Two charts tell you almost everything about a workload: <strong>how long the collector stopped you</strong>, and <strong>how much memory you were holding</strong> while it did. Here are three workloads that land in three completely different places.</p> <style>.zgc-fig,.zgc-cap{--zgc-surface:#fff;--zgc-ink:#0b0b0b;--zgc-ink-2:#52514e;--zgc-muted:#898781;--zgc-grid:#e1e0d9;--zgc-axis:#c3c2b7;--zgc-a:#2a78d6;--zgc-b:#eb6834;--zgc-c:#1baf7a}html[data-theme="dark"] .zgc-fig,html[data-theme="dark"] .zgc-cap{--zgc-surface:#1c1c1d;--zgc-ink:#fff;--zgc-ink-2:#c3c2b7;--zgc-muted:#898781;--zgc-grid:#2c2c2a;--zgc-axis:#383835;--zgc-a:#3987e5;--zgc-b:#d95926;--zgc-c:#199e70}.zgc-fig{margin:1.8rem 0 0;overflow-x:auto}.zgc-chart{display:block;width:100%;min-width:660px;height:auto;font-family:system-ui,-apple-system,"Segoe UI",sans-serif}.zgc-head{fill:var(--zgc-ink);font-size:13px;font-weight:600}.zgc-tick{fill:var(--zgc-muted);font-size:10.5px;text-anchor:middle;font-variant-numeric:tabular-nums}.zgc-tick-y{text-anchor:end}.zgc-grid{stroke:var(--zgc-grid);stroke-width:1}.zgc-axis{stroke:var(--zgc-axis);stroke-width:1}.zgc-line-a,.zgc-line-b,.zgc-line-c{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}.zgc-line-a{stroke:var(--zgc-a)}.zgc-line-b{stroke:var(--zgc-b)}.zgc-line-c{stroke:var(--zgc-c)}.zgc-note{fill:var(--zgc-ink-2);font-size:11px}.zgc-legend{display:flex;flex-wrap:wrap;gap:1rem;margin:0 0 .5rem;font-size:.8rem;color:var(--zgc-ink-2)}.zgc-key{display:inline-flex;align-items:center;gap:.4rem}.zgc-key i{width:14px;height:2px;border-radius:1px;display:inline-block}.zgc-cap{font-size:.8rem;line-height:1.5;color:var(--zgc-muted);margin:.6rem 0 2.4rem}.zgc-sub2{fill:var(--zgc-muted);font-size:11px}.zgc-d-a,.zgc-d-b,.zgc-d-c{stroke:var(--zgc-surface);stroke-width:2}.zgc-d-a{fill:var(--zgc-a)}.zgc-d-b{fill:var(--zgc-b)}.zgc-d-c{fill:var(--zgc-c)}</style> <h3 id="the-gc-was-never-in-your-way">The GC was never in your way</h3> <p>An ASP.NET Core minimal API, driven by eight concurrent workers for ten minutes.</p> <div class="zgc-fig"> <div class="zgc-legend"><span class="zgc-key"><i style="background:var(--zgc-a)"></i>Workstation GC</span><span class="zgc-key"><i style="background:var(--zgc-b)"></i>Server GC</span><span class="zgc-key"><i style="background:var(--zgc-c)"></i>ZeroGC</span></div> <svg class="zgc-chart" viewBox="0 0 780 400" role="img" width="780" aria-label="ASP.NET Core (Kestrel) minimal API: GC pause time in milliseconds per second over the run, and working set in megabytes over the run, for Workstation GC, Server GC and ZeroGC."> <title>ASP.NET Core (Kestrel) minimal API</title> <text class="zgc-head" x="0" y="16">GC pause time</text> <text class="zgc-sub2" x="106" y="16">ms per second</text> <line class="zgc-grid" x1="66" y1="162.0" x2="754" y2="162.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="166.0">0</text> <line class="zgc-grid" x1="66" y1="96.0" x2="754" y2="96.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="100.0">2.5</text> <line class="zgc-grid" x1="66" y1="30.0" x2="754" y2="30.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="34.0">5</text> <line class="zgc-axis" x1="66" y1="162.0" x2="754" y2="162.0"/> <text class="zgc-head" x="0" y="200">Working set</text> <text class="zgc-sub2" x="91" y="200">MB</text> <line class="zgc-grid" x1="66" y1="346.0" x2="754" y2="346.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="350.0">0</text> <line class="zgc-grid" x1="66" y1="302.0" x2="754" y2="302.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="306.0">1,000</text> <line class="zgc-grid" x1="66" y1="258.0" x2="754" y2="258.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="262.0">2,000</text> <line class="zgc-grid" x1="66" y1="214.0" x2="754" y2="214.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="218.0">3,000</text> <line class="zgc-axis" x1="66" y1="346.0" x2="754" y2="346.0"/> <text class="zgc-tick" x="66.0" y="370">0</text> <text class="zgc-tick" x="203.6" y="370">120 s</text> <text class="zgc-tick" x="341.2" y="370">240 s</text> <text class="zgc-tick" x="478.8" y="370">360 s</text> <text class="zgc-tick" x="616.4" y="370">480 s</text> <text class="zgc-tick" x="754.0" y="370">600 s</text> <polyline class="zgc-line-a" points="67.1,162.0 68.3,162.0 69.4,83.3 70.6,162.0 72.9,162.0 74.0,131.1 75.2,162.0 76.3,162.0 77.5,142.8 78.6,162.0 80.9,162.0 82.1,144.8 83.2,162.0 85.5,162.0 86.6,147.5 87.8,162.0 88.9,162.0 90.1,143.6 91.2,162.0 93.5,162.0 94.7,143.8 95.8,162.0 97.0,162.0 98.1,140.4 99.3,162.0 101.5,162.0 102.7,140.6 103.8,162.0 106.1,162.0 107.3,127.2 108.4,162.0 109.6,162.0 110.7,141.1 111.9,162.0 114.2,162.0 115.3,142.6 116.5,162.0 117.6,162.0 118.7,39.9 119.9,162.0 122.2,162.0 123.3,135.0 124.5,162.0 126.8,162.0 127.9,143.6 129.1,162.0 130.2,162.0 131.4,141.3 132.5,162.0 134.8,162.0 135.9,139.6 137.1,162.0 138.2,162.0 139.4,142.7 140.5,162.0 142.8,162.0 144.0,141.2 145.1,162.0 147.4,162.0 148.6,145.6 149.7,162.0 150.9,162.0 152.0,144.0 153.1,162.0 155.4,162.0 156.6,140.7 157.7,162.0 158.9,162.0 160.0,144.1 161.2,162.0 163.5,162.0 164.6,143.2 165.8,162.0 168.1,162.0 169.2,140.9 170.3,162.0 171.5,162.0 172.6,140.7 173.8,162.0 176.1,162.0 177.2,148.4 178.4,162.0 179.5,162.0 180.7,145.8 181.8,162.0 184.1,162.0 185.3,148.7 186.4,162.0 188.7,162.0 189.8,144.3 191.0,162.0 192.1,162.0 193.3,140.7 194.4,162.0 196.7,162.0 197.9,140.1 199.0,162.0 200.2,162.0 201.3,146.7 202.5,162.0 204.7,162.0 205.9,141.1 207.0,162.0 209.3,162.0 210.5,148.9 211.6,162.0 212.8,162.0 213.9,140.4 215.1,162.0 217.4,162.0 218.5,141.8 219.7,162.0 220.8,162.0 221.9,144.2 223.1,162.0 225.4,162.0 226.5,142.5 227.7,162.0 230.0,162.0 231.1,138.8 232.3,162.0 233.4,162.0 234.6,138.7 235.7,162.0 238.0,162.0 239.1,140.0 240.3,162.0 241.4,162.0 242.6,144.0 243.7,162.0 246.0,162.0 247.2,142.1 248.3,162.0 250.6,162.0 251.8,141.0 252.9,162.0 254.1,162.0 255.2,139.2 256.3,162.0 258.6,162.0 259.8,142.3 260.9,162.0 262.1,162.0 263.2,143.1 264.4,162.0 266.7,162.0 267.8,142.1 269.0,162.0 271.3,162.0 272.4,144.0 273.5,162.0 274.7,162.0 275.8,132.9 277.0,162.0 279.3,162.0 280.4,140.9 281.6,162.0 282.7,162.0 283.9,142.0 285.0,162.0 287.3,162.0 288.5,144.5 289.6,162.0 291.9,162.0 293.0,141.5 294.2,162.0 295.3,162.0 296.5,141.2 297.6,162.0 299.9,162.0 301.1,140.5 302.2,162.0 303.4,162.0 304.5,138.8 305.7,162.0 307.9,162.0 309.1,140.4 310.2,162.0 312.5,162.0 313.7,140.5 314.8,162.0 316.0,162.0 317.1,137.8 318.3,162.0 320.6,162.0 321.7,142.5 322.9,162.0 324.0,162.0 325.1,142.9 326.3,162.0 328.6,162.0 329.7,146.3 330.9,162.0 333.2,162.0 334.3,143.3 335.5,162.0 336.6,162.0 337.8,137.8 338.9,162.0 341.2,162.0 342.3,146.2 343.5,162.0 344.6,162.0 345.8,144.0 346.9,162.0 349.2,162.0 350.4,143.6 351.5,162.0 353.8,162.0 355.0,140.8 356.1,162.0 357.3,162.0 358.4,143.5 359.5,162.0 361.8,162.0 363.0,138.8 364.1,162.0 365.3,162.0 366.4,137.3 367.6,162.0 369.9,162.0 371.0,141.1 372.2,162.0 374.5,162.0 375.6,140.4 376.7,162.0 377.9,162.0 379.0,133.0 380.2,162.0 382.5,162.0 383.6,146.5 384.8,162.0 385.9,162.0 387.1,139.4 388.2,162.0 390.5,162.0 391.7,142.8 392.8,162.0 395.1,162.0 396.2,136.7 397.4,162.0 398.5,162.0 399.7,147.7 400.8,162.0 403.1,162.0 404.3,141.8 405.4,162.0 406.6,162.0 407.7,143.9 408.9,162.0 411.1,162.0 412.3,125.8 413.4,162.0 415.7,162.0 416.9,141.0 418.0,162.0 419.2,162.0 420.3,144.7 421.5,162.0 423.8,162.0 424.9,141.8 426.1,162.0 427.2,162.0 428.3,140.3 429.5,162.0 431.8,162.0 432.9,140.1 434.1,162.0 436.4,162.0 437.5,141.7 438.7,162.0 439.8,162.0 441.0,146.2 442.1,162.0 444.4,162.0 445.5,142.8 446.7,162.0 447.8,162.0 449.0,142.7 450.1,162.0 452.4,162.0 453.6,144.6 454.7,162.0 457.0,162.0 458.2,142.5 459.3,162.0 460.5,162.0 461.6,142.4 462.7,162.0 465.0,162.0 466.2,139.2 467.3,162.0 468.5,162.0 469.6,141.7 470.8,162.0 473.1,162.0 474.2,141.1 475.4,162.0 477.7,162.0 478.8,141.1 479.9,162.0 481.1,162.0 482.2,135.9 483.4,162.0 485.7,162.0 486.8,143.1 488.0,162.0 489.1,162.0 490.3,142.5 491.4,162.0 493.7,162.0 494.9,139.6 496.0,162.0 497.1,162.0 498.3,143.3 499.4,162.0 501.7,162.0 502.9,140.9 504.0,162.0 506.3,162.0 507.5,141.1 508.6,162.0 509.8,162.0 510.9,137.2 512.1,162.0 514.3,162.0 515.5,140.7 516.6,162.0 517.8,162.0 518.9,141.9 520.1,162.0 522.4,162.0 523.5,140.4 524.7,162.0 527.0,162.0 528.1,139.5 529.3,162.0 530.4,162.0 531.5,145.0 532.7,162.0 535.0,162.0 536.1,142.4 537.3,162.0 538.4,162.0 539.6,53.2 540.7,162.0 543.0,162.0 544.2,134.1 545.3,162.0 547.6,162.0 548.7,143.4 549.9,162.0 551.0,162.0 552.2,141.9 553.3,162.0 555.6,162.0 556.8,146.6 557.9,162.0 559.1,162.0 560.2,143.8 561.4,162.0 563.7,162.0 564.8,144.7 565.9,162.0 568.2,162.0 569.4,139.7 570.5,162.0 571.7,162.0 572.8,143.8 574.0,162.0 576.3,162.0 577.4,144.9 578.6,162.0 579.7,162.0 580.9,149.3 582.0,162.0 584.3,162.0 585.4,148.7 586.6,162.0 588.9,162.0 590.0,145.8 591.2,162.0 592.3,162.0 593.5,142.8 594.6,162.0 596.9,162.0 598.1,141.4 599.2,162.0 601.5,162.0 602.6,141.6 603.8,162.0 604.9,162.0 606.1,145.6 607.2,162.0 609.5,162.0 610.7,141.6 611.8,162.0 613.0,162.0 614.1,141.4 615.3,162.0 617.5,162.0 618.7,138.2 619.8,162.0 621.0,162.0 622.1,140.4 623.3,162.0 625.6,162.0 626.7,142.6 627.9,162.0 630.2,162.0 631.3,142.3 632.5,162.0 633.6,162.0 634.7,144.2 635.9,162.0 638.2,162.0 639.3,142.9 640.5,162.0 641.6,162.0 642.8,140.1 643.9,162.0 646.2,162.0 647.4,143.7 648.5,162.0 649.7,162.0 650.8,140.7 651.9,162.0 654.2,162.0 655.4,144.9 656.5,162.0 658.8,162.0 660.0,141.2 661.1,162.0 662.3,162.0 663.4,139.4 664.6,162.0 666.9,162.0 668.0,145.5 669.1,162.0 670.3,162.0 671.4,144.2 672.6,162.0 674.9,162.0 676.0,142.4 677.2,162.0 679.5,162.0 680.6,138.9 681.8,162.0 682.9,162.0 684.1,144.2 685.2,162.0 687.5,162.0 688.6,145.0 689.8,162.0 690.9,162.0 692.1,143.0 693.2,162.0 695.5,162.0 696.7,141.9 697.8,162.0 700.1,162.0 701.3,139.9 702.4,162.0 703.5,162.0 704.7,141.4 705.8,162.0 708.1,162.0 709.3,134.6 710.4,162.0 711.6,162.0 712.7,144.2 713.9,162.0 716.2,162.0 717.3,144.6 718.5,162.0 720.7,162.0 721.9,139.1 723.0,162.0 724.2,162.0 725.3,143.8 726.5,162.0 728.8,162.0 729.9,143.1 731.1,162.0 732.2,162.0 733.4,130.5 734.5,162.0 736.8,162.0 737.9,146.6 739.1,162.0 741.4,162.0 742.5,143.3 743.7,162.0 744.8,162.0 746.0,147.2 747.1,162.0 749.4,162.0"><title>Workstation GC</title></polyline> <polyline class="zgc-line-b" points="67.1,162.0 68.3,135.5 69.4,135.7 70.6,143.3 71.7,131.2 72.9,162.0 74.0,142.7 75.2,138.5 77.5,136.2 78.6,162.0 79.8,142.9 80.9,139.1 82.1,137.9 83.2,140.5 84.3,162.0 85.5,133.2 86.6,139.1 87.8,131.4 88.9,135.8 90.1,162.0 91.2,135.4 92.4,136.1 94.7,141.5 95.8,162.0 97.0,134.0 98.1,137.2 99.3,136.8 100.4,132.5 101.5,162.0 102.7,135.2 103.8,137.7 105.0,135.5 106.1,140.9 107.3,162.0 108.4,142.9 110.7,137.0 111.9,136.3 113.0,162.0 114.2,135.5 115.3,136.2 116.5,142.0 117.6,134.9 118.7,138.0 119.9,162.0 121.0,135.3 122.2,134.0 124.5,134.5 125.6,162.0 126.8,130.5 127.9,136.8 129.1,132.7 130.2,136.7 131.4,138.5 132.5,162.0 133.7,137.6 135.9,136.8 137.1,135.5 138.2,162.0 139.4,138.1 141.7,136.8 142.8,140.8 144.0,162.0 145.1,138.2 146.3,135.4 147.4,125.1 148.6,134.9 149.7,162.0 150.9,136.2 152.0,136.4 153.1,134.8 154.3,142.2 155.4,162.0 156.6,138.8 157.7,135.4 158.9,135.0 160.0,138.8 161.2,162.0 162.3,139.6 163.5,134.6 164.6,133.3 165.8,135.3 166.9,162.0 168.1,137.8 169.2,140.7 171.5,133.9 172.6,162.0 173.8,134.2 174.9,128.4 176.1,141.6 177.2,137.6 178.4,136.6 179.5,162.0 180.7,133.3 181.8,138.5 183.0,132.9 184.1,141.8 185.3,162.0 186.4,139.2 187.5,140.5 188.7,135.1 189.8,136.8 191.0,162.0 192.1,136.9 193.3,141.0 194.4,135.0 195.6,141.2 196.7,162.0 197.9,134.6 199.0,138.9 200.2,137.7 201.3,132.8 202.5,162.0 203.6,132.9 204.7,138.5 205.9,136.6 207.0,136.7 208.2,162.0 209.3,137.1 210.5,139.5 211.6,129.1 212.8,139.1 213.9,162.0 215.1,130.7 216.2,138.7 219.7,133.2 220.8,162.0 221.9,138.1 223.1,133.0 224.2,136.5 225.4,135.2 226.5,162.0 227.7,137.4 228.8,137.7 230.0,141.3 231.1,135.2 232.3,162.0 233.4,135.9 234.6,134.4 236.9,139.1 238.0,162.0 239.1,135.1 240.3,139.5 241.4,135.5 242.6,135.0 243.7,162.0 244.9,137.3 246.0,133.7 247.2,136.8 248.3,137.6 249.5,162.0 250.6,132.4 251.8,135.9 252.9,134.3 254.1,137.2 255.2,162.0 256.3,133.6 257.5,139.0 258.6,136.0 259.8,134.8 260.9,162.0 262.1,135.8 263.2,138.5 264.4,136.0 265.5,141.5 266.7,162.0 267.8,133.7 269.0,137.6 270.1,134.6 271.3,135.2 272.4,162.0 273.5,127.3 274.7,133.4 275.8,135.8 277.0,136.4 278.1,162.0 279.3,135.1 280.4,139.7 281.6,132.8 282.7,137.1 283.9,138.7 285.0,162.0 286.2,135.9 287.3,135.1 288.5,141.8 289.6,132.9 290.7,162.0 291.9,135.8 293.0,135.8 294.2,134.3 295.3,136.2 296.5,162.0 297.6,136.3 298.8,135.8 299.9,131.7 301.1,137.1 302.2,162.0 303.4,138.3 305.7,130.1 306.8,135.1 307.9,162.0 309.1,138.4 310.2,133.6 312.5,134.1 313.7,162.0 314.8,140.4 316.0,134.6 317.1,138.8 318.3,130.0 319.4,138.6 320.6,162.0 322.9,124.0 324.0,136.5 325.1,134.9 326.3,162.0 327.4,132.4 329.7,137.3 330.9,133.4 332.0,162.0 333.2,140.8 334.3,131.6 335.5,134.8 336.6,136.2 337.8,162.0 338.9,136.6 340.1,140.7 341.2,135.9 342.3,136.5 343.5,162.0 344.6,132.2 345.8,136.6 346.9,137.7 348.1,136.2 349.2,162.0 350.4,137.3 351.5,140.1 353.8,134.1 355.0,162.0 356.1,141.2 357.3,134.2 358.4,131.1 359.5,135.2 360.7,137.1 361.8,162.0 363.0,133.4 364.1,133.0 365.3,137.4 366.4,136.2 367.6,162.0 368.7,137.9 369.9,138.1 371.0,131.8 372.2,134.7 373.3,162.0 374.5,132.5 375.6,136.8 376.7,135.2 377.9,137.6 379.0,162.0 380.2,136.4 382.5,134.8 383.6,136.6 384.8,162.0 385.9,132.5 387.1,136.3 388.2,137.4 389.4,130.7 390.5,162.0 391.7,136.2 392.8,133.9 393.9,132.6 395.1,134.5 396.2,162.0 397.4,136.5 398.5,133.2 399.7,133.7 400.8,141.8 402.0,162.0 403.1,135.3 404.3,135.3 405.4,133.9 406.6,136.4 407.7,131.5 408.9,162.0 410.0,140.9 411.1,136.0 412.3,138.0 413.4,138.4 414.6,162.0 415.7,138.0 416.9,137.5 419.2,134.0 420.3,162.0 421.5,128.7 422.6,136.2 423.8,138.6 424.9,133.2 426.1,162.0 427.2,135.3 428.3,137.1 430.6,132.1 431.8,162.0 432.9,135.8 434.1,137.8 435.2,125.1 436.4,134.1 437.5,162.0 438.7,137.2 439.8,137.6 442.1,133.7 443.3,162.0 444.4,137.7 446.7,137.4 447.8,136.0 449.0,162.0 450.1,136.9 451.3,135.9 452.4,137.8 453.6,137.8 454.7,162.0 457.0,126.4 458.2,134.4 459.3,138.4 460.5,134.3 461.6,162.0 462.7,136.3 463.9,133.8 465.0,136.9 466.2,130.0 467.3,162.0 468.5,142.0 471.9,147.8 473.1,122.7 474.2,146.8 475.4,125.7 476.5,117.7 477.7,116.5 478.8,143.4 479.9,125.4 481.1,121.7 482.2,123.5 483.4,130.9 484.5,127.8 485.7,141.6 486.8,122.6 488.0,113.4 489.1,123.2 490.3,119.7 491.4,140.8 492.6,130.3 494.9,122.5 496.0,137.1 497.1,117.1 498.3,55.8 499.4,119.2 501.7,131.2 502.9,122.9 504.0,125.3 505.2,125.7 506.3,138.3 507.5,125.3 508.6,126.7 509.8,126.1 510.9,129.3 512.1,142.5 513.2,122.9 514.3,125.1 516.6,116.8 517.8,140.4 520.1,112.4 521.2,117.7 522.4,117.9 523.5,142.6 525.8,117.1 527.0,123.7 528.1,142.1 529.3,116.0 530.4,118.1 531.5,122.6 532.7,121.9 533.8,140.4 535.0,120.3 537.3,126.3 538.4,122.9 539.6,142.9 540.7,120.6 541.9,121.2 543.0,123.4 544.2,122.7 545.3,137.7 546.5,120.9 547.6,119.1 548.7,119.9 549.9,139.0 552.2,112.1 553.3,120.7 554.5,120.9 555.6,147.6 556.8,122.6 557.9,122.0 559.1,120.1 560.2,115.1 561.4,142.2 562.5,122.7 563.7,119.8 564.8,120.1 565.9,141.6 567.1,113.9 568.2,120.4 569.4,120.6 571.7,129.9 572.8,143.8 574.0,118.1 575.1,122.4 576.3,100.1 577.4,144.7 578.6,113.6 579.7,127.3 580.9,122.8 582.0,141.3 583.1,120.2 584.3,138.5 585.4,122.6 586.6,141.6 587.7,123.6 588.9,141.9 590.0,124.6 591.2,117.9 592.3,139.1 593.5,123.3 594.6,141.6 595.8,117.4 596.9,142.8 598.1,114.9 599.2,142.2 600.3,123.2 601.5,135.7 602.6,118.5 603.8,141.5 604.9,118.1 606.1,141.9 607.2,119.5 608.4,143.2 609.5,114.2 610.7,142.7 611.8,125.1 613.0,119.0 614.1,140.9 615.3,123.0 616.4,137.4 617.5,120.4 618.7,137.3 619.8,123.5 621.0,142.8 622.1,118.7 623.3,139.2 624.4,125.1 625.6,141.4 626.7,120.3 627.9,115.7 629.0,139.2 630.2,131.7 631.3,142.0 632.5,124.9 633.6,144.0 634.7,119.7 637.0,138.7 638.2,121.4 639.3,140.9 640.5,122.2 641.6,143.9 642.8,123.4 643.9,140.9 645.1,124.2 646.2,140.0 647.4,124.5 648.5,142.4 649.7,126.5 650.8,123.6 651.9,140.2 653.1,117.6 654.2,141.6 655.4,119.7 656.5,142.1 657.7,119.5 658.8,142.1 660.0,122.5 661.1,141.2 662.3,113.6 663.4,140.8 665.7,141.1 666.9,140.1 668.0,130.0 669.1,141.1 670.3,137.3 671.4,123.0 673.7,146.6 674.9,139.9 676.0,122.0 677.2,138.2 678.3,142.1 680.6,140.0 681.8,122.2 682.9,142.9 684.1,139.7 685.2,142.5 686.3,119.2 687.5,139.4 688.6,143.6 689.8,146.2 690.9,122.4 692.1,139.2 693.2,126.1 694.4,142.5 695.5,145.1 696.7,117.0 697.8,142.9 700.1,139.3 701.3,116.3 702.4,141.1 703.5,139.8 704.7,142.9 705.8,121.6 707.0,142.2 708.1,140.5 709.3,142.6 710.4,138.2 711.6,124.7 712.7,142.2 713.9,136.8 715.0,142.8 716.2,141.2 717.3,137.1 718.5,122.8 719.6,142.0 720.7,138.4 721.9,137.8 723.0,141.4 724.2,137.6 725.3,137.1 727.6,143.3 728.8,138.3 729.9,144.0 731.1,139.1 732.2,141.9 733.4,129.0 734.5,142.7 735.7,144.4 736.8,138.8 737.9,145.1 739.1,142.0 740.2,145.2 741.4,141.0 743.7,121.1 744.8,147.0 746.0,145.9 747.1,147.0 748.3,139.3 749.4,142.1"><title>Server GC</title></polyline> <polyline class="zgc-line-c" points="67.1,162.0 749.4,162.0"><title>ZeroGC</title></polyline> <polyline class="zgc-line-a" points="66.0,342.3 749.4,342.2"><title>Workstation GC</title></polyline> <polyline class="zgc-line-b" points="66.0,342.6 749.4,342.8"><title>Server GC</title></polyline> <polyline class="zgc-line-c" points="66.0,339.9 749.4,224.7"><title>ZeroGC</title></polyline> <circle class="zgc-d-b" cx="498.3" cy="55.8" r="4"/> <text class="zgc-note" x="488.3" y="41.8" text-anchor="end">worst pause of the whole run: 4.6 ms</text> <circle class="zgc-d-c" cx="462.7" cy="273.1" r="4"/> <text class="zgc-note" x="472.7" y="293.1" text-anchor="start">2.7 GB and still climbing</text> </svg> </div> <div class="zgc-cap">ASP.NET Core (Kestrel) minimal API, 8 concurrent workers, 10 minutes. Drawn from the repo's full 1&nbsp;Hz counter capture (596 samples per run, <code>results/raw/</code>); every peak is at its true height.</div> <p>Look at the top panel: <strong>no pause, under any collector, ever reaches 5 ms</strong> - the worst of the entire ten-minute run is 4.6 ms. Add every single pause together and Workstation GC stopped this app for <strong>0.14 seconds in total</strong>, Server GC for 0.57. Out of six hundred. There is no big latency problem here, so there is not much for ZeroGC to win back - unless you really want sub-millisecond (zero!) pauses. Throughput is identical to three significant figures (507.3 → 507.5 ops/s).</p> <p>Now look at the bottom panel, and notice that the two real collectors <em>are</em> the flat line along the bottom - they hold steady around 85 MB for the whole run, because that is what a collector does. ZeroGC’s straight line is every request the API ever served, still resident. It ends the run at <strong>2.7 GB and rising</strong>, and it would keep rising until the process died. That is 31× the memory in exchange for a latency win of exactly zero.</p> <h3 id="real-collections-but-nobody-notices">Real collections, but nobody notices</h3> <p>GCPerfSim’s cache / large-object-heavy workload - a growing pool of long-lived objects, many of them on the LOH.</p> <div class="zgc-fig"> <div class="zgc-legend"><span class="zgc-key"><i style="background:var(--zgc-a)"></i>Workstation GC</span><span class="zgc-key"><i style="background:var(--zgc-b)"></i>Server GC</span><span class="zgc-key"><i style="background:var(--zgc-c)"></i>ZeroGC</span></div> <svg class="zgc-chart" viewBox="0 0 780 400" role="img" width="780" aria-label="GCPerfSim: cache / large-object-heavy workload: GC pause time in milliseconds per second over the run, and working set in megabytes over the run, for Workstation GC, Server GC and ZeroGC."> <title>GCPerfSim: cache / large-object-heavy workload</title> <text class="zgc-head" x="0" y="16">GC pause time</text> <text class="zgc-sub2" x="106" y="16">ms per second</text> <line class="zgc-grid" x1="66" y1="162.0" x2="754" y2="162.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="166.0">0</text> <line class="zgc-grid" x1="66" y1="109.2" x2="754" y2="109.2"/> <text class="zgc-tick zgc-tick-y" x="56" y="113.2">10</text> <line class="zgc-grid" x1="66" y1="56.4" x2="754" y2="56.4"/> <text class="zgc-tick zgc-tick-y" x="56" y="60.4">20</text> <line class="zgc-axis" x1="66" y1="162.0" x2="754" y2="162.0"/> <text class="zgc-head" x="0" y="200">Working set</text> <text class="zgc-sub2" x="91" y="200">MB</text> <line class="zgc-grid" x1="66" y1="346.0" x2="754" y2="346.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="350.0">0</text> <line class="zgc-grid" x1="66" y1="302.0" x2="754" y2="302.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="306.0">2,000</text> <line class="zgc-grid" x1="66" y1="258.0" x2="754" y2="258.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="262.0">4,000</text> <line class="zgc-grid" x1="66" y1="214.0" x2="754" y2="214.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="218.0">6,000</text> <line class="zgc-axis" x1="66" y1="346.0" x2="754" y2="346.0"/> <text class="zgc-tick" x="66.0" y="370">0</text> <text class="zgc-tick" x="203.6" y="370">120 s</text> <text class="zgc-tick" x="341.2" y="370">240 s</text> <text class="zgc-tick" x="478.8" y="370">360 s</text> <text class="zgc-tick" x="616.4" y="370">480 s</text> <text class="zgc-tick" x="754.0" y="370">600 s</text> <polyline class="zgc-line-a" points="67.1,143.9 69.4,162.0 70.6,155.3 71.7,162.0 72.9,155.2 74.0,162.0 75.2,144.9 76.3,162.0 77.5,153.2 78.6,155.2 79.8,162.0 80.9,154.6 82.1,162.0 83.2,144.3 84.3,162.0 85.5,153.2 86.6,162.0 87.8,154.2 88.9,162.0 90.1,155.2 91.2,154.9 92.4,162.0 93.5,146.4 94.7,162.0 95.8,154.7 97.0,162.0 98.1,155.0 99.3,162.0 101.5,144.5 102.7,162.0 103.8,154.2 105.0,162.0 106.1,155.1 107.3,162.0 108.4,154.9 109.6,162.0 110.7,145.3 113.0,162.0 114.2,154.8 115.3,162.0 116.5,155.0 117.6,162.0 118.7,144.5 119.9,162.0 121.0,154.6 122.2,162.0 123.3,154.6 124.5,154.8 125.6,162.0 126.8,145.4 127.9,162.0 129.1,154.5 130.2,162.0 131.4,155.1 132.5,162.0 134.8,145.1 135.9,162.0 137.1,154.3 138.2,162.0 139.4,154.5 140.5,162.0 141.7,154.2 142.8,162.0 144.0,144.7 145.1,162.0 146.3,152.0 147.4,153.6 148.6,162.0 149.7,154.9 150.9,162.0 152.0,154.3 153.1,162.0 154.3,144.9 155.4,162.0 156.6,152.4 157.7,152.1 158.9,162.0 160.0,153.9 161.2,162.0 162.3,154.2 163.5,162.0 164.6,154.2 165.8,162.0 166.9,139.1 168.1,154.3 169.2,162.0 170.3,153.9 171.5,162.0 172.6,154.3 173.8,162.0 174.9,141.5 177.2,162.0 178.4,153.8 179.5,162.0 180.7,153.7 181.8,162.0 183.0,141.7 184.1,162.0 185.3,154.0 186.4,162.0 187.5,154.3 188.7,153.6 189.8,162.0 191.0,141.6 192.1,162.0 193.3,154.0 194.4,162.0 195.6,154.3 196.7,162.0 197.9,153.7 199.0,162.0 200.2,141.5 202.5,162.0 203.6,154.6 204.7,162.0 205.9,154.0 207.0,162.0 208.2,142.5 209.3,162.0 210.5,154.2 211.6,154.2 212.8,162.0 213.9,154.0 215.1,162.0 216.2,142.7 217.4,162.0 218.5,151.9 219.7,162.0 220.8,152.1 221.9,153.3 223.1,162.0 224.2,154.1 225.4,162.0 226.5,153.5 227.7,162.0 228.8,144.0 230.0,162.0 231.1,153.5 232.3,153.6 233.4,162.0 234.6,153.9 235.7,162.0 236.9,143.3 238.0,162.0 239.1,154.2 240.3,162.0 241.4,154.0 242.6,162.0 244.9,143.2 246.0,162.0 247.2,152.0 248.3,162.0 249.5,151.7 250.6,162.0 251.8,153.5 252.9,162.0 254.1,153.7 255.2,162.0 257.5,143.6 258.6,162.0 259.8,153.6 260.9,162.0 262.1,153.5 263.2,162.0 264.4,153.2 265.5,162.0 266.7,140.7 269.0,162.0 270.1,153.6 271.3,162.0 272.4,152.9 273.5,162.0 274.7,143.0 275.8,162.0 277.0,153.5 278.1,162.0 279.3,153.3 280.4,152.6 281.6,162.0 282.7,143.2 283.9,162.0 285.0,153.6 286.2,162.0 287.3,150.3 288.5,162.0 289.6,150.6 290.7,162.0 291.9,143.6 294.2,162.0 295.3,153.4 296.5,162.0 297.6,153.2 298.8,162.0 299.9,143.5 301.1,162.0 302.2,153.7 303.4,153.3 304.5,162.0 305.7,152.9 306.8,162.0 307.9,142.2 309.1,162.0 310.2,153.7 311.4,162.0 312.5,153.5 313.7,153.0 314.8,162.0 316.0,142.4 317.1,162.0 318.3,153.7 319.4,162.0 320.6,153.2 321.7,162.0 324.0,141.7 325.1,162.0 326.3,153.0 327.4,162.0 328.6,153.0 329.7,162.0 330.9,152.5 332.0,162.0 333.2,138.6 335.5,162.0 336.6,152.6 337.8,162.0 338.9,152.5 340.1,162.0 341.2,141.8 342.3,162.0 343.5,150.5 344.6,162.0 345.8,152.4 346.9,152.9 348.1,162.0 349.2,152.5 350.4,162.0 351.5,138.7 352.7,162.0 353.8,152.7 355.0,162.0 356.1,153.2 357.3,152.2 358.4,162.0 359.5,141.7 360.7,162.0 361.8,153.3 363.0,162.0 364.1,152.8 365.3,162.0 366.4,152.4 367.6,162.0 368.7,142.0 371.0,162.0 372.2,153.0 373.3,162.0 374.5,152.6 375.6,162.0 376.7,142.2 377.9,162.0 379.0,149.3 380.2,153.0 381.3,162.0 382.5,152.2 383.6,162.0 384.8,140.8 385.9,162.0 387.1,150.9 388.2,162.0 389.4,151.8 390.5,153.0 391.7,162.0 392.8,151.4 393.9,162.0 395.1,140.7 396.2,162.0 397.4,153.0 398.5,162.0 399.7,153.0 400.8,162.0 403.1,139.8 404.3,162.0 405.4,153.4 406.6,162.0 407.7,153.3 408.9,162.0 410.0,152.9 411.1,162.0 412.3,140.3 414.6,162.0 415.7,153.1 416.9,162.0 418.0,152.8 419.2,162.0 420.3,141.1 421.5,162.0 422.6,153.4 423.8,162.0 424.9,153.5 426.1,153.1 427.2,162.0 428.3,141.4 429.5,162.0 430.6,153.5 431.8,162.0 432.9,153.5 434.1,162.0 435.2,153.1 436.4,162.0 437.5,141.1 439.8,162.0 441.0,153.8 442.1,162.0 443.3,152.9 444.4,162.0 445.5,140.3 446.7,162.0 447.8,149.9 449.0,152.9 450.1,162.0 451.3,153.9 452.4,162.0 453.6,153.4 454.7,162.0 455.9,140.3 457.0,162.0 458.2,153.7 459.3,153.8 460.5,162.0 461.6,146.4 462.7,162.0 463.9,136.9 465.0,162.0 466.2,153.6 467.3,162.0 468.5,152.8 469.6,153.6 470.8,162.0 471.9,139.8 473.1,162.0 474.2,150.9 475.4,162.0 476.5,153.0 477.7,162.0 478.8,153.6 479.9,162.0 482.2,138.5 483.4,162.0 484.5,154.0 485.7,162.0 486.8,153.8 488.0,162.0 489.1,153.8 490.3,162.0 491.4,140.9 492.6,162.0 493.7,151.6 494.9,152.5 496.0,162.0 497.1,153.9 498.3,162.0 499.4,153.8 500.6,162.0 501.7,141.8 502.9,162.0 504.0,153.4 505.2,154.2 506.3,162.0 507.5,154.0 508.6,162.0 509.8,141.8 510.9,162.0 512.1,153.8 513.2,162.0 514.3,154.3 515.5,162.0 517.8,141.8 518.9,162.0 520.1,151.9 521.2,162.0 522.4,153.7 523.5,162.0 524.7,154.2 525.8,162.0 528.1,142.4 529.3,162.0 530.4,151.5 531.5,162.0 532.7,153.7 533.8,162.0 535.0,153.9 536.1,162.0 538.4,142.8 539.6,162.0 540.7,149.6 541.9,162.0 543.0,154.1 544.2,162.0 545.3,153.4 546.5,162.0 547.6,141.1 548.7,162.0 549.9,154.1 551.0,152.0 552.2,162.0 553.3,153.7 554.5,162.0 555.6,142.0 556.8,162.0 557.9,153.5 559.1,162.0 560.2,154.1 561.4,153.6 562.5,162.0 563.7,139.8 564.8,162.0 565.9,151.2 567.1,162.0 568.2,152.4 569.4,162.0 570.5,153.8 571.7,162.0 574.0,142.9 575.1,162.0 576.3,151.9 577.4,162.0 578.6,153.3 579.7,162.0 580.9,153.7 582.0,162.0 583.1,153.5 584.3,162.0 585.4,142.2 587.7,162.0 588.9,150.9 590.0,162.0 591.2,151.1 592.3,162.0 593.5,153.5 594.6,162.0 595.8,153.6 596.9,153.2 598.1,162.0 599.2,143.6 600.3,162.0 601.5,153.6 602.6,162.0 603.8,153.6 604.9,162.0 607.2,142.8 608.4,162.0 609.5,153.3 610.7,156.1 611.8,147.4 613.0,162.0 614.1,151.3 615.3,162.0 616.4,152.4 617.5,153.4 618.7,162.0 619.8,153.2 621.0,162.0 622.1,141.2 623.3,162.0 624.4,153.4 625.6,162.0 626.7,153.8 627.9,162.0 630.2,141.0 631.3,162.0 632.5,153.6 633.6,162.0 634.7,153.6 635.9,162.0 637.0,152.9 638.2,162.0 639.3,140.6 641.6,162.0 642.8,153.4 643.9,162.0 645.1,153.2 646.2,162.0 647.4,141.1 648.5,162.0 649.7,153.8 650.8,162.0 651.9,153.5 653.1,153.3 654.2,162.0 655.4,141.6 656.5,162.0 657.7,153.6 658.8,162.0 660.0,154.0 661.1,162.0 663.4,140.3 664.6,162.0 665.7,150.7 666.9,162.0 668.0,148.9 669.1,162.0 670.3,145.7 671.4,162.0 673.7,140.2 674.9,162.0 676.0,153.5 677.2,162.0 678.3,153.7 679.5,162.0 680.6,153.4 681.8,162.0 682.9,137.1 684.1,162.0 685.2,150.1 686.3,152.9 687.5,162.0 688.6,153.4 689.8,162.0 690.9,152.8 692.1,162.0 693.2,141.9 694.4,162.0 695.5,151.6 696.7,162.0 697.8,150.0 699.0,153.3 700.1,162.0 701.3,154.0 702.4,162.0 703.5,153.5 704.7,162.0 705.8,142.4 707.0,162.0 708.1,150.7 709.3,152.7 710.4,162.0 711.6,153.8 712.7,162.0 713.9,153.5 715.0,162.0 716.2,142.9 717.3,162.0 718.5,152.3 719.6,162.0 720.7,154.1 721.9,153.5 723.0,162.0 724.2,143.5 725.3,162.0 726.5,153.7 727.6,162.0 728.8,153.6 729.9,162.0 731.1,153.5 732.2,162.0 733.4,143.2 734.5,153.8 735.7,156.9 736.8,147.4 737.9,162.0 739.1,153.1 740.2,162.0 741.4,141.8 742.5,162.0 743.7,153.2 744.8,153.9 746.0,162.0"><title>Workstation GC</title></polyline> <polyline class="zgc-line-b" points="67.1,162.0 68.3,148.2 69.4,162.0 72.9,162.0 74.0,149.9 75.2,162.0 78.6,162.0 79.8,102.9 80.9,162.0 114.2,162.0 115.3,125.2 116.5,162.0 149.7,162.0 150.9,129.4 152.0,162.0 191.0,162.0 192.1,116.4 193.3,162.0 195.6,162.0 196.7,158.1 197.9,162.0 232.3,162.0 233.4,124.4 234.6,162.0 269.0,162.0 270.1,126.5 271.3,162.0 285.0,162.0 286.2,127.7 287.3,162.0 304.5,162.0 305.7,125.5 306.8,162.0 335.5,162.0 336.6,49.1 337.8,162.0 367.6,162.0 368.7,130.8 369.9,162.0 373.3,162.0 374.5,155.2 375.6,162.0 399.7,162.0 400.8,129.3 402.0,162.0 438.7,162.0 439.8,120.6 441.0,162.0 478.8,162.0 479.9,124.0 481.1,162.0 482.2,154.4 483.4,162.0 517.8,162.0 518.9,122.3 520.1,162.0 552.2,162.0 553.3,126.1 554.5,162.0 587.7,162.0 588.9,123.3 590.0,162.0 622.1,162.0 623.3,74.6 624.4,162.0 645.1,162.0 646.2,130.0 647.4,162.0 679.5,162.0 680.6,126.2 681.8,162.0 719.6,162.0 720.7,115.8 721.9,162.0 751.7,162.0"><title>Server GC</title></polyline> <polyline class="zgc-line-c" points="67.1,162.0 751.7,162.0"><title>ZeroGC</title></polyline> <polyline class="zgc-line-a" points="66.0,337.5 162.3,333.7 259.8,331.3 374.5,326.7 377.9,326.6 379.0,327.2 603.8,322.9 609.5,322.9 611.8,323.5 665.7,322.2 670.3,323.1 733.4,321.9 736.8,323.0 746.0,322.9"><title>Workstation GC</title></polyline> <polyline class="zgc-line-b" points="66.0,337.4 78.6,336.7 79.8,335.8 87.8,335.4 114.2,331.2 144.0,331.3 150.9,330.6 185.3,330.0 192.1,329.1 262.1,328.8 269.0,328.2 282.7,327.9 327.4,327.9 336.6,326.9 465.0,326.9 481.1,326.2 616.4,326.1 623.3,325.2 751.7,325.2"><title>Server GC</title></polyline> <polyline class="zgc-line-c" points="66.0,337.7 751.7,221.3"><title>ZeroGC</title></polyline> <circle class="zgc-d-b" cx="336.6" cy="49.1" r="4"/> <text class="zgc-note" x="346.6" y="35.1" text-anchor="start">the worst pause of the entire run: 21 ms</text> <circle class="zgc-d-c" cx="367.6" cy="286.3" r="4"/> <text class="zgc-note" x="377.6" y="306.3" text-anchor="start">ZeroGC never gives the cache back</text> </svg> </div> <div class="zgc-cap">GCPerfSim cache / large-object-heavy workload, 10 minutes. Drawn from the repo's full 1&nbsp;Hz counter capture (596 samples per run, <code>results/raw/</code>); every peak is at its true height.</div> <p>This one really does collect, and the two collectors disagree about how. Workstation GC produces the dense blue sawtooth - a collection every second or so, none of them costing more than 4.7 ms all run. Server GC is the opposite shape: long quiet stretches punctuated by gen2 spikes, the worst reaching 21 ms.</p> <p>But check the axis - the whole panel is 25 ms tall. Totalled over ten minutes, Workstation GC spent <strong>0.71 s</strong> collecting and Server GC <strong>0.18 s</strong>. Collections everywhere, latency nowhere. Note also that “fewer pauses” and “less time paused” are not the same property: Server GC pauses four times less in total, yet owns every one of the tall spikes.</p> <p>The bottom panel is where the two real collectors earn their keep. Both hold the working set near 1 GB, roughly flat, while ZeroGC climbs steadily to <strong>5.5 GB</strong> - about 6× more. The genuinely-live data is much the same in all three runs; the entire difference is garbage that ZeroGC is contractually unable to release.</p> <h3 id="the-one-where-it-actually-pays-off">The one where it actually pays off</h3> <p>A console app with a growing in-memory cache under a real request workload - the case that naturally escalates into expensive gen2 collections.</p> <div class="zgc-fig"> <div class="zgc-legend"><span class="zgc-key"><i style="background:var(--zgc-a)"></i>Workstation GC</span><span class="zgc-key"><i style="background:var(--zgc-b)"></i>Server GC</span><span class="zgc-key"><i style="background:var(--zgc-c)"></i>ZeroGC</span></div> <svg class="zgc-chart" viewBox="0 0 780 400" role="img" width="780" aria-label="Console: growing in-memory cache with real request workload (naturally escalating gen2 GCs): GC pause time in milliseconds per second over the run, and working set in megabytes over the run, for Workstation GC, Server GC and ZeroGC."> <title>Console: growing in-memory cache with real request workload (naturally escalating gen2 GCs)</title> <text class="zgc-head" x="0" y="16">GC pause time</text> <text class="zgc-sub2" x="106" y="16">ms per second</text> <line class="zgc-grid" x1="66" y1="162.0" x2="754" y2="162.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="166.0">0</text> <line class="zgc-grid" x1="66" y1="96.0" x2="754" y2="96.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="100.0">250</text> <line class="zgc-grid" x1="66" y1="30.0" x2="754" y2="30.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="34.0">500</text> <line class="zgc-axis" x1="66" y1="162.0" x2="754" y2="162.0"/> <text class="zgc-head" x="0" y="200">Working set</text> <text class="zgc-sub2" x="91" y="200">MB</text> <line class="zgc-grid" x1="66" y1="346.0" x2="754" y2="346.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="350.0">0</text> <line class="zgc-grid" x1="66" y1="313.0" x2="754" y2="313.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="317.0">2,000</text> <line class="zgc-grid" x1="66" y1="280.0" x2="754" y2="280.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="284.0">4,000</text> <line class="zgc-grid" x1="66" y1="247.0" x2="754" y2="247.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="251.0">6,000</text> <line class="zgc-grid" x1="66" y1="214.0" x2="754" y2="214.0"/> <text class="zgc-tick zgc-tick-y" x="56" y="218.0">8,000</text> <line class="zgc-axis" x1="66" y1="346.0" x2="754" y2="346.0"/> <text class="zgc-tick" x="66.0" y="370">0</text> <text class="zgc-tick" x="203.6" y="370">120 s</text> <text class="zgc-tick" x="341.2" y="370">240 s</text> <text class="zgc-tick" x="478.8" y="370">360 s</text> <text class="zgc-tick" x="616.4" y="370">480 s</text> <text class="zgc-tick" x="754.0" y="370">600 s</text> <polyline class="zgc-line-a" points="67.1,156.7 68.3,162.0 69.4,156.7 70.6,162.0 71.7,156.3 72.9,162.0 74.0,151.6 76.3,162.0 77.5,156.6 78.6,162.0 79.8,157.2 80.9,162.0 82.1,150.8 83.2,162.0 84.3,158.5 85.5,157.1 86.6,162.0 87.8,152.7 88.9,162.0 90.1,158.8 91.2,162.0 93.5,151.8 94.7,162.0 95.8,158.8 97.0,162.0 98.1,157.2 99.3,162.0 100.4,149.2 101.5,158.5 102.7,162.0 103.8,156.9 105.0,162.0 106.1,157.5 107.3,162.0 108.4,151.9 110.7,162.0 111.9,156.5 113.0,162.0 114.2,157.1 115.3,162.0 116.5,151.9 118.7,162.0 119.9,156.4 121.0,162.0 122.2,152.4 123.3,162.0 125.6,157.0 126.8,162.0 127.9,151.5 129.1,162.0 130.2,158.8 131.4,162.0 132.5,156.8 133.7,157.1 134.8,162.0 135.9,151.8 137.1,162.0 138.2,158.5 139.4,162.0 140.5,154.0 142.8,162.0 144.0,152.2 145.1,162.0 146.3,158.4 147.4,162.0 149.7,151.9 150.9,162.0 152.0,158.5 153.1,162.0 154.3,157.0 155.4,162.0 156.6,152.6 157.7,162.0 160.0,155.2 161.2,162.0 162.3,157.1 163.5,162.0 164.6,150.4 165.8,162.0 168.1,155.5 169.2,162.0 170.3,156.8 171.5,162.0 172.6,156.7 173.8,162.0 174.9,153.1 177.2,162.0 178.4,157.0 179.5,162.0 180.7,151.7 181.8,162.0 183.0,158.4 184.1,157.1 185.3,162.0 186.4,151.8 187.5,162.0 188.7,158.3 189.8,162.0 192.1,152.3 193.3,162.0 194.4,158.6 195.6,162.0 196.7,157.0 197.9,162.0 200.2,152.4 201.3,162.0 202.5,158.6 203.6,162.0 204.7,157.0 205.9,162.0 207.0,152.5 209.3,162.0 210.5,156.9 211.6,162.0 212.8,151.7 213.9,162.0 215.1,157.9 216.2,156.1 217.4,162.0 218.5,151.9 219.7,162.0 220.8,158.4 221.9,162.0 223.1,151.2 225.4,162.0 226.5,152.2 227.7,162.0 228.8,158.7 230.0,162.0 231.1,156.7 232.3,162.0 234.6,151.4 235.7,162.0 236.9,158.4 238.0,162.0 239.1,156.9 240.3,162.0 241.4,152.7 243.7,162.0 244.9,156.9 246.0,162.0 247.2,151.1 248.3,162.0 250.6,155.5 251.8,162.0 252.9,157.1 254.1,162.0 255.2,157.1 256.3,162.0 257.5,152.2 259.8,162.0 260.9,156.5 262.1,162.0 263.2,157.2 264.4,162.0 265.5,152.4 266.7,158.7 267.8,162.0 269.0,157.1 270.1,162.0 271.3,152.4 272.4,162.0 273.5,158.4 274.7,156.9 275.8,162.0 277.0,152.2 278.1,162.0 279.3,158.8 280.4,162.0 281.6,156.9 282.7,157.1 283.9,162.0 285.0,151.9 286.2,162.0 287.3,158.8 288.5,162.0 289.6,156.7 290.7,157.2 291.9,162.0 293.0,151.9 294.2,162.0 295.3,158.7 296.5,162.0 298.8,152.5 299.9,162.0 301.1,158.0 302.2,162.0 303.4,157.0 304.5,162.0 305.7,152.1 307.9,162.0 309.1,156.5 310.2,162.0 311.4,152.3 312.5,162.0 313.7,158.3 314.8,162.0 316.0,156.9 317.1,157.0 318.3,162.0 319.4,151.9 320.6,162.0 321.7,158.5 322.9,162.0 325.1,150.5 326.3,162.0 327.4,158.6 328.6,162.0 329.7,156.9 330.9,162.0 332.0,152.3 334.3,162.0 335.5,156.8 336.6,162.0 337.8,151.9 338.9,162.0 340.1,158.5 341.2,156.8 342.3,162.0 343.5,157.3 344.6,162.0 345.8,152.8 346.9,162.0 348.1,158.3 349.2,156.8 350.4,162.0 351.5,156.5 352.7,162.0 353.8,152.2 355.0,162.0 356.1,158.4 357.3,157.1 358.4,162.0 359.5,151.6 360.7,162.0 361.8,158.6 363.0,162.0 365.3,152.2 366.4,162.0 367.6,158.1 368.7,162.0 369.9,156.7 371.0,162.0 373.3,152.3 374.5,162.0 375.6,158.2 376.7,162.0 377.9,156.8 379.0,162.0 381.3,152.3 382.5,162.0 383.6,146.9 384.8,162.0 385.9,156.9 387.1,162.0 388.2,152.1 389.4,158.7 390.5,162.0 391.7,156.7 392.8,162.0 393.9,152.0 395.1,162.0 396.2,157.6 397.4,162.0 398.5,153.2 400.8,162.0 402.0,157.1 403.1,162.0 404.3,152.0 405.4,162.0 407.7,156.8 408.9,162.0 410.0,157.0 411.1,162.0 412.3,152.4 413.4,162.0 415.7,157.1 416.9,162.0 418.0,152.0 419.2,162.0 420.3,158.7 421.5,162.0 423.8,151.8 424.9,162.0 426.1,158.5 427.2,162.0 428.3,156.9 429.5,162.0 430.6,152.3 432.9,162.0 434.1,156.6 435.2,162.0 436.4,157.0 437.5,162.0 438.7,152.5 441.0,162.0 442.1,157.0 443.3,162.0 444.4,152.0 445.5,162.0 447.8,156.9 449.0,162.0 450.1,151.6 451.3,162.0 452.4,158.6 453.6,162.0 455.9,152.1 457.0,162.0 458.2,158.2 459.3,162.0 460.5,156.8 461.6,162.0 463.9,152.2 465.0,162.0 466.2,158.3 467.3,162.0 468.5,156.5 469.6,162.0 470.8,155.9 471.9,162.0 473.1,152.3 475.4,162.0 476.5,157.2 477.7,162.0 478.8,152.4 479.9,162.0 482.2,157.0 483.4,162.0 484.5,151.6 485.7,162.0 486.8,158.5 488.0,162.0 489.1,157.0 490.3,157.3 491.4,162.0 492.6,152.6 493.7,162.0 494.9,158.9 496.0,162.0 497.1,156.8 498.3,157.0 499.4,162.0 500.6,152.4 501.7,162.0 502.9,158.9 504.0,162.0 506.3,152.5 507.5,162.0 508.6,158.4 509.8,162.0 510.9,157.0 512.1,162.0 514.3,151.8 515.5,162.0 516.6,158.7 517.8,162.0 518.9,155.1 520.1,162.0 522.4,152.0 523.5,162.0 524.7,158.4 525.8,162.0 527.0,156.9 528.1,162.0 530.4,151.9 531.5,162.0 532.7,158.7 533.8,162.0 535.0,157.2 536.1,162.0 537.3,152.5 539.6,162.0 540.7,156.9 541.9,162.0 543.0,152.3 544.2,162.0 545.3,158.5 546.5,162.0 548.7,152.3 549.9,162.0 551.0,158.7 552.2,162.0 553.3,156.8 554.5,162.0 556.8,152.5 557.9,162.0 559.1,158.4 560.2,162.0 561.4,156.9 562.5,162.0 563.7,152.0 565.9,162.0 567.1,156.9 568.2,162.0 569.4,152.0 570.5,162.0 572.8,156.9 574.0,162.0 575.1,151.9 576.3,162.0 577.4,158.5 578.6,162.0 579.7,156.8 580.9,157.2 582.0,162.0 583.1,151.8 584.3,162.0 585.4,157.7 586.6,162.0 588.9,152.2 590.0,162.0 591.2,158.8 592.3,162.0 593.5,156.9 594.6,162.0 595.8,152.6 598.1,162.0 599.2,157.0 600.3,162.0 601.5,151.6 602.6,161.4 603.8,158.8 604.9,153.4 606.1,162.0 607.2,156.9 608.4,162.0 609.5,157.2 610.7,162.0 611.8,152.0 614.1,162.0 615.3,156.9 616.4,162.0 617.5,155.6 618.7,162.0 619.8,151.8 622.1,162.0 623.3,157.3 624.4,162.0 625.6,152.2 626.7,162.0 627.9,158.5 629.0,162.0 631.3,152.3 632.5,162.0 633.6,158.6 634.7,162.0 635.9,157.0 637.0,162.0 639.3,151.9 640.5,162.0 641.6,158.5 642.8,162.0 643.9,156.1 645.1,162.0 647.4,152.5 648.5,162.0 649.7,158.7 650.8,162.0 651.9,156.7 653.1,162.0 654.2,152.4 656.5,162.0 657.7,156.7 658.8,162.0 660.0,151.8 661.1,162.0 662.3,158.4 663.4,156.8 664.6,162.0 665.7,156.9 666.9,162.0 668.0,150.6 669.1,162.0 670.3,157.9 671.4,156.7 672.6,162.0 673.7,154.2 674.9,162.0 676.0,151.8 677.2,162.0 679.5,156.6 680.6,162.0 681.8,152.4 682.9,162.0 684.1,158.1 685.2,162.0 686.3,156.6 687.5,156.5 688.6,162.0 689.8,149.9 690.9,162.0 692.1,158.5 693.2,162.0 695.5,151.8 696.7,162.0 697.8,158.0 699.0,162.0 700.1,156.7 701.3,162.0 702.4,156.1 703.5,162.0 704.7,151.8 705.8,131.0 707.0,162.0 708.1,151.6 709.3,162.0 710.4,142.0 711.6,162.0 712.7,142.2 713.9,157.5 715.0,162.0 716.2,156.8 717.3,162.0 718.5,151.9 719.6,162.0 720.7,158.3 721.9,156.6 723.0,162.0 724.2,149.8 725.3,162.0 726.5,158.0 727.6,162.0 728.8,156.8 729.9,157.1 731.1,162.0 732.2,150.0 733.4,162.0 734.5,158.1 735.7,162.0 736.8,156.6 737.9,156.9 739.1,162.0 740.2,151.8 741.4,162.0 742.5,158.3 743.7,162.0 746.0,151.9 747.1,162.0 748.3,158.1"><title>Workstation GC</title></polyline> <polyline class="zgc-line-b" points="67.1,162.0 68.3,162.0 69.4,151.7 70.6,161.9 71.7,158.1 72.9,162.0 78.6,162.0 79.8,152.1 80.9,162.0 86.6,162.0 87.8,151.5 88.9,162.0 94.7,162.0 95.8,151.2 97.0,162.0 102.7,162.0 103.8,150.7 105.0,162.0 110.7,162.0 111.9,90.1 113.0,162.0 119.9,162.0 121.0,150.2 122.2,162.0 127.9,162.0 129.1,151.8 130.2,162.0 135.9,162.0 137.1,151.4 138.2,162.0 144.0,162.0 145.1,148.5 146.3,162.0 152.0,162.0 153.1,87.4 154.3,162.0 160.0,162.0 161.2,150.6 162.3,162.0 168.1,162.0 169.2,151.3 170.3,162.0 176.1,162.0 177.2,151.2 178.4,162.0 184.1,162.0 185.3,150.5 186.4,162.0 192.1,162.0 193.3,87.0 194.4,162.0 200.2,162.0 201.3,150.3 202.5,162.0 208.2,162.0 209.3,151.3 210.5,162.0 216.2,162.0 217.4,150.5 218.5,162.0 224.2,162.0 225.4,145.2 226.5,162.0 232.3,162.0 233.4,87.4 234.6,162.0 240.3,162.0 241.4,150.3 242.6,162.0 248.3,162.0 249.5,150.9 250.6,162.0 256.3,162.0 257.5,151.0 258.6,162.0 264.4,162.0 265.5,103.7 266.7,162.0 272.4,162.0 273.5,150.8 274.7,162.0 280.4,162.0 281.6,150.7 282.7,162.0 288.5,162.0 289.6,150.9 290.7,162.0 296.5,162.0 297.6,150.3 298.8,161.7 304.5,162.0 305.7,87.3 306.8,162.0 312.5,162.0 313.7,150.4 314.8,162.0 320.6,162.0 321.7,150.7 322.9,162.0 328.6,162.0 329.7,151.0 330.9,162.0 336.6,162.0 337.8,49.5 338.9,162.0 344.6,162.0 345.8,151.9 346.9,162.0 352.7,162.0 353.8,151.7 355.0,162.0 360.7,162.0 361.8,151.0 363.0,162.0 368.7,162.0 369.9,105.3 371.0,162.0 376.7,162.0 377.9,149.9 379.0,162.0 382.5,162.0 383.6,161.5 384.8,162.0 385.9,139.9 387.1,162.0 393.9,162.0 395.1,151.0 396.2,162.0 402.0,162.0 403.1,95.9 404.3,162.0 410.0,162.0 411.1,150.7 412.3,162.0 418.0,162.0 419.2,151.6 420.3,162.0 426.1,162.0 427.2,151.1 428.3,162.0 434.1,162.0 435.2,101.0 436.4,162.0 442.1,162.0 443.3,149.8 444.4,162.0 450.1,162.0 451.3,151.6 452.4,162.0 458.2,162.0 459.3,150.5 460.5,162.0 466.2,162.0 467.3,98.1 468.5,162.0 474.2,162.0 475.4,150.9 476.5,162.0 482.2,162.0 483.4,151.7 484.5,162.0 490.3,162.0 491.4,151.1 492.6,162.0 498.3,162.0 499.4,103.5 500.6,162.0 506.3,162.0 507.5,150.4 508.6,162.0 514.3,162.0 515.5,151.6 516.6,162.0 522.4,162.0 523.5,150.2 524.7,162.0 530.4,162.0 531.5,104.2 532.7,162.0 538.4,162.0 539.6,150.8 540.7,162.0 546.5,162.0 547.6,144.3 548.7,162.0 554.5,162.0 555.6,142.0 556.8,162.0 562.5,162.0 563.7,142.0 564.8,161.2 565.9,162.0 570.5,162.0 571.7,42.5 572.8,162.0 578.6,162.0 579.7,149.8 580.9,162.0 586.6,162.0 587.7,151.3 588.9,162.0 594.6,162.0 595.8,147.8 596.9,162.0 602.6,162.0 603.8,31.9 604.9,162.0 610.7,162.0 611.8,146.4 613.0,162.0 618.7,162.0 619.8,151.2 621.0,162.0 626.7,162.0 627.9,151.0 629.0,162.0 634.7,162.0 635.9,105.5 637.0,162.0 642.8,162.0 643.9,150.3 645.1,162.0 650.8,162.0 651.9,151.3 653.1,162.0 658.8,162.0 660.0,150.7 661.1,162.0 668.0,162.0 669.1,96.2 670.3,162.0 676.0,162.0 677.2,150.6 678.3,162.0 684.1,162.0 685.2,151.6 686.3,162.0 692.1,162.0 693.2,151.1 694.4,162.0 700.1,162.0 701.3,101.0 702.4,162.0 704.7,161.9 705.8,161.0 707.0,162.0 708.1,162.0 709.3,126.2 710.4,162.0 716.2,162.0 717.3,151.3 718.5,162.0 724.2,162.0 725.3,94.1 726.5,162.0 732.2,162.0 733.4,89.5 734.5,162.0 740.2,162.0 741.4,149.4 742.5,162.0 748.3,162.0"><title>Server GC</title></polyline> <polyline class="zgc-line-c" points="67.1,162.0 749.4,162.0"><title>ZeroGC</title></polyline> <polyline class="zgc-line-a" points="66.0,344.5 98.1,339.9 99.3,339.0 117.6,336.2 138.2,333.7 139.4,332.3 163.5,329.1 164.6,328.2 220.8,321.9 221.9,319.0 246.0,315.7 247.2,313.2 380.2,297.7 381.3,297.4 382.5,296.2 383.6,291.9 395.1,290.4 396.2,284.9 398.5,286.9 703.5,251.7 704.7,240.6 748.3,234.9"><title>Workstation GC</title></polyline> <polyline class="zgc-line-b" points="66.0,344.5 78.6,342.7 79.8,341.9 98.1,339.3 99.3,337.7 138.2,332.5 139.4,330.0 192.1,323.0 193.3,323.5 220.8,319.8 223.1,314.4 336.6,299.5 337.8,297.5 360.7,297.4 382.5,294.6 383.6,283.5 384.8,284.4 562.5,261.1 602.6,255.6 603.8,253.7 627.9,253.5 703.5,243.6 705.8,221.4 707.0,223.2 708.1,223.1 709.3,223.8 748.3,218.7"><title>Server GC</title></polyline> <polyline class="zgc-line-c" points="66.0,344.4 98.1,339.0 99.3,338.1 138.2,332.2 139.4,330.5 221.9,318.1 223.1,314.7 382.5,290.7 383.6,284.2 703.5,236.1 704.7,223.2 749.4,216.5"><title>ZeroGC</title></polyline> <circle class="zgc-d-b" cx="603.8" cy="31.9" r="4"/> <text class="zgc-note" x="593.8" y="17.9" text-anchor="end">493 ms - a request that visibly hangs</text> <circle class="zgc-d-c" cx="271.3" cy="307.5" r="4"/> <text class="zgc-note" x="281.3" y="329.5" text-anchor="start">all three grow together - the app keeps what it allocates</text> </svg> </div> <div class="zgc-cap">Console app, growing in-memory cache with a real request workload, 10 minutes. Drawn from the repo's full 1&nbsp;Hz counter capture (596 samples per run, <code>results/raw/</code>); every peak is at its true height.</div> <p>Now we see something. Server GC’s gen2 collections arrive on a visible frequency, and they get worse as the cache grows: about 270 ms early in the run, <strong>493 ms by the end</strong>. Workstation GC trades those for constant small pauses plus its own 117 ms spike near the end. Totalled up, both real collectors stop the app for roughly <strong>8.2-8.6 seconds</strong> across the run. ZeroGC is the flat green line along the bottom: not “fast”, just absent.</p> <p>And the bottom panel is why this is the one workload where the bargain is worth taking. Because the app <em>retains</em> nearly everything it allocates, all three lines climb together. Never freeing costs about <strong>16% more memory</strong> (6.6 GB → 7.7 GB) - and buys away every pause in the panel above. Compare that against 31× for the API that had nothing to gain in the first place.</p> <h2 id="try-it---for-fun">Try it - for fun</h2> <p>If your app is <strong>time-bounded</strong> rather than long-running - a CLI tool, a batch job, a benchmark, a short-lived worker, an inference run that fits in RAM - ZeroGC might genuinely finish faster and quieter. You don’t have to build anything: prebuilt, <strong>signed</strong> binaries are published as NuGet packages, built by Microsoft’s official Arcade/Azure Pipelines infrastructure and pushed to the public <code class="language-plaintext highlighter-rouge">dotnet-experimental</code> feed.</p> <p><strong>1. Get the binary.</strong> There is one package per targeted runtime major version - <code class="language-plaintext highlighter-rouge">Microsoft.DotNet.RuntimeLab.ZeroGC.Net10</code> for <code class="language-plaintext highlighter-rouge">net10.0</code> apps (built against <code class="language-plaintext highlighter-rouge">v10.0.0</code> GA), <code class="language-plaintext highlighter-rouge">Microsoft.DotNet.RuntimeLab.ZeroGC.Net11</code> for <code class="language-plaintext highlighter-rouge">net11.0</code> (tracking the previews), x64 Windows and Linux. Add the feed to a <code class="language-plaintext highlighter-rouge">nuget.config</code> next to your project:</p> <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;configuration&gt;</span>
  <span class="nt">&lt;packageSources&gt;</span>
    <span class="nt">&lt;add</span> <span class="na">key=</span><span class="s">"dotnet-experimental"</span>
         <span class="na">value=</span><span class="s">"https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;/packageSources&gt;</span>
<span class="nt">&lt;/configuration&gt;</span>
</code></pre></div></div> <div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dotnet</span><span class="w"> </span><span class="nx">add</span><span class="w"> </span><span class="nx">package</span><span class="w"> </span><span class="nx">Microsoft.DotNet.RuntimeLab.ZeroGC.Net10</span><span class="w"> </span><span class="nt">--version</span><span class="w"> </span><span class="nx">1.0.0-zerogc.26381.4</span><span class="w">
</span></code></pre></div></div> <p>(Use whatever prerelease version is currently published - <code class="language-plaintext highlighter-rouge">--version</code> and <code class="language-plaintext highlighter-rouge">--prerelease</code> can’t be combined in one command.)</p> <p><strong>2. Put it next to your app.</strong> The package carries <code class="language-plaintext highlighter-rouge">ZeroGC.dll</code> / <code class="language-plaintext highlighter-rouge">libZeroGC.so</code> as a RID-specific <em>native</em> asset, so a plain <code class="language-plaintext highlighter-rouge">dotnet build</code> won’t flatten it next to your executable. Either copy the one file out of the NuGet cache into your build output by hand (fine for a quick experiment, easy to automate with a post-build <code class="language-plaintext highlighter-rouge">&lt;Copy&gt;</code> target)…</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> ~/.nuget/packages/microsoft.dotnet.runtimelab.zerogc.net10/1.0.0-zerogc.26381.4/runtimes/linux-x64/native/libZeroGC.so <span class="se">\</span>
   bin/Release/net10.0/libZeroGC.so
</code></pre></div></div> <p>…or do a RID-specific publish, which picks it up automatically:</p> <div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dotnet</span><span class="w"> </span><span class="nx">publish</span><span class="w"> </span><span class="nt">-c</span><span class="w"> </span><span class="nx">Release</span><span class="w"> </span><span class="nt">-r</span><span class="w"> </span><span class="nx">win-x64</span><span class="w"> </span><span class="nt">--self-contained</span><span class="w"> </span><span class="nx">false</span><span class="w">
</span></code></pre></div></div> <p><strong>3. Turn it on.</strong> Same knob as any other standalone GC - just the file name:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DOTNET_GCName</span><span class="o">=</span>libZeroGC.so dotnet YourApp.dll   <span class="c"># Linux</span>
</code></pre></div></div> <div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">DOTNET_GCName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"ZeroGC.dll"</span><span class="p">;</span><span class="w"> </span><span class="n">dotnet</span><span class="w"> </span><span class="nx">YourApp.dll</span><span class="w">   </span><span class="c"># Windows</span><span class="w">
</span></code></pre></div></div> <p>Unset it to go back to the normal GC. If you’d rather bake it into the app than set an environment variable, the equivalent <code class="language-plaintext highlighter-rouge">runtimeconfig.json</code> property (<code class="language-plaintext highlighter-rouge">configProperties."System.GC.Name"</code>) and <code class="language-plaintext highlighter-rouge">AppContext</code> switch work too.</p> <p><strong>4. Check it’s actually live.</strong> ZeroGC reports itself through <code class="language-plaintext highlighter-rouge">GC.GetConfigurationValues()</code>, but the cheapest sanity check is the behaviour itself: <code class="language-plaintext highlighter-rouge">GC.CollectionCount(0/1/2)</code> should stay pinned at <code class="language-plaintext highlighter-rouge">0</code> forever, and the working set should climb monotonically.</p> <blockquote> <p><strong>Which package should I take?</strong> Match your app’s target framework - <code class="language-plaintext highlighter-rouge">.Net10</code> for <code class="language-plaintext highlighter-rouge">net10.0</code>, <code class="language-plaintext highlighter-rouge">.Net11</code> for <code class="language-plaintext highlighter-rouge">net11.0</code>. That is the sanity default and what the docs recommend, though it is probably not load-bearing; <a href="#versioning-what-a-standalone-gc-is-tied-to">what a standalone GC is actually tied to</a> is covered below.</p> </blockquote> <p>Run it, watch the memory graph climb, and see whether your workload is one of the “GC was invisible anyway” cases or one of the “pauses gone, memory paid” cases. Then share what you find - I’d love to see the graphs.</p> <h3 id="building-from-sources">Building from sources</h3> <p>If you’re on a restricted network, need a runtime version that isn’t published, or want to hack on ZeroGC itself, building from source takes a couple of minutes. Both scripts want a local <code class="language-plaintext highlighter-rouge">dotnet/runtime</code> checkout, used <strong>read-only</strong> as a header and reference dependency - nothing under it is built or modified - and both default that path to my machine, so pass your own:</p> <div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">\build.ps1</span><span class="w"> </span><span class="nt">-RuntimeRepo</span><span class="w"> </span><span class="nx">C:\src\runtime</span><span class="w">          </span><span class="c"># needs the VC++ build tools</span><span class="w">
</span></code></pre></div></div> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./build-linux.sh <span class="nt">--runtime-repo</span> ~/src/runtime    <span class="c"># needs clang++ with C++17</span>
</code></pre></div></div> <p>Checking out the <code class="language-plaintext highlighter-rouge">dotnet/runtime</code> tag matching your app’s target runtime is the tidy thing to do; any reasonably recent tag will build a working binary, for reasons covered under <a href="#versioning-what-a-standalone-gc-is-tied-to">versioning</a> below. Full details: <a href="https://github.com/dotnet/runtimelab/blob/feature/ZeroGC/docs/zerogc/using-prebuilt-binaries.md"><code class="language-plaintext highlighter-rouge">docs/zerogc/using-prebuilt-binaries.md</code></a> and <a href="https://github.com/dotnet/runtimelab/blob/feature/ZeroGC/src/ZeroGC/README.md"><code class="language-plaintext highlighter-rouge">src/ZeroGC/README.md</code></a>.</p> <h2 id="versioning-what-a-standalone-gc-is-tied-to">Versioning: what a standalone GC is tied to</h2> <p>A standalone GC is loaded across a versioned ABI, described by two numbers in <a href="https://github.com/dotnet/runtime/blob/main/src/coreclr/gc/gcinterface.h"><code class="language-plaintext highlighter-rouge">gcinterface.h</code></a>:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define GC_INTERFACE_MAJOR_VERSION 5
#define GC_INTERFACE_MINOR_VERSION 8
</span></code></pre></div></div> <p>Those numbers cover more than the C++ methods. They version the <em>whole</em> GC/EE contract: which methods exist on <code class="language-plaintext highlighter-rouge">IGCHeap</code> and <code class="language-plaintext highlighter-rouge">IGCToCLR</code> and what they mean, and also the behaviours and data contracts that never appear as a method signature at all - including the object-model layout the GC reads directly.</p> <p>There is a second number for the other direction, <code class="language-plaintext highlighter-rouge">EE_INTERFACE_MAJOR_VERSION</code> (currently <code class="language-plaintext highlighter-rouge">4</code>), describing what the runtime side offers. The two are exchanged at load time, which is what lets each side adapt to the other.</p> <p>The loader in <a href="https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/gcheaputilities.cpp"><code class="language-plaintext highlighter-rouge">gcheaputilities.cpp</code></a> applies a single rule:</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">g_gc_version_info</span><span class="p">.</span><span class="n">MajorVersion</span> <span class="o">&lt;</span> <span class="n">GC_INTERFACE_MAJOR_VERSION</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">LOG</span><span class="p">((</span><span class="n">LF_GC</span><span class="p">,</span> <span class="n">LL_FATALERROR</span><span class="p">,</span> <span class="s">"Loaded GC has incompatible major version number ..."</span><span class="p">));</span>
    <span class="k">return</span> <span class="n">E_FAIL</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div> <p>The comparison is one-directional. A GC reporting an <em>older</em> major version is rejected; one reporting the same or a <em>newer</em> version is accepted. That is what permits a GC built against a newer runtime to load into an older one. A lower <em>minor</em> version is not rejected at all - it logs at <code class="language-plaintext highlighter-rouge">LL_INFO100</code> and continues.</p> <p>The major number moves rarely: it has been <strong>version 5 since January 2023</strong> (<a href="https://github.com/dotnet/runtime/pull/81188">dotnet/runtime#81188</a>, <em>“Fix GC interfaces versioning”</em>), spanning .NET 8, 9, 10 and 11-preview. Keeping a newer GC loadable on older runtimes is an explicit goal on the runtime side - <a href="https://github.com/dotnet/runtime/pull/88457">dotnet/runtime#88457</a>, <em>“Ensure GCHeap related debugging still works even when we use newer CLRGC to target older runtimes”</em>, is maintenance for exactly that scenario. When the major version does change, the outcome is a refusal to load with <code class="language-plaintext highlighter-rouge">E_FAIL</code> and a logged reason.</p> <h3 id="the-object-layout-is-part-of-that-contract">The object layout is part of that contract</h3> <p>A standalone GC does not only call the interface. It also compiles against the <code class="language-plaintext highlighter-rouge">gcenv.*.h</code> shim headers, which mirror the VM’s private <code class="language-plaintext highlighter-rouge">MethodTable</code> and <code class="language-plaintext highlighter-rouge">ObjHeader</code> structures field for field. A GC reads object headers directly - it has to, and it cannot avoid the dependency in any case: <code class="language-plaintext highlighter-rouge">Object</code>, <code class="language-plaintext highlighter-rouge">MethodTable</code> and <code class="language-plaintext highlighter-rouge">gc_alloc_context</code> appear throughout the <code class="language-plaintext highlighter-rouge">IGCHeap</code> and <code class="language-plaintext highlighter-rouge">IGCHandleManager</code> signatures every standalone GC must implement.</p> <p>That layout is versioned by the same numbers. The <code class="language-plaintext highlighter-rouge">MethodTable</code> data a collector needs to walk the object graph is a binary contract kept that way for performance, and changing it incompatibly requires a major version bump even though no C++ signature changes. <a href="https://github.com/dotnet/runtime/pull/91821">dotnet/runtime#91821</a> (<em>“Converge Representations between NativeAOT and CoreCLR”</em>, September 2023) is the worked example: it moved <code class="language-plaintext highlighter-rouge">MTFlag_Collectible</code> from <code class="language-plaintext highlighter-rouge">0x10000000</code> to <code class="language-plaintext highlighter-rouge">0x00200000</code> and bumped <code class="language-plaintext highlighter-rouge">EE_INTERFACE_MAJOR_VERSION</code> from 1 to 2 in the same change, touching no interface method.</p> <p>The compatibility code handles that signal, and compiles only for standalone GCs:</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">bool</span> <span class="nf">Collectible</span><span class="p">()</span>
<span class="p">{</span>
<span class="cp">#ifdef BUILD_AS_STANDALONE
</span>    <span class="k">if</span> <span class="p">(</span><span class="n">g_oldMethodTableFlags</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">// This flag is used for .NET 8 or below</span>
        <span class="k">const</span> <span class="kt">int</span> <span class="n">Old_MTFlag_Collectible</span> <span class="o">=</span> <span class="mh">0x10000000</span><span class="p">;</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">m_flags</span> <span class="o">&amp;</span> <span class="n">Old_MTFlag_Collectible</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="p">}</span>
<span class="cp">#endif
</span>    <span class="k">return</span> <span class="p">(</span><span class="n">m_flags</span> <span class="o">&amp;</span> <span class="n">MTFlag_Collectible</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div> <p>A GC opts into it during the version handshake. The runtime passes its own version in, the GC records it and writes its own version back - ZeroGC’s <code class="language-plaintext highlighter-rouge">GC_VersionInfo</code> is the whole mechanism in eight lines:</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ZEROGC_EXPORT</span> <span class="kt">void</span> <span class="nf">GC_VersionInfo</span><span class="p">(</span><span class="n">VersionInfo</span><span class="o">*</span> <span class="n">info</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// On entry, `info` carries the interface version the runtime supports;</span>
    <span class="c1">// remember it so we know which optional IGCToCLR members are safe to call.</span>
    <span class="n">g_runtimeSupportedVersion</span> <span class="o">=</span> <span class="o">*</span><span class="n">info</span><span class="p">;</span>
    <span class="n">g_oldMethodTableFlags</span> <span class="o">=</span> <span class="n">g_runtimeSupportedVersion</span><span class="p">.</span><span class="n">MajorVersion</span> <span class="o">&lt;</span> <span class="mi">2</span><span class="p">;</span>

    <span class="n">info</span><span class="o">-&gt;</span><span class="n">MajorVersion</span> <span class="o">=</span> <span class="n">GC_INTERFACE_MAJOR_VERSION</span><span class="p">;</span>
    <span class="n">info</span><span class="o">-&gt;</span><span class="n">MinorVersion</span> <span class="o">=</span> <span class="n">GC_INTERFACE_MINOR_VERSION</span><span class="p">;</span>
    <span class="n">info</span><span class="o">-&gt;</span><span class="n">BuildVersion</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="n">info</span><span class="o">-&gt;</span><span class="n">Name</span> <span class="o">=</span> <span class="s">"ZeroGC"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div> <h3 id="what-all-this-means-in-practice">What all this means in practice</h3> <ul> <li>The <code class="language-plaintext highlighter-rouge">net10.0</code> build would run on .NET 11-preview: the GC interface major version has not moved</li> <li>Older runtimes (.NET 9, .NET 8) are plausible for the same reason, and where the object model did change, the version handshake is what lets a single binary cope.</li> <li>If the major version is bumped, the failure is a refusal at startup with a logged reason, not corruption</li> </ul> <p>For anyone writing their own collector, the practical shape of this is: the version numbers cover more than the vtable, so read them as the contract for the object model too, and use the handshake to branch when you must. Depending on <code class="language-plaintext highlighter-rouge">MethodTable</code> is not something to design around - every standalone GC does it, including the one in the box.</p>]]></content><author><name></name></author><category term="dotnet"/><category term="dotnet"/><category term="gc"/><category term="experiments"/><summary type="html"><![CDATA[A custom CoreCLR GC that only ever allocates and never reclaims memory - the simplest possible baseline for writing a real one, and a fun toy for measuring how GC-bound your app really is.]]></summary></entry><entry><title type="html">Introducing dotLLM - Building an LLM Inference Engine in C#</title><link href="https://kokosa.dev/blog/2026/dotllm/" rel="alternate" type="text/html" title="Introducing dotLLM - Building an LLM Inference Engine in C#"/><published>2026-04-14T10:00:00+02:00</published><updated>2026-04-14T10:00:00+02:00</updated><id>https://kokosa.dev/blog/2026/dotllm</id><content type="html" xml:base="https://kokosa.dev/blog/2026/dotllm/"><![CDATA[<figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/dotllm-hero-480.webp 480w,/assets/img/dotllm-hero-800.webp 800w,/assets/img/dotllm-hero-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/dotllm-hero.gif" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>If you’ve been building .NET applications and wanted to run LLMs locally, your options have been… limited. You could wrap <a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a> through <a href="https://github.com/SciSharp/LLamaSharp">LLamaSharp</a>, deal with <a href="https://onnxruntime.ai/docs/get-started/with-csharp.html">ONNX Runtime</a>, or orchestrate calls to external Python services. None of these are fully satisfying if you want to run LLM inference purely in .NET.</p> <p>So I built my own.</p> <p>Over the past two months I’ve been building <a href="https://dotllm.dev/">dotLLM</a> - a ground-up, high-performance LLM inference engine written natively in C#/.NET 10. Not a wrapper. Not bindings. A full implementation: GGUF model loading, tokenization, attention, sampling, SIMD-optimized CPU inference, CUDA GPU acceleration, an OpenAI-compatible API server, and a built-in chat UI. This week I’ve published the <a href="https://github.com/kkokosa/dotLLM/releases">first preview release</a> (v0.1.0-preview.2).</p> <p>This post covers three things: <strong>what</strong> dotLLM is, <strong>why</strong> I built it, and <strong>how</strong> AI-assisted development - Claude Code, Codex, and Gemini working together in a structured workflow - made it possible for a single developer to ship this in about two months.</p> <h2 id="what-is-dotllm">What is dotLLM?</h2> <h3 id="the-short-version">The short version</h3> <p>dotLLM is a native C#/.NET 10 LLM inference engine. It runs transformer-based models - Llama, Mistral, Phi, Qwen (and more in future) - from GGUF files, with SIMD-optimized CPU, CUDA GPU backend, or hybrid/offloading mode. It exposes an OpenAI-compatible API, ships as a CLI tool and as NuGet packages you can embed in your own applications.</p> <p>The key word is <strong>native</strong>. All orchestration, model loading, tokenization, sampling, scheduling, and CPU compute is implemented in pure C#. The only native code is a thin CUDA library for GPU kernels, loaded via PTX through the CUDA Driver API (P/Invoked).</p> <h3 id="architecture">Architecture</h3> <p>dotLLM is organized as a layered architecture where each layer depends only on the layers below:</p> <pre><code class="language-mermaid">graph TD
    subgraph "User-Facing"
        CLI["DotLLM.Cli&lt;br/&gt;&lt;small&gt;CLI tool&lt;/small&gt;"]
        ChatUI["Built-in Chat UI&lt;br/&gt;&lt;small&gt;Browser-based&lt;/small&gt;"]
        Server["DotLLM.Server&lt;br/&gt;&lt;small&gt;OpenAI-compatible API&lt;/small&gt;"]
    end

    subgraph "Engine"
        Eng["DotLLM.Engine&lt;br/&gt;&lt;small&gt;KV-cache, scheduler, samplers,&lt;br/&gt;constraints, speculative decoding&lt;/small&gt;"]
    end

    subgraph "Model Layer"
        Models["DotLLM.Models&lt;br/&gt;&lt;small&gt;GGUF loader, Llama, Mistral,&lt;br/&gt;Phi, Qwen, DeepSeek&lt;/small&gt;"]
        Tokenizers["DotLLM.Tokenizers&lt;br/&gt;&lt;small&gt;BPE, SentencePiece,&lt;br/&gt;Jinja2 chat templates&lt;/small&gt;"]
    end

    subgraph "Compute"
        CPU["DotLLM.Cpu&lt;br/&gt;&lt;small&gt;SIMD / AVX2 / AVX-512&lt;/small&gt;"]
        CUDA["DotLLM.Cuda&lt;br/&gt;&lt;small&gt;PTX kernels, cuBLAS&lt;/small&gt;"]
    end

    Core["DotLLM.Core&lt;br/&gt;&lt;small&gt;ITensor, IBackend, IModel,&lt;br/&gt;ISamplerStep, IDecodingConstraint&lt;/small&gt;"]

    CLI --&gt; Server
    ChatUI --&gt; Server
    Server --&gt; Eng
    CLI --&gt; Eng
    Eng --&gt; Models
    Eng --&gt; Tokenizers
    Models --&gt; CPU
    Models --&gt; CUDA
    CPU --&gt; Core
    CUDA --&gt; Core
    Models --&gt; Core
    Tokenizers --&gt; Core
    Eng --&gt; Core
</code></pre> <p>Each project ships as a separate NuGet package. <code class="language-plaintext highlighter-rouge">DotLLM.Core</code> defines all abstractions (<code class="language-plaintext highlighter-rouge">ITensor</code>, <code class="language-plaintext highlighter-rouge">IBackend</code>, <code class="language-plaintext highlighter-rouge">IModel</code>, <code class="language-plaintext highlighter-rouge">ISamplerStep</code>, etc.) while concrete implementations live in their respective projects. You pull in only what you need.</p> <h3 id="key-features">Key features</h3> <p><strong>Performance:</strong></p> <ul> <li><strong>Zero-alloc inference</strong> - all tensor data uses <code class="language-plaintext highlighter-rouge">NativeMemory.AlignedAlloc</code> (64-byte aligned). No managed heap allocations on the hot path (well, “the best” effort so far). (Almost) no allocs, no GC triggered.</li> <li><strong>SIMD vectorization for CPU backend</strong> - <code class="language-plaintext highlighter-rouge">TensorPrimitives</code> for standard operations, hand-tuned <code class="language-plaintext highlighter-rouge">System.Runtime.Intrinsics</code> for quantized matmul, RMSNorm, RoPE, softmax operations. AVX2 and AVX-512 with scalar fallbacks.</li> <li><strong>CUDA GPU backend</strong> - PTX kernels loaded via the CUDA Driver API with cuBLAS HGEMM for prefill, custom quantized GEMV for decode, and FP16 activation pipeline. Supports full GPU inference, hybrid CPU/GPU layer offloading, and KV-cache quantization.</li> <li><strong>Memory-mapped model loading</strong> - GGUF files loaded via <code class="language-plaintext highlighter-rouge">MemoryMappedFile</code>. OS demand-paging means multi-GB models load in milliseconds.</li> <li><strong>Quantized inference</strong> - FP16, Q8_0, Q4_K_M and other GGUF formats with fused scale-int dot-product kernels operating directly on quantized blocks.</li> </ul> <p><strong>Serving:</strong></p> <ul> <li><strong>OpenAI-compatible API</strong> - <code class="language-plaintext highlighter-rouge">/v1/chat/completions</code>, <code class="language-plaintext highlighter-rouge">/v1/completions</code>, tool calling, structured output, streaming SSE via ASP.NET.</li> <li><strong>Speculative decoding</strong> - draft-verify-accept loop with KV-cache rollback for higher throughput.</li> <li><strong>Structured output</strong> - FSM/PDA-based constrained decoding guaranteeing valid JSON, JSON Schema, regex, and GBNF grammar.</li> </ul> <p><strong>Extensibility:</strong></p> <ul> <li><strong>Pluggable backends</strong> - <code class="language-plaintext highlighter-rouge">IBackend</code> interface with separate packages (CPU, CUDA, future ROCm).</li> <li>(planned) <strong>LoRA adapters</strong> - runtime loading, no weight merging, concurrent multi-adapter serving.</li> <li>(planned) <strong>Diagnostic hooks</strong> - zero-cost <code class="language-plaintext highlighter-rouge">IInferenceHook</code> for activation capture, logit lens, SAE integration.</li> <li>(planned) <strong>OpenTelemetry</strong> - <code class="language-plaintext highlighter-rouge">System.Diagnostics.Metrics</code> + <code class="language-plaintext highlighter-rouge">Activity</code> for throughput, latency, and per-request tracing.</li> </ul> <p>Here’s a minimal streaming generation example:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">var</span> <span class="n">gguf</span> <span class="p">=</span> <span class="n">GgufFile</span><span class="p">.</span><span class="nf">Open</span><span class="p">(</span><span class="n">modelPath</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">config</span> <span class="p">=</span> <span class="n">GgufModelConfigExtractor</span><span class="p">.</span><span class="nf">Extract</span><span class="p">(</span><span class="n">gguf</span><span class="p">.</span><span class="n">Metadata</span><span class="p">);</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">model</span> <span class="p">=</span> <span class="n">TransformerModel</span><span class="p">.</span><span class="nf">LoadFromGguf</span><span class="p">(</span><span class="n">gguf</span><span class="p">,</span> <span class="n">config</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">tokenizer</span> <span class="p">=</span> <span class="n">GgufBpeTokenizerFactory</span><span class="p">.</span><span class="nf">Load</span><span class="p">(</span><span class="n">gguf</span><span class="p">.</span><span class="n">Metadata</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">generator</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">TextGenerator</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">tokenizer</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">options</span> <span class="p">=</span> <span class="k">new</span> <span class="n">InferenceOptions</span>
<span class="p">{</span>
    <span class="n">SamplerSteps</span> <span class="p">=</span>
    <span class="p">[</span>
        <span class="k">new</span> <span class="nf">TemperatureSampler</span><span class="p">(</span><span class="m">0.8f</span><span class="p">),</span>
        <span class="k">new</span> <span class="nf">TopKSampler</span><span class="p">(</span><span class="m">40</span><span class="p">),</span>
        <span class="k">new</span> <span class="nf">TopPSampler</span><span class="p">(</span><span class="m">0.95f</span><span class="p">)</span>
    <span class="p">],</span>
    <span class="n">StopConditions</span> <span class="p">=</span> <span class="p">[</span><span class="k">new</span> <span class="nf">EosStopCondition</span><span class="p">(</span><span class="n">tokenizer</span><span class="p">.</span><span class="n">EosTokenId</span><span class="p">)],</span>
    <span class="n">MaxTokens</span> <span class="p">=</span> <span class="m">128</span>
<span class="p">};</span>

<span class="k">await</span> <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">token</span> <span class="k">in</span> <span class="n">generator</span><span class="p">.</span><span class="nf">GenerateStreamingTokensAsync</span><span class="p">(</span><span class="n">prompt</span><span class="p">,</span> <span class="n">options</span><span class="p">))</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">Write</span><span class="p">(</span><span class="n">token</span><span class="p">.</span><span class="n">Text</span><span class="p">);</span>
</code></pre></div></div> <p>And here’s what the CLI looks like for a quick generation run with SmolLM-135M:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-- dotllm | Llama 30L/576H | Q8_0 | 16 threads | greedy ──────────────────
The capital of France is Paris. Paris is a city of romance and culture,

╭──────────────────────────────────────────────────────────────────────────╮
│  Generation Complete                                      163.27 tok/s  │
│                                                                         │
│  Prefill            12.3 ms       6 tokens       487.80 tok/s           │
│  Decode             91.8 ms      15 tokens       163.40 tok/s           │
│  Sampling            0.1 ms      15 tokens                              │
│  Total             104.2 ms      21 tokens       201.54 tok/s           │
│  Load              456.7 ms                                             │
│                                                                         │
│  Weights         136.73 MiB      (memory-mapped)                        │
│  KV Cache        158.20 MiB      (192 slots)                            │
╰──────────────────────────────────────────────────────────────────────────╯
</code></pre></div></div> <p>There is also build-in <code class="language-plaintext highlighter-rouge">serve</code> command to host simple chat UX for testing and research:</p> <video autoplay="" loop="" muted="" playsinline="" class="img-fluid rounded z-depth-1" style="width: 100%;"> <source src="/assets/img/dotllm-chat-ui.mp4" type="video/mp4"/> </video> <h3 style="margin-top: 2rem;">Performance reality check</h3> <p>Let’s be honest about where dotLLM stands today. While performance has been a key design consideration from day one (zero-alloc hot paths, SIMD kernels, memory-mapped loading), the primary focus so far has been closing the feature set — getting the full inference pipeline, constrained decoding, tool calling, speculative decoding, and the API server working correctly. Dedicated performance polishing is coming. Here are CPU benchmarks against llama.cpp (AMD Ryzen 9 5950X, 16 threads, same models and quantizations):</p> <p><strong>Decode throughput (tok/s)</strong> - the metric that matters most for interactive chat:</p> <table> <thead> <tr> <th>Model</th> <th>Quant</th> <th style="text-align: right">dotLLM</th> <th style="text-align: right">llama.cpp</th> <th style="text-align: right">Ratio</th> </tr> </thead> <tbody> <tr> <td>SmolLM-135M</td> <td>Q4_K_M</td> <td style="text-align: right">279.1</td> <td style="text-align: right">334.7</td> <td style="text-align: right">0.83x</td> </tr> <tr> <td>SmolLM-135M</td> <td>Q8_0</td> <td style="text-align: right">197.7</td> <td style="text-align: right">255.9</td> <td style="text-align: right">0.77x</td> </tr> <tr> <td>Llama 3.2 1B</td> <td>Q4_K_M</td> <td style="text-align: right">32.4</td> <td style="text-align: right">48.9</td> <td style="text-align: right">0.66x</td> </tr> <tr> <td>Llama 3.2 1B</td> <td>Q8_0</td> <td style="text-align: right">25.0</td> <td style="text-align: right">31.0</td> <td style="text-align: right">0.81x</td> </tr> <tr> <td>Llama 3.2 3B</td> <td>Q4_K_M</td> <td style="text-align: right">15.4</td> <td style="text-align: right">19.6</td> <td style="text-align: right">0.79x</td> </tr> <tr> <td>Llama 3.2 3B</td> <td>Q8_0</td> <td style="text-align: right">9.9</td> <td style="text-align: right">11.2</td> <td style="text-align: right">0.88x</td> </tr> </tbody> </table> <p>On decode, dotLLM reaches <strong>66-88% of llama.cpp</strong> throughput. Decode is largely memory-bandwidth-bound, and C# with SIMD intrinsics can get reasonably close to saturating the memory bus.</p> <p><strong>Prefill is a different story</strong> - dotLLM is roughly 2-5x slower than llama.cpp across the board. Prefill is compute-bound, and llama.cpp has years of hand-tuned GEMM kernels. We hit a specific wall here (outer-product tiled matmul vs. RyuJIT register pressure) which I’ll describe in the lessons learned section below.</p> <p>The CUDA backend is functional but still early - it currently underperforms CPU on small models due to launch overhead, and the kernel tuning work is ongoing.</p> <p>This is a preview release. If you need maximum throughput today, llama.cpp is faster. The gap will narrow.</p> <h2 id="why-build-this">Why build this?</h2> <p>If out-of-the-box performance is not easily achieved, why build this at all? A few reasons, starting with the most important:</p> <p><strong>Understanding by building.</strong> I’ve been writing about LLM internals on this blog - <a href="/blog/2026/temperature/">temperature and sampling</a>, <a href="/blog/2026/logprobs/">logprobs</a>, learning a lot on my own. At some point, it makes you want to implement the whole pipeline. Building dotLLM was the logical next step to accelerate learning: implementing the entire inference pipeline from GGUF parsing through attention to token generation, seeing every allocation and every SIMD instruction up close. And it will generate A LOT of other blog posts…😇</p> <p><strong>Seeing how far AI-assisted development can go.</strong> This was also, explicitly, an experiment. <strong>Not vibe coding</strong> - not “prompt and pray” loop. Structured, documented, reviewed AI-assisted development where a human makes the architectural decisions and AI handles implementation within well-defined boundaries. I wanted to find out what a solo developer can realistically build in one-two months with this approach. The answer surprised me.</p> <p><strong>Creating a platform for research and experimentation.</strong> Building from the ground up with this goal in mind means the architecture is open to experimentation - adding new features, deeper diagnostics, and interpretability tools into the inference pipeline. All outside the gold-standard HuggingFace monopoly. In .NET 😍</p> <p><strong>Proving the platform.</strong> There’s a persistent assumption that systems-level performance work requires C, C++, or Rust. Twenty years of .NET performance work has taught me that’s not always true. C# with <code class="language-plaintext highlighter-rouge">NativeMemory</code>, <code class="language-plaintext highlighter-rouge">System.Runtime.Intrinsics</code>, <code class="language-plaintext highlighter-rouge">MemoryMappedFile</code>, and <code class="language-plaintext highlighter-rouge">Span&lt;T&gt;</code> gives you genuine control over memory and compute. dotLLM is a proof point.</p> <p><strong>The .NET ecosystem gap is real.</strong> If you’re building .NET applications and want local LLM inference, your choices are wrappers (LLamaSharp wrapping llama.cpp), limited runtimes (ONNX Runtime with restricted model support), or orchestration layers (Semantic Kernel, which is about chaining calls, not running inference). Enterprise .NET shops that want to run models in production without Python or C++ dependencies have no native option. dotLLM fills that gap, but it will take a LOT of time to treat it as a serious replacement.</p> <blockquote class="block-info"> <p>dotLLM is not meant to replace llama.cpp or vLLM in production - at least not yet. It’s built for .NET developers who want native inference without leaving their ecosystem, and for researchers and experimenters who want to explore LLM internals from C#. And everyone who wants to understand how to build a LLM inference engine from scratch.</p> </blockquote> <h2 id="how-ai-built-an-ai-engine">How AI built an AI engine</h2> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/dotllm-git-log-480.webp 480w,/assets/img/dotllm-git-log-800.webp 800w,/assets/img/dotllm-git-log-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/dotllm-git-log.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Let me be upfront: nearly every commit in dotLLM’s git history has a <code class="language-plaintext highlighter-rouge">Co-Authored-By: Claude Opus 4.6</code> line. This project would not exist in its current form without AI assistance. But the story of <em>how</em> that assistance was structured is more interesting than the fact that it was used.</p> <h3 id="the-development-methodology">The development methodology</h3> <p>dotLLM was built over ~60 implementation steps organized into 7 phases, documented in a detailed <a href="https://github.com/kkokosa/dotLLM/blob/main/docs/ROADMAP.md"><code class="language-plaintext highlighter-rouge">ROADMAP.md</code></a>. Each phase was a discrete unit of work with clear scope and acceptance criteria:</p> <ul> <li><strong>Phase 1</strong> - End-to-end single-token generation (GGUF loader, dequantization, CPU ops, tokenizer, attention, KV-cache, sampling)</li> <li><strong>Phase 2</strong> - Practical local inference (Q4_K_M, chat templates, streaming, multi-threading, additional architectures)</li> <li><strong>Phase 3</strong> - CPU performance (tiled attention, SIMD tuning, NUMA awareness, operator fusion)</li> <li><strong>Phase 4</strong> - GPU acceleration (CUDA backend, hybrid CPU/GPU, KV-cache quantization)</li> <li><strong>Phase 5</strong> - Constrained decoding and API (JSON/schema/regex/grammar, tool calling, server, chat UI, prompt caching)</li> <li><strong>Phase 6</strong> - Improved serving (warm-up, Native AOT, paged KV-cache, speculative decoding)</li> <li><strong>Phase 7</strong> - Diagnostics and interpretability (logprobs, hooks, logit lens - in progress)</li> </ul> <p>Every step started as a GitHub issue. Every issue lived on a branch named <code class="language-plaintext highlighter-rouge">issue/{number}-{short-description}</code>. Every PR closed its issue and updated the roadmap. This was relentlessly boring discipline, and it was the single most important factor in the project’s success.</p> <p>After the initial release, just before making the repository public, I also ran a series of “Waves” - systematic quality passes across the entire codebase:</p> <ul> <li><strong>Wave 1</strong> (P0): Security and crash fixes - path traversal, CUDA shared memory guards, hybrid GPU edge cases</li> <li><strong>Wave 2</strong> (P1): Quick correctness and consistency fixes</li> <li><strong>Wave 3</strong>: Presentation cleanup - remove dead code, label stubs, fix samples</li> <li><strong>Wave 4</strong>: Server hardening - request validation, LINQ removal from hot paths</li> <li><strong>Wave 5</strong> was skipped - it was earmarked for batch-serving improvements that depend on Phase 9 (continuous batching), which isn’t implemented yet</li> <li><strong>Wave 6</strong>: CUDA kernel rewrite - tiled softmax, vectorization, grid-stride loops</li> <li><strong>Wave 7</strong>: CPU performance - TopK sampler optimization, AVX2 gap filling, schema cache tuning</li> </ul> <p>Those “waves” are a bunch of findings that come from in-depth reviews from other models, grouped into GitHub issues.</p> <h3 id="roadmapmd-and-claudemd---the-highest-roi-investments"><code class="language-plaintext highlighter-rouge">ROADMAP.md</code> and <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> - the highest-ROI investments</h3> <p>If there’s one takeaway from this experiment, it’s this: <strong>the time you spend writing structured documentation for AI is not overhead - it IS the development methodology.</strong></p> <p><code class="language-plaintext highlighter-rouge">ROADMAP.md</code> was the backbone. Each of the ~60 steps had a feature name, a description, key files to modify, and dependencies on other steps. This gave both me and the AI a shared understanding of what to build next, in what order, and why. Without it, AI would be coding in circles - solving the wrong problem, building features in the wrong order, missing dependencies.</p> <p>The roadmap also forced me to think through the architecture upfront. When you have to discuss things like <em>“Step 31: CUDA backend - PTX kernels loaded via CUDA Driver API, no native shared library, cuBLAS HGEMM for prefill, custom quantized GEMV for decode”</em> before writing any code, you’ve already made the hard decisions. And learnt a lot.</p> <p><code class="language-plaintext highlighter-rouge">CLAUDE.md</code> was the project’s “constitution” - 180+ lines defining how AI should work in this codebase. Here are some actual rules from it:</p> <div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gs">**Native .NET first**</span> - All orchestration, model loading, tokenization, sampling, scheduling, CPU compute in pure C#.

<span class="gs">**Unmanaged memory for tensors**</span> - <span class="sb">`NativeMemory.AlignedAlloc`</span> (64-byte). Zero GC allocations on inference hot path.

<span class="gs">**Hybrid GPU architecture**</span> - Thin native C/CUDA lib via <span class="sb">`[LibraryImport]`</span>. GPU memory as opaque <span class="sb">`IntPtr`</span> - tensor data never crosses P/Invoke boundary.
</code></pre></div></div> <p>And specific coding rules:</p> <div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gs">**NEVER**</span> allocate managed arrays for tensor data. Use <span class="sb">`NativeMemory.AlignedAlloc`</span> (64-byte for AVX-512, 32-byte for AVX2).

SIMD: Foundation is <span class="sb">`System.Numerics.Tensors.TensorPrimitives`</span> for standard ops. Hot inner loops: <span class="sb">`System.Runtime.Intrinsics`</span> - prefer cross-platform <span class="sb">`Vector128&lt;T&gt;`</span>/<span class="sb">`Vector256&lt;T&gt;`</span>, use platform-specific only when measurably faster. <span class="ge">**</span>Always provide scalar fallback.
</code></pre></div></div> <p>Beyond <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>, there were <strong>22 detailed design documents</strong> in <code class="language-plaintext highlighter-rouge">/docs/</code> - one for each major subsystem like <code class="language-plaintext highlighter-rouge">ARCHITECTURE</code>, <code class="language-plaintext highlighter-rouge">QUANTIZATION</code>, <code class="language-plaintext highlighter-rouge">ATTENTION</code>, <code class="language-plaintext highlighter-rouge">CUDA</code>, and more. The rule was simple: AI reads the relevant spec before touching a module.</p> <p>This <strong>documentation-first</strong> approach had a compound effect. Every implementation step could reference the roadmap for scope, the design docs for architecture, and <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> for coding conventions. The AI wasn’t guessing - it was implementing within well-defined constraints.</p> <h3 id="claude-code-as-implementation-partner">Claude Code as implementation partner</h3> <p><a href="https://claude.ai/code">Claude Code</a> with Opus 4.6 (1M context) was the primary implementation tool from the project start. The workflow was built around six custom <a href="https://docs.anthropic.com/en/docs/claude-code/skills">Claude Code skills</a> that automated the development lifecycle:</p> <ul> <li><strong><code class="language-plaintext highlighter-rouge">/plan-step</code></strong> - looks for a given roadmap step in <code class="language-plaintext highlighter-rouge">ROADMAP.md</code> plus relevant docs from <code class="language-plaintext highlighter-rouge">/docs/</code>, enters plan mode, and produces a step-by-step implementation plan for my approval before any code is written.</li> <li><strong><code class="language-plaintext highlighter-rouge">/create-pr</code></strong> - commits remaining changes, pushes the branch, and creates a PR with a detailed description following project conventions.</li> <li><strong><code class="language-plaintext highlighter-rouge">/apply-pr-comments</code></strong> - reads review comments from Codex, Gemini, or human reviewers, analyzes them, enters plan mode so I can approve the fixes before any code changes.</li> <li><strong><code class="language-plaintext highlighter-rouge">/finish-pr-comments</code></strong> - after fixes are applied and tested, commits the changes, pushes to the PR branch, and replies to each reviewer comment with what was fixed and the commit hash.</li> <li><strong><code class="language-plaintext highlighter-rouge">/merge-pr</code></strong> - squash-merges the PR into main, deletes the remote branch, and switches to an updated local main.</li> <li><strong><code class="language-plaintext highlighter-rouge">/plan-issue</code></strong> - similar to <code class="language-plaintext highlighter-rouge">plan-step</code> but for less frequent case when we start from an issue, not a roadmap’s step.</li> </ul> <p>There was also a GitHub Actions workflow that responds to <code class="language-plaintext highlighter-rouge">@claude</code> mentions in PRs and issues, enabling asynchronous interaction.</p> <p>The typical flow for a feature looked like this: I’d run <code class="language-plaintext highlighter-rouge">/plan-step</code> (or <code class="language-plaintext highlighter-rouge">/plan-issue</code>), review and adjust the plan, then let Claude implement it step by step while I reviewed each change. The key insight is that <strong>planning was always a separate, human-approved step</strong> before implementation began. Most of the work and brainstorming happened there.</p> <h3 id="codex-and-gemini-as-pr-reviewers">Codex and Gemini as PR reviewers</h3> <p>Every PR was also reviewed by <a href="https://chatgpt.com/codex">Codex</a> and <a href="https://ai.google.dev/">Gemini</a> triggered manually via mentions of <code class="language-plaintext highlighter-rouge">@codex</code> and <code class="language-plaintext highlighter-rouge">@gemini</code> in PR comments.</p> <p>Gemini was powered by a custom Python bot (<a href="https://github.com/kkokosa/dotLLM/blob/main/.github/scripts/gemini_bot.py"><code class="language-plaintext highlighter-rouge">.github/scripts/gemini_bot.py</code></a>) with retry logic and configurable thinking budgets. A separate <a href="https://github.com/kkokosa/dotLLM/blob/main/GEMINI.md">GEMINI.md</a> file defined its review persona.</p> <p>The review findings were not cosmetic. They caught genuinely critical bugs that could have shipped. Just as examples:</p> <ul> <li><strong>KV-cache quantization</strong> (PR #75): Codex caught a <strong>ring-buffer indexing bug</strong> (window reads used linear indexing instead of ring indices, producing garbage after wrap-around) or a <strong>pinned buffer scope issue</strong> (pointers from <code class="language-plaintext highlighter-rouge">fixed</code> blocks used after the scope exited), and a <strong>shared-state race condition</strong> (per-layer eviction progress stored in a shared counter)</li> <li><strong>JSON Schema constrained decoding</strong> (PR #79): Found a <strong>cache key collision</strong> (string substates not included in the hash, collapsing distinct parser states) and a <strong>unicode escape flag preservation bug</strong> (<code class="language-plaintext highlighter-rouge">\u</code> parsing wiped the key-string flag)</li> <li><strong>Wave 6 CUDA kernel rewrite</strong> (PR #114): Gemini identified thread underutilization in GEMV kernels and uncoalesced memory reads - architectural issues that Codex’s code-level analysis didn’t surface</li> </ul> <p>Once Codex and Gemini leave their comments, the remaining skills close the loop. <code class="language-plaintext highlighter-rouge">/apply-pr-comments</code> reads all review comments on the current PR, analyzes them, and enters plan mode — so I can approve which fixes to make before any code changes. This prevents blindly applying every suggestion without human judgment on what’s worth addressing versus deferring.</p> <p>After the fixes are implemented and tested, straightforward <code class="language-plaintext highlighter-rouge">/finish-pr-comments</code> commits the changes, pushes to the PR branch, and has the Claude Code bot (<code class="language-plaintext highlighter-rouge">dotllm-claude-code-bot</code>) reply to each reviewer comment with what was fixed and the corresponding commit hash.</p> <p>This creates a <strong>fully traceable chain</strong>: Codex/Gemini finds a bug -&gt; Claude fixes it -&gt; the reply references the exact commit. The PR thread becomes a complete audit trail.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/dotllm-codex-review-480.webp 480w,/assets/img/dotllm-codex-review-800.webp 800w,/assets/img/dotllm-codex-review-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/dotllm-codex-review.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Finally, <code class="language-plaintext highlighter-rouge">/merge-pr</code> squash-merges the PR into main, deletes the remote branch, and checks out an updated local main. The entire cycle - from roadmap step to merged PR - typically took one Claude Code session.</p> <h3 id="lessons-learned">Lessons learned</h3> <p><strong>What worked brilliantly:</strong></p> <ul> <li><strong>The implementation is correct.</strong> This is the most obvious finding to overlook. But here it is - this setup really worked! Step by step, we were progressing with the implementation, making it more and more sophisticated. And the moment when for the first time I saw an answer from the real model - it felt like magic! Reaching 80-90% of llama.cpp CPU decode performance is a thing, too.</li> <li><strong><code class="language-plaintext highlighter-rouge">ROADMAP.md</code> was the highest-ROI time investment.</strong> Planning structured documentation saved many hours of correcting misdirected AI implementation. The documentation <em>is</em> the development methodology - it’s not overhead, it’s the thing that makes AI-assisted development work at all. Without the roadmap, the AI has no sense of direction.</li> <li><strong>Two-role separation: implementation vs. review.</strong> Claude wrote the code. Codex and Gemini reviewed it independently. The AI that writes the code should not be the only AI that reviews it. Different models have genuinely different blind spots - Codex and Gemini almost never flagged the same issues.</li> <li><strong>No <a href="https://github.com/anthropics/claude-code/blob/main/plugins/ralph-wiggum/README.md">Ralph Wiggum loop</a> or other YOLO mode.</strong> I really wanted to <em>drive</em> the work, not <em>fire &amp; forget</em> it to burn tokens. It worked out perfectly for me. The single task took around several minutes, up to 30-40, so you could return to them from time to time, during breaks whilst waiting for something else. Great background work.</li> </ul> <p><strong>Where AI struggled:</strong></p> <ul> <li><strong>Fundamental architectural decisions.</strong> Which attention strategy to use, how to structure the GPU interop layer, whether to use PagedAttention kernels or staging-buffer gather - these required human judgment informed by comparing to llama.cpp, vLLM, and the research literature. AI can implement an architecture, but it’s less reliable at choosing between architectures.</li> <li><strong>The prefill throughput gap.</strong> This is the biggest remaining performance wall - but to be fair, it’s not really an AI failure per se. It might be AI struggling to workaround platform’s out-of-the-box behaviours. The 2-5x prefill gap you saw in the benchmarks comes down to one unfinished roadmap step: Step 26, the outer-product tiled matmul. The current batched GEMM (Step 11) uses an inner-product formulation - it tiles over M but still computes one output element at a time. It delivered a 2.2x prefill speedup when implemented. But the “outer-product” approach would share one activation load across all weight-row dot products: a 4x3 AVX2 tile with 12 YMM accumulators. Problem: due to AI that tile needs ~23 YMM registers, but AVX2 only has 16. RyuJIT spills to stack, and the spills eat the reuse benefit the tile was designed to get. AI was actually <em>very good</em> at experimenting here — it iterated through multiple tile sizes, inspected the JIT disassembly for spills, and methodically narrowed down the root cause. The options now seems to be: AVX-512 (32 ZMM registers, fits comfortably), a native C microkernel via <code class="language-plaintext highlighter-rouge">[LibraryImport]</code> (what llamafile/tinyBLAS does), or a smaller tile with partial reuse. I’ve left this for a much more manual, still AI-assisted, research phase - it needs careful benchmarking and disassembly analysis rather than the “plan step, implement, make PR” cadence that worked for the rest of the project.</li> <li><strong>Keeping it within the guiderails.</strong> Small but very annoying. No matter how many times, and in how many places, I ask him to avoid compound tool calls (esp. <code class="language-plaintext highlighter-rouge">cd D:\github\ai\dotLLM &amp;&amp; git ...</code>) it eventually ignores it, breaking the permissions. It breaks the flow, as instead of an autonomous work it got stuck waiting for permission for tools that otherwise should be accepted (like allowed GitHub read-only commands etc). Now I think I need pre-tool hook that checks that💡</li> <li><strong>Claude Code got really stuck 2-3 times.</strong> There were a few times when implementing a step took a lot of struggle. Implementing, retrying, failing, in a loop that took literally hours (!). But even more surprising is the fact that he eventually did it. With some of my help and brainstorming, but it was mostly his async work trying to figure it out.</li> </ul> <h2 id="whats-next">What’s next</h2> <p>dotLLM is at v0.1.0-preview.2 - explicitly a preview. The foundations are solid, but there’s a long road ahead:</p> <ul> <li><strong>Phase 7</strong> (in progress): Diagnostic hooks, logit lens, Sparse Autoencoders (SAE) integration, LoRA adapters</li> <li><strong>Phase 8</strong> (planned): MLA attention (DeepSeek), SmolLM3, Gemma 4, Mixture of Experts</li> <li><strong>Phase 9</strong> (planned): Production serving - continuous batching, prefix sharing, advanced scheduling. This will be fun🔥</li> </ul> <p>After those Phases I will consider feature set (mostly) done and start to focus on performance more and more.</p> <p>BTW, the project is <a href="https://github.com/kkokosa/dotLLM/blob/main/LICENSE">GPLv3</a> and contributions are welcome! There is still a lot to do. The codebase has 22 design docs, a detailed roadmap, and a CLAUDE.md that makes it easy for both human and AI contributors to get oriented quickly.</p> <ul> <li><strong>GitHub</strong>: <a href="https://github.com/kkokosa/dotLLM">github.com/kkokosa/dotLLM</a></li> <li><strong>Website</strong>: <a href="https://dotllm.dev/">dotllm.dev</a></li> <li><strong>NuGet packages</strong>: <code class="language-plaintext highlighter-rouge">DotLLM.Engine</code>, <code class="language-plaintext highlighter-rouge">DotLLM.Cpu</code>, <code class="language-plaintext highlighter-rouge">DotLLM.Cuda</code>, <code class="language-plaintext highlighter-rouge">DotLLM.Server</code>, and more</li> <li><strong>Discussions</strong>: <a href="https://github.com/kkokosa/dotLLM/discussions">GitHub Discussions</a></li> </ul> <h2 id="closing">Closing</h2> <p>Two things are true at once. First: .NET can do native, systems-level AI work. Zero-GC inference, SIMD-vectorized kernels, memory-mapped model loading, paged KV-cache, speculative decoding - all in C#. The platform is more capable than many give it credit for.</p> <p>Second: a solo developer can build something of this scope in two months with AI assistance - but only with relentless structure. The roadmap, the design docs, the CLAUDE.md constitution, the dual-review workflow - take any of these away and the productivity collapses. AI amplifies discipline; it doesn’t replace it.</p> <p>If you’re a .NET developer curious about LLM inference, or a researcher who wants to explore model internals from C#, <a href="https://github.com/kkokosa/dotLLM">give dotLLM a try</a>. File issues. Break things. Tell me what’s missing.</p>]]></content><author><name></name></author><category term="llm"/><category term="llm"/><category term="architecture"/><category term="tools"/><summary type="html"><![CDATA[How I built a ground-up LLM inference engine in .NET 10, and what I learned about AI-assisted (not vibe-coded) development along the way.]]></summary></entry><entry><title type="html">Visualizing logprobs from OpenAI responses</title><link href="https://kokosa.dev/blog/2026/logprobs/" rel="alternate" type="text/html" title="Visualizing logprobs from OpenAI responses"/><published>2026-02-03T09:00:00+01:00</published><updated>2026-02-03T09:00:00+01:00</updated><id>https://kokosa.dev/blog/2026/logprobs</id><content type="html" xml:base="https://kokosa.dev/blog/2026/logprobs/"><![CDATA[<p>In the <a href="/blog/2026/temperature/">previous post</a> we explored what <strong>logits</strong>, <strong>logprobs</strong> and <strong>temperature</strong> are. We learned that LLMs are actually outputting a probability distribution for every single token they generate.</p> <p>But looking at raw numbers and graphs is boring. Let’s see how we can use this data to visualize the model’s “confidence” and at least try to spot places when it is “unsure”.</p> <h3 id="getting-logprobs-from-api">Getting Logprobs from API</h3> <p>If you are using OpenAI API we need to set two parameters: <code class="language-plaintext highlighter-rouge">logprobs</code> to <code class="language-plaintext highlighter-rouge">true</code> and <code class="language-plaintext highlighter-rouge">top_logprobs</code> to some integer $k$ (e.g. <code class="language-plaintext highlighter-rouge">2</code> or <code class="language-plaintext highlighter-rouge">5</code>) to see top-$k$ alternatives. Information will be provided for <strong>all</strong> tokens from the response, as they were seen at the time of generation of each token.</p> <p>Here is what a raw sample HTTP request could look for a question <em>“Who is the President of Poland?”</em>:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://api.openai.com/v1/chat/completions <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$OPENAI_API_KEY</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{
    "model": "gpt-4o",
    "messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      },
      {
        "role": "user",
        "content": "Who is the president of Poland?"
      }
    ],
    "logprobs": true,
    "top_logprobs": 2
  }'</span>
</code></pre></div></div> <p>And here is a relevant snippet of the JSON response we get back. Notice the <code class="language-plaintext highlighter-rouge">logprobs</code> field inside <code class="language-plaintext highlighter-rouge">choices</code>:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"chatcmpl-123..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"object"</span><span class="p">:</span><span class="w"> </span><span class="s2">"chat.completion"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"created"</span><span class="p">:</span><span class="w"> </span><span class="mi">1677652288</span><span class="p">,</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"gpt-4o-2024-08-06"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"choices"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"role"</span><span class="p">:</span><span class="w"> </span><span class="s2">"assistant"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Andrzej Duda"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"refusal"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
        </span><span class="nl">"annotations"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">        
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"logprobs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"And"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.000035</span><span class="p">,</span><span class="w">
            </span><span class="nl">"top_logprobs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> 
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"And"</span><span class="p">,</span><span class="w"> </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.000035</span><span class="w"> </span><span class="p">},</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"The"</span><span class="p">,</span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-11.4</span><span class="w"> </span><span class="p">}</span><span class="w">
            </span><span class="p">]</span><span class="w">
          </span><span class="p">},</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rzej"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.00021</span><span class="p">,</span><span class="w">
            </span><span class="nl">"top_logprobs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rzej"</span><span class="p">,</span><span class="w"> </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.00021</span><span class="w"> </span><span class="p">},</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rew"</span><span class="p">,</span><span class="w">  </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-9.1</span><span class="w"> </span><span class="p">}</span><span class="w">
            </span><span class="p">]</span><span class="w">
          </span><span class="p">},</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">" D"</span><span class="p">,</span><span class="w"> </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.004</span><span class="p">,</span><span class="w">
            </span><span class="nl">"top_logprobs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">" D"</span><span class="p">,</span><span class="w"> </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.004</span><span class="w"> </span><span class="p">},</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">" Du"</span><span class="p">,</span><span class="w"> </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-6.2</span><span class="w"> </span><span class="p">}</span><span class="w">
            </span><span class="p">]</span><span class="w">
          </span><span class="p">},</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"uda"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.0012</span><span class="p">,</span><span class="w">
            </span><span class="nl">"top_logprobs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"uda"</span><span class="p">,</span><span class="w"> </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-0.0012</span><span class="w"> </span><span class="p">},</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"uda"</span><span class="p">,</span><span class="w"> </span><span class="nl">"logprob"</span><span class="p">:</span><span class="w"> </span><span class="mf">-7.5</span><span class="w"> </span><span class="p">}</span><span class="w">
            </span><span class="p">]</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"finish_reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stop"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="err">...</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>As the API returns <strong>logprobs</strong> (logarithmic probabilities), which are literally the natural logarithm of the probability $ p $. To get the actual probability percentage, we just need to calculate the exponential:</p> \[p = e^{\text{logprob}} \times 100\%\] <p>For example, a logprob of <code class="language-plaintext highlighter-rouge">-0.004</code> corresponds to $ e^{-0.004} \approx 0.996 $, or <strong>99.6%</strong>, and <code class="language-plaintext highlighter-rouge">-0.002</code> is as small as <strong>0.2%</strong>.</p> <p>Let’s visualize the probabilities for the “Andrzej Duda” tokens. In this case, the model is nearly 100% certain, and the second-best alternatives have almost zero probability.</p> <pre><code class="language-plotly">{
  "data": [
    {
      "x": ["And", "rzej", " D", "uda"],
      "y": [99.99, 99.98, 99.60, 99.88],
      "type": "bar",
      "marker": { "color": "#2ca02c" },
      "name": "Top Choice (%)"
    },
    {
      "x": ["And", "rzej", " D", "uda"],
      "y": [0.001, 0.01, 0.20, 0.05],
      "type": "bar",
      "marker": { "color": "#d62728" },
      "name": "Second Choice (%)"
    }
  ],
  "layout": {
    "title": "Top vs Second Token Probability",
    "barmode": "group",
    "yaxis": { "title": "Probability (%)", "range": [0, 110] },
    "paper_bgcolor": "rgba(0,0,0,0)",
    "plot_bgcolor": "rgba(0,0,0,0)"
  },
  "config": {
    "displayModeBar": false,
    "responsive": true
  }
}
</code></pre> <p>The model is very <strong>confident</strong> about the response “Andrzej Duda” tokens!</p> <blockquote class="block-info"> <p>I specifically chose this example to illustrate an important point: the model’s <strong>certainty</strong> about a given fact does not mean that it is a <strong>correct</strong> answer or <strong>not a hallucination</strong>. After the recent elections in 2025, Karol Nawrocki is the President of Poland. However, based on the data available to the model (and no RAG for up to date info), the model is certain that Andrzej Duda is still the President.</p> </blockquote> <h3 id="visualizing-token-clues">Visualizing “Token Clues”</h3> <p>Nevertheless, it may be interesting to see how confident model is about what <em>he thinks is true</em>. We could visualize some “token clues” to have a grasp of his internal thinking and spot some interesting tokens.</p> <p>I’ve implemented a simple <a href="https://github.com/kkokosa/devblog-code/tree/main/logprobs">Logprobs app</a> in C# to play with this. Here are the three main cues I found useful:</p> <h4 id="1-low-confidence-clue-uncertainty">1. Low Confidence Clue (“uncertainty”)</h4> <p>If the probability of the chosen token is low (e.g., $&lt; 10\%$ ), the model was very lucky - apparently due to the temperature it selected less probable token. Nevertheless, the effect is low confidence about what the model has selected. Our first condition can be:</p> \[p(token) &lt; 0.1\] <h4 id="2-ambiguity-close-alternatives">2. Ambiguity (close alternatives)</h4> <p>Sometimes the model may be fairly confident, but there is another option(s) just as good. I would call it *ambigue** it the model can choose from two or more token with similar probabilities. Out simplified condition can be:</p> \[p(top\_token) - p(second\_token) &lt; 0.15\] <h4 id="3-not-top-choice-sampling-effect">3. Not Top Choice (sampling effect)</h4> <p>If you use <code class="language-plaintext highlighter-rouge">temperature &gt; 0</code>, the model might sample a token that <strong>wasn’t</strong> the most probable one. This is a clear signal that the output is purely a result of sampling randomness (“creativity”). The condition would be:</p> \[token_{chosen} \neq token_{top}\] <h3 id="putting-it-all-together">Putting it all together</h3> <p>By highlighting text with these cues, we can get an “X-Ray” view of the generation.</p> <ul> <li><strong>Green</strong>: confidence &gt; 90%</li> <li><strong>Lime</strong>: confidence &gt; 70%</li> <li><strong>Yellowe</strong>: confidence &gt; 50%</li> <li><strong>Orange</strong>: condifence &gt; 30%</li> <li><strong>Red</strong>: Low confidence (&lt;30%)</li> <li><strong>Annotations</strong>: <code class="language-plaintext highlighter-rouge">🔀</code> as Ambiguous (point 2.), <code class="language-plaintext highlighter-rouge">🎯</code> as Not Top (point 3.)</li> </ul> <p>Imagine asking the model: <em>“Who is the president of Poland?”</em></p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/logprobs_query01-480.webp 480w,/assets/img/logprobs_query01-800.webp 800w,/assets/img/logprobs_query01-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/logprobs_query01.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Here, even a factual answer starts with some ambiguity because the model has multiple ways to phrase its knowledge cutoff disclaimer. Also, we can see the sentence starts as <em>“As of October”…</em> but with 75% it could be <em>“As of my”…</em>.</p> <p>The tool visualizes top-$k$ tokens, so beginning of the sentence in details looks like this:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/logprobs_query02-480.webp 480w,/assets/img/logprobs_query02-800.webp 800w,/assets/img/logprobs_query02-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/logprobs_query02.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Of course, we can expect some ambiguity when asking some questions, like <em>“Tabs or spaces? Single word answer.</em>”:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/logprobs_query03-480.webp 480w,/assets/img/logprobs_query03-800.webp 800w,/assets/img/logprobs_query03-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/logprobs_query03.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Clearly it prefers spaces…😇</p> <p>Or we can observe the <a href="https://x.com/infobeautiful/status/1783132545887953369">famous non-randomness</a> of random numbers:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/logprobs_query04-480.webp 480w,/assets/img/logprobs_query04-800.webp 800w,/assets/img/logprobs_query04-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/logprobs_query04.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Or some interesting “biases”:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/logprobs_query05-480.webp 480w,/assets/img/logprobs_query05-800.webp 800w,/assets/img/logprobs_query05-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/logprobs_query05.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Clearly, for more complex queries the answer will be having much more clues like this, but still interesting to observe:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/logprobs_query06-480.webp 480w,/assets/img/logprobs_query06-800.webp 800w,/assets/img/logprobs_query06-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/logprobs_query06.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p><em>Note: Here for example we can observe that with 33% chance he was going to use Markdown bold <code class="language-plaintext highlighter-rouge">**</code> when listing cat names.</em></p> <h3 id="a-note-on-hugging-face">A Note on Hugging Face</h3> <p>If you are running open-weights models locally (or in the cloud) using the <strong>Hugging Face</strong> <code class="language-plaintext highlighter-rouge">transformers</code> library, you can access the same information.</p> <p>When calling <code class="language-plaintext highlighter-rouge">generate()</code>, simply set <code class="language-plaintext highlighter-rouge">return_dict_in_generate=True</code> and <code class="language-plaintext highlighter-rouge">output_scores=True</code>. The returned object will contain <code class="language-plaintext highlighter-rouge">scores</code>, which are the raw <strong>logits</strong>. You can then apply <code class="language-plaintext highlighter-rouge">softmax</code> to convert them into probabilities.</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="nf">generate</span><span class="p">(</span>
    <span class="n">inputs</span><span class="p">,</span> 
    <span class="n">return_dict_in_generate</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> 
    <span class="n">output_scores</span><span class="o">=</span><span class="bp">True</span>
<span class="p">)</span>
<span class="c1"># outputs.scores contains logits for each generated token
</span></code></pre></div></div> <h3 id="conclusion">Conclusion</h3> <p>Logprobs give us a peek under the hood. They turn a “black box” text generator into a slightly more transparent probabilistic machine. It won’t help you to fight with hallucinations (as we seen with Andrzej Duda example), but at least can give some clues about what the model thinks!</p>]]></content><author><name></name></author><category term="llm"/><category term="llm"/><category term="basics"/><category term="tools"/><category term="experiments"/><summary type="html"><![CDATA[Let's visualize how LLM is confident about its own thoughts.]]></summary></entry><entry><title type="html">Logits, logprobs, and temperature</title><link href="https://kokosa.dev/blog/2026/temperature/" rel="alternate" type="text/html" title="Logits, logprobs, and temperature"/><published>2026-01-26T15:00:00+01:00</published><updated>2026-01-26T15:00:00+01:00</updated><id>https://kokosa.dev/blog/2026/temperature</id><content type="html" xml:base="https://kokosa.dev/blog/2026/temperature/"><![CDATA[<h3 id="vocabulary">Vocabulary</h3> <p>LLMs are smart “next token predictors”. Token by token, they generate responses, based on the probabilities of what token may come next, out of all tokens in the model’s <strong>vocabulary</strong>.</p> <p>For example, <code class="language-plaintext highlighter-rouge">gpt-4</code> has a vocabulary of 100278 tokens, each with its own ID (position) and value. We can see vocabulary as a very high-dimensional vector:</p> \[\mathbf{v} \in \mathbb{R}^{100278} = \overbrace{ [ \underbrace{\text{!}}_{id_0}, \underbrace{\text{'}}_{id_1}, \underbrace{\text{#}}_{id_2}, \cdots, \underbrace{\text{A}}_{id_{32}}, \underbrace{\text{B}}_{id_{33}}, \underbrace{\text{C}}_{id_{34}}, \cdots, \underbrace{\text{ webinars}}_{id_{100275}}, \underbrace{\text{gard}}_{id_{100276}}, \underbrace{\text{гӡ}}_{id_{100277}} ] }^{\text{100278 dimensions}}\] <p>Tokens have very different values. There are obvious ones like single letters, digits or special characters (as we see <code class="language-plaintext highlighter-rouge">!</code>, <code class="language-plaintext highlighter-rouge">#</code>, <code class="language-plaintext highlighter-rouge">A</code>, or <code class="language-plaintext highlighter-rouge">B</code> above), whole words (like <code class="language-plaintext highlighter-rouge">cat</code> or <code class="language-plaintext highlighter-rouge">window</code>) or parts (like <code class="language-plaintext highlighter-rouge">ing</code>, <code class="language-plaintext highlighter-rouge">urity</code>). Tokens are also case sensitive, and many of them have a version with a leading space. Thus, we can have four different tokens like <code class="language-plaintext highlighter-rouge">window</code>, <code class="language-plaintext highlighter-rouge">Window</code>, <code class="language-plaintext highlighter-rouge">_window</code> and <code class="language-plaintext highlighter-rouge">_Window</code> (I’ve used _ to denote whitespace for clarity).</p> <p>There are also some surprises hiding there. For example, the longest token in <code class="language-plaintext highlighter-rouge">gpt-4</code> represents 128 empty space (<code class="language-plaintext highlighter-rouge"> </code>) characters. And there are many similar, like with 96, 88 or 80 <code class="language-plaintext highlighter-rouge">*</code> stars (yes, coming from training on source code and other formatted texts). And the longest “word” is <code class="language-plaintext highlighter-rouge">.translatesAutoresizingMaskIntoConstraints</code>. You can use a tool like <a href="https://tiktokenizer.vercel.app/">https://tiktokenizer.vercel.app</a> to play with tokens online.</p> <h3 id="logits">Logits</h3> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/temperature_tokens.svg" sizes="95vw"/> <img src="/assets/img/temperature_tokens.svg" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>So, when at some point in time our LLM generated <code class="language-plaintext highlighter-rouge">A cat climbed the bookshelf and knocked over a vase. She looked</code>, it calculated the “odds” of every next possible token from its vocabulary. Some are obvious nonsense in such a context. We don’t expect tokens like <code class="language-plaintext highlighter-rouge">{</code>, <code class="language-plaintext highlighter-rouge">*******</code> or <code class="language-plaintext highlighter-rouge">.translatesAutoresizingMaskIntoConstraints</code> to be selected here. But some are much more “probable”, like <code class="language-plaintext highlighter-rouge">_guilty</code> or <code class="language-plaintext highlighter-rouge">_down</code> (note that most probably we need whitespace after the previous word).</p> <p>The output layer of LLM is producing a score for each token in the model’s vocabulary - so called <strong>logits</strong> (“log-odds,” the <strong>logarithm</strong> of the odds of each token). Let’s denote them as $l_i$ (a logit for a token $i$). They are raw, not normalized numerical predictions to represent model’s “confidence” about each token being the next in the sequence. And they are just numbers ranging from $-\infty$ to $+\infty$.</p> <p>At the last stage, LLM converts them to a probability distribution - where all values sum to 1.0 and have values between 0.0 and 1.0 - with the help of Softmax function, which looks like this:</p> \[p(x_i) = \text{softmax}(x_i) = \frac{e^{l_i}}{\sum_{j=1}^{n} e^{l_j}}\] <blockquote class="block-info"> <p>Note: Typically logits are also normalized by substracing their maximum value, to make calculations more stable.</p> </blockquote> <p>The idea behind Softmax function is simple - it uses the expotential function $e^x$ to produce non-negative value for each logit, from which we can then calculate its ratio against the sum of all other <em>expotentiated</em> logits.</p> <h3 id="temperature">Temperature</h3> <p>But here comes the simple trick called <strong>temperature</strong>. What if we would <em>scale</em> each logit before expotentiation, by some value $T$:</p> \[p(x_i) = \text{softmax}(x_i) = \frac{e^{ {l_i}/T}}{\sum_{j=1}^{n} e^{ {l_j}/T}}\] <p>Such scaling gives a nice consequence, visible on the below graph:</p> <ul> <li>for $T &gt; 1$ the curve is flattened, making differences between each exponentiated logit (relative probability) smaller. The bigger $T$, the smaller the differences between resulting probabilities</li> <li>for $T &lt; 1$ the curve becomes steeper, making bigger differences. The smaller $T$, the bigger the differences between the most probable logits (right side of the graph) vs the least probable ones (left side of the graph)</li> </ul> <pre><code class="language-plotly">{
  "data": [
    {
      "x": [-3, -2.5, -2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2, 2.5, 3],
      "y": [0.049, 0.082, 0.135, 0.223, 0.368, 0.606, 1, 1.648, 2.718, 4.481, 7.389, 12.182, 20.085],
      "type": "scatter",
      "mode": "lines+markers",
      "name": "e^x (T=1.0)"
    },
    {
      "x": [-3, -2.5, -2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2, 2.5, 3],
      "y": [0.002, 0.007, 0.018, 0.050, 0.135, 0.368, 1, 2.718, 7.389, 20.086, 54.598, 148.413, 403.429],
      "type": "scatter",
      "mode": "lines+markers",
      "name": "e^(x/0.5) (T=0.5)"
    },
    {
      "x": [-3, -2.5, -2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2, 2.5, 3],
      "y": [0.223, 0.287, 0.368, 0.472, 0.607, 0.779, 1, 1.284, 1.649, 2.117, 2.718, 3.490, 4.482],
      "type": "scatter",
      "mode": "lines+markers",
      "name": "e^(x/2.0) (T=2.0)"
    }
  ],
  "layout": {
    "title": "Effect of Temperature on Exponential Growth",
    "paper_bgcolor": "rgba(0,0,0,0)",
    "plot_bgcolor": "rgba(0,0,0,0)",
    "xaxis": {
      "title": "Logit value (x)"
    },
    "yaxis": {
      "title": "Exponential value (e^(x/T))",
      "range": [0, 20]
    }
  },
  "config": {
    "displayModeBar": false,
    "responsive": true
  }
}
</code></pre> <p>Let’s see how these temperatures affect the final probability distribution for a specific set of logit values like $2$, $1$, $-0.5$ and $-3$:</p> <ul> <li>$T=1.0$ - the standard Softmax distribution. We can treat it as our baseline</li> <li>$T=0.5$ (Low Temperature) - the probabilities become “sharper”. The highest logit (of value $2$) becomes significantly more probable (from ~$68\% \rightarrow 88\%$), while the lower ones are “suppressed”</li> <li>$T=2.0$ (High Temperature) - the probabilities become “flatter”. The gap between the most and least probable tokens becomes smaller, making the distribution more uniform</li> </ul> <pre><code class="language-plotly">{
  "data": [
    {
      "x": ["Token A (2.0)", "Token B (1.0)", "Token C (-0.5)", "Token D (-3.0)"],
      "y": [0.8756, 0.1185, 0.0059, 0.0000],
      "name": "T=0.5 (Sharper)",
      "type": "bar"
    },
    {
      "x": ["Token A (2.0)", "Token B (1.0)", "Token C (-0.5)", "Token D (-3.0)"],
      "y": [0.6865, 0.2525, 0.0564, 0.0046],
      "name": "T=1.0 (Standard)",
      "type": "bar"
    },
    {
      "x": ["Token A (2.0)", "Token B (1.0)", "Token C (-0.5)", "Token D (-3.0)"],
      "y": [0.5062, 0.3071, 0.1451, 0.0415],
      "name": "T=2.0 (Flatter)",
      "type": "bar"
    }
  ],
  "layout": {
    "title": "Probability Distribution vs Temperature",
    "paper_bgcolor": "rgba(0,0,0,0)",
    "plot_bgcolor": "rgba(0,0,0,0)",
    "barmode": "group",
    "xaxis": {
      "title": "Logits"
    },
    "yaxis": {
      "title": "Probability",
      "range": [0, 1]
    }
  },
  "config": {
    "displayModeBar": false,
    "responsive": true
  }
}
</code></pre> <p>What about $T=0.0$? Mathematically, we can’t divide by zero. So, in practice, LLMs treat this as a limit - as the temperature approaches zero, the distribution becomes infinitely sharp. At $0.0$ the model simply picks the token with the highest logit and gives it 100% probability, turning the “soft” max into a “hard” max (also called $argmax$ function).</p> <h3 id="logprobs">Logprobs</h3> <p>What we’ve seen so far is how the model calculates probabilities internally. But some models/providers allow us to observe the output of such probability calculations in the form of so-called <strong>logprobs</strong> (Log Probabilities, let’s denote them as $lp$). It’s possible via API call options like <code class="language-plaintext highlighter-rouge">IncludeLogProbabilities</code> or <code class="language-plaintext highlighter-rouge">Logbprobs</code> (we will play with them more in the upcoming blog post - <em>Update: it’s <a href="/blog/2026/logprobs/">already published</a></em>).</p> <p>Logprobs are natural logarithms of the probability ($ln(p)$). They are already normalized by the model’s Softmax and temperature internally - in other words, the Softmax and temperature have already happened <em>inside</em> the model when we receive them from the API. They take values from $-\infty$ to $0$ (always negative or zero), as they represent probabilities from the range $[0.0, 1.0]$. To calculate probabilities out of it we just need to call $e^x$ on them (inverse of natural logaritm). For example:</p> \[{lp}=0.0 \text{ so } p = e^{0.0} = 1.0 \text{ (100%)}\] \[{lp}=-0.69 \text{ so } p = e^{-0.69} \approx 0.5 \text{ (50%)}\] \[{lp}=-4.6 \text{ so } p = e^{-4.6} \approx 0.01 \text{ (1%)}\] <p>Let’s return to our <code class="language-plaintext highlighter-rouge">A cat climbed the bookshelf and knocked over a vase. She looked</code> example. When we ask for logprobs we will get them for all tokens in the sequence (including the last one, not yet shown here). We can treat logprobes as yet another high-dimensional vector of logprobs values for each token in the model’s vocabulary.</p> <p>Here’s the real example of logprobes returned for <code class="language-plaintext highlighter-rouge">gpt-4</code> model and the token after <code class="language-plaintext highlighter-rouge">looked</code> (13-th index in sequence):</p> \[\mathbf{L}_{13} \in \mathbb{R}^{100278} = \begin{pmatrix} {lp}_1 \\ {lp}_2 \\ {lp}_3 \\ \vdots \\ {lp}_{519} \\ {lp}_{520} \\ {lp}_{521} \\ \vdots \\ {lp}_{1203} \\ \vdots \\ {lp}_{1523} \\ \vdots \end{pmatrix} = \begin{pmatrix} -56.0 \\ -80.2 \\ -67.1 \\ \vdots \\ -81.2 \\ -1.11 \\ -91.2 \\ \vdots \\ -5.61 \\ \vdots \\ -4.11 \\ \vdots \end{pmatrix}\] <p>By mapping positions/ids of tokens around the highest values, we will see that they correspond to the following tokens:</p> \[[ \cdots, \underbrace{\text{ant}}_{id_{519}}, \underbrace{\text{ at}}_{id_{520}}, \underbrace{\text{ase}}_{id_{521}} \cdots, \underbrace{\text{ back}}_{id_{1203}}, \cdots, \underbrace{\text{ down}}_{id_{1523}}, \cdots, \underbrace{\text{ around}}_{id_{2212}}, \cdots, \underbrace{\text{ proud}}_{id_{12691}}, \cdots, \underbrace{\text{ surprised}}_{id_{14792}}, \cdots, \underbrace{\text{ guilty}}_{id_{16390}}, \cdots \underbrace{\text{ innocent}}_{id_{25226}}, \cdots ]\] <p>Not all tokens were presented in the above $\mathbf{L}$ but we see an example for tokens $519$ (<code class="language-plaintext highlighter-rouge">ant</code>, very low value), $520$ (<code class="language-plaintext highlighter-rouge">_at</code>, pretty high value) and $521$ (<code class="language-plaintext highlighter-rouge">_ase</code>, again with very low value.)</p> <p>And here’s the graph presenting values for those top 8 the highest valued logprobes:</p> <pre><code class="language-plotly">{
  "data": [
    {
      "x": [
        "innocent",
        "at",
        "proud",
        "surprised",
        "down",
        "around",
        "guilty",
        "back"
      ],
      "y": [
        -0.61,
        -1.11,
        -2.98,
        -3.86,
        -4.11,
        -4.48,
        -5.23,
        -5.61
      ],
      "name": "Logit",
      "type": "bar"
    }
  ],
  "layout": {
    "title": "Raw Logits",
    "paper_bgcolor": "rgba(0,0,0,0)",
    "plot_bgcolor": "rgba(0,0,0,0)"
  },
  "config": {
    "displayModeBar": false,
    "responsive": true
  }
}
</code></pre> <p>By just $p_i = e^{{lp}_i}$ we get corresponding probabilities:</p> <pre><code class="language-plotly">{
  "data": [
    {
      "x": [
        "innocent",
        "at",
        "proud",
        "surprised",
        "down",
        "around",
        "guilty",
        "back"
      ],
      "y": [
        0.5536,
        0.3358,
        0.0517,
        0.0215,
        0.0167,
        0.0115,
        0.0055,
        0.0037
      ],
      "name": "Probability",
      "type": "bar"
    }
  ],
  "layout": {
    "title": "Probabilities (T=1.0)",
    "paper_bgcolor": "rgba(0,0,0,0)",
    "plot_bgcolor": "rgba(0,0,0,0)",
    "xaxis": {
      "automargin": true
    },
    "yaxis": {
      "automargin": true
    }
  },
  "config": {
    "displayModeBar": false,
    "responsive": true
  }
}
</code></pre> <blockquote class="block-info"> <p>Note: Logprobes returned by API are truncated to top $n$ values, so calculated probabilities won’t sum to 1.0</p> </blockquote> <p>With the help of logprobes we can nicely visualize how temperature influences the resulting probabilities for our sentence:</p> <pre><code class="language-plotly">{
  "data": [
    {
      "x": [
        "innocent",
        "at",
        "proud",
        "surprised",
        "down",
        "around",
        "guilty",
        "back"
      ],
      "y": [
        0.3596,
        0.28,
        0.1099,
        0.0708,
        0.0625,
        0.0519,
        0.0357,
        0.0295
      ],
      "name": "T=2.0",
      "type": "bar"
    },
    {
      "x": [
        "innocent",
        "at",
        "proud",
        "surprised",
        "down",
        "around",
        "guilty",
        "back"
      ],
      "y": [
        0.5536,
        0.3358,
        0.0517,
        0.0215,
        0.0167,
        0.0115,
        0.0055,
        0.0037
      ],
      "name": "T=1.0",
      "type": "bar"
    },
    {
      "x": [
        "innocent",
        "at",
        "proud",
        "surprised",
        "down",
        "around",
        "guilty",
        "back"
      ],
      "y": [
        0.7248,
        0.2667,
        0.0063,
        0.0011,
        0.0007,
        0.0003,
        0.0001,
        0.0
      ],
      "name": "T=0.5",
      "type": "bar"
    },
    {
      "x": [
        "innocent",
        "at",
        "proud",
        "surprised",
        "down",
        "around",
        "guilty",
        "back"
      ],
      "y": [
        0.9933,
        0.0067,
        0.0,
        0.0,
        0.0,
        0.0,
        0.0,
        0.0
      ],
      "name": "T=0.1",
      "type": "bar"
    }
  ],
  "layout": {
    "title": "Temperature Comparison (T=1.0 vs T=0.5 vs T=0.1)",
    "paper_bgcolor": "rgba(0,0,0,0)",
    "plot_bgcolor": "rgba(0,0,0,0)",
    "xaxis": {
      "automargin": true
    },
    "yaxis": {
      "automargin": true
    },
    "barmode": "group"
  },
  "config": {
    "displayModeBar": false,
    "responsive": true
  }
}
</code></pre> <p>We clearly see, in the real-world example, that:</p> <ul> <li>by setting high temperature like $T = 2.0$ we make many tokens more probable. It could be interpreted as the model being “more creative” (or less constrained)</li> <li>by setting lower temperatures like $T = 0.1$ we definitely “boost” most probable tokens at the expense of the remaining tokens. The model becomes more predictable (“less creative”, if you wish)</li> </ul> <p>That’s it for now! In the next blog post we will dive into visualizing logprobs in a nicer way. We also have yet to cover sampling methods like “top-k” and “top-p”, but that’s also a topic for another day.</p>]]></content><author><name></name></author><category term="llm"/><category term="llm"/><category term="hyperparameter"/><category term="basics"/><summary type="html"><![CDATA[Deep dive into the famous temperature hyperparameter in LLMs.]]></summary></entry><entry><title type="html">Simple CLI REPL for Model Context Protocol (MCP)</title><link href="https://kokosa.dev/blog/2026/repl-mcp/" rel="alternate" type="text/html" title="Simple CLI REPL for Model Context Protocol (MCP)"/><published>2026-01-20T15:00:00+01:00</published><updated>2026-01-20T15:00:00+01:00</updated><id>https://kokosa.dev/blog/2026/repl-mcp</id><content type="html" xml:base="https://kokosa.dev/blog/2026/repl-mcp/"><![CDATA[<figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/repl-mcp-demo-480.webp 480w,/assets/img/repl-mcp-demo-800.webp 800w,/assets/img/repl-mcp-demo-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/repl-mcp-demo.gif" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Since many months I’ve been working with the <strong>Model Context Protocol (MCP)</strong> and I found myself frustrated with the available tools to easily access and test them. It is not not even for building your own servers, but just to connect to already made and look around what’s possible. I couldn’t find anything that quite suited my needs - something leightweight, simple to use and CLI-based. And I’m not a big fan of the standard <a href="https://modelcontextprotocol.io/docs/tools/inspector">MCP Inspector</a>.</p> <p>So, I <strong>decided to build my own</strong>: <a href="https://github.com/kkokosa/repl-mcp">https://github.com/kkokosa/repl-mcp</a> - a simple, interactive CLI tool for testing MCP servers.</p> <p>The idea was to have a lightweight, REPL-style (Read-Eval-Print Loop) environment where I could quickly poke at tools, prompts, and resources without any overhead.</p> <p>Key Features:</p> <ul> <li><strong>Interactive REPL</strong> - command history and tab autocompletion</li> <li><strong>Inspect Everything</strong> - list and inspect available tools, prompts, and resources</li> <li><strong>Call Tools</strong> - execute tools both with JSON or interactive parameter input</li> <li><strong>Transport Support</strong> - works with both Stdio and HTTP transports</li> <li><strong>Syntax Highlighting</strong> - clean, readable terminal output</li> </ul> <h3 id="examples">Examples</h3> <h4 id="simple-local-server-via-stdio---filesystem">Simple local server via <code class="language-plaintext highlighter-rouge">stdio</code> - filesystem</h4> <p>You can try it out with the <a href="https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem"><code class="language-plaintext highlighter-rouge">server-filesystem</code></a> MCP server. In this case we choose <code class="language-plaintext highlighter-rouge">--command</code> (or <code class="language-plaintext highlighter-rouge">-c</code>) option to run the server on our own (remember to <code class="language-plaintext highlighter-rouge">npm install</code> it beforehand):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>repl-mcp <span class="nt">-c</span> <span class="s2">"npx -y @modelcontextprotocol/server-filesystem /tmp"</span>
</code></pre></div></div> <h4 id="remote-server-with-api-token---github">Remote server with API token - GitHub</h4> <p>If we want to access remote MCP server, we can use <code class="language-plaintext highlighter-rouge">--url</code>/<code class="language-plaintext highlighter-rouge">-u</code> option, but <strong>bear in mind authorization</strong> - they (hopefuly!) require some kind of them.</p> <p>Some of them may accept API token, like in case of <a href="https://github.com/github/github-mcp-server">official GitHub MCP Server</a>, which we provide as <a href="https://swagger.io/docs/specification/v3_0/authentication/bearer-authentication/">Bearer token</a> (and use HTTP transport):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>repl-mcp <span class="nt">-u</span> https://api.githubcopilot.com/mcp/ <span class="nt">-t</span> http <span class="nt">-H</span> Authorization:<span class="s2">"Bearer github_pat_..."</span>
</code></pre></div></div> <h4 id="remote-servier-with-oauth---atlassian">Remote servier with OAuth - Atlassian</h4> <p>Many MCP servers rely on <a href="https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow">OAuth 2.x flow</a> to authorize the user, which is more tricky. In such case we need to support the full OAuth flow, which requires exposing a public endpoint to the Internet (point 8 in the link), to log in to the given service and let the callback return to us. That could be responsibility of my <code class="language-plaintext highlighter-rouge">repl-mcp</code> but there’s already a handy <a href="https://www.npmjs.com/package/mcp-remote"><code class="language-plaintext highlighter-rouge">mcp-remote</code></a> proxy MCP server.</p> <p>Let’s see in action while trying to use <a href="https://www.atlassian.com/platform/remote-mcp-server">Atlassian Remote MCP Server</a>. It is hosted at <code class="language-plaintext highlighter-rouge">https://mcp.atlassian.com/v1/mcp</code> and requires OAuth 2.1 authorization flow. “Supported clients” like ChatGPT, Claude or Google Gemini handle authorization flow out of the box. But as we can <a href="https://support.atlassian.com/atlassian-rovo-mcp-server/docs/getting-started-with-the-atlassian-remote-mcp-server/">read in the docs</a>, their MCP server: <em>“also supports any <strong>local MCP-compatible</strong> client that can run on <code class="language-plaintext highlighter-rouge">localhost</code> and connect to the server via the <code class="language-plaintext highlighter-rouge">mcp-remote</code> proxy”</em>.</p> <p>This is exactly what we will do! Here are the steps:</p> <ol> <li>run <code class="language-plaintext highlighter-rouge">mcp-remote</code> proxy: <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx mcp-remote https://mcp.atlassian.com/v1/mcp
</code></pre></div> </div> </li> <li>see what port it is using, like <code class="language-plaintext highlighter-rouge">Using existing client port: 3736</code></li> <li>run <a href="https://ngrok.com/">ngrok</a> or similar tool to expose the port: <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok.exe http --domain some-words-here.ngrok-free.app http://localhost:3736
</code></pre></div> </div> </li> <li>also, we need to configure Atlassian to accept/trust our callback domain - in <a href="https://admin.atlassian.com/">Administration panel</a>, we need to add our [[ngrok]]-based domain to <em>Apps -&gt; AI settings -&gt; Rovo MCP server</em> like <code class="language-plaintext highlighter-rouge">https://some-words-here.ngrok-free.app/**</code></li> <li>go to the link printed in the <code class="language-plaintext highlighter-rouge">mcp-remote</code>, something like: <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>...
Please authorize this client by visiting:
https://mcp.atlassian.com/v1/authorize?response_type=code&amp;client_id=Y...
...
</code></pre></div> </div> </li> <li>After logging in/authorizing, the callback will be send (via ngrok) to the mcp-proxy and received token will be stored. Close <code class="language-plaintext highlighter-rouge">mcp-remote</code>. <strong>We won’t need to repeat it every time.</strong></li> <li>Run the proxy under <code class="language-plaintext highlighter-rouge">repl-mcp</code> and enjoy your Atlassian access! <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>repl-mcp <span class="nt">-c</span> <span class="s2">"npx mcp-remote https://mcp.atlassian.com/v1/mcp"</span>
</code></pre></div> </div> </li> </ol>]]></content><author><name></name></author><category term="tools"/><category term="mcp"/><summary type="html"><![CDATA[I've decided to build my own lightweight, REPL-style (Read-Eval-Print Loop) MCP testing tool.]]></summary></entry></feed>