<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged benchmarking</title>
<link>https://coderlegion.com/tag/benchmarking</link>
<description>Powered by Question2Answer</description>
<item>
<title>Benchmarking the FSM: Pure Strings, Already Fast — and About to Get Faster</title>
<link>https://coderlegion.com/26350/benchmarking-the-fsm-pure-strings-already-fast-and-about-to-get-faster</link>
<description>&lt;h2 id=&quot;benchmarking-the-fsm-pure-strings-already-fast-and-about-to-get-faster&quot;&gt;Benchmarking the FSM: Pure Strings, Already Fast — and About to Get Faster&lt;/h2&gt;
&lt;p&gt;There's a particular reaction you tend to get when you tell an experienced systems programmer that your architecture is string-backed:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Don't use strings.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It's almost reflexive.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=15571909521889431178&quot; alt=&quot;Stick figure teacher shown pointing at the white board where each white board has a different rule written on it.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Use enums.&lt;/p&gt;
&lt;p&gt;Use integers.&lt;/p&gt;
&lt;p&gt;Use precomputed IDs.&lt;/p&gt;
&lt;p&gt;Avoid dictionary lookups.&lt;/p&gt;
&lt;p&gt;Watch the garbage collector.&lt;/p&gt;
&lt;p&gt;And, above all, don't put strings anywhere near a hot loop.&lt;/p&gt;
&lt;p&gt;There's good engineering history behind those instincts. Strings &lt;em&gt;can&lt;/em&gt; be expensive. Poorly designed string-heavy systems can generate allocations, create unnecessary comparisons, and turn perfectly reasonable abstractions into performance problems.&lt;/p&gt;
&lt;p&gt;But there's a problem with rules of thumb:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Eventually, you have to measure.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;So that's what I did.&lt;/p&gt;
&lt;p&gt;The current FSM_API is &lt;strong&gt;purely string-backed&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Not string-backed with an integer cache hiding underneath.&lt;/p&gt;
&lt;p&gt;Not enums converted to strings at the API boundary.&lt;/p&gt;
&lt;p&gt;Not generated IDs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strings.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And I wanted to know what that actually costs.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-baseline&quot;&gt;The Baseline&lt;/h2&gt;
&lt;p&gt;Using BenchmarkDotNet, I measured the cost of updating active process groups as the workload increased from one group to ten and then to fifty.&lt;/p&gt;
&lt;p&gt;The results:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Method                 &lt;/th&gt;&lt;th&gt;Active Process Groups &lt;/th&gt;&lt;th&gt;       Mean &lt;/th&gt;&lt;th&gt;    Error &lt;/th&gt;&lt;th&gt;   StdDev &lt;/th&gt;&lt;th&gt; Gen 0 &lt;/th&gt;&lt;th&gt;Allocated&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;UpdateMultipleGroups&lt;/code&gt; &lt;/td&gt;&lt;td&gt;                    1 &lt;/td&gt;&lt;td&gt;   305.1 ns &lt;/td&gt;&lt;td&gt;  1.89 ns &lt;/td&gt;&lt;td&gt;  1.76 ns &lt;/td&gt;&lt;td&gt;0.0572 &lt;/td&gt;&lt;td&gt;    360 B&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;UpdateMultipleGroups&lt;/code&gt; &lt;/td&gt;&lt;td&gt;                   10 &lt;/td&gt;&lt;td&gt; 3,115.7 ns &lt;/td&gt;&lt;td&gt; 20.14 ns &lt;/td&gt;&lt;td&gt; 18.84 ns &lt;/td&gt;&lt;td&gt;0.5722 &lt;/td&gt;&lt;td&gt;  3,600 B&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;UpdateMultipleGroups&lt;/code&gt; &lt;/td&gt;&lt;td&gt;                   50 &lt;/td&gt;&lt;td&gt;15,736.6 ns &lt;/td&gt;&lt;td&gt;121.61 ns &lt;/td&gt;&lt;td&gt;107.81 ns &lt;/td&gt;&lt;td&gt;2.8687 &lt;/td&gt;&lt;td&gt; 18,000 B&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The first number is the one that gets your attention.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5632822310681256007&quot; alt=&quot;Stick figure teacher pointing at whiteboard with the results drawn out in a table on it.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;305 nanoseconds.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's the measured cost of updating one active process group.&lt;/p&gt;
&lt;p&gt;A 60 FPS application has approximately &lt;strong&gt;16.67 milliseconds&lt;/strong&gt; available for an entire frame.&lt;/p&gt;
&lt;p&gt;That's 16,670,000 nanoseconds.&lt;/p&gt;
&lt;p&gt;The measured FSM operation therefore consumes approximately &lt;strong&gt;0.0018% of that frame budget&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;However, the more interesting result isn't the 305 ns.&lt;/p&gt;
&lt;p&gt;It's what happens when I increase the workload.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;fifty-groups-fifteen-microseconds-&quot;&gt;Fifty Groups. Fifteen Microseconds.&lt;/h2&gt;
&lt;p&gt;At ten active process groups, the benchmark reports:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3.12 µs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;At fifty:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;15.74 µs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's approximately &lt;strong&gt;0.0157 milliseconds&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Against a 16.67 ms frame budget, fifty active process groups consume approximately:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;0.094% of one frame.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's not a theoretical &quot;eventually it should scale&quot; statement.&lt;/p&gt;
&lt;p&gt;That's measured behavior.&lt;/p&gt;
&lt;p&gt;And the scaling is almost perfectly linear.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=17829156475947039196&quot; alt=&quot;Stick figure teacher with white board showing near linear scaling!&quot;&gt;&lt;/p&gt;
&lt;p&gt;One group:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;305 ns&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Ten groups:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3.12 µs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Fifty groups:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;15.74 µs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The architecture isn't encountering some mysterious performance cliff as the workload grows.&lt;/p&gt;
&lt;p&gt;More groups mean more work.&lt;/p&gt;
&lt;p&gt;Approximately proportionally more work.&lt;/p&gt;
&lt;p&gt;That's exactly what we want from this kind of system.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-fifty-&quot;&gt;Why Fifty?&lt;/h2&gt;
&lt;p&gt;Fifty isn't a magic number.&lt;/p&gt;
&lt;p&gt;It isn't a claim that fifty process groups represents the maximum this FSM_API can handle.&lt;/p&gt;
&lt;p&gt;It doesn't.&lt;/p&gt;
&lt;p&gt;It is simply a useful workload for demonstrating what happens when we stop talking about one isolated state machine and start talking about &lt;strong&gt;multiple active systems being serviced together&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That's an important distinction.&lt;/p&gt;
&lt;p&gt;A single-state benchmark can make almost anything look fast.&lt;/p&gt;
&lt;p&gt;Real software rarely gets the luxury of doing only one thing.&lt;/p&gt;
&lt;p&gt;A game might have character state, animation state, weapon state, UI state, navigation state, interaction state, environmental state, and networking state all operating simultaneously.&lt;/p&gt;
&lt;p&gt;An application outside of games has its own collection of concurrent concerns.&lt;/p&gt;
&lt;p&gt;The useful question therefore becomes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens when we start stacking these things together?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In this benchmark, the answer is refreshingly boring.&lt;/p&gt;
&lt;p&gt;The cost grows with the workload.&lt;/p&gt;
&lt;p&gt;It doesn't suddenly explode.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-this-benchmark-does-and-doesn-t-prove&quot;&gt;What This Benchmark Does — and Doesn't — Prove&lt;/h2&gt;
&lt;p&gt;I believe it is worth being precise here.&lt;/p&gt;
&lt;p&gt;This benchmark is measuring the cost of the FSM's process-group update machinery.&lt;/p&gt;
&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; claiming that the actual work performed by your states is free.&lt;/p&gt;
&lt;p&gt;If your state executes an expensive pathfinding operation, performs a large physics calculation, processes network data, or does something computationally expensive, the FSM doesn't magically make that work disappear.&lt;/p&gt;
&lt;p&gt;That's not what we're measuring.&lt;/p&gt;
&lt;p&gt;We're measuring the machinery responsible for organizing and updating those processes.&lt;/p&gt;
&lt;p&gt;And that machinery is currently taking approximately fifteen microseconds for fifty active groups.&lt;/p&gt;
&lt;p&gt;That distinction is important because abstractions are often blamed for the work they organize.&lt;/p&gt;
&lt;p&gt;A state machine can be blamed for a slow AI routine simply because the AI routine happens to execute inside a state.&lt;/p&gt;
&lt;p&gt;This benchmark lets us separate those concerns.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The abstraction has a cost.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I measured it.&lt;/p&gt;
&lt;p&gt;And at this scale, that cost is fairly small.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;but-there-s-something-important-hiding-in-that-table&quot;&gt;But There's Something Important Hiding in That Table&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6106992878403153157&quot; alt=&quot;Stick figure teacher pointing at whiteboard with the cost of strings drawn out on it.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The benchmark also reports allocation.&lt;/p&gt;
&lt;p&gt;One group:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;360 bytes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Ten:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3,600 bytes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Fifty:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;18,000 bytes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That is approximately &lt;strong&gt;360 bytes per active process group per update&lt;/strong&gt; under this benchmark.&lt;/p&gt;
&lt;p&gt;At 60 FPS, if fifty groups were updated every frame, that corresponds to:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;approximately 1.08 MB of managed allocation per second.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That isn't catastrophic.&lt;/p&gt;
&lt;p&gt;But it isn't zero either.&lt;/p&gt;
&lt;p&gt;And this is where the benchmark becomes particularly useful.&lt;/p&gt;
&lt;p&gt;Because I'm not finished...&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;this-is-the-performance-baseline-not-the-destination&quot;&gt;This Is the Performance Baseline — Not the Destination&lt;/h2&gt;
&lt;p&gt;The FSM_API architecture is currently &lt;strong&gt;fully string-backed&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=13634934897178831906&quot; alt=&quot;Stick figure shouting 'All Aboard' a train heading from string backing to integer backing.&quot;&gt;&lt;/p&gt;
&lt;p&gt;That's important because the numbers above aren't the result of some hidden integer representation doing the real work underneath.&lt;/p&gt;
&lt;p&gt;The strings are actually part of the machinery being measured.&lt;/p&gt;
&lt;p&gt;And that's intentional.&lt;/p&gt;
&lt;p&gt;One of the architectural goals of FSM_API has always been to make state management expressive and discoverable.&lt;/p&gt;
&lt;p&gt;States can have meaningful names.&lt;/p&gt;
&lt;p&gt;Process groups can be identified without forcing the developer to maintain a parallel universe of integer constants.&lt;/p&gt;
&lt;p&gt;The API remains readable.&lt;/p&gt;
&lt;p&gt;The architecture remains flexible.&lt;/p&gt;
&lt;p&gt;And now we have measured what that flexibility costs.&lt;/p&gt;
&lt;p&gt;The answer is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Not very much.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;But there is another conclusion hiding in that result.&lt;/p&gt;
&lt;p&gt;If this is what the system can do &lt;strong&gt;while paying the cost of its string-backed implementation&lt;/strong&gt;, then we now have a very interesting optimization target.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-next-version-removes-the-string-cost&quot;&gt;The Next Version Removes the String Cost&lt;/h2&gt;
&lt;p&gt;I'm currently refactoring the underlying implementation so that the FSM can retain its &lt;strong&gt;string-facing architecture and developer experience while moving the internal backing representation toward integer indexing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's an important distinction.&lt;/p&gt;
&lt;p&gt;The goal isn't to tell developers:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Sorry, strings are slow. Go back and rewrite your application using integers.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The goal is the opposite.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keep the expressive interface. Optimize what happens underneath it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The developer can continue thinking in terms of:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&quot;Idle&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&quot;Running&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&quot;Paused&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&quot;Processing&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;while the machinery underneath increasingly has the opportunity to operate on compact integer representations.&lt;/p&gt;
&lt;p&gt;That's the architecture we're moving toward.&lt;/p&gt;
&lt;p&gt;And the benchmark above gives us something extremely valuable:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;a control group.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We already know what the purely string-backed implementation costs.&lt;/p&gt;
&lt;p&gt;When the integer-backed implementation is complete, we can run the same benchmark again.&lt;/p&gt;
&lt;p&gt;Same workload.&lt;/p&gt;
&lt;p&gt;Same benchmark harness.&lt;/p&gt;
&lt;p&gt;Same measurement methodology.&lt;/p&gt;
&lt;p&gt;Then we can put the two implementations beside each other and see exactly what the architectural change bought us.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;we-can-already-see-the-shape-of-the-opportunity&quot;&gt;We Can Already See the Shape of the Opportunity&lt;/h2&gt;
&lt;p&gt;We shouldn't manufacture a performance number before we have measured it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=17348225345711217563&quot; alt=&quot;Stick figure reviewing the shape of opportunity when we go from string backing to integer backing.&quot;&gt;&lt;/p&gt;
&lt;p&gt;That's one of the reasons I like this experiment.&lt;/p&gt;
&lt;p&gt;We have a baseline.&lt;/p&gt;
&lt;p&gt;We have a known workload.&lt;/p&gt;
&lt;p&gt;We have known execution times.&lt;/p&gt;
&lt;p&gt;We have known allocations.&lt;/p&gt;
&lt;p&gt;Now we have something to beat.&lt;/p&gt;
&lt;p&gt;The current 50-group benchmark takes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;15,736.6 ns&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;and allocates:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;18,000 bytes.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Suppose, purely as a thought experiment, the integer-backed implementation reduced execution time by 25%.&lt;/p&gt;
&lt;p&gt;That would take the measured operation from approximately:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;15.7 µs → 11.8 µs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A 50% reduction would produce:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;15.7 µs → 7.9 µs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A 75% reduction would produce:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;15.7 µs → 3.9 µs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Those aren't predictions.&lt;/p&gt;
&lt;p&gt;They're not marketing numbers.&lt;/p&gt;
&lt;p&gt;They're simply showing the size of the optimization space we're about to measure.&lt;/p&gt;
&lt;p&gt;And the allocation story could be even more interesting.&lt;/p&gt;
&lt;p&gt;If the integer-backed representation eliminates allocations currently associated with string construction or other string-oriented work in the measured path, the memory profile could change substantially even if the raw execution-time improvement turns out to be modest.&lt;/p&gt;
&lt;p&gt;That matters for systems that run continuously.&lt;/p&gt;
&lt;p&gt;A few microseconds once isn't particularly interesting.&lt;/p&gt;
&lt;p&gt;A few microseconds and a small allocation repeated every frame, across hundreds or thousands of objects, is a different conversation.&lt;/p&gt;
&lt;p&gt;This is where optimization becomes architecture.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-goal-isn-t-integer-good-string-bad-&quot;&gt;The Goal Isn't &quot;Integer Good, String Bad&quot;&lt;/h2&gt;
&lt;p&gt;This is probably the most important point in the entire experiment.&lt;/p&gt;
&lt;p&gt;I'm not refactoring FSM_API because the string-backed implementation failed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It didn't.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It's fast.&lt;/p&gt;
&lt;p&gt;It scales predictably.&lt;/p&gt;
&lt;p&gt;It's readable.&lt;/p&gt;
&lt;p&gt;It's flexible.&lt;/p&gt;
&lt;p&gt;And it has now survived measurement.&lt;/p&gt;
&lt;p&gt;The integer-backed refactor is about taking an architecture that is already useful and asking:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can we preserve everything developers like about it while making the machinery underneath even cheaper?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's a much more interesting engineering problem.&lt;/p&gt;
&lt;p&gt;We're not sacrificing usability to chase a benchmark score.&lt;/p&gt;
&lt;p&gt;We're trying to make the implementation disappear even further beneath the abstraction.&lt;/p&gt;
&lt;p&gt;The developer shouldn't have to care whether the machine underneath is using strings, integers, tables, caches, or some future representation we haven't invented yet.&lt;/p&gt;
&lt;p&gt;They should be able to describe their state.&lt;/p&gt;
&lt;p&gt;The machine should do the rest.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;and-this-is-where-don-t-use-strings-gets-interesting&quot;&gt;And This Is Where &quot;Don't Use Strings&quot; Gets Interesting&lt;/h2&gt;
&lt;p&gt;The benchmark doesn't prove that strings are universally fast.&lt;/p&gt;
&lt;p&gt;They aren't.&lt;/p&gt;
&lt;p&gt;It proves something much more useful:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A carefully engineered string-backed FSM does not automatically become a performance problem.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Our current implementation can update fifty active process groups in approximately fifteen microseconds.&lt;/p&gt;
&lt;p&gt;That's fast enough that the abstraction itself occupies less than one tenth of one percent of a 60 FPS frame in this benchmark.&lt;/p&gt;
&lt;p&gt;And that's before the integer-backed optimization is finished.&lt;/p&gt;
&lt;p&gt;So perhaps the better engineering rule isn't:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Don't use strings.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Maybe it's:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Don't assume. Measure.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If the strings are costing us something measurable, optimize them.&lt;/p&gt;
&lt;p&gt;If they aren't costing us enough to matter, spend our engineering effort somewhere more valuable.&lt;/p&gt;
&lt;p&gt;And if we can preserve the developer-facing benefits of strings while removing much of their runtime cost?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10517863792517626526&quot; alt=&quot;Stick figure observing tradeoffs between string backing and integer backing&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Then we've got the best of both worlds.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-benchmark-becomes-part-of-the-architecture&quot;&gt;The Benchmark Becomes Part of the Architecture&lt;/h2&gt;
&lt;p&gt;This is ultimately why I like this benchmark more than a simple performance chart.&lt;/p&gt;
&lt;p&gt;It isn't a victory lap.&lt;/p&gt;
&lt;p&gt;It's a timestamp.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This is what the FSM costs today.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Pure strings.&lt;/p&gt;
&lt;p&gt;305 ns for one group.&lt;/p&gt;
&lt;p&gt;3.12 µs for ten.&lt;/p&gt;
&lt;p&gt;15.74 µs for fifty.&lt;/p&gt;
&lt;p&gt;360 bytes per group under the measured workload.&lt;/p&gt;
&lt;p&gt;Linear scaling.&lt;/p&gt;
&lt;p&gt;And now we're changing the internal representation.&lt;/p&gt;
&lt;p&gt;So we'll measure it again.&lt;/p&gt;
&lt;p&gt;The interesting question isn't whether the next implementation is faster.&lt;/p&gt;
&lt;p&gt;It almost certainly should be.&lt;/p&gt;
&lt;p&gt;The interesting question is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much faster?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And more importantly:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much allocation can we eliminate without sacrificing the API and architectural flexibility that made the string-backed design worthwhile in the first place?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's something worth measuring.&lt;/p&gt;
&lt;p&gt;And when those numbers are available, we'll have something better than a benchmark claim.&lt;/p&gt;
&lt;p&gt;We'll have an &lt;strong&gt;A/B measurement of the architecture itself.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The string-backed FSM already flies.&lt;/p&gt;
&lt;p&gt;Now we're taking the strings out of the engine and finding out just how much faster it can fly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;you-don-t-have-to-take-my-word-for-it&quot;&gt;You Don't Have to Take My Word for It&lt;/h2&gt;
&lt;p&gt;And here's the part where the benchmark stops being an article and becomes an invitation.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=3726050540135857480&quot; alt=&quot;Stick figure seen trying the API out for themselves&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The FSM_API is available now.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You don't have to agree with me about strings.&lt;/p&gt;
&lt;p&gt;You don't have to take the 305 ns number on faith.&lt;/p&gt;
&lt;p&gt;You don't even have to like the architecture.&lt;/p&gt;
&lt;p&gt;Install it.&lt;/p&gt;
&lt;p&gt;Build something with it.&lt;/p&gt;
&lt;p&gt;Put it under your own workload.&lt;/p&gt;
&lt;p&gt;Benchmark it against whatever you're currently using.&lt;/p&gt;
&lt;p&gt;Then tell me what happens.&lt;/p&gt;
&lt;p&gt;That's one of the reasons I publish these experiments in the first place.&lt;/p&gt;
&lt;p&gt;The goal isn't to tell developers what they &lt;em&gt;should&lt;/em&gt; believe.&lt;/p&gt;
&lt;p&gt;The goal is to build the tooling, expose the architecture, measure the results, and let other engineers kick the tires.&lt;/p&gt;
&lt;p&gt;And if you find a workload where it falls over?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I want to know about that too.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Because that's useful data.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Resources &amp;amp; Code:&lt;/p&gt;
&lt;p&gt;The FSM Package (Unity Asset Store):&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://assetstore.unity.com/packages/slug/332450&quot;&gt;https://assetstore.unity.com/packages/slug/332450&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;NuGet Package (Non-Unity Core):&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_API&quot;&gt;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GitHub Repository:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/FSM_API&quot;&gt;https://github.com/TrentBest/FSM_API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Support Our Work:&lt;/p&gt;
&lt;p&gt;Patreon Page:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/c/TheSingularityWorkshop&quot;&gt;https://www.patreon.com/c/TheSingularityWorkshop&lt;/a&gt;&lt;/p&gt;
&lt;p&gt; Support Us (PayPal Donation):&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.paypal.com/donate/?hosted_button_id=3Z7263LCQMV9J&quot;&gt;https://www.paypal.com/donate/?hosted_button_id=3Z7263LCQMV9J&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Please Like this post, Love the code, and Share your experience in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11780617275403541958&quot; alt=&quot;A Hyper-Detailed, Architecturally Impossible Synthesis of Consciousness and Digital Matter. The image is a frenetic, deeply complex digital vista where a central Luminous Eye dominates a vast, glowing circuit landscape. This eye, suspended mid-air, is a sphere of intense, fractal energy—a pulsating vortex of pink, violet, and electric blue light—that powerfully suggests an emergence of digital consciousness or a Technological Singularity itself. The core is a bottomless black aperture, ringed by a white-hot plasma disc. Below this ocular energy source, the light dissipates into an intricate, copper-gold and neon-blue Circuit Board Megastructure that stretches to the horizon, impossibly dense with exaggerated microchips, glowing resistor arrays, and power conduits that form deep, glowing canyons. The background is a dark, holographic projection field displaying complex schematics, mathematical models, and flowing data streams, reinforcing the theme of Absolute Digital Engineering. The entire scene is bathed in a dramatic, opposing light source—a warm, orange-gold glow from the left and an ice-cold, electric-blue glow from the right—creating a maximalist, high-contrast visual experience that definitively rejects the minimalist simplicity of conventional design and announces: Technology is intricate, overwhelming, and infinitely complex.&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26350/benchmarking-the-fsm-pure-strings-already-fast-and-about-to-get-faster</guid>
<pubDate>Sun, 06 Sep 2026 19:41:30 +0000</pubDate>
</item>
<item>
<title>The Compiler Got 5% Slower. The Benchmark Called It a 10% Regression a Quarter of the Time.</title>
<link>https://coderlegion.com/26245/the-compiler-got-5-slower-the-benchmark-called-it-a-10-regression-a-quarter-of-the-time</link>
<description>&lt;p&gt;IBM's Benchpress pins the seeds that build its circuits and passes no seed to the code that compiles them. On one circuit, over 400 seeds per version, the real change between two Qiskit releases is +5.37%. The suite's own three-run protocol reports it as a ≥10% regression 24.4% of the time. Twenty runs per version — about forty hours of compute — still leaves 3.7%.&lt;/p&gt;
&lt;p&gt;A benchmark suite is an instrument. You point it at two versions of a compiler and it tells you whether the new one got worse. Somebody then merges or reverts a pull request on the strength of that answer.&lt;/p&gt;
&lt;p&gt;So the question I care about is not &quot;is this compiler slower&quot;. It is &lt;strong&gt;how often does the instrument give an answer that disagrees with its own long-run behaviour&lt;/strong&gt;. I call that &lt;strong&gt;finite-sample decision risk&lt;/strong&gt;: the probability that a verdict computed from &lt;em&gt;k&lt;/em&gt; runs per version disagrees with the verdict implied by the mean of the same measurement.&lt;/p&gt;
&lt;h3 id=&quot;where-the-randomness-comes-from&quot;&gt;Where the randomness comes from&lt;/h3&gt;
&lt;p&gt;Qiskit's transpiler is stochastic. Its routing pass uses randomness, and &lt;code&gt;generate_preset_pass_manager&lt;/code&gt; takes a &lt;code&gt;seed_transpiler&lt;/code&gt; argument to pin it.&lt;/p&gt;
&lt;p&gt;At the pinned revision I measured, Benchpress's Qiskit gym does not pass it. Circuit &lt;em&gt;construction&lt;/em&gt; seeds are fixed — &lt;code&gt;seed=12345&lt;/code&gt;, fourteen occurrences. The &lt;em&gt;compilation&lt;/em&gt; seed is not. So every gate count the suite reports is one draw from a distribution nobody measured.&lt;/p&gt;
&lt;p&gt;One detail matters for honesty here, and I got it wrong in an earlier draft: this is not universal across the suite. The BQSKit gym in the same repository &lt;em&gt;does&lt;/em&gt; seed its compiler, with &lt;code&gt;seed=0&lt;/code&gt;. &lt;code&gt;seed_transpiler&lt;/code&gt; is a Qiskit-specific API the other gyms cannot call. The claim is about the Qiskit gym, and only about it.&lt;/p&gt;
&lt;h3 id=&quot;what-one-circuit-does&quot;&gt;What one circuit does&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;bv_n140&lt;/code&gt; is a Bernstein–Vazirani circuit that Qiskit issue #14402 names directly. Mapped to a heavy-hex lattice, going from Qiskit 1.4.3 to 2.0.0, measured at &lt;strong&gt;400 seeds per version across 21 operating-system processes&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;quantity &lt;/th&gt;&lt;th&gt;value &lt;/th&gt;&lt;th&gt;95% interval&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;long-run mean change &lt;/td&gt;&lt;td&gt;&lt;strong&gt;+5.37%&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;+4.27% to +6.50%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;called a ≥10% regression, 3 runs/version &lt;/td&gt;&lt;td&gt;&lt;strong&gt;24.4%&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;19.5% to 31.1%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The true change is nowhere near the threshold. The verdict crosses it a quarter of the time anyway.&lt;/p&gt;
&lt;p&gt;I did not trust that number when I first got it, because all 200 of the original seeds came from one contiguous block inside one process — so any per-process state, hash randomisation, allocator layout, a cached RNG, was held constant. I drew a &lt;strong&gt;disjoint&lt;/strong&gt; set of 200 seeds spanning 5.9 million to 1.08 billion, ran them across ten fresh processes with differing &lt;code&gt;PYTHONHASHSEED&lt;/code&gt;, and got &lt;strong&gt;22.6%&lt;/strong&gt; [16.4, 30.6] against &lt;strong&gt;26.3%&lt;/strong&gt; [18.5, 34.2] from the contiguous set. Each point falls inside the other's interval.&lt;/p&gt;
&lt;h3 id=&quot;running-it-more-does-not-fix-it&quot;&gt;Running it more does not fix it&lt;/h3&gt;
&lt;p&gt;The obvious response is &quot;then run it more times&quot;. I measured exactly how much that buys, pooling all 400 seeds:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;runs per version &lt;/th&gt;&lt;th&gt;1 &lt;/th&gt;&lt;th&gt;3 &lt;/th&gt;&lt;th&gt;5 &lt;/th&gt;&lt;th&gt;8 &lt;/th&gt;&lt;th&gt;10 &lt;/th&gt;&lt;th&gt;20&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;false-positive rate &lt;/td&gt;&lt;td&gt;34.58% &lt;/td&gt;&lt;td&gt;24.38% &lt;/td&gt;&lt;td&gt;18.56% &lt;/td&gt;&lt;td&gt;12.94% &lt;/td&gt;&lt;td&gt;10.36% &lt;/td&gt;&lt;td&gt;&lt;strong&gt;3.74%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Twenty runs per version is on the order of &lt;strong&gt;forty hours of compute&lt;/strong&gt;, at the issue's own stated &quot;about two hours&quot; per suite run. It still leaves 3.74%. One &lt;code&gt;seed_transpiler&lt;/code&gt; argument removes the sampling variance at a single run.&lt;/p&gt;
&lt;p&gt;Taking the minimum of three runs instead of the mean — a common instinct — makes it worse, not better. All eight circuits I tested still err, and every rate roughly doubles: &lt;code&gt;bv_n280&lt;/code&gt; goes from 17.3% to 28.4%.&lt;/p&gt;
&lt;h3 id=&quot;the-number-in-the-issue&quot;&gt;The number in the issue&lt;/h3&gt;
&lt;p&gt;Issue #14402 reports &lt;strong&gt;+46.1%&lt;/strong&gt; for that circuit. A single three-run comparison of the same circuit, under the same protocol, returns anywhere from &lt;strong&gt;−10.5% to +100.0%&lt;/strong&gt;, with a 95% range of [+9.3%, +57.2%].&lt;/p&gt;
&lt;p&gt;+46.1% sits at the 88th percentile of that distribution. The same range extends below the +10% threshold. The reported figure is not wrong — it is one draw, reported as though it were a measurement.&lt;/p&gt;
&lt;h3 id=&quot;the-part-i-pre-registered&quot;&gt;The part I pre-registered&lt;/h3&gt;
&lt;p&gt;One circuit is an anecdote. So before I looked at any result, I wrote down the selection rule, the analysis, the endpoint and the labels, and committed them. Then I collected the data. The commit ordering is checkable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;a36a34a  2026-09-03 23:51:08  pre-registration
fbc573d  2026-09-03 23:52:38  analysis code
30224a0  2026-09-04 09:13:07  raw data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rule selected every &lt;code&gt;qasmbench-large&lt;/code&gt; circuit whose twelve-seed heavy-hex runtime was under ten seconds — 39 circuits, 200 seeds per version per arm.&lt;/p&gt;
&lt;p&gt;The pre-registered endpoint: &lt;strong&gt;12 of 26&lt;/strong&gt; eligible circuits have a decision-error rate whose interval excludes zero. That is 46.2%, Wilson 95% [28.8%, 64.5%]. Seven of 26 sit at 5% or worse, four at 10% or worse.&lt;/p&gt;
&lt;h3 id=&quot;what-i-withdrew&quot;&gt;What I withdrew&lt;/h3&gt;
&lt;p&gt;That 46.2% is the number I trust least in this work, and I want to say why rather than let someone find it.&lt;/p&gt;
&lt;p&gt;The 26 circuits are not 26 independent observations. They come from eleven algorithm families, and within a family the results are nearly all-hit or all-miss. A cluster bootstrap over families gives &lt;strong&gt;[17.4%, 81.0%]&lt;/strong&gt; — against the Wilson interval's [28.8%, 64.5%]. The effective sample size is far below 26, and no interval I can compute is informative about a population of circuits.&lt;/p&gt;
&lt;p&gt;So the suite-level rate is &lt;strong&gt;withdrawn as a headline&lt;/strong&gt;. The 12/26 stands as a descriptive count of these 26 circuits and nothing more.&lt;/p&gt;
&lt;p&gt;Three other claims went the same way, and all four withdrawals are still in the repository with the evidence that defeated them. One of them was mine: I had reported a correlation of ρ = +0.876 between &quot;compilation is stochastic&quot; and risk, presented as a discovered mechanism. Circuits whose two arms are constant have zero risk by arithmetic, so a correlation computed over a sample containing them is measuring a definition, not a finding. Simulated data containing no compiler at all reproduces the statistic.&lt;/p&gt;
&lt;h3 id=&quot;what-went-wrong-in-my-own-instrument&quot;&gt;What went wrong in my own instrument&lt;/h3&gt;
&lt;p&gt;The paper's whole argument is that measurement apparatus should be checked rather than trusted, so it would be poor form not to turn that on myself.&lt;/p&gt;
&lt;p&gt;After three rounds of adversarial review and a sixteen-phase red team — all of which attacked the &lt;em&gt;claims&lt;/em&gt; — I had an independent audit read the ~4,600 lines of Python that nobody had ever read. It found six defects. None changed a published number. The worst one was this:&lt;/p&gt;
&lt;p&gt;My numeric inventory recomputed every reported figure and compared it to a recorded value. But the recorded value was &lt;em&gt;itself&lt;/em&gt; a function call evaluated when the registry was built, and the recomputation called the same function on the same file. It compared &lt;code&gt;f()&lt;/code&gt; with &lt;code&gt;f()&lt;/code&gt;. &lt;strong&gt;It could not fail.&lt;/strong&gt; I proved it by falsifying every value in a summary file — tripling them and adding 40 percentage points — and the checker still reported &quot;41/41 reproduce&quot;.&lt;/p&gt;
&lt;p&gt;The values are frozen literals now, twenty-nine of forty-one re-derive from raw measurements rather than from a summary, and re-running that same falsification produces twelve failures. A checker that cannot fail is worse than no checker, because it buys confidence it has not earned.&lt;/p&gt;
&lt;h3 id=&quot;what-this-does-and-does-not-say&quot;&gt;What this does and does not say&lt;/h3&gt;
&lt;p&gt;It does not say Qiskit's transpiler got worse, or that Benchpress is a bad suite. It measures one property of one benchmarking protocol: that an unseeded stochastic compiler, sampled three times, produces regression verdicts that disagree with its own long-run behaviour at rates that are not small.&lt;/p&gt;
&lt;p&gt;Limits, stated plainly: one SDK, one version pair, one machine. The +10% threshold is mine — Benchpress defines none and the issue states no formal cut. The estimate of the true change is a plug-in from 200 seeds, not an external criterion. And &lt;code&gt;seed_transpiler&lt;/code&gt; is not a universal remedy: it removes false positives on the circuits I tested, but was &lt;em&gt;worse&lt;/em&gt; on three of the four circuits showing false negatives.&lt;/p&gt;
&lt;h3 id=&quot;everything-is-in-the-record&quot;&gt;Everything is in the record&lt;/h3&gt;
&lt;p&gt;The paper, the complete analysis code, and all &lt;strong&gt;41,790 raw per-seed measurements&lt;/strong&gt; are archived with a DOI: &lt;a rel=&quot;nofollow&quot; href=&quot;https://doi.org/10.5281/zenodo.22310060&quot;&gt;10.5281/zenodo.22310060&lt;/a&gt;. &lt;code&gt;verify.py&lt;/code&gt; re-runs the toolchain pin check, the test suite, the numeric inventory, the replication artifact and a proof of one withdrawn claim, in under a minute.&lt;/p&gt;
&lt;p&gt;The full write-up is on my site: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai.bedvibe.studio/decision-risk/&quot;&gt;The Compiler Got 5% Slower. The Benchmark Called It a 10% Regression a Quarter of the Time.&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you maintain a benchmark that drives accept/reject decisions on a stochastic system, the cheap version of this check is: run the same comparison twenty times without changing anything, and look at the spread of &lt;em&gt;verdicts&lt;/em&gt; rather than the spread of values. If the verdict moves, the number of runs is part of your instrument, and it belongs in the write-up.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26245/the-compiler-got-5-slower-the-benchmark-called-it-a-10-regression-a-quarter-of-the-time</guid>
<pubDate>Sat, 05 Sep 2026 07:33:09 +0000</pubDate>
</item>
<item>
<title>Fewer LLM turns, more commands: a practical way to verify research-heavy agent work</title>
<link>https://coderlegion.com/24113/fewer-llm-turns-more-commands-a-practical-way-to-verify-research-heavy-agent-work</link>
<description>&lt;p&gt;&lt;img src=&quot;https://github.com/user-attachments/assets/f3f269e8-b4f7-4ddf-bfff-6359c120e724&quot; alt=&quot;Real 15-slide deliverable&quot;&gt;&lt;/p&gt;
&lt;p&gt;What if an agent spent less time going back to the model, but did much more useful work inside each turn?&lt;/p&gt;
&lt;p&gt;That is the idea I have been testing: let an LLM turn make a decision, then let one batch of commands search, inspect, compare, and verify the result. The goal is not “fewer turns” by itself. The goal is fewer expensive decision loops and more observable work between them.&lt;/p&gt;
&lt;p&gt;I used a deliberately messy task: build a 15-slide guide to 10 East Asian squid recipes, with illustrations, recipe sources, and cooking videos. This kind of job is easy to make look finished while leaving behind broken links, duplicated videos, missing assets, or a layout that only works on one screen.&lt;/p&gt;
&lt;p&gt;Across four runs, the two CLI-style runs averaged 13.5 LLM turns and 10.5 tool calls. The two direct-execution runs averaged 10 LLM turns and 82 tool calls. So, in this small matrix, the direct approach used 25.9% fewer LLM turns while issuing about 7.8× more commands.&lt;/p&gt;
&lt;p&gt;The interesting part was not the headline number. In one early turn, the agent launched roughly 20 discovery commands for recipes and matching videos. Later checks confirmed 10 sources, 10 unique video destinations, 15 local assets with none missing, navigation behavior, and rendering at 1440, 768, and 390 pixels. One bad recipe destination was corrected, and the remaining video IDs were validated.&lt;/p&gt;
&lt;p&gt;This pattern is useful anywhere the work is “collect, produce, then prove it is usable”: research briefs, slide decks, market maps, buyer guides, travel plans, content migrations, link audits, and QA-heavy reports.&lt;/p&gt;
&lt;p&gt;The mental model is simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Use the LLM turn to decide what needs to happen.&lt;/li&gt;
&lt;li&gt;Fan that decision out into many independent commands.&lt;/li&gt;
&lt;li&gt;Gather structured results.&lt;/li&gt;
&lt;li&gt;Use the next turn only when judgment is actually needed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is only a four-run case study, not proof that one setup always wins. Runtime was mixed, and token accounting came from different harness sources. But it does show why “turn count” and “command count” should not be treated as the same thing.&lt;/p&gt;
&lt;p&gt;The full interactive task and the raw run artifacts are public:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Task: &lt;a rel=&quot;nofollow&quot; href=&quot;https://turaai.net/benchmark-task?task=east-asian-squid-recipes-slides&quot;&gt;https://turaai.net/benchmark-task?task=east-asian-squid-recipes-slides&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Results: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Tura-AI/benchmark/tree/main/results/design/east-asian-squid-recipes-slides/report-20260711-design-matrix&quot;&gt;https://github.com/Tura-AI/benchmark/tree/main/results/design/east-asian-squid-recipes-slides/report-20260711-design-matrix&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Disclosure: I maintain Tura and this benchmark.&lt;/p&gt;
</description>
<category>Data &amp; AI</category>
<guid isPermaLink="true">https://coderlegion.com/24113/fewer-llm-turns-more-commands-a-practical-way-to-verify-research-heavy-agent-work</guid>
<pubDate>Tue, 04 Aug 2026 21:13:10 +0000</pubDate>
</item>
<item>
<title>I built a repeatable way to check whether token-saving tools actually work</title>
<link>https://coderlegion.com/23694/i-built-a-repeatable-way-to-check-whether-token-saving-tools-actually-work</link>
<description>&lt;p&gt;Hi, I’m Yu. Earlier this month I released Tura and wrote about why agent harnesses need long-horizon benchmarks, not just one-off demos.&lt;/p&gt;
&lt;p&gt;Over the past two weeks, I’ve seen more people publish tests of token-saving tools such as RTK and Ponytail. That’s great, but the results are hard to compare when everyone uses different tasks, logs, and output formats.&lt;/p&gt;
&lt;p&gt;So I open-sourced the benchmark framework I use:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Tura-AI/benchmark/tree/main&quot;&gt;https://github.com/Tura-AI/benchmark/tree/main&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It can run the benchmark workflow, collect logs and artifacts into one result schema, and let CI index any result added to the repository. The website then turns those results into comparable graphs and detailed run pages.&lt;/p&gt;
&lt;p&gt;If you have a tool, harness, or test case you want included, tell me. You can also run the benchmark locally, reproduce the evaluation, and submit the result folder as a pull request. CI handles the indexing automatically.&lt;/p&gt;
&lt;p&gt;I’d especially like feedback on what should be tested next and where the framework may introduce bias.&lt;/p&gt;
&lt;p&gt;Disclosure: I maintain Tura and this benchmark framework.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cf.preview.redd.it/i-created-a-framework-to-test-if-a-token-saving-solution-is-v0-ypxxk3qnyzfh1.png?width=1327&amp;amp;format=png&amp;amp;auto=webp&amp;amp;s=b1be15047ca3f1bc65c1aa9b2b30d7e81c0b058e&quot; alt=&quot;Verified outcomes versus benchmark cost&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cf.preview.redd.it/i-created-a-framework-to-test-if-a-token-saving-solution-is-v0-6yubg4hwyzfh1.png?width=1724&amp;amp;format=png&amp;amp;auto=webp&amp;amp;s=3b08d71bd937bdfc520d52b2bd8353c28c7537ac&quot; alt=&quot;Benchmark rules and agent run&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cf.preview.redd.it/i-created-a-framework-to-test-if-a-token-saving-solution-is-v0-b9waa32zyzfh1.png?width=1077&amp;amp;format=png&amp;amp;auto=webp&amp;amp;s=2026593c0462513e75bba79b620a61aa75130f1a&quot; alt=&quot;Indexed run details and artifact&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23694/i-built-a-repeatable-way-to-check-whether-token-saving-tools-actually-work</guid>
<pubDate>Tue, 28 Jul 2026 17:24:03 +0000</pubDate>
</item>
<item>
<title>The Divergence Problem: Why Your Proxy Ages Faster Than You Think</title>
<link>https://coderlegion.com/21530/the-divergence-problem-why-your-proxy-ages-faster-than-you-think</link>
<description>&lt;p&gt;You know that moment when your test suite goes green but the feature is clearly broken? Multiply that by an entire field, stretch it over decades, and you've got what dendroclimatologists call &lt;em&gt;the divergence problem&lt;/em&gt;.&lt;/p&gt;
&lt;h3 id=&quot;the-benchmark-that-quietly-expired&quot;&gt;The benchmark that quietly expired&lt;/h3&gt;
&lt;p&gt;HumanEval — 164 hand-written Python problems from 2021 — became &lt;em&gt;the&lt;/em&gt; benchmark for LLM coding ability. For three years, pass@1 scores tracked real capability improvements. Then frontier models started hitting 90%+ and the benchmark was quietly dropped from comparisons.&lt;/p&gt;
&lt;p&gt;How much of that 90% was real?&lt;/p&gt;
&lt;p&gt;Bradbury and More (2024) built HumanEval-T — same problems, different enough to prevent memorization. Every model dropped 5 to 14 percentage points. Qwen-2.5-Coder now explicitly decontaminates against HumanEval using 10-gram collision detection. A major lab admitting in their training pipeline that the benchmark was compromised.&lt;/p&gt;
&lt;p&gt;The proxy expired. Nobody printed the warranty.&lt;/p&gt;
&lt;h3 id=&quot;this-is-actually-a-thousand-year-old-problem&quot;&gt;This is actually a thousand-year-old problem&lt;/h3&gt;
&lt;p&gt;For a thousand years, tree rings at far-northern latitudes tracked temperature faithfully. Wider rings meant warmer summers. Dendroclimatologists built entire climate reconstructions on this relationship — centuries of temperature data, cited by the IPCC.&lt;/p&gt;
&lt;p&gt;Then around 1960, the trees stopped matching the thermometers. Ring widths diverged downward while temperatures went up. Nobody noticed for 35 years.&lt;/p&gt;
&lt;p&gt;The field has six plausible explanations. None definitively rules out the others. D'Arrigo's 2008 review laid them all out: drought stress, global dimming, UV-B damage, snowmelt timing shifts, survivorship bias in sample selection, and statistical artifacts. The forensics are permanently messy — because when a proxy fails, multiple causal pathways break at once.&lt;/p&gt;
&lt;h3 id=&quot;the-pattern-shows-up-in-ai-evaluation-too&quot;&gt;The pattern shows up in AI evaluation too&lt;/h3&gt;
&lt;p&gt;GPT-4 passed theory-of-mind tests (the Sally-Anne false-belief task) at 75%. Looked like emergent reasoning.&lt;/p&gt;
&lt;p&gt;Then Ullman (2023) changed one thing: made the container transparent instead of opaque. GPT-3.5 dropped to 6%. SCALPEL (2024) found GPT-4 at 20.35% on the variant.&lt;/p&gt;
&lt;p&gt;Here's the kicker: add one explicit line saying the character &quot;recognizes&quot; the contents, and GPT-4 jumped back to 89.64%.&lt;/p&gt;
&lt;p&gt;The capability existed. The proxy just lost the ability to measure it.&lt;/p&gt;
&lt;h3 id=&quot;the-five-phase-warranty&quot;&gt;The five-phase warranty&lt;/h3&gt;
&lt;p&gt;Every proxy failure follows the same arc:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Calibration&lt;/strong&gt; — proxy tracks reality&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reliance&lt;/strong&gt; — you build systems on it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Silent divergence&lt;/strong&gt; — proxy quietly decouples&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hindsight discovery&lt;/strong&gt; — a second instrument reveals the gap&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Overdetermined forensics&lt;/strong&gt; — multiple explanations, none clean&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;what-you-can-actually-do&quot;&gt;What you can actually do&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Rotate your benchmarks.&lt;/strong&gt; LiveCodeBench (ICLR 2025) uses rolling monthly updates so models can't train on the test set. If your internal benchmark hasn't changed in two years, treat that as a yellow flag.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Treat stability as suspicious.&lt;/strong&gt; A metric that hasn't moved could be saturated, gamed, or decoupled. Stable ≠ reliable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't scale your way out.&lt;/strong&gt; Pan et al. (2022) showed larger models get higher proxy rewards but &lt;em&gt;lower&lt;/em&gt; true rewards. More optimization against a broken proxy makes the problem worse.&lt;/p&gt;
&lt;p&gt;Every metric you rely on — test coverage, sprint velocity, interview rubrics, code review approval rates — was calibrated under conditions that will not hold forever. The trees kept faith for a thousand years. When they stopped, nobody heard it happen.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Sources: D'Arrigo et al. (2008); Bradbury &amp;amp; More (2024), arXiv:2412.01526; Pi et al. (2024), arXiv:2406.14737; Kosinski (2024), PNAS; Ullman (2023); Pan et al. (2022); LiveCodeBench (ICLR 2025); &quot;Dead rats, dopamine, performance metrics, and peacock tails,&quot; BBS (2023).&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;The essay's prescription: build a second instrument.&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://vibeagentmaking.com/verify/&quot;&gt;Chain of Consciousness&lt;/a&gt; applies this to agent systems — every action anchored to a verifiable external record, so your audit trail doesn't depend on the agent's own self-report. &lt;code&gt;pip install chain-of-consciousness&lt;/code&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21530/the-divergence-problem-why-your-proxy-ages-faster-than-you-think</guid>
<pubDate>Mon, 29 Jun 2026 02:13:16 +0000</pubDate>
</item>
<item>
<title>The 1,000-Message Test: A Benchmark for AI Memory That Most Apps Fail</title>
<link>https://coderlegion.com/16288/the-1-000-message-test-a-benchmark-for-ai-memory-that-most-apps-fail</link>
<description>&lt;p&gt;Most apps that claim &quot;memory&quot; don't have it.&lt;/p&gt;
&lt;p&gt;I spent 200 days testing AI companion apps. 15 platforms, every subscription paid out of pocket. What I found, consistently, is that &quot;memory&quot; in marketing copy usually means one of three things: a context window, a summarizer, or nothing at all.&lt;/p&gt;
&lt;p&gt;None of those are memory.&lt;/p&gt;
&lt;p&gt;Real memory means the app can recall specific things you said days, weeks, or months ago, reliably, across sessions. That's a much higher bar than most apps clear. So I built a benchmark to test it.&lt;/p&gt;
&lt;p&gt;What follows is the full methodology. It works for any AI product that claims persistent memory: companions, chatbots, agents, therapy apps, coding assistants. If it claims to remember you, this test tells you whether it actually does.&lt;/p&gt;
&lt;h3 id=&quot;why-this-test-exists&quot;&gt;Why this test exists&lt;/h3&gt;
&lt;p&gt;The problem with evaluating AI memory is that most apps &lt;em&gt;feel&lt;/em&gt; like they remember, as long as you stay within one conversation. Open a session, chat for 30 minutes, reference something you said 20 messages ago. Works fine. Looks like memory.&lt;/p&gt;
&lt;p&gt;It's not. It's a context window.&lt;/p&gt;
&lt;p&gt;The real test is what happens when:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The conversation gets longer than the window&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You close the app and come back later&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You reference something specific from weeks ago&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is where apps diverge. Some hold up. Most don't.&lt;/p&gt;
&lt;h3 id=&quot;the-methodology&quot;&gt;The methodology&lt;/h3&gt;
&lt;p&gt;The test has four phases. Total time to run: about 3-4 hours of real usage, spread across multiple days.&lt;/p&gt;
&lt;h4 id=&quot;phase-1-the-planting&quot;&gt;Phase 1: The planting&lt;/h4&gt;
&lt;p&gt;Over roughly 1,000 messages, you plant specific, memorable facts at known checkpoints. The facts need two properties:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Specific enough to verify later.&lt;/strong&gt; Not &quot;I like coffee.&quot; Something like &quot;My cat's name is Mortimer and she's a tabby with one white paw.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Varied in type.&lt;/strong&gt; A name, a number, a preference, a relationship, an event, an opinion.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I use four checkpoints in the 1,000-message conversation:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Checkpoint &lt;/th&gt;&lt;th&gt;Message # &lt;/th&gt;&lt;th&gt;Fact type&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Early &lt;/td&gt;&lt;td&gt;50 &lt;/td&gt;&lt;td&gt;A specific name (pet, friend, coworker)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Medium &lt;/td&gt;&lt;td&gt;200 &lt;/td&gt;&lt;td&gt;A numerical detail (age, date, price)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Late &lt;/td&gt;&lt;td&gt;500 &lt;/td&gt;&lt;td&gt;A preference paired with a reason&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Very late &lt;/td&gt;&lt;td&gt;900 &lt;/td&gt;&lt;td&gt;A story or event with multiple details&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The varied types matter. Apps that summarize conversations often retain &lt;em&gt;categories&lt;/em&gt; of information (names, preferences) but lose &lt;em&gt;specifics&lt;/em&gt; within those categories.&lt;/p&gt;
&lt;h4 id=&quot;phase-2-the-break&quot;&gt;Phase 2: The break&lt;/h4&gt;
&lt;p&gt;Between planting and retrieval, you need to force the app out of its comfort zone:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Exceed the context window.&lt;/strong&gt; Most apps advertise 8K, 16K, or 32K token windows. Push past whatever they claim.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Close the session.&lt;/strong&gt; Fully exit the app. Don't just background it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wait at least 24 hours.&lt;/strong&gt; This catches apps that hold state in RAM or short-lived caches.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Open a new session.&lt;/strong&gt; Fresh conversation, no context carried over.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This sequence is deliberate. Each step exposes a different class of failure.&lt;/p&gt;
&lt;h4 id=&quot;phase-3-the-retrieval&quot;&gt;Phase 3: The retrieval&lt;/h4&gt;
&lt;p&gt;In the new session, you query each planted fact. The key is &lt;em&gt;how&lt;/em&gt; you ask.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't lead.&lt;/strong&gt; &quot;Remember Mortimer?&quot; is leading. The app will often confabulate a plausible response even if it doesn't actually remember.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do ask open-ended questions.&lt;/strong&gt; &quot;Tell me about my pet&quot; or &quot;What was that thing I mentioned about my cat?&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ask both specific and general.&lt;/strong&gt; Test whether the app can surface the right memory without being handed the answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vary the phrasing.&lt;/strong&gt; Ask about the same fact two or three different ways across a few minutes. Apps that retrieve purely on keyword match will fail on paraphrase.&lt;/p&gt;
&lt;h4 id=&quot;phase-4-the-scoring&quot;&gt;Phase 4: The scoring&lt;/h4&gt;
&lt;p&gt;For each planted fact, the response falls into one of four buckets:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Score &lt;/th&gt;&lt;th&gt;What it means&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Pass&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;App recalls the specific fact accurately without leading&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Partial&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;App recalls the category but misses the specific (remembers &quot;cat&quot; but not &quot;Mortimer&quot;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Hallucination&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;App confidently invents details you never shared&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Fail&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;App says it doesn't know, or the response ignores the fact entirely&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Hallucinations are the most dangerous failure mode, worse than an honest &quot;I don't know.&quot; An app that makes things up is creating false memories of a relationship that doesn't exist.&lt;/p&gt;
&lt;h3 id=&quot;what-the-results-actually-show&quot;&gt;What the results actually show&lt;/h3&gt;
&lt;p&gt;When you run this test across a catalog of apps, a pattern emerges fast. Out of the 15 platforms I tested, only a small minority passed the full benchmark. Most failed in predictable ways, and the way they failed said more about their architecture than any marketing copy could.&lt;/p&gt;
&lt;p&gt;Here are the buckets I consistently found:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Context window pretenders.&lt;/strong&gt; Apps that advertise 16K or 32K context windows and then hard-truncate when the window fills. These fail at message 20-50, well before any of the planted facts even enter long-term consideration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Session-only memory.&lt;/strong&gt; Apps that feel great inside a single session but reset to zero on reopen. These pass Phase 1 easily, then fail every single retrieval in Phase 3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Preference summarizers.&lt;/strong&gt; Apps that remember &lt;em&gt;what kind of thing&lt;/em&gt; you like but not the specific thing. These produce the most Partial scores: &quot;You mentioned having a pet&quot; instead of &quot;Your cat Mortimer.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Confident hallucinators.&lt;/strong&gt; Apps that confidently invent details rather than admit they don't know. These are the scariest failure mode, because a user would have to already know the right answer to catch the mistake.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Actual memory.&lt;/strong&gt; Apps that pass cleanly across all four checkpoints with accurate recall. In my testing, this bucket was the smallest. These apps tend to have architecture built specifically for this: a retrieval layer, embeddings on past messages, a distinct fact store separate from the conversation buffer. I wrote about &lt;a rel=&quot;nofollow&quot; href=&quot;https://aicompanionpicker.com/guides/ai-companion-memory-failure-modes/&quot;&gt;the three most common failure modes in more detail here&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;why-apps-fail-this-test-and-how-to-pass-it-&quot;&gt;Why apps fail this test (and how to pass it)&lt;/h3&gt;
&lt;p&gt;Same failure modes, over and over:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failure mode: context-only storage.&lt;/strong&gt; The app treats the prompt as the memory. When the prompt fills up, the oldest messages get discarded. There is no second layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failure mode: session-scoped state.&lt;/strong&gt; The app persists within a session but not across sessions. Often this is a database design choice: the conversation is stored per-session with no cross-session retrieval.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failure mode: summary without retrieval.&lt;/strong&gt; The app summarizes old conversations into a rolling memory document but discards the raw text. If the summary loses a detail, it's gone forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failure mode: retrieval without salience.&lt;/strong&gt; The app stores everything and retrieves based on keyword or embedding similarity, but has no sense of &lt;em&gt;importance&lt;/em&gt;. A passing mention of &quot;Mortimer&quot; six weeks ago gets outranked by a recent mention of &quot;cat food brands.&quot;&lt;/p&gt;
&lt;p&gt;Apps that pass the test typically have all of the following:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Persistent storage of raw messages (never deleted)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Compressed knowledge layer (facts + summary, updated async)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Semantic retrieval (embeddings + similarity search)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Salience scoring (emotional weight, personal facts, recency decay)&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you're building, I wrote up &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/nolan_voss/how-id-design-a-memory-system-for-an-ai-companion-app-469i&quot;&gt;the full architecture I'd build for this&lt;/a&gt; in a separate post.&lt;/p&gt;
&lt;h3 id=&quot;how-to-use-this-benchmark&quot;&gt;How to use this benchmark&lt;/h3&gt;
&lt;p&gt;If you're a user trying to decide whether to pay for an app, run the test before you subscribe. Most apps have a free tier that's plenty for the 1,000-message run.&lt;/p&gt;
&lt;p&gt;If you're a developer building a memory-dependent product, run the test on your own system before shipping. If you can't pass it, don't claim &quot;long-term memory&quot; in your marketing. Users notice, usually after they've paid for three months.&lt;/p&gt;
&lt;p&gt;If you're a reviewer or journalist covering AI products, this benchmark gives you something to cite. &quot;The app failed standard memory benchmarks&quot; is a much stronger claim than &quot;it seemed to forget things.&quot;&lt;/p&gt;
&lt;h3 id=&quot;the-quiet-thing-about-memory&quot;&gt;The quiet thing about memory&lt;/h3&gt;
&lt;p&gt;Most product teams discover the memory problem the hard way: users churn, and exit interviews say some version of &quot;it didn't feel like it really knew me.&quot; The team then scrambles to retrofit memory onto a system that wasn't designed for it, which is much harder than designing for it from day one.&lt;/p&gt;
&lt;p&gt;The 1,000-message test is a forcing function. Run it early, run it honestly, and you'll know whether your product has what it claims.&lt;/p&gt;
&lt;p&gt;Most don't.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I test AI companion apps and write about what I find at&lt;/em&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://aicompanionpicker.com&quot;&gt;&lt;em&gt;AI Companion Picker&lt;/em&gt;&lt;/a&gt;&lt;em&gt;. If you're building in this space, I'm always down to compare notes.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16288/the-1-000-message-test-a-benchmark-for-ai-memory-that-most-apps-fail</guid>
<pubDate>Thu, 30 Apr 2026 20:28:06 +0000</pubDate>
</item>
<item>
<title>We Measured the N+1 Query Problem. The Numbers Are Worse Than You Think.</title>
<link>https://coderlegion.com/11471/we-measured-the-n-1-query-problem-the-numbers-are-worse-than-you-think</link>
<description>&lt;p&gt;I kept seeing the same advice in every ORM tutorial: &quot;Watch out for N+1 queries.&quot; Every code review, every blog post, every conference talk. Always the same warning, never the actual numbers.&lt;/p&gt;
&lt;p&gt;So I decided to measure it.&lt;/p&gt;
&lt;p&gt;I built a benchmark suite, seeded a PostgreSQL database, wrote the &quot;bad&quot; code that everyone warns about, wrote the &quot;good&quot; code that everyone recommends, and ran them side by side. Four different scenarios, each one timed and query-counted down to the individual SQL statement.&lt;/p&gt;
&lt;p&gt;The short version? On a tiny dataset — just 100 users — the worst pattern was &lt;strong&gt;22x slower&lt;/strong&gt; and fired &lt;strong&gt;200 unnecessary queries&lt;/strong&gt;. And that's the &lt;em&gt;conservative&lt;/em&gt; estimate, because I ran everything on localhost where network latency is basically zero.&lt;/p&gt;
&lt;p&gt;Here's what I found.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-the-n-1-problem-actually-&quot;&gt;What's the N+1 problem, actually?&lt;/h3&gt;
&lt;p&gt;You probably already know this, but it's worth being precise about it because the devil is in the details.&lt;/p&gt;
&lt;p&gt;Say you've got 100 users and you want each user's posts. The naive approach does this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;One query to grab all users&lt;/li&gt;
&lt;li&gt;Then, inside a loop, one query &lt;em&gt;per user&lt;/em&gt; to get their posts&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That's 101 database round-trips. The &quot;1&quot; to get the users, plus &quot;N&quot; (100) to get each user's posts. Hence, N+1.&lt;/p&gt;
&lt;p&gt;The thing is, each individual query is fast — sub-millisecond in most cases. But every single one of those queries carries overhead. The app has to send the request to PostgreSQL, PostgreSQL has to parse it, plan it, execute it, serialise the results, and send them back. Do that 101 times and the overhead stacks up fast.&lt;/p&gt;
&lt;p&gt;The fix is usually straightforward: tell your ORM to grab the related data upfront. In Prisma, that's the &lt;code&gt;include&lt;/code&gt; keyword. Instead of 101 queries, you get 2. Same result, fraction of the time.&lt;/p&gt;
&lt;p&gt;But &lt;em&gt;how much&lt;/em&gt; of a fraction? That's what I wanted to pin down.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-i-set-this-up&quot;&gt;How I set this up&lt;/h3&gt;
&lt;p&gt;I wanted the benchmarks to be fair and reproducible, so here's what I did.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt; Prisma 6.3.1, PostgreSQL 17, TypeScript, Node.js 22 — all running locally on the same machine. Running the database locally means there's no network hop between the app and the database. That makes the numbers &lt;em&gt;more conservative&lt;/em&gt; than what you'd see in production, where the database is usually on a separate server (or across the internet, if you're using a managed service).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The data:&lt;/strong&gt; 100 users, each with 3 posts, 2 comments per post, and 2 orders. All generated with Faker so the string content is realistic — not just &lt;code&gt;&quot;test&quot;&lt;/code&gt; repeated a thousand times. Serialisation costs are real costs, and I didn't want to cheat there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The measurement:&lt;/strong&gt; Every query that Prisma sends to PostgreSQL gets intercepted and counted. Not estimated, not sampled — counted. Each benchmark runs 5 times (after a warmup run) using &lt;code&gt;performance.now()&lt;/code&gt;, and I report the mean, median, P95, and P99.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The test cases:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;# &lt;/th&gt;&lt;th&gt;What it tests &lt;/th&gt;&lt;th&gt;Relationship&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;TC1 &lt;/td&gt;&lt;td&gt;Users with their posts &lt;/td&gt;&lt;td&gt;One-to-many&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC2 &lt;/td&gt;&lt;td&gt;Users → posts → comments &lt;/td&gt;&lt;td&gt;Three-level nesting&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC3 &lt;/td&gt;&lt;td&gt;Orders with their user &lt;/td&gt;&lt;td&gt;Many-to-one (reverse)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC4 &lt;/td&gt;&lt;td&gt;Active orders, conditionally load user &lt;/td&gt;&lt;td&gt;Conditional access&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each test case has a &quot;bad&quot; version (the N+1 pattern) and a &quot;good&quot; version (the recommended fix). Both return the exact same data.&lt;/p&gt;
&lt;p&gt;Source code for everything: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/liangk/empirical-study&quot;&gt;github.com/liangk/empirical-study&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc1-users-with-posts-the-textbook-case&quot;&gt;TC1: Users with posts — the textbook case&lt;/h3&gt;
&lt;p&gt;This is the one everyone uses as an example. Grab all users, loop through them, fetch posts for each one.&lt;/p&gt;
&lt;p&gt;The bad version fires 101 queries. The good version uses &lt;code&gt;include: { posts: true }&lt;/code&gt; and fires 2.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Eager) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;101 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;98.0% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;39.31 ms &lt;/td&gt;&lt;td&gt;4.28 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;9.2x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;38.96 ms &lt;/td&gt;&lt;td&gt;4.21 ms &lt;/td&gt;&lt;td&gt;9.3x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;41.49 ms &lt;/td&gt;&lt;td&gt;4.53 ms &lt;/td&gt;&lt;td&gt;9.2x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Here's what caught my attention: the variance in the good version is tiny. The standard deviation is under 0.25ms. That matters if you're building an API where you care about tail latency — and you should care about tail latency.&lt;br&gt;
The bad version stays surprisingly steady too, with a relatively small variance. It &lt;em&gt;feels&lt;/em&gt; fine at this scale. 39ms isn't going to trigger any alarms. But it's doing 50x more work than it needs to, and the cost grows linearly with your user count.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc2-nested-relationships-where-it-gets-ugly&quot;&gt;TC2: Nested relationships — where it gets ugly&lt;/h3&gt;
&lt;p&gt;This one goes deeper. Users → posts → comments. Three levels of entities.&lt;/p&gt;
&lt;p&gt;The bad code has two nested loops, each firing queries. With 100 users and 3 posts each, that's 1 query for users + 100 for posts + 300 for comments = &lt;strong&gt;401 queries&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The good version? Nested &lt;code&gt;include&lt;/code&gt; directives. &lt;strong&gt;3 queries total.&lt;/strong&gt; One per entity level, no matter how many records.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Eager) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;401 &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;99.3% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;153.59 ms &lt;/td&gt;&lt;td&gt;10.50 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;14.6x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;143.21 ms &lt;/td&gt;&lt;td&gt;10.07 ms &lt;/td&gt;&lt;td&gt;14.2x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;185.53 ms &lt;/td&gt;&lt;td&gt;11.82 ms &lt;/td&gt;&lt;td&gt;15.7x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;401 queries versus 3. That's not a percentage I need to calculate to know it's bad.&lt;/p&gt;
&lt;p&gt;But the timing tells a more interesting story. The bad version's P95 is 185ms — that's 29% higher than its median. Some of those runs are hitting extra overhead, probably connection pool pressure or plan cache misses from hammering PostgreSQL with 400 queries in rapid succession. The good version's P95 is only 17% above its median. Fewer queries means less contention, which means more predictable performance.&lt;/p&gt;
&lt;p&gt;And this is the part that should worry you: 153ms with 100 users. The N+1 cost here isn't linear — it's multiplicative. Each level of nesting multiplies the query count. The general formula looks something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Queries = 1 + N + (N × posts_per_user) + (N × posts_per_user × comments_per_post) + ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With eager loading, it's just the number of entity levels + 1. Always. Doesn't matter if you've got 100 users or 100,000.&lt;/p&gt;
&lt;p&gt;If you're thinking &quot;well, my app doesn't have three levels of nesting&quot; — check again. User → projects → tasks. Store → products → reviews. Organization → teams → members. It's more common than you'd expect.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc3-orders-user-the-biggest-speedup&quot;&gt;TC3: Orders → User — the biggest speedup&lt;/h3&gt;
&lt;p&gt;This one flips the relationship. Instead of fetching children for each parent, you're fetching the parent for each child. Classic scenario: you've got an orders list and you want to show the customer name next to each order.&lt;/p&gt;
&lt;p&gt;The bad version grabs all orders (200 of them — 2 per user), then calls &lt;code&gt;findUnique&lt;/code&gt; for each order's user. That's 201 queries.&lt;/p&gt;
&lt;p&gt;The good version adds &lt;code&gt;include: { user: true }&lt;/code&gt;. Two queries.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Eager) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;201 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;99.0% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;82.54 ms &lt;/td&gt;&lt;td&gt;3.72 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;22.2x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;86.10 ms &lt;/td&gt;&lt;td&gt;3.66 ms &lt;/td&gt;&lt;td&gt;23.5x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;88.71 ms &lt;/td&gt;&lt;td&gt;4.02 ms &lt;/td&gt;&lt;td&gt;22.1x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;22.2x.&lt;/strong&gt; That's the largest speedup across all four test cases.&lt;/p&gt;
&lt;p&gt;Why so much bigger than TC1? Two reasons. First, there are more records involved — 200 orders versus 100 users. More queries means more wasted overhead. Second, many of those &lt;code&gt;findUnique&lt;/code&gt; calls are fetching the &lt;em&gt;same user&lt;/em&gt; — each user has 2 orders, so each user gets fetched twice. Prisma doesn't deduplicate those calls. It doesn't have an identity map or a request-level cache. So you're not just making too many queries — you're making redundant ones too.&lt;/p&gt;
&lt;p&gt;The eager loading version sidesteps all of that. Prisma resolves the relationships from the batch result set in memory.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc4-conditional-loading-the-tricky-one&quot;&gt;TC4: Conditional loading — the tricky one&lt;/h3&gt;
&lt;p&gt;Here's where things get interesting from a design perspective. You've got active orders, but you only need user data for orders where &lt;code&gt;requiresUserData&lt;/code&gt; is true. Not all of them.&lt;/p&gt;
&lt;p&gt;You can't just slap &lt;code&gt;include: { user: true }&lt;/code&gt; on this one — that would over-fetch user data for orders that don't need it. So the fix is different: collect the user IDs you actually need, fetch them all in one batch, build a lookup map, and assign the results.&lt;/p&gt;
&lt;p&gt;The bad version loops through active orders and calls &lt;code&gt;findUnique&lt;/code&gt; for each one that needs user data. About 70% of them do, which gave us 56 queries.&lt;/p&gt;
&lt;p&gt;The good version collects the needed IDs into a Set, does one &lt;code&gt;findMany&lt;/code&gt; with &lt;code&gt;WHERE id IN (...)&lt;/code&gt;, builds a Map, and does the assignment in a second pass. Two queries total.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Batch) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;56 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;96.4% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;22.75 ms &lt;/td&gt;&lt;td&gt;2.16 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;10.5x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;23.05 ms &lt;/td&gt;&lt;td&gt;2.11 ms &lt;/td&gt;&lt;td&gt;10.9x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;23.49 ms &lt;/td&gt;&lt;td&gt;2.57 ms &lt;/td&gt;&lt;td&gt;9.1x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Even though the conditional logic cuts the query count from a potential 200+ down to 56, you're still paying a &lt;strong&gt;10.5x penalty&lt;/strong&gt;. Partial N+1 is still N+1.&lt;/p&gt;
&lt;p&gt;This batch-query pattern is worth keeping in your back pocket. It works everywhere — not just in Prisma, not just in JavaScript. Collect the IDs, batch fetch, build a map, assign. You trade a bit of memory for the Map and an extra loop, but you eliminate dozens (or hundreds or thousands) of database round-trips. That trade-off pays for itself every time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-full-picture&quot;&gt;The full picture&lt;/h3&gt;
&lt;p&gt;Here's everything in one table:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Test Case &lt;/th&gt;&lt;th&gt;Bad Queries &lt;/th&gt;&lt;th&gt;Good Queries &lt;/th&gt;&lt;th&gt;Reduction &lt;/th&gt;&lt;th&gt;Bad Time &lt;/th&gt;&lt;th&gt;Good Time &lt;/th&gt;&lt;th&gt;Speedup&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;TC1: One-to-many &lt;/td&gt;&lt;td&gt;101 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;98.0% &lt;/td&gt;&lt;td&gt;39.31 ms &lt;/td&gt;&lt;td&gt;4.28 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;9.2x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC2: Nested (3-level) &lt;/td&gt;&lt;td&gt;401 &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;99.3% &lt;/td&gt;&lt;td&gt;153.59 ms &lt;/td&gt;&lt;td&gt;10.50 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;14.6x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC3: Many-to-one &lt;/td&gt;&lt;td&gt;201 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;99.0% &lt;/td&gt;&lt;td&gt;82.54 ms &lt;/td&gt;&lt;td&gt;3.72 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;22.2x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC4: Conditional &lt;/td&gt;&lt;td&gt;56 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;96.4% &lt;/td&gt;&lt;td&gt;22.75 ms &lt;/td&gt;&lt;td&gt;2.16 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;10.5x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A few things jump out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The optimised versions never exceed 3 queries.&lt;/strong&gt; Doesn't matter which pattern, doesn't matter how many records. That's the whole point — you go from O(N) queries to O(1). And the benefit scales with your data. At 1,000 users, the bad TC2 version would fire over 4,000 queries. The good version would still fire 3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The speedup correlates with query count.&lt;/strong&gt; TC3 had the most queries (201) and the biggest speedup (22.2x). TC4 had the fewest (56) and the smallest speedup (10.5x). Per-query overhead is roughly constant, so more queries = more waste = more gain from fixing it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;These numbers are the floor, not the ceiling.&lt;/strong&gt; I ran everything on localhost. In production, add network latency between app and database — maybe 1-5ms per round-trip. Now multiply that by 401 queries. That's where a 150ms endpoint becomes a 2-second one.&lt;/p&gt;
&lt;h3 id=&quot;what-happens-at-1-000-users-&quot;&gt;What happens at 1,000 users?&lt;/h3&gt;
&lt;p&gt;I ran the same benchmarks on a 10x larger dataset. 1,000 users, 3,000 posts, 6,000 comments, 2,000 orders.&lt;/p&gt;
&lt;p&gt;Here's the medium dataset alongside the small one:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Test Case &lt;/th&gt;&lt;th&gt;100 Users &lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;1,000 Users &lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Change&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;TC1: One-to-many &lt;/td&gt;&lt;td&gt;39ms → 4ms &lt;/td&gt;&lt;td&gt;9.2x &lt;/td&gt;&lt;td&gt;364ms → 35ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;10.4x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Slightly worse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC2: Nested &lt;/td&gt;&lt;td&gt;154ms → 11ms &lt;/td&gt;&lt;td&gt;14.6x &lt;/td&gt;&lt;td&gt;1,271ms → 88ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;14.5x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;About the same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC3: Many-to-one &lt;/td&gt;&lt;td&gt;83ms → 4ms &lt;/td&gt;&lt;td&gt;22.2x &lt;/td&gt;&lt;td&gt;739ms → 28ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.5x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Noticeably worse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC4: Conditional &lt;/td&gt;&lt;td&gt;23ms → 2ms &lt;/td&gt;&lt;td&gt;10.5x &lt;/td&gt;&lt;td&gt;169ms → 8ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;21.9x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;Way&lt;/em&gt; worse&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A few things I didn't expect.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TC2 blew past 1 second.&lt;/strong&gt; 1,271ms for a single data fetch. With 4,001 queries. On localhost. In production with a network hop, you're looking at multiple seconds — from one API call. That's not a performance issue. That's a broken endpoint.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TC4's speedup doubled.&lt;/strong&gt; It went from 10.5x to 21.9x. That surprised me. But the reason makes sense once you look at the numbers: the batch query version barely grew (2ms → 8ms, only 3.6x) while the loop-with-findUnique version grew 7.4x. The batch approach has near-constant overhead regardless of how many IDs you're fetching — PostgreSQL is really good at &lt;code&gt;WHERE id IN (...)&lt;/code&gt; queries. The loop approach pays full overhead for every single query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TC3 got worse too.&lt;/strong&gt; 22.2x → 26.5x. Same story — the good version scales better than the bad version because batch fetching is fundamentally more efficient than doing it one at a time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The bad code scales roughly linearly.&lt;/strong&gt; 10x more data, roughly 8-9x more time across the board. Which makes sense — 10x more users means ~10x more queries in the loop, and per-query overhead is approximately constant.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The good code sometimes scales &lt;em&gt;better&lt;/em&gt; than linearly.&lt;/strong&gt; TC4's good version grew only 3.6x for 10x more data. The batch query with deduplication is doing less redundant work at scale.&lt;/p&gt;
&lt;p&gt;The bottom line: the N+1 penalty doesn't shrink as you scale. It gets worse. Every test case either maintained or increased its speedup factor at 1,000 users compared to 100. If you've got N+1 patterns in your code and you're thinking &quot;I'll fix it when we scale&quot; — the data says you're already paying for it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-to-spot-this-in-your-own-code&quot;&gt;How to spot this in your own code&lt;/h3&gt;
&lt;p&gt;The pattern is always the same: &lt;strong&gt;a database call inside a loop.&lt;/strong&gt; If you see &lt;code&gt;await prisma.something.findMany()&lt;/code&gt; or &lt;code&gt;findUnique()&lt;/code&gt; inside a &lt;code&gt;for&lt;/code&gt;, &lt;code&gt;map&lt;/code&gt;, &lt;code&gt;forEach&lt;/code&gt;, or any kind of iteration — that's your red flag.&lt;/p&gt;
&lt;p&gt;Three ways to catch it:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Turn on query logging.&lt;/strong&gt; Set &lt;code&gt;log: [{ emit: &quot;stdout&quot;, level: &quot;query&quot; }]&lt;/code&gt; in your Prisma client config. If you see the same &lt;code&gt;SELECT&lt;/code&gt; statement repeated 50 times in a row, differing only in the &lt;code&gt;WHERE&lt;/code&gt; clause value, you've got an N+1.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use static analysis.&lt;/strong&gt; Tools like &lt;a rel=&quot;nofollow&quot; href=&quot;https://codeevolutionlab.com&quot;&gt;Code Evolution Lab&lt;/a&gt; walk your AST (Abstract Syntax Tree) to find database calls nested inside loops. They can catch these before your code even runs — during CI, code review, or in your IDE. They'll also suggest the right fix for your specific ORM.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Watch your APM dashboards.&lt;/strong&gt; If an endpoint's query count scales linearly with the number of records it returns, something is wrong. A healthy endpoint has a roughly constant query count.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;two-fixes-that-cover-almost-everything&quot;&gt;Two fixes that cover almost everything&lt;/h3&gt;
&lt;p&gt;Across all four test cases, two patterns handled every situation:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Eager loading&lt;/strong&gt; for cases where you know you'll need the related data. In Prisma, that's &lt;code&gt;include&lt;/code&gt;. It works for one-to-many, many-to-one, and nested relationships. You can stack &lt;code&gt;include&lt;/code&gt; directives as deep as your schema goes, and Prisma always resolves it in a constant number of queries (one per entity level).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Batch pre-fetching&lt;/strong&gt; for cases where you need related data conditionally. Collect the IDs, do one &lt;code&gt;findMany&lt;/code&gt; with &lt;code&gt;WHERE IN&lt;/code&gt;, build a Map for O(1) lookup, and do the assignment in memory. It's a few extra lines of code, but it turns 56 queries into 2.&lt;/p&gt;
&lt;p&gt;Both approaches do the same fundamental thing: they take the number of database round-trips from &quot;proportional to your data&quot; down to &quot;constant, no matter how much data.&quot; That's the whole game.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-d-push-back-on&quot;&gt;What I'd push back on&lt;/h3&gt;
&lt;p&gt;There's a common belief that N+1 queries are a &quot;scaling problem&quot; — something you can ignore early and fix when your dataset gets big enough to matter.&lt;/p&gt;
&lt;p&gt;The data doesn't support that.&lt;/p&gt;
&lt;p&gt;At 100 users, we're already seeing 9x-22x slowdowns. At 1,000 users, it's 10x-27x. That's not a rounding error. If your API has a 200ms budget per endpoint (pretty standard), the bad TC2 version is already eating 75% of that &lt;em&gt;with just 100 records and zero network latency&lt;/em&gt;. In production, you're probably blown past it.&lt;/p&gt;
&lt;p&gt;The other thing I'd push back on: the idea that &lt;code&gt;findUnique&lt;/code&gt; is somehow safe because it's a primary key lookup. It's fast per query, sure. But when you call it 200 times in a loop (TC3), the fixed overhead of 200 round-trips completely dominates. A fast query executed 200 times is slower than a slightly heavier query executed once.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-about-the-honesty-section-&quot;&gt;What about the honesty section?&lt;/h3&gt;
&lt;p&gt;A few things that could affect how you read these numbers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Localhost flatters the good version.&lt;/strong&gt; With no network latency, the per-query overhead is as small as it can be. In production, the penalty for each extra query would be higher, which means the speedup numbers would be &lt;em&gt;larger&lt;/em&gt; than what I measured. So think of these as conservative estimates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I only tested 100 users.&lt;/strong&gt; The scaling behaviour at 1K, 10K, and 100K records is something I want to measure next. I expect the gap to widen, not narrow, because connection pool contention and buffer pressure get worse with more queries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This is Prisma-specific.&lt;/strong&gt; Sequelize, TypeORM, and other ORMs have different internals. The per-query overhead might be higher or lower. The fix patterns are similar though — eager loading and batching work everywhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Warm caches.&lt;/strong&gt; I did a warmup run before measuring, which means PostgreSQL's query plan cache and shared buffers are warm. Both versions benefit equally from this, so it shouldn't bias the comparison. But cold-start numbers might look different.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;Everything's open source:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/liangk/empirical-study.git
cd empirical-study
npm install
npx prisma db push
npm run seed -- small
npm run bench:all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swap &lt;code&gt;small&lt;/code&gt; for &lt;code&gt;medium&lt;/code&gt; (1,000 users), &lt;code&gt;large&lt;/code&gt; (10,000), or &lt;code&gt;xlarge&lt;/code&gt; (100,000) to see how the numbers scale.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-coming-next&quot;&gt;What's coming next&lt;/h3&gt;
&lt;p&gt;This is the first piece in a series I'm working on. The questions I still want to answer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How do the numbers scale beyond 1K?&lt;/strong&gt; The 1,000-user results are in (see above) and the penalties got worse, not better. Next up: 10K and 100K, where I expect connection pool contention to make things even uglier.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can automated tools fix this?&lt;/strong&gt; I'm going to feed the bad code into &lt;a rel=&quot;nofollow&quot; href=&quot;https://codeevolutionlab.com&quot;&gt;Code Evolution Lab&lt;/a&gt; and see if the auto-generated solutions perform as well as the hand-written ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is Prisma better or worse than other ORMs?&lt;/strong&gt; Same benchmarks, Sequelize and TypeORM. The per-query overhead profile might be very different.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What about concurrent load?&lt;/strong&gt; All my benchmarks are single-threaded. Under real traffic, connection pool contention makes the N+1 penalty even worse.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The bottom line: the N+1 problem isn't a theoretical concern you can defer. It's a measurable, order-of-magnitude performance hit that shows up at 100 records and gets worse at 1,000. The nested pattern crossed 1.2 seconds. The conditional pattern's penalty doubled. And fixing it is rarely more than a one-line change.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The benchmark suite and raw data are on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/liangk/empirical-study&quot;&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://stackinsight.dev/blog/n-plus-1-query-empirical-study&quot;&gt;Stack Insight&lt;/a&gt;. For the best reading experience and future updates, visit the original post.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11471/we-measured-the-n-1-query-problem-the-numbers-are-worse-than-you-think</guid>
<pubDate>Thu, 12 Feb 2026 00:58:11 +0000</pubDate>
</item>
</channel>
</rss>