Introducing Luna: My Rust-based NNUE Chess Engine

Discussion of anything and everything relating to chess playing software and machines.

Moderator: Ras

User avatar
Jim Ablett
Posts: 2555
Joined: Fri Jul 14, 2006 7:56 am
Location: London, England
Full name: Jim Ablett

Re: Introducing Luna: My Rust-based NNUE Chess Engine

Post by Jim Ablett »

Spunc595 wrote: Wed Jul 15, 2026 1:57 pm Hi everyone,

I edited the nnue.rs file as suggested by Jim and it resulted in a significant boost in both nodes and NPS. The engine is now much faster and more stable during search.

However, I’ve hit a critical issue with the NNUE evaluation. Despite the performance gains, the evaluation itself doesn’t seem to be correct. I’ve already performed an in-depth check of the NNUE’s Layers 1, 2, and 3, and the calculation logic seems fine; I just can’t isolate the exact point in my code where the error or inconsistency lies.

I am attaching the command-line logs from a few test positions (including the starting position and K vs K) below so you can see what’s happening:

✅ NNUE: Weights loaded (Safe mode, AVX2 backend).
✅ NNUE: Attiva e carica!
⚠️ Book: File 'book.bin' non trovato. Si userà solo la rete.
Luna CE v1.2.0
uci
id name Luna_CE v1.2.0
id author Daniele Marpino
option name Hash type spin default 256 min 1 max 1024
uciok
isready
readyok
position startpos
go depth 10
info depth 1 score cp 29 nodes 41 nps 0 time 0 pv d2d3
info depth 2 score cp 29 nodes 229 nps 0 time 0 pv g1f3 d7d5
info depth 3 score cp 29 nodes 1219 nps 1219000 time 1 pv g1f3 d7d5 d2d3
info depth 4 score cp 29 nodes 2529 nps 632250 time 4 pv g1f3 d7d5 b2b3 h7h6
info depth 5 score cp 29 nodes 3845 nps 640833 time 6 pv g1f3 d7d5 b2b3 h7h6 c2c3
info depth 6 score cp 29 nodes 11646 nps 776400 time 15 pv g1f3 d7d5 b1c3 h7h6 c3d5 d8d5
info depth 7 score cp 29 nodes 17200 nps 781818 time 22 pv g1f3 d7d5 b1c3 h7h6 c3d5 d8d5 d2d4
info depth 8 score cp 29 nodes 43793 nps 826283 time 53 pv g1f3 d7d5 b1c3 h7h6 c3d5 d8d5 b2b3 d5f3
info depth 9 score cp 29 nodes 165278 nps 888591 time 186 pv g1f3 d7d5 b2b3 b8c6 b1c3 h7h5 c3d5 d8d5 c1b2
info depth 10 score cp 29 nodes 859558 nps 810139 time 1061 pv g1f3 d7d5 b1c3 h7h5 c3b5 b8c6 b5c7 d8c7 b2b3 f7f6 d2d3
bestmove g1f3
position fen rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBN1 w KQkq - 0 1
go depth 10
info depth 1 score cp 29 nodes 43 nps 0 time 0 pv b2b3
info depth 2 score cp 28 nodes 150 nps 0 time 0 pv b2b3 e7e6
info depth 3 score cp 29 nodes 1154 nps 1154000 time 1 pv b2b3 e7e6 d2d4
info depth 4 score cp 28 nodes 2400 nps 800000 time 3 pv b2b3 e7e6 d2d4 d7d5
info depth 5 score cp 29 nodes 6670 nps 741111 time 9 pv g1f3 d7d5 g2g3 h7h5 b2b4
info depth 6 score cp 28 nodes 14356 nps 683619 time 21 pv g1f3 d7d5 g2g3 h7h5 b2b4 b8c6
info depth 7 score cp 28 nodes 40330 nps 876739 time 46 pv g1f3 d7d5 g2g3 h7h5 b2b4 b8c6 d2d3
info depth 8 score cp 28 nodes 241421 nps 807428 time 299 pv g1f3 d7d5 g2g3 h7h5 b2b4 b8c6 d2d3 c6b4
info depth 9 score cp 28 nodes 360386 nps 828473 time 435 pv g1f3 d7d5 g2g3 h7h5 b2b4 b8c6 d2d3 c6b4 e2e3
info depth 10 score cp 28 nodes 2489841 nps 786679 time 3165 pv g1f3 d7d5 g2g3 h7h5 b2b4 b8c6 d2d3 c6b4 e2e3 b4a2
bestmove g1f3
position fen 8/8/8/8/4k3/8/8/2K5 w - - 0 1
go depth 10
info depth 1 score cp 28 nodes 11 nps 0 time 0 pv c1b1
info depth 2 score cp 28 nodes 41 nps 0 time 0 pv c1b1 e4d3
info depth 3 score cp 28 nodes 131 nps 0 time 0 pv c1b1 e4d3 b1a1
info depth 4 score cp 28 nodes 512 nps 512000 time 1 pv c1b2 e4d3 b2b1 d3e2
info depth 5 score cp 28 nodes 713 nps 713000 time 1 pv c1b2 e4d3 b2b1 d3e2 b1a2
info depth 6 score cp 28 nodes 1617 nps 808500 time 2 pv c1b2 e4d3 b2b3 d3d2 b3a2 d2c1
info depth 7 score cp 28 nodes 2113 nps 704333 time 3 pv c1b2 e4d3 b2b3 d3d2 b3a2 d2c1 a2a3
info depth 8 score cp 27 nodes 3189 nps 637800 time 5 pv c1b2 e4d3 b2b3 d3d2 b3a2 d2e3 a2a3 e3e2
info depth 9 score cp 28 nodes 4582 nps 763666 time 6 pv c1b2 e4d3 b2b3 d3d2 b3a4 d2e2 a4a5 e2f3 a5a6
info depth 10 score cp 28 nodes 6312 nps 789000 time 8 pv c1b2 e4d3 b2b3 d3d2 b3a4 d2e2 a4a5 e2f3 a5a6 f3e2
bestmove c1b2

