diff --git a/sei-db/bench/cryptosim/block.go b/sei-db/bench/cryptosim/block.go index 9c935935ae..292f0f7b19 100644 --- a/sei-db/bench/cryptosim/block.go +++ b/sei-db/bench/cryptosim/block.go @@ -1,8 +1,7 @@ package cryptosim import ( - "iter" - + "github.com/sei-protocol/sei-chain/sei-db/proto" evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) @@ -29,6 +28,12 @@ type block struct { // The next ERC20 contract ID to be used when creating a new ERC20 contract, as of the end of this block. nextErc20ContractID int64 + // The writes this block makes, in the form the DB accepts, so finalizing has nothing left to + // convert. Built by the block builder before the block is published and not modified after. + // + // Only the DB reads this. Executor reads go to the DB, never here — see Database.Get(). + changeset []*proto.KVPair + metrics *CryptosimMetrics } @@ -54,15 +59,10 @@ func NewBlock( } } -// Returns an iterator over the transactions in the block. -func (b *block) Iterator() iter.Seq[*transaction] { - return func(yield func(*transaction) bool) { - for _, txn := range b.transactions { - if !yield(txn) { - return - } - } - } +// Transactions returns the block's transactions. The caller must not modify the slice or its +// contents: once the block has been dispatched the executors read it concurrently. +func (b *block) Transactions() []*transaction { + return b.transactions } // Adds a transaction to the block. @@ -111,3 +111,21 @@ func (b *block) NextErc20ContractID() int64 { func (b *block) TransactionCount() int64 { return int64(len(b.transactions)) } + +// SetWrites records the writes this block makes, collapsing the builder's keyed map into the slice the +// DB takes. Called by the block builder before the block is published, after which the changeset must +// not be modified. +func (b *block) SetWrites(writes map[string]*proto.KVPair) { + // Room for the counter keys FinalizeBlock appends, so appending them does not have to copy the + // whole slice on the thread this design exists to keep idle. + b.changeset = make([]*proto.KVPair, 0, len(writes)+counterKeysPerBlock) + for _, pair := range writes { + b.changeset = append(b.changeset, pair) + } +} + +// Changeset returns the block's writes in the form the DB accepts, excluding the counter keys that +// FinalizeBlock appends. +func (b *block) Changeset() []*proto.KVPair { + return b.changeset +} diff --git a/sei-db/bench/cryptosim/block_builder.go b/sei-db/bench/cryptosim/block_builder.go index 5389b33135..45e0bb2070 100644 --- a/sei-db/bench/cryptosim/block_builder.go +++ b/sei-db/bench/cryptosim/block_builder.go @@ -3,8 +3,18 @@ package cryptosim import ( "context" "fmt" + "sync" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) +// Each transaction selects two accounts and writes four keys. Both are properties of what a +// transaction is, and both are needed to divide a block into ranges: the first to compute which +// accounts a range mints, the second to size its write map. +const selectionsPerTransaction = 2 +const writesPerTransaction = 4 + // A builder for blocks of transactions. type blockBuilder struct { ctx context.Context @@ -17,6 +27,10 @@ type blockBuilder struct { // Produces random data. dataGenerator *DataGenerator + // Where writes are accumulated. The builder is the only writer once setup is done, which is what + // makes the accumulating map safe to keep unsynchronized. + database *Database + // Blocks are sent to this channel. blocksChan chan *block @@ -30,12 +44,14 @@ func NewBlockBuilder( config *CryptoSimConfig, metrics *CryptosimMetrics, dataGenerator *DataGenerator, + database *Database, ) *blockBuilder { return &blockBuilder{ ctx: ctx, config: config, metrics: metrics, dataGenerator: dataGenerator, + database: database, blocksChan: make(chan *block, config.BlockChannelCapacity), } } @@ -59,40 +75,229 @@ func (b *blockBuilder) mainLoop() { } } +// buildBlock generates a block's transactions and the changeset they produce. +// +// The changeset is built here, rather than accumulated by the executors and converted by the main +// thread at finalize time, because none of that work touches the DB and so none of it belongs on the +// critical path. It is possible here because a transaction's written values are pre-generated random +// bytes that do not depend on anything it reads: the whole block's writes are known before a single +// transaction executes. A real system could not do this, and simulating a parallel execution layer's +// consistency is explicitly not what this benchmark measures — it measures the DB underneath, and +// assumes such a layer exists and is correct. +// +// The transactions themselves are generated across BlockBuildWorkers goroutines; see +// buildBlockRanges(). Generating a block had come to cost nearly as much as consuming one, which +// capped throughput regardless of how fast the store underneath was. func (b *blockBuilder) buildBlock() *block { - blk := NewBlock(b.config, b.metrics, b.nextBlockNumber, b.config.TransactionsPerBlock) + blockNumber := b.nextBlockNumber + blk := NewBlock(b.config, b.metrics, blockNumber, b.config.TransactionsPerBlock) b.nextBlockNumber++ - for i := 0; i < b.config.TransactionsPerBlock; i++ { - txn, err := BuildTransaction(b.dataGenerator) + results := b.buildBlockRanges(blockNumber) + + // Starts from whatever was accumulated outside the ranges — the setup path fills this before the + // builder starts, and its writes belong to the first block. + writes := b.database.HarvestWrites() + + // The fee balance of the last transaction to produce one. Every transaction draws a fee balance, + // because the draw is part of the sequence this block's randomness is defined by, but they all write + // the same key — so only the last one survives, and only the last one is written. + var feeBalance []byte + var accountsMinted int64 + var coldAccountsMinted int64 + + for _, result := range results { + for _, txn := range result.transactions { + blk.AddTransaction(txn) + } + for _, rcpt := range result.receipts { + blk.AddReceipt(rcpt) + } + // Merged in range order, so a key written by more than one range keeps the value the later + // transaction gave it — the same answer generating them in sequence would reach. + for key, pair := range result.writes { + writes[key] = pair + } + if result.lastFeeBalance != nil { + feeBalance = result.lastFeeBalance + } + accountsMinted += result.accountsMinted + coldAccountsMinted += result.coldAccountsMinted + } + + // Written once, after the transactions, because every transaction writes the same key: issuing it + // per transaction produced one map entry out of TransactionsPerBlock writes and threw the rest away. + if feeBalance != nil { + feeKey := b.dataGenerator.FeeCollectionAddress() + writes[string(feeKey)] = &proto.KVPair{Key: feeKey, Value: feeBalance} + } + + // The forks minted from ranges of IDs reserved before they ran; this is where those ranges are + // accounted for, so the next block's arithmetic starts from the right place. + b.dataGenerator.AdoptForkResults(accountsMinted, coldAccountsMinted) + + blk.SetBlockAccountStats( + b.dataGenerator.NextAccountID(), + b.dataGenerator.NumberOfColdAccounts(), + b.dataGenerator.NextErc20ContractID()) + + // After this the map belongs to the block and must not be touched again from here: publishing the + // block is what exposes it to the DB. + blk.SetWrites(writes) + + b.dataGenerator.ReportEndOfBlock() + + return blk +} + +// buildRangeResult is one worker's share of a block: its transactions and receipts in the order it +// generated them, plus the writes they made. +type buildRangeResult struct { + transactions []*transaction + receipts []*evmtypes.Receipt + writes map[string]*proto.KVPair + lastFeeBalance []byte + accountsMinted int64 + coldAccountsMinted int64 +} + +// buildBlockRanges divides a block's transactions into contiguous runs, generates each on its own +// goroutine, and returns the results in block order. +// +// Which selections mint an account is a function of the selection count alone, so every range's account +// IDs are computed before any of them run and no two ranges can mint the same one. Order is preserved +// by concatenating results in range order rather than by coordinating the workers. +func (b *blockBuilder) buildBlockRanges(blockNumber int64) []buildRangeResult { + workers := b.config.BlockBuildWorkers + transactions := b.config.TransactionsPerBlock + if workers < 2 || transactions < workers { + return []buildRangeResult{ + b.buildRange(blockNumber, 0, transactions, b.dataGenerator.NextAccountID()), + } + } + + // The remainder is spread over the leading ranges, one extra each, so no range is more than one + // transaction larger than another. + base := transactions / workers + remainder := transactions % workers + + results := make([]buildRangeResult, workers) + firstTransaction := 0 + firstAccountID := b.dataGenerator.NextAccountID() + + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + count := base + if i < remainder { + count++ + } + + wg.Add(1) + go func(index int, first int, transactionCount int, accountID int64) { + defer wg.Done() + results[index] = b.buildRange(blockNumber, first, transactionCount, accountID) + }(i, firstTransaction, count, firstAccountID) + + // Counted from the first selection of the run rather than of the block, matching what the + // generator serves: a reservation measured from the block's own start would not be the run the + // worker goes on to use. + firstAccountID += b.dataGenerator.AccountsMintedPerSelections( + b.firstSelectionOf(blockNumber, firstTransaction), + int64(count)*selectionsPerTransaction) + firstTransaction += count + } + wg.Wait() + + return results +} + +// buildRange generates one contiguous run of a block's transactions. +func (b *blockBuilder) buildRange( + blockNumber int64, + firstTransaction int, + transactionCount int, + firstAccountID int64, +) buildRangeResult { + + generator := b.dataGenerator.Fork(firstAccountID) + + result := buildRangeResult{ + transactions: make([]*transaction, 0, transactionCount), + writes: make(map[string]*proto.KVPair, transactionCount*writesPerTransaction), + } + if b.config.GenerateReceipts { + result.receipts = make([]*evmtypes.Receipt, 0, transactionCount) + } + + for i := 0; i < transactionCount; i++ { + // Re-pointed per transaction rather than left to run on: what a transaction draws and whether it + // creates an account have to depend on which transaction it is, or a block's contents would + // depend on how many workers generated it. + generator.BeginTransaction(b.transactionIndexOf(blockNumber, firstTransaction+i)) + + txn, err := BuildTransaction(generator) if err != nil { fmt.Printf("failed to build transaction: %v\n", err) continue } - blk.AddTransaction(txn) + result.transactions = append(result.transactions, txn) + recordTransactionWrites(result.writes, txn) + result.lastFeeBalance = txn.newFeeBalance if b.config.GenerateReceipts { - receipt, err := BuildERC20TransferReceiptFromTxn( - b.dataGenerator.Rand(), - b.dataGenerator.FeeCollectionAddress(), - uint64(blk.BlockNumber()), //nolint:gosec - uint32(i), //nolint:gosec + rcpt, err := BuildERC20TransferReceiptFromTxn( + generator.Rand(), + generator.FeeCollectionAddress(), + uint64(blockNumber), //nolint:gosec + //nolint:gosec // G115 - a transaction's index within its block fits in uint32 + uint32(firstTransaction+i), txn, ) if err != nil { fmt.Printf("failed to build receipt: %v\n", err) continue } - blk.AddReceipt(receipt) + result.receipts = append(result.receipts, rcpt) } } - blk.SetBlockAccountStats( - b.dataGenerator.NextAccountID(), - b.dataGenerator.NumberOfColdAccounts(), - b.dataGenerator.NextErc20ContractID()) + result.accountsMinted = generator.AccountsMinted() + result.coldAccountsMinted = generator.ColdAccountsMinted() + return result +} - b.dataGenerator.ReportEndOfBlock() +// transactionIndexOf returns a transaction's index counted from the first transaction of the run, +// given its index within its block. Both the randomness a transaction draws and the selections it +// serves are keyed on this, so neither restarts at a block boundary. +func (b *blockBuilder) transactionIndexOf(blockNumber int64, transactionInBlock int) int64 { + return blockNumber*int64(b.config.TransactionsPerBlock) + int64(transactionInBlock) +} - return blk +// firstSelectionOf returns the selection count a transaction's first selection sits at, counted from +// the first selection of the run. +func (b *blockBuilder) firstSelectionOf(blockNumber int64, transactionInBlock int) int64 { + return b.transactionIndexOf(blockNumber, transactionInBlock) * selectionsPerTransaction +} + +// recordTransactionWrites records the writes a transaction makes: the two accounts' balances and their +// two ERC20 storage slots. The fee collection account is written once per block instead: see +// buildBlock(). +// +// These used to be issued by Execute() on the executor threads. They are issued here because the values +// are pre-generated and independent of everything the transaction reads, so making the executors pay +// for them bought nothing. Reads still happen on the executors, which is the part the benchmark is +// measuring. +func recordTransactionWrites(writes map[string]*proto.KVPair, txn *transaction) { + pairs := [...]struct { + key []byte + value []byte + }{ + {txn.srcAccount, txn.newSrcBalance}, + {txn.dstAccount, txn.newDstBalance}, + {txn.srcAccountSlot, txn.newSrcAccountSlot}, + {txn.dstAccountSlot, txn.newDstAccountSlot}, + } + for _, pair := range pairs { + writes[string(pair.key)] = &proto.KVPair{Key: pair.key, Value: pair.value} + } } diff --git a/sei-db/bench/cryptosim/block_builder_parallel_test.go b/sei-db/bench/cryptosim/block_builder_parallel_test.go new file mode 100644 index 0000000000..5c5da5d71a --- /dev/null +++ b/sei-db/bench/cryptosim/block_builder_parallel_test.go @@ -0,0 +1,268 @@ +package cryptosim + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/common/keys" +) + +// A block's contents must not depend on how many goroutines generated it. That is the property the +// whole range split rests on: if it does not hold, two runs at different worker counts are not +// comparable and the benchmark stops measuring the same thing. +func TestBlockContentsIndependentOfWorkerCount(t *testing.T) { + t.Parallel() + + reference := buildBlocksWithWorkers(t, 1, 3) + + for _, workers := range []int{2, 3, 8, 17} { + got := buildBlocksWithWorkers(t, workers, 3) + require.Len(t, got, len(reference)) + + for i := range reference { + context := fmt.Sprintf("block %d at %d workers", i, workers) + requireSameBlock(t, reference[i], got[i], context) + } + } +} + +// A cadence of zero must mint nothing, whatever the worker count. +func TestZeroCadenceMintsNoAccounts(t *testing.T) { + t.Parallel() + + builder := newTestBuilderWithWorkers(t, 64, 4) + builder.config.SelectionsPerNewAccount = 0 + before := builder.dataGenerator.NextAccountID() + + results := builder.buildBlockRanges(builder.nextBlockNumber) + for _, result := range results { + require.Zero(t, result.accountsMinted) + } + require.Equal(t, before, builder.dataGenerator.NextAccountID()) +} + +// newTestBuilderWithWorkers is newTestBuilder with the block split across the given number of workers. +func newTestBuilderWithWorkers(t *testing.T, transactionsPerBlock int, workers int) *blockBuilder { + t.Helper() + builder := newTestBuilder(t, transactionsPerBlock) + builder.config.BlockBuildWorkers = workers + return builder +} + +// newMintingTestBuilder returns a builder whose every account selection creates a new account, which +// is what a cadence of one means now that creating takes precedence over a hot selection. +func newMintingTestBuilder(t *testing.T, workers int) *blockBuilder { + t.Helper() + builder := newTestBuilder(t, 64) + builder.config.BlockBuildWorkers = workers + builder.config.SelectionsPerNewAccount = 1 + return builder +} + +// newMixedSelectionTestBuilder returns a builder whose selections are a mix of all three kinds: a +// cadence that creates often enough for several accounts per block, and hot selections left on. +func newMixedSelectionTestBuilder(t *testing.T, workers int) *blockBuilder { + t.Helper() + builder := newTestBuilder(t, 64) + builder.config.BlockBuildWorkers = workers + // A cadence of nine so that some create slots land on hot selections: multiples of an even cadence + // never do, which would leave the rule that settles the tie untested. + builder.config.SelectionsPerNewAccount = 9 + builder.config.HotAccountProbability = 0.1 + return builder +} + +// TestABlockCreatesExactlyTheReservedAccountIDs pins what makes a worker's reserved range of account +// IDs correct: the IDs a block hands out are the run reserved for it, all of it and nothing else. +// +// A worker's range is reserved by counting the selections in its slice of the block that create an +// account, before any worker runs. Two ways for that to be wrong are a worker leaving the tail of its +// range unused — which happened when a hot selection could win the tie and talk a selection out of +// creating — and two workers being handed overlapping starts, which no count of accounts created can +// see. So the addresses the block actually created are compared against the addresses the reserved run +// implies, which catches a gap and a collision alike. +func TestABlockCreatesExactlyTheReservedAccountIDs(t *testing.T) { + t.Parallel() + + const blocks = 3 + + for _, workers := range []int{1, 4, 8} { + builder := newMixedSelectionTestBuilder(t, workers) + selections := int64(builder.config.TransactionsPerBlock) * selectionsPerTransaction + var createdInRun int64 + + for block := 0; block < blocks; block++ { + // Taken per block from where the block starts in the run: the cadence does not restart at a + // block boundary, so how many accounts a block reserves depends on which block it is. + reserved := builder.dataGenerator.AccountsMintedPerSelections( + builder.firstSelectionOf(builder.nextBlockNumber, 0), selections) + require.Positive(t, reserved, + "the fixture must reserve something to be a test at %d workers", workers) + + firstID := builder.dataGenerator.NextAccountID() + + created := createdAccountAddresses(builder.buildBlock()) + + // Counted before being compared as a set, so that one address created twice is a failure + // rather than a set that happens to match. + require.Len(t, created, int(reserved), + "block %d at %d workers created %d accounts against %d reserved", + block, workers, len(created), reserved) + require.Equal(t, + reservedAccountAddresses(builder.dataGenerator, firstID, reserved), + asAddressSet(created), + "block %d at %d workers did not create the run reserved for it", block, workers) + + require.Equal(t, firstID+reserved, builder.dataGenerator.NextAccountID(), + "the counter must cover every ID the block used, at %d workers", workers) + + createdInRun += int64(len(created)) + } + + // The cadence runs across the whole sequence, so a run of blocks creates what the cadence says + // for that many selections — not what each block would round to on its own. + require.Equal(t, + builder.dataGenerator.AccountsMintedPerSelections(0, blocks*selections), createdInRun, + "the run created %d accounts against the cadence's %d, at %d workers", + createdInRun, + builder.dataGenerator.AccountsMintedPerSelections(0, blocks*selections), workers) + } +} + +// createdAccountAddresses returns the address of every account a block created, in block order and +// with repeats kept, taken from the transactions that reported creating one. +func createdAccountAddresses(blk *block) [][]byte { + created := make([][]byte, 0, blk.TransactionCount()) + for _, txn := range blk.Transactions() { + if txn.isSrcNew { + created = append(created, txn.srcAccount) + } + if txn.isDstNew { + created = append(created, txn.dstAccount) + } + } + return created +} + +// reservedAccountAddresses returns the addresses of the account IDs [firstID, firstID+reserved). +// +// An ID's address is a function of the ID alone, so the run reserved for a block can be turned into the +// keys that run stands for without asking the generator what it did. +func reservedAccountAddresses(generator *DataGenerator, firstID int64, reserved int64) map[string]bool { + addresses := make(map[string]bool, reserved) + for id := firstID; id < firstID+reserved; id++ { + addr := generator.Rand().Address(accountPrefix, id, keys.AddressLen) + addresses[string(keys.BuildEVMKey(accountKeyPrefix, addr))] = true + } + return addresses +} + +// asAddressSet indexes addresses for comparison against a reserved run. +func asAddressSet(addresses [][]byte) map[string]bool { + set := make(map[string]bool, len(addresses)) + for _, address := range addresses { + set[string(address)] = true + } + return set +} + +// Where a selection is both a create slot and a hot slot, it creates. That tie is the whole defect +// this design closes: the arithmetic that reserves ID ranges counts create slots, so a tie settled the +// other way would reserve an ID that no worker ever uses. +func TestCreatingWinsOverAHotSelection(t *testing.T) { + t.Parallel() + + builder := newMixedSelectionTestBuilder(t, 1) + generator := builder.dataGenerator + + tie := int64(-1) + for selection := range int64(1000) { + if generator.selectionCreatesAccount(selection) && generator.selectionIsHot(selection) { + tie = selection + break + } + } + require.NotEqual(t, int64(-1), tie, "the fixture must contain a selection that is both") + + generator.selectionCount = tie + before := generator.NextAccountID() + _, _, isNew, err := generator.RandomAccount() + require.NoError(t, err) + require.True(t, isNew, "selection %d is a create slot, so it must create an account", tie) + require.Equal(t, before+1, generator.NextAccountID()) +} + +// Hot selections are spread evenly rather than drawn, so any run of selections carries the configured +// share of them. A block that lost them entirely, or took nothing but them, would make every other +// test here agree about a workload nobody runs. +func TestHotSelectionsFollowTheConfiguredShare(t *testing.T) { + t.Parallel() + + builder := newTestBuilder(t, 64) + builder.config.HotAccountProbability = 0.1 + builder.config.SelectionsPerNewAccount = 0 + + const selections = 100_000 + hot := 0 + for selection := range int64(selections) { + if builder.dataGenerator.selectionIsHot(selection) { + hot++ + } + } + require.Equal(t, selections/10, hot, "a tenth of the selections must be hot, exactly") +} + +// buildBlocksWithWorkers builds count blocks from a freshly seeded builder at the given worker count. +func buildBlocksWithWorkers(t *testing.T, workers int, count int) []*block { + t.Helper() + builder := newMintingTestBuilder(t, workers) + blocks := make([]*block, 0, count) + for i := 0; i < count; i++ { + blocks = append(blocks, builder.buildBlock()) + } + return blocks +} + +// requireSameBlock asserts two blocks carry the same transactions and the same writes. +func requireSameBlock(t *testing.T, want *block, got *block, context string) { + t.Helper() + + require.Equal(t, want.BlockNumber(), got.BlockNumber(), "%s: block number", context) + + wantTxns := want.Transactions() + gotTxns := got.Transactions() + require.Len(t, gotTxns, len(wantTxns), "%s: transaction count", context) + for i := range wantTxns { + require.Equal(t, wantTxns[i].srcAccount, gotTxns[i].srcAccount, "%s: txn %d source", context, i) + require.Equal(t, wantTxns[i].dstAccount, gotTxns[i].dstAccount, "%s: txn %d dest", context, i) + require.Equal(t, wantTxns[i].srcAccountSlot, gotTxns[i].srcAccountSlot, + "%s: txn %d source slot", context, i) + require.Equal(t, wantTxns[i].dstAccountSlot, gotTxns[i].dstAccountSlot, + "%s: txn %d dest slot", context, i) + require.Equal(t, wantTxns[i].newSrcBalance, gotTxns[i].newSrcBalance, + "%s: txn %d source balance", context, i) + } + + // Compared as a set: SetWrites flattens a map, so the changeset's order carries no meaning and + // differs run to run even without any of this. + wantWrites := writeSet(want) + gotWrites := writeSet(got) + require.Len(t, gotWrites, len(wantWrites), "%s: write count", context) + for key, value := range wantWrites { + gotValue, ok := gotWrites[key] + require.True(t, ok, "%s: missing write for %x", context, key) + require.Equal(t, value, gotValue, "%s: write value for %x", context, key) + } +} + +// writeSet indexes a block's changeset by key, so two blocks' writes can be compared without depending +// on the order the changeset happens to be in. +func writeSet(blk *block) map[string][]byte { + writes := make(map[string][]byte, len(blk.Changeset())) + for _, pair := range blk.Changeset() { + writes[string(pair.Key)] = pair.Value + } + return writes +} diff --git a/sei-db/bench/cryptosim/block_builder_test.go b/sei-db/bench/cryptosim/block_builder_test.go new file mode 100644 index 0000000000..c384603106 --- /dev/null +++ b/sei-db/bench/cryptosim/block_builder_test.go @@ -0,0 +1,174 @@ +package cryptosim + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" +) + +// newTestBuilder returns a builder over a database that records writes but serves no reads, with a +// small transaction count so a block is cheap to build. +// +// Selection is pinned to the hot account and hot contract sets, whose bounds come from config rather +// than from the generator's counters. A generator that has not been through setup has a cold window +// running from a negative ID and an empty contract range, so cold selection cannot succeed — and +// buildBlock reports a failed transaction by printing and moving on, which would leave an empty block +// rather than a failing test. +func newTestBuilder(t *testing.T, transactionsPerBlock int) *blockBuilder { + t.Helper() + + cfg := DefaultCryptoSimConfig() + cfg.TransactionsPerBlock = transactionsPerBlock + cfg.GenerateReceipts = false + cfg.NumberOfHotAccounts = 16 + cfg.HotAccountProbability = 1.0 + cfg.SelectionsPerNewAccount = 0 + cfg.HotErc20ContractProbability = 1.0 + cfg.HotErc20ContractSetSize = 4 + // A buffer big enough for the largest single draw. The benchmark's own size is a gigabyte, which a + // test that only needs a handful of blocks would pay for in full, per builder. + cfg.CannedRandomSize = 1024 * 1024 + + db, err := NewDatabase(cfg, &readTrackingStateDB{view: &readTrackingView{}}, nil, nil) + require.NoError(t, err) + + random := crand.NewCannedRandom(cfg.CannedRandomSize, cfg.Seed) + generator := NewDataGenerator(cfg, db, random, nil) + + // The hot contract range is bounded by how many contracts exist, so it is empty until some do. + for i := 0; i < cfg.HotErc20ContractSetSize; i++ { + _, _, err := generator.CreateNewErc20Contract(cfg.Erc20ContractSize, false) + require.NoError(t, err) + } + generator.ReportEndOfBlock() + db.HarvestWrites() + + return NewBlockBuilder(context.Background(), cfg, nil, generator, db) +} + +// requireFullBlock guards against buildBlock silently producing a short block: it reports a failed +// transaction by printing and continuing, so an invalid fixture reads as a passing test over no data. +func requireFullBlock(t *testing.T, b *blockBuilder, blk *block) { + t.Helper() + require.Len(t, blk.transactions, b.config.TransactionsPerBlock, + "block is short, so some transactions failed to build") +} + +// The fee collection account is written once per block, not once per transaction. Every transaction +// still draws a fee balance — the draw is part of the random sequence the block is defined by — but +// they all name one key, so only the last draw is written. +func TestBuildBlockWritesFeeCollectionAccountOnce(t *testing.T) { + t.Parallel() + + const transactions = 8 + b := newTestBuilder(t, transactions) + feeKey := string(b.dataGenerator.FeeCollectionAddress()) + + blk := b.buildBlock() + requireFullBlock(t, b, blk) + + feeWrites := 0 + var feeValue []byte + for _, pair := range blk.Changeset() { + if string(pair.Key) == feeKey { + feeWrites++ + feeValue = pair.Value + } + } + require.Equal(t, 1, feeWrites, "the fee collection account must be written exactly once per block") + + // The surviving value is the last transaction's, which is what the per-transaction version left in + // the map after every earlier write was overwritten. + last := blk.transactions[len(blk.transactions)-1] + require.Equal(t, last.newFeeBalance, feeValue) +} + +// Transaction values are windows onto the canned random buffer rather than copies of it. That is only +// sound if the buffer is never rewritten, so a block's values must still read the same after later +// blocks have been built. +func TestBuildBlockValuesSurviveLaterBlocks(t *testing.T) { + t.Parallel() + + b := newTestBuilder(t, 8) + + first := b.buildBlock() + requireFullBlock(t, b, first) + before := make([][]byte, 0, len(first.Changeset())) + for _, pair := range first.Changeset() { + before = append(before, bytes.Clone(pair.Value)) + } + + for i := 0; i < 4; i++ { + b.buildBlock() + } + + for i, pair := range first.Changeset() { + require.True(t, bytes.Equal(before[i], pair.Value), + "value %d changed after later blocks were built", i) + } +} + +// dispatchBlock splits a block across the executors by range, so the partition arithmetic is the whole +// risk: an off-by-one in how the remainder is spread would silently drop or double-run transactions. +// Counts are chosen to divide evenly, to leave a remainder, and to be smaller than the executor count. +func TestDispatchBlockCoversEveryTransactionExactlyOnce(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + transactions int + executors int + }{ + {transactions: 512, executors: 64}, + {transactions: 511, executors: 64}, + {transactions: 513, executors: 64}, + {transactions: 7, executors: 64}, + {transactions: 0, executors: 64}, + {transactions: 100, executors: 1}, + } { + t.Run(fmt.Sprintf("txns=%d/executors=%d", tc.transactions, tc.executors), func(t *testing.T) { + t.Parallel() + + blk := &block{transactions: make([]*transaction, tc.transactions)} + for i := range blk.transactions { + blk.transactions[i] = &transaction{} + } + + // Stand-in for the executors: record which ranges were handed out without running anything. + ranges := make([][]*transaction, 0, tc.executors) + c := &CryptoSim{executors: make([]*TransactionExecutor, tc.executors)} + dispatched := func(_ int, txns []*transaction) { ranges = append(ranges, txns) } + + partitionBlock(c, blk, dispatched) + + seen := make(map[*transaction]int, tc.transactions) + total := 0 + for _, r := range ranges { + require.NotEmpty(t, r, "an empty range must not be dispatched") + total += len(r) + for _, txn := range r { + seen[txn]++ + } + } + require.Equal(t, tc.transactions, total, "ranges must cover the block exactly") + require.Len(t, seen, tc.transactions, "every transaction must appear") + for txn, count := range seen { + require.Equal(t, 1, count, "transaction %p dispatched %d times", txn, count) + } + + // The split must stay even: no executor may carry more than one extra transaction. + if len(ranges) > 1 { + smallest, largest := len(ranges[0]), len(ranges[0]) + for _, r := range ranges { + smallest = min(smallest, len(r)) + largest = max(largest, len(r)) + } + require.LessOrEqual(t, largest-smallest, 1, "ranges are unevenly sized") + } + }) + } +} diff --git a/sei-db/bench/cryptosim/config/basic-config.json b/sei-db/bench/cryptosim/config/basic-config.json index b53a5f97df..aac705d991 100644 --- a/sei-db/bench/cryptosim/config/basic-config.json +++ b/sei-db/bench/cryptosim/config/basic-config.json @@ -42,13 +42,14 @@ "MinimumNumberOfDormantAccounts": 1000000, "MinimumNumberOfErc20Contracts": 10000, "NewAccountDormancyProbability": 1.0, - "NewAccountProbability": 0.001, + "SelectionsPerNewAccount": 1111, "NumberOfHotAccounts": 100, "PaddedAccountSize": 32, "Seed": 1337, "SetupUpdateIntervalCount": 100000, "ThreadsPerCore": 2.0, "TransactionsPerBlock": 1024, + "BlockBuildWorkers": 8, "MaxRuntimeSeconds": 0, "TransactionMetricsSampleRate": 0.001, "BackgroundMetricsScrapeInterval": 60, diff --git a/sei-db/bench/cryptosim/cryptosim.go b/sei-db/bench/cryptosim/cryptosim.go index 08a78d3b40..0158ce18da 100644 --- a/sei-db/bench/cryptosim/cryptosim.go +++ b/sei-db/bench/cryptosim/cryptosim.go @@ -60,12 +60,10 @@ type CryptoSim struct { // The database for the benchmark. database *Database - // The transaction executors for the benchmark. Transactions are distributed round-robin to the executors. + // The transaction executors for the benchmark. A block is split into one contiguous range per + // executor; see dispatchBlock(). executors []*TransactionExecutor - // The index of the next executor to receive a transaction. - nextExecutorIndex int - // The metrics for the benchmark. metrics *CryptosimMetrics @@ -227,7 +225,7 @@ func NewCryptoSim( rateLimiter = rate.NewLimiter(rate.Limit(config.MaxTPS), config.TransactionsPerBlock) } - blockBuilder := NewBlockBuilder(ctx, config, metrics, dataGenerator) + blockBuilder := NewBlockBuilder(ctx, config, metrics, dataGenerator, database) c := &CryptoSim{ ctx: ctx, @@ -463,21 +461,64 @@ func (c *CryptoSim) maybeThrottle() { } } +// dispatchBlock hands each executor one contiguous range of the block's transactions. +// +// Ranges rather than round-robin because the executors are equivalent and the transactions are +// independent: an even split needs no cursor carried between blocks, and every executor has all of its +// work after len(executors) sends instead of after one send per transaction. +// +// The remainder of an uneven division is spread over the leading executors, one extra each, so no +// single executor carries the whole of it. +func (c *CryptoSim) dispatchBlock(blk *block) { + partitionBlock(c, blk, func(index int, txns []*transaction) { + c.executors[index].ScheduleRange(txns) + }) +} + +// partitionBlock splits a block into one contiguous range per executor and hands each to dispatch. +// +// Separated from dispatchBlock() so the split can be checked without executors: the arithmetic is what +// would silently drop or double-run a transaction, and that is invisible from the outside. +func partitionBlock(c *CryptoSim, blk *block, dispatch func(index int, txns []*transaction)) { + transactions := blk.Transactions() + executorCount := len(c.executors) + perExecutor := len(transactions) / executorCount + remainder := len(transactions) % executorCount + + start := 0 + for i := 0; i < executorCount; i++ { + size := perExecutor + if i < remainder { + size++ + } + if size == 0 { + continue + } + dispatch(i, transactions[start:start+size]) + start += size + } +} + // Execute and finalize the next block. func (c *CryptoSim) handleNextBlock(blk *block) { c.mostRecentBlock = blk + + // Published before any of this block's transactions is scheduled, so that finalizing commits this + // block's changeset rather than the previous one's. + c.database.SetCurrentBlock(blk) + c.metrics.SetMainThreadPhase("send_to_executors") - for i := int64(0); i < blk.TransactionCount(); i++ { - c.database.IncrementTransactionCount() - } + c.database.AddTransactionCount(blk.TransactionCount()) // TODO: skip executor dispatch and FinalizeBlock when DisableTransactionExecution // is true and only receipts are being benchmarked. FlatKV commits waste I/O here. - for txn := range blk.Iterator() { - c.executors[c.nextExecutorIndex].ScheduleForExecution(txn) - c.nextExecutorIndex = (c.nextExecutorIndex + 1) % len(c.executors) - } + // + // One message per executor naming a contiguous range, rather than one per transaction. The sends are + // hidden behind execution either way, so this is about the work itself: at thousands of transactions + // per block, the channel operations on both ends were a measurable share of the main thread's time + // and of each executor's. + c.dispatchBlock(blk) if err := c.database.FinalizeBlock(blk.NextAccountID(), blk.NextErc20ContractID()); err != nil { fmt.Printf("failed to finalize block: %v\n", err) diff --git a/sei-db/bench/cryptosim/cryptosim_config.go b/sei-db/bench/cryptosim/cryptosim_config.go index 96797cdad5..5916023cb3 100644 --- a/sei-db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/bench/cryptosim/cryptosim_config.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" flatkvConfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" ) @@ -45,9 +46,14 @@ type CryptoSimConfig struct { // a value between 0.0 and 1.0. HotAccountProbability float64 - // When selecting a non-hot account for a transaction, the benchmark will create a new account with this - // probability. Should be a value between 0.0 and 1.0. - NewAccountProbability float64 + // One new account is created every this many account selections, of which a transaction makes two. + // 0 never creates accounts. + // + // A cadence rather than a probability, so that the set of accounts in existence at any point in a + // block follows from arithmetic rather than from the draws that came before. That is what lets a + // block's transactions be generated in parallel: a worker can compute which account IDs it will + // create without coordinating with any other worker. + SelectionsPerNewAccount int // Each account contains an integer value used to track a balance, plus a bunch of random // bytes for padding. This is the total size of the account after padding is added. @@ -82,6 +88,10 @@ type CryptoSimConfig struct { // The number of transactions that will be processed in each "block". TransactionsPerBlock int + // How many goroutines generate one block's transactions. Values below 2 generate them on the + // calling goroutine. + BlockBuildWorkers int + // How many blocks the benchmark may run ahead of block hashing. Databases hash committed blocks // asynchronously, and the benchmark takes one block's hash per block committed once it is this far // ahead — so a block's hash must arrive no later than this many blocks after it was committed, and @@ -173,12 +183,10 @@ type CryptoSimConfig struct { // The capacity of the channel that holds blocks sent to the receipt store. RecieptChannelCapacity int - // If true, disables simulation of transaction execution, and writes very little to the database. This is - // potentially useful when benchmarking things other than state storage (e.g. the receipt store). - // - // Note that switching execution on after previously running with execution disabled may result in buggy behavior, - // as the benchmark will not be properly maintaining DB state when transaction execution is disabled. In order - // to switch transaction execution back on, it is necessary to delete the on-disk database and start over. + // If true, the transaction executors drop the transactions they are handed instead of executing them, + // so the benchmark issues no execution-time reads at all. A block's writes are produced when the + // block is built rather than by execution, so they are still committed. This is potentially useful + // when benchmarking something other than execution-time reads (e.g. the receipt store). DisableTransactionExecution bool // If true, skip transaction-time database reads and only issue writes. Useful @@ -251,7 +259,7 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { MinimumNumberOfDormantAccounts: 1_000_000, NewAccountDormancyProbability: 1.0, HotAccountProbability: 0.1, - NewAccountProbability: 0.001, + SelectionsPerNewAccount: 1111, PaddedAccountSize: 32, MinimumNumberOfErc20Contracts: 10_000, HotErc20ContractProbability: 0.5, @@ -261,6 +269,7 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { AccountBalanceSize: 32, Erc20InteractionsPerAccount: 10, TransactionsPerBlock: 1024, + BlockBuildWorkers: 8, HashLagBlocks: 32, Seed: 1337, CannedRandomSize: 1024 * 1024 * 1024, // 1GB @@ -302,9 +311,34 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { LogLevel: "info", } + disableReadCacheMetrics(cfg.FlatKVConfig) + return cfg } +// disableReadCacheMetrics turns off the read caches' own metrics for every live state DB store. +// +// Those are recorded per read — a counter for hits, another for misses, a histogram for miss latency — +// and every executor thread reports into the same instrument. At the read rates this benchmark drives, +// what it measures starts to include the cost of measuring it. +// +// The cost is visibility: cache hit rate and cache size are reported by these same instruments, so a +// run configured this way cannot show them. Turn them back on for any run whose question is about cache +// behaviour rather than throughput. +func disableReadCacheMetrics(cfg *flatkvConfig.Config) { + if cfg == nil { + return + } + for _, storeConfig := range []*view.ViewManagerConfig{ + &cfg.AccountStoreConfig, + &cfg.CodeStoreConfig, + &cfg.StorageStoreConfig, + &cfg.MiscStoreConfig, + } { + storeConfig.MetricsEnabled = false + } +} + // StringifiedConfig returns the config as human-readable, multi-line JSON. func (c *CryptoSimConfig) StringifiedConfig() (string, error) { b, err := json.MarshalIndent(c, "", " ") @@ -339,8 +373,8 @@ func (c *CryptoSimConfig) Validate() error { if c.HotAccountProbability < 0 || c.HotAccountProbability > 1 { return fmt.Errorf("HotAccountProbability must be in [0, 1] (got %f)", c.HotAccountProbability) } - if c.NewAccountProbability < 0 || c.NewAccountProbability > 1 { - return fmt.Errorf("NewAccountProbability must be in [0, 1] (got %f)", c.NewAccountProbability) + if c.SelectionsPerNewAccount < 0 { + return fmt.Errorf("SelectionsPerNewAccount must be non-negative (got %d)", c.SelectionsPerNewAccount) } if c.HotErc20ContractProbability < 0 || c.HotErc20ContractProbability > 1 { return fmt.Errorf("HotErc20ContractProbability must be in [0, 1] (got %f)", c.HotErc20ContractProbability) @@ -359,6 +393,9 @@ func (c *CryptoSimConfig) Validate() error { if c.TransactionsPerBlock < 1 { return fmt.Errorf("TransactionsPerBlock must be at least 1 (got %d)", c.TransactionsPerBlock) } + if c.BlockBuildWorkers < 1 { + return fmt.Errorf("BlockBuildWorkers must be at least 1 (got %d)", c.BlockBuildWorkers) + } if c.CannedRandomSize < 8 { return fmt.Errorf("CannedRandomSize must be at least 8 (got %d)", c.CannedRandomSize) } diff --git a/sei-db/bench/cryptosim/data_generator.go b/sei-db/bench/cryptosim/data_generator.go index 3523c9b214..cec70280b6 100644 --- a/sei-db/bench/cryptosim/data_generator.go +++ b/sei-db/bench/cryptosim/data_generator.go @@ -3,6 +3,7 @@ package cryptosim import ( "encoding/binary" "fmt" + "math" "github.com/sei-protocol/sei-chain/sei-db/common/keys" crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" @@ -20,6 +21,10 @@ const ( accountKeyPrefix = keys.EVMKeyCodeHash ) +// selectionPatternCycle is the number of account selections the hot pattern repeats over. A +// probability is rounded to this many parts, so it also sets the resolution of the hot share. +const selectionPatternCycle = 1_000_000 + // Generates random data for the benchmark. This is not a thread safe utility. type DataGenerator struct { config *CryptoSimConfig @@ -47,10 +52,29 @@ type DataGenerator struct { // highest account ID that was created before the current block. highestSafeAccountIDInBlock int64 + // How many account selections this generator has served. Which selections create an account is a + // function of this count alone, which is what makes a fork's account IDs computable in advance. + selectionCount int64 + + // The first account ID this generator may mint, so that what it has minted is a subtraction. + firstMintableAccountID int64 + + // The number of cold accounts this generator started from, so that what it has minted is a + // subtraction. + coldAccountsAtStart int64 + // The current number of cold accounts. These are accounts that are not used frequently, but are not // entirely dormant. numberOfColdAccounts int64 + // The number of cold accounts a selection may draw from, held apart from the live count and frozen + // for the duration of a block. + // + // Frozen for the same reason highestSafeAccountIDInBlock is: a window that moved as accounts were + // created would make the account a transaction selects depend on how many its own generator had + // created first, and so on how a block was divided among workers. + coldAccountsVisibleInBlock int64 + // The metrics for the benchmark. metrics *CryptosimMetrics } @@ -93,12 +117,15 @@ func NewDataGenerator( return &DataGenerator{ config: config, nextAccountID: nextAccountID, + firstMintableAccountID: nextAccountID, nextErc20ContractID: nextErc20ContractID, rand: rand, feeCollectionAddress: feeCollectionAddress, database: database, highestSafeAccountIDInBlock: nextAccountID - 1, numberOfColdAccounts: int64(config.MinimumNumberOfColdAccounts), + coldAccountsAtStart: int64(config.MinimumNumberOfColdAccounts), + coldAccountsVisibleInBlock: int64(config.MinimumNumberOfColdAccounts), metrics: metrics, } } @@ -201,39 +228,153 @@ func (d *DataGenerator) CreateNewErc20Contract( return erc20ContractID, address, nil } -// Select a random account for a transaction. If an existing account is selected then its ID is guaranteed to be -// less or equal to maxAccountID. If a new account is created, it may have an ID greater than maxAccountID. +// Select a random account for a transaction. A newly created account may have an ID greater than any +// existing one; an account selected from the hot set or the cold window never does. +// +// Which of the three a selection is follows from its position in the selection sequence rather than +// from a draw, so the accounts a run of selections creates are known before any of them run. Which +// account it lands on within the hot set or the cold window is still drawn at random. func (d *DataGenerator) RandomAccount() (id int64, address []byte, isNew bool, err error) { - hot := d.rand.Float64() < d.config.HotAccountProbability + selection := d.selectionCount + d.selectionCount++ + + // Creating takes precedence over a hot selection where the two coincide. Both patterns run over the + // same counter, so they intersect, and letting the hot selection win there would make the accounts + // a run of selections creates depend on the hot pattern — which is exactly what the arithmetic that + // reserves ID ranges for parallel generation cannot see. + if d.selectionCreatesAccount(selection) { + id, address, _, err := d.CreateNewAccount(d.config.PaddedAccountSize, false) + if err != nil { + return 0, nil, false, fmt.Errorf("failed to create new account: %w", err) + } + return id, address, true, nil + } - if hot { + if d.selectionIsHot(selection) { firstHotAccountID := 1 lastHotAccountID := d.config.NumberOfHotAccounts accountID := d.rand.Int64Range(int64(firstHotAccountID), int64(lastHotAccountID+1)) addr := d.rand.Address(accountPrefix, accountID, keys.AddressLen) return accountID, keys.BuildEVMKey(accountKeyPrefix, addr), false, nil - } else { - - new := d.rand.Float64() < d.config.NewAccountProbability - if new { - // create a new account - id, address, _, err := d.CreateNewAccount(d.config.PaddedAccountSize, false) - if err != nil { - return 0, nil, false, fmt.Errorf("failed to create new account: %w", err) - } - return id, address, true, nil - } + } - // select an existing account at random + // Select an existing account from the cold window at random. Both bounds are frozen for the block, + // so the account this selection lands on does not depend on what the generator has created since + // the block began. + lastLegalColdAccountID := d.highestSafeAccountIDInBlock + 1 + firstLegalColdAccountID := lastLegalColdAccountID - d.coldAccountsVisibleInBlock - lastLegalColdAccountID := d.highestSafeAccountIDInBlock + 1 - firstLegalColdAccountID := lastLegalColdAccountID - d.numberOfColdAccounts + accountID := d.rand.Int64Range(firstLegalColdAccountID, lastLegalColdAccountID) + addr := d.rand.Address(accountPrefix, accountID, keys.AddressLen) + return accountID, keys.BuildEVMKey(accountKeyPrefix, addr), false, nil +} - accountID := d.rand.Int64Range(firstLegalColdAccountID, lastLegalColdAccountID) - addr := d.rand.Address(accountPrefix, accountID, keys.AddressLen) - return accountID, keys.BuildEVMKey(accountKeyPrefix, addr), false, nil +// selectionCreatesAccount reports whether the selection at the given count creates a new account. +// +// A function of the count alone, so the accounts any span of selections will create are known before +// any of them run. A cadence of zero never creates. +func (d *DataGenerator) selectionCreatesAccount(selection int64) bool { + cadence := int64(d.config.SelectionsPerNewAccount) + if cadence == 0 { + return false } + return selection%cadence == 0 +} + +// selectionIsHot reports whether the selection at the given count draws from the hot set. +// +// A function of the count alone, like selectionCreatesAccount(). The hot selections are spread evenly +// through each cycle of selectionPatternCycle, so their share of a cycle is HotAccountProbability at +// that resolution, and a run of selections anywhere in the sequence carries that share. +func (d *DataGenerator) selectionIsHot(selection int64) bool { + share := int64(math.Round(d.config.HotAccountProbability * selectionPatternCycle)) + position := selection % selectionPatternCycle + return (position+1)*share/selectionPatternCycle > position*share/selectionPatternCycle +} + +// accountsMinted reports how many accounts this generator has minted since it was forked. +func (d *DataGenerator) accountsMinted() int64 { + return d.nextAccountID - d.firstMintableAccountID +} + +// AccountsMintedPerSelections returns how many accounts a run of selections mints, given how many +// selections precede it. Both are needed because a cadence hits on the count itself, so where a run +// starts decides how many hits it contains. +func (d *DataGenerator) AccountsMintedPerSelections(precedingSelections int64, selections int64) int64 { + cadence := int64(d.config.SelectionsPerNewAccount) + if cadence == 0 || selections <= 0 { + return 0 + } + hitsThrough := func(count int64) int64 { + if count <= 0 { + return 0 + } + // Counts multiples of cadence in [0, count), and 0 is a multiple. + return (count-1)/cadence + 1 + } + return hitsThrough(precedingSelections+selections) - hitsThrough(precedingSelections) +} + +// Fork returns a generator that creates accounts from firstAccountID onwards, for one worker's share +// of a block. +// +// The fork shares the immutable random buffer through a cursor of its own; where that cursor reads, +// and which selection it is serving, are both set per transaction by BeginTransaction(), so what a +// transaction draws and whether it creates an account follow from its index rather than from which +// fork served it. Two forks never create the same ID, because a selection creates an account by its +// position alone, so the run of IDs a fork will use is the run the caller reserved for it. +// +// The account selection window is frozen at the value the parent holds, so every fork of one block +// draws from the same set of pre-existing accounts — which is what the block-at-a-time visibility rule +// already guaranteed when selections were served in sequence. +func (d *DataGenerator) Fork(firstAccountID int64) *DataGenerator { + + fork := *d + fork.rand = d.rand.Clone(false) + fork.nextAccountID = firstAccountID + fork.firstMintableAccountID = firstAccountID + fork.coldAccountsAtStart = d.numberOfColdAccounts + + // The two quantities a block's selections are drawn against. Carried over explicitly because + // freezing them is what makes a block's contents independent of how it was divided. + fork.highestSafeAccountIDInBlock = d.highestSafeAccountIDInBlock + fork.coldAccountsVisibleInBlock = d.coldAccountsVisibleInBlock + return &fork +} + +// BeginTransaction points the generator at the randomness belonging to one transaction, and at the +// selections that transaction serves. +// +// transactionIndex counts from the first transaction of the run rather than from the first of its +// block, which both patterns over the selection count depend on: a cadence measured from a block's own +// start would restart at every boundary, which rounds the accounts a block creates up to a whole +// number and floors it at one however large the cadence is. +// +// Both are functions of which transaction it is rather than of how many came before on this goroutine, +// which is what makes a block's contents independent of how it was divided among workers: the same +// transaction index always draws the same values and creates the same accounts. +func (d *DataGenerator) BeginTransaction(transactionIndex int64) { + d.rand.SeekTo(transactionIndex) + d.selectionCount = transactionIndex * selectionsPerTransaction +} + +// AdoptForkResults folds what a block's forks minted back into the generator they were taken from, so +// the next block's arithmetic starts from the right place. +func (d *DataGenerator) AdoptForkResults(accountsMinted int64, coldAccountsMinted int64) { + d.nextAccountID += accountsMinted + d.numberOfColdAccounts += coldAccountsMinted +} + +// ColdAccountsMinted reports how many of the accounts this generator minted since it was forked were +// cold rather than dormant. +func (d *DataGenerator) ColdAccountsMinted() int64 { + return d.numberOfColdAccounts - d.coldAccountsAtStart +} + +// AccountsMinted reports how many accounts this generator minted since it was forked. +func (d *DataGenerator) AccountsMinted() int64 { + return d.accountsMinted() } // Selects a random account slot for a transaction. @@ -288,9 +429,11 @@ func (d *DataGenerator) FeeCollectionAddress() []byte { } // Call this to signal that we have reached the end of a block. This is a signal that it is now safe to use -// recently created accounts as read/write targets. +// recently created accounts as read/write targets, and that the accounts the block created may be +// selected from. func (d *DataGenerator) ReportEndOfBlock() { d.highestSafeAccountIDInBlock = d.nextAccountID - 1 + d.coldAccountsVisibleInBlock = d.numberOfColdAccounts } // Get the random number generator. Note that the random number generator is not thread safe, and diff --git a/sei-db/bench/cryptosim/database.go b/sei-db/bench/cryptosim/database.go index 5400288cfa..caac90a44e 100644 --- a/sei-db/bench/cryptosim/database.go +++ b/sei-db/bench/cryptosim/database.go @@ -11,6 +11,10 @@ import ( gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) +// The number of counter keys FinalizeBlock appends to every block's changeset: the account ID counter +// and the ERC20 contract ID counter. +const counterKeysPerBlock = 2 + // Encapsulates the database for the cryptosim benchmark. type Database struct { // The configuration for the benchmark. @@ -35,9 +39,21 @@ type Database struct { // The block number the next commit lands on. Incremented after each finalized block. nextBlockNumber int64 - // The current batch of key-value pairs waiting to be committed. Represents changes we are accumulating - // as part of a simulated "block". Stored as value []byte; converted to NamedChangeSet when applied to the DB. - batch *SyncMap[string, []byte] + // The writes accumulated for the block currently being assembled, keyed by string(key), already in + // the form the DB accepts so that finalizing has nothing left to convert. + // + // A plain map carrying no synchronization at all, which is sound only because it has one writer at a + // time and never a concurrent reader. Setup fills it from the main thread before the block builder + // is started; from then on the builder is the sole writer, harvesting it into each block it + // publishes. Executors never touch it — they write nothing, and their reads go to the DB. + pendingWrites map[string]*proto.KVPair + + // The block being executed, or nil during setup. The DB reads its frozen changeset when the block + // is finalized. + // + // Written by the main thread before any of that block's transactions are scheduled, and read by the + // finalize path on that same thread, so no lock is needed. + currentBlock *block // A method that flushes the executors. flushFunc func() @@ -64,7 +80,7 @@ func NewDatabase( db: db, garbageCollector: garbageCollector, view: view, - batch: NewSyncMap[string, []byte](), + pendingWrites: make(map[string]*proto.KVPair), metrics: metrics, nextBlockNumber: view.GetBlockHeight() + 1, } @@ -80,30 +96,58 @@ func NewDatabase( return database, nil } -// Insert a key-value pair into the database/cache. +// Insert a key-value pair into the block currently being assembled. +// +// Not safe to call concurrently, with itself or with HarvestWrites() — see pendingWrites. Both callers +// are single-threaded and do not overlap: setup on the main thread, and the block builder on its own +// goroutine once setup is done. // -// This method is safe to call concurrently with other calls to Put() and Get(). Is not thread -// safe with FinalizeBlock(). It is not thread safe to modify the returned value (make a copy first). +// The key and value are retained rather than copied, so a caller must not reuse either buffer. Every +// caller allocates both fresh per write, or takes them from the immutable canned random buffer. func (d *Database) Put(key []byte, value []byte) error { - d.batch.Put(string(key), value) + d.pendingWrites[string(key)] = &proto.KVPair{Key: key, Value: value} return nil } -// Retrieve a value from the database/cache. +// HarvestWrites returns the writes accumulated since the last harvest and installs a fresh map for the +// next block. The returned map must not be modified once it has been handed to a block. +// +// Called only by the block builder, on its own goroutine, between blocks. +func (d *Database) HarvestWrites() map[string]*proto.KVPair { + harvested := d.pendingWrites + d.pendingWrites = make(map[string]*proto.KVPair, len(harvested)) + return harvested +} + +// SetCurrentBlock records the block whose transactions are about to be scheduled, so that finalizing +// commits that block's changeset. Called by the main thread before any of that block's transactions is +// handed to an executor. +func (d *Database) SetCurrentBlock(blk *block) { + d.currentBlock = blk +} + +// Retrieve a value from the database. +// +// Every read goes to the DB. There is deliberately no in-memory short-circuit in front of it: the read +// throughput of the DB is the thing this benchmark exists to measure, so a read served from a map is a +// read that did not get measured. A transaction reads the same keys it writes, so consulting the +// block's writes first silently excluded most of a block's reads from the measurement. // -// This method is safe to call concurrently with other calls to Put() and Get(). Is not thread -// safe with FinalizeBlock(). +// This method is safe to call concurrently with other calls to Get(). Is not thread safe with +// FinalizeBlock(). func (d *Database) Get(key []byte) ([]byte, bool) { - if value, found := d.batch.Get(string(key)); found { - return value, true - } return d.view.Get(keys.EVMStoreKey, key) } // Signal that a transaction has been added to the current block. func (d *Database) IncrementTransactionCount() { - d.transactionCount++ - d.transactionsInCurrentBlock++ + d.AddTransactionCount(1) +} + +// Signal that count transactions have been added to the current block. +func (d *Database) AddTransactionCount(count int64) { + d.transactionCount += count + d.transactionsInCurrentBlock += count } // Reset the transaction count. Useful for when changing test phases. @@ -152,36 +196,28 @@ func (d *Database) FinalizeBlock( d.metrics.SetMainThreadPhase("finalizing") - changeSets := make([]*proto.NamedChangeSet, 0, d.transactionsInCurrentBlock+3) - for key, value := range d.batch.Iterator() { - changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: keys.EVMStoreKey, - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{Key: []byte(key), Value: value}}}, - }) - } - d.batch.Clear() + pairs := d.blockPairs() // Persist the account ID counter in every batch. nonceValue := make([]byte, 8) //nolint:gosec // G115 - nextAccountID is benchmark counter, overflow acceptable binary.BigEndian.PutUint64(nonceValue, uint64(nextAccountID)) - changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: keys.EVMStoreKey, - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ - {Key: AccountIDCounterKey(), Value: nonceValue}, - }}, - }) + pairs = append(pairs, &proto.KVPair{Key: AccountIDCounterKey(), Value: nonceValue}) // Persist the ERC20 contract ID counter in every batch. erc20ContractIDValue := make([]byte, 8) //nolint:gosec // G115 - nextErc20ContractID is benchmark counter, overflow acceptable binary.BigEndian.PutUint64(erc20ContractIDValue, uint64(nextErc20ContractID)) - changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: keys.EVMStoreKey, - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ - {Key: Erc20IDCounterKey(), Value: erc20ContractIDValue}, - }}, - }) + pairs = append(pairs, &proto.KVPair{Key: Erc20IDCounterKey(), Value: erc20ContractIDValue}) + + // One changeset carrying every pair, matching the shape a real block produces: the EVM module's + // whole block arrives as a single contiguous batch of pairs. Wrapping each pair in a changeset of + // its own instead would make the consuming store chase a separate allocation per pair, which is + // benchmark overhead rather than a real cost. + changeSets := []*proto.NamedChangeSet{{ + Name: keys.EVMStoreKey, + Changeset: proto.ChangeSet{Pairs: pairs}, + }} blockNum := d.nextBlockNumber @@ -208,6 +244,27 @@ func (d *Database) FinalizeBlock( return nil } +// blockPairs returns the block's writes in the form the DB accepts, without the counter keys, which +// FinalizeBlock appends. +// +// There are two sources because there are two producers. A benchmark block arrives with its pairs +// already built by the block builder, so this is a field read and the conversion cost has already been +// paid off the critical path — the point of the whole arrangement. Setup has no block: it Puts account +// and contract data straight into pendingWrites, and there is nowhere earlier to have done the +// conversion, so it happens here. Setup runs once and is not what the benchmark reports. +func (d *Database) blockPairs() []*proto.KVPair { + if d.currentBlock != nil { + return d.currentBlock.Changeset() + } + + pairs := make([]*proto.KVPair, 0, len(d.pendingWrites)+counterKeysPerBlock) + for _, pair := range d.pendingWrites { + pairs = append(pairs, pair) + } + d.pendingWrites = make(map[string]*proto.KVPair) + return pairs +} + // reopenView replaces the read view with one over the block just committed. A view never observes // writes made after it was opened, so without this every read would keep answering from the height // the benchmark started at. diff --git a/sei-db/bench/cryptosim/sync_map.go b/sei-db/bench/cryptosim/sync_map.go deleted file mode 100644 index d97d283637..0000000000 --- a/sei-db/bench/cryptosim/sync_map.go +++ /dev/null @@ -1,45 +0,0 @@ -package cryptosim - -import ( - "iter" - "sync" -) - -// A thread safe map-like data structure. Unlike sync.Map, supports generics. -type SyncMap[K comparable, V any] struct { - base sync.Map -} - -// NewSyncMap returns a new empty SyncMap. -func NewSyncMap[K comparable, V any]() *SyncMap[K, V] { - return &SyncMap[K, V]{} -} - -// Put stores the key-value pair in the map. -func (m *SyncMap[K, V]) Put(key K, value V) { - m.base.Store(key, value) -} - -// Clear removes all key-value pairs from the map. -func (m *SyncMap[K, V]) Clear() { - m.base.Clear() -} - -// Get returns the value for key and true if present, or the zero value of V and false otherwise. -func (m *SyncMap[K, V]) Get(key K) (V, bool) { - val, ok := m.base.Load(key) - if !ok { - var zero V - return zero, false - } - return val.(V), true -} - -// All returns an iterator over the map's key-value pairs for use with range. -func (m *SyncMap[K, V]) Iterator() iter.Seq2[K, V] { - return func(yield func(K, V) bool) { - m.base.Range(func(key, value any) bool { - return yield(key.(K), value.(V)) - }) - } -} diff --git a/sei-db/bench/cryptosim/transaction.go b/sei-db/bench/cryptosim/transaction.go index 04647e66db..0541cb17a3 100644 --- a/sei-db/bench/cryptosim/transaction.go +++ b/sei-db/bench/cryptosim/transaction.go @@ -74,18 +74,21 @@ func BuildTransaction( captureMetrics := dataGenerator.rand.Float64() < dataGenerator.config.TransactionMetricsSampleRate return &transaction{ - srcAccount: srcAccountAddress, - isSrcNew: isSrcNew, - dstAccount: dstAccountAddress, - isDstNew: isDstNew, - srcAccountSlot: srcAccountSlot, - dstAccountSlot: dstAccountSlot, - erc20Contract: erc20Contract, - newSrcBalance: append([]byte(nil), dataGenerator.rand.Bytes(dataGenerator.config.AccountBalanceSize)...), - newDstBalance: append([]byte(nil), dataGenerator.rand.Bytes(dataGenerator.config.AccountBalanceSize)...), - newFeeBalance: append([]byte(nil), dataGenerator.rand.Bytes(dataGenerator.config.AccountBalanceSize)...), - newSrcAccountSlot: append([]byte(nil), dataGenerator.rand.Bytes(dataGenerator.config.Erc20StorageSlotSize)...), - newDstAccountSlot: append([]byte(nil), dataGenerator.rand.Bytes(dataGenerator.config.Erc20StorageSlotSize)...), + srcAccount: srcAccountAddress, + isSrcNew: isSrcNew, + dstAccount: dstAccountAddress, + isDstNew: isDstNew, + srcAccountSlot: srcAccountSlot, + dstAccountSlot: dstAccountSlot, + erc20Contract: erc20Contract, + // Windows onto the canned buffer rather than copies of it. The buffer is never written after + // construction, and every consumer of a value copies what it keeps. A copy here would be a copy + // of bytes nothing can change, five times per transaction. + newSrcBalance: dataGenerator.rand.Bytes(dataGenerator.config.AccountBalanceSize), + newDstBalance: dataGenerator.rand.Bytes(dataGenerator.config.AccountBalanceSize), + newFeeBalance: dataGenerator.rand.Bytes(dataGenerator.config.AccountBalanceSize), + newSrcAccountSlot: dataGenerator.rand.Bytes(dataGenerator.config.Erc20StorageSlotSize), + newDstAccountSlot: dataGenerator.rand.Bytes(dataGenerator.config.Erc20StorageSlotSize), captureMetrics: captureMetrics, }, nil } @@ -143,46 +146,11 @@ func (txn *transaction) Execute( database.Get(feeCollectionAddress) } - phaseTimer.SetPhase("update_balances") - var err error - - // Write the following: - // - the sender's native balance - // - the receiver's native balance - // - the sender's storage slot for the ERC20 contract - // - the receiver's storage slot for the ERC20 contract - // - the fee collection account's native balance - - // Write the sender's account data. - err = database.Put(txn.srcAccount, txn.newSrcBalance) - if err != nil { - return fmt.Errorf("failed to put source account: %w", err) - } - - // Write the receiver's account data. - err = database.Put(txn.dstAccount, txn.newDstBalance) - if err != nil { - return fmt.Errorf("failed to put destination account: %w", err) - } - - // Write the sender's storage slot for the ERC20 contract. - err = database.Put(txn.srcAccountSlot, txn.newSrcAccountSlot) - if err != nil { - return fmt.Errorf("failed to put source account slot: %w", err) - } - - // Write the receiver's storage slot for the ERC20 contract. - err = database.Put(txn.dstAccountSlot, txn.newDstAccountSlot) - if err != nil { - return fmt.Errorf("failed to put destination account slot: %w", err) - } - - // Write the fee collection account's native balance. - err = database.Put(feeCollectionAddress, txn.newFeeBalance) - if err != nil { - return fmt.Errorf("failed to put fee collection account: %w", err) - } - + // The writes this transaction makes — both accounts' balances and both ERC20 storage slots, plus the + // block's single fee collection write — were recorded when the block was generated, so there is + // nothing to write here. See recordTransactionWrites(): the values are pre-generated and depend on + // nothing that was just read, so issuing them on this thread only took time away from the reads, + // which are what this benchmark exists to measure. phaseTimer.Reset() return nil diff --git a/sei-db/bench/cryptosim/transaction_executor.go b/sei-db/bench/cryptosim/transaction_executor.go index 17271e1f1f..fbdc093f58 100644 --- a/sei-db/bench/cryptosim/transaction_executor.go +++ b/sei-db/bench/cryptosim/transaction_executor.go @@ -55,11 +55,15 @@ func NewTransactionExecutor( return e } -// Schedule a transaction for execution. -func (e *TransactionExecutor) ScheduleForExecution(txn *transaction) { +// Schedule a run of transactions for execution. +// +// A whole range is handed over in one message rather than one message per transaction: at thousands of +// transactions per block, the channel sends and receives were themselves a measurable share of the main +// thread's time and of this goroutine's. The slice is owned by the block and is only read here. +func (e *TransactionExecutor) ScheduleRange(txns []*transaction) { select { case <-e.ctx.Done(): - case e.workChan <- txn: + case e.workChan <- txns: } } @@ -87,20 +91,14 @@ func (e *TransactionExecutor) mainLoop() { return case request := <-e.workChan: switch request := request.(type) { - case *transaction: + case []*transaction: if e.config.DisableTransactionExecution { continue } - var phaseTimer *metrics.PhaseTimer - if request.ShouldCaptureMetrics() { - phaseTimer = e.phaseTimer - } - - if err := request.Execute(e.database, e.feeCollectionAddress, phaseTimer); err != nil { - log.Printf("transaction execution error: %v", err) - e.cancel() + for _, txn := range request { + e.execute(txn) } case flushRequest: request.doneChan <- struct{}{} @@ -108,3 +106,17 @@ func (e *TransactionExecutor) mainLoop() { } } } + +// execute runs one transaction. A failure stops the benchmark: a transaction that cannot execute means +// the database is not answering, and whatever ran afterwards would not be measuring anything. +func (e *TransactionExecutor) execute(txn *transaction) { + var phaseTimer *metrics.PhaseTimer + if txn.ShouldCaptureMetrics() { + phaseTimer = e.phaseTimer + } + + if err := txn.Execute(e.database, e.feeCollectionAddress, phaseTimer); err != nil { + log.Printf("transaction execution error: %v", err) + e.cancel() + } +} diff --git a/sei-db/bench/cryptosim/transaction_test.go b/sei-db/bench/cryptosim/transaction_test.go index 047988120f..1c486661ad 100644 --- a/sei-db/bench/cryptosim/transaction_test.go +++ b/sei-db/bench/cryptosim/transaction_test.go @@ -68,9 +68,67 @@ func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { require.NoError(t, txn.Execute(db, []byte("fee"), nil)) require.Zero(t, stateDB.view.readCalls) - // The write the transaction made is in the batch, so it is served without reaching the view. - _, found := db.Get([]byte("src")) - require.True(t, found) + // Execute performs no writes at all: a transaction's writes are recorded by the block builder when + // the block is generated, so there is nothing left for this to do but read. + require.Empty(t, db.pendingWrites) +} + +// TestBlockCarriesItsWritesToTheDB covers the handoff the finalize path depends on: writes accumulate +// in the Database, the builder harvests them into a block, and the block yields the changeset with +// nothing left to convert on the commit thread. +func TestBlockCarriesItsWritesToTheDB(t *testing.T) { + t.Parallel() + + cfg := DefaultCryptoSimConfig() + db, err := NewDatabase(cfg, &readTrackingStateDB{view: &readTrackingView{}}, nil, nil) + require.NoError(t, err) + + require.NoError(t, db.Put([]byte("src"), []byte("src-balance"))) + require.NoError(t, db.Put([]byte("dst"), []byte("dst-balance"))) + + // A key written twice in one block collapses to its last write, which is what keeps the changeset + // the size of the key set rather than the write count. + require.NoError(t, db.Put([]byte("src"), []byte("src-balance-again"))) + + harvested := db.HarvestWrites() + require.Len(t, harvested, 2) + require.Empty(t, db.pendingWrites, "harvest must leave a fresh map behind") + + blk := NewBlock(cfg, nil, 0, cfg.TransactionsPerBlock) + blk.SetWrites(harvested) + + require.Len(t, blk.Changeset(), 2) + require.Equal(t, len(blk.Changeset())+counterKeysPerBlock, cap(blk.Changeset()), + "the changeset reserves room for the counter keys FinalizeBlock appends") + + values := make([][]byte, 0, len(blk.Changeset())) + for _, pair := range blk.Changeset() { + values = append(values, pair.Value) + } + require.Contains(t, values, []byte("src-balance-again"), "the last write for a key is the one kept") + require.NotContains(t, values, []byte("src-balance")) +} + +// TestDatabaseReadsAlwaysReachTheDB pins the property the benchmark's fidelity depends on: no read is +// ever served from memory, not even one whose key this block writes. +// +// The regression it guards against is real and shipped once: Get consulted the block's pending writes +// first, and because a transaction reads the same keys it writes, that excluded most of a block's reads +// from the measurement entirely. +func TestDatabaseReadsAlwaysReachTheDB(t *testing.T) { + t.Parallel() + + cfg := DefaultCryptoSimConfig() + stateDB := &readTrackingStateDB{view: &readTrackingView{}} + db, err := NewDatabase(cfg, stateDB, nil, nil) + require.NoError(t, err) + + require.NoError(t, db.Put([]byte("written"), []byte("value"))) + + value, found := db.Get([]byte("written")) + require.Nil(t, value) + require.False(t, found, "the view serves no reads, so a read that reached it cannot have found a value") + require.Equal(t, 1, stateDB.view.readCalls, "the read must have reached the view") } func TestDefaultCryptoSimConfigDisablesTransactionReadsByDefaultFalse(t *testing.T) { diff --git a/sei-db/bench/gigasim/account_model.go b/sei-db/bench/gigasim/account_model.go index 75c124db73..dde00a4ecb 100644 --- a/sei-db/bench/gigasim/account_model.go +++ b/sei-db/bench/gigasim/account_model.go @@ -3,6 +3,7 @@ package gigasim import ( "encoding/binary" "fmt" + "math" "github.com/sei-protocol/sei-chain/sei-db/common/keys" crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" @@ -23,6 +24,10 @@ const ( storageKeyLen = keys.AddressLen + slotLen ) +// selectionPatternCycle is the number of account selections the hot and minting patterns repeat over. +// A share is rounded to this many parts, so it also sets the resolution of those shares. +const selectionPatternCycle = 1_000_000 + // EVM value sizes. These are not configurable: FlatKV parses the value by the key it arrives under and // rejects a write whose length does not match, so a record of any other size never reaches disk. const ( @@ -150,6 +155,10 @@ type accountModel struct { rand *crand.CannedRandom + // How many account selections this model has served. Which kind a selection is follows from this + // count alone, which is what makes the mints of any run of selections computable in advance. + selectionCount int64 + // The identifier the next account created takes, and so also the number of accounts in existence. nextAccountID int64 @@ -253,27 +262,63 @@ func (a *accountModel) CreateErc20Contract() { a.state.Put(address, a.rand.Bytes(a.config.Erc20ContractSize)) } -// RandomAccount selects the account for one side of a transfer, minting a new one with the configured -// probability. The identifier is returned alongside the address because the storage slots a +// RandomAccount selects the account for one side of a transfer, minting a new one on the configured +// share of selections. The identifier is returned alongside the address because the storage slots a // transaction touches are derived from it. // +// Which of the three kinds a selection is follows from its position in the selection sequence rather +// than from a draw, so the accounts any run of selections mints are known before it runs. Which +// account it lands on within the hot set or the cold population is still drawn at random. +// // A dormant account is never returned. Dormant identifiers are not excluded by narrowing the range, // which is what let them be selected before: those minted during a run are interleaved with the hot // and cold ones, so the classes have to be addressed rather than bounded. func (a *accountModel) RandomAccount() (address []byte, accountID int64, err error) { - if a.rand.Float64() < a.config.HotAccountProbability { - return a.selectHot() - } + selection := a.selectionCount + a.selectionCount++ - if a.rand.Float64() < a.config.NewAccountProbability { + // Minting takes precedence over a hot selection where the two coincide. Both patterns run over the + // same counter, so they intersect, and letting the hot selection win there would make the accounts + // a run of selections mints depend on the hot pattern rather than on the run itself. + if a.selectionMintsAccount(selection) { accountID := a.nextAccountID a.nextAccountID++ return a.accountAddress(accountID), accountID, nil } + if a.selectionIsHot(selection) { + return a.selectHot() + } + return a.selectCold() } +// selectionMintsAccount reports whether the selection at the given count mints a new account. +// +// A function of the count alone, so the accounts any span of selections will mint are known before any +// of them run. The minting selections are spread evenly through each cycle of selectionPatternCycle, +// so their share of a cycle is NewAccountProbability at that resolution. +func (a *accountModel) selectionMintsAccount(selection int64) bool { + return selectionInShare(selection, a.config.NewAccountProbability) +} + +// selectionIsHot reports whether the selection at the given count draws from the hot set. +// +// A function of the count alone, like selectionMintsAccount(). A selection that both patterns claim +// mints instead, so the hot share is short by the minting share wherever the two coincide. +func (a *accountModel) selectionIsHot(selection int64) bool { + return selectionInShare(selection, a.config.HotAccountProbability) +} + +// selectionInShare reports whether the selection at the given count falls in a pattern covering the +// given share of every cycle. The selections it claims are spread evenly through the cycle, so any run +// of them carries that share rather than only a long run doing so. +func selectionInShare(selection int64, share float64) bool { + claimed := int64(math.Round(share * selectionPatternCycle)) + position := selection % selectionPatternCycle + return (position+1)*claimed/selectionPatternCycle > position*claimed/selectionPatternCycle +} + // mintedSoFar is how many accounts have been minted at or below the newest identifier selection may // reach. Accounts minted for the block being built are excluded: they may not be committed yet. func (a *accountModel) mintedSoFar() int64 { diff --git a/sei-db/bench/gigasim/block_generator.go b/sei-db/bench/gigasim/block_generator.go index 597a151b85..4c7d0a6d7d 100644 --- a/sei-db/bench/gigasim/block_generator.go +++ b/sei-db/bench/gigasim/block_generator.go @@ -10,6 +10,11 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" ) +// writesPerTransaction is how many keys one transfer writes: both accounts' records and both of their +// ERC20 storage slots. The fee account is written once per block rather than once per transaction, so +// it is not counted here. +const writesPerTransaction = 4 + // simulatedBlock is one block's worth of work: the transactions the execution phase runs, the payload // the block store persists, and the receipts that execution is taken to have produced. type simulatedBlock struct { @@ -32,10 +37,15 @@ type simulatedBlock struct { // the block store holds as opaque bytes. payload [][]byte - // The identifier counters as of this block, committed alongside it so that a resumed run mints - // identifiers where this one stopped. They travel with the block because the account model that - // produced them keeps moving on the generator's goroutine. - counters identifierCounters + // The state changes this block makes, in the form the state DB takes, carrying the identifier + // counters as of this block so that a resumed run mints identifiers where this one stopped. + // + // Staged when the block is generated rather than by the executors: a transaction's written values + // are drawn up front and depend on nothing it reads, so the whole block's writes are known before + // any of it executes. Executing it is then reads alone, and committing it has nothing to convert. + // A real system could not do this; simulating an execution layer's consistency is explicitly not + // what this benchmark measures. + writes blockWrites } // identifierCounters is the account and contract population recorded in state at a given height. @@ -63,6 +73,10 @@ type blockGenerator struct { accounts *accountModel blocks *blockStoreWriter + // Stages the writes of the block being built. Reused across blocks: draining it hands the pairs to + // the block and leaves the batch empty. + batch *stateBatch + // The height the next block generated commits at. next int64 @@ -114,6 +128,7 @@ func newBlockGenerator( config: config, accounts: accounts, blocks: blocks, + batch: newStateBatch(writesPerTransaction*config.TransactionsPerBlock + 1), rateLimiter: rateLimiter, blocksChan: make(chan *simulatedBlock, config.MaxPendingExecutionQueueSize), receiptCache: newReceiptCache(), @@ -215,6 +230,7 @@ func (g *blockGenerator) buildBlock() (*simulatedBlock, error) { } block.transactions[i] = txn block.payload[i] = g.accounts.Rand().Bytes(g.config.BytesPerTransaction) + g.stageTransactionWrites(txn) if receipts != nil { if err := receipts.build(i, g.accounts.Rand(), txn, number); err != nil { @@ -226,12 +242,27 @@ func (g *blockGenerator) buildBlock() (*simulatedBlock, error) { block.receiptBytes = receipts.encodedBytes } + // Staged once, after the transactions, because they all name this one key: every transaction draws + // a fee balance, since the draw is part of the sequence the block's randomness is defined by, but + // only the last draw survives into the block. Staging it per transaction made the same entry + // TransactionsPerBlock times and threw all but one away. + g.batch.Put(g.accounts.FeeCollectionAddress(), transactions[count-1].newFeeBalance) + // Accounts minted for this block become legal read targets once it is complete. g.accounts.ReportEndOfBlock() - block.counters = g.accounts.Counters() + block.writes = g.batch.drainToChangeSet(g.accounts.Counters()) return block, nil } +// stageTransactionWrites stages the writes one transfer makes: both accounts' records and both of their +// ERC20 storage slots. The fee account is staged once per block instead; see buildBlock(). +func (g *blockGenerator) stageTransactionWrites(txn *transaction) { + g.batch.Put(txn.srcAccount, txn.newSrcBalance) + g.batch.Put(txn.dstAccount, txn.newDstBalance) + g.batch.Put(txn.srcAccountSlot, txn.newSrcAccountSlot) + g.batch.Put(txn.dstAccountSlot, txn.newDstAccountSlot) +} + // storeBlock appends a block to the ledger and flushes on the configured cadence. func (g *blockGenerator) storeBlock(block *simulatedBlock) error { // The writer names the record it is on; this closes whichever it ended on, so that these phases diff --git a/sei-db/bench/gigasim/block_generator_test.go b/sei-db/bench/gigasim/block_generator_test.go new file mode 100644 index 0000000000..b316124f3a --- /dev/null +++ b/sei-db/bench/gigasim/block_generator_test.go @@ -0,0 +1,122 @@ +package gigasim + +import ( + "testing" + + "github.com/stretchr/testify/require" + + crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" +) + +// newTestGenerator returns a generator over a populated account model, with everything a block needs +// to be built and nothing it needs to be stored: buildBlock touches neither the block ledger nor the +// stores. +func newTestGenerator(t *testing.T, transactionsPerBlock int) *blockGenerator { + t.Helper() + + config := DefaultGigasimConfig() + config.TransactionsPerBlock = transactionsPerBlock + config.EnableReceiptStore = false + + population := plannedAccountPopulation(config) + accounts := &accountModel{ + config: config, + rand: crand.NewCannedRandom(1<<20, config.Seed), + population: population, + feeAccount: testAccountKey(0), + // A population past setup, so both selection paths have accounts to draw from. + nextAccountID: population.total, + nextErc20ContractID: int64(config.MinimumNumberOfErc20Contracts), + } + accounts.highestSafeAccountID = accounts.nextAccountID - 1 + + return &blockGenerator{ + config: config, + accounts: accounts, + batch: newStateBatch(writesPerTransaction*transactionsPerBlock + 1), + next: 1, + } +} + +// keysWritten is the set of keys a block's transactions write, derived from the transactions rather +// than from the changeset so that the two can be compared. +func keysWritten(blk *simulatedBlock) map[string]bool { + written := map[string]bool{} + for _, txn := range blk.transactions { + written[string(txn.srcAccount)] = true + written[string(txn.dstAccount)] = true + written[string(txn.srcAccountSlot)] = true + written[string(txn.dstAccountSlot)] = true + } + return written +} + +// A generated block carries its whole changeset, so that executing it writes nothing and committing it +// has nothing to convert: one pair per distinct key its transactions touch, the fee account, and the +// identifier counters. +func TestGeneratedBlockCarriesItsChangeset(t *testing.T) { + t.Parallel() + + g := newTestGenerator(t, 64) + blk, err := g.buildBlock() + require.NoError(t, err) + require.Len(t, blk.transactions, g.config.TransactionsPerBlock) + + staged := map[string][]byte{} + for _, pair := range blk.writes.changeSets[0].Changeset.Pairs { + _, duplicate := staged[string(pair.Key)] + require.False(t, duplicate, "key %x is committed twice in one block", pair.Key) + staged[string(pair.Key)] = pair.Value + } + + written := keysWritten(blk) + require.Len(t, staged, len(written)+1+len(counterKeys), + "the changeset holds the transactions' keys, the fee account and the counters") + for key := range written { + require.Contains(t, staged, key) + } + require.Equal(t, encodeCounter(g.accounts.NextAccountID()), staged[string(counterKeys[0])]) + require.Equal(t, encodeCounter(g.accounts.NextErc20ContractID()), staged[string(counterKeys[1])]) +} + +// The fee account is written once per block, not once per transaction. Every transaction still draws a +// fee balance — the draw is part of the random sequence the block is defined by — but they all name one +// key, so only the last draw is written. +func TestGeneratedBlockWritesTheFeeAccountOnce(t *testing.T) { + t.Parallel() + + g := newTestGenerator(t, 8) + feeKey := string(g.accounts.FeeCollectionAddress()) + + blk, err := g.buildBlock() + require.NoError(t, err) + + writes := 0 + var value []byte + for _, pair := range blk.writes.changeSets[0].Changeset.Pairs { + if string(pair.Key) == feeKey { + writes++ + value = pair.Value + } + } + require.Equal(t, 1, writes, "the fee account must be written exactly once per block") + + last := blk.transactions[len(blk.transactions)-1] + require.Equal(t, last.newFeeBalance, value, "the surviving value is the last draw") +} + +// Blocks are built back to back off one reused batch, so a block must carry only its own writes. +func TestEachBlockCarriesOnlyItsOwnWrites(t *testing.T) { + t.Parallel() + + g := newTestGenerator(t, 16) + + first, err := g.buildBlock() + require.NoError(t, err) + second, err := g.buildBlock() + require.NoError(t, err) + + require.Len(t, second.writes.changeSets[0].Changeset.Pairs, + len(keysWritten(second))+1+len(counterKeys)) + require.NotEqual(t, first.number, second.number) +} diff --git a/sei-db/bench/gigasim/execution_state.go b/sei-db/bench/gigasim/execution_state.go index da4ae62a6d..198877af84 100644 --- a/sei-db/bench/gigasim/execution_state.go +++ b/sei-db/bench/gigasim/execution_state.go @@ -6,13 +6,12 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) // executionState is the state DB as the execution phase sees it: a read view of the last committed -// block, and a batch collecting the writes of the block being executed. +// block, and the writes setup stages before generation takes over. // // The state DB itself belongs to the storage manager, which opened it and closes it. Only the view // opened here is released by Close. @@ -25,9 +24,9 @@ type executionState struct { // batch is served from. Replaced after each commit. view gigatypes.StateView - // The writes of the block currently executing, keyed by the raw EVM key. Written concurrently by - // the executors and drained by the main thread at commit. - batch *stateBatch + // The writes setup stages, drained as one block by finalizeSetupBlock. A measured run never uses + // this: the generator stages a block's writes when it builds the block. + setupWrites *stateBatch // Takes one block hash per block committed, so that the benchmark cannot outrun hashing. hashes *blockHashWaiter @@ -58,12 +57,12 @@ func newExecutionState( } return &executionState{ - db: db, - view: view, - batch: newStateBatch(), - hashes: waiter, - lifecycle: lifecycle, - metrics: metrics, + db: db, + view: view, + setupWrites: newStateBatch(config.TransactionsPerBlock), + hashes: waiter, + lifecycle: lifecycle, + metrics: metrics, }, nil } @@ -72,49 +71,54 @@ func (s *executionState) height() int64 { return s.view.GetBlockHeight() } -// Put stages a write for the block currently executing. -// -// Safe to call concurrently with other calls to Put and Get, but not with commitBlock. +// Put stages a write for the setup block being assembled. Setup is the only caller: during a run the +// generator stages a block's writes, so nothing writes through here while the executors are running. func (s *executionState) Put(key []byte, value []byte) { - s.batch.Put(key, value) + s.setupWrites.Put(key, value) +} + +// drainSetupWrites returns what setup has staged as the block to commit, leaving the batch empty for +// the next one. +func (s *executionState) drainSetupWrites(counters identifierCounters) blockWrites { + return s.setupWrites.drainToChangeSet(counters) } -// Get reads a key, answering from the block currently executing before falling back to the view. +// Get reads a key from the newest committed block. // -// Safe to call concurrently with other calls to Put and Get, but not with commitBlock. +// Every read goes to the state DB. There is deliberately nothing in memory in front of it: the read +// throughput of the DB is what this benchmark exists to measure, so a read answered from a map is a +// read that did not get measured. Answering from the block being executed cost most of that +// measurement, because every transaction reads the fee account and every transaction writes it. +// +// Safe to call concurrently with other calls to Get, but not with commitBlock. func (s *executionState) Get(key []byte) ([]byte, bool) { - if value, found := s.batch.Get(key); found { - return value, true - } return s.view.Get(keys.EVMStoreKey, key) } -// commitBlock writes the staged batch to the state DB as blockNum, reopens the read view over it, and -// waits for a block hash once the benchmark is a full lag window ahead of hashing. The identifier -// counters ride along, so that a reopened data directory resumes where the previous run stopped. +// commitBlock writes a block's changeset to the state DB as blockNum, reopens the read view over it, +// and waits for a block hash once the benchmark is a full lag window ahead of hashing. +// +// The changeset arrives already assembled — by the generator during a run, by setup before one — so +// this performs no work of its own ahead of the commit. // // Must not run concurrently with Put or Get. -func (s *executionState) commitBlock(blockNum int64, counters identifierCounters) error { - s.lifecycle.SetPhase("collect_changeset") - changeSets := s.batch.drainToChangeSet(counters) - +func (s *executionState) commitBlock(blockNum int64, writes blockWrites) error { // SC and SS are handed the same changeset, so the volume they take in is the same. SS is reported // only when it is open, which is what makes it fall to zero on a validator's stack rather than // claiming writes nothing performed. - staged := changesetBytes(changeSets) - s.metrics.ReportStoreBytesWritten(storeStateCommit, staged) + s.metrics.ReportStoreBytesWritten(storeStateCommit, writes.bytes) if s.db.SS() != nil { - s.metrics.ReportStoreBytesWritten(storeStateStore, staged) + s.metrics.ReportStoreBytesWritten(storeStateStore, writes.bytes) } // One commit per block: that is the store contract, so the benchmark must not batch. // The state DB splits the commit across the state WAL, SC and SS and times each itself, being the // layer that can tell them apart. Standing down here keeps one commit out of two breakdowns. s.lifecycle.Reset() - if err := s.db.CommitStateChanges(blockNum, changeSets); err != nil { + if err := s.db.CommitStateChanges(blockNum, writes.changeSets); err != nil { return fmt.Errorf("failed to commit block %d to the state DB: %w", blockNum, err) } - s.metrics.ReportStateCommit(int64(len(changeSets[0].Changeset.Pairs))) + s.metrics.ReportStateCommit(int64(len(writes.changeSets[0].Changeset.Pairs))) // Committing a block is not finishing it: the hash of a block committed a bounded number of blocks // ago is taken here, and waited for when hashing has fallen behind execution. Reopening the view @@ -128,18 +132,6 @@ func (s *executionState) commitBlock(blockNum int64, counters identifierCounters return nil } -// changesetBytes is the size of the keys and values a block commits, which is the volume the state WAL, -// SC and SS each take in. -func changesetBytes(changeSets []*proto.NamedChangeSet) int64 { - var total int64 - for _, named := range changeSets { - for _, pair := range named.Changeset.Pairs { - total += int64(len(pair.Key) + len(pair.Value)) - } - } - return total -} - // reopenView replaces the read view with one over the block just committed. A view never observes // writes made after it was opened, so without this every read would keep answering from the height // the benchmark started at. diff --git a/sei-db/bench/gigasim/execution_state_test.go b/sei-db/bench/gigasim/execution_state_test.go new file mode 100644 index 0000000000..f1c26363cb --- /dev/null +++ b/sei-db/bench/gigasim/execution_state_test.go @@ -0,0 +1,69 @@ +package gigasim + +import ( + "testing" + + "github.com/stretchr/testify/require" + + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" +) + +// readCountingView counts the reads it serves and finds nothing, which is what makes a read that was +// answered elsewhere visible. It embeds StateView without implementing it, so a method the tests do not +// expect to be called panics on the nil interface rather than answering with a zero value. +type readCountingView struct { + gigatypes.StateView + + reads int +} + +func (v *readCountingView) Get(_ string, _ []byte) ([]byte, bool) { + v.reads++ + return nil, false +} + +func (v *readCountingView) GetBlockHeight() int64 { return 0 } + +func (v *readCountingView) Close() {} + +// newTestState returns an execution state over a view that serves no reads, which is enough for +// anything that does not commit. +func newTestState() (*executionState, *readCountingView) { + view := &readCountingView{} + return &executionState{view: view, setupWrites: newStateBatch(0)}, view +} + +// TestReadsAlwaysReachTheStateDB pins the property the benchmark's fidelity rests on: no read is +// served from memory, not even one whose key this block writes. +// +// The regression it guards against is real and shipped once: Get answered from the block being +// executed, and because every transaction reads the fee account while every transaction writes it, +// roughly a sixth of a run's reads never reached the DB at all. +func TestReadsAlwaysReachTheStateDB(t *testing.T) { + t.Parallel() + + state, view := newTestState() + key := testAccountKey(1) + state.Put(key, []byte("staged")) + + value, found := state.Get(key) + require.Nil(t, value) + require.False(t, found, "the view serves no reads, so a read that reached it cannot have found one") + require.Equal(t, 1, view.reads, "the read must have reached the view") +} + +// Setup stages its writes through the state, and draining hands them over as the block to commit. +func TestSetupWritesDrainIntoTheBlockToCommit(t *testing.T) { + t.Parallel() + + state, _ := newTestState() + account, slot := testAccountKey(1), testSlotKey(1) + state.Put(account, []byte("account value")) + state.Put(slot, []byte("slot value")) + + writes := state.drainSetupWrites(identifierCounters{nextAccountID: 3, nextErc20ContractID: 4}) + require.Len(t, writes.changeSets, 1) + require.Len(t, writes.changeSets[0].Changeset.Pairs, 2+len(counterKeys)) + + require.Zero(t, state.setupWrites.count(), "draining must leave the batch empty for the next block") +} diff --git a/sei-db/bench/gigasim/gigasim.go b/sei-db/bench/gigasim/gigasim.go index c931977ba2..b9154a527d 100644 --- a/sei-db/bench/gigasim/gigasim.go +++ b/sei-db/bench/gigasim/gigasim.go @@ -401,7 +401,8 @@ func (g *GigaSim) finalizeSetupBlock() error { if err := g.blocks.writeBlock(number, payload); err != nil { return err } - if err := g.persistExecutionResults(number, nil, 0, g.accounts.Counters()); err != nil { + writes := g.state.drainSetupWrites(g.accounts.Counters()) + if err := g.persistExecutionResults(number, nil, 0, writes); err != nil { return err } g.accounts.ReportEndOfBlock() @@ -472,7 +473,9 @@ func (g *GigaSim) halt() { func (g *GigaSim) executeAndRecord(block *simulatedBlock) error { g.executeBlock(block) - if err := g.persistExecutionResults(block.number, block.receiptRecords, block.receiptBytes, block.counters); err != nil { + if err := g.persistExecutionResults( + block.number, block.receiptRecords, block.receiptBytes, block.writes, + ); err != nil { return err } @@ -516,7 +519,7 @@ func (g *GigaSim) persistExecutionResults( number int64, records []receipt.ReceiptRecord, receiptBytes int64, - counters identifierCounters, + writes blockWrites, ) error { if g.receipts != nil { g.lifecycle.SetPhase("write_receipts") @@ -524,7 +527,7 @@ func (g *GigaSim) persistExecutionResults( return err } } - return g.state.commitBlock(number, counters) + return g.state.commitBlock(number, writes) } // awaitGenerator stops block production and drains the staging queue, reporting the error that ended diff --git a/sei-db/bench/gigasim/gigasim_config.go b/sei-db/bench/gigasim/gigasim_config.go index 7cb91f6b00..1a07a70081 100644 --- a/sei-db/bench/gigasim/gigasim_config.go +++ b/sei-db/bench/gigasim/gigasim_config.go @@ -8,6 +8,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/metrics" "github.com/sei-protocol/sei-chain/sei-db/common/utils" "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" autobahn "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" ) @@ -51,11 +52,16 @@ type GigasimConfig struct { // chosen for a transaction; they exist to give the state DB a realistic resident size. MinimumNumberOfDormantAccounts int - // The probability in [0,1] that a transaction picks one of its accounts from the hot set. + // The share in [0,1] of account selections that draw from the hot set. Which selections those are + // follows from their position rather than from a draw, so any run of selections carries this share. + // + // A selection that is also a minting selection mints instead, so the hot share is short by + // NewAccountProbability wherever the two patterns coincide. HotAccountProbability float64 - // The probability in [0,1] that a non-hot account selection creates a new account instead of - // reusing a cold one. + // The share in [0,1] of account selections that mint a new account rather than reusing an existing + // one. Like the hot share, position decides, so the accounts any run of selections mints are known + // before it runs. NewAccountProbability float64 // The share in [0,1] of newly created accounts that join the hot population. @@ -149,6 +155,16 @@ type GigasimConfig struct { // two stores, which nothing else reports. LittMetricsEnabled bool + // If true, the live state DB's read caches record their own instruments: a hit counter, a miss + // counter and a miss latency histogram, all of them per read and reported into by every executor + // thread. At the read rates a measured run drives, what the run measures starts to include the cost + // of measuring it. + // + // The cost of leaving it off is visibility: cache hit rate and cache size are reported by these + // same instruments, so a run configured that way cannot show them. Turn it on for any run whose + // question is about cache behaviour rather than throughput. + ReadCacheMetricsEnabled bool + // If true, pressing Enter in the terminal toggles suspend/resume. EnableSuspension bool @@ -210,6 +226,7 @@ func DefaultGigasimConfig() *GigasimConfig { MetricsAddr: ":9090", BackgroundMetricsScrapeInterval: 60, LittMetricsEnabled: true, + ReadCacheMetricsEnabled: false, EnableSuspension: true, LogDir: "logs", LogLevel: "info", @@ -236,6 +253,15 @@ func (c *GigasimConfig) storageConfig() (*config.GigaStorageConfig, error) { storage.BlockDBConfig.Litt.MetricsEnabled = c.LittMetricsEnabled storage.ReceiptDBConfig.LittMetricsEnabled = c.LittMetricsEnabled + for _, store := range []*view.ViewManagerConfig{ + &storage.FlatKVConfig.AccountStoreConfig, + &storage.FlatKVConfig.CodeStoreConfig, + &storage.FlatKVConfig.StorageStoreConfig, + &storage.FlatKVConfig.MiscStoreConfig, + } { + store.MetricsEnabled = c.ReadCacheMetricsEnabled + } + storage.PruningConfig.RollbackWindow = c.RollbackWindow storage.PruningConfig.LookbackWindow = c.LookbackWindow storage.PruningConfig.PruneInterval = time.Duration(c.PruneIntervalSeconds) * time.Second diff --git a/sei-db/bench/gigasim/state_batch.go b/sei-db/bench/gigasim/state_batch.go index b3725468a7..1a4a609135 100644 --- a/sei-db/bench/gigasim/state_batch.go +++ b/sei-db/bench/gigasim/state_batch.go @@ -2,7 +2,6 @@ package gigasim import ( "fmt" - "sync" "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -12,11 +11,6 @@ import ( // a slot after the one-byte EVM prefix. const maxStagedKeyLen = 1 + storageKeyLen -// batchShards is how many independently locked maps the staged writes are spread across, which keeps the -// executor pool from serialising on a single lock. It must not exceed 256, the range of the key byte the -// shard is chosen by. -const batchShards = 256 - // stagedKey is an EVM key held by value, so that using it as a map key costs no allocation. type stagedKey struct { length uint8 @@ -41,93 +35,69 @@ type stagedWrite struct { value []byte } -// batchShard is one independently locked slice of the staged writes. It is padded out to a cache line -// so that an executor taking one shard's lock does not invalidate a neighbouring shard for another. -type batchShard struct { - mu sync.Mutex - entries map[stagedKey]stagedWrite - _ [40]byte -} +// blockWrites is one block's state changes in the form the state DB takes, along with the volume of +// key and value bytes they carry. +type blockWrites struct { + changeSets []*proto.NamedChangeSet -// stateBatch collects the writes of the block being executed, keyed so that a key written twice in one -// block commits once. The executors fill it concurrently and the main thread drains it at commit. -type stateBatch struct { - shards [batchShards]batchShard + // The bytes the state WAL, SC and SS each take in, counted while the changeset was assembled so + // that the commit thread does not walk the pairs again to find out. + bytes int64 } -// newStateBatch returns an empty batch with every shard ready to accept writes. -func newStateBatch() *stateBatch { - batch := &stateBatch{} - for i := range batch.shards { - batch.shards[i].entries = make(map[stagedKey]stagedWrite) - } - return batch +// stateBatch collects the writes of one block, keyed so that a key written twice in one block commits +// once. +// +// It carries no synchronization, because it never has more than one writer: the generator stages the +// block it is building, and setup stages on the main thread. Nothing writes here during execution — a +// block's writes are known when it is generated, so they are staged then. +type stateBatch struct { + entries map[stagedKey]stagedWrite } -// shardFor picks a key's shard from its last byte, which holds random data for every key the benchmark -// stages and so spreads keys evenly. -func (b *stateBatch) shardFor(key []byte) *batchShard { - return &b.shards[uint(key[len(key)-1])%batchShards] +// newStateBatch returns an empty batch sized for the number of distinct keys one block writes. +func newStateBatch(expectedWrites int) *stateBatch { + return &stateBatch{entries: make(map[stagedKey]stagedWrite, expectedWrites)} } // Put stages a write, replacing any earlier write to the same key. -// -// Safe to call concurrently with Put and Get, but not with drainToChangeSet. func (b *stateBatch) Put(key []byte, value []byte) { - shard := b.shardFor(key) - shard.mu.Lock() - shard.entries[newStagedKey(key)] = stagedWrite{key: key, value: value} - shard.mu.Unlock() -} - -// Get returns a staged write, reporting false when the block being executed has not written the key. -// -// Safe to call concurrently with Put and Get, but not with drainToChangeSet. -func (b *stateBatch) Get(key []byte) ([]byte, bool) { - shard := b.shardFor(key) - shard.mu.Lock() - write, found := shard.entries[newStagedKey(key)] - shard.mu.Unlock() - return write.value, found + b.entries[newStagedKey(key)] = stagedWrite{key: key, value: value} } // drainToChangeSet empties the batch into a single changeset over the EVM store, appending the // identifier counters that ride along with every block. -// -// Must not run concurrently with Put or Get. -func (b *stateBatch) drainToChangeSet(counters identifierCounters) []*proto.NamedChangeSet { +func (b *stateBatch) drainToChangeSet(counters identifierCounters) blockWrites { total := b.count() + len(counterKeys) pairs := make([]proto.KVPair, total) pointers := make([]*proto.KVPair, total) next := 0 + var staged int64 stage := func(key []byte, value []byte) { pairs[next] = proto.KVPair{Key: key, Value: value} pointers[next] = &pairs[next] + staged += int64(len(key) + len(value)) next++ } - for i := range b.shards { - entries := b.shards[i].entries - for _, write := range entries { - stage(write.key, write.value) - } - clear(entries) + for _, write := range b.entries { + stage(write.key, write.value) } + clear(b.entries) stage(counterKeys[0], encodeCounter(counters.nextAccountID)) stage(counterKeys[1], encodeCounter(counters.nextErc20ContractID)) - return []*proto.NamedChangeSet{{ - Name: keys.EVMStoreKey, - Changeset: proto.ChangeSet{Pairs: pointers}, - }} + return blockWrites{ + changeSets: []*proto.NamedChangeSet{{ + Name: keys.EVMStoreKey, + Changeset: proto.ChangeSet{Pairs: pointers}, + }}, + bytes: staged, + } } -// count returns how many distinct keys the block being executed has written. +// count returns how many distinct keys the batch holds. func (b *stateBatch) count() int { - total := 0 - for i := range b.shards { - total += len(b.shards[i].entries) - } - return total + return len(b.entries) } diff --git a/sei-db/bench/gigasim/state_batch_test.go b/sei-db/bench/gigasim/state_batch_test.go index 6506fc8380..772cfec094 100644 --- a/sei-db/bench/gigasim/state_batch_test.go +++ b/sei-db/bench/gigasim/state_batch_test.go @@ -3,8 +3,6 @@ package gigasim import ( "encoding/binary" "fmt" - "runtime" - "sync" "testing" "github.com/stretchr/testify/require" @@ -13,8 +11,7 @@ import ( ) // testAccountKey builds the shorter of the two key lengths the batch stages. The identifier goes at -// the end so that distinct identifiers give distinct keys and spread across the shards, which are -// chosen by a key's last byte. +// the end so that distinct identifiers give distinct keys. func testAccountKey(id int) []byte { address := make([]byte, keys.AddressLen) binary.BigEndian.PutUint64(address[keys.AddressLen-8:], uint64(id)) @@ -32,12 +29,12 @@ func testSlotKey(id int) []byte { // also asserts the changeset's shape: one entry, over the EVM store. func stagedPairs(t *testing.T, batch *stateBatch, counters identifierCounters) map[string][]byte { t.Helper() - changeSets := batch.drainToChangeSet(counters) - require.Len(t, changeSets, 1) - require.Equal(t, keys.EVMStoreKey, changeSets[0].Name) + writes := batch.drainToChangeSet(counters) + require.Len(t, writes.changeSets, 1) + require.Equal(t, keys.EVMStoreKey, writes.changeSets[0].Name) staged := map[string][]byte{} - for _, pair := range changeSets[0].Changeset.Pairs { + for _, pair := range writes.changeSets[0].Changeset.Pairs { _, duplicate := staged[string(pair.Key)] require.False(t, duplicate, "key %x was committed twice in one block", pair.Key) staged[string(pair.Key)] = pair.Value @@ -45,51 +42,30 @@ func stagedPairs(t *testing.T, batch *stateBatch, counters identifierCounters) m return staged } -// A read of a key the block has written is served from the batch, and a read of one it has not is -// reported as missing so that the caller falls through to the committed view. -func TestBatchServesWhatTheBlockHasWritten(t *testing.T) { - batch := newStateBatch() - - account, slot := testAccountKey(1), testSlotKey(1) - batch.Put(account, []byte("account value")) - batch.Put(slot, []byte("slot value")) - - value, found := batch.Get(account) - require.True(t, found) - require.Equal(t, []byte("account value"), value) - - value, found = batch.Get(slot) - require.True(t, found) - require.Equal(t, []byte("slot value"), value) - - _, found = batch.Get(testAccountKey(2)) - require.False(t, found) -} - // Keys are held by value in a fixed-width array, so two keys that share a prefix and differ only in // length have to stay distinct rather than colliding on the padding. func TestBatchKeepsKeysOfDifferentLengthsApart(t *testing.T) { - batch := newStateBatch() + t.Parallel() + + batch := newStateBatch(2) short := keys.BuildEVMKey(accountKeyPrefix, make([]byte, keys.AddressLen)) long := keys.BuildEVMKey(keys.EVMKeyStorage, make([]byte, storageKeyLen)) batch.Put(short, []byte("short")) batch.Put(long, []byte("long")) - - value, found := batch.Get(short) - require.True(t, found) - require.Equal(t, []byte("short"), value) - - value, found = batch.Get(long) - require.True(t, found) - require.Equal(t, []byte("long"), value) require.Equal(t, 2, batch.count()) + + staged := stagedPairs(t, batch, identifierCounters{}) + require.Equal(t, []byte("short"), staged[string(short)]) + require.Equal(t, []byte("long"), staged[string(long)]) } // A key written more than once in a block commits once, holding the last value written. func TestBatchCommitsARewrittenKeyOnce(t *testing.T) { - batch := newStateBatch() + t.Parallel() + + batch := newStateBatch(1) account := testAccountKey(1) batch.Put(account, []byte("first")) @@ -103,9 +79,11 @@ func TestBatchCommitsARewrittenKeyOnce(t *testing.T) { // Draining commits every staged write together with the identifier counters, and leaves the batch // empty for the next block. func TestDrainCommitsEveryWriteAndEmptiesTheBatch(t *testing.T) { - batch := newStateBatch() + t.Parallel() const written = 500 + batch := newStateBatch(2 * written) + for i := range written { batch.Put(testAccountKey(i), []byte(fmt.Sprintf("account %d", i))) batch.Put(testSlotKey(i), []byte(fmt.Sprintf("slot %d", i))) @@ -124,44 +102,32 @@ func TestDrainCommitsEveryWriteAndEmptiesTheBatch(t *testing.T) { require.Len(t, stagedPairs(t, batch, identifierCounters{}), len(counterKeys)) } -// The executors write to the batch concurrently, so every write made by the pool has to survive into -// the commit regardless of which shard it landed on. -func TestBatchKeepsEveryConcurrentWrite(t *testing.T) { - batch := newStateBatch() +// The volume a block commits is counted as its changeset is assembled, which is what keeps the commit +// thread from walking the pairs again to find it. It has to be the whole block: every key and every +// value, the identifier counters included. +func TestDrainCountsEveryByteItStaged(t *testing.T) { + t.Parallel() - const ( - workers = 16 - writesPerWorer = 200 - ) - var wg sync.WaitGroup - for worker := range workers { - wg.Add(1) - go func() { - defer wg.Done() - for i := range writesPerWorer { - id := worker*writesPerWorer + i - batch.Put(testAccountKey(id), []byte(fmt.Sprintf("%d", id))) - batch.Get(testAccountKey(id)) - } - }() - } - wg.Wait() + batch := newStateBatch(2) + account, slot := testAccountKey(1), testSlotKey(1) + batch.Put(account, []byte("account value")) + batch.Put(slot, []byte("slot value")) - staged := stagedPairs(t, batch, identifierCounters{}) - require.Len(t, staged, workers*writesPerWorer+len(counterKeys)) - for id := range workers * writesPerWorer { - require.Equal(t, []byte(fmt.Sprintf("%d", id)), staged[string(testAccountKey(id))]) + writes := batch.drainToChangeSet(identifierCounters{}) + + var expected int64 + for _, pair := range writes.changeSets[0].Changeset.Pairs { + expected += int64(len(pair.Key) + len(pair.Value)) } + require.Equal(t, expected, writes.bytes) } -// BenchmarkStateBatch drives the batch the way a block does: the pool executes the block's -// transactions concurrently, then a single drain. It reports the cost the benchmark harness adds to -// every block, which is the part of a measurement that is not the storage engine. +// BenchmarkStateBatch drives the batch the way a block does: the generator stages every write as it +// builds the block, then drains it once. It reports the cost the benchmark harness adds to every +// block, which is the part of a measurement that is not the storage engine. func BenchmarkStateBatch(b *testing.B) { const ( transactionsPerBlock = 500 - readsPerTransaction = 6 - writesPerTransaction = 5 hotAccounts = 100 population = 4096 ) @@ -183,35 +149,20 @@ func BenchmarkStateBatch(b *testing.B) { return list[n%len(list)] } - batch := newStateBatch() - workers := max(1, runtime.NumCPU()*2) - share := transactionsPerBlock / workers - var wg sync.WaitGroup + batch := newStateBatch(writesPerTransaction*transactionsPerBlock + 1) b.ReportAllocs() b.ResetTimer() for block := 0; block < b.N; block++ { - for worker := range workers { - wg.Add(1) - go func() { - defer wg.Done() - for t := range share { - n := block*transactionsPerBlock + worker*share + t - src, dst := pick(accountKeys, n), pick(accountKeys, n+1) - srcSlot, dstSlot := pick(slotKeys, n), pick(slotKeys, n+1) - - for range readsPerTransaction { - batch.Get(src) - } - batch.Put(src, value) - batch.Put(dst, value) - batch.Put(srcSlot, value) - batch.Put(dstSlot, value) - batch.Put(pick(accountKeys, n+2), value) - } - }() + for t := range transactionsPerBlock { + n := block*transactionsPerBlock + t + batch.Put(pick(accountKeys, n), value) + batch.Put(pick(accountKeys, n+1), value) + batch.Put(pick(slotKeys, n), value) + batch.Put(pick(slotKeys, n+1), value) } - wg.Wait() + // The fee account is one write per block, whatever the transaction count. + batch.Put(pick(accountKeys, 0), value) batch.drainToChangeSet(identifierCounters{}) } } diff --git a/sei-db/bench/gigasim/transaction.go b/sei-db/bench/gigasim/transaction.go index 78de8ffc04..edf7281df7 100644 --- a/sei-db/bench/gigasim/transaction.go +++ b/sei-db/bench/gigasim/transaction.go @@ -7,8 +7,8 @@ import ( ) // transaction is one simulated ERC20 transfer: the keys it touches and the values it writes, all -// resolved up front so that executing it is nothing but the reads and writes a real transfer would -// make against state. +// resolved up front so that executing it is nothing but the reads a real transfer would make against +// state. Its writes are staged when the block is generated; see blockGenerator.buildBlock(). type transaction struct { // The ERC20 contract's code, which is read. erc20Contract []byte @@ -99,12 +99,9 @@ func (txn *transaction) execute(state *executionState, feeAccount []byte, phaseT phaseTimer.SetPhase("read_fee_account") state.Get(feeAccount) - phaseTimer.SetPhase("update_balances") - state.Put(txn.srcAccount, txn.newSrcBalance) - state.Put(txn.dstAccount, txn.newDstBalance) - state.Put(txn.srcAccountSlot, txn.newSrcAccountSlot) - state.Put(txn.dstAccountSlot, txn.newDstAccountSlot) - state.Put(feeAccount, txn.newFeeBalance) - + // The writes this transfer makes — both accounts' records, both storage slots, and the block's + // single fee account write — were staged when the block was generated, so there is nothing to write + // here. Their values are drawn up front and depend on nothing that was just read, so issuing them + // on this thread only took time away from the reads, which are what is under measurement. phaseTimer.Reset() } diff --git a/sei-db/bench/gigasim/transaction_test.go b/sei-db/bench/gigasim/transaction_test.go new file mode 100644 index 0000000000..7b43dc14ca --- /dev/null +++ b/sei-db/bench/gigasim/transaction_test.go @@ -0,0 +1,37 @@ +package gigasim + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// readsPerTransaction is what a transfer reads: the contract code, both accounts, both storage slots, +// and the fee account. +const readsPerTransaction = 6 + +// TestExecuteOnlyReads pins where a block's writes come from. They are staged when the block is +// generated, so execution is reads alone — issuing them here instead took time away from the reads, +// which are what is under measurement. +func TestExecuteOnlyReads(t *testing.T) { + t.Parallel() + + state, view := newTestState() + txn := &transaction{ + erc20Contract: []byte("erc20"), + srcAccount: testAccountKey(1), + dstAccount: testAccountKey(2), + srcAccountSlot: testSlotKey(1), + dstAccountSlot: testSlotKey(2), + newSrcBalance: []byte("src balance"), + newDstBalance: []byte("dst balance"), + newFeeBalance: []byte("fee balance"), + newSrcAccountSlot: []byte("src slot value"), + newDstAccountSlot: []byte("dst slot value"), + } + + txn.execute(state, testAccountKey(0), nil) + + require.Equal(t, readsPerTransaction, view.reads) + require.Zero(t, state.setupWrites.count(), "execution must stage no writes") +} diff --git a/sei-db/common/rand/canned_random.go b/sei-db/common/rand/canned_random.go index 55c8640ddc..4f28e339d3 100644 --- a/sei-db/common/rand/canned_random.go +++ b/sei-db/common/rand/canned_random.go @@ -80,6 +80,15 @@ func (cr *CannedRandom) Clone(randomizeOffset bool) *CannedRandom { } } +// SeekTo moves the read position to one derived from key, so the sequence that follows is a function of +// key alone rather than of everything read before it. +// +// Use this to make a unit of work's randomness depend on which unit it is: the same key replays the same +// sequence however the work was divided up, and two keys that differ read from unrelated positions. +func (cr *CannedRandom) SeekTo(key int64) { + cr.index = utils.PositiveHash64(key) % int64(len(cr.buffer)) +} + // Reset the index of the CannedRandom to the beginning of the buffer. func (cr *CannedRandom) Reset() { cr.index = 0