<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Umang Pokhriyal</title>
    <description>The latest articles on DEV Community by Umang Pokhriyal (@umang_up).</description>
    <link>https://dev.to/umang_up</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4025132%2F8631b09b-aeb6-4199-b31d-13ed17cbb4c0.jpg</url>
      <title>DEV Community: Umang Pokhriyal</title>
      <link>https://dev.to/umang_up</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/umang_up"/>
    <language>en</language>
    <item>
      <title>I benchmarked 11 TCP server concurrency models in Rust. Here's what actually mattered.</title>
      <dc:creator>Umang Pokhriyal</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:39:39 +0000</pubDate>
      <link>https://dev.to/umang_up/i-benchmarked-11-tcp-server-concurrency-models-in-rust-heres-what-actually-mattered-534d</link>
      <guid>https://dev.to/umang_up/i-benchmarked-11-tcp-server-concurrency-models-in-rust-heres-what-actually-mattered-534d</guid>
      <description>&lt;p&gt;If you've spent any time learning networking or backend systems, you've probably run into questions like these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If Node.js is single-threaded, how can it handle thousands of connections?&lt;/li&gt;
&lt;li&gt;Why is Redis still single-threaded when modern CPUs have dozens of cores?&lt;/li&gt;
&lt;li&gt;What is Tokio actually doing when I write async Rust?&lt;/li&gt;
&lt;li&gt;Is io_uring really replacing epoll, or is that just internet hype?&lt;/li&gt;
&lt;li&gt;Should I build a thread pool, an event loop, or something else entirely?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At first glance these all seem like different topics, but they're really different answers to the same question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How should a server handle many connections at the same time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The problem is that most tutorials only explain one architecture in isolation. You'll find an epoll server, an io_uring server, or a thread-pool server, but they're usually different projects with different parsers, different benchmark setups, and different application code. When one benchmark is faster than another, it's hard to know whether you're measuring the concurrency model or everything else that changed.&lt;/p&gt;

&lt;p&gt;I wanted to compare those designs as fairly as I could, so I built eleven TCP server concurrency models behind the same &lt;code&gt;Sans-IO HTTP&lt;/code&gt; implementation. &lt;strong&gt;Every server shares the same HTTP parser, router, connection state machine, benchmark harness, and workload. The only thing that changes is the concurrency strategy&lt;/strong&gt;, making it possible to compare classic UNIX designs, modern event loops, and io_uring on equal footing.&lt;/p&gt;

&lt;p&gt;Going into the project, I expected io_uring to come out ahead. It did reduce syscall overhead almost exactly as advertised. What surprised me was that this wasn't the thing limiting throughput.&lt;/p&gt;

&lt;h2&gt;
  
  
  One protocol core, eleven concurrency models
&lt;/h2&gt;

&lt;p&gt;The biggest challenge wasn't implementing eleven servers, it was making the comparison fair.&lt;/p&gt;

&lt;p&gt;If every implementation had its own parser, router, and request handling code, any performance difference could come from the application rather than the concurrency model. I wanted to compare the architecture, not eleven slightly different web servers.&lt;/p&gt;

&lt;p&gt;To avoid that, every server shares exactly the same HTTP parser, router, response encoder, and connection state machine. The only thing each implementation owns is how it performs I/O and schedules work.&lt;/p&gt;

&lt;p&gt;In practice, every concurrency model implements the same Server trait, while the protocol logic remains unchanged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Every concurrency model implements this ONE trait.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cd"&gt;/// Runs until the process is signalled to stop.&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ServerConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbzzr7fzc3vhwo1f76dl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbzzr7fzc3vhwo1f76dl.png" alt="Architecture: a central frozen sans-IO core (HTTP parse, route, encode, connection state machine) that never touches a socket, with eleven model boxes around it, each owning only its I/O and concurrency strategy." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every implementation shares the same application logic. The only variable is the concurrency model, making the benchmark a comparison of I/O strategies rather than different web servers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I included both classic and modern designs so the comparison covers the progression most developers encounter while learning network programming. The implementations start with iterative and thread-based servers, move through readiness-based designs using poll and epoll, and finish with Linux-specific approaches like a pinned SO_REUSEPORT multireactor and a purpose-built io_uring server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Iterative&lt;/li&gt;
&lt;li&gt;Forking&lt;/li&gt;
&lt;li&gt;Preforked&lt;/li&gt;
&lt;li&gt;Thread-per-connection&lt;/li&gt;
&lt;li&gt;Thread pool&lt;/li&gt;
&lt;li&gt;poll&lt;/li&gt;
&lt;li&gt;epoll (LT)&lt;/li&gt;
&lt;li&gt;epoll (ET)&lt;/li&gt;
&lt;li&gt;Single reactor&lt;/li&gt;
&lt;li&gt;Multireactor (SO_REUSEPORT)&lt;/li&gt;
&lt;li&gt;io_uring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, they cover almost every concurrency architecture you'll encounter while learning UNIX network programming.&lt;/p&gt;