Has anyone encountered similar issues during NNUE pipeline integration/optimization? Could this be an alignment issue, a feature input problem, or some detail in the accumulator that gets missed after the performance optimization?

Any suggestions on where to focus my debugging efforts would be invaluable. Thanks in advance!
I asked Claude to fix the bug:
The bug: in nnue.rs, Layer 1's CReLU clamps to a ceiling of 2048:
rustlet mut l1_out = [0i16; L1_SIZE];
simd::crelu(&l1_acc, &mut l1_out, 2048);

But the comment right above the accumulator explicitly documents the quantization scale as QA=255:
rust// --- LAYER 1: FEATURE TRANSFORMER (QA=255)

255 and 2048 are off by ~8x. With a real, QA=255-scaled weight file, the accumulator (bias + summed active-feature weights, saturating i16 add) very quickly runs past 255 for most neurons — but the code doesn't clip there, it lets values run all the way up to 2048 (or floor at 0) before clipping. In practice this means most neurons end up pinned at one of the two extremes of [0, 2048] almost regardless of which pieces are on the board, which is exactly the flat, material-blind score you're seeing. The L2 clamp (clamp(0, 128), documented as QB=64) is internally consistent with its own comment — it's only L1's ceiling that contradicts its documentation.

Code: Select all

// nnue.rs (fixed)

use std::fs::File;
use std::io::Read;
use crate::board::{Scacchiera, Colore};

// --- NNUE ARCHITECTURE CONFIGURATION ---
// 768 input features (12 piece types * 64 squares).
const INPUT_SIZE: usize = 768;
// Size of the first hidden layer (Feature Transformer).
const L1_SIZE: usize = 256;
// Size of the second hidden layer.
const L2_SIZE: usize = 32;

// ============================================================================
// SIMD BACKEND SELECTION
// ============================================================================
// The hot path of NNUE inference is:
//   1) L1 accumulation: for every occupied square, add a 256-wide i16 row
//      from the weight matrix into the accumulator (saturating).
//   2) CReLU clamp of the 256-wide accumulator.
//   3) L2: a 256x32 mat-vec product (i16 x i16 -> i32).
//   4) L3: a 32-wide dot product.
//
// All four steps are data-parallel over contiguous i16 arrays, which is the
// textbook case for SIMD. We provide:
//   - an AVX2 implementation (x86_64, runtime-detected via CPUID, cached),
//   - a NEON implementation (aarch64, always available on that target),
//   - a portable scalar fallback used anywhere else, or if AVX2 is missing.
//
// Every SIMD routine has a scalar twin computing the identical arithmetic
// (same saturation points, same integer division/clamping), so switching
// backends never changes the evaluation output.
// ============================================================================

#[cfg(target_arch = "x86_64")]
fn has_avx2() -> bool {
    use std::sync::OnceLock;
    static AVX2: OnceLock<bool> = OnceLock::new();
    *AVX2.get_or_init(|| is_x86_feature_detected!("avx2"))
}

/// Main structure for the quantized LunaNNUE neural network.
/// Stores weights and biases as 16-bit integers (`i16`) to optimize performance on CPUs without an FPU.
pub struct LunaNNUE {
    l1_weights: Vec<i16>,
    l1_bias: Vec<i16>,
    l2_weights: Vec<i16>,
    l2_bias: Vec<i16>,
    l3_weights: Vec<i16>,
    l3_bias: Vec<i16>,
}

impl LunaNNUE {
    /// Loads the network's binary weights from an external file in Little-Endian format.
    /// Returns `None` in case of I/O error or corrupted file.
    pub fn load(path: &str) -> Option<Self> {
        let mut file = File::open(path).ok()?;
        let mut buffer = Vec::new();
        file.read_to_end(&mut buffer).ok()?;

        let mut net = LunaNNUE {
            l1_weights: vec![0; INPUT_SIZE * L1_SIZE],
            l1_bias: vec![0; L1_SIZE],
            l2_weights: vec![0; L1_SIZE * L2_SIZE],
            l2_bias: vec![0; L2_SIZE],
            l3_weights: vec![0; L2_SIZE],
            l3_bias: vec![0; 1],
        };

        let mut offset = 0;
        // Helper closure to read paired bytes sequentially and convert them into i16.
        let mut read_i16 = |count: usize| -> Vec<i16> {
            let mut v = Vec::with_capacity(count);
            for _ in 0..count {
                if offset + 2 <= buffer.len() {
                    v.push(i16::from_le_bytes([buffer[offset], buffer[offset + 1]]));
                    offset += 2;
                }
            }
            v
        };

        // Ordered mapping of weight and bias vectors.
        net.l1_weights = read_i16(INPUT_SIZE * L1_SIZE);
        net.l1_bias = read_i16(L1_SIZE);
        net.l2_weights = read_i16(L1_SIZE * L2_SIZE);
        net.l2_bias = read_i16(L2_SIZE);
        net.l3_weights = read_i16(L2_SIZE);
        net.l3_bias = read_i16(1);

        // Initial diagnostic check on the binary file status.
        if net.l1_weights.iter().take(100).all(|&x| x == 0) {
            println!("⚠️ WARNING: The NNUE file seems empty!");
        } else {
            #[cfg(target_arch = "x86_64")]
            let backend = if has_avx2() { "AVX2" } else { "scalar" };
            #[cfg(target_arch = "aarch64")]
            let backend = "NEON";
            #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
            let backend = "scalar";
            println!("✅ NNUE: Weights loaded (Safe mode, {} backend).", backend);
        }

        Some(net)
    }

