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/// 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    #[error("Chain listener startup error")]
240    ChainListenerStartupError,
241}
242
243#[derive(Debug)]
244struct HistogramSnapshot {
245    buckets: Vec<HistogramCount>,
246    count: f64,
247    sum: f64,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(rename_all = "kebab-case")]
252/// Configuration listing the chains to use for a benchmark.
253pub struct BenchmarkConfig {
254    /// The chains to use for the benchmark.
255    pub chain_ids: Vec<ChainId>,
256}
257
258impl BenchmarkConfig {
259    /// Loads the benchmark configuration from a YAML file.
260    pub fn load_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
261        let content = std::fs::read_to_string(path)?;
262        let config = serde_yaml::from_str(&content)?;
263        Ok(config)
264    }
265
266    /// Saves the benchmark configuration to a YAML file.
267    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
268        let content = serde_yaml::to_string(self)?;
269        std::fs::write(path, content)?;
270        Ok(())
271    }
272}
273
274/// Driver for running benchmarks against a network.
275pub struct Benchmark<Env: Environment> {
276    _phantom: std::marker::PhantomData<Env>,
277}
278
279impl<Env: Environment> Benchmark<Env> {
280    /// Runs a benchmark with the given chain clients and operation generators.
281    ///
282    /// Each chain client is paired with an operation generator (one per chain).
283    /// The generators produce the operations to include in each block.
284    #[expect(clippy::too_many_arguments)]
285    pub async fn run_benchmark<C: ClientContext<Environment = Env> + 'static>(
286        bps: usize,
287        chain_clients: Vec<ChainClient<Env>>,
288        generators: Vec<Box<dyn OperationGenerator>>,
289        transactions_per_block: usize,
290        health_check_endpoints: Option<String>,
291        runtime_in_seconds: Option<u64>,
292        delay_between_chains_ms: Option<u64>,
293        chain_listener: ChainListener<C>,
294        shutdown_notifier: &CancellationToken,
295    ) -> Result<(), BenchmarkError> {
296        assert_eq!(
297            chain_clients.len(),
298            generators.len(),
299            "Must have one generator per chain client"
300        );
301        let num_chains = chain_clients.len();
302        let bps_counts = (0..num_chains)
303            .map(|_| Arc::new(AtomicUsize::new(0)))
304            .collect::<Vec<_>>();
305        let notifier = Arc::new(Notify::new());
306        let barrier = Arc::new(Barrier::new(num_chains + 1));
307
308        let chain_listener_future = chain_listener
309            .run()
310            .await
311            .map_err(|_| BenchmarkError::ChainListenerStartupError)?;
312        let chain_listener_handle = tokio::spawn(chain_listener_future.in_current_span());
313
314        let bps_control_task = Self::bps_control_task(
315            &barrier,
316            shutdown_notifier,
317            &bps_counts,
318            &notifier,
319            transactions_per_block,
320            bps,
321        );
322
323        let (runtime_control_task, runtime_control_sender) =
324            Self::runtime_control_task(shutdown_notifier, runtime_in_seconds, num_chains);
325
326        let bps_initial_share = bps / num_chains;
327        let mut bps_remainder = bps % num_chains;
328        let mut join_set = task::JoinSet::<Result<(), BenchmarkError>>::new();
329        for (chain_idx, (chain_client, generator)) in
330            chain_clients.into_iter().zip(generators).enumerate()
331        {
332            let chain_id = chain_client.chain_id();
333            let shutdown_notifier_clone = shutdown_notifier.clone();
334            let barrier_clone = barrier.clone();
335            let bps_count_clone = bps_counts[chain_idx].clone();
336            let notifier_clone = notifier.clone();
337            let runtime_control_sender_clone = runtime_control_sender.clone();
338            let bps_share = if bps_remainder > 0 {
339                bps_remainder -= 1;
340                bps_initial_share + 1
341            } else {
342                bps_initial_share
343            };
344            join_set.spawn(
345                async move {
346                    Box::pin(Self::run_benchmark_internal(
347                        chain_idx,
348                        chain_id,
349                        bps_share,
350                        chain_client,
351                        generator,
352                        transactions_per_block,
353                        shutdown_notifier_clone,
354                        bps_count_clone,
355                        barrier_clone,
356                        notifier_clone,
357                        runtime_control_sender_clone,
358                        delay_between_chains_ms,
359                    ))
360                    .await?;
361
362                    Ok(())
363                }
364                .instrument(tracing::info_span!("chain_id", chain_id = ?chain_id)),
365            );
366        }
367
368        let metrics_watcher =
369            Self::metrics_watcher(health_check_endpoints, shutdown_notifier).await?;
370
371        // Wait for tasks and fail immediately if any task returns an error or panics
372        while let Some(result) = join_set.join_next().await {
373            let inner_result = result?;
374            if let Err(e) = inner_result {
375                error!("Benchmark task failed: {}", e);
376                shutdown_notifier.cancel();
377                join_set.abort_all();
378                return Err(e);
379            }
380        }
381        info!("All benchmark tasks completed successfully");
382
383        bps_control_task.await?;
384        if let Some(metrics_watcher) = metrics_watcher {
385            metrics_watcher.await??;
386        }
387        if let Some(runtime_control_task) = runtime_control_task {
388            runtime_control_task.await?;
389        }
390
391        if let Err(e) = chain_listener_handle.await? {
392            tracing::error!("chain listener error: {e}");
393        }
394
395        Ok(())
396    }
397
398    // The bps control task will control the BPS from the threads.
399    fn bps_control_task(
400        barrier: &Arc<Barrier>,
401        shutdown_notifier: &CancellationToken,
402        bps_counts: &[Arc<AtomicUsize>],
403        notifier: &Arc<Notify>,
404        transactions_per_block: usize,
405        bps: usize,
406    ) -> task::JoinHandle<()> {
407        let shutdown_notifier = shutdown_notifier.clone();
408        let bps_counts = bps_counts.to_vec();
409        let notifier = notifier.clone();
410        let barrier = barrier.clone();
411        task::spawn(
412            async move {
413                barrier.wait().await;
414                let mut one_second_interval = time::interval(time::Duration::from_secs(1));
415                loop {
416                    if shutdown_notifier.is_cancelled() {
417                        info!("Shutdown signal received in bps control task");
418                        break;
419                    }
420                    one_second_interval.tick().await;
421                    let current_bps_count: usize = bps_counts
422                        .iter()
423                        .map(|count| count.swap(0, Ordering::Relaxed))
424                        .sum();
425                    notifier.notify_waiters();
426                    let formatted_current_bps = current_bps_count.to_formatted_string(&Locale::en);
427                    let formatted_current_tps = (current_bps_count * transactions_per_block)
428                        .to_formatted_string(&Locale::en);
429                    let formatted_tps_goal =
430                        (bps * transactions_per_block).to_formatted_string(&Locale::en);
431                    let formatted_bps_goal = bps.to_formatted_string(&Locale::en);
432                    if current_bps_count >= bps {
433                        info!(
434                            "Achieved {} BPS/{} TPS",
435                            formatted_current_bps, formatted_current_tps
436                        );
437                    } else {
438                        warn!(
439                            "Failed to achieve {} BPS/{} TPS, only achieved {} BPS/{} TPS",
440                            formatted_bps_goal,
441                            formatted_tps_goal,
442                            formatted_current_bps,
443                            formatted_current_tps,
444                        );
445                    }
446                }
447
448                info!("Exiting bps control task");
449            }
450            .instrument(tracing::info_span!("bps_control")),
451        )
452    }
453
454    async fn metrics_watcher(
455        health_check_endpoints: Option<String>,
456        shutdown_notifier: &CancellationToken,
457    ) -> Result<Option<task::JoinHandle<Result<(), BenchmarkError>>>, BenchmarkError> {
458        if let Some(health_check_endpoints) = health_check_endpoints {
459            let metrics_addresses = health_check_endpoints
460                .split(',')
461                .map(|address| format!("http://{}/metrics", address.trim()))
462                .collect::<Vec<_>>();
463
464            let mut previous_histogram_snapshots: HashMap<String, HistogramSnapshot> =
465                HashMap::new();
466            let scrapes = Self::get_scrapes(&metrics_addresses).await?;
467            for (metrics_address, scrape) in scrapes {
468                previous_histogram_snapshots.insert(
469                    metrics_address,
470                    Self::parse_histogram(&scrape, LATENCY_METRIC_PREFIX)?,
471                );
472            }
473
474            let shutdown_notifier = shutdown_notifier.clone();
475            let metrics_watcher: task::JoinHandle<Result<(), BenchmarkError>> = tokio::spawn(
476                async move {
477                    let mut health_interval = time::interval(time::Duration::from_secs(5));
478                    let mut shutdown_interval = time::interval(time::Duration::from_secs(1));
479                    loop {
480                        tokio::select! {
481                            biased;
482                            _ = health_interval.tick() => {
483                                let result = Self::validators_healthy(&metrics_addresses, &mut previous_histogram_snapshots).await;
484                                if let Err(ref err) = result {
485                                    info!("Shutting down benchmark due to error: {}", err);
486                                    shutdown_notifier.cancel();
487                                    break;
488                                } else if !result? {
489                                    info!("Shutting down benchmark due to unhealthy validators");
490                                    shutdown_notifier.cancel();
491                                    break;
492                                }
493                            }
494                            _ = shutdown_interval.tick() => {
495                                if shutdown_notifier.is_cancelled() {
496                                    info!("Shutdown signal received, stopping metrics watcher");
497                                    break;
498                                }
499                            }
500                        }
501                    }
502
503                    Ok(())
504                }
505                .instrument(tracing::info_span!("metrics_watcher")),
506            );
507
508            Ok(Some(metrics_watcher))
509        } else {
510            Ok(None)
511        }
512    }
513
514    fn runtime_control_task(
515        shutdown_notifier: &CancellationToken,
516        runtime_in_seconds: Option<u64>,
517        num_chain_groups: usize,
518    ) -> (Option<task::JoinHandle<()>>, Option<mpsc::Sender<()>>) {
519        if let Some(runtime_in_seconds) = runtime_in_seconds {
520            let (runtime_control_sender, mut runtime_control_receiver) =
521                mpsc::channel(num_chain_groups);
522            let shutdown_notifier = shutdown_notifier.clone();
523            let runtime_control_task = task::spawn(
524                async move {
525                    let mut chains_started = 0;
526                    while runtime_control_receiver.recv().await.is_some() {
527                        chains_started += 1;
528                        if chains_started == num_chain_groups {
529                            break;
530                        }
531                    }
532                    time::sleep(time::Duration::from_secs(runtime_in_seconds)).await;
533                    shutdown_notifier.cancel();
534                }
535                .instrument(tracing::info_span!("runtime_control")),
536            );
537            (Some(runtime_control_task), Some(runtime_control_sender))
538        } else {
539            (None, None)
540        }
541    }
542
543    async fn validators_healthy(
544        metrics_addresses: &[String],
545        previous_histogram_snapshots: &mut HashMap<String, HistogramSnapshot>,
546    ) -> Result<bool, BenchmarkError> {
547        let scrapes = Self::get_scrapes(metrics_addresses).await?;
548        for (metrics_address, scrape) in scrapes {
549            let histogram = Self::parse_histogram(&scrape, LATENCY_METRIC_PREFIX)?;
550            let diff = Self::diff_histograms(
551                previous_histogram_snapshots.get(&metrics_address).ok_or(
552                    BenchmarkError::PreviousHistogramSnapshotDoesNotExist(metrics_address.clone()),
553                )?,
554                &histogram,
555            )?;
556            let p99 = match Self::compute_quantile(&diff.buckets, diff.count, 0.99) {
557                Ok(p99) => p99,
558                Err(BenchmarkError::NoDataYetForP99Calculation) => {
559                    info!(
560                        "No data available yet to calculate p99 for {}",
561                        metrics_address
562                    );
563                    continue;
564                }
565                Err(e) => {
566                    error!("Error computing p99 for {}: {}", metrics_address, e);
567                    return Err(e);
568                }
569            };
570
571            let last_bucket_boundary = diff.buckets[diff.buckets.len() - 2].less_than;
572            if p99 == f64::INFINITY {
573                info!(
574                    "{} -> Estimated p99 for {} is higher than the last bucket boundary of {:?} ms",
575                    metrics_address, LATENCY_METRIC_PREFIX, last_bucket_boundary
576                );
577            } else {
578                info!(
579                    "{} -> Estimated p99 for {}: {:.2} ms",
580                    metrics_address, LATENCY_METRIC_PREFIX, p99
581                );
582            }
583            if p99 > PROXY_LATENCY_P99_THRESHOLD {
584                if p99 == f64::INFINITY {
585                    error!(
586                        "Proxy of validator {} unhealthy! Latency p99 is too high, it is higher than \
587                        the last bucket boundary of {:.2} ms",
588                        metrics_address, last_bucket_boundary
589                    );
590                } else {
591                    error!(
592                        "Proxy of validator {} unhealthy! Latency p99 is too high: {:.2} ms",
593                        metrics_address, p99
594                    );
595                }
596                return Ok(false);
597            }
598            previous_histogram_snapshots.insert(metrics_address.clone(), histogram);
599        }
600
601        Ok(true)
602    }
603
604    fn diff_histograms(
605        previous: &HistogramSnapshot,
606        current: &HistogramSnapshot,
607    ) -> Result<HistogramSnapshot, BenchmarkError> {
608        if current.count < previous.count {
609            return Err(BenchmarkError::HistogramCountMismatch);
610        }
611        let total_diff = current.count - previous.count;
612        let mut buckets_diff: Vec<HistogramCount> = Vec::new();
613        for (before, after) in previous.buckets.iter().zip(current.buckets.iter()) {
614            let bound_before = before.less_than;
615            let bound_after = after.less_than;
616            let cumulative_before = before.count;
617            let cumulative_after = after.count;
618            if (bound_before - bound_after).abs() > f64::EPSILON {
619                return Err(BenchmarkError::BucketBoundariesDoNotMatch(
620                    bound_before,
621                    bound_after,
622                ));
623            }
624            let diff = (cumulative_after - cumulative_before).max(0.0);
625            buckets_diff.push(HistogramCount {
626                less_than: bound_after,
627                count: diff,
628            });
629        }
630        Ok(HistogramSnapshot {
631            buckets: buckets_diff,
632            count: total_diff,
633            sum: current.sum - previous.sum,
634        })
635    }
636
637    async fn get_scrapes(
638        metrics_addresses: &[String],
639    ) -> Result<Vec<(String, Scrape)>, BenchmarkError> {
640        let mut scrapes = Vec::new();
641        for metrics_address in metrics_addresses {
642            let response = reqwest::get(metrics_address)
643                .await
644                .map_err(BenchmarkError::Reqwest)?;
645            let metrics = response.text().await.map_err(BenchmarkError::Reqwest)?;
646            let scrape = Scrape::parse(metrics.lines().map(|line| Ok(line.to_owned())))
647                .map_err(BenchmarkError::IoError)?;
648            scrapes.push((metrics_address.clone(), scrape));
649        }
650        Ok(scrapes)
651    }
652
653    fn parse_histogram(
654        scrape: &Scrape,
655        metric_prefix: &str,
656    ) -> Result<HistogramSnapshot, BenchmarkError> {
657        let mut buckets: Vec<HistogramCount> = Vec::new();
658        let mut total_count: Option<f64> = None;
659        let mut total_sum: Option<f64> = None;
660
661        // Iterate over each metric in the scrape.
662        for sample in &scrape.samples {
663            if sample.metric == metric_prefix {
664                if let Value::Histogram(histogram) = &sample.value {
665                    buckets.extend(histogram.iter().cloned());
666                } else {
667                    return Err(BenchmarkError::ExpectedHistogramValue(sample.value.clone()));
668                }
669            } else if sample.metric == format!("{metric_prefix}_count") {
670                if let Value::Untyped(count) = sample.value {
671                    total_count = Some(count);
672                } else {
673                    return Err(BenchmarkError::ExpectedUntypedValue(sample.value.clone()));
674                }
675            } else if sample.metric == format!("{metric_prefix}_sum") {
676                if let Value::Untyped(sum) = sample.value {
677                    total_sum = Some(sum);
678                } else {
679                    return Err(BenchmarkError::ExpectedUntypedValue(sample.value.clone()));
680                }
681            }
682        }
683
684        match (total_count, total_sum) {
685            (Some(count), Some(sum)) if !buckets.is_empty() => {
686                buckets.sort_by(|a, b| {
687                    a.less_than
688                        .partial_cmp(&b.less_than)
689                        .expect("Comparison should not fail")
690                });
691                Ok(HistogramSnapshot {
692                    buckets,
693                    count,
694                    sum,
695                })
696            }
697            _ => Err(BenchmarkError::IncompleteHistogramData),
698        }
699    }
700
701    fn compute_quantile(
702        buckets: &[HistogramCount],
703        total_count: f64,
704        quantile: f64,
705    ) -> Result<f64, BenchmarkError> {
706        if total_count == 0.0 {
707            // Had no samples in the last 5s.
708            return Err(BenchmarkError::NoDataYetForP99Calculation);
709        }
710        // Compute the target cumulative count.
711        let target = (quantile * total_count).ceil();
712        let mut prev_cumulative = 0.0;
713        let mut prev_bound = 0.0;
714        for bucket in buckets {
715            if bucket.count >= target {
716                let bucket_count = bucket.count - prev_cumulative;
717                if bucket_count == 0.0 {
718                    // Bucket that is supposed to contain the target quantile is empty, unexpectedly.
719                    return Err(BenchmarkError::UnexpectedEmptyBucket);
720                }
721                let fraction = (target - prev_cumulative) / bucket_count;
722                return Ok(prev_bound + (bucket.less_than - prev_bound) * fraction);
723            }
724            prev_cumulative = bucket.count;
725            prev_bound = bucket.less_than;
726        }
727        Err(BenchmarkError::CouldNotComputeQuantile)
728    }
729
730    #[expect(clippy::too_many_arguments)]
731    async fn run_benchmark_internal(
732        chain_idx: usize,
733        chain_id: ChainId,
734        bps: usize,
735        chain_client: ChainClient<Env>,
736        mut generator: Box<dyn OperationGenerator>,
737        transactions_per_block: usize,
738        shutdown_notifier: CancellationToken,
739        bps_count: Arc<AtomicUsize>,
740        barrier: Arc<Barrier>,
741        notifier: Arc<Notify>,
742        runtime_control_sender: Option<mpsc::Sender<()>>,
743        delay_between_chains_ms: Option<u64>,
744    ) -> Result<(), BenchmarkError> {
745        barrier.wait().await;
746        if let Some(delay_between_chains_ms) = delay_between_chains_ms {
747            time::sleep(time::Duration::from_millis(
748                (chain_idx as u64) * delay_between_chains_ms,
749            ))
750            .await;
751        }
752        info!("Starting benchmark for chain {:?}", chain_id);
753
754        if let Some(runtime_control_sender) = runtime_control_sender {
755            runtime_control_sender.send(()).await?;
756        }
757
758        let owner = chain_client
759            .identity()
760            .await
761            .map_err(BenchmarkError::ChainClient)?;
762
763        loop {
764            tokio::select! {
765                biased;
766
767                _ = shutdown_notifier.cancelled() => {
768                    info!("Shutdown signal received, stopping benchmark");
769                    break;
770                }
771                result = chain_client.execute_operations(
772                    generator.generate_operations(owner, transactions_per_block),
773                    vec![]
774                ) => {
775                    result
776                        .map_err(BenchmarkError::ChainClient)?
777                        .expect("should execute block with operations");
778
779                    let current_bps_count = bps_count.fetch_add(1, Ordering::Relaxed) + 1;
780                    if current_bps_count >= bps {
781                        notifier.notified().await;
782                    }
783                }
784            }
785        }
786
787        info!("Exiting task...");
788        Ok(())
789    }
790
791    /// Closes the chain that was created for the benchmark.
792    pub async fn close_benchmark_chain(
793        chain_client: &ChainClient<Env>,
794    ) -> Result<(), BenchmarkError> {
795        let start = Instant::now();
796        loop {
797            let result = chain_client
798                .execute_operation(Operation::system(SystemOperation::CloseChain))
799                .await?;
800            match result {
801                ClientOutcome::Committed(_) => break,
802                ClientOutcome::Conflict(certificate) => {
803                    info!(
804                        "Conflict while closing chain {:?}: {}. Retrying...",
805                        chain_client.chain_id(),
806                        certificate.hash()
807                    );
808                }
809                ClientOutcome::WaitForTimeout(timeout) => {
810                    info!(
811                        "Waiting for timeout while closing chain {:?}: {}",
812                        chain_client.chain_id(),
813                        timeout
814                    );
815                    linera_base::time::timer::sleep(
816                        timeout.timestamp.duration_since(Timestamp::now()),
817                    )
818                    .await;
819                }
820            }
821        }
822
823        debug!(
824            "Closed chain {:?} in {} ms",
825            chain_client.chain_id(),
826            start.elapsed().as_millis()
827        );
828
829        Ok(())
830    }
831
832    /// Returns the chains to benchmark, from the config file if given, otherwise from the wallet.
833    pub fn get_all_chains(
834        chains_config_path: Option<&Path>,
835        benchmark_chains: &[(ChainId, AccountOwner)],
836    ) -> Result<Vec<ChainId>, BenchmarkError> {
837        let all_chains = if let Some(config_path) = chains_config_path {
838            if !config_path.exists() {
839                return Err(BenchmarkError::ConfigFileNotFound(
840                    config_path.to_path_buf(),
841                ));
842            }
843            let config = BenchmarkConfig::load_from_file(config_path)
844                .map_err(BenchmarkError::ConfigLoadError)?;
845            config.chain_ids
846        } else {
847            benchmark_chains.iter().map(|(id, _)| *id).collect()
848        };
849
850        Ok(all_chains)
851    }
852}
853
854/// Builds a fungible token transfer operation for the given application.
855pub fn fungible_transfer(
856    application_id: ApplicationId,
857    chain_id: ChainId,
858    sender: AccountOwner,
859    receiver: AccountOwner,
860    amount: Amount,
861) -> Operation {
862    let target_account = Account {
863        chain_id,
864        owner: receiver,
865    };
866    let bytes = bcs::to_bytes(&FungibleOperation::Transfer {
867        owner: sender,
868        amount,
869        target_account,
870    })
871    .expect("should serialize fungible token operation");
872    Operation::User {
873        application_id,
874        bytes,
875    }
876}