&lt;p&gt;Every implementation is benchmarked with the same open-loop, coordinated-omission-correct load generator, ensuring each model receives exactly the same workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  C10K changes what "efficient" means
&lt;/h2&gt;

&lt;p&gt;One of the goals of this project was to compare how different concurrency models behave under high connection counts, not just high request rates.&lt;/p&gt;

&lt;p&gt;To do that, I ran all eleven servers with 10,000 concurrent keep-alive connections while offering the same workload to every implementation.&lt;/p&gt;

&lt;p&gt;What surprised me was that throughput wasn't the biggest difference.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;poll&lt;/code&gt;, &lt;code&gt;epoll&lt;/code&gt;, &lt;code&gt;reactor&lt;/code&gt;, &lt;code&gt;multireactor&lt;/code&gt;, and &lt;code&gt;io_uring&lt;/code&gt; implementations all sustained the offered load.&lt;/p&gt;

&lt;p&gt;The real difference was how much memory and operating-system resources each architecture needed to keep those 10,000 connections alive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fss1tiltjdj8ooztezr31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fss1tiltjdj8ooztezr31.png" alt="Horizontal bar chart, log scale: thread-per-connection uses **261 MiB** of RSS to hold 10,000 connections, while the readiness-driven event-loop models (poll, epoll-LT, epoll-ET, event-loop, multireactor, io_uring) all sit near 10.7 MiB." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The event-loop models hold 10,000 connections flat at about **10.7 MiB&lt;/em&gt;&lt;em&gt;. Thread-per-connection needs **261 MiB&lt;/em&gt;* for the same job. Source: &lt;code&gt;c10k_summary.csv&lt;/code&gt;.*&lt;/p&gt;

&lt;p&gt;The difference comes from how each architecture represents a connection.&lt;/p&gt;

&lt;p&gt;A thread-per-connection server needs an operating-system thread for every active client, along with its stack and scheduler state. Event-driven servers don't create one thread per client. A connection is mostly just a file descriptor and a small amount of application state, while a handful of worker threads multiplex thousands of sockets using poll, &lt;em&gt;epoll&lt;/em&gt;, or io_uring.&lt;/p&gt;

&lt;p&gt;That's why the event-driven implementations all stay around &lt;strong&gt;10.7 MiB&lt;/strong&gt;, while the thread-per-connection server grows to roughly &lt;strong&gt;261 MiB&lt;/strong&gt; under the same workload.&lt;/p&gt;

&lt;p&gt;Not every model reached the same point. The single-threaded iterative server eventually saturated because it can only process one connection at a time. The preforked and thread-pool servers behaved differently: once every worker was occupied, new connections could no longer be serviced, so the benchmark reported a high error rate instead of sustained throughput.&lt;/p&gt;

&lt;p&gt;That doesn't mean those architectures are "bad." They're simply designed around bounded worker pools rather than extremely large numbers of mostly idle connections.&lt;/p&gt;

&lt;p&gt;At this point, the event-driven models looked like the obvious winners. But there was still another question I wanted to answer. Among modern Linux I/O APIs, does io_uring actually outperform &lt;em&gt;epoll&lt;/em&gt;, or is the difference smaller than people often claim?&lt;/p&gt;

&lt;h2&gt;
  
  
  Fewer syscalls didn't mean higher throughput
&lt;/h2&gt;

&lt;p&gt;After looking at memory usage, I wanted to compare two architectures that are often discussed together: &lt;em&gt;epoll&lt;/em&gt; and io_uring.&lt;/p&gt;

&lt;p&gt;One of the biggest advantages of io_uring is that it can eliminate many of the system calls required by traditional readiness-based I/O. Features like multishot accept and provided buffer rings let the kernel do more work without repeatedly transitioning between user space and kernel space.&lt;/p&gt;

&lt;p&gt;Going into the benchmark, I expected that reduction in syscall overhead to translate into noticeably higher throughput.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fks8ipiaeucrcr85rxvyh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fks8ipiaeucrcr85rxvyh.png" alt="Bar chart of syscalls per request: io_uring at **2.02** versus epoll-ET at **4.03**, a 1.99x reduction, annotated that it did not become 1.99x more throughput because the workload is frontend-bound." width="799" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;io_uring almost halved the number of syscalls per request, but that reduction didn't translate into proportionally higher throughput. Source: &lt;code&gt;profiles/summary.csv&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The syscall reduction was real. Compared to the &lt;em&gt;epoll&lt;/em&gt; implementation, the io_uring averaged &lt;strong&gt;2.02 syscalls/request&lt;/strong&gt; versus &lt;strong&gt;4.03&lt;/strong&gt; for &lt;em&gt;epoll&lt;/em&gt;., almost exactly a 2× reduction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The surprising part was that throughput barely changed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If fewer syscalls automatically meant a faster server, this shouldn't have happened. So the obvious question became: where was the processor actually spending its time? &lt;/p&gt;

&lt;p&gt;To answer that, I profiled both implementations on AMD EPYC hardware using the native Zen 4 pipeline utilization counters.&lt;/p&gt;