    /// Performs the feedforward (inference) step of the network for the current position.
    /// Extracts active features via bitboards and propagates values through quantized layers.
    pub fn evaluate(&self, s: &Scacchiera) -> i32 {
        // ==========================================
        // --- LAYER 1: FEATURE TRANSFORMER (QA=255)
        // ==========================================
        // Initialize the L1 accumulator by cloning the initial bias values.
        let mut l1_acc = [0i16; L1_SIZE];
        l1_acc.copy_from_slice(&self.l1_bias);

        // Iterate over the 6 piece types.
        for p_idx in 0..6 {
            // Sub-step for White (Color Index 0).
            let mut bb_w = s.pezzi[p_idx] & s.colori[0];
            while bb_w != 0 {
                let sq = bb_w.trailing_zeros() as usize;
                let offset = (p_idx * 64 + sq) * L1_SIZE;
                simd::accumulate(&mut l1_acc, &self.l1_weights[offset..offset + L1_SIZE]);
                bb_w &= bb_w - 1; // Clear the processed bit (Bit-pop)
            }

            // Sub-step for Black (Color Index 1).
            // Black pieces are shifted by 6 positions in the feature index (+6).
            let mut bb_b = s.pezzi[p_idx] & s.colori[1];
            while bb_b != 0 {
                let sq = bb_b.trailing_zeros() as usize;
                let offset = ((p_idx + 6) * 64 + sq) * L1_SIZE;
                simd::accumulate(&mut l1_acc, &self.l1_weights[offset..offset + L1_SIZE]);
                bb_b &= bb_b - 1;
            }
        }

        // Apply CReLU activation function on Layer 1.
        // Ceiling must match the QA=255 quantization scale documented above.
        // (Was hardcoded to 2048 - ~8x too high - which let most L1 neurons
        // run unclipped into saturation regardless of the actual position,
        // making the network output nearly independent of material/board state.)
        let mut l1_out = [0i16; L1_SIZE];
        simd::crelu(&l1_acc, &mut l1_out, 255);

        // ==========================================
        // --- LAYER 2: HIDDEN LAYER (QB=64)
        // ==========================================
        let mut l2_acc = [0i32; L2_SIZE];
        simd::forward_l2(&l1_out, &self.l2_weights, &self.l2_bias, &mut l2_acc);

        // CReLU activation function on Layer 2.
        // The ceiling is set to 128 to respect the QB=64 quantization scale.
        let mut l2_out = [0i16; L2_SIZE];
        for i in 0..L2_SIZE {
            l2_out[i] = l2_acc[i].clamp(0, 128) as i16;
        }

        // ==========================================
        // --- LAYER 3: LINEAR OUTPUT
        // ==========================================
        let score = self.l3_bias[0] as i32
            + simd::weighted_sum_div_pow2(&l2_out, &self.l3_weights, 8); // /256 = >>8

        // Synchronize output with the Negamax search perspective.
        if s.turno == Colore::Bianco { score } else { -score }
    }
}

// ============================================================================
// SIMD implementations
// ============================================================================
mod simd {
    use super::{L1_SIZE, L2_SIZE};

    /// acc[i] = saturating_add(acc[i], weights[i]) for i in 0..L1_SIZE
    #[inline(always)]
    pub fn accumulate(acc: &mut [i16; L1_SIZE], weights: &[i16]) {
        debug_assert_eq!(weights.len(), L1_SIZE);

        #[cfg(target_arch = "x86_64")]
        {
            if super::has_avx2() {
                unsafe { x86::accumulate_avx2(acc, weights) };
                return;
            }
        }
        #[cfg(target_arch = "aarch64")]
        {
            unsafe { arm::accumulate_neon(acc, weights) };
            return;
        }
        #[allow(unreachable_code)]
        accumulate_scalar(acc, weights);
    }

    #[inline(always)]
    #[allow(dead_code)]
    fn accumulate_scalar(acc: &mut [i16; L1_SIZE], weights: &[i16]) {
        for i in 0..L1_SIZE {
            acc[i] = acc[i].saturating_add(weights[i]);
        }
    }

    /// out[i] = clamp(acc[i], 0, ceiling)
    #[inline(always)]
    pub fn crelu(acc: &[i16; L1_SIZE], out: &mut [i16; L1_SIZE], ceiling: i16) {
        #[cfg(target_arch = "x86_64")]
        {
            if super::has_avx2() {
                unsafe { x86::crelu_avx2(acc, out, ceiling) };
                return;
            }
        }
        #[cfg(target_arch = "aarch64")]
        {
            unsafe { arm::crelu_neon(acc, out, ceiling) };
            return;
        }
        #[allow(unreachable_code)]
        crelu_scalar(acc, out, ceiling);
    }

    #[inline(always)]
    #[allow(dead_code)]
    fn crelu_scalar(acc: &[i16; L1_SIZE], out: &mut [i16; L1_SIZE], ceiling: i16) {
        for i in 0..L1_SIZE {
            out[i] = acc[i].clamp(0, ceiling);
        }
    }

