From 8e711c29fe7d5d9b317de46ec5f0cd848e56fbaf Mon Sep 17 00:00:00 2001 From: anematode Date: Tue, 9 Jun 2026 19:35:05 +0200 Subject: [PATCH] Add wasm32 and wasm32-relaxed-simd targets, and light optimizations Lichess maintains some patches on top of SF dev to get it working with Emscripten. This PR moves some of these patches into SF and adds WASM to CI. It also adds a few changes in places where the x86 intrinsics don't cleanly map onto WebAssembly SIMD instructions; otherwise, we use Emscripten's x86 compatibility layer and take SSE4.1 code paths. Summary of the compatibility changes: - Define `wasm32` and `wasm32-relaxed-simd` targets. - We don't support wasm without SIMD; it'd be a waste of time. - Add option to disable TBs - This is required because `tbprobe.cpp` pulls in `mmap`. This option can be used on any target, of course, but it's only enabled by default for wasm. - Add compilation job + test to CI And the changes for performance: - Disable atomics for shared history on wasm - Atomics are always `seq_cst` there, which can be quite slow (even on the x86, stores are locked `xchg [mem], reg`) - Add SSE code path to `get_changed_pieces`, modeled after the AVX2 path - `_mm_mulhi_epi16` has a complicated emulation sequence, so for the pairwise multiplication, use an approach similar to the NEON impl. - __int128 is gets lowered to runtime functions on wasm, so use the fallback impl for `mul_hi64` - V8 does a poor job with the NNZ finding, so use a slightly different sequence there - Add relaxed simd support for `m128_dpbusd`. Some local perf figures (single-threaded speedtest): ``` wasm Nodes/second : 902523 sse4.1 Nodes/second : 1155380 avx512icl Nodes/second : 1676184 ``` Further avenues to explore: - Optimize for performance under V8's experimental AVX revectorizer (Currently it's about +10% in my testing, but could definittely be more) - Branch hinting. For example, run bench while collecting branch frequency info, then inject it late in the WASM compilation pipeline. I tried this locally and it didn't help much, but maybe I'm missing something. - PGO. Gives +1.5% NPS locally, but hard to integrate with WASM compilation wrokflows closes https://github.com/official-stockfish/Stockfish/pull/6875 No functional change --- .github/workflows/stockfish.yml | 4 ++ .github/workflows/wasm_compilation.yml | 51 +++++++++++++++++++ src/Makefile | 58 +++++++++++++++++++-- src/history.h | 8 ++- src/misc.h | 6 +-- src/nnue/nnue_accumulator.cpp | 13 +++++ src/nnue/nnue_feature_transformer.h | 21 ++++++-- src/nnue/nnz_helper.h | 4 ++ src/nnue/simd.h | 12 ++++- src/syzygy/tbprobe.cpp | 70 +++++++++++++++++++------- tests/signature.sh | 3 +- 11 files changed, 216 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/wasm_compilation.yml diff --git a/.github/workflows/stockfish.yml b/.github/workflows/stockfish.yml index 0606d685c..2608f0bf6 100644 --- a/.github/workflows/stockfish.yml +++ b/.github/workflows/stockfish.yml @@ -150,6 +150,10 @@ jobs: name: Compiler check if: ${{ always() }} uses: ./.github/workflows/avx2_compilers.yml + WASMCompilation: + name: WASM compilation + if: ${{ always() }} + uses: ./.github/workflows/wasm_compilation.yml # Release Jobs ARMCompilation: name: Android builds diff --git a/.github/workflows/wasm_compilation.yml b/.github/workflows/wasm_compilation.yml new file mode 100644 index 000000000..12f23c683 --- /dev/null +++ b/.github/workflows/wasm_compilation.yml @@ -0,0 +1,51 @@ +name: WASM Compilation + +on: + workflow_call: + +jobs: + wasm: + name: wasm (${{ matrix.arch }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: [wasm32, wasm32-relaxed-simd] + defaults: + run: + working-directory: src + shell: bash + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install emsdk + working-directory: . + run: | + git clone --depth 1 https://github.com/emscripten-core/emsdk.git + cd emsdk + ./emsdk install latest + ./emsdk activate latest + + - name: Extract the bench number from the commit history + run: | + for hash in $(git rev-list -100 HEAD); do + benchref=$(git show -s $hash | tac | grep -m 1 -o -x '[[:space:]]*\b[Bb]ench[ :]\+[1-9][0-9]\{5,7\}\b[[:space:]]*' | sed 's/[^0-9]//g') && break || true + done + [[ -n "$benchref" ]] && echo "benchref=$benchref" >> $GITHUB_ENV && echo "From commit: $hash" && echo "Reference bench: $benchref" || echo "No bench found" + + - name: Download the used network from the fishtest framework + run: make net + + - name: Build ${{ matrix.arch }} build + run: | + source $GITHUB_WORKSPACE/emsdk/emsdk_env.sh + em++ --version + net=$(grep -oE 'nn-[0-9a-f]+\.nnue' evaluate.h | head -1) + make clean + COMPCXX=em++ make -j build ARCH=${{ matrix.arch }} EXTRALDFLAGS="--preload-file $net" + + - name: Verify bench under node + run: EXE=./stockfish.js RUN_PREFIX=node ../tests/signature.sh $benchref diff --git a/src/Makefile b/src/Makefile index ab91d1d0f..cdf0ee1a8 100644 --- a/src/Makefile +++ b/src/Makefile @@ -55,6 +55,8 @@ endif ### Executable name ifeq ($(target_windows),yes) EXE = stockfish.exe +else ifneq (,$(findstring wasm,$(ARCH))) + EXE = stockfish.js else EXE = stockfish endif @@ -122,7 +124,9 @@ VPATH = syzygy:nnue:nnue/features # neon = yes/no --- -DUSE_NEON --- Use ARM SIMD architecture # dotprod = yes/no --- -DUSE_NEON_DOTPROD --- Use ARM advanced SIMD Int8 dot product instructions # lsx = yes/no --- -mlsx --- Use Loongson SIMD eXtension -# lasx = yes/no --- -mlasx --- use Loongson Advanced SIMD eXtension +# lasx = yes/no --- -mlasx --- Use Loongson Advanced SIMD eXtension +# relaxedsimd = y/n --- -mrelaxed-simd --- Use WebAssembly relaxed SIMD extension +# syzygy = yes/no --- -DNO_TABLEBASES --- Support Syzygy tablebase probing # # Note that Makefile is space sensitive, so when adding new architectures # or modifying existing flags, you have to make sure there are no extra spaces @@ -149,7 +153,7 @@ ifeq ($(ARCH), $(filter $(ARCH), \ x86-64-bmi2 x86-64-avx2 x86-64-sse41-popcnt x86-64-modern x86-64-ssse3 x86-64-sse3-popcnt \ x86-64 x86-32-sse41-popcnt x86-32-sse2 x86-32 ppc-64 ppc-64-altivec ppc-64-vsx ppc-32 e2k \ armv7 armv7-neon armv8 armv8-dotprod arm64-universal apple-silicon general-64 general-32 riscv64 \ - loongarch64 loongarch64-lsx loongarch64-lasx)) + loongarch64 loongarch64-lsx loongarch64-lasx wasm32 wasm32-relaxed-simd)) SUPPORTED_ARCH=true else SUPPORTED_ARCH=false @@ -186,6 +190,8 @@ dotprod = no arm_version = 0 lsx = no lasx = no +relaxedsimd = no +syzygy = yes STRIP = strip ifneq ($(shell which clang-format-20 2> /dev/null),) @@ -389,6 +395,27 @@ ifeq ($(ARCH),apple-silicon) arm_version = 8 endif +# WASM is largely compiled using emcc's x86 compatibility layer, with occasional +# WASM-specific intrinsics +ifeq ($(ARCH),wasm32) + arch = wasm32 + sse = yes + sse2 = yes + ssse3 = yes + sse41 = yes + syzygy = no +endif + +ifeq ($(ARCH),wasm32-relaxed-simd) + arch = wasm32 + sse = yes + sse2 = yes + ssse3 = yes + sse41 = yes + relaxedsimd = yes + syzygy = no +endif + ifeq ($(ARCH),ppc-32) arch = ppc bits = 32 @@ -480,7 +507,7 @@ ifeq ($(COMP),gcc) endif else ifeq ($(arch),loongarch64) CXXFLAGS += -latomic - else + else ifneq ($(arch),wasm32) CXXFLAGS += -m$(bits) LDFLAGS += -m$(bits) endif @@ -490,8 +517,10 @@ ifeq ($(COMP),gcc) endif ifneq ($(KERNEL),Darwin) + ifneq ($(arch),wasm32) LDFLAGS += -Wl,--no-as-needed endif + endif endif ifeq ($(target_windows),yes) @@ -654,6 +683,7 @@ ifneq ($(comp),mingw) # Haiku has pthreads in its libroot, so only link it in on other platforms ifneq ($(KERNEL),Haiku) ifneq ($(COMP),ndk) + ifneq ($(arch),wasm32) LDFLAGS += -lpthread add_lrt = yes @@ -669,6 +699,7 @@ ifneq ($(comp),mingw) LDFLAGS += -lrt endif endif + endif endif endif endif @@ -846,6 +877,14 @@ ifeq ($(lsx),yes) endif endif +ifeq ($(arch),wasm32) + CXXFLAGS += -pthread -msimd128 -DUSE_POPCNT -DNNUE_EMBEDDING_OFF + LDFLAGS += -pthread -sINITIAL_MEMORY=64MB -sALLOW_MEMORY_GROWTH -sSTACK_SIZE=3MB + ifeq ($(relaxedsimd),yes) + CXXFLAGS += -mrelaxed-simd + endif +endif + ### 3.7 pext ifeq ($(pext),yes) CXXFLAGS += -DUSE_PEXT @@ -854,6 +893,11 @@ ifeq ($(pext),yes) endif endif +### Syzygy tablebases +ifeq ($(syzygy),no) + CXXFLAGS += -DNO_TABLEBASES +endif + ### 3.8.1 Try to include git info for versioning and avoid recompiles if nothing changes BUILD_SHA_FILE := .build_sha.txt BUILD_DATE_FILE := .build_date.txt @@ -885,7 +929,8 @@ endif ### needs access to the optimization flags. ifeq ($(optimize),yes) ifeq ($(debug),no) - ifneq ($(KERNEL),Darwin) + ifeq ($(arch),wasm32) + else ifneq ($(KERNEL),Darwin) LLD_BIN := $(shell command -v ld.lld 2>/dev/null) ifeq ($(LLD_BIN),) LLD_BIN := $(shell command -v lld 2>/dev/null) @@ -1207,6 +1252,7 @@ config-sanity: net echo "arm_version: '$(arm_version)'" && \ echo "lsx: '$(lsx)'" && \ echo "lasx: '$(lasx)'" && \ + echo "syzygy: '$(syzygy)'" && \ echo "target_windows: '$(target_windows)'" && \ echo "" && \ echo "Flags:" && \ @@ -1222,7 +1268,8 @@ config-sanity: net (test "$(arch)" = "any" || test "$(arch)" = "x86_64" || test "$(arch)" = "i386" || \ test "$(arch)" = "ppc64" || test "$(arch)" = "ppc" || test "$(arch)" = "e2k" || \ test "$(arch)" = "armv7" || test "$(arch)" = "armv8" || test "$(arch)" = "arm64" || \ - test "$(arch)" = "riscv64" || test "$(arch)" = "loongarch64") && \ + test "$(arch)" = "riscv64" || test "$(arch)" = "loongarch64" || \ + test "$(arch)" = "wasm32") && \ (test "$(bits)" = "32" || test "$(bits)" = "64") && \ (test "$(prefetch)" = "yes" || test "$(prefetch)" = "no") && \ (test "$(popcnt)" = "yes" || test "$(popcnt)" = "no") && \ @@ -1241,6 +1288,7 @@ config-sanity: net (test "$(neon)" = "yes" || test "$(neon)" = "no") && \ (test "$(lsx)" = "yes" || test "$(lsx)" = "no") && \ (test "$(lasx)" = "yes" || test "$(lasx)" = "no") && \ + (test "$(syzygy)" = "yes" || test "$(syzygy)" = "no") && \ (test "$(comp)" = "gcc" || test "$(comp)" = "icx" || test "$(comp)" = "mingw" || \ test "$(comp)" = "clang" || test "$(comp)" = "armv7a-linux-androideabi16-clang" || \ test "$(comp)" = "aarch64-linux-android21-clang") diff --git a/src/history.h b/src/history.h index 7ba43234d..4e0dbeb03 100644 --- a/src/history.h +++ b/src/history.h @@ -52,11 +52,17 @@ static_assert((CORRHIST_BASE_SIZE & (CORRHIST_BASE_SIZE - 1)) == 0, // the entry. The first template parameter T is the base type of the array, // and the second template parameter D limits the range of updates in [-D, D] // when we update values with the << operator -template +template struct StatsEntry { static_assert(std::is_arithmetic_v, "Not an arithmetic type"); private: + static constexpr bool Atomic = +#ifdef __wasm__ + false; // don't use atomics on WASM as they are always seq_cst +#else + Shared; +#endif std::conditional_t, T> entry; public: diff --git a/src/misc.h b/src/misc.h index cadff28cc..219e75ab8 100644 --- a/src/misc.h +++ b/src/misc.h @@ -384,12 +384,12 @@ class PRNG { } }; -inline u64 mul_hi64(u64 a, u64 b) { -#if defined(__GNUC__) && defined(IS_64BIT) +inline usize mul_hi64(u64 a, usize b) { +#if defined(__GNUC__) && defined(IS_64BIT) && !defined(__wasm__) return (u128(a) * u128(b)) >> 64; #else u64 aL = u32(a), aH = a >> 32; - u64 bL = u32(b), bH = b >> 32; + u64 bL = u32(b), bH = u64(b) >> 32; u64 c1 = (aL * bL) >> 32; u64 c2 = aH * bL + c1; u64 c3 = aL * bH + u32(c2); diff --git a/src/nnue/nnue_accumulator.cpp b/src/nnue/nnue_accumulator.cpp index 35040b2b8..46ebe5db4 100644 --- a/src/nnue/nnue_accumulator.cpp +++ b/src/nnue/nnue_accumulator.cpp @@ -561,6 +561,19 @@ Bitboard get_changed_pieces(const std::array& oldPieces, uint8x8_t sameBB = vshrn_n_u16(vreinterpretq_u16_u8(merged), 4); return ~vget_lane_u64(vreinterpret_u64_u8(sameBB), 0); +#elif defined(USE_SSE2) + Bitboard sameBB = 0; + + for (int i = 0; i < 64; i += 16) + { + const __m128i old_v = _mm_loadu_si128(reinterpret_cast(&oldPieces[i])); + const __m128i new_v = _mm_loadu_si128(reinterpret_cast(&newPieces[i])); + const __m128i same = _mm_cmpeq_epi8(old_v, new_v); + + sameBB |= static_cast(_mm_movemask_epi8(same)) << i; + } + + return ~sameBB; #else Bitboard changed = 0; diff --git a/src/nnue/nnue_feature_transformer.h b/src/nnue/nnue_feature_transformer.h index c4c92f059..12f620648 100644 --- a/src/nnue/nnue_feature_transformer.h +++ b/src/nnue/nnue_feature_transformer.h @@ -233,11 +233,9 @@ class FeatureTransformer { static_assert((HalfDimensions / 2) % OutputChunkSize == 0); constexpr IndexType NumOutputChunks = HalfDimensions / 2 / OutputChunkSize; - #if !defined(USE_NEON) - const vec_t Zero = vec_zero(); - const vec_t FtMax = vec_set_16(FtMaxVal); - constexpr int shift = 7; - #endif + [[maybe_unused]] const vec_t Zero = vec_zero(); + [[maybe_unused]] const vec_t FtMax = vec_set_16(FtMaxVal); + [[maybe_unused]] constexpr int shift = 7; const vec_t* in0 = reinterpret_cast(&(accumulation[perspectives[p]][0])); const vec_t* in1 = @@ -323,6 +321,19 @@ class FeatureTransformer { vec_t hi = vec_mulhi_8(pa, pb); vec_t result = vec_srli_8(hi, 1); + #elif defined(__wasm__) + // _mm_mulhi_epi16 is lowered to 32-bit multiplies, so we take + // a similar approach as the NEON path. + vec_t mul0 = vec_packus_16(acc0a, acc0b); + vec_t mul1 = vec_packus_16(acc1a, acc1b); + + vec_t low = wasm_u16x8_extmul_low_u8x16(mul0, mul1); + vec_t hi = wasm_u16x8_extmul_high_u8x16(mul0, mul1); + + // equivalent to vuzp2_u8 + vec_t merged = wasm_i8x16_shuffle(low, hi, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, + 21, 23, 25, 27, 29, 31); + vec_t result = wasm_u8x16_shr(merged, 1); #else vec_t sum0a = vec_slli_16(vec_max_16(vec_min_16(acc0a, FtMax), Zero), shift); vec_t sum0b = vec_slli_16(vec_max_16(vec_min_16(acc0b, FtMax), Zero), shift); diff --git a/src/nnue/nnz_helper.h b/src/nnue/nnz_helper.h index 7bbe98437..b7542c7bc 100644 --- a/src/nnue/nnz_helper.h +++ b/src/nnue/nnz_helper.h @@ -132,6 +132,10 @@ struct NNZInfo { const uint16x8_t bits = vandq_u16(packed, vld1q_u16(Mask8)); *out++ = vaddvq_u16(bits); + #elif defined(__wasm__) + __m128i packed = _mm_packus_epi32(neurons1, neurons2); + packed = _mm_packs_epi16(packed, packed); + *out++ = ~_mm_movemask_epi8(_mm_cmpeq_epi8(packed, _mm_setzero_si128())); #else auto m1 = vec_nnz(neurons1); auto m2 = vec_nnz(neurons2); diff --git a/src/nnue/simd.h b/src/nnue/simd.h index fca5e260a..bf6c78732 100644 --- a/src/nnue/simd.h +++ b/src/nnue/simd.h @@ -168,7 +168,12 @@ inline __m128i _mm_cvtsi64_si128(i64 val) { #endif #ifdef USE_SSE41 - #define vec_convert_8_16(a) _mm_cvtepi8_epi16(_mm_cvtsi64_si128(static_cast(a))) + #ifdef __wasm__ + #define vec_convert_8_16(a) wasm_i16x8_load8x8(reinterpret_cast(&a)) + #else + #define vec_convert_8_16(a) \ + _mm_cvtepi8_epi16(_mm_cvtsi64_si128(static_cast(a))) + #endif #else // Credit: Yoshie2000 inline __m128i vec_convert_8_16(u64 x) { @@ -495,10 +500,13 @@ fused(const typename VecWrapper::type& in, const T& operand, const Ts&... operan } [[maybe_unused]] static void m128_add_dpbusd_epi32(__m128i& acc, __m128i a, __m128i b) { - + #if defined(__wasm_relaxed_simd__) + acc = wasm_i32x4_relaxed_dot_i8x16_i7x16_add(b, a, acc); + #else __m128i product0 = _mm_maddubs_epi16(a, b); product0 = _mm_madd_epi16(product0, _mm_set1_epi16(1)); acc = _mm_add_epi32(acc, product0); + #endif } #endif diff --git a/src/syzygy/tbprobe.cpp b/src/syzygy/tbprobe.cpp index b230ac59b..21d619572 100644 --- a/src/syzygy/tbprobe.cpp +++ b/src/syzygy/tbprobe.cpp @@ -59,6 +59,40 @@ #include #endif +#ifdef NO_TABLEBASES + +// Stubbed out impls +namespace Stockfish::Tablebases { + +int MaxCardinality; + +void init(const std::string&) {} + +WDLScore probe_wdl(Position&, ProbeState* result) { + *result = FAIL; + return WDLDraw; +} + +int probe_dtz(Position&, ProbeState* result) { + *result = FAIL; + return 0; +} + +bool root_probe(Position&, Search::RootMoves&, bool, bool, const std::function&) { + return false; +} + +bool root_probe_wdl(Position&, Search::RootMoves&, bool) { return false; } + +Config rank_root_moves( + const OptionsMap&, Position&, Search::RootMoves&, bool, const std::function&) { + return Config{}; +} + +} // namespace Stockfish::Tablebases + +#else + using namespace Stockfish::Tablebases; int Stockfish::Tablebases::MaxCardinality; @@ -206,11 +240,11 @@ class TBFile: public std::ifstream { TBFile(const std::string& f) { -#ifndef _WIN32 + #ifndef _WIN32 constexpr char SepChar = ':'; -#else + #else constexpr char SepChar = ';'; -#endif + #endif std::stringstream ss(Paths); std::string path; @@ -228,7 +262,7 @@ class TBFile: public std::ifstream { if (is_open()) close(); // Need to re-open to get native file descriptor -#ifndef _WIN32 + #ifndef _WIN32 struct stat statbuf; int fd = ::open(fname.c_str(), O_RDONLY); @@ -245,9 +279,9 @@ class TBFile: public std::ifstream { *mapping = statbuf.st_size; *baseAddress = mmap(nullptr, statbuf.st_size, PROT_READ, MAP_SHARED, fd, 0); - #if defined(MADV_RANDOM) + #if defined(MADV_RANDOM) madvise(*baseAddress, statbuf.st_size, MADV_RANDOM); - #endif + #endif ::close(fd); if (*baseAddress == MAP_FAILED) @@ -255,7 +289,7 @@ class TBFile: public std::ifstream { std::cerr << "Could not mmap() " << fname << std::endl; exit(EXIT_FAILURE); } -#else + #else // Note FILE_FLAG_RANDOM_ACCESS is only a hint to Windows and as such may get ignored. HANDLE fd = CreateFileA(fname.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_RANDOM_ACCESS, nullptr); @@ -290,7 +324,7 @@ class TBFile: public std::ifstream { << ", error = " << GetLastError() << std::endl; exit(EXIT_FAILURE); } -#endif + #endif u8* data = (u8*) *baseAddress; constexpr u8 Magics[][4] = {{0xD7, 0x66, 0x0C, 0xA5}, {0x71, 0xE8, 0x23, 0x5D}}; @@ -307,12 +341,12 @@ class TBFile: public std::ifstream { static void unmap(void* baseAddress, u64 mapping) { -#ifndef _WIN32 + #ifndef _WIN32 munmap(baseAddress, mapping); -#else + #else UnmapViewOfFile(baseAddress); CloseHandle((HANDLE) mapping); -#endif + #endif } }; @@ -728,12 +762,12 @@ int map_score(TBTable* entry, File f, int value, WDLScore wdl) { return value + 1; } -// A temporary fix for the compiler bug with vectorization. (#4450) -#if defined(__clang__) && defined(__clang_major__) && __clang_major__ >= 15 - #define DISABLE_CLANG_LOOP_VEC _Pragma("clang loop vectorize(disable)") -#else - #define DISABLE_CLANG_LOOP_VEC -#endif + // A temporary fix for the compiler bug with vectorization. (#4450) + #if defined(__clang__) && defined(__clang_major__) && __clang_major__ >= 15 + #define DISABLE_CLANG_LOOP_VEC _Pragma("clang loop vectorize(disable)") + #else + #define DISABLE_CLANG_LOOP_VEC + #endif // Compute a unique index out of a position and use it to probe the TB file. To // encode k pieces of the same type and color, first sort the pieces by square in @@ -1802,3 +1836,5 @@ Config Tablebases::rank_root_moves(const OptionsMap& options, return config; } } // namespace Stockfish + +#endif // NO_TABLEBASES diff --git a/tests/signature.sh b/tests/signature.sh index ef781a0f4..ad96d2ee3 100755 --- a/tests/signature.sh +++ b/tests/signature.sh @@ -18,7 +18,8 @@ error() trap 'error ${LINENO}' ERR # obtain -eval "$RUN_PREFIX ./stockfish bench" > "$STDOUT_FILE" 2> "$STDERR_FILE" || error ${LINENO} +EXE=${EXE:-./stockfish} +eval "$RUN_PREFIX $EXE bench" > "$STDOUT_FILE" 2> "$STDERR_FILE" || error ${LINENO} signature=$(grep "Nodes searched : " "$STDERR_FILE" | awk '{print $4}') rm -f "$STDOUT_FILE" "$STDERR_FILE"