&lt;p&gt;The counters showed that syscall overhead was no longer the dominant bottleneck. Instead, the processor spent much of its time stalled in the frontend, waiting on frontend instruction delivery rather than kernel transitions. Once that became the dominant bottleneck, reducing the number of syscalls simply wasn't enough to produce a proportional increase in throughput.&lt;/p&gt;

&lt;p&gt;The lesson wasn't that io_uring failed. It did exactly what it promised by reducing kernel transitions. The benchmark showed something different: removing one source of overhead doesn't necessarily improve end-to-end performance if something else has already become the limiting factor.&lt;/p&gt;

&lt;p&gt;While comparing &lt;em&gt;epoll&lt;/em&gt; and io_uring answered one question, another result stood out even more. Among all eleven implementations, the architecture that consistently produced the best latency wasn't the newest API, it was the pinned multireactor design built around SO_REUSEPORT.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture I'd build on
&lt;/h2&gt;

&lt;p&gt;Out of all eleven implementations, the one I'd choose as a starting point for a real server isn't necessarily the newest API, it's the pinned multireactor built around SO_REUSEPORT.&lt;/p&gt;

&lt;p&gt;It combines the advantages of event-driven I/O with a shared-nothing design. Because each reactor stays on its own core, connections rarely migrate between CPUs, reducing cache disruption and synchronization between workers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ooywkzk1fvliiyyph7m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ooywkzk1fvliiyyph7m.png" alt="Diagram: one listening address, the kernel fanning accepts across N pinned reactors, each on its own core with its own _epoll_ loop and no shared acceptor." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One listening address, one reactor per core. Each reactor owns its own event loop while the kernel distributes new connections using SO_REUSEPORT.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In my benchmarks, this architecture consistently produced the lowest median latency (&lt;strong&gt;≈70 μs&lt;/strong&gt;) while continuing to scale cleanly as concurrency increased.&lt;/p&gt;

&lt;p&gt;More importantly, it's a design that extends naturally to larger systems because each reactor is largely independent of the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's more than a benchmark
&lt;/h2&gt;

&lt;p&gt;I started this project because I wanted to understand how different TCP server architectures actually compare when they're solving the same problem under the same workload.&lt;/p&gt;

&lt;p&gt;The benchmarks answered some questions I expected, like why event-driven servers use dramatically less memory than &lt;em&gt;thread-per-&lt;/em&gt;&lt;em&gt;connection&lt;/em&gt; designs. More interestingly, they also challenged a few assumptions. Reducing syscall overhead with io_uring didn't automatically produce higher throughput because syscall overhead wasn't the dominant bottleneck in this workload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The value of benchmarking isn't proving one technology is universally better. It's understanding why it behaves the way it does under a particular workload.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This project is also laying the groundwork for a larger system I'm building around microVM-based agent execution.&lt;/p&gt;

&lt;p&gt;A control plane for that kind of system still has to solve the same networking problems: accepting thousands of connections, distributing work across cores, and applying backpressure under load. The multireactor architecture from this project is the design I'll be carrying forward into that work.&lt;/p&gt;

&lt;p&gt;Everything in this article is reproducible. Every benchmark, CSV, profiling result, and plotting script is committed in the repository.&lt;/p&gt;

&lt;p&gt;If you'd like to reproduce the results, every benchmark, CSV, profiling result, and plotting script is available in the repository.&lt;/p&gt;

&lt;p&gt;Repository: &lt;strong&gt;&lt;a href="https://github.com/umangPokhriyall/Rust-Tcp-Server" rel="noopener noreferrer"&gt;https://github.com/umangPokhriyall/Rust-Tcp-Server&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>performance</category>
      <category>networking</category>
      <category>systems</category>
    </item>
    <item>
      <title>The "Optimal" Order Book Data Structure Lost by 288 on Real Market Data</title>
      <dc:creator>Umang Pokhriyal</dc:creator>
      <pubDate>Mon, 27 Jul 2026 13:24:50 +0000</pubDate>
      <link>https://dev.to/umang_up/the-optimal-order-book-data-structure-lost-by-288x-on-real-market-data-30hd</link>
      <guid>https://dev.to/umang_up/the-optimal-order-book-data-structure-lost-by-288x-on-real-market-data-30hd</guid>
      <description>&lt;p&gt;While learning about limit order books, I kept coming across two ideas. The first was the textbook answer: a flat array indexed by price is the optimal solution for a dense book because updates are O(1). The second came from low-latency systems and HFT discussions, where people repeatedly stress that contiguous memory and cache locality often matter more than algorithmic complexity.&lt;/p&gt;

&lt;p&gt;That got me wondering how these ideas compare in practice, so I implemented four different order book data structures and benchmarked each implementation using both synthetic workloads and a real BTCUSDT replay.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A limit order book is the core data structure of an exchange: for one symbol, it tracks resting buy and sell orders at each price level, and the hot operation is applying a stream of updates (add, cancel, modify) as fast as possible. &lt;/p&gt;