    /// acc2[o] = bias[o] + sum_j( l1_out[j] * weights[j * L2_SIZE + o] ) / 2048
    /// (weights are laid out row-major: row j, L2_SIZE contiguous columns)
    #[inline(always)]
    pub fn forward_l2(
        l1_out: &[i16; L1_SIZE],
        weights: &[i16],
        bias: &[i16],
        acc2: &mut [i32; L2_SIZE],
    ) {
        debug_assert_eq!(weights.len(), L1_SIZE * L2_SIZE);
        debug_assert_eq!(bias.len(), L2_SIZE);

        #[cfg(target_arch = "x86_64")]
        {
            if super::has_avx2() && L2_SIZE == 32 {
                unsafe { x86::forward_l2_avx2(l1_out, weights, bias, acc2) };
                return;
            }
        }
        #[cfg(target_arch = "aarch64")]
        {
            unsafe { arm::forward_l2_neon(l1_out, weights, bias, acc2) };
            return;
        }
        #[allow(unreachable_code)]
        forward_l2_scalar(l1_out, weights, bias, acc2);
    }

    #[inline(always)]
    #[allow(dead_code)]
    fn forward_l2_scalar(
        l1_out: &[i16; L1_SIZE],
        weights: &[i16],
        bias: &[i16],
        acc2: &mut [i32; L2_SIZE],
    ) {
        for i in 0..L2_SIZE {
            acc2[i] = bias[i] as i32;
        }
        for j in 0..L1_SIZE {
            let lj = l1_out[j] as i32;
            if lj == 0 {
                continue; // CReLU sparsity: skip zeroed activations
            }
            let row = &weights[j * L2_SIZE..j * L2_SIZE + L2_SIZE];
            for i in 0..L2_SIZE {
                acc2[i] += (lj * row[i] as i32) / 2048;
            }
        }
    }

    /// sum_i( truncating_div(a[i] * b[i], 2^shift) )
    ///
    /// NOTE: this deliberately divides *each term* before summing, matching
    /// the reference scalar loop `score += (a[i]*b[i]) / divisor`. Integer
    /// truncating division does not distribute over addition for negative
    /// operands (e.g. (-1)/2 + (-1)/2 == 0 but (-2)/2 == -1), so summing the
    /// raw products first and dividing once would silently change the score.
    #[inline(always)]
    pub fn weighted_sum_div_pow2(a: &[i16], b: &[i16], shift: u32) -> i32 {
        debug_assert_eq!(a.len(), b.len());

        #[cfg(target_arch = "x86_64")]
        {
            if super::has_avx2() && a.len() % 16 == 0 {
                return unsafe { x86::weighted_sum_div_pow2_avx2(a, b, shift) };
            }
        }
        #[cfg(target_arch = "aarch64")]
        {
            return unsafe { arm::weighted_sum_div_pow2_neon(a, b, shift) };
        }
        #[allow(unreachable_code)]
        weighted_sum_div_pow2_scalar(a, b, shift)
    }

    #[inline(always)]
    #[allow(dead_code)]
    fn weighted_sum_div_pow2_scalar(a: &[i16], b: &[i16], shift: u32) -> i32 {
        let divisor = 1i32 << shift;
        let mut sum = 0i32;
        for i in 0..a.len() {
            sum += (a[i] as i32 * b[i] as i32) / divisor;
        }
        sum
    }

    // ------------------------------------------------------------------
    // x86_64 / AVX2 backend
    // ------------------------------------------------------------------
    #[cfg(target_arch = "x86_64")]
    mod x86 {
        use super::{L1_SIZE, L2_SIZE};
        use std::arch::x86_64::*;

        #[target_feature(enable = "avx2")]
        pub unsafe fn accumulate_avx2(acc: &mut [i16; L1_SIZE], weights: &[i16]) {
            // 16 x i16 lanes per __m256i -> L1_SIZE / 16 iterations.
            let mut i = 0;
            while i < L1_SIZE {
                let a = _mm256_loadu_si256(acc.as_ptr().add(i) as *const __m256i);
                let w = _mm256_loadu_si256(weights.as_ptr().add(i) as *const __m256i);
                let sum = _mm256_adds_epi16(a, w); // saturating add, matches i16::saturating_add
                _mm256_storeu_si256(acc.as_mut_ptr().add(i) as *mut __m256i, sum);
                i += 16;
            }
        }

        #[target_feature(enable = "avx2")]
        pub unsafe fn crelu_avx2(acc: &[i16; L1_SIZE], out: &mut [i16; L1_SIZE], ceiling: i16) {
            let zero = _mm256_setzero_si256();
            let cap = _mm256_set1_epi16(ceiling);
            let mut i = 0;
            while i < L1_SIZE {
                let a = _mm256_loadu_si256(acc.as_ptr().add(i) as *const __m256i);
                let clamped = _mm256_min_epi16(_mm256_max_epi16(a, zero), cap);
                _mm256_storeu_si256(out.as_mut_ptr().add(i) as *mut __m256i, clamped);
                i += 16;
            }
        }

        /// Truncating (round-toward-zero) division of each i32 lane by 2^shift,
        /// matching Rust's `/` operator for i32 (as opposed to an arithmetic
        /// shift, which rounds toward negative infinity for negative inputs).
        /// Uses the variable-count shift intrinsics since `shift` is a runtime
        /// value here (the immediate-shift intrinsics require a compile-time
        /// constant).
        #[target_feature(enable = "avx2")]
        unsafe fn div_pow2_trunc(x: __m256i, shift: u32) -> __m256i {
            let sign = _mm256_srai_epi32(x, 31); // all-1s if negative, else all-0s (31 is a literal immediate)
            let bias = _mm256_srl_epi32(sign, _mm_cvtsi32_si128((32 - shift) as i32));
            let biased = _mm256_add_epi32(x, bias);
            _mm256_sra_epi32(biased, _mm_cvtsi32_si128(shift as i32))
        }

