Skip to content

Fix sha ni avx transition - #2083

Merged
evoskuil merged 7 commits into
libbitcoin:masterfrom
Raimo33:fix-sha-ni-avx-transition
Oct 14, 2026
Merged

evoskuil merged 7 commits into
libbitcoin:masterfrom
Raimo33:fix-sha-ni-avx-transition

Conversation

@Raimo33

@Raimo33 Raimo33 commented Oct 12, 2026 •

Copy link
Copy Markdown

Problem

SHA-NI instructions (sha256rnds2, sha256msg1/2, sha1rnds4, ...) only have legacy SSE encodings. If they run while the upper ymm/zmm state is dirty, each one pays an SSE/AVX transition penalty. Compilers emit vzeroupper only at call and return boundaries. So when a native sha256/sha160 transform is inlined into code that has just written a 256/512-bit register, nothing clears that state.

Whether this happens depends on inlining decisions, and those change with unrelated edits to the surrounding code. Two real triggers:

  • accumulate(state, block) copies the block into an ablocks_t<one> temporary using a zmm move.
  • A loop that runs accumulate(state, iblocks) and then vector.push_back(state): GCC copies the 32-byte state with a ymm move.

In the baseline static library, muhash3072::flush() is affected. Header-inlined use in downstream libraries can be affected too.

Changes

  • Add HAVE_AVX and sha::zeroupper(). On AVX builds this emits vzeroupper. It is a no-op on non-AVX x86, NEON and no-SHA builds.
  • Call it once at the start of each native sha256/sha160 transform, finalize and double hash, not once per round. Native sha512 is VEX-encoded and does not need it.
  • On the native path, accumulate(state, block) now transforms the block in place instead of copying it into ablocks_t<one>.
  • Add the performance test hash_performance__sha{256,160}__dirty_upper_64. It does a 256-bit store before each single-block accumulate.

Results

GCC 13.3, -O3 -msha -mavx2 -mavx512f -mavx512bw -mavx512vl -mavx512ifma, Xeon @ 2.1 GHz (AVX-512/AMX, virtualized). Median of 5. Each before/after cell shows two separate runs.

Dirty upper state (new test), ns/block

before after
sha256 native 8858 / 9003 62.6 / 62.7
sha160 native 5844 / 5606 59.7 / 58.3
sha256 scalar (reference) 276 / 279 292 / 278

Existing hash benchmarks: no regression (within noise)

before after
sha256 stream 1m native (ns/byte) 0.737 / 0.731 0.737 / 0.741
sha256 stream 32 native (ns/byte) 2.06 / 2.07 2.11 / 2.09
sha256 fixed 32 native (ns/byte) 2.13 / 2.07 2.14 / 2.06
sha256 double 64x1024 native (ns/byte) 2.31 / 2.20 2.20 / 2.25
sha256 double 64x1024 native 2 lanes (ns/byte) 1.95 / 1.76 1.78 / 1.77
sha256 merkle root 9001 native (ns/leaf) 113.6 / 114.9 114.5 / 114.5
sha160 stream 1m native (ns/byte) 0.696 / 0.695 0.688 / 0.749

Codegen (static library, objdump)

functions with SHA-NI of which without vzeroupper
before 12 11
after 11 0

A standalone reproducer forces the transform inline with --param max-inline-insns-*. Before the fix, every case stalls at 5.5–8.3 µs/block, including accumulator::write (usually ~57 ns/block). After the fix, every case runs at 50–75 ns/block.

Testing

  • Full libbitcoin-system-test suite at 54ba857 (make check, GCC 13.3, Boost 1.86, --enable-shani --enable-avx2 --enable-sse41 --enable-ndebug, static):
    Test module "libbitcoin_test" has passed with:
      7100 test cases out of 7100 passed
      35280 assertions out of 35280 passed
    
  • Performance tests with HAVE_PERFORMANCE_TESTS defined, including the new hash_performance__sha{256,160}__dirty_upper_64; native, vector and scalar results agree.
  • Compile check of the intrinsics headers under -mavx2 -msha -msse4.1, -msha -msse4.1 (no AVX) and no flags: vzeroupper is emitted only when AVX is enabled, and SHA-NI codegen is otherwise unchanged.
  • Builds with GCC 13 and Clang 18 under several flag sets.
  • NEON build not tested (sha::zeroupper() is an empty inline there).

