Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 26 additions & 22 deletions bin/katana/src/cli/db/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ impl PruneArgs {
/// Collect statistics about what will be pruned
fn collect_pruning_stats(&self) -> Result<PruningStats> {
let mode = self.mode();
let tx = open_db_ro(&self.path)?.tx().context("Failed to create read transaction")?;
let db = open_db_ro(&self.path)?;
let tx = db.tx().context("Failed to create read transaction")?;

match mode {
PruneMode::Latest => count_all_historical_deletions(&tx),
PruneMode::KeepLastN { blocks } => count_keep_last_n_deletions(&tx, blocks),
PruneMode::KeepLastN { blocks } => {
let latest = get_latest_block_number(&db)?;
count_keep_last_n_deletions(&tx, blocks, latest)
}
}
}
}
Expand All @@ -93,7 +97,7 @@ fn prune_database(db_path: &str, mode: PruneMode) -> Result<()> {
let db = open_db_rw(db_path)?;
let tx = db.tx_mut().context("Failed to create write transaction")?;

let latest_block = get_latest_block_number(&tx)?;
let latest_block = get_latest_block_number(&db)?;

match mode {
PruneMode::Latest => {
Expand Down Expand Up @@ -132,14 +136,16 @@ fn prune_database(db_path: &str, mode: PruneMode) -> Result<()> {
Ok(())
}

/// Get the latest block number from the Headers table
fn get_latest_block_number(tx: &impl DbTx) -> Result<BlockNumber> {
let mut cursor = tx.cursor::<tables::Headers>()?;
if let Some((block_num, _)) = cursor.last()? {
Ok(block_num)
} else {
Ok(0)
}
/// Get the latest block number from the MDBX Headers table.
fn get_latest_block_number(db: &katana_db::Db) -> Result<BlockNumber> {
use katana_db::abstraction::Database;
let tx = db.tx().context("Failed to create read transaction")?;
let mut cursor = tx.cursor::<tables::Headers>().context("Failed to open Headers cursor")?;
cursor
.last()
.context("Failed to read last header")?
.map(|(num, _)| num)
.ok_or_else(|| anyhow!("No blocks found"))
}

/// Prune all historical trie data (keeping only current state)
Expand Down Expand Up @@ -358,8 +364,12 @@ fn count_all_historical_deletions(tx: &impl DbTx) -> Result<PruningStats> {
}

/// Count total entries that will be deleted for PruneMode::KeepLastN
fn count_keep_last_n_deletions(tx: &impl DbTx, keep_last_n: BlockNumber) -> Result<PruningStats> {
let cutoff_block = get_latest_block_number(tx)?.saturating_sub(keep_last_n);
fn count_keep_last_n_deletions(
tx: &impl DbTx,
keep_last_n: BlockNumber,
latest_block: BlockNumber,
) -> Result<PruningStats> {
let cutoff_block = latest_block.saturating_sub(keep_last_n);

if cutoff_block == 0 {
return Ok(PruningStats::default());
Expand Down Expand Up @@ -476,7 +486,6 @@ mod tests {
use katana_db::mdbx::{test_utils, DbEnv};
use katana_db::models::list::BlockChangeList;
use katana_db::models::trie::{TrieDatabaseKey, TrieDatabaseValue, TrieHistoryEntry};
use katana_db::models::VersionedHeader;
use katana_db::tables::{self, Tables};
use katana_primitives::block::BlockNumber;
use katana_utils::arbitrary;
Expand Down Expand Up @@ -545,11 +554,6 @@ mod tests {
}
}

// Insert headers for block range
for block in block_range {
tx.put::<tables::Headers>(block, VersionedHeader::default())?;
}

Ok(())
}

Expand Down Expand Up @@ -620,7 +624,7 @@ mod tests {
let tx = db.tx()?;

// Test keeping last 5 blocks (should delete blocks 0-14)
let stats = count_keep_last_n_deletions(&tx, 5)?;
let stats = count_keep_last_n_deletions(&tx, 5, 19)?;

// History tables: blocks 0-14 = 15 blocks
assert_eq!(stats.table_entries_deletions.get(Tables::ClassesTrieHistory.name()), Some(&15));
Expand Down Expand Up @@ -685,7 +689,7 @@ mod tests {
// Count entries before pruning
let tx = db.tx().unwrap();
let before_count = count_total_entries(&tx).unwrap();
let stats = count_keep_last_n_deletions(&tx, 10).unwrap();
let stats = count_keep_last_n_deletions(&tx, 10, 29).unwrap();
let predicted_deletions: usize = stats.table_entries_deletions.values().sum();
drop(tx);

Expand Down Expand Up @@ -714,7 +718,7 @@ mod tests {
let tx = db.tx()?;

// Test keeping last 15 blocks when we only have 10
let stats = count_keep_last_n_deletions(&tx, 15)?;
let stats = count_keep_last_n_deletions(&tx, 15, 9)?;

// Should have no deletions
let total: usize = stats.table_entries_deletions.values().sum();
Expand Down
1 change: 1 addition & 0 deletions crates/storage/db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ indicatif = "0.17.8"
cairo-lang-starknet-classes.workspace = true
arbitrary = { workspace = true, optional = true }
metrics.workspace = true
memmap2 = "0.9"
page_size = "0.6.0"
parking_lot.workspace = true
roaring = { version = "0.10.3", features = [ "serde" ] }
Expand Down
178 changes: 178 additions & 0 deletions crates/storage/db/docs/static-files-benchmarks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Static Files Storage — Benchmark Results

Benchmarks run on file-backed databases using `tempdir` with criterion (20 samples).
All numbers are median values from `cargo bench -p katana-provider --bench static_files`.

Machine: Apple Silicon (M-series), macOS.

## Baseline (MDBX-only, commit `f6b971f5`)

All data stored in MDBX B-trees. No static files.

### Writes

| Workload | Time |
|---|---|
| 100 blocks × 1 tx | 11.62 ms |
| 100 blocks × 10 txs | 30.19 ms |
| 100 blocks × 50 txs | 107.79 ms |
| 500 blocks × 10 txs | 174.21 ms |

### Reads (500 blocks × 10 txs database)

| Operation | Time |
|---|---|
| latest_number | 276.4 ns |
| latest_hash | 381.7 ns |
| header_by_number | 490.8 ns |
| block_body_indices | 293.5 ns |
| transaction_by_hash | 1.899 µs |
| receipt_by_hash | 1.886 µs |
| transaction_execution | 2.080 µs |
| total_transactions | 176.9 ns |
| full_block | 17.63 µs |

---

## Optimization 1: pread/pwrite (commit `3401721e~`)

Replaced `Mutex<File>` + `seek` + `read`/`write` with `pread`/`pwrite` syscalls.
Reads no longer need the mutex. Cached file length in `AtomicU64`.

### Writes

| Workload | Time | vs Baseline |
|---|---|---|
| 100 blocks × 1 tx | 13.48 ms | +16% |
| 100 blocks × 10 txs | 37.37 ms | +24% |
| 100 blocks × 50 txs | 135.76 ms | +26% |
| 500 blocks × 10 txs | 201.67 ms | +16% |

### Reads

| Operation | Time | vs Baseline |
|---|---|---|
| latest_number | 280 ns | +1% |
| latest_hash | 618 ns | +62% |
| header_by_number | 818 ns | +67% |
| block_body_indices | 626 ns | +113% |
| transaction_by_hash | 2.22 µs | +17% |
| receipt_by_hash | 2.21 µs | +17% |
| transaction_execution | 2.39 µs | +15% |
| total_transactions | 186 ns | +5% |
| full_block | 22.95 µs | +30% |

**Takeaway**: pread removed mutex contention but reads are still slower due to
per-read syscall overhead vs MDBX's internal page cache.

---

## Optimization 2: mmap for reads (commit `3401721e`)

Memory-map static files for zero-copy reads. Falls back to pread for data
written after the last remap. Remap called on `commit()`.

### Writes

| Workload | Time | vs Baseline |
|---|---|---|
| 100 blocks × 1 tx | 15.16 ms | +30% |
| 100 blocks × 10 txs | 39.54 ms | +31% |
| 100 blocks × 50 txs | 137.76 ms | +28% |
| 500 blocks × 10 txs | 211.80 ms | +22% |

### Reads

| Operation | Time | vs Baseline |
|---|---|---|
| latest_number | 266 ns | **-4%** |
| latest_hash | 307 ns | **-20%** |
| header_by_number | 483 ns | **-2%** |
| block_body_indices | 323 ns | +10% |
| transaction_by_hash | 1.88 µs | **-1%** |
| receipt_by_hash | 1.85 µs | **-2%** |
| transaction_execution | 2.05 µs | **-1%** |
| total_transactions | 190 ns | +7% |
| full_block | 16.01 µs | **-9%** |

**Takeaway**: mmap eliminated read overhead entirely. Reads now at parity or
better than MDBX baseline. The kernel page cache serves reads without syscalls.

---

## Optimization 3: remove dual-writes for index tables (commit `3401721e`)

`BlockHashes`, `TxHashes`, `TxBlocks` only written to MDBX in fork
(non-sequential) mode. In sequential mode, they exist only in static files.

### Writes

| Workload | Time | vs Baseline |
|---|---|---|
| 100 blocks × 1 tx | 14.26 ms | +23% |
| 100 blocks × 10 txs | 37.36 ms | +24% |
| 100 blocks × 50 txs | 136.79 ms | +27% |
| 500 blocks × 10 txs | 211.62 ms | +21% |

### Reads

Unchanged from optimization 2 (read path not affected).

**Takeaway**: Modest write improvement (~5%) from eliminating 3 MDBX puts per
block. Most write overhead comes from the 6 `StaticFileRef` pointer entries.

---

## Optimization 4: buffered writes (commit `7ade6f09`)

Buffer small `pwrite` calls in memory (64KB initial, auto-flush at 256KB).
Flush happens on `remap()` or `sync()`. Reads check the write buffer for
recently-appended data not yet flushed.

### Writes

| Workload | Time | vs Baseline |
|---|---|---|
| 100 blocks × 1 tx | 13.34 ms | +15% |
| 100 blocks × 10 txs | 31.21 ms | **+3%** |
| 100 blocks × 50 txs | 124.05 ms | +15% |
| 500 blocks × 10 txs | 170.35 ms | **-2%** |

### Reads

| Operation | Time | vs Baseline |
|---|---|---|
| latest_number | 250 ns | **-10%** |
| latest_hash | 307 ns | **-20%** |
| header_by_number | 504 ns | +3% |
| block_body_indices | 325 ns | +11% |
| transaction_by_hash | 1.90 µs | 0% |
| receipt_by_hash | 1.88 µs | **-1%** |
| transaction_execution | 2.08 µs | 0% |
| total_transactions | 182 ns | +3% |
| full_block | 16.41 µs | **-7%** |

**Takeaway**: Buffered writes reduced syscalls from ~54 per block (10 txs) to a
handful. Write overhead now +3-15% for small blocks, and **faster than baseline**
for the 500-block batch. The remaining overhead is from MDBX `StaticFileRef`
pointer serialization (13 bytes per entry).

---

## Summary

| Optimization | Write overhead | Read perf |
|---|---|---|
| Initial (Mutex + seek + fsync/block) | Unusable | +100-190% slower |
| + Remove per-block fsync | +21-27% | +30-113% slower |
| + pread/pwrite | +16-26% | +17-67% slower |
| + mmap reads | +21-27% | **At parity or faster** |
| + Remove dual-writes | +21-27% | At parity or faster |
| + **Buffered writes** | **+3-15%** | **At parity or faster** |

The real benefits of static files will compound at larger database sizes where:
- MDBX B-tree page splits on large values (headers ~500B, traces ~100KB) cause
write amplification that flat file appends avoid entirely
- The MDBX file stays smaller (only pointers + indexes), improving OS page cache
hit rates for the mutable state tables
- Sequential flat file reads have better disk locality than B-tree traversal
Loading