        /// Outer-product accumulation: for each active (non-zero) input feature j,
        /// broadcast l1_out[j], multiply by the corresponding 32-wide weight row,
        /// divide *each* resulting term by 2048 (matching the scalar reference's
        /// per-term division), then accumulate into a 32-lane i32 accumulator
        /// (4 x __m256i of 8 lanes).
        #[target_feature(enable = "avx2")]
        pub unsafe fn forward_l2_avx2(
            l1_out: &[i16; L1_SIZE],
            weights: &[i16],
            bias: &[i16],
            acc2: &mut [i32; L2_SIZE],
        ) {
            let mut acc0 = _mm256_setzero_si256();
            let mut acc1 = _mm256_setzero_si256();
            let mut acc2v = _mm256_setzero_si256();
            let mut acc3 = _mm256_setzero_si256();

            for j in 0..L1_SIZE {
                let lj = l1_out[j];
                if lj == 0 {
                    continue; // CReLU sparsity: skip zeroed activations
                }
                let broadcast = _mm256_set1_epi32(lj as i32);
                let row_ptr = weights.as_ptr().add(j * L2_SIZE);

                // Widen 4 groups of 8 x i16 -> 8 x i32, multiply, divide, accumulate.
                let w0 = _mm256_cvtepi16_epi32(_mm_loadu_si128(row_ptr as *const __m128i));
                let w1 = _mm256_cvtepi16_epi32(_mm_loadu_si128(row_ptr.add(8) as *const __m128i));
                let w2 = _mm256_cvtepi16_epi32(_mm_loadu_si128(row_ptr.add(16) as *const __m128i));
                let w3 = _mm256_cvtepi16_epi32(_mm_loadu_si128(row_ptr.add(24) as *const __m128i));

                let p0 = div_pow2_trunc(_mm256_mullo_epi32(broadcast, w0), 11);
                let p1 = div_pow2_trunc(_mm256_mullo_epi32(broadcast, w1), 11);
                let p2 = div_pow2_trunc(_mm256_mullo_epi32(broadcast, w2), 11);
                let p3 = div_pow2_trunc(_mm256_mullo_epi32(broadcast, w3), 11);

                acc0 = _mm256_add_epi32(acc0, p0);
                acc1 = _mm256_add_epi32(acc1, p1);
                acc2v = _mm256_add_epi32(acc2v, p2);
                acc3 = _mm256_add_epi32(acc3, p3);
            }

            let mut raw = [0i32; L2_SIZE];
            _mm256_storeu_si256(raw.as_mut_ptr().add(0) as *mut __m256i, acc0);
            _mm256_storeu_si256(raw.as_mut_ptr().add(8) as *mut __m256i, acc1);
            _mm256_storeu_si256(raw.as_mut_ptr().add(16) as *mut __m256i, acc2v);
            _mm256_storeu_si256(raw.as_mut_ptr().add(24) as *mut __m256i, acc3);

            for i in 0..L2_SIZE {
                acc2[i] = bias[i] as i32 + raw[i];
            }
        }

        /// sum_i( truncating_div(a[i]*b[i], 2^shift) ), see the scalar twin
        /// `weighted_sum_div_pow2_scalar` for the exact semantics preserved here.
        #[target_feature(enable = "avx2")]
        pub unsafe fn weighted_sum_div_pow2_avx2(a: &[i16], b: &[i16], shift: u32) -> i32 {
            let mut acc = _mm256_setzero_si256();
            let mut i = 0;
            while i + 16 <= a.len() {
                let va = _mm256_loadu_si256(a.as_ptr().add(i) as *const __m256i);
                let vb = _mm256_loadu_si256(b.as_ptr().add(i) as *const __m256i);

                // Widen the low/high 8 x i16 lanes to i32 so each product can be
                // divided individually before summation (matches scalar order).
                let va_lo = _mm256_cvtepi16_epi32(_mm256_castsi256_si128(va));
                let va_hi = _mm256_cvtepi16_epi32(_mm256_extracti128_si256(va, 1));
                let vb_lo = _mm256_cvtepi16_epi32(_mm256_castsi256_si128(vb));
                let vb_hi = _mm256_cvtepi16_epi32(_mm256_extracti128_si256(vb, 1));

                let prod_lo = div_pow2_trunc(_mm256_mullo_epi32(va_lo, vb_lo), shift);
                let prod_hi = div_pow2_trunc(_mm256_mullo_epi32(va_hi, vb_hi), shift);

                acc = _mm256_add_epi32(acc, prod_lo);
                acc = _mm256_add_epi32(acc, prod_hi);
                i += 16;
            }
            let mut sum = hsum_epi32(acc);
            let divisor = 1i32 << shift;
            while i < a.len() {
                sum += (a[i] as i32 * b[i] as i32) / divisor;
                i += 1;
            }
            sum
        }

        #[target_feature(enable = "avx2")]
        unsafe fn hsum_epi32(v: __m256i) -> i32 {
            let hi = _mm256_extracti128_si256(v, 1);
            let lo = _mm256_castsi256_si128(v);
            let sum128 = _mm_add_epi32(hi, lo);
            let hi64 = _mm_unpackhi_epi64(sum128, sum128);
            let sum64 = _mm_add_epi32(sum128, hi64);
            let hi32 = _mm_shuffle_epi32(sum64, 0b01);
            let sum32 = _mm_add_epi32(sum64, hi32);
            _mm_cvtsi128_si32(sum32)
        }
    }

    // ------------------------------------------------------------------
    // aarch64 / NEON backend
    // ------------------------------------------------------------------
    #[cfg(target_arch = "aarch64")]
    mod arm {
        use super::{L1_SIZE, L2_SIZE};
        use std::arch::aarch64::*;