&lt;p&gt;In Rust, this is implemented using a common &lt;code&gt;OrderBook&lt;/code&gt; trait. I also wrote a differential test harness that replays the same event stream through every implementation and verifies that they all produce identical results before benchmarking them.&lt;/p&gt;

&lt;p&gt;I chose four implementations that represent the trade-offs you'll commonly see discussed in systems programming: pointer-based trees, contiguous vectors, linear scans that exploit locality, and direct indexing with arrays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25eknqjnigcbw2slaiqh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25eknqjnigcbw2slaiqh.png" alt="BTreeMap, Sorted Vec, Reverse-sorted Vec, FlatBook" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BTreeMap&lt;/strong&gt; the straightforward implementation. Memory usage grows only with occupied price levels, but updates involve tree traversal and pointer chasing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sorted Vec&lt;/strong&gt; of &lt;code&gt;(price, level)&lt;/code&gt;. stores price levels in contiguous memory and locates them with binary search. Better cache locality than a tree, but insertions require shifting elements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse-sorted Vec with a linear scan.&lt;/strong&gt; keeps the best prices at the front and relies on a simple linear scan. This sounds inefficient, but if most updates happen near the inside market, it can outperform binary search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FlatBook (flat array indexed by price tick)&lt;/strong&gt; indexes directly by price tick, giving constant-time access without searching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Going into the project, I expected the flat array to come out ahead. It has constant-time lookups, avoids tree traversal entirely, and is the implementation that's usually recommended for dense order books. The benchmarks were mainly meant to measure how much faster it was than the alternatives.&lt;/p&gt;

&lt;p&gt;The order book isn't the whole system, though. In a market data engine, updates also need to be distributed to multiple consumers, so each implementation plugs into the same lock-free pipeline shown below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7y52jr399rnyyicvdv68.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7y52jr399rnyyicvdv68.png" alt="Architecture: a pinned producer runs book.apply, seqlock.store for the latest top-of-book, and ring.push for the full broadcast stream; K independent pinned consumers read from the seqlock and ring, resyncing from the seqlock on overrun." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One pinned producer, two lock-free primitives, K independent consumers. The book is the hot path; the seqlock and ring are how its output fans out.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  On synthetic data, the textbook answer wins. On real data, it comes in last by 288x
&lt;/h2&gt;

&lt;p&gt;To avoid drawing conclusions from a single workload, I benchmarked each implementation under two very different conditions. The first was a synthetic workload with a relatively narrow, uniformly distributed price range. The second replayed a real BTCUSDT market session, where updates arrive with the same characteristics as they did on the exchange.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9oet0i7f9s4xc3fliyav.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9oet0i7f9s4xc3fliyav.png" alt="Grouped bar chart, log scale: on the synthetic narrow book FlatBook is fastest at 7.46 ns/event; on the real BTCUSDT book FlatBook collapses to 10,896 ns/event while BTreeBook leads at 37.79 ns/event, a 288x gap.&lt;br&gt;
" width="799" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The synthetic benchmark matches the textbook expectation. The real market replay tells a completely different story. Every bar is re-derived from &lt;code&gt;throughput.csv&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The surprising part isn't that FlatBook slows down—it's that the entire ranking reverses once the workload becomes realistic.&lt;/p&gt;

&lt;p&gt;The result wasn't just that the ranking changed—it completely flipped. The FlatBook implementation, which was consistently the fastest on the synthetic benchmark, became the slowest on the real replay, while the BTreeMap implementation ended up on top by a wide margin.&lt;/p&gt;

&lt;p&gt;If I had only benchmarked the synthetic workload, I would've concluded that the flat array was the obvious choice. Replaying real market data completely changed that conclusion.&lt;/p&gt;

&lt;p&gt;At this point, the obvious question was why. The algorithms hadn't changed, only the workload had. The answer turned out to have very little to do with Big-O complexity and everything to do with how much memory the data structure actually touches.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the FlatBook slowed down
&lt;/h2&gt;

&lt;p&gt;The biggest difference between the synthetic benchmark and the real replay wasn't the number of updates—it was the range of prices those updates covered.&lt;/p&gt;

&lt;p&gt;A FlatBook allocates memory for every possible price tick in that range, whether there's an order there or not. That means its memory footprint grows with the price span, not with the number of price levels that actually contain orders.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpkz5jmoqjiaiurrpbq0j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpkz5jmoqjiaiurrpbq0j.png" alt="Memory layout: the real BTC/USDT book spans about 88 MiB as one dense FlatBook array, which is 2.74x the 32 MiB per-CCD L3, so most accesses miss; BTreeBook's memory is proportional to occupied levels and stays small." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The flat array's span on the real book is ~88 MiB, about 2.74x the 32 MiB L3. Every "O(1)" index is now a main-memory round trip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This was the point where it became clear that memory footprint mattered more than lookup complexity.&lt;/p&gt;

&lt;p&gt;Once the array grows beyond what the processor can comfortably keep in cache, the cost of an "O(1)" lookup changes dramatically. The indexing itself is still constant time, but the data you're indexing often has to be fetched from lower levels of the memory hierarchy.&lt;/p&gt;

&lt;p&gt;The BTreeMap has the opposite trade-off. Tree traversal involves pointer chasing, but memory usage stays proportional to the number of occupied price levels instead of the total price span. On the real replay, that smaller working set outweighed the extra traversal cost.&lt;/p&gt;

&lt;p&gt;In this workload, the deciding factor wasn't algorithmic complexity—it was the working set size. Once the FlatBook grew to roughly 88 MiB, cache locality became the dominant cost.&lt;/p&gt;

&lt;p&gt;The same idea explains another result from the benchmarks. I originally expected the binary-search implementation to consistently outperform the linear scan, but that wasn't always true either.&lt;/p&gt;

&lt;p&gt;If most updates happen near the best bid and ask, a short linear scan often finishes before a binary search has a chance to pay off. If updates are spread uniformly across the book, the scan eventually becomes too expensive and binary search wins instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fht1mqwn5s46kjzwy7en4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fht1mqwn5s46kjzwy7en4.png" alt="Two-panel line chart: under concentrated touches the linear-scan RevVec never loses to the binary-search SortedVec; under uniform touches RevVec degrades to 519 ns at depth 2048 while SortedVec holds at 29 ns." width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Under uniform touches the linear scan degrades ~18x by depth 2048; under concentrated touches it never loses. Source: &lt;code&gt;service_sweep.csv&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The choice between linear scan and binary search depends more on access patterns than on theoretical complexity.&lt;/p&gt;

&lt;p&gt;At this point the benchmark results made sense, but I still wanted evidence that the processor was actually spending time where I thought it was. That's where the hardware performance counters came in.&lt;/p&gt;

&lt;p&gt;One of the reasons low-latency systems often favor contiguous data structures is cache locality—but this benchmark also shows the other half of that advice. Contiguous memory only helps when the working set still fits in cache. Once it doesn't, the advantage can disappear surprisingly quickly.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the hardware counters showed
&lt;/h2&gt;

&lt;p&gt;The benchmarks told me which implementation was faster, but they couldn't explain why. For that, I needed hardware performance counters (PMUs), which expose where the processor is actually spending its time.&lt;/p&gt;

&lt;p&gt;Before I had access to server hardware, I tried to explain each benchmark result just from its behavior. Looking at how throughput changed under different workloads, I formed a hypothesis about what each implementation was bottlenecked on.&lt;/p&gt;

&lt;p&gt;Later, I reran the benchmarks on a rented AMD EPYC system and collected the native Zen 4 PMU counters. They matched those hypotheses surprisingly well. Here's a summary of what the counters showed.&lt;/p&gt;

