From 247833d9176dbb9d77f372eb9645678d76d1e3c5 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 15 Jun 2026 15:40:53 -0700 Subject: [PATCH 1/5] feat(generator): wire key/size/op distributions into StorageRW (PLT-465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn StorageRW into the contention + size axes: slot = keyDist.SampleIndex( recordcount), op (read/write/rmw) by configured proportions, calldata pad = sizeDist bucket draw — each on an INDEPENDENT seeded sub-stream so changing one axis never perturbs another. Nil-guarded like the gas-picker: no distribution config => fixed slot 0 / rmw / empty pad, byte-identical to the PLT-461 scaffold (consumes zero randomness, account cadence untouched). Adds one append-only sub-stream id (dist:%d:op) to the FROZEN set per the append-only derivation rule; pad gas added as intrinsic EIP-2028 cost so it can't underprovision. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 23 +++ config/operation.go | 51 ++++++ config/operation_test.go | 61 ++++++ generator/generator.go | 3 + generator/scenarios/StorageRW.go | 91 +++++++-- generator/scenarios/StorageRW_test.go | 255 ++++++++++++++++++++++++++ generator/scenarios/doc.go | 28 +-- utils/rng/rng.go | 3 +- utils/rng/rng_test.go | 19 ++ utils/rng/streams.go | 5 + 10 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 config/operation.go create mode 100644 config/operation_test.go diff --git a/config/config.go b/config/config.go index 938a8e4..ebcaece 100644 --- a/config/config.go +++ b/config/config.go @@ -81,4 +81,27 @@ type Scenario struct { GasTipCapPicker *GasPicker `json:"gasTipCapPicker,omitempty"` KeyDistribution *Distribution `json:"keyDistribution,omitempty"` SizeDistribution *Distribution `json:"sizeDistribution,omitempty"` + // RecordCount is the keyspace size the KeyDistribution indexes into: the + // per-tx slot is a draw in [0, RecordCount). Zero (the default) is the + // single-slot, 100%-conflict scaffold behavior. + RecordCount uint64 `json:"recordCount,omitempty"` + // SizeBuckets is the calldata-pad-length histogram the SizeDistribution + // indexes into: the per-tx pad length is SizeBuckets[draw]. Empty (the + // default) is the empty-pad scaffold behavior. + SizeBuckets []int `json:"sizeBuckets,omitempty"` + // Operations is the read/write/rmw selection mix. Nil (the default) is the + // all-rmw scaffold behavior. + Operations *OperationMix `json:"operations,omitempty"` +} + +// OperationMix is the relative weighting of the StorageRW read/write/rmw +// operations. The weights need not sum to anything in particular; a per-tx draw +// selects an operation in proportion to its weight over the total. An all-zero +// (or nil) mix falls back to rmw, the scaffold default. +type OperationMix struct { + Read uint64 `json:"read,omitempty"` + Write uint64 `json:"write,omitempty"` + Rmw uint64 `json:"rmw,omitempty"` + + holder opStreamHolder } diff --git a/config/operation.go b/config/operation.go new file mode 100644 index 0000000..910b516 --- /dev/null +++ b/config/operation.go @@ -0,0 +1,51 @@ +package config + +import ( + "math/rand/v2" + + "github.com/sei-protocol/sei-load/utils/rng" +) + +// Operation identifies one StorageRW contract method. +type Operation uint8 + +const ( + // OpRmw is the read-modify-write operation; it is the zero value so a + // zero/nil OperationMix selects rmw, matching the scaffold default. + OpRmw Operation = iota + OpRead + OpWrite +) + +// stream is set by SetStream; nil means draw from the unseeded global RNG. The +// pointer aliases on copy, matching GasPicker/Distribution. +type opStreamHolder struct { + stream *rng.Stream +} + +// SetStream binds the selector to a deterministic sub-stream (nil = unseeded +// global RNG), mirroring GasPicker.SetStream and Distribution.SetStream. +func (m *OperationMix) SetStream(s *rng.Stream) { m.holder.stream = s } + +// Select draws one operation in proportion to the configured weights. A zero +// total (all weights zero) falls back to OpRmw so an empty mix is the scaffold +// default rather than a panic. +func (m *OperationMix) Select() Operation { + total := m.Read + m.Write + m.Rmw + if total == 0 { + return OpRmw + } + var u uint64 + if m.holder.stream != nil { + u = m.holder.stream.Uint64N(total) + } else { + u = rand.Uint64N(total) + } + if u < m.Rmw { + return OpRmw + } + if u < m.Rmw+m.Read { + return OpRead + } + return OpWrite +} diff --git a/config/operation_test.go b/config/operation_test.go new file mode 100644 index 0000000..4cc2e6b --- /dev/null +++ b/config/operation_test.go @@ -0,0 +1,61 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/utils/rng" +) + +// TestOperationMixEmptyFallsBackToRmw: a zero-weight (or nil-equivalent) mix +// selects rmw, the scaffold default, rather than panicking on a zero total. +func TestOperationMixEmptyFallsBackToRmw(t *testing.T) { + t.Parallel() + var m config.OperationMix + m.SetStream(rng.NewSource(1).Stream(rng.OpDistributionStream(0))) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRmw, m.Select()) + } +} + +// TestOperationMixHonorsWeights: a single-weighted op is selected exclusively, +// and a balanced mix selects all three. +func TestOperationMixHonorsWeights(t *testing.T) { + t.Parallel() + t.Run("single", func(t *testing.T) { + m := config.OperationMix{Read: 1} + m.SetStream(rng.NewSource(1).Stream(rng.OpDistributionStream(0))) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRead, m.Select()) + } + }) + t.Run("balanced", func(t *testing.T) { + m := config.OperationMix{Read: 1, Write: 1, Rmw: 1} + m.SetStream(rng.NewSource(1).Stream(rng.OpDistributionStream(0))) + seen := map[config.Operation]int{} + for i := 0; i < 3000; i++ { + seen[m.Select()]++ + } + require.Positive(t, seen[config.OpRead]) + require.Positive(t, seen[config.OpWrite]) + require.Positive(t, seen[config.OpRmw]) + }) +} + +// TestOperationMixDeterminism: same seed + same stream id reproduces the +// selection sequence. +func TestOperationMixDeterminism(t *testing.T) { + t.Parallel() + draw := func() []config.Operation { + m := config.OperationMix{Read: 2, Write: 3, Rmw: 5} + m.SetStream(rng.NewSource(99).Stream(rng.OpDistributionStream(0))) + out := make([]config.Operation, 256) + for i := range out { + out[i] = m.Select() + } + return out + } + require.Equal(t, draw(), draw()) +} diff --git a/generator/generator.go b/generator/generator.go index 7a8b04d..a60c1c1 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -152,6 +152,9 @@ func (g *configBasedGenerator) bindDistributionStreams(i int, cfg config.Scenari if cfg.SizeDistribution != nil { cfg.SizeDistribution.SetStream(g.rng.Stream(rng.SizeDistributionStream(i))) } + if cfg.Operations != nil { + cfg.Operations.SetStream(g.rng.Stream(rng.OpDistributionStream(i))) + } } // mockDeployAll deploys all scenario instances that require deployment (for unit tests). diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index e8d5a03..a598a38 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -15,12 +15,23 @@ import ( const StorageRW = "storagerw" -// Fixed slot and empty pad for the scaffold; PLT-465 makes these per-tx. -var ( - storageRWSlot = big.NewInt(0) - storageRWPad = []byte{} +const ( + // storageRWBaseGas covers the cold-first-touch rmw (cold SLOAD + zero->nonzero + // SSTORE, ~44k) plus the fixed calldata head, with headroom. The distribution- + // driven pad's intrinsic cost is added on top per-tx; see package doc. + storageRWBaseGas = 50000 + // calldataZeroByteGas is the EIP-2028 intrinsic cost of one zero calldata byte. + // The pad is a zero-filled slice, so each pad byte costs exactly this. + calldataZeroByteGas = 4 + // storageRWWriteValue is the constant value write() stores; the load contract + // never asserts on it. + storageRWWriteValue = 1 ) +// storageRWDefaultSlot is the single slot every tx targets when no key +// distribution is configured (the scaffold's 100%-conflict default). +var storageRWDefaultSlot = big.NewInt(0) + // StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations type StorageRWScenario struct { *ContractScenarioBase[bindings.StorageRWv1] @@ -77,12 +88,70 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add return err } -// CreateContractTransaction implements ContractDeployer interface - creates a -// fixed StorageRWv1 rmw transaction. See package doc for the scaffold and gas -// rationale. +// CreateContractTransaction implements ContractDeployer interface - builds one +// StorageRWv1 transaction whose slot (key contention), operation, and calldata +// pad (tx size) are drawn from the configured distributions. With no +// distribution config it reproduces the scaffold's single-slot empty-pad rmw. +// See package doc for the gas rationale. func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - // 50k fits rmw (SLOAD+SSTORE) with headroom; see package doc for sizing. - // PLT-465 revisits with the distribution-driven pad. - auth.GasLimit = 50000 - return s.contract.Rmw(auth, storageRWSlot, storageRWPad) + slot, err := s.pickSlot() + if err != nil { + return nil, err + } + pad, err := s.pickPad() + if err != nil { + return nil, err + } + + // The pad's intrinsic calldata cost is the only gas the base does not already + // cover; add it so a large pad cannot underprovision the tx. + auth.GasLimit = storageRWBaseGas + uint64(len(pad))*calldataZeroByteGas + + switch s.pickOp() { + case config.OpRead: + return s.contract.Read(auth, slot, pad) + case config.OpWrite: + return s.contract.Write(auth, slot, big.NewInt(storageRWWriteValue), pad) + default: + return s.contract.Rmw(auth, slot, pad) + } +} + +// pickSlot draws the storage slot from the key distribution over the configured +// RecordCount keyspace. With no key distribution it returns the fixed default +// slot, preserving the scaffold's 100%-conflict behavior. +func (s *StorageRWScenario) pickSlot() (*big.Int, error) { + cfg := s.scenarioConfig + if cfg.KeyDistribution == nil || cfg.RecordCount == 0 { + return storageRWDefaultSlot, nil + } + idx, err := cfg.KeyDistribution.SampleIndex(cfg.RecordCount) + if err != nil { + return nil, err + } + return new(big.Int).SetUint64(idx), nil +} + +// pickPad draws the calldata pad length from the size distribution over the +// configured SizeBuckets histogram, on a sub-stream independent of the key draw. +// With no size distribution it returns an empty pad, preserving the scaffold. +func (s *StorageRWScenario) pickPad() ([]byte, error) { + cfg := s.scenarioConfig + if cfg.SizeDistribution == nil || len(cfg.SizeBuckets) == 0 { + return nil, nil + } + bucket, err := cfg.SizeDistribution.SampleIndex(uint64(len(cfg.SizeBuckets))) + if err != nil { + return nil, err + } + return make([]byte, cfg.SizeBuckets[bucket]), nil +} + +// pickOp selects read/write/rmw from the configured mix on its own independent +// sub-stream. With no mix it returns rmw, preserving the scaffold. +func (s *StorageRWScenario) pickOp() config.Operation { + if s.scenarioConfig.Operations == nil { + return config.OpRmw + } + return s.scenarioConfig.Operations.Select() } diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 5ddde6e..a48141c 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -1,14 +1,18 @@ package scenarios_test import ( + "encoding/json" + "math/big" "testing" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/bindings" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils/rng" ) // rmwSelector is the 4-byte function selector for StorageRWv1.rmw(uint256,bytes). @@ -74,3 +78,254 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { require.NoError(t, err) require.Equal(t, rmwSelector, parsed.Methods["rmw"].ID) } + +// storageRWABI returns the parsed StorageRWv1 ABI for calldata decoding. +func storageRWABI(t *testing.T) *abi.ABI { + t.Helper() + parsed, err := bindings.StorageRWv1MetaData.GetAbi() + require.NoError(t, err) + return parsed +} + +// decodeStorageRWTx decodes one produced tx into (method name, slot, pad-length). +// It identifies the method by its 4-byte selector and unpacks the operands so a +// test can assert on the slot draw and pad size without reimplementing ABI rules. +func decodeStorageRWTx(t *testing.T, parsed *abi.ABI, data []byte) (string, uint64, int) { + t.Helper() + require.GreaterOrEqual(t, len(data), 4) + method, err := parsed.MethodById(data[:4]) + require.NoError(t, err) + args, err := method.Inputs.Unpack(data[4:]) + require.NoError(t, err) + slot := args[0].(*big.Int) + pad := args[len(args)-1].([]byte) // _pad is always the trailing operand. + return method.Name, slot.Uint64(), len(pad) +} + +// newConfiguredStorageRW builds a mock-deployed StorageRW scenario from cfg and +// binds its distribution streams the way generator.bindDistributionStreams does, +// so the produced txs are seeded deterministically. +func newConfiguredStorageRW(t *testing.T, seed uint64, cfg config.Scenario) scenarios.TxGenerator { + t.Helper() + cfg.Name = scenarios.StorageRW + src := rng.NewSource(seed) + if cfg.KeyDistribution != nil { + cfg.KeyDistribution.SetStream(src.Stream(rng.KeyDistributionStream(0))) + } + if cfg.SizeDistribution != nil { + cfg.SizeDistribution.SetStream(src.Stream(rng.SizeDistributionStream(0))) + } + if cfg.Operations != nil { + cfg.Operations.SetStream(src.Stream(rng.OpDistributionStream(0))) + } + gen := scenarios.CreateScenario(cfg) + loadCfg := &config.LoadConfig{ChainID: 7777, MockDeploy: true, Endpoints: []string{"http://localhost:8545"}} + require.NoError(t, gen.Attach(loadCfg, types.GenerateAccounts(1)[0].Address)) + return gen +} + +// drawSlots produces count txs and returns the slot drawn for each. +func drawSlots(t *testing.T, gen scenarios.TxGenerator, parsed *abi.ABI, count int) []uint64 { + t.Helper() + sender := types.GenerateAccounts(1)[0] + slots := make([]uint64, count) + for i := range slots { + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + _, slot, _ := decodeStorageRWTx(t, parsed, tx.EthTx.Data()) + slots[i] = slot + } + return slots +} + +func uniformDist(t *testing.T) *config.Distribution { + t.Helper() + var d config.Distribution + require.NoError(t, d.UnmarshalJSON([]byte(`{"Name":"uniform"}`))) + return &d +} + +// TestStorageRWContentionSweep pins the contention continuum at its two ends: a +// uniform draw over a huge keyspace collides almost never, and the default +// single-slot config collides always. The assertion is on the generator's slot +// draw, not on-chain state. +func TestStorageRWContentionSweep(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + const draws = 2000 + + t.Run("huge_uniform_keyspace_near_zero_collision", func(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 1, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 1_000_000, + }) + slots := drawSlots(t, gen, parsed, draws) + seen := make(map[uint64]int, draws) + for _, s := range slots { + seen[s]++ + } + // Birthday collisions over a 1e6 keyspace with 2000 draws are a handful; + // require >99% distinct so a regression that collapsed the keyspace fails. + require.Greater(t, len(seen), int(float64(draws)*0.99), + "uniform over a huge keyspace must barely collide; got %d distinct of %d", len(seen), draws) + }) + + t.Run("single_slot_full_collision", func(t *testing.T) { + t.Parallel() + // No key distribution => scaffold single-slot default: every draw is slot 0. + gen := newConfiguredStorageRW(t, 1, config.Scenario{}) + slots := drawSlots(t, gen, parsed, draws) + for _, s := range slots { + require.Zero(t, s, "default config must target the single fixed slot") + } + }) +} + +// TestStorageRWSizeBuckets proves the size distribution selects calldata pad +// lengths from the configured bucket histogram and only from those buckets. +func TestStorageRWSizeBuckets(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + buckets := []int{0, 64, 256, 1024} + allowed := make(map[int]bool, len(buckets)) + for _, b := range buckets { + allowed[b] = true + } + + gen := newConfiguredStorageRW(t, 7, config.Scenario{ + SizeDistribution: uniformDist(t), + SizeBuckets: buckets, + }) + + sender := types.GenerateAccounts(1)[0] + const draws = 4000 + hits := make(map[int]int, len(buckets)) + for i := 0; i < draws; i++ { + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + _, _, padLen := decodeStorageRWTx(t, parsed, tx.EthTx.Data()) + require.True(t, allowed[padLen], "pad length %d not in configured buckets", padLen) + hits[padLen]++ + } + // Uniform over 4 buckets: each should be well-represented (no empty bucket), + // proving the size draw actually spans the histogram. + for _, b := range buckets { + require.Positive(t, hits[b], "bucket %d never selected under uniform size dist", b) + } +} + +// TestStorageRWKeySizeIndependence is the core trap guard: changing the size +// distribution must not perturb the key sequence. Same seed + same key config +// must yield an identical slot sequence regardless of the size config. +func TestStorageRWKeySizeIndependence(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + const seed, draws = 42, 500 + + keyOnly := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + }) + withSize := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + SizeDistribution: uniformDist(t), + SizeBuckets: []int{0, 128, 512}, + }) + + require.Equal(t, + drawSlots(t, keyOnly, parsed, draws), + drawSlots(t, withSize, parsed, draws), + "adding a size distribution must not change the key draw sequence") +} + +// TestStorageRWOpIndependence guards that op selection rides its own sub-stream: +// configuring an op mix must not change the key draw sequence. +func TestStorageRWOpIndependence(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + const seed, draws = 42, 500 + + keyOnly := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + }) + withOps := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + Operations: &config.OperationMix{Read: 1, Write: 1, Rmw: 1}, + }) + + require.Equal(t, + drawSlots(t, keyOnly, parsed, draws), + drawSlots(t, withOps, parsed, draws), + "adding an operation mix must not change the key draw sequence") +} + +// TestStorageRWOpMix proves the operation selector honors the configured mix: +// all three methods appear when all three are weighted, and a single-op mix +// produces only that op. +func TestStorageRWOpMix(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + sender := types.GenerateAccounts(1)[0] + + countOps := func(gen scenarios.TxGenerator, draws int) map[string]int { + out := map[string]int{} + for i := 0; i < draws; i++ { + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + name, _, _ := decodeStorageRWTx(t, parsed, tx.EthTx.Data()) + out[name]++ + } + return out + } + + t.Run("all_three_appear", func(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 3, config.Scenario{ + Operations: &config.OperationMix{Read: 1, Write: 1, Rmw: 1}, + }) + got := countOps(gen, 3000) + require.Positive(t, got["read"]) + require.Positive(t, got["write"]) + require.Positive(t, got["rmw"]) + }) + + t.Run("single_op_only", func(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 3, config.Scenario{ + Operations: &config.OperationMix{Write: 1}, + }) + got := countOps(gen, 500) + require.Equal(t, 500, got["write"]) + require.Len(t, got, 1, "single-op mix must produce only that op") + }) +} + +// TestStorageRWDefaultPathByteIdentical pins the additive guarantee: a scenario +// with no distribution config produces calldata byte-identical to the PLT-461 +// scaffold (fixed slot 0, empty pad, rmw) for a fixed sender/nonce. +func TestStorageRWDefaultPathByteIdentical(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 99, config.Scenario{}) + sender := types.GenerateAccounts(1)[0] + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + + data := tx.EthTx.Data() + require.Equal(t, rmwSelector, data[:4]) + body := data[4:] + require.Len(t, body, 96) + want := make([]byte, 96) + want[63] = 0x40 // offset to the empty _pad bytes argument. + require.Equal(t, want, body) +} + +// TestStorageRWScenarioConfigAdditive proves the new fields are omitempty: a +// scenario carrying none of them round-trips without introducing their keys. +func TestStorageRWScenarioConfigAdditive(t *testing.T) { + t.Parallel() + out, err := json.Marshal(config.Scenario{Name: scenarios.StorageRW}) + require.NoError(t, err) + for _, key := range []string{"recordCount", "sizeBuckets", "operations"} { + require.NotContains(t, string(out), key) + } +} diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index 8d1815c..8808f5d 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -41,22 +41,28 @@ // are emitted by `make generate` from the contract bindings — do not edit that // block by hand. // -// # StorageRW scaffold +// # StorageRW // -// StorageRW issues a read-modify-write against StorageRWv1 to exercise the SLOAD -// + SSTORE storage path under load. PLT-461 lands it as a scaffold: every -// transaction targets one fixed slot with an empty calldata pad, which is enough -// to prove the deploy/send path. The per-tx slot/value/pad distribution arrives -// in PLT-465. +// StorageRW exercises the SLOAD + SSTORE storage path under load against +// StorageRWv1. PLT-465 turns it into the two customer-named axes: key contention +// and tx size. Per tx the scenario draws a slot from the key distribution over +// the configured RecordCount keyspace, an operation (read/write/rmw) from the +// configured mix, and a calldata-pad length from the size distribution over the +// configured SizeBuckets histogram. The three draws ride independent rng +// sub-streams (dist:i:key, dist:i:op, dist:i:size) so tuning any one axis leaves +// the others' sequences identical. Each field is nil-guarded exactly like the +// gas pickers: with no distribution config the scenario reproduces the PLT-461 +// scaffold byte-for-byte — single slot 0, empty pad, rmw. // // Gas sizing. The rmw is an SLOAD + SSTORE on a single slot: ~26k gas warm, but // ~44k on a cold first touch (the cold-SLOAD and the zero-to-nonzero SSTORE both -// charge their higher rates). The scaffold pins GasLimit to 50k: it covers the -// cold-first-touch case with headroom for the (currently empty) pad, and packs +// charge their higher rates). The base GasLimit is pinned to 50k, which covers +// the cold-first-touch case with headroom for the fixed calldata head and packs // roughly 4x denser than the 200k default in CreateTransactionOpts. Density // matters on a gas-limit-admission chain, where a block admits transactions up // to its gas limit regardless of gas actually used — an oversized limit reserves -// block space the rmw never spends and throttles achievable throughput. PLT-465 -// revisits the limit once the calldata pad is distribution-driven, since pad size -// changes calldata gas. +// block space the rmw never spends and throttles achievable throughput. The +// distribution-driven pad adds its own intrinsic calldata cost (4 gas per +// zero pad byte, EIP-2028) on top of the base so a large pad cannot +// underprovision the tx, without inflating the limit when the pad is empty. package scenarios diff --git a/utils/rng/rng.go b/utils/rng/rng.go index 24ea692..9967a0e 100644 --- a/utils/rng/rng.go +++ b/utils/rng/rng.go @@ -43,7 +43,8 @@ import ( // streamID feeds fnv1a64, so renaming "gas:0:base" reseeds that stream. // Additions are append-only and do not perturb existing streams (a new id // hashes to its own sub-stream); PLT-460 added "dist:%d:key" and -// "dist:%d:size" for the per-scenario distribution index samplers. +// "dist:%d:size" for the per-scenario distribution index samplers, and +// PLT-465 added "dist:%d:op" for the per-scenario operation-mix selector. // 3. The per-stream draw order. Each stream is a sequence; drawing base before // tip before feecap is part of the contract — reordering draws within a // stream shifts every downstream value. diff --git a/utils/rng/rng_test.go b/utils/rng/rng_test.go index 693bc8b..4e443ae 100644 --- a/utils/rng/rng_test.go +++ b/utils/rng/rng_test.go @@ -25,6 +25,25 @@ func TestSameSeedSameStreamReproduces(t *testing.T) { } } +// TestDistributionStreamsAreDistinct pins that scenario i's key, size, and op +// distribution streams are mutually distinct ids. Sharing any two would couple +// the axes — drawing a size or op would perturb the key sequence — which the +// StorageRW independence tests rely on being impossible. +func TestDistributionStreamsAreDistinct(t *testing.T) { + ids := map[string]string{ + "key": KeyDistributionStream(0), + "size": SizeDistributionStream(0), + "op": OpDistributionStream(0), + } + seen := map[string]string{} + for name, id := range ids { + if prev, dup := seen[id]; dup { + t.Fatalf("stream id %q shared by %s and %s", id, prev, name) + } + seen[id] = name + } +} + func TestDifferentStreamsDiverge(t *testing.T) { a := drawSeq(42, "gas:0:base", 64) b := drawSeq(42, "gas:1:base", 64) diff --git a/utils/rng/streams.go b/utils/rng/streams.go index 4f4aa60..becb53b 100644 --- a/utils/rng/streams.go +++ b/utils/rng/streams.go @@ -36,3 +36,8 @@ func KeyDistributionStream(i int) string { return fmt.Sprintf("dist:%d:key", i) // SizeDistributionStream is the stream id for scenario i's size-distribution // index sampler (PLT-460). func SizeDistributionStream(i int) string { return fmt.Sprintf("dist:%d:size", i) } + +// OpDistributionStream is the stream id for scenario i's operation-mix selector +// (PLT-465). Distinct from the key and size streams so the op draw is +// independent: changing the op mix must not perturb the key or size sequence. +func OpDistributionStream(i int) string { return fmt.Sprintf("dist:%d:op", i) } From ca2b957eec143625b9a551730e36c3e25d4b2109 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 15 Jun 2026 15:50:54 -0700 Subject: [PATCH 2/5] fix(config): validate SizeBuckets + inline op-stream field (cohort review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scenario.Validate rejects SizeBuckets[i] < 0 (make([]byte,-1) panic on the pickPad hot path) and > maxCalldataPadBytes (1 MiB OOM guard); wired via LoadConfig.ValidateScenarios in loadConfig, mirroring ValidateFunding. - Inline OperationMix.stream (drop the one-field opStreamHolder wrapper) to match GasPicker/Distribution; move the mis-subjected doc comment to the field. No change to draw behavior — byte-identical-default + independence tests green. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 38 +++++++++++++++++++++++++++++++++++++- config/config_test.go | 29 +++++++++++++++++++++++++++++ config/operation.go | 12 +++--------- main.go | 4 ++++ 4 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 config/config_test.go diff --git a/config/config.go b/config/config.go index ebcaece..db90fb1 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,8 @@ import ( "fmt" "math/big" "time" + + "github.com/sei-protocol/sei-load/utils/rng" ) // LoadConfig stores the configuration for load-related settings. @@ -94,6 +96,38 @@ type Scenario struct { Operations *OperationMix `json:"operations,omitempty"` } +// maxCalldataPadBytes caps each SizeBuckets entry. It is a generous guard +// against a config typo (e.g. a stray extra digit OOMing the generator on the +// make([]byte, n) hot path), not a security boundary: configs are +// author-controlled today. +const maxCalldataPadBytes = 1 << 20 // 1 MiB + +// Validate checks per-scenario invariants that a malformed config would +// otherwise surface as a hot-path panic or OOM. Mirrors ZipfianDistribution's +// parameter validation; call once after the config is loaded. +func (s *Scenario) Validate() error { + for i, n := range s.SizeBuckets { + if n < 0 { + return fmt.Errorf("scenario %q: sizeBuckets[%d] is negative (%d)", s.Name, i, n) + } + if n > maxCalldataPadBytes { + return fmt.Errorf("scenario %q: sizeBuckets[%d]=%d exceeds the %d-byte cap", s.Name, i, n, maxCalldataPadBytes) + } + } + return nil +} + +// ValidateScenarios runs each scenario's Validate. It must be called after the +// config is loaded. +func (c *LoadConfig) ValidateScenarios() error { + for i := range c.Scenarios { + if err := c.Scenarios[i].Validate(); err != nil { + return err + } + } + return nil +} + // OperationMix is the relative weighting of the StorageRW read/write/rmw // operations. The weights need not sum to anything in particular; a per-tx draw // selects an operation in proportion to its weight over the total. An all-zero @@ -103,5 +137,7 @@ type OperationMix struct { Write uint64 `json:"write,omitempty"` Rmw uint64 `json:"rmw,omitempty"` - holder opStreamHolder + // stream is set by SetStream; nil draws from the unseeded global RNG. The + // pointer aliases on copy, matching GasPicker/Distribution. + stream *rng.Stream } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..f450886 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestScenarioValidateSizeBuckets: a negative pad length (makeslice panic on the +// hot path) and an over-cap pad length (OOM risk) are both rejected; a valid +// histogram, the cap boundary, and an empty/nil bucket list pass. +func TestScenarioValidateSizeBuckets(t *testing.T) { + t.Parallel() + t.Run("negative rejected", func(t *testing.T) { + s := Scenario{Name: "s", SizeBuckets: []int{0, -1}} + require.ErrorContains(t, s.Validate(), "negative") + }) + t.Run("over cap rejected", func(t *testing.T) { + s := Scenario{Name: "s", SizeBuckets: []int{maxCalldataPadBytes + 1}} + require.ErrorContains(t, s.Validate(), "cap") + }) + t.Run("valid accepted", func(t *testing.T) { + s := Scenario{Name: "s", SizeBuckets: []int{0, 64, maxCalldataPadBytes}} + require.NoError(t, s.Validate()) + }) + t.Run("empty accepted", func(t *testing.T) { + require.NoError(t, (&Scenario{Name: "s"}).Validate()) + }) +} diff --git a/config/operation.go b/config/operation.go index 910b516..8fefd24 100644 --- a/config/operation.go +++ b/config/operation.go @@ -17,15 +17,9 @@ const ( OpWrite ) -// stream is set by SetStream; nil means draw from the unseeded global RNG. The -// pointer aliases on copy, matching GasPicker/Distribution. -type opStreamHolder struct { - stream *rng.Stream -} - // SetStream binds the selector to a deterministic sub-stream (nil = unseeded // global RNG), mirroring GasPicker.SetStream and Distribution.SetStream. -func (m *OperationMix) SetStream(s *rng.Stream) { m.holder.stream = s } +func (m *OperationMix) SetStream(s *rng.Stream) { m.stream = s } // Select draws one operation in proportion to the configured weights. A zero // total (all weights zero) falls back to OpRmw so an empty mix is the scaffold @@ -36,8 +30,8 @@ func (m *OperationMix) Select() Operation { return OpRmw } var u uint64 - if m.holder.stream != nil { - u = m.holder.stream.Uint64N(total) + if m.stream != nil { + u = m.stream.Uint64N(total) } else { u = rand.Uint64N(total) } diff --git a/main.go b/main.go index 5baa309..483d3a7 100644 --- a/main.go +++ b/main.go @@ -419,6 +419,10 @@ func loadConfig(filename string) (*config.LoadConfig, error) { return nil, fmt.Errorf("no scenarios specified in config") } + if err := cfg.ValidateScenarios(); err != nil { + return nil, err + } + if err := cfg.ValidateFunding(); err != nil { return nil, err } From a541fba8ec6e68ef8609c3b60aca5df6a143951e Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 15 Jun 2026 16:34:42 -0700 Subject: [PATCH 3/5] docs(generator): comment-discipline sweep (PLT-465) De-changelog the doc.go StorageRW narrative (drop 'PLT-465 turns it into', 'PLT-461 scaffold byte-for-byte') to a timeless present-tense description, and replace the now-dangling era term 'scaffold' with 'default' in inline default-path notes. Keep load-bearing comments (FROZEN append-only stream-id ledger, EIP-2028 gas-per-byte why, nil-guard/default invariants, the no-underprovision note). Comment-only. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 8 ++++---- config/operation.go | 6 +++--- config/operation_test.go | 2 +- generator/scenarios/StorageRW.go | 10 +++++----- generator/scenarios/StorageRW_test.go | 8 ++++---- generator/scenarios/doc.go | 18 +++++++++--------- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/config/config.go b/config/config.go index db90fb1..8d341d3 100644 --- a/config/config.go +++ b/config/config.go @@ -85,14 +85,14 @@ type Scenario struct { SizeDistribution *Distribution `json:"sizeDistribution,omitempty"` // RecordCount is the keyspace size the KeyDistribution indexes into: the // per-tx slot is a draw in [0, RecordCount). Zero (the default) is the - // single-slot, 100%-conflict scaffold behavior. + // single-slot, 100%-conflict behavior. RecordCount uint64 `json:"recordCount,omitempty"` // SizeBuckets is the calldata-pad-length histogram the SizeDistribution // indexes into: the per-tx pad length is SizeBuckets[draw]. Empty (the - // default) is the empty-pad scaffold behavior. + // default) is the empty-pad behavior. SizeBuckets []int `json:"sizeBuckets,omitempty"` // Operations is the read/write/rmw selection mix. Nil (the default) is the - // all-rmw scaffold behavior. + // all-rmw behavior. Operations *OperationMix `json:"operations,omitempty"` } @@ -131,7 +131,7 @@ func (c *LoadConfig) ValidateScenarios() error { // OperationMix is the relative weighting of the StorageRW read/write/rmw // operations. The weights need not sum to anything in particular; a per-tx draw // selects an operation in proportion to its weight over the total. An all-zero -// (or nil) mix falls back to rmw, the scaffold default. +// (or nil) mix falls back to rmw, the default. type OperationMix struct { Read uint64 `json:"read,omitempty"` Write uint64 `json:"write,omitempty"` diff --git a/config/operation.go b/config/operation.go index 8fefd24..d893dfb 100644 --- a/config/operation.go +++ b/config/operation.go @@ -11,7 +11,7 @@ type Operation uint8 const ( // OpRmw is the read-modify-write operation; it is the zero value so a - // zero/nil OperationMix selects rmw, matching the scaffold default. + // zero/nil OperationMix selects rmw, matching the default. OpRmw Operation = iota OpRead OpWrite @@ -22,8 +22,8 @@ const ( func (m *OperationMix) SetStream(s *rng.Stream) { m.stream = s } // Select draws one operation in proportion to the configured weights. A zero -// total (all weights zero) falls back to OpRmw so an empty mix is the scaffold -// default rather than a panic. +// total (all weights zero) falls back to OpRmw so an empty mix is the default +// rather than a panic. func (m *OperationMix) Select() Operation { total := m.Read + m.Write + m.Rmw if total == 0 { diff --git a/config/operation_test.go b/config/operation_test.go index 4cc2e6b..38b7c21 100644 --- a/config/operation_test.go +++ b/config/operation_test.go @@ -10,7 +10,7 @@ import ( ) // TestOperationMixEmptyFallsBackToRmw: a zero-weight (or nil-equivalent) mix -// selects rmw, the scaffold default, rather than panicking on a zero total. +// selects rmw, the default, rather than panicking on a zero total. func TestOperationMixEmptyFallsBackToRmw(t *testing.T) { t.Parallel() var m config.OperationMix diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index a598a38..7fec0ff 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -29,7 +29,7 @@ const ( ) // storageRWDefaultSlot is the single slot every tx targets when no key -// distribution is configured (the scaffold's 100%-conflict default). +// distribution is configured — the 100%-conflict default. var storageRWDefaultSlot = big.NewInt(0) // StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations @@ -91,7 +91,7 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add // CreateContractTransaction implements ContractDeployer interface - builds one // StorageRWv1 transaction whose slot (key contention), operation, and calldata // pad (tx size) are drawn from the configured distributions. With no -// distribution config it reproduces the scaffold's single-slot empty-pad rmw. +// distribution config it falls back to a single-slot empty-pad rmw. // See package doc for the gas rationale. func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { slot, err := s.pickSlot() @@ -119,7 +119,7 @@ func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, s // pickSlot draws the storage slot from the key distribution over the configured // RecordCount keyspace. With no key distribution it returns the fixed default -// slot, preserving the scaffold's 100%-conflict behavior. +// slot — the 100%-conflict default. func (s *StorageRWScenario) pickSlot() (*big.Int, error) { cfg := s.scenarioConfig if cfg.KeyDistribution == nil || cfg.RecordCount == 0 { @@ -134,7 +134,7 @@ func (s *StorageRWScenario) pickSlot() (*big.Int, error) { // pickPad draws the calldata pad length from the size distribution over the // configured SizeBuckets histogram, on a sub-stream independent of the key draw. -// With no size distribution it returns an empty pad, preserving the scaffold. +// With no size distribution it returns an empty pad. func (s *StorageRWScenario) pickPad() ([]byte, error) { cfg := s.scenarioConfig if cfg.SizeDistribution == nil || len(cfg.SizeBuckets) == 0 { @@ -148,7 +148,7 @@ func (s *StorageRWScenario) pickPad() ([]byte, error) { } // pickOp selects read/write/rmw from the configured mix on its own independent -// sub-stream. With no mix it returns rmw, preserving the scaffold. +// sub-stream. With no mix it returns rmw. func (s *StorageRWScenario) pickOp() config.Operation { if s.scenarioConfig.Operations == nil { return config.OpRmw diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index a48141c..a8678a2 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -63,7 +63,7 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { require.GreaterOrEqual(t, len(data), 4) require.Equal(t, rmwSelector, data[:4]) - // Pin the fixed scaffold calldata: rmw(uint256 slot, bytes _pad) with + // Pin the fixed default-path calldata: rmw(uint256 slot, bytes _pad) with // slot == 0 and an empty pad. ABI head is the slot operand (32B) then the // bytes offset (0x40); the tail is the bytes length (0). All zero except the // 0x40 offset, so the full body is 96 bytes. @@ -172,7 +172,7 @@ func TestStorageRWContentionSweep(t *testing.T) { t.Run("single_slot_full_collision", func(t *testing.T) { t.Parallel() - // No key distribution => scaffold single-slot default: every draw is slot 0. + // No key distribution => single-slot default: every draw is slot 0. gen := newConfiguredStorageRW(t, 1, config.Scenario{}) slots := drawSlots(t, gen, parsed, draws) for _, s := range slots { @@ -302,8 +302,8 @@ func TestStorageRWOpMix(t *testing.T) { } // TestStorageRWDefaultPathByteIdentical pins the additive guarantee: a scenario -// with no distribution config produces calldata byte-identical to the PLT-461 -// scaffold (fixed slot 0, empty pad, rmw) for a fixed sender/nonce. +// with no distribution config produces the fixed default-path calldata (slot 0, +// empty pad, rmw), byte-identical for a fixed sender/nonce. func TestStorageRWDefaultPathByteIdentical(t *testing.T) { t.Parallel() gen := newConfiguredStorageRW(t, 99, config.Scenario{}) diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index 8808f5d..dc8d094 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -44,15 +44,15 @@ // # StorageRW // // StorageRW exercises the SLOAD + SSTORE storage path under load against -// StorageRWv1. PLT-465 turns it into the two customer-named axes: key contention -// and tx size. Per tx the scenario draws a slot from the key distribution over -// the configured RecordCount keyspace, an operation (read/write/rmw) from the -// configured mix, and a calldata-pad length from the size distribution over the -// configured SizeBuckets histogram. The three draws ride independent rng -// sub-streams (dist:i:key, dist:i:op, dist:i:size) so tuning any one axis leaves -// the others' sequences identical. Each field is nil-guarded exactly like the -// gas pickers: with no distribution config the scenario reproduces the PLT-461 -// scaffold byte-for-byte — single slot 0, empty pad, rmw. +// StorageRWv1 along two customer-named axes: key contention and tx size. Per tx +// the scenario draws a slot from the key distribution over the configured +// RecordCount keyspace, an operation (read/write/rmw) from the configured mix, +// and a calldata-pad length from the size distribution over the configured +// SizeBuckets histogram. The three draws ride independent rng sub-streams +// (dist:i:key, dist:i:op, dist:i:size) so tuning any one axis leaves the others' +// sequences identical. Each field is nil-guarded exactly like the gas pickers: +// with no distribution config the scenario degenerates to a single fixed slot 0, +// an empty pad, and rmw — the 100%-conflict baseline. // // Gas sizing. The rmw is an SLOAD + SSTORE on a single slot: ~26k gas warm, but // ~44k on a cold first touch (the cold-SLOAD and the zero-to-nonzero SSTORE both From 705b089c0bafa0958305bae6a4d26613ab69f77b Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 19:53:06 -0700 Subject: [PATCH 4/5] docs(config): state the reproducibility contract that actually holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Seed doc promised deterministic PRNG sub-streams and a per-stream draw multiset "for fair A/B comparison". The generator rewrite removed sub-streams, so that text sold a property the code no longer has — and it sold it around the exact task an operator would use it for. An operator changing one axis and attributing the delta to that axis would have been wrong. The contract now stated: one stream serves the whole run, the config is half of the contract because it fixes the call order, and two runs compare only when their configs match. Swept the same stale vocabulary out of config/doc.go, sender/doc.go, and a distribution test comment. Also corrected in the StorageRW docs: - the fixed draw order carried no obligation and overclaimed, since a default config runs zero draws, not three - the op mix was called a "distribution", which it is not — Distribution is a tagged type keyed by "Name", and a reader following that wording would have written a config that silently selects all-rmw - the gas rationale justified the 50k base for rmw alone, though read and write now share it - the SizeBuckets cap was documented only on an unexported const, so an operator met it as a load error rather than in the field doc Co-Authored-By: Claude Opus 5 (1M context) --- config/config.go | 45 ++++++++++-------- config/distribution_test.go | 4 +- config/doc.go | 15 ++++-- generator/scenarios/StorageRW.go | 11 +++-- generator/scenarios/StorageRW_test.go | 2 +- generator/scenarios/doc.go | 68 ++++++++++++++++----------- sender/doc.go | 4 +- 7 files changed, 88 insertions(+), 61 deletions(-) diff --git a/config/config.go b/config/config.go index c1082ba..2bdf595 100644 --- a/config/config.go +++ b/config/config.go @@ -22,12 +22,19 @@ type LoadConfig struct { Funding *FundingConfig `json:"funding,omitempty"` // Path to write a JSON report of the load test. ReportPath string `json:"reportPath,omitempty"` - // Seed roots the deterministic PRNG sub-streams that drive the run. Same - // seed + config reproduces the per-stream draw multiset, so the workload - // (the distribution of keys, sizes, gas, and accounts) is statistically - // reproducible for fair A/B comparison. On-chain arrival order is concurrent - // regardless. A nil Seed means "unseeded": the generator resolves a random - // one and records it for after-the-fact replay. + // Seed roots the PRNG behind every workload draw: key and size + // distributions, gas pickers, operation mixes, and account selection. The + // same seed and the same config reproduce the same draw sequence. + // + // One stream serves the whole run, so the axes are reproducible together + // rather than independently: adding, removing, or reweighting any axis + // changes how many draws each transaction takes, which shifts every other + // axis's sequence. Two runs compare only when their configs match — a saved + // workload is the seed and the config together, never the seed alone. + // + // On-chain arrival order is concurrent regardless. A nil Seed means + // "unseeded": the generator resolves a random one and records it for + // after-the-fact replay. Seed *uint64 `json:"seed,omitempty"` } @@ -88,38 +95,38 @@ type Scenario struct { // per-tx slot is a draw in [0, RecordCount). Zero (the default) is the // single-slot, 100%-conflict behavior. RecordCount uint64 `json:"recordCount,omitempty"` - // SizeBuckets is the calldata-pad-length histogram the SizeDistribution - // indexes into: the per-tx pad length is SizeBuckets[draw]. Empty (the - // default) is the empty-pad behavior. + // SizeBuckets is the pad-length histogram the SizeDistribution indexes into: + // the per-tx pad length is SizeBuckets[draw]. Empty (the default) is the + // empty-pad behavior. Each entry must be between 0 and 1 MiB; Validate + // rejects the config otherwise. SizeBuckets []int `json:"sizeBuckets,omitempty"` // Operations is the read/write/rmw selection mix. Nil (the default) is the // all-rmw behavior. Operations *OperationMix `json:"operations,omitempty"` } -// maxCalldataPadBytes caps each SizeBuckets entry. It is a generous guard -// against a config typo (e.g. a stray extra digit OOMing the generator on the -// make([]byte, n) hot path), not a security boundary: configs are -// author-controlled today. +// maxCalldataPadBytes caps each SizeBuckets entry at 1 MiB. It catches a config +// typo — a stray extra digit would OOM the generator on the make([]byte, n) hot +// path — and is not a security boundary: configs are author-controlled. const maxCalldataPadBytes = 1 << 20 // 1 MiB -// Validate checks per-scenario invariants that a malformed config would -// otherwise surface as a hot-path panic or OOM. Mirrors ZipfianDistribution's -// parameter validation; call once after the config is loaded. +// Validate checks the per-scenario invariants that a malformed config would +// otherwise surface as a hot-path panic or an OOM. loadConfig calls it through +// ValidateScenarios after unmarshalling; any new entrypoint must do the same. func (s *Scenario) Validate() error { for i, n := range s.SizeBuckets { if n < 0 { return fmt.Errorf("scenario %q: sizeBuckets[%d] is negative (%d)", s.Name, i, n) } if n > maxCalldataPadBytes { - return fmt.Errorf("scenario %q: sizeBuckets[%d]=%d exceeds the %d-byte cap", s.Name, i, n, maxCalldataPadBytes) + return fmt.Errorf("scenario %q: sizeBuckets[%d]=%d exceeds the 1 MiB (%d-byte) cap", s.Name, i, n, maxCalldataPadBytes) } } return nil } -// ValidateScenarios runs each scenario's Validate. It must be called after the -// config is loaded. +// ValidateScenarios runs each scenario's Validate and names the scenario that +// failed. loadConfig calls it after unmarshalling. func (c *LoadConfig) ValidateScenarios() error { for i := range c.Scenarios { if err := c.Scenarios[i].Validate(); err != nil { diff --git a/config/distribution_test.go b/config/distribution_test.go index 916ae8d..c31e027 100644 --- a/config/distribution_test.go +++ b/config/distribution_test.go @@ -82,8 +82,8 @@ func TestSampleIndexEmptyKeyspace(t *testing.T) { } } -// TestSampleIndexDeterminism: same seed + same stream id => identical draw -// sequence, for both samplers. This is the per-stream reproducibility contract. +// TestSampleIndexDeterminism: same seed and same call order => identical draw +// sequence, for both samplers. This is the reproducibility contract. func TestSampleIndexDeterminism(t *testing.T) { t.Parallel() const seed, n, count = 99, 1000, 256 diff --git a/config/doc.go b/config/doc.go index 737bb26..271fe1d 100644 --- a/config/doc.go +++ b/config/doc.go @@ -7,8 +7,8 @@ // // A Distribution is a tagged sampler that draws an index in [0, n) from some // keyspace distribution (see distribution.go). It is selected on the JSON wire -// by a "Name" discriminator and bound at run time to an explicit seeded PRNG so -// that two runs at the same seed draw the same sequence of indices. +// by a "Name" discriminator and draws from an explicitly supplied PRNG, so two +// runs at the same seed and the same config draw the same sequence of indices. // // # Wire format (FROZEN one-way door) // @@ -81,6 +81,13 @@ // # Seeded-stream reproducibility (FROZEN inputs) // // Draws go through an explicitly supplied *rand.Rand seeded from the run seed. -// This is what gives the workload its reproducibility contract: same seed + -// same config yields the same draw sequence for the same call order. +// The same seed and the same config yield the same draw sequence, because the +// config fixes the call order: which axes are configured decides how many draws +// each transaction takes, and in what sequence. +// +// One stream serves every axis, every scenario, and account selection. So the +// config is half of the contract, not a detail of it — adding, removing, or +// reweighting an axis changes the call order and shifts every other axis's +// sequence. Hold the config fixed to compare two runs, and record the seed and +// the config together to replay one. package config diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index ac4b13d..fc47641 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -91,12 +91,13 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add // CreateContractTransaction implements ContractDeployer interface - builds one // StorageRWv1 transaction whose slot (key contention), calldata pad (tx size), -// and operation are drawn from the configured distributions. With no -// distribution config it falls back to a single-slot empty-pad rmw, consuming -// no randomness. See package doc for the gas rationale. +// and operation are drawn from the scenario config. With none of the three +// configured it falls back to a single-slot empty-pad rmw and draws no +// randomness. See package doc for the gas rationale. // -// The draws run in a fixed order — slot, pad, operation — because all three -// share one RNG, so the order is part of the reproducibility contract. +// The draws run in a fixed order: slot, then pad, then operation. That order +// must stay stable — all three share the run's single PRNG, so reordering them +// shifts every subsequent draw and diverges a replay at the same seed. func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { slot, err := s.pickSlot(rng) if err != nil { diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index c7cde1a..122141c 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -67,7 +67,7 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { require.GreaterOrEqual(t, len(data), 4) require.Equal(t, rmwSelector, data[:4]) - // Pin the fixed scaffold calldata: rmw(uint256 slot, bytes _pad) with + // Pin the default calldata: rmw(uint256 slot, bytes _pad) with // slot == 0 and an empty pad. ABI head is the slot operand (32B) then the // bytes offset (0x40); the tail is the bytes length (0). All zero except the // 0x40 offset, so the full body is 96 bytes. diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index c1e4fd9..b0ba354 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -44,32 +44,44 @@ // # StorageRW // // StorageRW exercises the SLOAD + SSTORE storage path under load against -// StorageRWv1 along two customer-named axes: key contention and tx size. Per tx -// the scenario draws a slot from the key distribution over the configured -// RecordCount keyspace, a calldata-pad length from the size distribution over -// the configured SizeBuckets histogram, and an operation (read/write/rmw) from -// the configured mix. -// -// Those axes are what make contention a continuum rather than a binary. A wide -// keyspace drawn uniformly approaches zero conflict; a single slot is total -// conflict; a zipfian draw sits anywhere between. -// -// Each field is nil-guarded exactly like the gas pickers: with no distribution -// config the scenario degenerates to a single fixed slot 0, an empty pad, and -// rmw — the 100%-conflict baseline — and draws no randomness at all, so adding -// these fields to a profile that does not use them cannot perturb its workload. -// The three draws run in a fixed order — slot, pad, operation — because they -// share one RNG, which makes the order part of the reproducibility contract. -// -// Gas sizing. The rmw is an SLOAD + SSTORE on a single slot: ~26k gas warm, but -// ~44k on a cold first touch (the cold-SLOAD and the zero-to-nonzero SSTORE both -// charge their higher rates). The base GasLimit is pinned to 50k, which covers -// the cold-first-touch case with headroom for the fixed calldata head and packs -// roughly 4x denser than the 200k default in CreateTransactionOpts. Density -// matters on a gas-limit-admission chain, where a block admits transactions up -// to its gas limit regardless of gas actually used — an oversized limit reserves -// block space the rmw never spends and throttles achievable throughput. The -// distribution-driven pad adds its own intrinsic calldata cost (4 gas per -// zero pad byte, EIP-2028) on top of the base so a large pad cannot -// underprovision the tx, without inflating the limit when the pad is empty. +// StorageRWv1. Per tx it draws three things: a slot from the key distribution +// over the RecordCount keyspace, a pad length from the size distribution over +// the SizeBuckets histogram, and an operation — read, write, or rmw — from the +// Operations mix. Slot and pad are the two customer-named axes, key contention +// and tx size; the operation mix shapes what each drawn slot is used for. +// +// Slot and keyspace are what make contention a continuum rather than a binary. +// A wide keyspace drawn uniformly approaches zero conflict; a single slot is +// total conflict; a zipfian draw sits anywhere between. Throughout this section +// record, key, and slot all name the same thing: an index in [0, RecordCount). +// +// Every axis is optional. Omit them all and StorageRW behaves exactly as it did +// before they existed: slot 0, an empty pad, rmw — the 100%-conflict baseline — +// and zero draws from the RNG. So adding these fields to a profile that does not +// set them cannot perturb its workload. +// +// Whichever draws a config does enable run in a fixed order: slot, then pad, +// then operation. That order must stay stable. The draws share the run's single +// PRNG, so reordering them shifts every subsequent draw and diverges any saved +// workload replayed at the same seed. Conversely, enabling or reweighting an +// axis changes how many draws each transaction takes, which shifts the other +// axes — see the config package doc on what the seed does and does not promise. +// +// Gas sizing. All three operations share one base GasLimit of 50k. The rmw is +// the costliest: an SLOAD plus an SSTORE on a single slot, ~26k warm and ~44k on +// a cold first touch, where the cold SLOAD and the zero-to-nonzero SSTORE both +// charge their higher rates. write is an SSTORE without the load, and read folds +// its load into readAccumulator, so both sit under the rmw ceiling — 50k covers +// the worst of the three with headroom for the fixed calldata head. +// +// One limit for all three trades a little slack on read and write for a single +// number to reason about. Density is why the number is tight at all: it packs +// roughly 4x denser than the 200k default in CreateTransactionOpts, and on a +// gas-limit-admission chain a block admits transactions up to its gas limit +// regardless of gas actually used, so an oversized limit reserves block space +// the transaction never spends and throttles achievable throughput. +// +// The drawn pad adds its own intrinsic calldata cost — 4 gas per zero pad byte, +// EIP-2028 — on top of the base, so a large pad cannot underprovision the tx +// while an empty pad leaves the limit at exactly 50k. package scenarios diff --git a/sender/doc.go b/sender/doc.go index f13daf3..b8fc3f0 100644 --- a/sender/doc.go +++ b/sender/doc.go @@ -54,8 +54,8 @@ // wasted CPU on shed load). This makes the admitted transactions a deterministic // PREFIX of the seeded generator sequence: the same seed yields the same // admitted multiset regardless of how many ticks the SUT speed forced to drop — -// the per-stream reproducibility contract holds under saturation, where it -// otherwise would not. SequenceIndex is the arrival-tick index i (so +// the reproducibility contract holds under saturation, where it otherwise would +// not. SequenceIndex is the arrival-tick index i (so // IntendedSendTime = t₀ + i/λ holds); under drops it is monotonic but // non-contiguous across admitted txs, because dropped ticks still advance i and // the clock while consuming no draw. From d0d0f4fb71eb3e41c0b6d2df3d660857a42765b5 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 20:04:05 -0700 Subject: [PATCH 5/5] fix(generator): size the gas limit to the EIP-7623 floor, not the 2028 rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pad was charged at 4 gas per byte, the pre-Prague zero-byte rate. EIP-7623 is live on Sei (PragueTime is 0) and charges a transaction no less than 21000 + 10 per calldata token, so above roughly 4.6 KiB of pad the floor is the real cost and the declared limit fell under it. Measured against the pinned go-ethereum: short by 174 gas at a 4609-byte pad, and by 6,263,636 at 1 MiB. Sei's ante checks only the intrinsic cost, not the floor, so such a transaction is admitted, reserves its full declared limit, then fails in execution with GasUsed equal to the limit. It lands in a block as an included failure and inflates the gas-used metric the run reports — a plausible-looking result measuring nothing. The pad is now charged at 10 gas per on-wire byte, rounded up to the ABI word the pad actually occupies. An empty pad still leaves the limit at exactly 50k, so density below the crossover is unchanged. TestStorageRWGasClearsFloorAcrossPadSizes asserts the limit clears both core.IntrinsicGas and core.FloorDataGas across a pad sweep. Verified it fails on the old rate at four pad sizes, so it guards the invariant rather than restating the formula — which is what the previous assertion did. Validation now rejects the configurations that silently ran the baseline instead of the requested experiment: a half-configured axis in either direction, an explicit all-zero operations mix, weights that sum past uint64, and a keyspace beyond the point where the zipfian zeta precompute stalls the generator. The pad cap drops to 128 KiB, under both the CometBFT per-transaction limit and a sensible share of the block gas budget. Committed profiles now run scenario validation in CI. Documented what the measurements showed: read is the worst case at 46,269, not rmw; every read writes one contract-wide accumulator slot, so a read-weighted mix does not sweep contention; and the new wire keys are frozen. Co-Authored-By: Claude Opus 5 (1M context) --- config/config.go | 45 ++++++++++-- config/config_test.go | 88 ++++++++++++++++++++++- config/doc.go | 9 +++ config/operation.go | 21 +++++- generator/scenarios/StorageRW.go | 43 ++++++++--- generator/scenarios/StorageRW_test.go | 100 +++++++++++++++++++++++++- generator/scenarios/doc.go | 43 +++++++---- profiles/profiles_test.go | 7 ++ 8 files changed, 319 insertions(+), 37 deletions(-) diff --git a/config/config.go b/config/config.go index 2bdf595..61f1199 100644 --- a/config/config.go +++ b/config/config.go @@ -105,10 +105,24 @@ type Scenario struct { Operations *OperationMix `json:"operations,omitempty"` } -// maxCalldataPadBytes caps each SizeBuckets entry at 1 MiB. It catches a config -// typo — a stray extra digit would OOM the generator on the make([]byte, n) hot -// path — and is not a security boundary: configs are author-controlled. -const maxCalldataPadBytes = 1 << 20 // 1 MiB +const ( + // maxCalldataPadBytes caps each SizeBuckets entry at 128 KiB. Two ceilings + // sit above it and the cap stays under both: the CometBFT mempool rejects a + // transaction over 1 MiB, which a 1 MiB pad already breaches on calldata + // alone, and the pad is charged at the EIP-7623 floor rate, so 128 KiB costs + // about 1.4M gas against a 50M block. It also keeps a stray extra digit from + // pinning gigabytes: the send queue holds up to a few thousand transactions, + // each retaining its own pad. + maxCalldataPadBytes = 128 << 10 // 128 KiB + + // maxRecordCount caps the keyspace. The zipfian sampler precomputes zeta in + // O(n) on its first draw, under a mutex, on the generator's only goroutine: + // measured at ~26 ns per element, so 1e7 costs ~270 ms once and 1e11 would + // stall the run for roughly a minute per order of magnitude with nothing + // logged. The package doc puts the design target at ~1e6, so this leaves an + // order of magnitude of headroom. + maxRecordCount = 10_000_000 +) // Validate checks the per-scenario invariants that a malformed config would // otherwise surface as a hot-path panic or an OOM. loadConfig calls it through @@ -122,7 +136,28 @@ func (s *Scenario) Validate() error { return fmt.Errorf("scenario %q: sizeBuckets[%d]=%d exceeds the 1 MiB (%d-byte) cap", s.Name, i, n, maxCalldataPadBytes) } } - return nil + + if s.RecordCount > maxRecordCount { + return fmt.Errorf("scenario %q: recordCount=%d exceeds the %d cap", s.Name, s.RecordCount, maxRecordCount) + } + + // An axis needs both halves to do anything: a sampler and a space to sample. + // Half-configured, the scenario silently runs its baseline instead of the + // experiment the operator asked for, which is the worst outcome available to + // a benchmark. Reject the pairing rather than degenerate. + if s.KeyDistribution != nil && s.RecordCount == 0 { + return fmt.Errorf("scenario %q: keyDistribution is set but recordCount is 0, so every tx would target one slot", s.Name) + } + if s.KeyDistribution == nil && s.RecordCount != 0 { + return fmt.Errorf("scenario %q: recordCount is %d but no keyDistribution samples it", s.Name, s.RecordCount) + } + if s.SizeDistribution != nil && len(s.SizeBuckets) == 0 { + return fmt.Errorf("scenario %q: sizeDistribution is set but sizeBuckets is empty, so every tx would send an empty pad", s.Name) + } + if s.SizeDistribution == nil && len(s.SizeBuckets) != 0 { + return fmt.Errorf("scenario %q: sizeBuckets has %d entries but no sizeDistribution samples them", s.Name, len(s.SizeBuckets)) + } + return s.Operations.validate(s.Name) } // ValidateScenarios runs each scenario's Validate and names the scenario that diff --git a/config/config_test.go b/config/config_test.go index f450886..f374e9e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -12,18 +12,100 @@ import ( func TestScenarioValidateSizeBuckets(t *testing.T) { t.Parallel() t.Run("negative rejected", func(t *testing.T) { - s := Scenario{Name: "s", SizeBuckets: []int{0, -1}} + s := Scenario{Name: "s", SizeDistribution: &Distribution{}, SizeBuckets: []int{0, -1}} require.ErrorContains(t, s.Validate(), "negative") }) t.Run("over cap rejected", func(t *testing.T) { - s := Scenario{Name: "s", SizeBuckets: []int{maxCalldataPadBytes + 1}} + s := Scenario{Name: "s", SizeDistribution: &Distribution{}, SizeBuckets: []int{maxCalldataPadBytes + 1}} require.ErrorContains(t, s.Validate(), "cap") }) t.Run("valid accepted", func(t *testing.T) { - s := Scenario{Name: "s", SizeBuckets: []int{0, 64, maxCalldataPadBytes}} + s := Scenario{Name: "s", SizeDistribution: &Distribution{}, SizeBuckets: []int{0, 64, maxCalldataPadBytes}} require.NoError(t, s.Validate()) }) t.Run("empty accepted", func(t *testing.T) { require.NoError(t, (&Scenario{Name: "s"}).Validate()) }) } + +// TestScenarioValidateRecordCountCap: the keyspace is capped because the zipfian +// sampler precomputes zeta in O(n) under a mutex on the generator's only +// goroutine, so a stray extra digit stalls the run rather than failing it. +func TestScenarioValidateRecordCountCap(t *testing.T) { + t.Parallel() + at := Scenario{Name: "s", KeyDistribution: &Distribution{}, RecordCount: maxRecordCount} + require.NoError(t, at.Validate()) + over := Scenario{Name: "s", KeyDistribution: &Distribution{}, RecordCount: maxRecordCount + 1} + require.ErrorContains(t, over.Validate(), "cap") +} + +// TestScenarioValidateAxisPairing: an axis needs a sampler and a space to sample. +// Configured with only one half it silently runs the baseline instead of the +// experiment, so each direction is rejected. +func TestScenarioValidateAxisPairing(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + scenario Scenario + wantErr string + }{ + "key distribution without a keyspace": { + Scenario{Name: "s", KeyDistribution: &Distribution{}}, + "recordCount is 0", + }, + "keyspace without a key distribution": { + Scenario{Name: "s", RecordCount: 1000}, + "no keyDistribution", + }, + "size distribution without buckets": { + Scenario{Name: "s", SizeDistribution: &Distribution{}}, + "sizeBuckets is empty", + }, + "buckets without a size distribution": { + Scenario{Name: "s", SizeBuckets: []int{0, 32}}, + "no sizeDistribution", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + require.ErrorContains(t, tc.scenario.Validate(), tc.wantErr) + }) + } + + both := Scenario{ + Name: "s", + KeyDistribution: &Distribution{}, + RecordCount: 1000, + SizeDistribution: &Distribution{}, + SizeBuckets: []int{0, 32}, + } + require.NoError(t, both.Validate()) +} + +// TestScenarioValidateOperationsPresentButEmpty: an explicit all-zero mix is a +// misconfiguration, not the default. Omitting the field is the default, and +// Select's zero-total guard stays a safety net for that case rather than a +// swallower of this one. +func TestScenarioValidateOperationsPresentButEmpty(t *testing.T) { + t.Parallel() + empty := Scenario{Name: "s", Operations: &OperationMix{}} + require.ErrorContains(t, empty.Validate(), "every weight is 0") + + require.NoError(t, (&Scenario{Name: "s"}).Validate()) + require.NoError(t, (&Scenario{Name: "s", Operations: &OperationMix{Rmw: 1}}).Validate()) +} + +// TestValidateScenariosReportsOffendingScenario: validation runs across every +// scenario and names the one that failed, so an operator can find it in a +// multi-scenario profile. +func TestValidateScenariosReportsOffendingScenario(t *testing.T) { + t.Parallel() + cfg := LoadConfig{Scenarios: []Scenario{ + {Name: "good"}, + {Name: "bad", Operations: &OperationMix{}}, + }} + require.ErrorContains(t, cfg.ValidateScenarios(), `scenario "bad"`) + + cfg.Scenarios[1].Operations = &OperationMix{Read: 1} + require.NoError(t, cfg.ValidateScenarios()) +} diff --git a/config/doc.go b/config/doc.go index 271fe1d..e52ab82 100644 --- a/config/doc.go +++ b/config/doc.go @@ -24,6 +24,15 @@ // door — add new names, never rename existing ones. A zero-value Distribution // (empty Name) draws no randomness and samples 0. // +// The per-scenario workload keys are frozen on the same terms: +// +// "recordCount" the keyspace a keyDistribution indexes +// "sizeBuckets" the pad-length histogram a sizeDistribution indexes +// "operations" the operation mix, with keys "read", "write", "rmw" +// +// So is the order OperationMix.Select compares those weights in, which decides +// which operation a given draw selects. +// // # Semantics: uniform vs zipfian(theta) // // uniform draws every index in [0, n) with equal probability. diff --git a/config/operation.go b/config/operation.go index 53b3996..4a7ec9e 100644 --- a/config/operation.go +++ b/config/operation.go @@ -1,6 +1,7 @@ package config import ( + "fmt" mrand "math/rand/v2" ) @@ -25,8 +26,26 @@ type OperationMix struct { Rmw uint64 `json:"rmw,omitempty"` } +// validate rejects a mix that is present but cannot select anything, so an +// operator who writes "operations": {} — or misspells every weight key — gets an +// error instead of a silent all-rmw run. Select's own zero-total guard then +// covers only the absent-mix case it was written for. A nil mix is the +// documented default and passes. +func (m *OperationMix) validate(scenario string) error { + if m == nil { + return nil + } + if m.Read == 0 && m.Write == 0 && m.Rmw == 0 { + return fmt.Errorf("scenario %q: operations is set but every weight is 0; omit it for the all-rmw default", scenario) + } + if m.Read+m.Write+m.Rmw < m.Read { + return fmt.Errorf("scenario %q: operations weights sum past uint64", scenario) + } + return nil +} + // Select draws one operation in proportion to the configured weights. A zero -// total falls back to OpRmw, so an empty mix is the default rather than a +// total falls back to OpRmw, so an absent mix is the default rather than a // division by zero, and it draws no randomness. // // The comparison order (rmw, then read, then write) fixes which weight owns diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index fc47641..9c03fd6 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" "math/big" mrand "math/rand/v2" @@ -17,16 +18,32 @@ import ( const StorageRW = "storagerw" const ( - // storageRWBaseGas covers the cold-first-touch rmw (a cold SLOAD plus a - // zero-to-nonzero SSTORE, ~44k) and the fixed calldata head, with headroom. - // The pad's intrinsic cost is added per-tx on top; see package doc. + // storageRWBaseGas covers execution plus the fixed calldata head. Measured + // worst case is a read that first writes readAccumulator, at 46,269 including + // intrinsic; rmw and write cold-first-touch sit near 44k. 50k clears all + // three, but only by ~3.7k — and SSTORE_SET is a Sei governance parameter + // (SeiSstoreSetGasEip2200, default 20,000), so a raise past ~23.7k would put + // read out of gas. See package doc for why the limit is kept tight anyway. storageRWBaseGas = 50000 - // calldataZeroByteGas is the EIP-2028 intrinsic cost of one zero calldata - // byte. The pad is a zero-filled slice, so each pad byte costs exactly this. - calldataZeroByteGas = 4 // storageRWWriteValue is the constant value write stores. The load contract // never asserts on it. storageRWWriteValue = 1 + + // abiWord is the 32-byte unit the ABI right-pads a dynamic argument up to, + // so the pad reaches the wire as a whole number of words. + abiWord = 32 + // calldataFloorGasPerByte is what a zero calldata byte costs under EIP-7623, + // which is live on Sei (PragueTime is 0). The floor is 21000 + 10 per token + // and a zero byte is one token, so charging 10 per padded pad byte on top of + // the base always clears it: the base exceeds 21000 by more than the head's + // worst-case token cost. + // + // The pre-Prague rate of 4 would be short above roughly 4.5 KiB of pad, and + // Sei's ante checks only the intrinsic cost, not the floor — so such a tx is + // admitted, reserves its full declared limit, then fails in execution with + // GasUsed equal to the limit. It lands in a block as an included failure and + // inflates the very gas-used metric the run reports. + calldataFloorGasPerByte = 10 ) // storageRWDefaultSlot is the single slot every tx targets when no key @@ -108,17 +125,21 @@ func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bin return nil, err } - // The pad's intrinsic calldata cost is the only gas the base does not - // already cover, so a large pad cannot underprovision the tx. - auth.GasLimit = storageRWBaseGas + uint64(len(pad))*calldataZeroByteGas + // Charge the pad at the EIP-7623 floor rate over its on-wire length, which + // the ABI rounds up to a whole word. + paddedPad := (uint64(len(pad)) + abiWord - 1) / abiWord * abiWord + auth.GasLimit = storageRWBaseGas + paddedPad*calldataFloorGasPerByte - switch s.pickOp(rng) { + op := s.pickOp(rng) + switch op { + case config.OpRmw: + return s.contract.Rmw(auth, slot, pad) case config.OpRead: return s.contract.Read(auth, slot, pad) case config.OpWrite: return s.contract.Write(auth, slot, big.NewInt(storageRWWriteValue), pad) default: - return s.contract.Rmw(auth, slot, pad) + return nil, fmt.Errorf("storagerw: no contract method for operation %d", op) } } diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 122141c..e5bc353 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -2,10 +2,13 @@ package scenarios_test import ( "encoding/json" + "fmt" "math/big" mrand "math/rand/v2" "testing" + "github.com/ethereum/go-ethereum/core" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-load/config" @@ -184,8 +187,7 @@ func TestStorageRWSizeBuckets(t *testing.T) { _, _, padLen := decodeStorageRW(t, tx.Data()) require.Contains(t, buckets, padLen) seen[padLen] = struct{}{} - // Gas covers the base plus the pad's intrinsic calldata cost. - require.Equal(t, uint64(50000+padLen*4), tx.Gas()) + requireGasCoversFloor(t, tx) } require.Len(t, seen, len(buckets), "every bucket should be drawn over 256 samples") } @@ -242,6 +244,7 @@ func TestStorageRWDefaultPathUnchanged(t *testing.T) { require.Zero(t, slot) require.Zero(t, padLen) require.Equal(t, uint64(50000), tx.Gas()) + requireGasCoversFloor(t, tx) } // The default path must consume no randomness: an untouched RNG at the same @@ -258,3 +261,96 @@ func TestStorageRWScenarioConfigAdditive(t *testing.T) { require.NotContains(t, string(encoded), "sizeBuckets") require.NotContains(t, string(encoded), "operations") } + +// requireGasCoversFloor asserts the declared limit clears both admission costs a +// transaction has to satisfy: the intrinsic cost the ante checks, and the +// EIP-7623 calldata floor it does not. The floor is the one that matters here — +// Sei admits a transaction whose limit is below it, then fails execution with +// GasUsed equal to the limit, so the failure lands in a block as an included tx +// and inflates the gas-used metric a run reports. +func requireGasCoversFloor(t *testing.T, tx *ethtypes.Transaction) { + t.Helper() + + intrinsic, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), nil, false, true, true, true) + require.NoError(t, err) + require.GreaterOrEqualf(t, tx.Gas(), intrinsic, + "gas limit %d is below the intrinsic cost %d for %d bytes of calldata", + tx.Gas(), intrinsic, len(tx.Data())) + + floor, err := core.FloorDataGas(tx.Data()) + require.NoError(t, err) + require.GreaterOrEqualf(t, tx.Gas(), floor, + "gas limit %d is below the EIP-7623 floor %d for %d bytes of calldata", + tx.Gas(), floor, len(tx.Data())) +} + +// TestStorageRWGasClearsFloorAcrossPadSizes sweeps the pad from empty to the +// configured cap and asserts every transaction is fundable. The pre-Prague rate +// of 4 gas per pad byte fails this from roughly 4.6 KiB upward, which is inside +// the range sizeBuckets invites. +func TestStorageRWGasClearsFloorAcrossPadSizes(t *testing.T) { + for _, pad := range []int{0, 1, 31, 32, 1024, 4096, 4544, 4609, 8192, 65536, 128 << 10} { + t.Run(fmt.Sprintf("pad=%d", pad), func(t *testing.T) { + for _, mix := range []*config.OperationMix{{Rmw: 1}, {Read: 1}, {Write: 1}} { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + SizeDistribution: uniformDist(t), + SizeBuckets: []int{pad}, + Operations: mix, + }) + tx, err := gen.Generate(newTestRng(1), txs) + require.NoError(t, err) + requireGasCoversFloor(t, tx) + } + }) + } +} + +// TestStorageRWDrawOrderIsStable pins the documented draw order — slot, then +// pad, then operation — with all three axes live. Reordering the picks changes +// this sequence, which is what makes a saved workload replayable; without a +// golden the order is documented but unguarded. +func TestStorageRWDrawOrderIsStable(t *testing.T) { + scenario := config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 64, + SizeDistribution: uniformDist(t), + SizeBuckets: []int{0, 32, 96}, + Operations: &config.OperationMix{Rmw: 1, Read: 1, Write: 1}, + } + want := []struct { + method string + slot uint64 + pad int + }{ + {"rmw", 50, 96}, + {"read", 30, 32}, + {"rmw", 18, 96}, + {"rmw", 46, 32}, + {"rmw", 31, 96}, + {"rmw", 10, 0}, + {"read", 12, 96}, + {"write", 24, 96}, + } + + draw := func() []string { + gen, txs := newAttachedStorageRW(t, scenario) + rng := newTestRng(2026) + out := make([]string, len(want)) + for i := range out { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + method, slot, pad := decodeStorageRW(t, tx.Data()) + out[i] = fmt.Sprintf("%s/%d/%d", method, slot, pad) + } + return out + } + + golden := make([]string, len(want)) + for i, w := range want { + golden[i] = fmt.Sprintf("%s/%d/%d", w.method, w.slot, w.pad) + } + require.Equal(t, golden, draw()) + + // Same seed, a fresh scenario: the sequence repeats. + require.Equal(t, golden, draw()) +} diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index b0ba354..618dc95 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -67,21 +67,34 @@ // axis changes how many draws each transaction takes, which shifts the other // axes — see the config package doc on what the seed does and does not promise. // -// Gas sizing. All three operations share one base GasLimit of 50k. The rmw is -// the costliest: an SLOAD plus an SSTORE on a single slot, ~26k warm and ~44k on -// a cold first touch, where the cold SLOAD and the zero-to-nonzero SSTORE both -// charge their higher rates. write is an SSTORE without the load, and read folds -// its load into readAccumulator, so both sit under the rmw ceiling — 50k covers -// the worst of the three with headroom for the fixed calldata head. -// -// One limit for all three trades a little slack on read and write for a single +// read writes too, and that bounds what the key axis can show. Every read folds +// its load into readAccumulator — one contract-wide slot — so reads conflict with +// each other no matter which key they drew. A read-weighted mix therefore does +// not sweep contention; only rmw and write do. Reads also measure absent slots +// until something has written them, since a fresh deploy starts empty and there +// is no warm-up phase. +// +// Gas sizing. All three operations share one base GasLimit of 50k. The measured +// worst case is a read that first writes readAccumulator, at 46,269 including +// intrinsic cost; rmw and write cold-first-touch sit near 44k. So 50k clears all +// three, but by only ~3.7k — and SSTORE_SET is a Sei governance parameter +// (SeiSstoreSetGasEip2200, default 20,000), so a raise past roughly 23.7k would +// put read out of gas. Widening the keyspace also makes cold first touches the +// normal case rather than the exception, which is the regime this headroom has to +// survive. +// +// One limit for all three trades slack on the cheaper operations for a single // number to reason about. Density is why the number is tight at all: it packs // roughly 4x denser than the 200k default in CreateTransactionOpts, and on a -// gas-limit-admission chain a block admits transactions up to its gas limit -// regardless of gas actually used, so an oversized limit reserves block space -// the transaction never spends and throttles achievable throughput. -// -// The drawn pad adds its own intrinsic calldata cost — 4 gas per zero pad byte, -// EIP-2028 — on top of the base, so a large pad cannot underprovision the tx -// while an empty pad leaves the limit at exactly 50k. +// gas-limit-admission chain a block admits transactions up to their declared +// limit regardless of gas actually used, so an oversized limit reserves block +// space the transaction never spends and throttles achievable throughput. +// +// The drawn pad is charged at 10 gas per on-wire byte on top of the base. That is +// the EIP-7623 floor rate, which is live on Sei, and it is the binding cost above +// roughly 4.6 KiB of pad. Sei's ante checks only the intrinsic cost, so a limit +// sized to the older 4-gas rate is admitted, reserves its full limit, then fails +// in execution with GasUsed equal to the limit — an included failure that +// inflates the gas-used metric the run reports. An empty pad leaves the limit at +// exactly 50k. package scenarios diff --git a/profiles/profiles_test.go b/profiles/profiles_test.go index b02c105..d8eeb0c 100644 --- a/profiles/profiles_test.go +++ b/profiles/profiles_test.go @@ -87,6 +87,13 @@ func TestProfilesAlignment(t *testing.T) { return } + // Committed profiles must satisfy the same scenario invariants a + // run enforces, so a bad profile fails CI rather than a load test. + if err := strictConfig.ValidateScenarios(); err != nil { + t.Errorf("Profile %s fails scenario validation: %v", file.Name(), err) + return + } + t.Logf("✓ Profile %s successfully validated", file.Name()) }) }