        #[target_feature(enable = "neon")]
        pub unsafe fn accumulate_neon(acc: &mut [i16; L1_SIZE], weights: &[i16]) {
            // 8 x i16 lanes per 128-bit vector -> L1_SIZE / 8 iterations.
            let mut i = 0;
            while i < L1_SIZE {
                let a = vld1q_s16(acc.as_ptr().add(i));
                let w = vld1q_s16(weights.as_ptr().add(i));
                let sum = vqaddq_s16(a, w); // saturating add
                vst1q_s16(acc.as_mut_ptr().add(i), sum);
                i += 8;
            }
        }

        #[target_feature(enable = "neon")]
        pub unsafe fn crelu_neon(acc: &[i16; L1_SIZE], out: &mut [i16; L1_SIZE], ceiling: i16) {
            let zero = vdupq_n_s16(0);
            let cap = vdupq_n_s16(ceiling);
            let mut i = 0;
            while i < L1_SIZE {
                let a = vld1q_s16(acc.as_ptr().add(i));
                let clamped = vminq_s16(vmaxq_s16(a, zero), cap);
                vst1q_s16(out.as_mut_ptr().add(i), clamped);
                i += 8;
            }
        }

        /// Truncating (round-toward-zero) division of each i32 lane by 2^shift,
        /// matching Rust's `/` operator for i32. Uses variable-count shifts
        /// (vshlq_* with a negative amount shifts right) since `shift` is a
        /// runtime value; the `vshrq_n_*` intrinsics require a compile-time
        /// constant immediate.
        #[target_feature(enable = "neon")]
        unsafe fn div_pow2_trunc(x: int32x4_t, shift: i32) -> int32x4_t {
            let sign = vshrq_n_s32(x, 31); // 31 is a literal immediate: all-1s if negative, else all-0s
            let sign_u = vreinterpretq_u32_s32(sign);
            let neg_amt = vdupq_n_s32(-(32 - shift));
            let bias_u = vshlq_u32(sign_u, neg_amt); // negative amount => logical right shift
            let bias = vreinterpretq_s32_u32(bias_u);
            let biased = vaddq_s32(x, bias);
            vshlq_s32(biased, vdupq_n_s32(-shift)) // negative amount => arithmetic right shift
        }

        #[target_feature(enable = "neon")]
        pub unsafe fn forward_l2_neon(
            l1_out: &[i16; L1_SIZE],
            weights: &[i16],
            bias: &[i16],
            acc2: &mut [i32; L2_SIZE],
        ) {
            // 4 lanes of i32 per vector -> L2_SIZE / 4 accumulators.
            let mut accs = [vdupq_n_s32(0); L2_SIZE / 4];

            for j in 0..L1_SIZE {
                let lj = l1_out[j];
                if lj == 0 {
                    continue;
                }
                let broadcast = vdupq_n_s32(lj as i32);
                let row_ptr = weights.as_ptr().add(j * L2_SIZE);

                for k in 0..(L2_SIZE / 4) {
                    let w16 = vld1_s16(row_ptr.add(k * 4));
                    let w32 = vmovl_s16(w16); // widen 4 x i16 -> 4 x i32
                    let prod = vmulq_s32(broadcast, w32);
                    accs[k] = vaddq_s32(accs[k], div_pow2_trunc(prod, 11));
                }
            }

            let mut raw = [0i32; L2_SIZE];
            for k in 0..(L2_SIZE / 4) {
                vst1q_s32(raw.as_mut_ptr().add(k * 4), accs[k]);
            }

            for i in 0..L2_SIZE {
                acc2[i] = bias[i] as i32 + raw[i];
            }
        }

        /// sum_i( truncating_div(a[i]*b[i], 2^shift) ), see the scalar twin
        /// `weighted_sum_div_pow2_scalar` for the exact semantics preserved here.
        #[target_feature(enable = "neon")]
        pub unsafe fn weighted_sum_div_pow2_neon(a: &[i16], b: &[i16], shift: u32) -> i32 {
            let mut acc = vdupq_n_s32(0);
            let mut i = 0;
            let len = a.len();
            while i + 8 <= len {
                let va = vld1q_s16(a.as_ptr().add(i));
                let vb = vld1q_s16(b.as_ptr().add(i));
                let lo = vmull_s16(vget_low_s16(va), vget_low_s16(vb));
                let hi = vmull_s16(vget_high_s16(va), vget_high_s16(vb));
                acc = vaddq_s32(acc, div_pow2_trunc(lo, shift as i32));
                acc = vaddq_s32(acc, div_pow2_trunc(hi, shift as i32));
                i += 8;
            }
            let mut sum = vaddvq_s32(acc);
            let divisor = 1i32 << shift;
            while i < len {
                sum += (a[i] as i32 * b[i] as i32) / divisor;
                i += 1;
            }
            sum
        }
    }
}

// ============================================================================
// Tests: every SIMD backend must match the scalar reference exactly.
// ============================================================================
#[cfg(test)]
mod tests {
    use super::simd::*;
    use super::{L1_SIZE, L2_SIZE};

    fn rand_i16_vec(len: usize, seed: &mut u64) -> Vec<i16> {
        let mut v = Vec::with_capacity(len);
        for _ in 0..len {
            *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
            v.push(((*seed >> 48) as i16) % 2000);
        }
        v
    }

    #[test]
    fn accumulate_matches_scalar() {
        let mut seed = 12345u64;
        let base = rand_i16_vec(L1_SIZE, &mut seed);
        let weights = rand_i16_vec(L1_SIZE, &mut seed);

        let mut acc_simd = [0i16; L1_SIZE];
        acc_simd.copy_from_slice(&base);
        accumulate(&mut acc_simd, &weights);

        let mut acc_scalar = [0i16; L1_SIZE];
        acc_scalar.copy_from_slice(&base);
        for i in 0..L1_SIZE {
            acc_scalar[i] = acc_scalar[i].saturating_add(weights[i]);
        }

        assert_eq!(acc_simd, acc_scalar);
    }

