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::{BTreeMap, 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, ChainClient},
20    data_types::ClientOutcome,
21    Environment,
22};
23use linera_execution::{system::SystemOperation, Operation};
24use linera_sdk::abis::fungible::{self, 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, ListenerCommand};
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/// Generates native fungible token transfer operations between chains.
52pub struct NativeFungibleTransferGenerator {
53    source_chain_id: ChainId,
54    destination_chains: Vec<ChainId>,
55    destination_index: usize,
56    rng: SmallRng,
57    single_destination_per_block: bool,
58}
59
60impl NativeFungibleTransferGenerator {
61    /// Creates a generator that sends native token transfers from the source chain.
62    pub fn new(
63        source_chain_id: ChainId,
64        mut destination_chains: Vec<ChainId>,
65        single_destination_per_block: bool,
66    ) -> Result<Self, BenchmarkError> {
67        // With a single chain, send to self.
68        if destination_chains.is_empty() {
69            destination_chains.push(source_chain_id);
70        }
71        let mut rng = SmallRng::from_rng(thread_rng())?;
72        destination_chains.shuffle(&mut rng);
73        Ok(Self {
74            source_chain_id,
75            destination_chains,
76            destination_index: 0,
77            rng,
78            single_destination_per_block,
79        })
80    }
81
82    fn next_destination(&mut self) -> ChainId {
83        if self.destination_index >= self.destination_chains.len() {
84            self.destination_chains.shuffle(&mut self.rng);
85            self.destination_index = 0;
86        }
87        let destination_chain_id = self.destination_chains[self.destination_index];
88        self.destination_index += 1;
89        // Skip self when there are other destinations available.
90        if destination_chain_id == self.source_chain_id && self.destination_chains.len() > 1 {
91            self.next_destination()
92        } else {
93            destination_chain_id
94        }
95    }
96}
97
98impl OperationGenerator for NativeFungibleTransferGenerator {
99    fn generate_operations(&mut self, _owner: AccountOwner, count: usize) -> Vec<Operation> {
100        let amount = Amount::from_attos(1);
101        if self.single_destination_per_block {
102            let recipient = self.next_destination();
103            (0..count)
104                .map(|_| {
105                    Operation::system(SystemOperation::Transfer {
106                        owner: AccountOwner::CHAIN,
107                        recipient: Account::chain(recipient),
108                        amount,
109                    })
110                })
111                .collect()
112        } else {
113            (0..count)
114                .map(|_| {
115                    let recipient = self.next_destination();
116                    Operation::system(SystemOperation::Transfer {
117                        owner: AccountOwner::CHAIN,
118                        recipient: Account::chain(recipient),
119                        amount,
120                    })
121                })
122                .collect()
123        }
124    }
125}
126
127/// Generates fungible token transfer operations between chains.
128pub struct FungibleTransferGenerator {
129    application_id: ApplicationId,
130    source_chain_id: ChainId,
131    destination_chains: Vec<ChainId>,
132    destination_index: usize,
133    rng: SmallRng,
134    single_destination_per_block: bool,
135}
136
137impl FungibleTransferGenerator {
138    /// Creates a generator that sends fungible token transfers from the source chain.
139    pub fn new(
140        application_id: ApplicationId,
141        source_chain_id: ChainId,
142        mut destination_chains: Vec<ChainId>,
143        single_destination_per_block: bool,
144    ) -> Result<Self, BenchmarkError> {
145        // With a single chain, send to self (matching old behavior).
146        if destination_chains.is_empty() {
147            destination_chains.push(source_chain_id);
148        }
149        let mut rng = SmallRng::from_rng(thread_rng())?;
150        destination_chains.shuffle(&mut rng);
151        Ok(Self {
152            application_id,
153            source_chain_id,
154            destination_chains,
155            destination_index: 0,
156            rng,
157            single_destination_per_block,
158        })
159    }
160
161    fn next_destination(&mut self) -> ChainId {
162        if self.destination_index >= self.destination_chains.len() {
163            self.destination_chains.shuffle(&mut self.rng);
164            self.destination_index = 0;
165        }
166        let destination_chain_id = self.destination_chains[self.destination_index];
167        self.destination_index += 1;
168        // Skip self when there are other destinations available.
169        if destination_chain_id == self.source_chain_id && self.destination_chains.len() > 1 {
170            self.next_destination()
171        } else {
172            destination_chain_id
173        }
174    }
175}
176
177impl OperationGenerator for FungibleTransferGenerator {
178    fn generate_operations(&mut self, owner: AccountOwner, count: usize) -> Vec<Operation> {
179        let amount = Amount::from_attos(1);
180        if self.single_destination_per_block {
181            let recipient = self.next_destination();
182            (0..count)
183                .map(|_| fungible_transfer(self.application_id, recipient, owner, owner, amount))
184                .collect()
185        } else {
186            (0..count)
187                .map(|_| {
188                    let recipient = self.next_destination();
189                    fungible_transfer(self.application_id, recipient, owner, owner, amount)
190                })
191                .collect()
192        }
193    }
194}
195
196const PROXY_LATENCY_P99_THRESHOLD: f64 = 400.0;
197const LATENCY_METRIC_PREFIX: &str = "linera_proxy_request_latency";
198
199/// An error that can occur while running a benchmark.
200#[derive(Debug, thiserror::Error)]
201#[allow(missing_docs)]
202pub enum BenchmarkError {
203    #[error("Failed to join task: {0}")]
204    JoinError(#[from] task::JoinError),
205    #[error("Chain client error: {0}")]
206    ChainClient(#[from] chain_client::Error),
207    #[error("Current histogram count is less than previous histogram count")]
208    HistogramCountMismatch,
209    #[error("Expected histogram value, got {0:?}")]
210    ExpectedHistogramValue(Value),
211    #[error("Expected untyped value, got {0:?}")]
212    ExpectedUntypedValue(Value),
213    #[error("Incomplete histogram data")]
214    IncompleteHistogramData,
215    #[error("Could not compute quantile")]
216    CouldNotComputeQuantile,
217    #[error("Bucket boundaries do not match: {0} vs {1}")]
218    BucketBoundariesDoNotMatch(f64, f64),
219    #[error("Reqwest error: {0}")]
220    Reqwest(#[from] reqwest::Error),
221    #[error("Io error: {0}")]
222    IoError(#[from] std::io::Error),
223    #[error("Previous histogram snapshot does not exist: {0}")]
224    PreviousHistogramSnapshotDoesNotExist(String),
225    #[error("No data available yet to calculate p99")]
226    NoDataYetForP99Calculation,
227    #[error("Unexpected empty bucket")]
228    UnexpectedEmptyBucket,
229    #[error("Failed to send unit message: {0}")]
230    TokioSendUnitError(#[from] mpsc::error::SendError<()>),
231    #[error("Config file not found: {0}")]
232    ConfigFileNotFound(std::path::PathBuf),
233    #[error("Failed to load config file: {0}")]
234    ConfigLoadError(#[from] anyhow::Error),
235    #[error("Could not find enough chains in wallet alone: needed {0}, but only found {1}")]
236    NotEnoughChainsInWallet(usize, usize),
237    #[error("Random number generator error: {0}")]
238    RandError(#[from] rand::Error),
239}
240
241#[derive(Debug)]
242struct HistogramSnapshot {
243    buckets: Vec<HistogramCount>,
244    count: f64,
245    sum: f64,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249#[serde(rename_all = "kebab-case")]
250/// Configuration listing the chains to use for a benchmark.
251pub struct BenchmarkConfig {
252    /// The chains to use for the benchmark.
253    pub chain_ids: Vec<ChainId>,
254}
255
256impl BenchmarkConfig {
257    /// Loads the benchmark configuration from a YAML file.
258    pub fn load_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
259        let content = std::fs::read_to_string(path)?;
260        let config = serde_yaml::from_str(&content)?;
261        Ok(config)
262    }
263
264    /// Saves the benchmark configuration to a YAML file.
265    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
266        let content = serde_yaml::to_string(self)?;
267        std::fs::write(path, content)?;
268        Ok(())
269    }
270}
271
272/// Driver for running benchmarks against a network.
273pub struct Benchmark<Env: Environment> {
274    _phantom: std::marker::PhantomData<Env>,
275}
276
277impl<Env: Environment> Benchmark<Env> {
278    /// Runs a benchmark with the given chain clients and operation generators.
279    ///
280    /// Each chain client is paired with an operation generator (one per chain).
281    /// The generators produce the operations to include in each block.
282    #[expect(clippy::too_many_arguments)]
283    pub async fn run_benchmark<C: ClientContext<Environment = Env> + 'static>(
284        bps: usize,
285        chain_clients: Vec<ChainClient<Env>>,
286        generators: Vec<Box<dyn OperationGenerator>>,
287        transactions_per_block: usize,
288        health_check_endpoints: Option<String>,
289        runtime_in_seconds: Option<u64>,
290        delay_between_chains_ms: Option<u64>,
291        chain_listener: ChainListener<C>,
292        command_sender: mpsc::UnboundedSender<ListenerCommand>,
293        shutdown_notifier: &CancellationToken,
294    ) -> Result<(), BenchmarkError> {
295        assert_eq!(
296            chain_clients.len(),
297            generators.len(),
298            "Must have one generator per chain client"
299        );
300        let num_chains = chain_clients.len();
301        let bps_counts = (0..num_chains)
302            .map(|_| Arc::new(AtomicUsize::new(0)))
303            .collect::<Vec<_>>();
304        let notifier = Arc::new(Notify::new());
305        let barrier = Arc::new(Barrier::new(num_chains + 1));
306
307        let chain_listener_result = chain_listener.run().await;
308
309        let chain_listener_handle =
310            tokio::spawn(async move { chain_listener_result?.await }.in_current_span());
311
312        // Register benchmark chains with the ChainListener so it sets up
313        // validator notification listeners for incoming cross-chain messages.
314        let chain_map: BTreeMap<_, _> = chain_clients
315            .iter()
316            .map(|c| (c.chain_id(), c.preferred_owner()))
317            .collect();
318        if let Err(e) = command_sender.send(ListenerCommand::Listen(chain_map)) {
319            warn!("Failed to register benchmark chains with listener: {e}");
320        }
321
322        let bps_control_task = Self::bps_control_task(
323            &barrier,
324            shutdown_notifier,
325            &bps_counts,
326            &notifier,
327            transactions_per_block,
328            bps,
329        );
330
331        let (runtime_control_task, runtime_control_sender) =
332            Self::runtime_control_task(shutdown_notifier, runtime_in_seconds, num_chains);
333
334        let bps_initial_share = bps / num_chains;
335        let mut bps_remainder = bps % num_chains;
336        let mut join_set = task::JoinSet::<Result<(), BenchmarkError>>::new();
337        for (chain_idx, (chain_client, generator)) in
338            chain_clients.into_iter().zip(generators).enumerate()
339        {
340            let shutdown_notifier_clone = shutdown_notifier.clone();
341            let barrier_clone = barrier.clone();
342            let bps_count_clone = bps_counts[chain_idx].clone();
343            let notifier_clone = notifier.clone();
344            let runtime_control_sender_clone = runtime_control_sender.clone();
345            let bps_share = if bps_remainder > 0 {
346                bps_remainder -= 1;
347                bps_initial_share + 1
348            } else {
349                bps_initial_share
350            };
351            let chain_id = chain_client.chain_id();
352            join_set.spawn(
353                async move {
354                    Box::pin(Self::run_benchmark_internal(
355                        chain_idx,
356                        chain_id,
357                        bps_share,
358                        chain_client,
359                        generator,
360                        transactions_per_block,
361                        shutdown_notifier_clone,
362                        bps_count_clone,
363                        barrier_clone,
364                        notifier_clone,
365                        runtime_control_sender_clone,
366                        delay_between_chains_ms,
367                    ))
368                    .await?;
369
370                    Ok(())
371                }
372                .instrument(tracing::info_span!("chain_id", chain_id = ?chain_id)),
373            );
374        }
375
376        let metrics_watcher =
377            Self::metrics_watcher(health_check_endpoints, shutdown_notifier).await?;
378
379        // Wait for tasks and fail immediately if any task returns an error or panics
380        while let Some(result) = join_set.join_next().await {
381            let inner_result = result?;
382            if let Err(e) = inner_result {
383                error!("Benchmark task failed: {}", e);
384                shutdown_notifier.cancel();
385                join_set.abort_all();
386                return Err(e);
387            }
388        }
389        info!("All benchmark tasks completed successfully");
390
391        bps_control_task.await?;
392        if let Some(metrics_watcher) = metrics_watcher {
393            metrics_watcher.await??;
394        }
395        if let Some(runtime_control_task) = runtime_control_task {
396            runtime_control_task.await?;
397        }
398
399        if let Err(e) = chain_listener_handle.await? {
400            tracing::error!("chain listener error: {e}");
401        }
402
403        Ok(())
404    }
405
406    // The bps control task will control the BPS from the threads.
407    fn bps_control_task(
408        barrier: &Arc<Barrier>,
409        shutdown_notifier: &CancellationToken,
410        bps_counts: &[Arc<AtomicUsize>],
411        notifier: &Arc<Notify>,
412        transactions_per_block: usize,
413        bps: usize,
414    ) -> task::JoinHandle<()> {
415        let shutdown_notifier = shutdown_notifier.clone();
416        let bps_counts = bps_counts.to_vec();
417        let notifier = notifier.clone();
418        let barrier = barrier.clone();
419        task::spawn(
420            async move {
421                barrier.wait().await;
422                let mut one_second_interval = time::interval(time::Duration::from_secs(1));
423                loop {
424                    if shutdown_notifier.is_cancelled() {
425                        info!("Shutdown signal received in bps control task");
426                        break;
427                    }
428                    one_second_interval.tick().await;
429                    let current_bps_count: usize = bps_counts
430                        .iter()
431                        .map(|count| count.swap(0, Ordering::Relaxed))
432                        .sum();
433                    notifier.notify_waiters();
434                    let formatted_current_bps = current_bps_count.to_formatted_string(&Locale::en);
435                    let formatted_current_tps = (current_bps_count * transactions_per_block)
436                        .to_formatted_string(&Locale::en);
437                    let formatted_tps_goal =
438                        (bps * transactions_per_block).to_formatted_string(&Locale::en);
439                    let formatted_bps_goal = bps.to_formatted_string(&Locale::en);
440                    if current_bps_count >= bps {
441                        info!(
442                            "Achieved {} BPS/{} TPS",
443                            formatted_current_bps, formatted_current_tps
444                        );
445                    } else {
446                        warn!(
447                            "Failed to achieve {} BPS/{} TPS, only achieved {} BPS/{} TPS",
448                            formatted_bps_goal,
449                            formatted_tps_goal,
450                            formatted_current_bps,
451                            formatted_current_tps,
452                        );
453                    }
454                }
455
456                info!("Exiting bps control task");
457            }
458            .instrument(tracing::info_span!("bps_control")),
459        )
460    }
461
462    async fn metrics_watcher(
463        health_check_endpoints: Option<String>,
464        shutdown_notifier: &CancellationToken,
465    ) -> Result<Option<task::JoinHandle<Result<(), BenchmarkError>>>, BenchmarkError> {
466        if let Some(health_check_endpoints) = health_check_endpoints {
467            let metrics_addresses = health_check_endpoints
468                .split(',')
469                .map(|address| format!("http://{}/metrics", address.trim()))
470                .collect::<Vec<_>>();
471
472            let mut previous_histogram_snapshots: HashMap<String, HistogramSnapshot> =
473                HashMap::new();
474            let scrapes = Self::get_scrapes(&metrics_addresses).await?;
475            for (metrics_address, scrape) in scrapes {
476                previous_histogram_snapshots.insert(
477                    metrics_address,
478                    Self::parse_histogram(&scrape, LATENCY_METRIC_PREFIX)?,
479                );
480            }
481
482            let shutdown_notifier = shutdown_notifier.clone();
483            let metrics_watcher: task::JoinHandle<Result<(), BenchmarkError>> = tokio::spawn(
484                async move {
485                    let mut health_interval = time::interval(time::Duration::from_secs(5));
486                    let mut shutdown_interval = time::interval(time::Duration::from_secs(1));
487                    loop {
488                        tokio::select! {
489                            biased;
490                            _ = health_interval.tick() => {
491                                let result = Self::validators_healthy(&metrics_addresses, &mut previous_histogram_snapshots).await;
492                                if let Err(ref err) = result {
493                                    info!("Shutting down benchmark due to error: {}", err);
494                                    shutdown_notifier.cancel();
495                                    break;
496                                } else if !result? {
497                                    info!("Shutting down benchmark due to unhealthy validators");
498                                    shutdown_notifier.cancel();
499                                    break;
500                                }
501                            }
502                            _ = shutdown_interval.tick() => {
503                                if shutdown_notifier.is_cancelled() {
504                                    info!("Shutdown signal received, stopping metrics watcher");
505                                    break;
506                                }
507                            }
508                        }
509                    }
510
511                    Ok(())
512                }
513                .instrument(tracing::info_span!("metrics_watcher")),
514            );
515
516            Ok(Some(metrics_watcher))
517        } else {
518            Ok(None)
519        }
520    }
521
522    fn runtime_control_task(
523        shutdown_notifier: &CancellationToken,
524        runtime_in_seconds: Option<u64>,
525        num_chain_groups: usize,
526    ) -> (Option<task::JoinHandle<()>>, Option<mpsc::Sender<()>>) {
527        if let Some(runtime_in_seconds) = runtime_in_seconds {
528            let (runtime_control_sender, mut runtime_control_receiver) =
529                mpsc::channel(num_chain_groups);
530            let shutdown_notifier = shutdown_notifier.clone();
531            let runtime_control_task = task::spawn(
532                async move {
533                    let mut chains_started = 0;
534                    while runtime_control_receiver.recv().await.is_some() {
535                        chains_started += 1;
536                        if chains_started == num_chain_groups {
537                            break;
538                        }
539                    }
540                    time::sleep(time::Duration::from_secs(runtime_in_seconds)).await;
541                    shutdown_notifier.cancel();
542                }
543                .instrument(tracing::info_span!("runtime_control")),
544            );
545            (Some(runtime_control_task), Some(runtime_control_sender))
546        } else {
547            (None, None)
548        }
549    }
550
551    async fn validators_healthy(
552        metrics_addresses: &[String],
553        previous_histogram_snapshots: &mut HashMap<String, HistogramSnapshot>,
554    ) -> Result<bool, BenchmarkError> {
555        let scrapes = Self::get_scrapes(metrics_addresses).await?;
556        for (metrics_address, scrape) in scrapes {
557            let histogram = Self::parse_histogram(&scrape, LATENCY_METRIC_PREFIX)?;
558            let diff = Self::diff_histograms(
559                previous_histogram_snapshots.get(&metrics_address).ok_or(
560                    BenchmarkError::PreviousHistogramSnapshotDoesNotExist(metrics_address.clone()),
561                )?,
562                &histogram,
563            )?;
564            let p99 = match Self::compute_quantile(&diff.buckets, diff.count, 0.99) {
565                Ok(p99) => p99,
566                Err(BenchmarkError::NoDataYetForP99Calculation) => {
567                    info!(
568                        "No data available yet to calculate p99 for {}",
569                        metrics_address
570                    );
571                    continue;
572                }
573                Err(e) => {
574                    error!("Error computing p99 for {}: {}", metrics_address, e);
575                    return Err(e);
576                }
577            };
578
579            let last_bucket_boundary = diff.buckets[diff.buckets.len() - 2].less_than;
580            if p99 == f64::INFINITY {
581                info!(
582                    "{} -> Estimated p99 for {} is higher than the last bucket boundary of {:?} ms",
583                    metrics_address, LATENCY_METRIC_PREFIX, last_bucket_boundary
584                );
585            } else {
586                info!(
587                    "{} -> Estimated p99 for {}: {:.2} ms",
588                    metrics_address, LATENCY_METRIC_PREFIX, p99
589                );
590            }
591            if p99 > PROXY_LATENCY_P99_THRESHOLD {
592                if p99 == f64::INFINITY {
593                    error!(
594                        "Proxy of validator {} unhealthy! Latency p99 is too high, it is higher than \
595                        the last bucket boundary of {:.2} ms",
596                        metrics_address, last_bucket_boundary
597                    );
598                } else {
599                    error!(
600                        "Proxy of validator {} unhealthy! Latency p99 is too high: {:.2} ms",
601                        metrics_address, p99
602                    );
603                }
604                return Ok(false);
605            }
606            previous_histogram_snapshots.insert(metrics_address.clone(), histogram);
607        }
608
609        Ok(true)
610    }
611
612    fn diff_histograms(
613        previous: &HistogramSnapshot,
614        current: &HistogramSnapshot,
615    ) -> Result<HistogramSnapshot, BenchmarkError> {
616        if current.count < previous.count {
617            return Err(BenchmarkError::HistogramCountMismatch);
618        }
619        let total_diff = current.count - previous.count;
620        let mut buckets_diff: Vec<HistogramCount> = Vec::new();
621        for (before, after) in previous.buckets.iter().zip(current.buckets.iter()) {
622            let bound_before = before.less_than;
623            let bound_after = after.less_than;
624            let cumulative_before = before.count;
625            let cumulative_after = after.count;
626            if (bound_before - bound_after).abs() > f64::EPSILON {
627                return Err(BenchmarkError::BucketBoundariesDoNotMatch(
628                    bound_before,
629                    bound_after,
630                ));
631            }
632            let diff = (cumulative_after - cumulative_before).max(0.0);
633            buckets_diff.push(HistogramCount {
634                less_than: bound_after,
635                count: diff,
636            });
637        }
638        Ok(HistogramSnapshot {
639            buckets: buckets_diff,
640            count: total_diff,
641            sum: current.sum - previous.sum,
642        })
643    }
644
645    async fn get_scrapes(
646        metrics_addresses: &[String],
647    ) -> Result<Vec<(String, Scrape)>, BenchmarkError> {
648        let mut scrapes = Vec::new();
649        for metrics_address in metrics_addresses {
650            let response = reqwest::get(metrics_address)
651                .await
652                .map_err(BenchmarkError::Reqwest)?;
653            let metrics = response.text().await.map_err(BenchmarkError::Reqwest)?;
654            let scrape = Scrape::parse(metrics.lines().map(|line| Ok(line.to_owned())))
655                .map_err(BenchmarkError::IoError)?;
656            scrapes.push((metrics_address.clone(), scrape));
657        }
658        Ok(scrapes)
659    }
660
661    fn parse_histogram(
662        scrape: &Scrape,
663        metric_prefix: &str,
664    ) -> Result<HistogramSnapshot, BenchmarkError> {
665        let mut buckets: Vec<HistogramCount> = Vec::new();
666        let mut total_count: Option<f64> = None;
667        let mut total_sum: Option<f64> = None;
668
669        // Iterate over each metric in the scrape.
670        for sample in &scrape.samples {
671            if sample.metric == metric_prefix {
672                if let Value::Histogram(histogram) = &sample.value {
673                    buckets.extend(histogram.iter().cloned());
674                } else {
675                    return Err(BenchmarkError::ExpectedHistogramValue(sample.value.clone()));
676                }
677            } else if sample.metric == format!("{metric_prefix}_count") {
678                if let Value::Untyped(count) = sample.value {
679                    total_count = Some(count);
680                } else {
681                    return Err(BenchmarkError::ExpectedUntypedValue(sample.value.clone()));
682                }
683            } else if sample.metric == format!("{metric_prefix}_sum") {
684                if let Value::Untyped(sum) = sample.value {
685                    total_sum = Some(sum);
686                } else {
687                    return Err(BenchmarkError::ExpectedUntypedValue(sample.value.clone()));
688                }
689            }
690        }
691
692        match (total_count, total_sum) {
693            (Some(count), Some(sum)) if !buckets.is_empty() => {
694                buckets.sort_by(|a, b| {
695                    a.less_than
696                        .partial_cmp(&b.less_than)
697                        .expect("Comparison should not fail")
698                });
699                Ok(HistogramSnapshot {
700                    buckets,
701                    count,
702                    sum,
703                })
704            }
705            _ => Err(BenchmarkError::IncompleteHistogramData),
706        }
707    }
708
709    fn compute_quantile(
710        buckets: &[HistogramCount],
711        total_count: f64,
712        quantile: f64,
713    ) -> Result<f64, BenchmarkError> {
714        if total_count == 0.0 {
715            // Had no samples in the last 5s.
716            return Err(BenchmarkError::NoDataYetForP99Calculation);
717        }
718        // Compute the target cumulative count.
719        let target = (quantile * total_count).ceil();
720        let mut prev_cumulative = 0.0;
721        let mut prev_bound = 0.0;
722        for bucket in buckets {
723            if bucket.count >= target {
724                let bucket_count = bucket.count - prev_cumulative;
725                if bucket_count == 0.0 {
726                    // Bucket that is supposed to contain the target quantile is empty, unexpectedly.
727                    return Err(BenchmarkError::UnexpectedEmptyBucket);
728                }
729                let fraction = (target - prev_cumulative) / bucket_count;
730                return Ok(prev_bound + (bucket.less_than - prev_bound) * fraction);
731            }
732            prev_cumulative = bucket.count;
733            prev_bound = bucket.less_than;
734        }
735        Err(BenchmarkError::CouldNotComputeQuantile)
736    }
737
738    #[expect(clippy::too_many_arguments)]
739    async fn run_benchmark_internal(
740        chain_idx: usize,
741        chain_id: ChainId,
742        bps: usize,
743        chain_client: ChainClient<Env>,
744        mut generator: Box<dyn OperationGenerator>,
745        transactions_per_block: usize,
746        shutdown_notifier: CancellationToken,
747        bps_count: Arc<AtomicUsize>,
748        barrier: Arc<Barrier>,
749        notifier: Arc<Notify>,
750        runtime_control_sender: Option<mpsc::Sender<()>>,
751        delay_between_chains_ms: Option<u64>,
752    ) -> Result<(), BenchmarkError> {
753        barrier.wait().await;
754        if let Some(delay_between_chains_ms) = delay_between_chains_ms {
755            time::sleep(time::Duration::from_millis(
756                (chain_idx as u64) * delay_between_chains_ms,
757            ))
758            .await;
759        }
760        info!("Starting benchmark for chain {:?}", chain_id);
761
762        if let Some(runtime_control_sender) = runtime_control_sender {
763            runtime_control_sender.send(()).await?;
764        }
765
766        let owner = chain_client
767            .identity()
768            .await
769            .map_err(BenchmarkError::ChainClient)?;
770
771        loop {
772            tokio::select! {
773                biased;
774
775                _ = shutdown_notifier.cancelled() => {
776                    info!("Shutdown signal received, stopping benchmark");
777                    break;
778                }
779                result = chain_client.execute_operations(
780                    generator.generate_operations(owner, transactions_per_block),
781                    vec![]
782                ) => {
783                    result
784                        .map_err(BenchmarkError::ChainClient)?
785                        .expect("should execute block with operations");
786
787                    let current_bps_count = bps_count.fetch_add(1, Ordering::Relaxed) + 1;
788                    if current_bps_count >= bps {
789                        notifier.notified().await;
790                    }
791                }
792            }
793        }
794
795        info!("Exiting task...");
796        Ok(())
797    }
798
799    /// Closes the chain that was created for the benchmark.
800    pub async fn close_benchmark_chain(
801        chain_client: &ChainClient<Env>,
802    ) -> Result<(), BenchmarkError> {
803        let start = Instant::now();
804        loop {
805            let result = chain_client
806                .execute_operation(Operation::system(SystemOperation::CloseChain))
807                .await?;
808            match result {
809                ClientOutcome::Committed(_) => break,
810                ClientOutcome::Conflict(certificate) => {
811                    info!(
812                        "Conflict while closing chain {:?}: {}. Retrying...",
813                        chain_client.chain_id(),
814                        certificate.hash()
815                    );
816                }
817                ClientOutcome::WaitForTimeout(timeout) => {
818                    info!(
819                        "Waiting for timeout while closing chain {:?}: {}",
820                        chain_client.chain_id(),
821                        timeout
822                    );
823                    linera_base::time::timer::sleep(
824                        timeout.timestamp.duration_since(Timestamp::now()),
825                    )
826                    .await;
827                }
828            }
829        }
830
831        debug!(
832            "Closed chain {:?} in {} ms",
833            chain_client.chain_id(),
834            start.elapsed().as_millis()
835        );
836
837        Ok(())
838    }
839
840    /// Returns the chains to benchmark, from the config file if given, otherwise from the wallet.
841    pub fn get_all_chains(
842        chains_config_path: Option<&Path>,
843        benchmark_chains: &[(ChainId, AccountOwner)],
844    ) -> Result<Vec<ChainId>, BenchmarkError> {
845        let all_chains = if let Some(config_path) = chains_config_path {
846            if !config_path.exists() {
847                return Err(BenchmarkError::ConfigFileNotFound(
848                    config_path.to_path_buf(),
849                ));
850            }
851            let config = BenchmarkConfig::load_from_file(config_path)
852                .map_err(BenchmarkError::ConfigLoadError)?;
853            config.chain_ids
854        } else {
855            benchmark_chains.iter().map(|(id, _)| *id).collect()
856        };
857
858        Ok(all_chains)
859    }
860}
861
862/// Creates a fungible token transfer operation.
863pub fn fungible_transfer(
864    application_id: ApplicationId,
865    chain_id: ChainId,
866    sender: AccountOwner,
867    receiver: AccountOwner,
868    amount: Amount,
869) -> Operation {
870    let target_account = fungible::Account {
871        chain_id,
872        owner: receiver,
873    };
874    let bytes = bcs::to_bytes(&FungibleOperation::Transfer {
875        owner: sender,
876        amount,
877        target_account,
878    })
879    .expect("should serialize fungible token operation");
880    Operation::User {
881        application_id,
882        bytes,
883    }
884}