{"article":{"slug":"speeding-up-gearhash-on-arm64-2-faster","title":"Speeding up gearhash on ARM64 (2× faster)","subtitle":null,"summary":"The gearhash crate has gained a NEON backend for improved performance. How a direct port started out slower than scalar, and the dependency-chain work that fixed it.","content_type":"blog_post","language":"en","canonical_url":"https://sam.dev/blog/gearhash-on-arm64","author":{"name":"Sam Reis","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"sam.dev","url":"https://sam.dev/","listing_slug":null,"listing":null},"topics":[{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1709,"reading_minutes":7,"published_at":"2026-09-06T00:00:00.000Z","added_at":"2026-09-18T06:16:56.082Z","updated_at":"2026-09-18T06:16:56.082Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/speeding-up-gearhash-on-arm64-2-faster","markdown_url":"https://listedarticles.com/articles/speeding-up-gearhash-on-arm64-2-faster.md","example":false,"citation":"Sam Reis, sam.dev. \"Speeding up gearhash on ARM64 (2× faster).\" 6 Sept 2026. https://sam.dev/blog/gearhash-on-arm64 (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://sam.dev/blog/gearhash-on-arm64"},"body_markdown":"**tl;dr:** As of version `0.1.4`, the\n[`gearhash`](https://crates.io/crates/gearhash) crate has gained a NEON backend\nwhich makes it **roughly 2× faster on ARM64** at typical chunk sizes. It is\nselected automatically on aarch64 and backwards compatible, so consumers of the\ncrate don't need to do more than just update. Read on if you're interested in\nthe details of how this was achieved, or skip straight to the [final\nresults](#final-results).\n\nAt the end of 2019, I was building a personal backup system, and as part of this, became interested in a technique called content-defined chunking. The key idea behind it is that instead of chunking files on fixed chunk boundaries, you run a sliding window hash function across the file and trigger a chunk boundary whenever the hash has a particular value. The downside is that this gives you variable length chunks over a distribution, but the upside is that your chunking is now much more resilient to byte sequences being inserted or removed from the middle of files.\n\nAnyway, as part of this I came across the [FastCDC\npaper](https://www.usenix.org/node/196197). Its building block is the GEAR\nrolling hash. Because I like fast things, I spent quite some time trying to work\nout how to convert the serial algorithm published in the paper into a SIMD\nalgorithm. I ended up publishing the result of this as\n[`gearhash`](https://crates.io/crates/gearhash), a small Rust crate with\noptimizations for SSE4.2 and AVX2.\n\nWhen I wrote the crate, ARM64 was not really a target worth optimizing for. AWS had offered ARM64 instances for a year, but only the first-generation Graviton A1 family, built on Cortex-A72 cores and marketed for scale-out workloads rather than general compute. Graviton2, the first generation with a competitive core, was announced at re:Invent the same month as my first commit and did not reach general availability until May 2020. Apple announced the M1 in November 2020.\n\nFast forward to today, a lot has changed. Apple has pushed ARM64 into the\nmainstream of consumer hardware. AWS has shipped several further Graviton\ngenerations and says that for three years running more than half of the new CPU\ncapacity it added has been Graviton. GitHub Actions added free ARM64 runners for\npublic repositories in 2025. On all of those machines, the `gearhash` crate was\nfalling back to the scalar loop.\n\nOn top of this, while `gearhash` initially had virtually no production users\naside from myself, it has since become a core part of the [Xet\nclient](https://github.com/huggingface/xet-core), Hugging Face's storage\nprotocol for large files on the Hub, which has replaced Git LFS as the default.\nFor `gearhash` this means we're now doing between 10k and 20k downloads per day.\nThis renewed interest in the crate helped me find the motivation to see where I\ncan push things further.\n\nThe gear hash kernel is defined as a serial function over 64-bit unsigned integers:\n\nTwo properties make this difficult to vectorize:\n\n1. **It is a serial dependency chain.** Every byte's hash depends on the\nprevious byte's. There is no data parallelism to extract from a single\nstream.\n2. **The table lookup is a gather.** 256 × 8 bytes is 2 KB, far too large for\nany in-register permute. Every byte costs a real load.\n\nAfter banging my head against this for a bit, I ended up making an observation\nabout the first property: the hash is 64 bits wide and shifts left by one bit\nper byte, so after 64 bytes the starting value has been shifted out completely.\nThat means you can start hashing at any offset in a buffer with `hash = 0`, warm\nup over 64 bytes, and from then on the hash is bit-identical to a pass from the\nstart.\n\nWhat this enables is that a chunk can be split into *strips*: seed lane 0 with\nthe real incoming hash, seed every other lane by hashing the 64 bytes that\nprecede its strip, and run all strips in lockstep. When a lane reports a match,\nyou just need to work out *which* match is earliest, which is where most of the\ncomplexity in the implementation ended up being.\n\nI started out by doing a straight port from the SSE4.2 implementation.\n`aarch64::uint64x2_t` is two 64-bit lanes, the same as `x86_64::__m128i`, so the SSE4.2 structure\nmaps over almost mechanically.\n\nThe one thing that did not map over is the mask extraction. NEON has no\nequivalent of `pmovmskb`, so getting the lane comparison results into a scalar\nregister takes a narrowing shift and a move, which I wrapped in a small\n`movemask` helper.\n\nThe result was disappointing: 0.92×, slower than the scalar code.\n\nTo understand why, we need to take a look at the loop-carried latency on ARM64. Per iteration the NEON version would do this:\n\nOn Apple cores each of these are ~2 cycles each (per [Dougall Johnson's M1\ntables](https://dougallj.github.io/applecpu/firestorm-simd.html)), so ~4 cycles\nper iteration, and an iteration covers 2 bytes (one per lane), which comes out\nto ~2 cycles per byte.\n\nThe scalar version, `hash = (hash << 1) + table[b]`, compiles to a single\nshifted-register add, `add x0, x1, x0, lsl #1`, with ~2 cycles of latency. That\nis also ~2 cycles per byte.\n\nWhich means that the vector version does the same amount of work per unit of critical path as the scalar one, but on top of that has to pay for the loads and the mask extraction. It cannot come out ahead.\n\nTo win on NEON, the dependency chain itself has to get shorter.\n\nIf the chain is 2 ops per 2 bytes, why not make it 2 ops per 4 bytes by writing out two steps of the per-byte update and multiplying through:\n\n```\nh₁ = (h << 1) + g₀\nh₂ = (h << 2) + (g₀ << 1) + g₁\n```\nWith this, `h₂` depends on `h` through a single shift and a single add,\nprovided you precompute `G = (g₀ << 1) + g₁`. `G` depends only on table\nlookups, not on `h`, so it is off the critical path.\n\nResult: 0.92× → 1.13×, better, but still well short of the expected 2×.\n\nTurns out, LLVM had just gone and reassociated it! I wrote `(h << 2) + (G₀ + G₁)` and it emitted `((h << 2) + G₁) + G₀`. This is a legal transformation of\ncourse, but it puts a second add back on the dependency chain.\n\nYou cannot stop the compiler reassociating a sum, but you can (try to) stop it seeing one. The combined term is built from two table lookups, and those arrive in general-purpose registers anyway, so the combining can just happen there:\n\nChecking disassembly now showed only `shl.2d` → `add.2d` on the chain.\n\nResult: 1.13× → 1.46×, finally starting to be meaningfully faster, but not quite fast enough!\n\nEncouraged by the result of unrolling to two steps at once, I tried the same\nwith four intermediate states, each still computed directly from `h`:\n\nThis halves the length of the dependency chain per byte again, so I expected another large step. Measuring it however, there was no difference at all. It was at this point that I suspected the limit may no longer be the latency between iterations, but instead simply the CPU throughput.\n\nFollowing that hunch, my focus shifted to try and reduce instruction counts instead. The loop was now doing two loads per byte: one for the byte itself and one for its table entry. While the latter is unavoidable, we can now can replace those four consecutive byte loads with one unaligned 32-bit load, then peel one byte off each word per step with a shift.\n\nResult: 1.46× → 1.63×, another large step towards the 2× goal.\n\nWith the loads optimized, the next largest block of instructions per iteration was the boundary test: at every step, the code checks for a chunk boundary by masking the hash and comparing it to zero.\n\nWhile I had unrolled to four intermediate states per iteration, it was still probing them one by one. That is four separate moves out of the vector unit, each with a branch waiting on it.\n\nBecause the common case is no match, what we can actually do is combine the four tests inside the vector unit and make one trip out. If none of the four positions in either strip is a boundary, then the iteration can move on after one move to a general-purpose register and one branch:\n\nOnly in the uncommon case when that check fails does the code look at the four states one by one. With the 16-bit mask the Xet client uses for its 64 KiB chunks, that happens about once every 8192 iterations.\n\nResult: 1.63× → 1.81×. Quite happy with this, and here is where I stopped for now.\n\nEverything combined, on the crate's 11-bit benchmark mask, that takes the NEON\npath from 0.92× for the direct port to 1.81× in the version that I published as\n[`0.1.4`](https://crates.io/crates/gearhash/0.1.4).\n\nHowever, one thing I realized while working on this which is quite obvious in retrospect, is how dependent the benchmark is on the mask density. That's because the optimized path has a fixed cost per call that the scalar path does not, and sparser mask means more boundaries and more calls. To see how much that matters, I ran benchmarks across a range of masks with 4 to 20 bits set.\n\n| bits set | mean chunk | scalar MB/s | NEON MB/s | ratio | \n|---|---|---|---|---|\n| 4 | 16 B | 996 | 250 | 0.25× | \n| 8 | 256 B | 1864 | 1641 | 0.88× | \n| 11 | 2 KiB | 1981 | 3565 | 1.80× | \n| 16 | 64 KiB | 1999 | 4277 | 2.14× | \n| 20 | 1 MiB | 2000 | 4347 | 2.17× | \n\nThe crate's own benchmark, at 1.8×, sits on the steep part of the curve, which keeps rising until it flattens out at about 2.17× between 64 KiB and 1 MiB. The 16-bit row is the mask the Xet client uses, at 2.14×.\n\nSidenote: Below roughly 350-byte average chunks the per-call cost outweighs the gain and the NEON path becomes progressively slower than scalar. I wouldn't expect anyone to use this type of configuration, but falling back to the scalar path for masks with few bits set seems like a cheap way to close that gap.\n\nWith NEON now roughly twice as fast as scalar, it feels like it's time to take another look at the x86 backends. Who knows, some of the tricks I learned along the way on the NEON implementation might carry over. Stay tuned!","body_html":"<p><strong>tl;dr:</strong> As of version <code>0.1.4</code>, the\n<a href=\"https://crates.io/crates/gearhash\" rel=\"nofollow ugc noopener\"><code>gearhash</code></a> crate has gained a NEON backend\nwhich makes it <strong>roughly 2× faster on ARM64</strong> at typical chunk sizes. It is\nselected automatically on aarch64 and backwards compatible, so consumers of the\ncrate don&#39;t need to do more than just update. Read on if you&#39;re interested in\nthe details of how this was achieved, or skip straight to the <a href=\"#final-results\">final\nresults</a>.</p>\n<p>At the end of 2019, I was building a personal backup system, and as part of this, became interested in a technique called content-defined chunking. The key idea behind it is that instead of chunking files on fixed chunk boundaries, you run a sliding window hash function across the file and trigger a chunk boundary whenever the hash has a particular value. The downside is that this gives you variable length chunks over a distribution, but the upside is that your chunking is now much more resilient to byte sequences being inserted or removed from the middle of files.</p>\n<p>Anyway, as part of this I came across the <a href=\"https://www.usenix.org/node/196197\" rel=\"nofollow ugc noopener\">FastCDC\npaper</a>. Its building block is the GEAR\nrolling hash. Because I like fast things, I spent quite some time trying to work\nout how to convert the serial algorithm published in the paper into a SIMD\nalgorithm. I ended up publishing the result of this as\n<a href=\"https://crates.io/crates/gearhash\" rel=\"nofollow ugc noopener\"><code>gearhash</code></a>, a small Rust crate with\noptimizations for SSE4.2 and AVX2.</p>\n<p>When I wrote the crate, ARM64 was not really a target worth optimizing for. AWS had offered ARM64 instances for a year, but only the first-generation Graviton A1 family, built on Cortex-A72 cores and marketed for scale-out workloads rather than general compute. Graviton2, the first generation with a competitive core, was announced at re:Invent the same month as my first commit and did not reach general availability until May 2020. Apple announced the M1 in November 2020.</p>\n<p>Fast forward to today, a lot has changed. Apple has pushed ARM64 into the\nmainstream of consumer hardware. AWS has shipped several further Graviton\ngenerations and says that for three years running more than half of the new CPU\ncapacity it added has been Graviton. GitHub Actions added free ARM64 runners for\npublic repositories in 2025. On all of those machines, the <code>gearhash</code> crate was\nfalling back to the scalar loop.</p>\n<p>On top of this, while <code>gearhash</code> initially had virtually no production users\naside from myself, it has since become a core part of the <a href=\"https://github.com/huggingface/xet-core\" rel=\"nofollow ugc noopener\">Xet\nclient</a>, Hugging Face&#39;s storage\nprotocol for large files on the Hub, which has replaced Git LFS as the default.\nFor <code>gearhash</code> this means we&#39;re now doing between 10k and 20k downloads per day.\nThis renewed interest in the crate helped me find the motivation to see where I\ncan push things further.</p>\n<p>The gear hash kernel is defined as a serial function over 64-bit unsigned integers:</p>\n<p>Two properties make this difficult to vectorize:</p>\n<ol><li><p><strong>It is a serial dependency chain.</strong> Every byte&#39;s hash depends on the</p><p>previous byte&#39;s. There is no data parallelism to extract from a single\nstream.</p></li><li><p><strong>The table lookup is a gather.</strong> 256 × 8 bytes is 2 KB, far too large for</p><p>any in-register permute. Every byte costs a real load.</p></li></ol>\n<p>After banging my head against this for a bit, I ended up making an observation\nabout the first property: the hash is 64 bits wide and shifts left by one bit\nper byte, so after 64 bytes the starting value has been shifted out completely.\nThat means you can start hashing at any offset in a buffer with <code>hash = 0</code>, warm\nup over 64 bytes, and from then on the hash is bit-identical to a pass from the\nstart.</p>\n<p>What this enables is that a chunk can be split into <em>strips</em>: seed lane 0 with\nthe real incoming hash, seed every other lane by hashing the 64 bytes that\nprecede its strip, and run all strips in lockstep. When a lane reports a match,\nyou just need to work out <em>which</em> match is earliest, which is where most of the\ncomplexity in the implementation ended up being.</p>\n<p>I started out by doing a straight port from the SSE4.2 implementation.\n<code>aarch64::uint64x2_t</code> is two 64-bit lanes, the same as <code>x86_64::__m128i</code>, so the SSE4.2 structure\nmaps over almost mechanically.</p>\n<p>The one thing that did not map over is the mask extraction. NEON has no\nequivalent of <code>pmovmskb</code>, so getting the lane comparison results into a scalar\nregister takes a narrowing shift and a move, which I wrapped in a small\n<code>movemask</code> helper.</p>\n<p>The result was disappointing: 0.92×, slower than the scalar code.</p>\n<p>To understand why, we need to take a look at the loop-carried latency on ARM64. Per iteration the NEON version would do this:</p>\n<p>On Apple cores each of these are ~2 cycles each (per <a href=\"https://dougallj.github.io/applecpu/firestorm-simd.html\" rel=\"nofollow ugc noopener\">Dougall Johnson&#39;s M1\ntables</a>), so ~4 cycles\nper iteration, and an iteration covers 2 bytes (one per lane), which comes out\nto ~2 cycles per byte.</p>\n<p>The scalar version, <code>hash = (hash &lt;&lt; 1) + table[b]</code>, compiles to a single\nshifted-register add, <code>add x0, x1, x0, lsl #1</code>, with ~2 cycles of latency. That\nis also ~2 cycles per byte.</p>\n<p>Which means that the vector version does the same amount of work per unit of critical path as the scalar one, but on top of that has to pay for the loads and the mask extraction. It cannot come out ahead.</p>\n<p>To win on NEON, the dependency chain itself has to get shorter.</p>\n<p>If the chain is 2 ops per 2 bytes, why not make it 2 ops per 4 bytes by writing out two steps of the per-byte update and multiplying through:</p>\n<pre><code>h₁ = (h &lt;&lt; 1) + g₀\nh₂ = (h &lt;&lt; 2) + (g₀ &lt;&lt; 1) + g₁</code></pre>\n<p>With this, <code>h₂</code> depends on <code>h</code> through a single shift and a single add,\nprovided you precompute <code>G = (g₀ &lt;&lt; 1) + g₁</code>. <code>G</code> depends only on table\nlookups, not on <code>h</code>, so it is off the critical path.</p>\n<p>Result: 0.92× → 1.13×, better, but still well short of the expected 2×.</p>\n<p>Turns out, LLVM had just gone and reassociated it! I wrote <code>(h &lt;&lt; 2) + (G₀ + G₁)</code> and it emitted <code>((h &lt;&lt; 2) + G₁) + G₀</code>. This is a legal transformation of\ncourse, but it puts a second add back on the dependency chain.</p>\n<p>You cannot stop the compiler reassociating a sum, but you can (try to) stop it seeing one. The combined term is built from two table lookups, and those arrive in general-purpose registers anyway, so the combining can just happen there:</p>\n<p>Checking disassembly now showed only <code>shl.2d</code> → <code>add.2d</code> on the chain.</p>\n<p>Result: 1.13× → 1.46×, finally starting to be meaningfully faster, but not quite fast enough!</p>\n<p>Encouraged by the result of unrolling to two steps at once, I tried the same\nwith four intermediate states, each still computed directly from <code>h</code>:</p>\n<p>This halves the length of the dependency chain per byte again, so I expected another large step. Measuring it however, there was no difference at all. It was at this point that I suspected the limit may no longer be the latency between iterations, but instead simply the CPU throughput.</p>\n<p>Following that hunch, my focus shifted to try and reduce instruction counts instead. The loop was now doing two loads per byte: one for the byte itself and one for its table entry. While the latter is unavoidable, we can now can replace those four consecutive byte loads with one unaligned 32-bit load, then peel one byte off each word per step with a shift.</p>\n<p>Result: 1.46× → 1.63×, another large step towards the 2× goal.</p>\n<p>With the loads optimized, the next largest block of instructions per iteration was the boundary test: at every step, the code checks for a chunk boundary by masking the hash and comparing it to zero.</p>\n<p>While I had unrolled to four intermediate states per iteration, it was still probing them one by one. That is four separate moves out of the vector unit, each with a branch waiting on it.</p>\n<p>Because the common case is no match, what we can actually do is combine the four tests inside the vector unit and make one trip out. If none of the four positions in either strip is a boundary, then the iteration can move on after one move to a general-purpose register and one branch:</p>\n<p>Only in the uncommon case when that check fails does the code look at the four states one by one. With the 16-bit mask the Xet client uses for its 64 KiB chunks, that happens about once every 8192 iterations.</p>\n<p>Result: 1.63× → 1.81×. Quite happy with this, and here is where I stopped for now.</p>\n<p>Everything combined, on the crate&#39;s 11-bit benchmark mask, that takes the NEON\npath from 0.92× for the direct port to 1.81× in the version that I published as\n<a href=\"https://crates.io/crates/gearhash/0.1.4\" rel=\"nofollow ugc noopener\"><code>0.1.4</code></a>.</p>\n<p>However, one thing I realized while working on this which is quite obvious in retrospect, is how dependent the benchmark is on the mask density. That&#39;s because the optimized path has a fixed cost per call that the scalar path does not, and sparser mask means more boundaries and more calls. To see how much that matters, I ran benchmarks across a range of masks with 4 to 20 bits set.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>bits set</th><th>mean chunk</th><th>scalar MB/s</th><th>NEON MB/s</th><th>ratio</th></tr></thead><tbody><tr><td>4</td><td>16 B</td><td>996</td><td>250</td><td>0.25×</td></tr><tr><td>8</td><td>256 B</td><td>1864</td><td>1641</td><td>0.88×</td></tr><tr><td>11</td><td>2 KiB</td><td>1981</td><td>3565</td><td>1.80×</td></tr><tr><td>16</td><td>64 KiB</td><td>1999</td><td>4277</td><td>2.14×</td></tr><tr><td>20</td><td>1 MiB</td><td>2000</td><td>4347</td><td>2.17×</td></tr></tbody></table></div>\n<p>The crate&#39;s own benchmark, at 1.8×, sits on the steep part of the curve, which keeps rising until it flattens out at about 2.17× between 64 KiB and 1 MiB. The 16-bit row is the mask the Xet client uses, at 2.14×.</p>\n<p>Sidenote: Below roughly 350-byte average chunks the per-call cost outweighs the gain and the NEON path becomes progressively slower than scalar. I wouldn&#39;t expect anyone to use this type of configuration, but falling back to the scalar path for masks with few bits set seems like a cheap way to close that gap.</p>\n<p>With NEON now roughly twice as fast as scalar, it feels like it&#39;s time to take another look at the x86 backends. Who knows, some of the tricks I learned along the way on the NEON implementation might carry over. Stay tuned!</p>","headings":[]}}