    #[test]
    fn crelu_matches_scalar() {
        let mut seed = 999u64;
        let acc: Vec<i16> = rand_i16_vec(L1_SIZE, &mut seed)
            .iter()
            .map(|&x| x.wrapping_sub(1000))
            .collect();
        let mut acc_arr = [0i16; L1_SIZE];
        acc_arr.copy_from_slice(&acc);

        let mut out_simd = [0i16; L1_SIZE];
        crelu(&acc_arr, &mut out_simd, 2048);

        let mut out_scalar = [0i16; L1_SIZE];
        for i in 0..L1_SIZE {
            out_scalar[i] = acc_arr[i].clamp(0, 2048);
        }

        assert_eq!(out_simd, out_scalar);
    }

    #[test]
    fn forward_l2_matches_scalar() {
        let mut seed = 42u64;
        let l1_out_v: Vec<i16> = rand_i16_vec(L1_SIZE, &mut seed)
            .iter()
            .map(|&x| x.unsigned_abs() as i16 % 2048) // valid CReLU output range
            .collect();
        let mut l1_out = [0i16; L1_SIZE];
        l1_out.copy_from_slice(&l1_out_v);

        let weights = rand_i16_vec(L1_SIZE * L2_SIZE, &mut seed);
        let bias = rand_i16_vec(L2_SIZE, &mut seed);

        let mut acc_simd = [0i32; L2_SIZE];
        forward_l2(&l1_out, &weights, &bias, &mut acc_simd);

        let mut acc_scalar = [0i32; L2_SIZE];
        for i in 0..L2_SIZE {
            acc_scalar[i] = bias[i] as i32;
        }
        for j in 0..L1_SIZE {
            let lj = l1_out[j] as i32;
            if lj == 0 { continue; }
            let row = &weights[j * L2_SIZE..j * L2_SIZE + L2_SIZE];
            for i in 0..L2_SIZE {
                acc_scalar[i] += (lj * row[i] as i32) / 2048;
            }
        }

        assert_eq!(acc_simd, acc_scalar);
    }

    #[test]
    fn weighted_sum_div_pow2_matches_scalar() {
        let mut seed = 777u64;
        // Include plenty of negative values: this is exactly the case where
        // sum-then-divide would diverge from divide-then-sum.
        let a = rand_i16_vec(L2_SIZE, &mut seed);
        let b = rand_i16_vec(L2_SIZE, &mut seed);

        let simd_result = weighted_sum_div_pow2(&a, &b, 8);
        let scalar_result: i32 = a
            .iter()
            .zip(b.iter())
            .map(|(&x, &y)| (x as i32 * y as i32) / 256)
            .sum();

        assert_eq!(simd_result, scalar_result);
    }

    #[test]
    fn weighted_sum_div_pow2_matches_scalar_l1_sized() {
        // Also exercise the L1_SIZE-length case (used nowhere directly today,
        // but confirms the AVX2/NEON remainder-handling loop is correct for
        // lengths that aren't a clean multiple of 16).
        let mut seed = 2024u64;
        let a = rand_i16_vec(37, &mut seed);
        let b = rand_i16_vec(37, &mut seed);

        let simd_result = weighted_sum_div_pow2(&a, &b, 11);
        let scalar_result: i32 = a
            .iter()
            .zip(b.iter())
            .map(|(&x, &y)| (x as i32 * y as i32) / 2048)
            .sum();

        assert_eq!(simd_result, scalar_result);
    }

    #[test]
    fn stress_full_i16_range_many_trials() {
        // Real quantized weights can span the full i16 range and be negative;
        // hammer every primitive with 500 trials to catch rare lane/rounding bugs.
        let mut seed = 0xC0FFEEu64;
        let mut next = |lo: i32, hi: i32| -> i16 {
            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
            let r = (seed >> 33) as i64;
            (lo as i64 + (r % (hi - lo + 1) as i64)) as i16
        };

        for _ in 0..500 {
            // accumulate
            let base: Vec<i16> = (0..L1_SIZE).map(|_| next(-32000, 32000)).collect();
            let weights: Vec<i16> = (0..L1_SIZE).map(|_| next(i16::MIN as i32, i16::MAX as i32)).collect();
            let mut a1 = [0i16; L1_SIZE];
            a1.copy_from_slice(&base);
            accumulate(&mut a1, &weights);
            let mut a2 = [0i16; L1_SIZE];
            a2.copy_from_slice(&base);
            for i in 0..L1_SIZE { a2[i] = a2[i].saturating_add(weights[i]); }
            assert_eq!(a1, a2, "accumulate mismatch");

            // crelu
            let mut c1 = [0i16; L1_SIZE];
            crelu(&a1, &mut c1, 2048);
            let mut c2 = [0i16; L1_SIZE];
            for i in 0..L1_SIZE { c2[i] = a1[i].clamp(0, 2048); }
            assert_eq!(c1, c2, "crelu mismatch");

            // forward_l2 (c1 is a valid 0..=2048 CReLU output)
            let l2w: Vec<i16> = (0..L1_SIZE * L2_SIZE).map(|_| next(i16::MIN as i32, i16::MAX as i32)).collect();
            let l2b: Vec<i16> = (0..L2_SIZE).map(|_| next(i16::MIN as i32, i16::MAX as i32)).collect();
            let mut l2acc1 = [0i32; L2_SIZE];
            forward_l2(&c1, &l2w, &l2b, &mut l2acc1);
            let mut l2acc2 = [0i32; L2_SIZE];
            for i in 0..L2_SIZE { l2acc2[i] = l2b[i] as i32; }
            for j in 0..L1_SIZE {
                let lj = c1[j] as i32;
                if lj == 0 { continue; }
                for i in 0..L2_SIZE {
                    l2acc2[i] += (lj * l2w[j * L2_SIZE + i] as i32) / 2048;
                }
            }
            assert_eq!(l2acc1, l2acc2, "forward_l2 mismatch");

            // weighted_sum_div_pow2 (L3-style)
            let l2out: Vec<i16> = (0..L2_SIZE).map(|_| next(-32000, 32000)).collect();
            let l3w: Vec<i16> = (0..L2_SIZE).map(|_| next(i16::MIN as i32, i16::MAX as i32)).collect();
            let s1 = weighted_sum_div_pow2(&l2out, &l3w, 8);
            let s2: i32 = l2out.iter().zip(l3w.iter()).map(|(&x, &y)| (x as i32 * y as i32) / 256).sum();
            assert_eq!(s1, s2, "weighted_sum_div_pow2 mismatch");
        }
    }