SHA-NI (sha1/sha256) instructions have only legacy SSE encodings, so
executing them with dirty upper ymm/zmm state incurs an SSE/AVX
transition penalty. sha::zeroupper() emits vzeroupper on AVX builds and
is a no-op otherwise (including neon and no-sha builds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compilers emit vzeroupper only at call and return boundaries. When a
native sha256/sha160 transform is inlined after a 256/512 bit write,
every SHA-NI instruction pays the transition penalty (~100x slower).
Call sha::zeroupper() once at the entry of each native transform,
finalize and double hash sequence (not per round). sha512 native is VEX
encoded and unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ablocks_t<one> temporary was copied with a zmm move ahead of the
transform. On the native path, transform the block in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Single block accumulation preceded by a 256 bit store, for sha256 and
sha160 across all dispatches. Reproduces the transition stall when the
native transform is inlined (HAVE_PERFORMANCE_TESTS and HAVE_AVX2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@evoskuil

Copy link
Copy Markdown
Member

Thanks! A few things before this can go in.

All the existing native benchmarks are unchanged within noise. The only large delta comes from the new test, which puts a 256-bit store before every accumulate inside a loop built to be inlined, and the standalone reproducer needs forced inlining parameters. That shows the mechanism exists, not that it happens in practice. You name muhash3072::flush() as an affected real path. Please post before/after numbers for it, or for some other real caller, measured on non-virtualized hardware.

#ifdef in intel_sha.hpp. Raw intrinsics are wrapped in intel.hpp and gated at block level, with the alternative in #else. Please follow that pattern:

#if defined(HAVE_AVX)

// AVX
inline void mm256_zeroupper() NOEXCEPT
{
    _mm256_zeroupper();
}

#else

inline void mm256_zeroupper() NOEXCEPT
{
}

#endif // HAVE_AVX

sha::zeroupper() then just calls mm256_zeroupper() with no conditional.

Comments.

/// declaration docs should be one line, in the style of the sibling declarations. The paragraph on zeroupper() should be cut down to one line.
Please drop the new paragraph in the header of algorithm_native.ipp.
Please drop the “Transform in place...” comment in accumulate.
In transition.cpp, please drop the block comment above dirty_sink. The single comment on dirty_sink is enough.

Description. The testing section still has the TESTS_RESULT placeholder.

The in-place native accumulate(state, block) change is fine on its own merits.

Move HAVE_AVX gating into an mm256_zeroupper() wrapper in intel.hpp
(empty under #else), so sha::zeroupper() calls it unconditionally.
Reduce zeroupper() docs to one line and drop the explanatory comments
in algorithm_native.ipp, accumulate and transition.cpp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Raimo33

Raimo33 commented Oct 13, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Addressed in 54ba857:

  • Gating: mm256_zeroupper() now lives in intel.hpp, gated #if defined(HAVE_AVX) / #else (empty), and sha::zeroupper() just calls it, with no conditional of its own.
  • Docs: zeroupper() is down to one line; the other declaration docs are one line, like their siblings.
  • Comments: dropped the algorithm_native.ipp header paragraph, the "Transform in place..." comment in accumulate, and the block comment above dirty_sink.
  • Description: TESTS_RESULT filled in. Full suite at 54ba857 (GCC 13.3, Boost 1.86, --enable-shani --enable-avx2 --enable-sse41): 7100/7100 test cases, 35280/35280 assertions passed.

muhash3072::flush() numbers. One caveat up front: I don't have non-virtualized hardware right now, so these are from a KVM guest (Xeon @ 2.10GHz, pinned, interleaved runs, medians of 31 rounds). Base is 1185c20, PR is 54ba857.

In the base build, flush() inlines the native sha256. It writes ymm7 (the 384-byte from_uintx copy) and then executes sha256rnds2 with no vzeroupper in between. So the dirty-upper case does occur in this real caller.

base PR
sha256_hash(data_array<384>) (flush's hashing step, inlined) ~50 µs ~0.36 µs
flush() end to end 13.7–14.4 ms 14.8–14.9 ms
insert(36 bytes) 2.81–2.91 µs 2.69–3.14 µs
accumulator<sha256>::hash 36 / 1000 bytes 95 / 800 ns 84 / 795 ns

To be clear about what this shows: the penalty in flush's hashing step is real, about 50 µs, but that's ~0.35% of a flush, which is dominated by the 3072-bit inverse. So there's no measurable end-to-end gain for flush(). flush/insert measured 0–9% slower with the PR in some runs, but the SHA part of those paths is the same or faster, so I believe it's layout in the multiprecision code plus VM noise. I can't prove that from a VM, though.

The case for the change is that it costs one vzeroupper per native SHA call and removes a ~100x slowdown that any caller who inlines native SHA after a 256-bit write can hit. I'll post bare-metal numbers when I can get access to a machine, unless the codegen evidence above is enough for you.

Comment thread include/bitcoin/system/intrinsics/intel/intel.hpp Outdated
Comment thread test/hash/performance/transition.cpp Outdated
Replace raw _mm256_storeu_si256/_mm256_set1_epi32 calls with the library's
f::store and f::broadcast<xint256_t> wrappers, consistent with other AVX2
tests and avoiding direct platform intrinsic usage in test code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Raimo33
Raimo33 force-pushed the fix-sha-ni-avx-transition branch from 9ff25e2 to 936d31b Compare October 14, 2026 07:14
@evoskuil
evoskuil merged commit 97d4ccf into libbitcoin:master Oct 14, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants