<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" version="2.0">
  <!-- Source: https://gomomento.com/feed -->
  <channel>
    <title>Momento</title>
    <atom:link href="https://siftrss.com/f/0BgwYvPmQz" rel="self" type="application/rss+xml"/>
    <link>https://siftrss.com/f/0BgwYvPmQz</link>
    <description>An enterprise-ready serverless platform for caching and pub/sub</description>
    <lastBuildDate>Thu, 27 Aug 2026 09:00:00 GMT</lastBuildDate>
    <language>en</language>
    <sy:updatePeriod>hourly</sy:updatePeriod>
    <sy:updateFrequency>1</sy:updateFrequency>
    <image>
      <url>https://www.gomomento.com/wp-content/uploads/2024/06/cropped-favicon-green-32x32.png</url>
      <title>Momento</title>
      <link>https://www.gomomento.com/</link>
      <width>32</width>
      <height>32</height>
    </image>
    <item>
      <title>Stop counting indexes</title>
      <link>https://www.gomomento.com/blog/stop-counting-indexes/</link>
      <dc:creator><![CDATA[Allen Helton]]></dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:00:00 GMT</pubDate>
      <category><![CDATA[AI/ML]]></category>
      <category><![CDATA[Valkey]]></category>
      <guid isPermaLink="true">https://www.gomomento.com/blog/stop-counting-indexes/</guid>
      <description><![CDATA[<p>Fifty extra indexes don't slow down your writes, but one vector field slows them down 11x. I wanted to figure out why valkey-search behaves this way, and it wasn't what I expected.</p>]]></description>
      <content:encoded><![CDATA[<p>Free text search is a beast. Sometimes a user will type in a description of what they’re looking for, like “wireless earbuds under $100,” and other times they’ll copy/paste a SKU for an item they’re replacing. Both are completely valid use cases, but the former requires a vector search and the latter relies on lexical matching. I’ve been spending a lot of time in <a href="https://valkey.io/topics/search/">valkey-search</a> trying to handle both. As you can probably imagine, one index doesn’t cut it, but two indexes on the same keys can.</p>
<p>But doubling the number of indexes I was using made me nervous. I didn’t know what impact that would have on performance. I’m using Valkey because I needed ultra-low latency, I didn’t want to shoot myself in the foot trying to be clever.</p>
<p>And yes, going from one to two indexes is probably not a big deal. But indexes have a way of piling up. You create one to filter products by category, then add price to it a month later, then the search team wants a vector field, then the payment team indexes their keys, then fulfillment indexes theirs. Before you know it, there are a dozen <code>FT.CREATE</code> statements in the repo with no single owner.</p>
<p>My instinct tells me that performance scales inversely with the count. Every index in valkey-search subscribes to a key prefix. When you write a matching key, that index gets an entry in a mutation queue, and your client stays blocked until every entry is processed. That’s what gives you read-after-write consistency. If you have a dozen indexes, you also have a dozen entries, and a dozen times the wait. Right?</p>
<p>So I built a benchmark to see how much additional indexes cost in valkey-search. And I discovered I was very wrong.</p>
<h2 id="the-setup">The setup</h2>
<p>The server was an <code>r7i.4xlarge</code> with 8 physical cores, running Valkey 9.1.1 and valkey-search 1.2.1. valkey-search sized its own writer pool to 8 threads. I turned persistence off, because I didn’t need a background save forking mid-run throwing a wrench into my latency numbers. Load came from a separate <code>c7i.8xlarge</code> in the same placement group, over 384 connections.</p>
<p>Every write is the same 4.6KB product hash that includes a category, a price, a SKU, a title, a description, and a 1024-dimension embedding. The load generator sends on a schedule and times each request from when it was supposed to go out, not when it managed to (more on this later). Every point runs for 25 seconds, three times, and I capture the median.</p>
<h2 id="do-idle-indexes-cost-anything">Do idle indexes cost anything?</h2>
<p>As I mentioned earlier, indexes subscribe to a key prefix. If a key is upserted that doesn’t match on an index, does that slow things down? In other words, my test here determines if the existence of indexes slows down the performance of others.</p>
<p>I ran the same write workload against <code>product:</code> keys in two setups: one index on <code>product:</code>, and another one with the same index plus fifty more indexes on unrelated prefixes.</p>




















<table><thead><tr><th>setup</th><th>sustained writes/sec</th><th>p99 @ 2,000/s</th></tr></thead><tbody><tr><td>1 index on <code>product:</code></td><td>32,000</td><td>2.18ms</td></tr><tr><td>same index + 50 on other prefixes</td><td>32,000</td><td>2.17ms</td></tr></tbody></table>
<p>Nice! I couldn’t measure a difference between them at any rate I tried.</p>
<p>This is because the prefix subscriptions live in a <a href="https://en.wikipedia.org/wiki/Trie">trie</a>. When you write <code>product:88213</code>, valkey-search walks the trie and only notifies the indexes whose prefix matches. An index subscribed to <code>orders:</code> doesn’t hear about it. <code>FT.INFO idx:other0</code> reports a field called <code>mutation_queue_size</code>, and across the entire run it never left zero, which validates the promise of the trie architecture.</p>
<p>So that hodgepodge of indexes in your repo isn’t what’s slowing your writes down. Only indexes whose prefix matches the keys you write are in the path at all.</p>
<h2 id="the-impact-of-matching-indexes">The impact of matching indexes</h2>
<p>So what’s the performance impact of having multiple matching indexes? I ran the same workload again, this time adding multiple identical indexes directly on the <code>product:</code> prefix.</p>






























<table><thead><tr><th>indexes on <code>product:</code></th><th>writes/sec</th><th>indexing jobs/sec</th></tr></thead><tbody><tr><td>0</td><td>80,000</td><td>0</td></tr><tr><td>1</td><td>33,636</td><td>33,636</td></tr><tr><td>2</td><td>20,000</td><td>40,000</td></tr><tr><td>4</td><td>16,818</td><td>67,272</td></tr></tbody></table>
<p>Every matching index gets an indexing job, and the write isn’t done until all of them are. To figure this out, I took Valkey’s <code>search_ingest_hash_keys</code> counter and divided by the writes that completed in the same window. I took this number and multiplied it by the write rate to calculate the indexing jobs/sec column.</p>
<p>The expensive part, naturally, is switching indexing on in the first place. Going from zero indexes to one cost me 58% of my write rate. The second index cost another 41%. Doubling from two to four only cost 16%. Each additional index cost less than the previous one.</p>
<p>Adding indexes pulls more total indexing work per second out of the same server, 33,636 jobs a second at one index and 67,272 at four. So one or two indexes clearly weren’t saturating the writer pool, because it eventually went on to do twice the work.</p>
<p>Indexing jobs fan out across a pool sized to your physical core count, and the blocked-client handles collapse into a single block on your connection. The write waits for the slowest single job, which is why four indexes don’t cost four times what one does.</p>
<p>Unfortunately I don’t know what the limiting factor is. My hunch is that it’s the per-index bookkeeping that happens on the main thread before a job reaches the pool, but I didn’t measure that, so 🤷.</p>
<p><img src="https://www.gomomento.com/blog/2026-08-27_stop-counting-indexes/fanout-p99.webp" alt="p99 write latency against offered write rate, for zero, one, two and four indexes on the product: prefix. All four curves are within about a millisecond of each other below 10,000 writes per second, then separate and turn sharply upward, each at a different rate."></p>
<p><em>NOTE - Adding matching indexes won’t appear to add latency until it’s too late. At 2,000 writes/sec, one index and four indexes were within a millisecond of each other. You won’t catch it watching p99 on a healthy system, because what you’re spending is headroom. You find out it’s gone when you need it.</em></p>
<h2 id="vector-fields-hit-different">Vector fields hit different</h2>
<p>The benchmarks above use TAG and NUMERIC fields, just the normal filter field types. So I went back to the first benchmark, added a single 1024-dimension HNSW vector field to the one-index setup, and re-ran it to find some staggering results.</p>
<p>32,000 writes per second became 2,828. 🤯</p>
<p>That’s an 11x difference in throughput because of a single field. My napkin math says that’s 2-3 milliseconds of CPU per vector insert spread across eight writer threads. To make matters worse, the performance cliffs. I increased the rate on my benchmark by ~40%, and the wheels fell off.</p>




















<table><thead><tr><th>offered writes/sec</th><th>p99</th><th>writer queue depth</th></tr></thead><tbody><tr><td>2,828</td><td>5.65ms</td><td>4</td></tr><tr><td>4,000</td><td>2,046ms</td><td>376</td></tr></tbody></table>
<p>The performance hit goes from five milliseconds to two seconds. The queue went from basically empty to 376 entries deep, and it stayed there for every rate I tried above that. You’re either under the line and fine, or over it and everything is late. If you’re capacity planning, be sure to check whether an index on your hot prefix has a vector field.</p>
<h2 id="do-vector-fields-affect-other-matching-indexes">Do vector fields affect other matching indexes?</h2>
<p>Back to the search feature I was building that required two indexes. A single index can’t do both jobs because of <a href="https://valkey.io/topics/search-data-formats/#stop-word-removal">stop words</a>. The text pipeline is configured for the entire index, and it splits words on punctuation before dropping anything in the stop word list. So <code>IT-500</code> becomes <code>it</code> and <code>500</code>, <code>it</code> is a stop word, so just <code>500</code> is added to the index. Turning on <code>NOSTOPWORDS</code> means your description field will index every <em>the</em>, <em>is</em>, <em>and</em>, and <em>it</em> (plus a lot more) in the catalog. So we need two indexes to have it turned on for one and off for the other.</p>
<p>But that made me wonder what the second index costs when the first one has a vector field. We saw how much of a hit it made to throughput in our earlier benchmarks.</p>




















<table><thead><tr><th>configuration</th><th>sustained writes/sec</th><th>p99</th></tr></thead><tbody><tr><td>semantic (TAG + NUMERIC + HNSW)</td><td>2,828</td><td>5.29ms</td></tr><tr><td>semantic + exact (TEXT, NOSTOPWORDS)</td><td>2,828</td><td>5.44ms</td></tr></tbody></table>
<p>About a three percent difference. The exact match index processed 141,408 text fields during the benchmark run. Both mutations hit the pool together, the write waits for the slower one (the vector). So if you’re already paying the HNSW tax, the lexical index has essentially no additional latency.</p>
<h2 id="my-takeaways">My takeaways</h2>
<p>So it turns out I was asking the wrong question when I started this experiment. I thought the number of indexes I had was going to slow performance down to a crawl. But it doesn’t. The real question is <em>what fields are inside the indexes that match your keys</em>? Which is a relief, when I think about it.</p>
<p>I also learned a couple of things about benchmarking while I was busy answering the wrong question. 😅</p>
<p>Every sustained writes/sec number in this post could have been bigger. At the top rung of my ladder, the no-index setup completed 128,000 writes a second (but my tables show 80,000). At that run rate, it was making every request wait 1.9 seconds in a queue first. A server running at full utilization drains as fast as it fills, so throughput looks perfect, but at a cost to latency. So the number I used in every table is the highest rate where p99 stayed within reason.</p>
<p>It took me three runs to believe what I saw in that four-row table early in this post. The first run stepped the rate by 1.4x per rung, which put two indexes and four indexes on the same 16,000 rung. Which at first made me think indexes three and four had no performance implications. But in reality, they had both fallen apart somewhere between rungs and the ladder couldn’t show me where. So I re-ran it with 1.19x steps and got a cleaner separation. Then I noticed that ladder started at 14,000, which is already well up the curve, so it never measured what latency looks like when the server is idle. My rule for picking a sustainable rate is relative to that idle number, which meant the second run was grading itself on a curve. The third run started at 2,000 and is the one in the table. A rate ladder can’t resolve a difference smaller than its own step, and it can’t tell you where the knee is if it never saw the flat part before it.</p>
<h3 id="try-it-yourself">Try it yourself</h3>
<p>I have the benchmark, scripts, and results <a href="https://github.com/momentohq/valkey-index-bench">available in GitHub</a>. If you want to check my numbers (or disagree with them!), please do and let me know what you find.</p>
<p>If you want to run the same tests on your own Valkey cluster, you can run these three commands:</p>
<pre><code>FT.INFO &#x3C;index> # shows mutation_queue_size (write backlog) for the specified index
INFO search # shows search_writer_queue_size for the whole pool
CONFIG SET search.info-developer-visible yes # unlocks per-field-type counters like search_ingest_field_vector
</code></pre>
<p>It’s cheap to experiment with your existing clusters because these mutations are reversible. <code>FT.DROPINDEX</code> is instant and your data was never in the index to begin with. Add a shape, measure it, then discard it.</p>
<p>Happy coding!</p>]]></content:encoded>
    </item>
    <item>
      <title>Agent Memory on Valkey</title>
      <link>https://www.gomomento.com/blog/agent-memory-on-valkey/</link>
      <dc:creator><![CDATA[Allen Helton]]></dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:00:00 GMT</pubDate>
      <category><![CDATA[AI/ML]]></category>
      <category><![CDATA[Valkey]]></category>
      <guid isPermaLink="true">https://www.gomomento.com/blog/agent-memory-on-valkey/</guid>
      <description><![CDATA[<p>A filter that looks like a query detail can change how Valkey searches your vectors. By the time you notice, the important decisions are already behind you.</p>]]></description>
      <content:encoded><![CDATA[<p>Agent memory sounds like a cut-and-dried vector search problem. You embed the task, find the nearest memories, and give them back to the model. Done.</p>
<p>Unfortunately it’s not that simple. Memories need to be similar, yes, but they also need to be recent. You don’t want memories from a year ago influencing your agent. And outcome is important too. If one approach worked and another failed, you probably want the successful one steering behavior.</p>
<p>I ran into this while building a small demo that puts <a href="https://github.com/momentohq/valkey-agent-memory-demo">Valkey Search inside an agent’s inference loop</a>. Every task the agent finishes gets written as a memory. It persists the task text, the approach that worked, whether it succeeded, when it happened, and a vector of the task. Next time a similar task shows up, the agent recalls what worked instead of rediscovering it.</p>
<p>I ended up learning how valkey-search handles this the hard way, because my first <code>FT.SEARCH</code> call did not work the way I expected:</p>
<pre><code class="language-bash">FT.SEARCH idx:memory "(@outcome:{success} @created_at:[1782900000 +inf])=>[KNN 3 @vector $vec]" PARAMS 2 vec &#x3C;query-vector> DIALECT 2
</code></pre>
<p>Left of the <code>=></code> is an ordinary boolean filter of a tag and a numeric range over two fields I indexed alongside the embedding. Right of it is the vector search.</p>
<p>I thought Valkey was going to find the nearest vectors and apply the tag and timestamp filters afterward. But it doesn’t work like that (in a good way). The filter criteria are actually inputs to the query planner. Depending on how many memories they match, Valkey chooses a different algorithm to search the vector index. In other words, adding a filter changes how the search runs, which meant I needed to rethink my initial memory schema. Turns out my retrieval policy was also an index-design decision.</p>
<h2 id="nobody-post-filters-anymore">Nobody post-filters anymore</h2>
<p>If you’ve used Pinecone, Qdrant, Weaviate, or Milvus, then this should be familiar. All of them decide at query time whether to walk the graph with your filter applied or abandon the graph and brute-force the matching subset instead. Pinecone calls it single-stage filtering. Qdrant calls it query planning. Weaviate calls it a flat search cutoff. Milvus doesn’t really call it anything, it just does it 😂. valkey-search is the same idea, and on the <code>FT.SEARCH</code> vector path it doesn’t implement post-filtering at all.</p>
<p>If you’re a <a href="https://github.com/pgvector/pgvector">pgvector</a> user, however, filtering happens after the index scan. With the default <code>hnsw.ef_search</code> of 40, a filter that keeps roughly 10% of those candidates might leave you with only 4 results. <a href="https://github.com/pgvector/pgvector?tab=readme-ov-file#iterative-index-scans">Iterative index scans</a> were added in 0.8.0 to make that a little better, and they’re off by default.</p>
<p>valkey-search calls it a <a href="https://github.com/valkey-io/valkey-search/blob/main/src/query/planner.cc">query planner</a>. It estimates how many keys your filter matches and picks one of two algorithms to perform the search.</p>
<p>If the estimate is small compared to the index, it pre-filters results by walking the qualified key set, computing each distance directly, and keeping a top-k heap. The <a href="https://en.wikipedia.org/wiki/Hierarchical_navigable_small_world">HNSW graph</a> is never traversed. So valkey-search is essentially doing a brute-force scan over a tiny set.</p>
<p>If the estimate is large, it performs an inline filter. Your predicate is handed to hnswlib as an <code>isIdAllowed</code> functor and evaluated during traversal of the base layer. Non-matching nodes are still visited and expanded, they just don’t get added to the result set.</p>
<p>The cutoff point for one algorithm vs the other is 0.001. So if the number of estimated matching keys is at most .1% of the number of vectors in the index, it will go the brute-force route. Otherwise it uses the inline filter.</p>
<p>For reference, the cutoff point for Milvus is around 7%, which makes valkey-search about 70x stricter. You end up on the inline path more often than you’d think.</p>
<h2 id="make-the-filter-disappear">Make the filter disappear</h2>
<p>On my demo index with a few hundred memories, the threshold works out to well under 1 key, so every recall that matches anything takes the inline path. You’d never notice either way at that scale.</p>
<p>But what would happen with the same schema in production with 1,000,000 memories? The threshold is 1,000 keys. The planner considers the selectiveness of the filter. If <code>@outcome:{success}</code> matches 70% of the index and your <code>@created_at</code> window is also broad, you’re squarely in the inline path. And the planner is right to put you there. If you scope recall to a single tenant with 400 memories, you drop under the threshold, and the query becomes an exact scan. Perfect, fast recall because 400 distance computations is nothing.</p>
<p>Let’s make it more difficult. A filter matching 1% of a large index sits 10x above the cutoff point, so it takes the inline path, where HNSW traverses a significant number of candidates for every one it’s allowed to keep. That results in a lot of extra latency you didn’t account for. And you can’t change that by tuning it, because <code>search.prefiltering-threshold-ratio</code> is immutable unless <code>search.debug-mode</code> is on, and it isn’t in the public configurables table at all.</p>
<p>You can check which path you’re actually on, by the way. valkey-search counts both, named <code>search_prefiltering_requests_count</code> and <code>search_inline_filtering_requests_count</code>. They’re module fields, so you need <code>INFO SEARCH</code> and not plain <code>INFO</code>. You can run your recall query a hundred times to see which one moves.</p>
<p>So your best bet is the index itself. Make the filter act like a namespace. Put a hash tag in the index name, prefix the keys to match, and each tenant’s recall hits one shard against a small index where the filter is mostly irrelevant. Valkey enforces it in both directions, too. A tagged index name requires every prefix to carry the same tag, and an untagged one requires that none of them do. Be careful with this though, because it’s not easy to undo if you change your mind since there’s no <code>FT.ALTER</code> here.</p>
<h2 id="forgetting-is-expensive">Forgetting is expensive</h2>
<p>Removing a vector from an HNSW index calls <code>markDelete</code> and returns. The node isn’t removed from the graph, it’s still visited, it still routes other searches through itself, it just fails the deleted check. <code>search.hnsw-allow-replace-deleted</code> would let the next insert reuse that slot, but it’s false by default and more of a non-production flag. In production the space is stranded until you drop the index.</p>
<p>Luckily, updates are a different story. Modifying an indexed vector routes to an in-place update under the existing label, so the node keeps its slot and just gets its links rewired. Writing a hash field that isn’t the vector doesn’t mess with the graph, and rewriting the vector with identical bytes short-circuits before it gets there.</p>
<p>So updates are cheap, but deletion is where it gets expensive. That includes <code>DEL</code>, eviction, and expiry.</p>
<p>That’s a little scary, because expiry <em>is</em> the recency policy. valkey-search subscribes to generic, expired, and evicted keyspace notifications, so a TTL’d memory really does leave the vector index when the key goes away. Which is what you want, but it’s also what makes graph nodes stranded.</p>
<p>Which means you have to change how you key your memories. Creating a new key every run feels like a safe and reasonable default, and it’s what my demo does with its <code>memory:&#x3C;id></code> per completed task. But add a TTL to keep things fresh, and every expiring key leaves a node behind. That’s an expensive default at scale. Instead, use a stable key derived from a task fingerprint, and update it in place as the agent learns more about that kind of task. This means the task costs only one node instead of one per attempt.</p>
<p>There are two recency controls here to consider. The <code>@created_at</code> range decides what you’re willing to believe on any given query, and it doesn’t delete anything. The TTL decides what you’re willing to pay to store. Range should be your first lever and the TTL should be the slower fallback.</p>
<h2 id="decide-before-you-have-data">Decide before you have data</h2>
<p>This demo surprised me twice: the filter I wrote as a query detail decides which algorithm runs, and the TTL I (almost) added to keep memories fresh led to stranding the index.</p>
<p>Everything here is discoverable, at least. The planner code is a short function to read and understand. The filtering comes from hnswlib. Even the deleted node behavior is a comment in the source, and the counters are available in <code>INFO SEARCH</code>. You don’t have to take my word for any of it (but you should 😜).</p>
<p>What you can’t read your way out of is when you have to make decisions. How the index is scoped and how a memory is keyed are day-one calls, made before you have a single memory to check them against. Outside of that dev-only flag, a rebuild is the only way to reclaim space that deletion stranded. Get those wrong and you’re reindexing.</p>
<p>The demo <a href="https://github.com/momentohq/valkey-agent-memory-demo">is on GitHub</a> if you want somewhere to start. <code>docker compose up -d</code> gets you Valkey with the search module and an agent that writes its own memories. Run a few tasks through it, then look at <code>INFO SEARCH</code> and find out which path your queries are actually taking.</p>
<p>Happy coding!</p>]]></content:encoded>
    </item>
    <item>
      <title>Momento Cache Cluster and Flex enter limited preview</title>
      <link>https://www.gomomento.com/blog/momento-cache-cluster-and-flex-limited-preview/</link>
      <dc:creator><![CDATA[Dylan Abraham]]></dc:creator>
      <pubDate>Tue, 18 Aug 2026 12:00:00 GMT</pubDate>
      <category><![CDATA[Valkey]]></category>
      <category><![CDATA[Product Update]]></category>
      <guid isPermaLink="true">https://www.gomomento.com/blog/momento-cache-cluster-and-flex-limited-preview/</guid>
      <description><![CDATA[<p>Momento Cache puts high-performance Valkey at your fingertips. Cluster provides direct control over topology, while Flex automatically optimizes resources.</p>]]></description>
      <content:encoded><![CDATA[<p>We’re opening a limited preview of the new <strong>Cluster</strong> and <strong>Flex</strong> configurations for <strong>Momento Cache</strong>. This release conveniently packages the technology and operating experience that power some of the largest Valkey clusters in the world.</p>
<p>Momento Cache is built for fast-moving teams who want optimal Valkey performance without the hassle of babysitting infrastructure. Cluster capacity and Flex capacity bring single-tenant, fully-managed resources to this self-service infrastructure platform.</p>
<p>Cluster provides direct control over instance type, shards, replicas, and availability zones. Flex automatically optimizes resources within specified bounds. Both were designed for workloads that need <strong>stronger isolation</strong>, <strong>more control</strong>, and a <strong>higher performance ceiling</strong> than Momento Cache’s Serverless configuration.</p>
<h2 id="avoiding-the-trap-of-operational-creep">Avoiding the trap of operational creep</h2>
<p>In the AI era, it’s trivial to stand up basic infrastructure at near-zero cost. We’ve all been there: it’s fast, it’s easy, it mostly works. It’s the right solution when you need to ship.</p>
<p>Then, growth hits. Traffic changes shape. Memory fills unevenly. A shard needs to move. A primary fails. Clients stampede. A zero-day patch lands at midnight. Suddenly, <strong>operational creep</strong> has consumed the time and the token budget that you wanted to spend on building.</p>
<p>And the problems only multiply as you add more features, products, and services. Soon your entire team is stuck fighting against infrastructure as latency, cost, and complexity steadily creep up and to the right.</p>
<h2 id="fast-reliable-efficient---pick-all-three">Fast, reliable, efficient - pick all three</h2>
<p>The Momento platform powers critical features for millions of users around the world at companies like Capcom, Coinbase, Paramount, and Snap. Now, Momento Cache puts the full power and flexibility of Valkey at your fingertips, packaging up hard-won production lessons into a streamlined service.</p>
<p>The new Cluster and Flex configurations help you to tailor a Valkey deployment to fit your specific needs. Momento seamlessly operates the lifecycle behind that system: provisioning, health, failover, rolling topology changes, version upgrades, and security patches.</p>
<p>Whether you’re pushing one thousand or one million requests per second, Momento delivers unrivaled performance and resource utilization. As you grow, your infrastructure grows alongside you. For companies with a mature platform org, Momento Cache can even be deployed in a BYOC configuration, as part of your internal developer platform.</p>
<h2 id="transparent-pricing">Transparent pricing</h2>
<p>Momento Cache pricing is designed to be simple, predictable, and cost-effective. Pricing in us-east-1:</p>
<ul>
<li><strong>Flex</strong> starts at $13 per GiB-month of physical Valkey storage</li>
<li><strong>Cluster</strong> applies a 25% surcharge to the list price for every deployed instance</li>
<li><strong>Data transfer</strong> includes 200 GiB of ingress + egress each month, then $0.05/GB</li>
</ul>
<p>Momento Cache supports rapid autoscaling within a specified capacity range, making it easy to reduce the cost of idle resources.</p>
<h2 id="designing-for-scale">Designing for scale</h2>
<p>Momento Cache employs a two-tiered architecture that significantly improves reliability and efficiency at scale. Each valkey cluster sits behind a gateway that handles the hard traffic problems like hot keys and connection storms before they hit the data layer.</p>
<p>The gateway exposes a RESP endpoint, so Momento Cache is compatible with all standard Valkey and Redis clients. It masks the underlying cluster, presenting a single stable endpoint across any topology changes.</p>
<pre><code class="language-mermaid">block
  app("Redis or Valkey\nclient"):3
  space
  gateway("gateway"):3
  space
  pool("Valkey cluster"):3

  app --> gateway
  gateway --> pool
</code></pre>
<p>The gateway is optimized to quickly process TLS, auth, rate limits, and other traffic management concerns at high concurrency and high throughput. It multiplexes client traffic across a pool of warm connections to the Valkey cluster. This reduces connection latency, and enables advanced capabilities like request coalescing.</p>
<p>The result is an efficient system with deliberate separation of responsibilities. The gateway absorbs traffic concerns, while Valkey nodes focus their resources on handling data.</p>
<h2 id="get-started-with-momento-cache">Get started with Momento Cache</h2>
<p>Try out Momento Cache in a few short steps with the <a href="https://github.com/momentohq/momento-cli">Momento CLI</a>. While the service is still in preview, you’ll also need to request access via the web console.</p>
<p>First, create an API key in the <a href="https://console.gomomento.com/">Momento console</a>, copy the endpoint for your region, and configure the default CLI profile:</p>
<pre><code class="language-sh"># paste the api key and endpoint when prompted
momento configure
</code></pre>
<p>Then, create a Capacity Pool. Be sure to provide valid zone IDs for your region:</p>
<pre><code class="language-sh">momento preview pool create \
  --name example-pool \
  --capacity-gib 32..128 \
  --replicas-per-shard 1..2 \
  --zones use1-az1,use1-az2

momento preview pool describe --name example-pool
</code></pre>
<p>Once the Capacity Pool status is <code>active</code>, you can create a Database:</p>
<pre><code class="language-sh">momento preview database create \
  --name example-db \
  --pool-name example-pool
</code></pre>
<p>Back in the console, open the pool’s <strong>Databases</strong> tab and copy the regional RESP endpoint. Connect any Valkey or Redis client in standalone mode to this endpoint over TLS on port <code>6379</code>. The Database name is the username, and your Momento API key or token is the password. Here, we’ll demonstrate with the official <a href="https://valkey.io/topics/cli/">valkey cli</a>:</p>
<pre><code class="language-sh">valkey-cli -h &#x3C;resp-endpoint> -p 6379 --tls \
  --user example-db --pass &#x3C;momento-api-key>

> SET example-key "ready"
OK
> GET example-key
"ready"
</code></pre>
<p>Congratulations! You now have a high-performance Valkey cluster ready to go.</p>
<p>Next, check out the <a href="https://docs.momentohq.com/product/cache/">docs</a> to learn more about Momento Cache’s capabilities. Or, if you want to push your cache to the limit, load up <a href="https://github.com/cachecannon/cachecannon">cachecannon</a> on a <code>c7g.xlarge</code> instance in the same region and zone as your Database!</p>
<h2 id="up-next">Up next</h2>
<p>This launch begins the next chapter for Momento Cache. Stay tuned as we port more features from our enterprise services into Momento Cache, including fine-grained access control, VPC peering, and S3 integration!</p>
<p>We’re looking for feedback from teams operating demanding Valkey workloads as we refine the product. If you’re building a fast-growing product, operating a large Valkey or Redis cluster, designing an internal caching platform, or helping teams adopt Valkey, we would love to hear what you need next and where we can help out.</p>
<p>We’re grateful to the engineers and partners who turned years of demanding operating experience into a service anyone can start using today. Special thanks to <strong>Dylan Abraham</strong> and <strong>Jason LaPier</strong> for leading the development effort!</p>]]></content:encoded>
    </item>
    <item>
      <title>Consistency compounds: Valkey's journey to 200 Gbps</title>
      <link>https://www.gomomento.com/blog/consistency-compounds-valkeys-journey-to-200-gbps/</link>
      <dc:creator><![CDATA[Khawaja Shams]]></dc:creator>
      <pubDate>Thu, 23 Jul 2026 16:00:00 GMT</pubDate>
      <category><![CDATA[Caching]]></category>
      <category><![CDATA[Valkey]]></category>
      <guid isPermaLink="true">https://www.gomomento.com/blog/consistency-compounds-valkeys-journey-to-200-gbps/</guid>
      <description><![CDATA[<p>Across four releases, I/O-threading changes removed a serial copy bottleneck, cut p99 latency, and brought large GETs to line rate on our 200 Gbps test rig.</p>]]></description>
      <content:encoded><![CDATA[<p><img src="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/read-bandwidth.svg" alt="Valkey GET bandwidth by value size across versions 7.2, 8.1, 9.0, and 9.1, with the 200 Gbps NIC limit marked"></p>
<p>If you came here looking for a post from me on consistency models, I have to disappoint you. Today, I want to talk about the value of consistency in life. In life, effort is additive, but consistency is multiplicative.</p>
<p>Over the last few years, I have had the honor of watching the Valkey project blossom from an idea into an inspirational, community-driven effort with consistent improvements in each release. These improvements range from memory efficiency to availability at scale to substantial performance gains. These small improvements compound.</p>
<p>This compounding effect and the power of a driven community is perhaps best illustrated by the journey of the I/O-threading architecture and its impact on large objects from 1 MB to 64 MB in Valkey. Larger items are becoming increasingly important for inference KV caches, 4K video streaming, and other enticing use cases. At the very least, the impact of large objects should not be overlooked, as they can <a href="https://www.gomomento.com/blog/large-objects-in-valkey-9-0/">impact everyone else’s latency</a>; this post measures how fast the large objects themselves go.</p>
<p>Buckle up, because it’s about to get spicy.</p>
<h2 id="a-brief-history-of-io-threads">A brief history of I/O threads</h2>
<p>Valkey 7.2 inherited an I/O-thread model from a software stack doing its best to stay single-threaded. The main thread and I/O threads worked in coordinated phases, separated by synchronization barriers, and the <a href="https://github.com/valkey-io/valkey/blob/7.2/valkey.conf">7.2 config file</a> is candid about the result: “Usually threading reads doesn’t help much.”</p>
<p>Valkey 8.0 delivered a fundamental rearchitecture of I/O threads, <a href="https://valkey.io/blog/unlock-one-million-rps/">tripling throughput to over a million requests per second</a>. This idea was not new. In August 2023, seven months before the fork, Dan Touitou filed <a href="https://github.com/redis/redis/issues/12489">redis#12489</a>, laying out exactly this design in detail, benchmarks included.</p>
<blockquote>
<p>Redis let #12489 sit. The issue is still open in the tracker today, unassigned and without a milestone.</p>
</blockquote>
<p>Within days of the fork, the Valkey community copied the proposal verbatim into <a href="https://github.com/valkey-io/valkey/issues/22">issue #22</a>, greeted it as “a true gem,” and shipped it. The new architecture enabled continuously running I/O threads connected by queues, so reads, parses, and writes proceed on separate cores while the main thread executes commands. Valkey 8.1 delivered TLS handshake offload to I/O threads in <a href="https://github.com/valkey-io/valkey/pull/1338">#1338</a>.</p>
<p>Redis shipped a <a href="https://redis.io/blog/redis-8-0-m03-is-out-even-more-performance-new-features/">strikingly similar asynchronous I/O-threading model</a> in Redis 8 in May 2025, a year after the fork and 21 months after the design landed in its own tracker. Around the same time, we put <a href="https://www.gomomento.com/blog/valkey-turns-one-how-the-community-fork-left-redis-in-the-dust/">Valkey 8.1 and Redis 8.0 head to head</a> on small objects. Valkey 8.1 outran Redis 8.0 by 37% on writes and 16% on reads.</p>
<p>Valkey 9.0 brought <a href="https://github.com/valkey-io/valkey/pull/2078">reply copy avoidance</a>, changing the performance of large items entirely. Before this change, the main thread copied the entire object into a connection reply buffer before moving to the next command. While a large item is being copied, the entire pipeline stalls. Small objects are not serviced until the copy finishes.</p>
<p>Valkey 9.0 instead passes a reference to the I/O threads and keeps the object alive with a reference count. The I/O worker for that connection hands the object’s memory directly to <code>writev()</code>. This shortens the handoff to the I/O threads and gets the main thread back to handling requests.</p>
<p>Valkey 9.1 then redesigned communication between the main thread and I/O threads around <a href="https://github.com/valkey-io/valkey/pull/3324">lock-free queues</a>, credited in the release notes with an 8-17% throughput gain.</p>
<p>Based on these changes, we expected 8.0 to lift reads and writes for smaller items but still struggle to fill the network link for larger items. We expected 9.0 to bring GETs to line rate and 9.1 to improve writes.</p>
<p>This is what open-source competition buys everyone, including teams that never leave Redis. A performance design that sat for seven months as an unassigned issue became table stakes for both projects within two years of being filed.</p>
<h2 id="show-me-the-numbers">Show me the numbers</h2>
<p>We swept values from 1 MB to 64 MB across four Valkey releases on two nodes with 200 Gbps of bandwidth between them. The full setup is below.</p>
<p><img src="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/8mb-release-journey.svg" alt="GET and SET bandwidth for 8 MB values across Valkey 7.2, 8.1, 9.0, and 9.1"></p>
<h3 id="valkey-72-reads-capped-at-35-gbps-writes-at-55">Valkey 7.2: reads capped at 35 Gbps, writes at 55</h3>
<p><img src="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/release-7-2-bandwidth.svg" alt="GET and SET bandwidth by value size for Valkey 7.2.13, with GET shown as a solid line and SET shown as a dashed line"></p>
<p>An 8 MB value delivers 21 Gbps on reads and 23 Gbps on writes, with p99 latencies of 174 and 164 ms. Turning on <code>io-threads-do-reads</code> moved only the 1 MB read cell.</p>
<h3 id="valkey-80-and-81-writes-take-off-large-reads-stay-put">Valkey 8.0 and 8.1: writes take off, large reads stay put</h3>
<p><img src="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/release-8-1-bandwidth.svg" alt="GET and SET bandwidth by value size, comparing Valkey 7.2.13 in red with Valkey 8.1.8 in orange"></p>
<p>The threading rebuild lifts our 8 MB write from 23 to 137 Gbps, six times faster, and 1 MB reads reach 183 Gbps. Larger reads settle at 30-33 Gbps whether the value is 8 MB or 64 MB. That flat floor points to a serial, per-byte bottleneck. Valkey 8.0.9 and 8.1.8 measured the same at every size, so one line carries both.</p>
<h3 id="valkey-90-large-gets-jump-to-line-rate">Valkey 9.0: large GETs jump to line rate</h3>
<p><img src="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/release-9-0-get-bandwidth.svg" alt="GET bandwidth by value size, comparing Valkey 8.1.8 in orange with Valkey 9.0.4 in green"></p>
<p>Every size from 1 MB to 64 MB reads at 190-201 Gbps. The 8 MB p99 falls from 112 to 24 ms, and a 64 MB read drops from just under a second to 230 ms. Writes do not move because the ingest path was never copy-bound. That ceiling waits for 9.1.</p>
<h3 id="valkey-91-more-headroom-for-writes">Valkey 9.1: more headroom for writes</h3>
<p><img src="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/release-9-1-set-bandwidth.svg" alt="SET bandwidth by value size, comparing Valkey 9.0.4 in dark green with Valkey 9.1.0 in light green"></p>
<p>With reads pinned at the network limit, the gain surfaces on writes. The 8 MB write rises from 134 to 166 Gbps, a 24% gain, while values from 12 MB to 64 MB gain 11-19%.</p>
<h2 id="the-short-run-held">The short run held</h2>
<p>To make sure the 15-second runs were not catching a lucky window, I reran every 8 MB GET and SET cell for 15 minutes. Valkey 9.1 is a good example: GET held 200.8 Gbps and SET held 165.5 Gbps, right on top of the original 201 and 166 Gbps results. Nothing sagged as the runs went on.</p>
<picture>
  <source media="(max-width: 640px)" srcset="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/long-run-stability-mobile.svg">
  <img src="https://www.gomomento.com/assets/content/blog/consistency-compounds-valkeys-journey-to-200-gbps/long-run-stability.svg" alt="Valkey 9.1 GET and SET throughput holding steady over 15 minutes" loading="lazy">
</picture>
<h2 id="detailed-results">Detailed results</h2>
<h3 id="reads-get">Reads (GET)</h3>
<p>GET-only, 32 connections, 100% hit rate.</p>
<h4 id="get-bandwidth-gbps">GET bandwidth (Gbps)</h4>



























































<table><thead><tr><th>Value size</th><th align="right">7.2.13</th><th align="right">8.1.8</th><th align="right">9.0.4</th></tr></thead><tbody><tr><td>1 MB</td><td align="right">32</td><td align="right">183</td><td align="right">200</td></tr><tr><td>2 MB</td><td align="right">35</td><td align="right">55</td><td align="right">193</td></tr><tr><td>4 MB</td><td align="right">26</td><td align="right">45</td><td align="right">197</td></tr><tr><td>8 MB</td><td align="right">21</td><td align="right">33</td><td align="right">200</td></tr><tr><td>12 MB</td><td align="right">21</td><td align="right">32</td><td align="right">191</td></tr><tr><td>16 MB</td><td align="right">21</td><td align="right">32</td><td align="right">201</td></tr><tr><td>32 MB</td><td align="right">21</td><td align="right">32</td><td align="right">196</td></tr><tr><td>64 MB</td><td align="right">22</td><td align="right">31</td><td align="right">191</td></tr></tbody></table>
<p>Note: Valkey 9.1 reads also hold line rate, so the table stops at 9.0.</p>
<h4 id="get-p99-latency-ms">GET p99 latency (ms)</h4>



























































<table><thead><tr><th>Value size</th><th align="right">7.2.13</th><th align="right">8.1.8</th><th align="right">9.0.4</th></tr></thead><tbody><tr><td>1 MB</td><td align="right">13</td><td align="right">2.2</td><td align="right">2.4</td></tr><tr><td>2 MB</td><td align="right">20</td><td align="right">19</td><td align="right">5.1</td></tr><tr><td>4 MB</td><td align="right">58</td><td align="right">41</td><td align="right">9.4</td></tr><tr><td>8 MB</td><td align="right">174</td><td align="right">112</td><td align="right">24</td></tr><tr><td>12 MB</td><td align="right">244</td><td align="right">166</td><td align="right">56</td></tr><tr><td>16 MB</td><td align="right">329</td><td align="right">238</td><td align="right">56</td></tr><tr><td>32 MB</td><td align="right">531</td><td align="right">489</td><td align="right">116</td></tr><tr><td>64 MB</td><td align="right">948</td><td align="right">1,020</td><td align="right">230</td></tr></tbody></table>
<h3 id="writes-set">Writes (SET)</h3>
<p>SET-only, 32 connections.</p>
<h4 id="set-bandwidth-gbps">SET bandwidth (Gbps)</h4>




































































<table><thead><tr><th>Value size</th><th align="right">7.2.13</th><th align="right">8.1.8</th><th align="right">9.0.4</th><th align="right">9.1.0</th></tr></thead><tbody><tr><td>1 MB</td><td align="right">51</td><td align="right">201</td><td align="right">201</td><td align="right">201</td></tr><tr><td>2 MB</td><td align="right">53</td><td align="right">200</td><td align="right">200</td><td align="right">201</td></tr><tr><td>4 MB</td><td align="right">55</td><td align="right">200</td><td align="right">200</td><td align="right">201</td></tr><tr><td>8 MB</td><td align="right">23</td><td align="right">137</td><td align="right">134</td><td align="right">166</td></tr><tr><td>12 MB</td><td align="right">23</td><td align="right">135</td><td align="right">139</td><td align="right">166</td></tr><tr><td>16 MB</td><td align="right">24</td><td align="right">137</td><td align="right">139</td><td align="right">165</td></tr><tr><td>32 MB</td><td align="right">24</td><td align="right">139</td><td align="right">134</td><td align="right">159</td></tr><tr><td>64 MB</td><td align="right">24</td><td align="right">130</td><td align="right">134</td><td align="right">149</td></tr></tbody></table>
<h4 id="set-p99-latency-ms">SET p99 latency (ms)</h4>




































































<table><thead><tr><th>Value size</th><th align="right">7.2.13</th><th align="right">8.1.8</th><th align="right">9.0.4</th><th align="right">9.1.0</th></tr></thead><tbody><tr><td>1 MB</td><td align="right">8.7</td><td align="right">3.3</td><td align="right">3.2</td><td align="right">3.2</td></tr><tr><td>2 MB</td><td align="right">17</td><td align="right">6.5</td><td align="right">6.7</td><td align="right">5.3</td></tr><tr><td>4 MB</td><td align="right">35</td><td align="right">12</td><td align="right">13</td><td align="right">14</td></tr><tr><td>8 MB</td><td align="right">164</td><td align="right">41</td><td align="right">49</td><td align="right">34</td></tr><tr><td>12 MB</td><td align="right">289</td><td align="right">62</td><td align="right">57</td><td align="right">52</td></tr><tr><td>16 MB</td><td align="right">357</td><td align="right">77</td><td align="right">70</td><td align="right">60</td></tr><tr><td>32 MB</td><td align="right">1,580</td><td align="right">118</td><td align="right">143</td><td align="right">107</td></tr><tr><td>64 MB</td><td align="right">2,580</td><td align="right">237</td><td align="right">213</td><td align="right">206</td></tr></tbody></table>
<h2 id="the-setup">The setup</h2>
<p><strong>Machines.</strong> Two <code>c8gn.16xlarge</code> instances with Graviton4 and 200 Gbps networking in the same availability zone and cluster placement group, running Amazon Linux 2023.</p>
<p><strong>Server.</strong> We tested the official <code>valkey/valkey</code> Docker image at versions <code>7.2.13</code>, <code>8.1.8</code>, <code>9.0.4</code>, and <code>9.1.0</code>. We also measured <code>8.0.9</code>. It matched <code>8.1.8</code> within run-to-run noise at every size, so the tables show 8.1 as the 8.x column. Valkey 7.2 ran with the same flags as the newer versions. A control with <code>io-threads-do-reads yes</code> changed only the 1 MB GET cell, from 32 to 41 Gbps. Each version ran in a fresh container with host networking:</p>
<pre><code class="language-sh">docker run --network host --cpuset-cpus 8-23 \
  --ulimit nofile=32768:65536 valkey/valkey:&#x3C;version> \
  --save '' --appendonly no --io-threads 16 \
  --protected-mode no --maxmemory 60gb
</code></pre>
<p>Persistence was off. The 16 threads in Valkey’s <code>io-threads</code> count were one main thread plus 15 I/O workers. The process was pinned to cores 8-23 so it never fought the kernel for the cores doing network interrupt work.</p>
<p><strong>Interrupts.</strong> <code>irqbalance</code> was off on both machines. The ENA NIC was configured with four combined queues and their IRQs pinned to cores 0-3. Without this step, results wander from run to run as the kernel shuffles interrupts onto whatever cores the server or client threads happen to be using. If you benchmark at these speeds, pin your IRQs first and thank yourself later.</p>
<pre><code class="language-sh"># Run on both machines. ens50 is the ENA interface name on these instances.
sudo systemctl stop irqbalance
sudo ethtool -L ens50 combined 4
i=0
for irq in $(grep ens50 /proc/interrupts | awk '{print $1}' | tr -d ':'); do
  echo $((i%4)) | sudo tee /proc/irq/$irq/smp_affinity_list > /dev/null
  i=$((i+1))
done
</code></pre>
<p><strong>Client.</strong> We used <a href="https://github.com/cachecannon/cachecannon">valkey-lab</a>, built on cachecannon, with 16 worker threads pinned to cores 4-19, 32 connections, and pipeline depth 1. Reads and writes were measured in separate passes. Read passes prefilled the keyspace and ran at a 100% hit rate. Each short-run cell was a 15-second measurement after a five-second warmup, over a 500-key keyspace with 16-byte keys.</p>
<p>We kept concurrency at 32 connections because AWS caps a single TCP flow at roughly 9.5 Gbps. We verified 9.53 Gbps with iperf3. Saturating a 200 Gbps NIC requires spreading the load across flows.</p>
<p>One invocation per cell, with <code>-s</code> swept across the value sizes:</p>
<pre><code class="language-sh"># GET pass: prefill the 500-key keyspace, then measure at a 100% hit rate.
valkey-lab -h &#x3C;server> -c 32 -P 1 -t 16 --cpu-list 4-19 \
  -n 500 -r 100:0 --prefill --warmup 5s -d 15s \
  -s &#x3C;value_bytes> --key-size 16

# SET pass.
valkey-lab -h &#x3C;server> -c 32 -P 1 -t 16 --cpu-list 4-19 \
  -n 500 -r 0:100 --warmup 5s -d 15s \
  -s &#x3C;value_bytes> --key-size 16
</code></pre>
<p><strong>Scope of the result.</strong> This is a single-node, no-TLS, persistence-disabled test with 32 closed-loop connections, pipeline depth 1, and a 100% hit rate. Most table cells are one 15-second measurement after warmup. The results describe this rig and workload. Broader production claims need repeated runs and additional configurations.</p>
<p><em>We benchmarked whole releases rather than individual changes, so we treat the pull requests named in this post as leading explanations rather than proof of causality.</em></p>
<h2 id="what-this-means-if-you-run-valkey">What this means if you run Valkey</h2>
<p>Valkey 9.x delivers roughly 6x the 64 MB read bandwidth of 8.x while cutting p99 from just under a second to about 230 ms on this single-node, no-TLS test. Our earlier mixed-workload test also found far less collateral latency for small requests when a large read arrived.</p>
<p>The practical takeaway is narrower than “Valkey is always faster.” Valkey 8.1 is essentially flat on this workload, Valkey 9.0 changes the large-GET path, and Valkey 9.1 improves large SETs on this rig. If large values matter to your workload, test 9.x with your object-size distribution, concurrency, TLS, and persistence settings rather than extrapolating from a small-object benchmark, or from this one.</p>
<p>Valkey has consistently improved performance, memory efficiency, and availability at scale. Each version brings about a new set of improvements driven by issues faced by real users in production. A vibrant community where nobody is incentivized to withhold features for the sake of revenue and everyone is incentivized to chase continuous improvement is what makes Valkey truly special. Effort is additive. Consistency is multiplicative.</p>
<p>Serving megabyte-sized objects at wire speed is what I’ve been spending a lot of time on lately. If you are wrangling larger objects, let’s talk. <a href="https://valkey.io/slack/">Join me on Valkey Slack</a>.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