    #[test]
    #[ignore]
    fn bench_evaluate_throughput() {
        use crate::board::Scacchiera;
        use crate::zobrist::get_zobrist_keys;
        use std::time::Instant;

        let net = super::LunaNNUE::load("luna.nnue").expect("luna.nnue must be present for this bench");
        let z = get_zobrist_keys();
        let board = Scacchiera::new_iniziale(z);

        let iters = 2_000_000u64;
        let start = Instant::now();
        let mut sink = 0i64;
        for _ in 0..iters {
            sink += net.evaluate(&board) as i64;
        }
        let elapsed = start.elapsed();
        println!(
            "evaluate(): {} calls in {:?} ({:.1} ns/call, {:.2} M evals/sec) [sink={}]",
            iters,
            elapsed,
            elapsed.as_nanos() as f64 / iters as f64,
            iters as f64 / elapsed.as_secs_f64() / 1e6,
            sink
        );
    }

    #[test]
    fn full_forward_pass_is_deterministic() {
        let mut seed = 314159u64;
        let l1_out_v: Vec<i16> = rand_i16_vec(L1_SIZE, &mut seed)
            .iter()
            .map(|&x| x.unsigned_abs() as i16 % 2048)
            .collect();
        let mut l1_out = [0i16; L1_SIZE];
        l1_out.copy_from_slice(&l1_out_v);
        let weights = rand_i16_vec(L1_SIZE * L2_SIZE, &mut seed);
        let bias = rand_i16_vec(L2_SIZE, &mut seed);

        let mut a = [0i32; L2_SIZE];
        let mut b = [0i32; L2_SIZE];
        forward_l2(&l1_out, &weights, &bias, &mut a);
        forward_l2(&l1_out, &weights, &bias, &mut b);
        assert_eq!(a, b);
    }
}
Jim.
Spunc595
Posts: 22
Joined: Mon Jul 06, 2026 12:15 am
Full name: Daniele Marpino

Re: Introducing Luna: My Rust-based NNUE Chess Engine

Post by Spunc595 »

Hi everyone, following up on my earlier post where I asked for help with Luna's "flat evaluation" (the engine gave almost the same score to every position). Good news: it's solved.

Just so you know, I'm not a neural-network expert, so I'll keep this simple.

What was going wrong: the problem wasn't the engine's search or the network itself — it was in how the engine read the network file. Think of it like this: the network was trained and saved correctly, but when the engine loaded it, it was reading the numbers in the wrong order and with the wrong scale. Because of this, the math kept cancelling almost everything out, so the engine always spat out roughly the same number.

What I did: I lined up the loading/reading side of the engine with exactly how the network was saved — same order, same scaling, same math. To make sure, I also wrote a little check script that compares the engine's output to the original network: they now match almost perfectly, and the evaluation finally reacts to the position (being up a queen, down material, etc. now give sensible, different scores).

What's left: now that the "plumbing" is fixed, it's clear the current network just isn't very strong — it understands some things well (like being up a queen) but misjudges others (rook value, simple endgames). That's not a bug anymore, it's just a weak network. So my next step is to train a new, better NNUE with more and better data. I'll test it with the same check script before putting it in the engine.

Thanks a lot to everyone who chipped in with suggestions — it really helped me understand where to look. I'll report back once the new network is trained!
Spunc595
Posts: 22
Joined: Mon Jul 06, 2026 12:15 am
Full name: Daniele Marpino

Re: Introducing Luna: My Rust-based NNUE Chess Engine

Post by Spunc595 »

Hi Jim,

Thank you so much for taking the time to look at my code and for spotting that bug! You are absolutely right: the 2048 clamp in L1 instead of the correct QA=255 quantization scale was pinning almost all neurons to the extremes of the CReLU. This perfectly explained why the engine was completely blind to material.

In the meantime, by diving deep into the training and export files of Luna (which you obviously didn't have access to), I discovered and fixed two other critical issues that were breaking the model:

Transposed matrix layout: The weights exported from PyTorch were in [out, in] format, but my loader was reading them as [in, out]. This caused a complete mismatch of the weights during inference.

Extra clamp in L2: There was a hard ceiling of 128 on the L2 output in the code, whereas the original PyTorch training model used a standard unbounded nn.ReLU().

After fixing all three bugs (the L1 clamp, the weight transposition, and removing the L2 ceiling), Luna's Rust inference now perfectly matches the PyTorch model's output with a discrepancy of less than 0.001.

Thanks again for your valuable help, it was a great starting point!

Best regards,
Daniele