Skip to main content

forest/tool/subcommands/
benchmark_cmd.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use crate::blocks::{Tipset, TipsetKey};
5use crate::cid_collections::CidHashSet;
6use crate::db::car::ManyCar;
7use crate::db::car::forest::DEFAULT_FOREST_CAR_FRAME_SIZE;
8use crate::ipld::{stream_chain, stream_graph};
9use crate::prelude::*;
10use crate::shim::clock::ChainEpoch;
11use crate::utils::db::car_stream::{CarBlock, CarStream};
12use crate::utils::encoding::extract_cids;
13use crate::utils::multihash::MultihashCode;
14use crate::utils::stream::par_buffer;
15use crate::{
16    chain::{
17        ChainEpochDelta,
18        index::{ChainIndex, ResolveNullTipset},
19    },
20    db::{Either, parity_db::ParityDb, parity_db_config::ParityDbConfig},
21};
22use clap::Subcommand;
23use futures::{StreamExt, TryStreamExt};
24use fvm_ipld_encoding::DAG_CBOR;
25use human_repr::HumanCount as _;
26use indicatif::{ProgressBar, ProgressStyle};
27use itertools::Itertools;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30use std::time::Instant;
31use tokio::{
32    fs::File,
33    io::{AsyncWrite, AsyncWriteExt, BufReader},
34};
35
36#[derive(Debug, Clone, Copy, clap::ValueEnum)]
37pub enum DbType {
38    Parity,
39    ParityOpt,
40}
41
42#[derive(Debug, Subcommand)]
43pub enum BenchmarkCommands {
44    /// Benchmark streaming data from a CAR archive
45    CarStreaming {
46        /// Snapshot input files (`.car.`, `.car.zst`, `.forest.car.zst`)
47        #[arg(required = true)]
48        snapshot_files: Vec<PathBuf>,
49        /// Whether or not we want to expect [`ipld_core::ipld::Ipld`] data for each block.
50        #[arg(long)]
51        inspect: bool,
52    },
53    /// Depth-first traversal of the Filecoin graph
54    GraphTraversal {
55        /// Snapshot input files (`.car.`, `.car.zst`, `.forest.car.zst`)
56        #[arg(required = true)]
57        snapshot_files: Vec<PathBuf>,
58    },
59    /// Encoding of a `.forest.car.zst` file
60    ForestEncoding {
61        /// Snapshot input file (`.car.`, `.car.zst`, `.forest.car.zst`)
62        snapshot_file: PathBuf,
63        #[arg(long, default_value_t = 3)]
64        compression_level: u16,
65        /// End zstd frames after they exceed this length
66        #[arg(long, default_value_t = DEFAULT_FOREST_CAR_FRAME_SIZE)]
67        frame_size: usize,
68    },
69    /// Exporting a `.forest.car.zst` file from HEAD
70    Export {
71        /// Snapshot input files (`.car.`, `.car.zst`, `.forest.car.zst`)
72        #[arg(required = true)]
73        snapshot_files: Vec<PathBuf>,
74        #[arg(long, default_value_t = 3)]
75        compression_level: u16,
76        /// End zstd frames after they exceed this length
77        #[arg(long, default_value_t = DEFAULT_FOREST_CAR_FRAME_SIZE)]
78        frame_size: usize,
79        /// Latest epoch that has to be exported for this snapshot, the upper bound. This value
80        /// cannot be greater than the latest epoch available in the input snapshot.
81        #[arg(short, long)]
82        epoch: Option<ChainEpoch>,
83        /// How many state-roots to include. Lower limit is 900 for `calibnet` and `mainnet`.
84        #[arg(short, long, default_value_t = 2000)]
85        depth: ChainEpochDelta,
86    },
87    /// Benchmark key-value blockstore
88    Blockstore {
89        /// Snapshot input file (`.car.`, `.car.zst`, `.forest.car.zst`)
90        #[arg(required = true)]
91        snapshot_file: PathBuf,
92        #[arg(long, default_value = "parity")]
93        db: DbType,
94    },
95}
96
97impl BenchmarkCommands {
98    pub async fn run(self) -> anyhow::Result<()> {
99        match self {
100            Self::CarStreaming {
101                snapshot_files,
102                inspect,
103            } => match inspect {
104                true => benchmark_car_streaming_inspect(snapshot_files).await,
105                false => benchmark_car_streaming(snapshot_files).await,
106            },
107            Self::GraphTraversal { snapshot_files } => {
108                benchmark_graph_traversal(snapshot_files).await
109            }
110            Self::ForestEncoding {
111                snapshot_file,
112                compression_level,
113                frame_size,
114            } => benchmark_forest_encoding(snapshot_file, compression_level, frame_size).await,
115            Self::Export {
116                snapshot_files,
117                compression_level,
118                frame_size,
119                epoch,
120                depth,
121            } => {
122                benchmark_exporting(snapshot_files, compression_level, frame_size, epoch, depth)
123                    .await
124            }
125            Self::Blockstore { snapshot_file, db } => benchmark_blockstore(snapshot_file, db).await,
126        }
127    }
128}
129
130// Concatenate a set of CAR files and measure how quickly we can stream the
131// blocks.
132async fn benchmark_car_streaming(input: Vec<PathBuf>) -> anyhow::Result<()> {
133    let mut sink = indicatif_sink("traversed");
134
135    let mut s = Box::pin(
136        futures::stream::iter(input)
137            .then(File::open)
138            .map_ok(BufReader::new)
139            .and_then(CarStream::new)
140            .try_flatten(),
141    );
142    while let Some(block) = s.try_next().await? {
143        sink.write_all(&block.data).await?
144    }
145    Ok(())
146}
147
148// Concatenate a set of CAR files and measure how quickly we can stream the
149// blocks, while inspecting them. This a benchmark we could use for setting
150// realistic expectations in terms of DFS graph travels, for example.
151async fn benchmark_car_streaming_inspect(input: Vec<PathBuf>) -> anyhow::Result<()> {
152    let mut sink = indicatif_sink("traversed");
153    let mut s = Box::pin(
154        futures::stream::iter(input)
155            .then(File::open)
156            .map_ok(BufReader::new)
157            .and_then(CarStream::new)
158            .try_flatten(),
159    );
160    while let Some(block) = s.try_next().await? {
161        let block: CarBlock = block;
162        if block.cid.codec() == DAG_CBOR {
163            let cid_vec = extract_cids(&block.data)?;
164            let _ = cid_vec.iter().unique().count();
165        }
166        sink.write_all(&block.data).await?
167    }
168    Ok(())
169}
170
171// Open a set of CAR files as a block store and do a DFS traversal of all
172// reachable nodes.
173async fn benchmark_graph_traversal(input: Vec<PathBuf>) -> anyhow::Result<()> {
174    let store = open_store(input)?;
175    let heaviest = store.heaviest_tipset()?;
176
177    let mut sink = indicatif_sink("traversed");
178
179    let mut s = stream_graph(&store, heaviest.chain(&store), 0, CidHashSet::default());
180    while let Some(block) = s.try_next().await? {
181        sink.write_all(&block.data).await?
182    }
183
184    Ok(())
185}
186
187// Encode a file to the ForestCAR.zst format and measure throughput.
188async fn benchmark_forest_encoding(
189    input: PathBuf,
190    compression_level: u16,
191    frame_size: usize,
192) -> anyhow::Result<()> {
193    let file = tokio::io::BufReader::new(File::open(&input).await?);
194
195    let mut block_stream = CarStream::new(file).await?;
196    let roots = std::mem::replace(
197        &mut block_stream.header_v1.roots,
198        nunny::vec![Default::default()],
199    );
200
201    let mut dest = indicatif_sink("encoded");
202    let (blocks, _drop_guard) = par_buffer(1024, block_stream.map_err(anyhow::Error::from));
203    let frames =
204        crate::db::car::forest::Encoder::compress_stream(frame_size, compression_level, blocks);
205    crate::db::car::forest::Encoder::write(&mut dest, roots, frames).await?;
206    dest.flush().await?;
207    Ok(())
208}
209
210// Exporting combines a graph traversal with ForestCAR.zst encoding. Ideally, it
211// should be no lower than `min(benchmark_graph_traversal,
212// benchmark_forest_encoding)`.
213async fn benchmark_exporting(
214    input: Vec<PathBuf>,
215    compression_level: u16,
216    frame_size: usize,
217    epoch: Option<ChainEpoch>,
218    depth: ChainEpochDelta,
219) -> anyhow::Result<()> {
220    let store = Arc::new(open_store(input)?);
221    let heaviest = store.heaviest_tipset()?;
222    let idx = ChainIndex::new(
223        store.shallow_clone(),
224        heaviest.genesis(store.shallow_clone()).await?,
225    );
226    let ts = idx
227        .load_required_tipset_by_height(
228            epoch.unwrap_or(heaviest.epoch()),
229            heaviest,
230            ResolveNullTipset::TakeOlder,
231        )
232        .await?;
233    // We don't do any sanity checking for 'depth'. The output is discarded so
234    // there's no need.
235    let stateroot_lookup_limit = ts.epoch() - depth;
236
237    let mut dest = indicatif_sink("exported");
238
239    let blocks = stream_chain(
240        Arc::clone(&store),
241        ts.clone().chain_owned(Arc::clone(&store)),
242        stateroot_lookup_limit,
243        CidHashSet::default(),
244    );
245
246    let (blocks, _drop_guard) = par_buffer(1024, blocks.map_err(anyhow::Error::from));
247    let frames =
248        crate::db::car::forest::Encoder::compress_stream(frame_size, compression_level, blocks);
249    crate::db::car::forest::Encoder::write(&mut dest, ts.key().to_cids(), frames).await?;
250    dest.flush().await?;
251    Ok(())
252}
253
254async fn benchmark_blockstore(snapshot: PathBuf, db: DbType) -> anyhow::Result<()> {
255    let tmp_db_path = tempfile::tempdir()?;
256    let bs = open_blockstore(&db, tmp_db_path.path())?;
257    let head_tsk = benchmark_blockstore_import(&snapshot, &db, &bs, tmp_db_path.path()).await?;
258    benchmark_blockstore_traversal(&bs, &head_tsk).await?;
259    Ok(())
260}
261
262fn open_blockstore(db: &DbType, db_path: &Path) -> anyhow::Result<impl Blockstore> {
263    println!("temp db path: {}", db_path.display());
264    Ok(match db {
265        DbType::Parity => Either::Left(ParityDb::open(db_path, &ParityDbConfig::default())?),
266        DbType::ParityOpt => Either::Right(ParityDbOpt::open(db_path)?),
267    })
268}
269
270async fn benchmark_blockstore_import(
271    snapshot: &Path,
272    db: &DbType,
273    bs: &impl Blockstore,
274    bs_path: &Path,
275) -> anyhow::Result<TipsetKey> {
276    let mut car_stream = CarStream::new_from_path(snapshot).await?;
277    let head_tsk = car_stream.head_tipset_key();
278    println!("head tipset key: {head_tsk}");
279    println!("importing CAR into {db:?} blockstore...");
280    let start = Instant::now();
281    let mut n = 0;
282    while let Some(CarBlock { cid, data }) = car_stream.try_next().await? {
283        bs.put_keyed(&cid, &data)?;
284        n += 1;
285    }
286    let db_size = fs_extra::dir::get_size(bs_path).unwrap_or_default();
287    println!(
288        "imported {n} records into {db:?} blockstore(size={}), took {}",
289        db_size.human_count_bytes(),
290        humantime::format_duration(start.elapsed())
291    );
292    Ok(head_tsk)
293}
294
295async fn benchmark_blockstore_traversal(
296    bs: &impl Blockstore,
297    head_tsk: &TipsetKey,
298) -> anyhow::Result<()> {
299    println!("Traversing the chain...");
300    let head = Tipset::load_required(bs, head_tsk)?;
301    let mut sink = indicatif_sink("traversed");
302    let start = Instant::now();
303    let mut s = stream_graph(bs, head.chain(bs), 0, CidHashSet::default());
304    let mut n = 0;
305    while let Some(block) = s.try_next().await? {
306        sink.write_all(&block.data).await?;
307        n += 1;
308    }
309    println!(
310        "Traversed {n} records, took {}",
311        humantime::format_duration(start.elapsed())
312    );
313    Ok(())
314}
315
316// Sink with attached progress indicator
317fn indicatif_sink(task: &'static str) -> impl AsyncWrite {
318    let sink = tokio::io::sink();
319    let pb = ProgressBar::new_spinner()
320        .with_style(
321            ProgressStyle::with_template(
322                "{spinner} {prefix} {total_bytes} at {binary_bytes_per_sec} in {elapsed_precise}",
323            )
324            .expect("infallible"),
325        )
326        .with_prefix(task)
327        .with_finish(indicatif::ProgressFinish::AndLeave);
328    pb.enable_steady_tick(std::time::Duration::from_secs_f32(0.1));
329    pb.wrap_async_write(sink)
330}
331
332// Opening a block store may take a long time (CAR files have to be indexed,
333// CAR.zst files have to be decompressed). Show a progress indicator and clear
334// it when done.
335fn open_store(input: Vec<PathBuf>) -> anyhow::Result<ManyCar> {
336    let pb = indicatif::ProgressBar::new_spinner().with_style(
337        indicatif::ProgressStyle::with_template("{spinner} opening block store")
338            .expect("indicatif template must be valid"),
339    );
340    pb.enable_steady_tick(std::time::Duration::from_secs_f32(0.1));
341
342    let store = ManyCar::try_from(input).context("couldn't read input CAR file")?;
343
344    pb.finish_and_clear();
345
346    Ok(store)
347}
348
349struct ParityDbOpt {
350    db: parity_db::Db,
351}
352
353impl ParityDbOpt {
354    fn open(path: impl Into<PathBuf>) -> anyhow::Result<Self> {
355        let opts = parity_db::Options {
356            path: path.into(),
357            sync_wal: true,
358            sync_data: true,
359            stats: false,
360            salt: None,
361            columns: vec![
362                parity_db::ColumnOptions {
363                    preimage: true,
364                    uniform: true,
365                    compression: parity_db::CompressionType::Lz4,
366                    ..Default::default()
367                },
368                parity_db::ColumnOptions {
369                    preimage: true,
370                    compression: parity_db::CompressionType::Lz4,
371                    ..Default::default()
372                },
373            ],
374            compression_threshold: [(0, 128)].into_iter().collect(),
375        };
376        let db = parity_db::Db::open_or_create(&opts)?;
377        Ok(Self { db })
378    }
379}
380
381impl Blockstore for ParityDbOpt {
382    fn get(&self, k: &cid::Cid) -> anyhow::Result<Option<Vec<u8>>> {
383        Ok(if is_dag_cbor_blake2b256(k) {
384            self.db.get(0, k.hash().digest())?
385        } else {
386            self.db.get(1, &k.to_bytes())?
387        })
388    }
389
390    fn put_keyed(&self, k: &cid::Cid, block: &[u8]) -> anyhow::Result<()> {
391        if is_dag_cbor_blake2b256(k) {
392            self.db
393                .commit([(0, k.hash().digest(), Some(block.to_vec()))])?;
394        } else {
395            self.db.commit([(1, k.to_bytes(), Some(block.to_vec()))])?;
396        }
397        Ok(())
398    }
399}
400
401fn is_dag_cbor_blake2b256(cid: &Cid) -> bool {
402    cid.codec() == DAG_CBOR && cid.hash().code() == u64::from(MultihashCode::Blake2b256)
403}