1use 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
38pub trait OperationGenerator: Send + 'static {
47 fn generate_operations(&mut self, owner: AccountOwner, count: usize) -> Vec<Operation>;
49}
50
51#[cfg_attr(not(web), async_trait::async_trait)]
58#[cfg_attr(web, async_trait::async_trait(?Send))]
59pub trait BenchmarkClient: Send + Sync + 'static {
60 fn chain_id(&self) -> ChainId;
62
63 async fn owner(&self) -> Result<AccountOwner, BenchmarkError>;
65
66 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
90pub 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 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 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 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
178pub 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 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 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 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#[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 #[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")]
318pub struct BenchmarkConfig {
320 pub chain_ids: Vec<ChainId>,
322}
323
324impl BenchmarkConfig {
325 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 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
340pub struct Benchmark<Env: Environment> {
342 _phantom: std::marker::PhantomData<Env>,
343}
344
345impl<Env: Environment> Benchmark<Env> {
346 #[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 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 ¬ifier,
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 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 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 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 return Err(BenchmarkError::NoDataYetForP99Calculation);
785 }
786 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 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 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 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 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 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
935pub 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 #[test]
973 fn avoid_self_decides_whether_the_source_is_a_destination() {
974 let source = chain("source");
975 let other = chain("other");
976 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 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 #[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}