1use 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
38pub trait OperationGenerator: Send + 'static {
47 fn generate_operations(&mut self, owner: AccountOwner, count: usize) -> Vec<Operation>;
49}
50
51pub 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 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 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 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
127pub 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 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 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 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#[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")]
250pub struct BenchmarkConfig {
252 pub chain_ids: Vec<ChainId>,
254}
255
256impl BenchmarkConfig {
257 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 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
272pub struct Benchmark<Env: Environment> {
274 _phantom: std::marker::PhantomData<Env>,
275}
276
277impl<Env: Environment> Benchmark<Env> {
278 #[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 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 ¬ifier,
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 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 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 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 return Err(BenchmarkError::NoDataYetForP99Calculation);
717 }
718 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 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 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 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
862pub 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}