&lt;p&gt;The table below isn't meant to compare performance again—it summarizes where each implementation spends its time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;impl&lt;/th&gt;
&lt;th&gt;IPC&lt;/th&gt;
&lt;th&gt;bad-spec&lt;/th&gt;
&lt;th&gt;backend-mem&lt;/th&gt;
&lt;th&gt;verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SortedVec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50.5%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;memory-bound (branchless locate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BTreeBook&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.33&lt;/td&gt;
&lt;td&gt;28.3%&lt;/td&gt;
&lt;td&gt;9.1%&lt;/td&gt;
&lt;td&gt;pointer chase (frontend + memory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RevVec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6.10&lt;/td&gt;
&lt;td&gt;1.3%&lt;/td&gt;
&lt;td&gt;2.3%&lt;/td&gt;
&lt;td&gt;core-bound (scan length)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FlatBook&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.43&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25.5%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;13.4%&lt;/td&gt;
&lt;td&gt;mispredict-bound at wide depth&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The interesting part isn't which implementation is "best." It's that each one spends its time differently, even though they're solving exactly the same problem.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SortedVec&lt;/code&gt; spends most of its time waiting on memory. Its binary search uses &lt;code&gt;partition_point&lt;/code&gt;, which is already branchless, so branch prediction barely shows up.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BTreeBook&lt;/code&gt; pays the expected cost of pointer chasing through the tree.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RevVec&lt;/code&gt; is mostly limited by the work done in the scan itself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FlatBook&lt;/code&gt;, surprisingly, spends much more time recovering from branch mispredictions than waiting on memory.
This isn't something I'd be comfortable concluding from the benchmark alone. The PMU data is what made the explanation convincing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick note: these measurements come from AMD Zen 4's native pipeline-utilization counters. They're conceptually similar to Intel's Top-Down methodology, but they're not the same thing, so I've intentionally kept the terminology specific to AMD.&lt;/p&gt;

&lt;p&gt;Earlier I mentioned that low-latency engineers often emphasize cache locality and real measurements over theoretical complexity. This is exactly why. The benchmark tells you what happened. The PMU counters are what let you build a credible explanation for why it happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two primitives, and the seqlock that never blocks its writer
&lt;/h2&gt;

&lt;p&gt;The order book wasn't the only component I built. Rather than benchmark the order book in isolation, I embedded it in a small market-data pipeline. That's where the other two components in the architecture diagram come in: a seqlock for publishing the latest top-of-book snapshot and an SPMC ring buffer for broadcasting every update. Both implementations are entirely safe Rust (#![forbid(unsafe_code)]) and were verified with Loom before benchmarking.&lt;/p&gt;

&lt;p&gt;A seqlock uses a simple version counter. Even values mean the data is stable, while odd values indicate that a write is in progress. Readers optimistically copy the snapshot and then check whether the version changed while they were reading. If it did, they simply retry. Because readers never acquire a lock, the writer is never blocked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;load_counted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TopOfBook&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;s1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.seq&lt;/span&gt;&lt;span class="nf"&gt;.load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Acquire&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// (R1) pairs with the writer's Release&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                      &lt;span class="c1"&gt;// even =&amp;gt; no write in progress; snapshot it&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TopOfBook&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* payload: Relaxed loads */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="nf"&gt;fence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Acquire&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                   &lt;span class="c1"&gt;// (R2) order payload reads before the re-check&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;s2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.seq&lt;/span&gt;&lt;span class="nf"&gt;.load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Relaxed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;s2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// unchanged =&amp;gt; no write straddled the read&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// else: odd, or straddled =&amp;gt; discard and retry&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F32n8td64ai4uhuf5p35e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F32n8td64ai4uhuf5p35e.png" alt="Sequence diagram: the version counter goes even to odd (write in progress) to even; one reader whose snapshot straddles a write retries, another reader whose snapshot is clean accepts." width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In practice, a &lt;code&gt;load()&lt;/code&gt; stays around 10 ns (p50) and remains essentially flat even as the number of readers increases. Across six million timed reads, no torn snapshots were observed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The ring buffer did expose one limitation. As more consumers are added, producer throughput drops from 12.17 to 8.46 Mev/s. My first suspicion was false sharing, but &lt;code&gt;perf c2c&lt;/code&gt; showed something different. The payload slots remain isolated on separate cache lines; the contention comes from the write cursor itself, which every consumer must observe. In other words, this is true sharing, not false sharing. It's an inherent cost of broadcasting to multiple readers rather than a bug in the implementation.&lt;/p&gt;

&lt;p&gt;I left that behavior as-is because eliminating it would require changing the design rather than fixing an implementation issue. The producer remains wait-free; only the throughput changes.&lt;/p&gt;

&lt;p&gt;These primitives weren't the focus of the benchmark, but they complete the data path and are reusable outside the order book itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;I started this project wanting to compare different order book implementations. What I ended up with was a much better appreciation for how easily synthetic benchmarks can hide real bottlenecks.&lt;/p&gt;

&lt;p&gt;The FlatBook wasn't "wrong"—it was simply optimized for a workload that didn't resemble the one I eventually tested. Once the working set grew beyond cache, a data structure with theoretically better complexity became the slowest implementation in the benchmark.&lt;/p&gt;

&lt;p&gt;This project is also serving as a stepping stone toward a larger goal: building low-latency infrastructure for microVM-based systems. The same approach—benchmark on realistic workloads, measure with hardware counters, and avoid relying on intuition alone—is the one I'll carry into that work.&lt;/p&gt;

&lt;p&gt;The repository contains all benchmark inputs, raw CSVs, plotting scripts, and source code, so every figure in this article can be reproduced.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/umangPokhriyall/low-latency-lob" rel="noopener noreferrer"&gt;https://github.com/umangPokhriyall/low-latency-lob&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>performance</category>
      <category>systems</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>I Built a Threshold Signer. Then I Forged It.</title>
      <dc:creator>Umang Pokhriyal</dc:creator>
      <pubDate>Wed, 15 Jul 2026 08:55:42 +0000</pubDate>
      <link>https://dev.to/umang_up/i-built-a-threshold-signer-then-i-forged-it-2743</link>
      <guid>https://dev.to/umang_up/i-built-a-threshold-signer-then-i-forged-it-2743</guid>
      <description>&lt;p&gt;I built a threshold signature library, audited my own code, and found it was forgeable. So I forged it. Then I rebuilt it properly. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7uuzabgiwbxugo9kc2tf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7uuzabgiwbxugo9kc2tf.png" alt="Three panels: SHIPPED a threshold signer, FORGED a valid signature on a message no one signed in ~50ms, REBUILT to RFC 9591 FROST checked byte-for-byte." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I set out to build
&lt;/h2&gt;

&lt;p&gt;A t-of-n threshold Ed25519 signer. The idea is that a private key is split across several parties, and you need some threshold &lt;code&gt;t&lt;/code&gt; of them to cooperate to produce a signature. No single party ever holds the whole key. It's the primitive behind custody systems, MPC wallets, and any place where "one machine holds the key" is an unacceptable single point of failure.&lt;/p&gt;

&lt;p&gt;The existing reference for this on Solana is &lt;a href="https://github.com/ZenGo-X/solana-tss" rel="noopener noreferrer"&gt;ZenGo's solana-tss&lt;/a&gt;, but it's MuSig2 (n-of-n, no DKG) and only signs native SOL transfers, not SPL token transactions. A real custody setup needs t-of-n, distributed key generation, and token transfers, so I started implementing one. That's where I reached for naive threshold Schnorr, and that's the scheme that turned out to be forgeable.&lt;/p&gt;

&lt;p&gt;I got a version working end to end on devnet. Then I sat down to actually audit it and it fell apart in two places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first defect: the coordinator held the whole key
&lt;/h2&gt;

&lt;p&gt;In my design, each node returned its final secret share to the coordinator, which collected all of them. That means the coordinator saw at least &lt;code&gt;t&lt;/code&gt; shares and could reconstruct the private key and sign alone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp90mabz05cob7qjgf0nu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp90mabz05cob7qjgf0nu.png" alt="Three signer nodes each hand their secret share to a single coordinator box, which then holds enough shares to reconstruct the private key and sign alone." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A threshold system where one party can reconstruct the key is not a threshold system. It's a key in one place with extra steps.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fix, in the rebuild, is a Pedersen DKG where &lt;strong&gt;no party ever holds the group secret&lt;/strong&gt;: each participant ends with only its own share &lt;code&gt;sᵢ&lt;/code&gt;, and the group public key is assembled in the exponent, never the scalar. Here's the actual assembly, straight from &lt;a href="https://github.com/umangPokhriyall/frost-ed25519-kit/blob/main/frost-core/src/dkg.rs" rel="noopener noreferrer"&gt;dkg.rs&lt;/a&gt;` — notice it only ever adds public commitment points, never shares:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;rust&lt;br&gt;
// group_public = Σ_j φ_{j,0} over all participants (peers + self).&lt;br&gt;
let mut group_public = secret.own_commitment.0[0];&lt;br&gt;
for package in &amp;amp;round1_packages {&lt;br&gt;
    group_public = group_public + package.commitments.0[0];&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The second defect: the signing scheme was forgeable
&lt;/h2&gt;

&lt;p&gt;I'd implemented naive interactive threshold Schnorr: one nonce per signer, sum the commitments, hash for the challenge, sum the partial signatures. There's no &lt;em&gt;binding factor&lt;/em&gt; anywhere. That construction is broken by the ROS attack: given enough concurrent signing sessions, an attacker can treat the per-session challenges as a linear system and solve it for a forgery. This isn't folklore. It's the Benhamouda–Lefranc–Loss–Orsini–Raykova &lt;a href="https://eprint.iacr.org/2020/945" rel="noopener noreferrer"&gt;result&lt;/a&gt; from 2020, and it runs in polynomial time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forging it
&lt;/h2&gt;

&lt;p&gt;Saying "this is theoretically forgeable" proves nothing. So I kept the old scheme's math under &lt;code&gt;legacy/&lt;/code&gt;, reduced to its core (the threshold aggregate reduces to single-key concurrent Schnorr, so the oracle models that single key directly), and ran the actual ROS solver against it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe25hruv032ruwkyk6qgn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe25hruv032ruwkyk6qgn.png" alt="Worked example: open 256 concurrent sessions, collect all commitments, choose each message after seeing them, solve the challenges as one linear system, and emit a forged signature on a message no honest session ever signed." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The attack opens 256 concurrent sessions, uses the binary-decomposition trick to pick each session's message after seeing every commitment, and solves for a forgery.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It works. A valid signature on an unsigned message, in about 50 milliseconds. That number, and the proof that the message is outside the signed set, are committed in &lt;a href="https://github.com/umangPokhriyall/frost-ed25519-kit/blob/main/legacy/results/ros_forgery.txt" rel="noopener noreferrer"&gt;&lt;code&gt;legacy/results/ros_forgery.txt&lt;/code&gt;&lt;/a&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  The rebuild, and why it resists the attack
&lt;/h2&gt;

&lt;p&gt;The rebuild follows RFC 9591 FROST (Ed25519, SHA-512), hand-rolled on &lt;code&gt;curve25519-dalek&lt;/code&gt;. The reason FROST resists what the old scheme didn't is one line: each signer's nonce is bound by a factor that hashes in &lt;em&gt;everyone's&lt;/em&gt; commitments before anyone reveals a partial signature.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;plaintext&lt;br&gt;
ρ_j = H1(group_public ‖ msg ‖ commitment_list ‖ id_j)      // the binding factor&lt;br&gt;
R   = Σ_j (D_j + ρ_j · E_j)                                  // bound group commitment&lt;br&gt;
z_i = d_i + (ρ_i · e_i) + (λ_i · c · s_i)                    // partial signature&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbp6qhab56v4whf5ahb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbp6qhab56v4whf5ahb0.png" alt="Side by side: the naive scheme sums nonces with a linear hallenge that ROS can solve; FROST multiplies each nonce by a binding factor that depends on all commitments, so no linear system exists and the solver returns NoSolution." width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The challenge is no longer linear in quantities the attacker controls before committing, so the ROS solver has no linear system to solve.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Running the same solver against the FROST implementation returns &lt;code&gt;NoSolution&lt;/code&gt; &lt;a href="https://github.com/umangPokhriyall/frost-ed25519-kit/blob/main/frost-core/tests/ros_resistance.rs" rel="noopener noreferrer"&gt;&lt;code&gt;frost-core/tests/ros_resistance.rs&lt;/code&gt;&lt;/a&gt;. The attack fails because the binding factors remove the linear structure that the ROS solver depends on. That's the whole point of the audit-then-rebuild: the forgery is committed, reproducible evidence that the original was broken, and the new one is &lt;em&gt;checked against the standard&lt;/em&gt; rather than asserted to be correct.&lt;/p&gt;

&lt;p&gt;The implementation is hand-rolled on &lt;code&gt;curve25519-dalek&lt;/code&gt; rather than wrapping an existing FROST library. That also means it needs stronger verification than "the tests pass", so the implementation is checked against independent references at every stage instead of assuming the implementation is correct.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;RFC 9591 known-answer vectors are checked byte-for-byte, including intermediate values (binding factors, aggregate commitment, partial signatures, and final signature), so failures identify the first divergent step instead of only the final output.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A differential test compares the implementation against an independent FROST library across 10,000 randomized &lt;code&gt;(t, n)&lt;/code&gt; configurations. The reference implementation is used only during testing and is not part of the shipped dependency graph.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Invalid partial signatures identify the offending participant (&lt;code&gt;Err(Culprit(id))&lt;/code&gt;), allowing the coordinator to distinguish protocol failures from malicious or faulty signers.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bug the fuzzer found
&lt;/h2&gt;

&lt;p&gt;After I'd frozen the code, I ran a coverage-guided fuzzer, one target per deserializer, 104 million-plus executions. It found a bug. My &lt;a href="https://github.com/umangPokhriyall/frost-ed25519-kit/blob/main/frost-core/src/group.rs" rel="noopener noreferrer"&gt;&lt;code&gt;group.rs&lt;/code&gt;&lt;/a&gt; accepted non-canonical point encodings: two distinct byte-strings decoding to the same group element. That's a malleability vector, and &lt;code&gt;curve25519-dalek&lt;/code&gt;'s &lt;code&gt;decompress()&lt;/code&gt; silently canonicalizes exactly those inputs, so I'd never have caught it by hand.&lt;/p&gt;

&lt;p&gt;The fix is RFC 8032 strict decoding: re-encode the point and reject on any byte mismatch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;rust&lt;br&gt;
pub fn from_compressed(b: [u8; 32]) -&amp;gt; Result&amp;lt;Self, Error&amp;gt; {&lt;br&gt;
    let point = CompressedEdwardsY(b)&lt;br&gt;
        .decompress()&lt;br&gt;
        .ok_or(Error::InvalidPointEncoding)?;&lt;br&gt;
    // RFC 8032 strict decoding: reject NON-CANONICAL encodings (a y-coordinate&lt;br&gt;
    // &amp;gt;= the field prime, or a set sign bit on the x = 0 points). dalek's&lt;br&gt;
    //&lt;/code&gt;decompress()&lt;code&gt;accepts and silently canonicalizes these, which would let&lt;br&gt;
    // two distinct byte strings denote the same point (a malleability vector).&lt;br&gt;
    // (Found by the Phase 4 coverage-guided fuzz run.)&lt;br&gt;
    if point.compress().to_bytes() != b {&lt;br&gt;
        return Err(Error::InvalidPointEncoding);&lt;br&gt;
    }&lt;br&gt;
    if point.is_torsion_free() {&lt;br&gt;
        Ok(GElement(point))&lt;br&gt;
    } else {&lt;br&gt;
        Err(Error::NonPrimeOrderPoint)&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is a deserialization malleability issue rather than a key-recovery vulnerability, so that's how it's documented. The important part wasn't the bug itself—it was that coverage-guided fuzzing found it after the implementation had already been considered complete, with the failing inputs preserved as regression tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out of it
&lt;/h2&gt;

&lt;p&gt;The repository now contains both versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the original threshold Schnorr implementation under &lt;code&gt;legacy/&lt;/code&gt;, together with a reproducible ROS forgery;&lt;/li&gt;
&lt;li&gt;an RFC 9591 FROST implementation validated against published test vectors and differential tests;&lt;/li&gt;
&lt;li&gt;fuzz tests that uncovered and fixed a non-canonical point decoding issue before release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point wasn't simply to produce another signing library. It was to build one, break it under audit, fix it, and leave the evidence of each step in the repository.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;shell&lt;br&gt;
cargo run --example in_process_2of3&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Repository:&lt;br&gt;
&lt;a href="https://github.com/umangPokhriyall/frost-ed25519-kit" rel="noopener noreferrer"&gt;https://github.com/umangPokhriyall/frost-ed25519-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Threat model:&lt;br&gt;
&lt;a href="https://github.com/umangPokhriyall/frost-ed25519-kit/blob/main/docs/THREAT-MODEL.md" rel="noopener noreferrer"&gt;https://github.com/umangPokhriyall/frost-ed25519-kit/blob/main/docs/THREAT-MODEL.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>cryptography</category>
      <category>security</category>
      <category>systems</category>
    </item>
  </channel>
</rss>
