Skip to main content

linera_client/
benchmark.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::HashMap,
6    path::Path,
7    sync::{
8        atomic::{AtomicUsize, Ordering},
9        Arc,
10    },
11};
12
13use linera_base::{
14    data_types::{Amount, Timestamp},
15    identifiers::{Account, AccountOwner, ApplicationId, ChainId},
16    time::Instant,
17};
18use linera_core::{
19    client::chain_client::{self, ChainClient},
20    data_types::ClientOutcome,
21    Environment,
22};
23use linera_execution::{system::SystemOperation, Operation};
24use linera_sdk::abis::fungible::FungibleOperation;
25use num_format::{Locale, ToFormattedString};
26use prometheus_parse::{HistogramCount, Scrape, Value};
27use rand::{rngs::SmallRng, seq::SliceRandom, thread_rng, SeedableRng};
28use serde::{Deserialize, Serialize};
29use tokio::{
30    sync::{mpsc, Barrier, Notify},
31    task, time,
32};
33use tokio_util::sync::CancellationToken;
34use tracing::{debug, error, info, warn, Instrument as _};
35
36use crate::chain_listener::{ChainListener, ClientContext};
37
38/// Trait for generating benchmark operations.
39///
40/// Implement this trait to create custom operation generators for different
41/// application benchmarks (e.g., prediction markets, custom tokens, etc.).
42///
43/// Each benchmark chain gets its own generator instance. The generator is responsible
44/// for producing operations to include in blocks, including any destination chain
45/// selection logic.
46pub trait OperationGenerator: Send + 'static {
47    /// Generate a batch of operations for a single block.
48    fn generate_operations(&mut self, owner: AccountOwner, count: usize) -> Vec<Operation>;
49}
50
51/// A client the benchmark can drive, so the same harness runs against either the full
52/// [`ChainClient`] or a storage-free proposer.
53///
54/// The benchmark loop only ever asks a client to commit one block of operations, which is
55/// what makes the two interchangeable: everything else -- rate control, block sizing,
56/// destination selection, reporting -- is the harness's job and is shared.
57#[cfg_attr(not(web), async_trait::async_trait)]
58#[cfg_attr(web, async_trait::async_trait(?Send))]
59pub trait BenchmarkClient: Send + Sync + 'static {
60    /// The chain this client proposes on.
61    fn chain_id(&self) -> ChainId;
62
63    /// The owner the generated operations are attributed to.
64    async fn owner(&self) -> Result<AccountOwner, BenchmarkError>;
65
66    /// Proposes a block carrying `operations` and returns once it is committed.
67    async fn commit_operations(&self, operations: Vec<Operation>) -> Result<(), BenchmarkError>;
68}
69
70#[cfg_attr(not(web), async_trait::async_trait)]
71#[cfg_attr(web, async_trait::async_trait(?Send))]
72impl<Env: Environment> BenchmarkClient for ChainClient<Env> {
73    fn chain_id(&self) -> ChainId {
74        ChainClient::chain_id(self)
75    }
76
77    async fn owner(&self) -> Result<AccountOwner, BenchmarkError> {
78        self.identity().await.map_err(BenchmarkError::ChainClient)
79    }
80
81    async fn commit_operations(&self, operations: Vec<Operation>) -> Result<(), BenchmarkError> {
82        self.execute_operations(operations, vec![])
83            .await
84            .map_err(BenchmarkError::ChainClient)?
85            .expect("should execute block with operations");
86        Ok(())
87    }
88}
89
90/// Generates native fungible token transfer operations between chains.
91pub struct NativeFungibleTransferGenerator {
92    source_chain_id: ChainId,
93    destination_chains: Vec<ChainId>,
94    destination_index: usize,
95    rng: SmallRng,
96    single_destination_per_block: bool,
97    avoid_self: bool,
98}
99
100impl NativeFungibleTransferGenerator {
101    /// Creates a generator that sends native token transfers from the source chain.
102    ///
103    /// If `avoid_self` is true, `self.source_chain_id` is skipped whenever the destination
104    /// list has more than one entry (the historical behavior: a caller that wants a mix of
105    /// self- and cross-chain traffic should build a destination list that already includes
106    /// `source_chain_id` explicitly and pass `avoid_self = false`, otherwise it would never
107    /// actually be selected).
108    pub fn new(
109        source_chain_id: ChainId,
110        mut destination_chains: Vec<ChainId>,
111        single_destination_per_block: bool,
112        avoid_self: bool,
113    ) -> Result<Self, BenchmarkError> {
114        // With a single chain, send to self.
115        if destination_chains.is_empty() {
116            destination_chains.push(source_chain_id);
117        }
118        let mut rng = SmallRng::from_rng(thread_rng())?;
119        destination_chains.shuffle(&mut rng);
120        Ok(Self {
121            source_chain_id,
122            destination_chains,
123            destination_index: 0,
124            rng,
125            single_destination_per_block,
126            avoid_self,
127        })
128    }
129
130    fn next_destination(&mut self) -> ChainId {
131        if self.destination_index >= self.destination_chains.len() {
132            self.destination_chains.shuffle(&mut self.rng);
133            self.destination_index = 0;
134        }
135        let destination_chain_id = self.destination_chains[self.destination_index];
136        self.destination_index += 1;
137        // Skip self when there are other destinations available.
138        if destination_chain_id == self.source_chain_id
139            && self.destination_chains.len() > 1
140            && self.avoid_self
141        {
142            self.next_destination()
143        } else {
144            destination_chain_id
145        }
146    }
147}
148
149impl OperationGenerator for NativeFungibleTransferGenerator {
150    fn generate_operations(&mut self, _owner: AccountOwner, count: usize) -> Vec<Operation> {
151        let amount = Amount::from_attos(1);
152        if self.single_destination_per_block {
153            let recipient = self.next_destination();
154            (0..count)
155                .map(|_| {
156                    Operation::system(SystemOperation::Transfer {
157                        owner: AccountOwner::CHAIN,
158                        recipient: Account::chain(recipient),
159                        amount,
160                    })
161                })
162                .collect()
163        } else {
164            (0..count)
165                .map(|_| {
166                    let recipient = self.next_destination();
167                    Operation::system(SystemOperation::Transfer {
168                        owner: AccountOwner::CHAIN,
169                        recipient: Account::chain(recipient),
170                        amount,
171                    })
172                })
173                .collect()
174        }
175    }
176}
177
178/// Generates fungible token transfer operations between chains.
179pub struct FungibleTransferGenerator {
180    application_id: ApplicationId,
181    source_chain_id: ChainId,
182    destination_chains: Vec<ChainId>,
183    destination_index: usize,
184    rng: SmallRng,
185    single_destination_per_block: bool,
186    avoid_self: bool,
187}
188
189impl FungibleTransferGenerator {
190    /// Creates a generator that sends fungible token transfers from the source chain.
191    ///
192    /// `avoid_self` has the same meaning as on [`NativeFungibleTransferGenerator::new`]: with
193    /// it set, `source_chain_id` is skipped whenever the destination list has more than one
194    /// entry, so a caller wanting a mix of self- and cross-chain traffic passes `false` and a
195    /// list that already contains `source_chain_id`.
196    pub fn new(
197        application_id: ApplicationId,
198        source_chain_id: ChainId,
199        mut destination_chains: Vec<ChainId>,
200        single_destination_per_block: bool,
201        avoid_self: bool,
202    ) -> Result<Self, BenchmarkError> {
203        // With a single chain, send to self (matching old behavior).
204        if destination_chains.is_empty() {
205            destination_chains.push(source_chain_id);
206        }
207        let mut rng = SmallRng::from_rng(thread_rng())?;
208        destination_chains.shuffle(&mut rng);
209        Ok(Self {
210            application_id,
211            source_chain_id,
212            destination_chains,
213            destination_index: 0,
214            rng,
215            single_destination_per_block,
216            avoid_self,
217        })
218    }
219
220    fn next_destination(&mut self) -> ChainId {
221        if self.destination_index >= self.destination_chains.len() {
222            self.destination_chains.shuffle(&mut self.rng);
223            self.destination_index = 0;
224        }
225        let destination_chain_id = self.destination_chains[self.destination_index];
226        self.destination_index += 1;
227        // Skip self when there are other destinations available.
228        if destination_chain_id == self.source_chain_id
229            && self.destination_chains.len() > 1
230            && self.avoid_self
231        {
232            self.next_destination()
233        } else {
234            destination_chain_id
235        }
236    }
237}
238
239impl OperationGenerator for FungibleTransferGenerator {
240    fn generate_operations(&mut self, owner: AccountOwner, count: usize) -> Vec<Operation> {
241        let amount = Amount::from_attos(1);
242        if self.single_destination_per_block {
243            let recipient = self.next_destination();
244            (0..count)
245                .map(|_| fungible_transfer(self.application_id, recipient, owner, owner, amount))
246                .collect()
247        } else {
248            (0..count)
249                .map(|_| {
250                    let recipient = self.next_destination();
251                    fungible_transfer(self.application_id, recipient, owner, owner, amount)
252                })
253                .collect()
254        }
255    }
256}
257
258const PROXY_LATENCY_P99_THRESHOLD: f64 = 400.0;
259const LATENCY_METRIC_PREFIX: &str = "linera_proxy_request_latency";
260
261/// An error that can occur while running a benchmark.
262#[derive(Debug, thiserror::Error)]
263#[allow(missing_docs)]
264pub enum BenchmarkError {
265    #[error("Failed to join task: {0}")]
266    JoinError(#[from] task::JoinError),
267    #[error("Chain client error: {0}")]
268    ChainClient(#[from] chain_client::Error),
269    /// The storage-free client has no `chain_client::Error` to wrap, so its failures arrive
270    /// as a message.
271    #[error("Lite client error: {0}")]
272    LiteClient(String),
273    #[error("Current histogram count is less than previous histogram count")]
274    HistogramCountMismatch,
275    #[error("Expected histogram value, got {0:?}")]
276    ExpectedHistogramValue(Value),
277    #[error("Expected untyped value, got {0:?}")]
278    ExpectedUntypedValue(Value),
279    #[error("Incomplete histogram data")]
280    IncompleteHistogramData,
281    #[error("Could not compute quantile")]
282    CouldNotComputeQuantile,
283    #[error("Bucket boundaries do not match: {0} vs {1}")]
284    BucketBoundariesDoNotMatch(f64, f64),
285    #[error("Reqwest error: {0}")]
286    Reqwest(#[from] reqwest::Error),
287    #[error("Io error: {0}")]
288    IoError(#[from] std::io::Error),
289    #[error("Previous histogram snapshot does not exist: {0}")]
290    PreviousHistogramSnapshotDoesNotExist(String),
291    #[error("No data available yet to calculate p99")]
292    NoDataYetForP99Calculation,
293    #[error("Unexpected empty bucket")]
294    UnexpectedEmptyBucket,
295    #[error("Failed to send unit message: {0}")]
296    TokioSendUnitError(#[from] mpsc::error::SendError<()>),
297    #[error("Config file not found: {0}")]
298    ConfigFileNotFound(std::path::PathBuf),
299    #[error("Failed to load config file: {0}")]
300    ConfigLoadError(#[from] anyhow::Error),
301    #[error("Could not find enough chains in wallet alone: needed {0}, but only found {1}")]
302    NotEnoughChainsInWallet(usize, usize),
303    #[error("Random number generator error: {0}")]
304    RandError(#[from] rand::Error),
305    #[error("Chain listener startup error")]
306    ChainListenerStartupError,
307}
308
309#[derive(Debug)]
310struct HistogramSnapshot {
311    buckets: Vec<HistogramCount>,
312    count: f64,
313    sum: f64,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(rename_all = "kebab-case")]
318/// Configuration listing the chains to use for a benchmark.
319pub struct BenchmarkConfig {
320    /// The chains to use for the benchmark.
321    pub chain_ids: Vec<ChainId>,
322}
323
324impl BenchmarkConfig {
325    /// Loads the benchmark configuration from a YAML file.
326    pub fn load_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
327        let content = std::fs::read_to_string(path)?;
328        let config = serde_yaml::from_str(&content)?;
329        Ok(config)
330    }
331
332    /// Saves the benchmark configuration to a YAML file.
333    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
334        let content = serde_yaml::to_string(self)?;
335        std::fs::write(path, content)?;
336        Ok(())
337    }
338}
339
340/// Driver for running benchmarks against a network.
341pub struct Benchmark<Env: Environment> {
342    _phantom: std::marker::PhantomData<Env>,
343}
344
345impl<Env: Environment> Benchmark<Env> {
346    /// Runs a benchmark with the given chain clients and operation generators.
347    ///
348    /// Each chain client is paired with an operation generator (one per chain).
349    /// The generators produce the operations to include in each block.
350    #[expect(clippy::too_many_arguments)]
351    pub async fn run_benchmark<C: ClientContext<Environment = Env> + 'static>(
352        bps: usize,
353        chain_clients: Vec<Arc<dyn BenchmarkClient>>,
354        generators: Vec<Box<dyn OperationGenerator>>,
355        transactions_per_block: usize,
356        health_check_endpoints: Option<String>,
357        runtime_in_seconds: Option<u64>,
358        delay_between_chains_ms: Option<u64>,
359        chain_listener: Option<ChainListener<C>>,
360        shutdown_notifier: &CancellationToken,
361    ) -> Result<(), BenchmarkError> {
362        assert_eq!(
363            chain_clients.len(),
364            generators.len(),
365            "Must have one generator per chain client"
366        );
367        let num_chains = chain_clients.len();
368        let bps_counts = (0..num_chains)
369            .map(|_| Arc::new(AtomicUsize::new(0)))
370            .collect::<Vec<_>>();
371        let notifier = Arc::new(Notify::new());
372        let barrier = Arc::new(Barrier::new(num_chains + 1));
373
374        // Only the full client needs it: it keeps local chain state in sync in the
375        // background. The storage-free client has no local state to sync, and running one
376        // anyway would put exactly the work it avoids back onto the load generator.
377        let chain_listener_handle = match chain_listener {
378            Some(chain_listener) => {
379                let future = chain_listener
380                    .run()
381                    .await
382                    .map_err(|_| BenchmarkError::ChainListenerStartupError)?;
383                Some(tokio::spawn(future.in_current_span()))
384            }
385            None => None,
386        };
387
388        let bps_control_task = Self::bps_control_task(
389            &barrier,
390            shutdown_notifier,
391            &bps_counts,
392            &notifier,
393            transactions_per_block,
394            bps,
395        );
396
397        let (runtime_control_task, runtime_control_sender) =
398            Self::runtime_control_task(shutdown_notifier, runtime_in_seconds, num_chains);
399
400        let bps_initial_share = bps / num_chains;
401        let mut bps_remainder = bps % num_chains;
402        let mut join_set = task::JoinSet::<Result<(), BenchmarkError>>::new();
403        for (chain_idx, (chain_client, generator)) in
404            chain_clients.into_iter().zip(generators).enumerate()
405        {
406            let chain_id = chain_client.chain_id();
407            let shutdown_notifier_clone = shutdown_notifier.clone();
408            let barrier_clone = barrier.clone();
409            let bps_count_clone = bps_counts[chain_idx].clone();
410            let notifier_clone = notifier.clone();
411            let runtime_control_sender_clone = runtime_control_sender.clone();
412            let bps_share = if bps_remainder > 0 {
413                bps_remainder -= 1;
414                bps_initial_share + 1
415            } else {
416                bps_initial_share
417            };
418            join_set.spawn(
419                async move {
420                    Box::pin(Self::run_benchmark_internal(
421                        chain_idx,
422                        chain_id,
423                        bps_share,
424                        chain_client,
425                        generator,
426                        transactions_per_block,
427                        shutdown_notifier_clone,
428                        bps_count_clone,
429                        barrier_clone,
430                        notifier_clone,
431                        runtime_control_sender_clone,
432                        delay_between_chains_ms,
433                    ))
434                    .await?;
435
436                    Ok(())
437                }
438                .instrument(tracing::info_span!("chain_id", chain_id = ?chain_id)),
439            );
440        }
441
442        let metrics_watcher =
443            Self::metrics_watcher(health_check_endpoints, shutdown_notifier).await?;
444
445        // Wait for tasks and fail immediately if any task returns an error or panics
446        while let Some(result) = join_set.join_next().await {
447            let inner_result = result?;
448            if let Err(e) = inner_result {
449                error!("Benchmark task failed: {}", e);
450                shutdown_notifier.cancel();
451                join_set.abort_all();
452                return Err(e);
453            }
454        }
455        info!("All benchmark tasks completed successfully");
456
457        bps_control_task.await?;
458        if let Some(metrics_watcher) = metrics_watcher {
459            metrics_watcher.await??;
460        }
461        if let Some(runtime_control_task) = runtime_control_task {
462            runtime_control_task.await?;
463        }
464
465        if let Some(chain_listener_handle) = chain_listener_handle {
466            if let Err(e) = chain_listener_handle.await? {
467                tracing::error!("chain listener error: {e}");
468            }
469        }
470
471        Ok(())
472    }
473
474    // The bps control task will control the BPS from the threads.
475    fn bps_control_task(
476        barrier: &Arc<Barrier>,
477        shutdown_notifier: &CancellationToken,
478        bps_counts: &[Arc<AtomicUsize>],
479        notifier: &Arc<Notify>,
480        transactions_per_block: usize,
481        bps: usize,
482    ) -> task::JoinHandle<()> {
483        let shutdown_notifier = shutdown_notifier.clone();
484        let bps_counts = bps_counts.to_vec();
485        let notifier = notifier.clone();
486        let barrier = barrier.clone();
487        task::spawn(
488            async move {
489                barrier.wait().await;
490                let mut one_second_interval = time::interval(time::Duration::from_secs(1));
491                loop {
492                    if shutdown_notifier.is_cancelled() {
493                        info!("Shutdown signal received in bps control task");
494                        break;
495                    }
496                    one_second_interval.tick().await;
497                    let current_bps_count: usize = bps_counts
498                        .iter()
499                        .map(|count| count.swap(0, Ordering::Relaxed))
500                        .sum();
501                    notifier.notify_waiters();
502                    let formatted_current_bps = current_bps_count.to_formatted_string(&Locale::en);
503                    let formatted_current_tps = (current_bps_count * transactions_per_block)
504                        .to_formatted_string(&Locale::en);
505                    let formatted_tps_goal =
506                        (bps * transactions_per_block).to_formatted_string(&Locale::en);
507                    let formatted_bps_goal = bps.to_formatted_string(&Locale::en);
508                    if current_bps_count >= bps {
509                        info!(
510                            "Achieved {} BPS/{} TPS",
511                            formatted_current_bps, formatted_current_tps
512                        );
513                    } else {
514                        warn!(
515                            "Failed to achieve {} BPS/{} TPS, only achieved {} BPS/{} TPS",
516                            formatted_bps_goal,
517                            formatted_tps_goal,
518                            formatted_current_bps,
519                            formatted_current_tps,
520                        );
521                    }
522                }
523
524                info!("Exiting bps control task");
525            }
526            .instrument(tracing::info_span!("bps_control")),
527        )
528    }
529
530    async fn metrics_watcher(
531        health_check_endpoints: Option<String>,
532        shutdown_notifier: &CancellationToken,
533    ) -> Result<Option<task::JoinHandle<Result<(), BenchmarkError>>>, BenchmarkError> {
534        if let Some(health_check_endpoints) = health_check_endpoints {
535            let metrics_addresses = health_check_endpoints
536                .split(',')
537                .map(|address| format!("http://{}/metrics", address.trim()))
538                .collect::<Vec<_>>();
539
540            let mut previous_histogram_snapshots: HashMap<String, HistogramSnapshot> =
541                HashMap::new();
542            let scrapes = Self::get_scrapes(&metrics_addresses).await?;
543            for (metrics_address, scrape) in scrapes {
544                previous_histogram_snapshots.insert(
545                    metrics_address,
546                    Self::parse_histogram(&scrape, LATENCY_METRIC_PREFIX)?,
547                );
548            }
549
550            let shutdown_notifier = shutdown_notifier.clone();
551            let metrics_watcher: task::JoinHandle<Result<(), BenchmarkError>> = tokio::spawn(
552                async move {
553                    let mut health_interval = time::interval(time::Duration::from_secs(5));
554                    let mut shutdown_interval = time::interval(time::Duration::from_secs(1));
555                    loop {
556                        tokio::select! {
557                            biased;
558                            _ = health_interval.tick() => {
559                                let result = Self::validators_healthy(&metrics_addresses, &mut previous_histogram_snapshots).await;
560                                if let Err(ref err) = result {
561                                    info!("Shutting down benchmark due to error: {}", err);
562                                    shutdown_notifier.cancel();
563                                    break;
564                                } else if !result? {
565                                    info!("Shutting down benchmark due to unhealthy validators");
566                                    shutdown_notifier.cancel();
567                                    break;
568                                }
569                            }
570                            _ = shutdown_interval.tick() => {
571                                if shutdown_notifier.is_cancelled() {
572                                    info!("Shutdown signal received, stopping metrics watcher");
573                                    break;
574                                }
575                            }
576                        }
577                    }
578
579                    Ok(())
580                }
581                .instrument(tracing::info_span!("metrics_watcher")),
582            );
583
584            Ok(Some(metrics_watcher))
585        } else {
586            Ok(None)
587        }
588    }
589
590    fn runtime_control_task(
591        shutdown_notifier: &CancellationToken,
592        runtime_in_seconds: Option<u64>,
593        num_chain_groups: usize,
594    ) -> (Option<task::JoinHandle<()>>, Option<mpsc::Sender<()>>) {
595        if let Some(runtime_in_seconds) = runtime_in_seconds {
596            let (runtime_control_sender, mut runtime_control_receiver) =
597                mpsc::channel(num_chain_groups);
598            let shutdown_notifier = shutdown_notifier.clone();
599            let runtime_control_task = task::spawn(
600                async move {
601                    let mut chains_started = 0;
602                    while runtime_control_receiver.recv().await.is_some() {
603                        chains_started += 1;
604                        if chains_started == num_chain_groups {
605                            break;
606                        }
607                    }
608                    time::sleep(time::Duration::from_secs(runtime_in_seconds)).await;
609                    shutdown_notifier.cancel();
610                }
611                .instrument(tracing::info_span!("runtime_control")),
612            );
613            (Some(runtime_control_task), Some(runtime_control_sender))
614        } else {
615            (None, None)
616        }
617    }
618
619    async fn validators_healthy(
620        metrics_addresses: &[String],
621        previous_histogram_snapshots: &mut HashMap<String, HistogramSnapshot>,
622    ) -> Result<bool, BenchmarkError> {
623        let scrapes = Self::get_scrapes(metrics_addresses).await?;
624        for (metrics_address, scrape) in scrapes {
625            let histogram = Self::parse_histogram(&scrape, LATENCY_METRIC_PREFIX)?;
626            let diff = Self::diff_histograms(
627                previous_histogram_snapshots.get(&metrics_address).ok_or(
628                    BenchmarkError::PreviousHistogramSnapshotDoesNotExist(metrics_address.clone()),
629                )?,
630                &histogram,
631            )?;
632            let p99 = match Self::compute_quantile(&diff.buckets, diff.count, 0.99) {
633                Ok(p99) => p99,
634                Err(BenchmarkError::NoDataYetForP99Calculation) => {
635                    info!(
636                        "No data available yet to calculate p99 for {}",
637                        metrics_address
638                    );
639                    continue;
640                }
641                Err(e) => {
642                    error!("Error computing p99 for {}: {}", metrics_address, e);
643                    return Err(e);
644                }
645            };
646
647            let last_bucket_boundary = diff.buckets[diff.buckets.len() - 2].less_than;
648            if p99 == f64::INFINITY {
649                info!(
650                    "{} -> Estimated p99 for {} is higher than the last bucket boundary of {:?} ms",
651                    metrics_address, LATENCY_METRIC_PREFIX, last_bucket_boundary
652                );
653            } else {
654                info!(
655                    "{} -> Estimated p99 for {}: {:.2} ms",
656                    metrics_address, LATENCY_METRIC_PREFIX, p99
657                );
658            }
659            if p99 > PROXY_LATENCY_P99_THRESHOLD {
660                if p99 == f64::INFINITY {
661                    error!(
662                        "Proxy of validator {} unhealthy! Latency p99 is too high, it is higher than \
663                        the last bucket boundary of {:.2} ms",
664                        metrics_address, last_bucket_boundary
665                    );
666                } else {
667                    error!(
668                        "Proxy of validator {} unhealthy! Latency p99 is too high: {:.2} ms",
669                        metrics_address, p99
670                    );
671                }
672                return Ok(false);
673            }
674            previous_histogram_snapshots.insert(metrics_address.clone(), histogram);
675        }
676
677        Ok(true)
678    }
679
680    fn diff_histograms(
681        previous: &HistogramSnapshot,
682        current: &HistogramSnapshot,
683    ) -> Result<HistogramSnapshot, BenchmarkError> {
684        if current.count < previous.count {
685            return Err(BenchmarkError::HistogramCountMismatch);
686        }
687        let total_diff = current.count - previous.count;
688        let mut buckets_diff: Vec<HistogramCount> = Vec::new();
689        for (before, after) in previous.buckets.iter().zip(current.buckets.iter()) {
690            let bound_before = before.less_than;
691            let bound_after = after.less_than;
692            let cumulative_before = before.count;
693            let cumulative_after = after.count;
694            if (bound_before - bound_after).abs() > f64::EPSILON {
695                return Err(BenchmarkError::BucketBoundariesDoNotMatch(
696                    bound_before,
697                    bound_after,
698                ));
699            }
700            let diff = (cumulative_after - cumulative_before).max(0.0);
701            buckets_diff.push(HistogramCount {
702                less_than: bound_after,
703                count: diff,
704            });
705        }
706        Ok(HistogramSnapshot {
707            buckets: buckets_diff,
708            count: total_diff,
709            sum: current.sum - previous.sum,
710        })
711    }
712
713    async fn get_scrapes(
714        metrics_addresses: &[String],
715    ) -> Result<Vec<(String, Scrape)>, BenchmarkError> {
716        let mut scrapes = Vec::new();
717        for metrics_address in metrics_addresses {
718            let response = reqwest::get(metrics_address)
719                .await
720                .map_err(BenchmarkError::Reqwest)?;
721            let metrics = response.text().await.map_err(BenchmarkError::Reqwest)?;
722            let scrape = Scrape::parse(metrics.lines().map(|line| Ok(line.to_owned())))
723                .map_err(BenchmarkError::IoError)?;
724            scrapes.push((metrics_address.clone(), scrape));
725        }
726        Ok(scrapes)
727    }
728
729    fn parse_histogram(
730        scrape: &Scrape,
731        metric_prefix: &str,
732    ) -> Result<HistogramSnapshot, BenchmarkError> {
733        let mut buckets: Vec<HistogramCount> = Vec::new();
734        let mut total_count: Option<f64> = None;
735        let mut total_sum: Option<f64> = None;
736
737        // Iterate over each metric in the scrape.
738        for sample in &scrape.samples {
739            if sample.metric == metric_prefix {
740                if let Value::Histogram(histogram) = &sample.value {
741                    buckets.extend(histogram.iter().cloned());
742                } else {
743                    return Err(BenchmarkError::ExpectedHistogramValue(sample.value.clone()));
744                }
745            } else if sample.metric == format!("{metric_prefix}_count") {
746                if let Value::Untyped(count) = sample.value {
747                    total_count = Some(count);
748                } else {
749                    return Err(BenchmarkError::ExpectedUntypedValue(sample.value.clone()));
750                }
751            } else if sample.metric == format!("{metric_prefix}_sum") {
752                if let Value::Untyped(sum) = sample.value {
753                    total_sum = Some(sum);
754                } else {
755                    return Err(BenchmarkError::ExpectedUntypedValue(sample.value.clone()));
756                }
757            }
758        }
759
760        match (total_count, total_sum) {
761            (Some(count), Some(sum)) if !buckets.is_empty() => {
762                buckets.sort_by(|a, b| {
763                    a.less_than
764                        .partial_cmp(&b.less_than)
765                        .expect("Comparison should not fail")
766                });
767                Ok(HistogramSnapshot {
768                    buckets,
769                    count,
770                    sum,
771                })
772            }
773            _ => Err(BenchmarkError::IncompleteHistogramData),
774        }
775    }
776
777    fn compute_quantile(
778        buckets: &[HistogramCount],
779        total_count: f64,
780        quantile: f64,
781    ) -> Result<f64, BenchmarkError> {
782        if total_count == 0.0 {
783            // Had no samples in the last 5s.
784            return Err(BenchmarkError::NoDataYetForP99Calculation);
785        }
786        // Compute the target cumulative count.
787        let target = (quantile * total_count).ceil();
788        let mut prev_cumulative = 0.0;
789        let mut prev_bound = 0.0;
790        for bucket in buckets {
791            if bucket.count >= target {
792                let bucket_count = bucket.count - prev_cumulative;
793                if bucket_count == 0.0 {
794                    // Bucket that is supposed to contain the target quantile is empty, unexpectedly.
795                    return Err(BenchmarkError::UnexpectedEmptyBucket);
796                }
797                let fraction = (target - prev_cumulative) / bucket_count;
798                return Ok(prev_bound + (bucket.less_than - prev_bound) * fraction);
799            }
800            prev_cumulative = bucket.count;
801            prev_bound = bucket.less_than;
802        }
803        Err(BenchmarkError::CouldNotComputeQuantile)
804    }
805
806    #[expect(clippy::too_many_arguments)]
807    async fn run_benchmark_internal(
808        chain_idx: usize,
809        chain_id: ChainId,
810        bps: usize,
811        chain_client: Arc<dyn BenchmarkClient>,
812        mut generator: Box<dyn OperationGenerator>,
813        transactions_per_block: usize,
814        shutdown_notifier: CancellationToken,
815        bps_count: Arc<AtomicUsize>,
816        barrier: Arc<Barrier>,
817        notifier: Arc<Notify>,
818        runtime_control_sender: Option<mpsc::Sender<()>>,
819        delay_between_chains_ms: Option<u64>,
820    ) -> Result<(), BenchmarkError> {
821        barrier.wait().await;
822        if let Some(delay_between_chains_ms) = delay_between_chains_ms {
823            time::sleep(time::Duration::from_millis(
824                (chain_idx as u64) * delay_between_chains_ms,
825            ))
826            .await;
827        }
828        info!("Starting benchmark for chain {:?}", chain_id);
829
830        if let Some(runtime_control_sender) = runtime_control_sender {
831            runtime_control_sender.send(()).await?;
832        }
833
834        let owner = chain_client.owner().await?;
835
836        loop {
837            // Deliberately NOT raced against the shutdown signal. `select!` drops the losing
838            // future, and dropping a commit mid-flight abandons a block the validators have
839            // already voted on: the storage-free client keeps no local record of it, so the
840            // chain is left with an uncertified proposal at that height and every later
841            // proposal there is rejected with "Already voted to confirm a different block".
842            // Finishing the block first costs at most one block of shutdown latency.
843            if shutdown_notifier.is_cancelled() {
844                info!("Shutdown signal received, stopping benchmark");
845                break;
846            }
847
848            chain_client
849                .commit_operations(generator.generate_operations(owner, transactions_per_block))
850                .await?;
851
852            let current_bps_count = bps_count.fetch_add(1, Ordering::Relaxed) + 1;
853            if current_bps_count >= bps {
854                // Safe to race: waiting on the notifier holds no chain state, and it would
855                // otherwise block until the next tick even after shutdown.
856                tokio::select! {
857                    biased;
858
859                    _ = shutdown_notifier.cancelled() => {
860                        info!("Shutdown signal received, stopping benchmark");
861                        break;
862                    }
863                    _ = notifier.notified() => {}
864                }
865            }
866        }
867
868        info!("Exiting task...");
869        Ok(())
870    }
871
872    /// Closes the chain that was created for the benchmark.
873    pub async fn close_benchmark_chain(
874        chain_client: &ChainClient<Env>,
875    ) -> Result<(), BenchmarkError> {
876        let start = Instant::now();
877        loop {
878            let result = chain_client
879                .execute_operation(Operation::system(SystemOperation::CloseChain))
880                .await?;
881            match result {
882                ClientOutcome::Committed(_) => break,
883                ClientOutcome::Conflict(certificate) => {
884                    info!(
885                        "Conflict while closing chain {:?}: {}. Retrying...",
886                        chain_client.chain_id(),
887                        certificate.hash()
888                    );
889                }
890                ClientOutcome::WaitForTimeout(timeout) => {
891                    info!(
892                        "Waiting for timeout while closing chain {:?}: {}",
893                        chain_client.chain_id(),
894                        timeout
895                    );
896                    linera_base::time::timer::sleep(
897                        timeout.timestamp.duration_since(Timestamp::now()),
898                    )
899                    .await;
900                }
901            }
902        }
903
904        debug!(
905            "Closed chain {:?} in {} ms",
906            chain_client.chain_id(),
907            start.elapsed().as_millis()
908        );
909
910        Ok(())
911    }
912
913    /// Returns the chains to benchmark, from the config file if given, otherwise from the wallet.
914    pub fn get_all_chains(
915        chains_config_path: Option<&Path>,
916        benchmark_chains: &[(ChainId, AccountOwner)],
917    ) -> Result<Vec<ChainId>, BenchmarkError> {
918        let all_chains = if let Some(config_path) = chains_config_path {
919            if !config_path.exists() {
920                return Err(BenchmarkError::ConfigFileNotFound(
921                    config_path.to_path_buf(),
922                ));
923            }
924            let config = BenchmarkConfig::load_from_file(config_path)
925                .map_err(BenchmarkError::ConfigLoadError)?;
926            config.chain_ids
927        } else {
928            benchmark_chains.iter().map(|(id, _)| *id).collect()
929        };
930
931        Ok(all_chains)
932    }
933}
934
935/// Builds a fungible token transfer operation for the given application.
936pub fn fungible_transfer(
937    application_id: ApplicationId,
938    chain_id: ChainId,
939    sender: AccountOwner,
940    receiver: AccountOwner,
941    amount: Amount,
942) -> Operation {
943    let target_account = Account {
944        chain_id,
945        owner: receiver,
946    };
947    let bytes = bcs::to_bytes(&FungibleOperation::Transfer {
948        owner: sender,
949        amount,
950        target_account,
951    })
952    .expect("should serialize fungible token operation");
953    Operation::User {
954        application_id,
955        bytes,
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use linera_base::{crypto::CryptoHash, identifiers::ChainId};
962
963    use super::*;
964
965    fn chain(seed: &str) -> ChainId {
966        ChainId(CryptoHash::test_hash(seed))
967    }
968
969    /// `avoid_self` is what makes a mixed self/cross-chain workload expressible, and both
970    /// generators must honour it: the CLI hands them the same interleaved destination list,
971    /// so one ignoring the flag would silently measure 100% cross-chain traffic.
972    #[test]
973    fn avoid_self_decides_whether_the_source_is_a_destination() {
974        let source = chain("source");
975        let other = chain("other");
976        // As the CLI builds it for --mixed-self-transfers: one self entry per cross entry.
977        let interleaved = vec![other, source];
978
979        for avoid_self in [true, false] {
980            let mut native = NativeFungibleTransferGenerator::new(
981                source,
982                interleaved.clone(),
983                false,
984                avoid_self,
985            )
986            .unwrap();
987            let mut fungible = FungibleTransferGenerator::new(
988                ApplicationId::new(CryptoHash::test_hash("app")),
989                source,
990                interleaved.clone(),
991                false,
992                avoid_self,
993            )
994            .unwrap();
995
996            let native_hits = (0..100)
997                .filter(|_| native.next_destination() == source)
998                .count();
999            let fungible_hits = (0..100)
1000                .filter(|_| fungible.next_destination() == source)
1001                .count();
1002
1003            if avoid_self {
1004                assert_eq!(native_hits, 0, "native sent to itself despite avoid_self");
1005                assert_eq!(
1006                    fungible_hits, 0,
1007                    "fungible sent to itself despite avoid_self"
1008                );
1009            } else {
1010                // The list is shuffled, so this is a ratio and not an alternation.
1011                assert!(
1012                    (30..=70).contains(&native_hits),
1013                    "native self-share {native_hits}/100 is not ~half"
1014                );
1015                assert!(
1016                    (30..=70).contains(&fungible_hits),
1017                    "fungible self-share {fungible_hits}/100 is not ~half"
1018                );
1019            }
1020        }
1021    }
1022
1023    /// A lone destination is kept even when it is the source, or the generator would recurse
1024    /// forever looking for somewhere else to send.
1025    #[test]
1026    fn a_sole_self_destination_survives_avoid_self() {
1027        let source = chain("source");
1028        let mut generator =
1029            NativeFungibleTransferGenerator::new(source, vec![], false, true).unwrap();
1030        assert_eq!(generator.next_destination(), source);
1031    }
1032}