From 6de9b5cc907b7f54acbe58fd11a9569dddb18d6b Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 10 Oct 2026 09:50:03 -0500 Subject: [PATCH] perf(lite-backend): borrow embedded Sapling params instead of copying Every LightClient copied the Sapling spend and output parameters, which rust-embed already compiles into the binary, onto the heap: 51,551,256 bytes (49.2 MiB) of private memory per live client, whether or not it ever sent anything. LightClient::sapling_output and sapling_spend are now Cow<'static, [u8]> and borrow the embedded image. set_sapling_params still stores an owned copy, with its is-empty guard, hash checks and disk write unchanged. The prover receives the same bytes. Behaviour changes: - about 49 MiB less anonymous memory per live LightClient; the wallet file and JSON are unchanged; - the first send or shield pages the 51.5 MB of embedded params in from the binary, where client creation used to copy them (an estimated 0.3-1 s from the AppImage); - the public fields sapling_output and sapling_spend change type from Vec to Cow<'static, [u8]>: a Rust API change, with no C ABI or exported-symbol change. tests/sapling_params.rs checks that a restored, a new and a reloaded client all borrow the embedded image (same pointer, pinned lengths and SHA-256), that set_sapling_params keeps its guard and hash checks, and that the borrowed params parse like a heap copy. An ignored test measures 576 KiB of RssAnon growth for a second client, against 46.5 MiB with the old heap copy. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/lightclient.rs | 33 ++-- .../lib/tests/sapling_params.rs | 180 ++++++++++++++++++ 2 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 third_party/silentdragonxlite/silentdragonxlite-cli/lib/tests/sapling_params.rs diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs index ebd2e71..a020f1e 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs @@ -2,6 +2,7 @@ // Released under the GPLv3 use crate::lightwallet::LightWallet; +use std::borrow::Cow; use std::sync::{Arc, RwLock, Mutex, mpsc::channel}; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; use std::path::{Path, PathBuf}; @@ -302,8 +303,8 @@ pub struct LightClient { pub config : LightClientConfig, // zcash-params - pub sapling_output : Vec, - pub sapling_spend : Vec, + pub sapling_output : Cow<'static, [u8]>, + pub sapling_spend : Cow<'static, [u8]>, sync_lock : Mutex<()>, sync_status : Arc>, // The current syncing status of the Wallet. @@ -338,8 +339,8 @@ impl LightClient { fn read_sapling_params(&mut self) { // Read Sapling Params use crate::SaplingParams; - self.sapling_output.extend_from_slice(SaplingParams::get("sapling-output.params").unwrap().as_ref()); - self.sapling_spend.extend_from_slice(SaplingParams::get("sapling-spend.params").unwrap().as_ref()); + self.sapling_output = SaplingParams::get("sapling-output.params").unwrap(); + self.sapling_spend = SaplingParams::get("sapling-spend.params").unwrap(); } pub fn set_sapling_params(&mut self, sapling_output: &[u8], sapling_spend: &[u8]) -> Result<(), String> { use sha2::{Sha256, Digest}; @@ -354,10 +355,10 @@ impl LightClient { } // Will not overwrite previous params if self.sapling_output.is_empty() { - self.sapling_output.extend_from_slice(sapling_output); + self.sapling_output = Cow::Owned(sapling_output.to_vec()); } if self.sapling_spend.is_empty() { - self.sapling_spend.extend_from_slice(sapling_spend); + self.sapling_spend = Cow::Owned(sapling_spend.to_vec()); } // Ensure that the sapling params are stored on disk properly as well. @@ -389,8 +390,8 @@ impl LightClient { let mut l = LightClient { wallet : Arc::new(RwLock::new(LightWallet::new(Some(seed_phrase), &config, 0,0 )?)), config : config.clone(), - sapling_output : vec![], - sapling_spend : vec![], + sapling_output : Cow::Borrowed(&[]), + sapling_spend : Cow::Borrowed(&[]), sync_lock : Mutex::new(()), sync_status : Arc::new(RwLock::new(WalletStatus::new())), shutdown_flag : Arc::new(AtomicBool::new(false)), @@ -418,8 +419,8 @@ impl LightClient { let mut l = LightClient { wallet : Arc::new(RwLock::new(LightWallet::new(None, config, latest_block, 0)?)), config : config.clone(), - sapling_output : vec![], - sapling_spend : vec![], + sapling_output : Cow::Borrowed(&[]), + sapling_spend : Cow::Borrowed(&[]), sync_lock : Mutex::new(()), sync_status : Arc::new(RwLock::new(WalletStatus::new())), shutdown_flag : Arc::new(AtomicBool::new(false)), @@ -446,8 +447,8 @@ impl LightClient { let mut l = LightClient { wallet : Arc::new(RwLock::new(LightWallet::new(Some(seed_phrase), config, birthday, number)?)), config : config.clone(), - sapling_output : vec![], - sapling_spend : vec![], + sapling_output : Cow::Borrowed(&[]), + sapling_spend : Cow::Borrowed(&[]), sync_lock : Mutex::new(()), sync_status : Arc::new(RwLock::new(WalletStatus::new())), shutdown_flag : Arc::new(AtomicBool::new(false)), @@ -503,8 +504,8 @@ impl LightClient { let mut lc = LightClient { wallet : Arc::new(RwLock::new(wallet)), config : config.clone(), - sapling_output : vec![], - sapling_spend : vec![], + sapling_output : Cow::Borrowed(&[]), + sapling_spend : Cow::Borrowed(&[]), sync_lock : Mutex::new(()), sync_status : Arc::new(RwLock::new(WalletStatus::new())), shutdown_flag : Arc::new(AtomicBool::new(false)), @@ -1837,8 +1838,8 @@ pub mod tests { let lc = LightClient { wallet : Arc::new(RwLock::new(LightWallet::new(None, &config, 0, 0).unwrap())), config : config, - sapling_output : vec![], - sapling_spend : vec![], + sapling_output : std::borrow::Cow::Borrowed(&[]), + sapling_spend : std::borrow::Cow::Borrowed(&[]), sync_lock : Mutex::new(()), sync_status : Arc::new(RwLock::new(WalletStatus::new())), shutdown_flag : Arc::new(AtomicBool::new(false)), diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/tests/sapling_params.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/tests/sapling_params.rs new file mode 100644 index 0000000..d9bc048 --- /dev/null +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/tests/sapling_params.rs @@ -0,0 +1,180 @@ +// Copyright The Hush Developers 2019-2022 +// Released under the GPLv3 + +//! The Sapling params on a `LightClient` borrow the image that rust-embed bakes into the binary +//! (`Cow::Borrowed(&'static [u8])`) instead of copying ~49 MiB onto the heap for every client. +//! +//! Every client here is on the "main" chain (the "test" chain, which `LightClient::unconnected` +//! hard-codes, panics in `base58_pubkey_address` when the wallet is created) and lives in a temp +//! data dir `/data`, so the wallet file and `get_zcash_params_path()` (`/../.zcash-params`) +//! both stay inside the temp dir: nothing is written to ~/.silentdragonxlite or ~/.zcash-params. + +use std::borrow::Cow; +use std::path::PathBuf; + +use bellman::groth16::Parameters; +use pairing::bls12_381::Bls12; +use sha2::{Digest, Sha256}; +use tempdir::TempDir; + +use silentdragonxlitelib::lightclient::{LightClient, LightClientConfig}; +use silentdragonxlitelib::SaplingParams; + +const SEED: &str = "youth strong sweet gorilla hammer unhappy congress stamp left stereo riot salute road tag clean toilet artefact fork certain leopard entire civil degree wonder"; + +const OUTPUT_LEN: usize = 3_592_860; +const SPEND_LEN: usize = 47_958_396; +const OUTPUT_SHA256: &str = "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"; +const SPEND_SHA256: &str = "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"; + +fn main_config(tmp: &TempDir) -> LightClientConfig { + let data: PathBuf = tmp.path().join("data"); + LightClientConfig::create_unconnected("main".to_string(), data.to_str().map(|s| s.to_string())) +} + +fn embedded(name: &str) -> Cow<'static, [u8]> { + SaplingParams::get(name).expect("embedded Sapling params") +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn is_borrowed(c: &Cow<'static, [u8]>) -> bool { + matches!(c, Cow::Borrowed(_)) +} + +/// Borrowed (no heap copy), the right length and content, and the files are not swapped. +fn assert_params_pinned(lc: &LightClient, ctx: &str) { + assert!(is_borrowed(&lc.sapling_output), "{}: sapling_output is not Cow::Borrowed", ctx); + assert!(is_borrowed(&lc.sapling_spend), "{}: sapling_spend is not Cow::Borrowed", ctx); + assert_eq!(lc.sapling_output.len(), OUTPUT_LEN, "{}: sapling_output length", ctx); + assert_eq!(lc.sapling_spend.len(), SPEND_LEN, "{}: sapling_spend length", ctx); + assert_eq!(sha256_hex(&lc.sapling_output[..]), OUTPUT_SHA256, "{}: sapling_output hash", ctx); + assert_eq!(sha256_hex(&lc.sapling_spend[..]), SPEND_SHA256, "{}: sapling_spend hash", ctx); +} + +#[test] +fn params_borrowed_and_pinned() { + // Restore from a seed phrase. + let tmp = TempDir::new("sdxl-params").unwrap(); + let cfg = main_config(&tmp); + let lc = LightClient::new_from_phrase(SEED.to_string(), &cfg, 0, 0, false).unwrap(); + assert_params_pinned(&lc, "new_from_phrase"); + // The borrow points into the embedded image itself. + assert_eq!(lc.sapling_spend.as_ptr(), embedded("sapling-spend.params").as_ptr()); + assert_eq!(lc.sapling_output.as_ptr(), embedded("sapling-output.params").as_ptr()); + + // The GUI open path: create a new wallet, save it, then open it from disk. + let tmp2 = TempDir::new("sdxl-params").unwrap(); + let cfg2 = main_config(&tmp2); + let lc2 = LightClient::new(&cfg2, 0).unwrap(); + assert_params_pinned(&lc2, "new"); + lc2.do_save().unwrap(); + drop(lc2); + let lc3 = LightClient::read_from_disk(&cfg2).unwrap(); + assert_params_pinned(&lc3, "read_from_disk"); +} + +#[test] +fn set_params_guard_and_hash() { + let tmp = TempDir::new("sdxl-params").unwrap(); + let cfg = main_config(&tmp); + let mut lc = LightClient::new_from_phrase(SEED.to_string(), &cfg, 0, 0, false).unwrap(); + + let out = embedded("sapling-output.params").to_vec(); + let spend = embedded("sapling-spend.params").to_vec(); + + // Fields already filled from the embedded image: a valid set is Ok and does not overwrite them. + let p_out = lc.sapling_output.as_ptr(); + let p_spend = lc.sapling_spend.as_ptr(); + assert!(lc.set_sapling_params(&out, &spend).is_ok()); + assert!(is_borrowed(&lc.sapling_output) && is_borrowed(&lc.sapling_spend)); + assert_eq!(lc.sapling_output.as_ptr(), p_out); + assert_eq!(lc.sapling_spend.as_ptr(), p_spend); + + // Empty fields (as with embed_params off). + lc.sapling_output = Cow::Borrowed(&[]); + lc.sapling_spend = Cow::Borrowed(&[]); + + // Swapped files fail the hash check and leave both fields untouched. + assert!(lc.set_sapling_params(&spend, &out).is_err()); + assert!(lc.sapling_output.is_empty()); + assert!(lc.sapling_spend.is_empty()); + + // The right files are taken as owned copies with identical bytes. + assert!(lc.set_sapling_params(&out, &spend).is_ok()); + assert!(matches!(lc.sapling_output, Cow::Owned(_)), "sapling_output is not Cow::Owned"); + assert!(matches!(lc.sapling_spend, Cow::Owned(_)), "sapling_spend is not Cow::Owned"); + assert!(lc.sapling_output[..] == out[..]); + assert!(lc.sapling_spend[..] == spend[..]); + + // A second call does not overwrite them. + let p = lc.sapling_spend.as_ptr(); + let po = lc.sapling_output.as_ptr(); + assert!(lc.set_sapling_params(&out, &spend).is_ok()); + assert_eq!(lc.sapling_spend.as_ptr(), p); + assert_eq!(lc.sapling_output.as_ptr(), po); + + // The params are also written to /../.zcash-params, i.e. inside the temp dir. + #[cfg(target_os = "linux")] + { + let dir = tmp.path().join(".zcash-params"); + let spend_file = dir.join("sapling-spend.params"); + let output_file = dir.join("sapling-output.params"); + assert!(spend_file.exists(), "{} missing", spend_file.display()); + assert!(output_file.exists(), "{} missing", output_file.display()); + assert_eq!(std::fs::metadata(&spend_file).unwrap().len(), SPEND_LEN as u64); + assert_eq!(std::fs::metadata(&output_file).unwrap().len(), OUTPUT_LEN as u64); + } +} + +#[test] +fn borrowed_params_parse_identically() { + let tmp = TempDir::new("sdxl-params").unwrap(); + let cfg = main_config(&tmp); + let lc = LightClient::new_from_phrase(SEED.to_string(), &cfg, 0, 0, false).unwrap(); + + // The new borrowed path against the old heap-copy path: the prover gets identical keys. + let spend_new = Parameters::::read(&lc.sapling_spend[..], false).unwrap(); + let spend_old = Parameters::::read(&embedded("sapling-spend.params").to_vec()[..], false).unwrap(); + assert!(spend_new == spend_old, "spend params parse differently"); + drop(spend_new); + drop(spend_old); + + let output_new = Parameters::::read(&lc.sapling_output[..], false).unwrap(); + let output_old = Parameters::::read(&embedded("sapling-output.params").to_vec()[..], false).unwrap(); + assert!(output_new == output_old, "output params parse differently"); +} + +#[cfg(target_os = "linux")] +fn rss_anon_kib() -> u64 { + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + for line in status.lines() { + if line.starts_with("RssAnon:") { + return line["RssAnon:".len()..].trim().trim_end_matches("kB").trim().parse().unwrap(); + } + } + panic!("no RssAnon in /proc/self/status"); +} + +/// Run alone: `--test sapling_params -- --ignored --test-threads=1`. The old code copied both +/// params onto the heap for every client (~49 MiB of RssAnon). +#[cfg(target_os = "linux")] +#[test] +#[ignore] +fn open_rss_anon_delta() { + // Warm up one-time allocations (lazy statics, allocator arenas) with a first client. + let tmp0 = TempDir::new("sdxl-params").unwrap(); + let _warm = LightClient::new_from_phrase(SEED.to_string(), &main_config(&tmp0), 0, 0, false).unwrap(); + + let tmp = TempDir::new("sdxl-params").unwrap(); + let cfg = main_config(&tmp); + let before = rss_anon_kib(); + let lc = LightClient::new_from_phrase(SEED.to_string(), &cfg, 0, 0, false).unwrap(); + let after = rss_anon_kib(); + let delta = after.saturating_sub(before); + println!("RssAnon before {} kB, after {} kB, delta {} kB", before, after, delta); + assert_eq!(lc.sapling_spend.len(), SPEND_LEN); + assert!(delta < 16 * 1024, "opening a client grew RssAnon by {} kB", delta); +}