diff --git a/Cargo.toml b/Cargo.toml index 2a14bf1..e74bc5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ datagen = [ zstd = ["dep:zstd"] final-release = ["zstd", "bindgen", "syzygy"] ft-record = [] +# Rama Chess Android: net from a file (UCI option EvalFile) instead of embedding it +external-net = [] [build-dependencies] cc = { version = "1.2.6", optional = true } diff --git a/src/nnue/geometry/neon.rs b/src/nnue/geometry/neon.rs index 7e09df0..655f9a3 100644 --- a/src/nnue/geometry/neon.rs +++ b/src/nnue/geometry/neon.rs @@ -5,7 +5,7 @@ use std::arch::aarch64::*; use crate::{ chess::{piece::Piece, types::Square}, nnue::geometry::Bit, - util::Align64, + util::Align, }; use super::{BitRays, INCOMING_SLIDERS_MASK, INCOMING_THREATS_MASK, PERMUTATION, PIECE_TO_BIT}; @@ -133,7 +133,7 @@ pub fn permute_mailbox_ignoring( ) -> (Vector, Vector) { const NO_PIECE: u8 = unsafe { std::mem::transmute::, u8>(None) }; unsafe { - let iota = Align64([ + let iota = Align([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, diff --git a/src/nnue/network.rs b/src/nnue/network.rs index 642d76a..a7719f7 100644 --- a/src/nnue/network.rs +++ b/src/nnue/network.rs @@ -35,7 +35,16 @@ pub mod pawn_updates; pub mod threat_updates; /// The embedded neural network parameters. +#[cfg(not(feature = "external-net"))] pub static EMBEDDED_NNUE: &[u8] = include_bytes_aligned!("../../viridithas.nnue.zst"); +/// Android build (Rama Chess): the net is not embedded; it is loaded from the file given by the UCI option +/// EvalFile (the same zstd file the release embeds). Until then a zeroed network is used and "go" refuses to search. +#[cfg(feature = "external-net")] +pub static EMBEDDED_NNUE: &[u8] = &[]; + +#[cfg(feature = "external-net")] +static EXTERNAL_NNUE: std::sync::atomic::AtomicPtr = + std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); /// Whether the embedded network can be used verbatim. pub const EMBEDDED_NNUE_VERBATIM: bool = false; @@ -788,6 +797,18 @@ impl NNUEParams { return Ok(&*MIRI_NULL_NETWORK); } + #[cfg(feature = "external-net")] + { + let ptr = EXTERNAL_NNUE.load(std::sync::atomic::Ordering::Acquire); + if !ptr.is_null() { + // SAFETY: set by load_external from a leaked Box, never freed. + return Ok(unsafe { &*ptr }); + } + static ZEROED_NETWORK: LazyLock> = LazyLock::new(NNUEParams::zeroed); + return Ok(&*ZEROED_NETWORK); + } + + #[allow(unreachable_code)] if EMBEDDED_NNUE_VERBATIM { // if we're using the verbatim network, we don't need to decompress anything. // just return a reference to the static network. @@ -996,6 +1017,47 @@ impl NNUEParams { Ok(params) } + /// Android build (Rama Chess): whether a network was loaded with `load_external`. + #[cfg(feature = "external-net")] + pub fn external_loaded() -> bool { + !EXTERNAL_NNUE.load(std::sync::atomic::Ordering::Acquire).is_null() + } + + /// Android build (Rama Chess): load the zstd-compressed net file at `path` into memory + /// (no temp file / mmap: an app cannot rely on a shared temp dir). Later calls replace the net. + #[cfg(feature = "external-net")] + pub fn load_external(path: &str) -> anyhow::Result<&'static Self> { + type ZstdDecoder = ruzstd::decoding::StreamingDecoder; + let compressed = std::fs::read(path).with_context(|| format!("cannot read {path}"))?; + anyhow::ensure!( + compressed.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]), + "{path} is not a zstd-compressed Viridithas network" + ); + let mut net = QuantisedNetwork::zeroed(); + // SAFETY: QN is POD and we only write to it. + let mut mem = unsafe { + std::slice::from_raw_parts_mut( + std::ptr::from_mut(net.as_mut()).cast::(), + size_of::(), + ) + }; + let expected_bytes = mem.len() as u64; + let mut decoder = ZstdDecoder::new(compressed.as_slice()) + .with_context(|| "Failed to construct zstd decoder for NNUE weights.")?; + let bytes_written = std::io::copy(&mut decoder, &mut mem) + .with_context(|| "Failed to decompress NNUE weights.")?; + anyhow::ensure!( + bytes_written == expected_bytes, + "wrong network size: expected {expected_bytes} bytes, but got {bytes_written}" + ); + drop(decoder); + drop(compressed); + let use_simd = cfg!(any(target_arch = "x86_64", target_feature = "neon")); + let params: &'static Self = Box::leak(net.permute(use_simd)); + EXTERNAL_NNUE.store(std::ptr::from_ref(params).cast_mut(), std::sync::atomic::Ordering::Release); + Ok(params) + } + fn map_weight_file(weights_path: &Path) -> anyhow::Result { let without_full_ext = weights_path.with_extension("tmp"); let without_full_ext = without_full_ext.as_os_str().to_string_lossy(); diff --git a/src/nnue/simd.rs b/src/nnue/simd.rs index 2c23521..90f3c72 100644 --- a/src/nnue/simd.rs +++ b/src/nnue/simd.rs @@ -978,6 +978,20 @@ mod neon { // NOTE: This operation is limited and does not do exactly what it seems. // NEON only supports i8-i8 dotprod, so this function will misbehave if // vec0 contains values outwith 0..127. + // Rama Chess Android: sdot is ARMv8.2 (FEAT_DotProd) and is SIGILL on older arm64 phones. Without the + // "dotprod" target feature the same sum is computed with ARMv8.0 NEON (exact: i8*i8 fits i16). + #[cfg(not(target_feature = "dotprod"))] + #[inline(always)] + pub unsafe fn madd_u8_to_i32(sum: VecI32, vec0: VecI8, vec1: VecI8) -> VecI32 { + unsafe { + let a = vec0.inner(); + let b = vec1.inner(); + let lo = vpaddlq_s16(vmull_s8(vget_low_s8(a), vget_low_s8(b))); + let hi = vpaddlq_s16(vmull_high_s8(a, b)); + return VecI32::from_raw(vaddq_s32(sum.inner(), vpaddq_s32(lo, hi))); + } + } + #[cfg(target_feature = "dotprod")] #[inline(always)] pub unsafe fn madd_u8_to_i32(sum: VecI32, vec0: VecI8, vec1: VecI8) -> VecI32 { // Assembly implementation of vdotq_s32 very generously provided by diff --git a/src/uci.rs b/src/uci.rs index 45eefbd..b9007e0 100644 --- a/src/uci.rs +++ b/src/uci.rs @@ -71,7 +71,8 @@ pub fn main_loop() -> Result<(), UciError> { cache.resize(UCI_DEFAULT_HASH_MEGABYTES * MEGABYTE, &worker_threads); // default hash size let control = Arc::new(Control::default()); - let nnue_params = + #[cfg_attr(not(feature = "external-net"), allow(unused_mut))] + let mut nnue_params = NNUEParams::decompress_and_alloc().map_err(|e| UciError::NnueInit(e.to_string()))?; let (stdin, stdin_reader_handle) = stdin_reader(Arc::clone(&control))?; @@ -92,6 +93,11 @@ pub fn main_loop() -> Result<(), UciError> { .map_err(|e| UciError::NnueInit(e.to_string()))?; thread_data[0].info.set_stdin(&stdin); + // Android build (Rama Chess): false after a "position" command that failed to parse (e.g. side not to + // move in check); "go" then answers bestmove 0000 instead of searching the previous position. + #[cfg(feature = "external-net")] + let mut position_ok = true; + loop { std::io::stdout().flush()?; let Ok(line) = stdin @@ -104,6 +110,63 @@ pub fn main_loop() -> Result<(), UciError> { let input = line.trim(); let res: Result<(), UciError> = match input { + // Android build (Rama Chess): commands that need the net, while no net is loaded. + #[cfg(feature = "external-net")] + "bench" | "benchfull" | "eval" | "raweval" | "gobench" | "nnuebench" + if !NNUEParams::external_loaded() => + { + println!("info string ERROR: no network loaded, use setoption name EvalFile value "); + Ok(()) + } + #[cfg(feature = "external-net")] + input + if is_cmd(input, "go") + && !is_cmd(input, "go perft") + && (!NNUEParams::external_loaded() || !position_ok) => + { + if NNUEParams::external_loaded() { + println!("info string ERROR: the last position command was invalid"); + } else { + println!("info string ERROR: no network loaded, use setoption name EvalFile value "); + } + println!("bestmove 0000"); + Ok(()) + } + #[cfg(feature = "external-net")] + input + if is_cmd(input, "setoption") + && input.split_ascii_whitespace().nth(2) == Some("EvalFile") => + { + let path = input.split_once(" value ").map_or("", |(_, p)| p.trim()); + match NNUEParams::load_external(path) { + Ok(params) => { + nnue_params = params; + // Rebuild the thread data: each thread holds the net reference and its accumulators. + let pos = thread_data[0].board.clone(); + let conf = thread_data[0].info.conf.clone(); + std::mem::drop(thread_data); + thread_data = make_thread_data( + &pos, + cache.view(), + nnue_params, + &stopped, + &nodes, + &tbhits, + &control, + &worker_threads, + ) + .map_err(|e| UciError::NnueInit(e.to_string()))?; + for t in &mut thread_data { + t.info.conf = conf.clone(); + t.info.lm_table = LMTable::new(&t.info.conf); + t.info.set_stdin(&stdin); + } + println!("info string loaded network {path}"); + } + Err(e) => println!("info string ERROR: {e:#}"), + } + Ok(()) + } "uci" => { #[cfg(feature = "tuning")] print_uci_response(&thread_data[0].info, true); @@ -262,14 +325,21 @@ pub fn main_loop() -> Result<(), UciError> { Err(e) => Err(e.into()), } } - input if is_cmd(input, "position") => thread_data - .iter_mut() - .try_for_each(|t| { - parse_position(input, &mut t.board)?; - t.nnue.reïnit_from(&t.board, t.nnue_params); - Ok::<_, PositionParseError>(()) - }) - .map_err(Into::into), + input if is_cmd(input, "position") => { + let res = thread_data + .iter_mut() + .try_for_each(|t| { + parse_position(input, &mut t.board)?; + t.nnue.reïnit_from(&t.board, t.nnue_params); + Ok::<_, PositionParseError>(()) + }) + .map_err(Into::into); + #[cfg(feature = "external-net")] + { + position_ok = res.is_ok(); + } + res + } input if is_cmd(input, "go perft") || is_cmd(input, "perft") => { parse_perft(thread_data.first_mut(), input) } @@ -844,6 +914,8 @@ fn print_uci_response(info: &SearchInfo, full: bool) { println!("option name Contempt type spin default 0 min -10000 max 10000"); println!("option name Ponder type check default false"); println!("option name UCI_Chess960 type check default false"); + #[cfg(feature = "external-net")] + println!("option name EvalFile type string default "); if full { for (id, default, min, max, _) in info.conf.base_config() { println!("option name {id} type spin default {default} min {min} max {max}");