diff --git a/config/config.go b/config/config.go index 7b0c6c4..61f1199 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"` } @@ -84,4 +91,82 @@ 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 behavior. + RecordCount uint64 `json:"recordCount,omitempty"` + // 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"` +} + +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 +// 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 1 MiB (%d-byte) cap", s.Name, i, n, maxCalldataPadBytes) + } + } + + 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 +// failed. loadConfig calls it after unmarshalling. +func (c *LoadConfig) ValidateScenarios() error { + for i := range c.Scenarios { + if err := c.Scenarios[i].Validate(); err != nil { + return err + } + } + return nil } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..f374e9e --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,111 @@ +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", 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", SizeDistribution: &Distribution{}, SizeBuckets: []int{maxCalldataPadBytes + 1}} + require.ErrorContains(t, s.Validate(), "cap") + }) + t.Run("valid accepted", func(t *testing.T) { + 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/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..e52ab82 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) // @@ -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. @@ -81,6 +90,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/config/operation.go b/config/operation.go new file mode 100644 index 0000000..4a7ec9e --- /dev/null +++ b/config/operation.go @@ -0,0 +1,67 @@ +package config + +import ( + "fmt" + mrand "math/rand/v2" +) + +// 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-weight or absent OperationMix selects rmw, matching the default. + OpRmw Operation = iota + OpRead + OpWrite +) + +// 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 +// mix falls back to rmw, the default. +type OperationMix struct { + Read uint64 `json:"read,omitempty"` + Write uint64 `json:"write,omitempty"` + 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 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 +// which sub-range of the draw. It is arbitrary but must stay stable, because +// changing it changes which operation a given draw selects. +func (m *OperationMix) Select(rng *mrand.Rand) Operation { + total := m.Read + m.Write + m.Rmw + if total == 0 { + return OpRmw + } + switch u := rng.Uint64N(total); { + case u < m.Rmw: + return OpRmw + case u < m.Rmw+m.Read: + return OpRead + default: + return OpWrite + } +} diff --git a/config/operation_test.go b/config/operation_test.go new file mode 100644 index 0000000..f898eb7 --- /dev/null +++ b/config/operation_test.go @@ -0,0 +1,104 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" +) + +// TestOperationMixEmptyFallsBackToRmw: a zero-weight mix selects rmw, the +// default, rather than dividing by a zero total. +func TestOperationMixEmptyFallsBackToRmw(t *testing.T) { + t.Parallel() + var m config.OperationMix + rng := newTestRng(1) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRmw, m.Select(rng)) + } +} + +// TestOperationMixEmptyDrawsNoRandomness: the zero-weight fallback returns +// before touching the RNG, so an unconfigured mix cannot perturb other draws +// that share the same stream. +func TestOperationMixEmptyDrawsNoRandomness(t *testing.T) { + t.Parallel() + var m config.OperationMix + rng := newTestRng(5) + for i := 0; i < 100; i++ { + m.Select(rng) + } + require.Equal(t, newTestRng(5).Uint64(), rng.Uint64()) +} + +// TestOperationMixHonorsWeights: a single-weighted op is selected exclusively, +// and a balanced mix reaches all three. +func TestOperationMixHonorsWeights(t *testing.T) { + t.Parallel() + + t.Run("single weight is exclusive", func(t *testing.T) { + t.Parallel() + m := config.OperationMix{Read: 1} + rng := newTestRng(1) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRead, m.Select(rng)) + } + }) + + t.Run("balanced mix reaches every op", func(t *testing.T) { + t.Parallel() + m := config.OperationMix{Read: 1, Write: 1, Rmw: 1} + rng := newTestRng(1) + seen := map[config.Operation]int{} + for i := 0; i < 3000; i++ { + seen[m.Select(rng)]++ + } + require.Positive(t, seen[config.OpRead]) + require.Positive(t, seen[config.OpWrite]) + require.Positive(t, seen[config.OpRmw]) + }) +} + +// TestOperationMixApproximatesWeights: over many draws the selection converges +// on the configured proportions, which is the property a weighted workload +// depends on. +func TestOperationMixApproximatesWeights(t *testing.T) { + t.Parallel() + const draws = 100_000 + m := config.OperationMix{Rmw: 5, Read: 3, Write: 2} + rng := newTestRng(9) + + seen := map[config.Operation]int{} + for i := 0; i < draws; i++ { + seen[m.Select(rng)]++ + } + + // Total weight is 10, so each op should land within a fraction of a point of + // its own weight once scaled back. The tolerance is loose enough not to flake + // and tight enough to catch a mis-ordered comparison chain. + for op, wantWeight := range map[config.Operation]float64{ + config.OpRmw: 5, + config.OpRead: 3, + config.OpWrite: 2, + } { + got := float64(seen[op]) / draws * 10 + require.InDelta(t, wantWeight, got, 0.15, "operation %d proportion", op) + } +} + +// TestOperationMixDeterminism: the same seed reproduces the selection sequence, +// so a run is repeatable when the RNG is seeded. +func TestOperationMixDeterminism(t *testing.T) { + t.Parallel() + draw := func() []config.Operation { + m := config.OperationMix{Read: 2, Write: 3, Rmw: 5} + rng := newTestRng(99) + out := make([]config.Operation, 256) + for i := range out { + out[i] = m.Select(rng) + } + return out + } + require.Equal(t, draw(), draw()) +} diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index fdfa7df..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" @@ -16,12 +17,39 @@ 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 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 + // 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 +// distribution is configured — the 100%-conflict default. +var storageRWDefaultSlot = big.NewInt(0) + // StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations type StorageRWScenario struct { *ContractScenarioBase[bindings.StorageRWv1] @@ -78,12 +106,78 @@ 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), calldata pad (tx size), +// 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, 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) { - // 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(rng) + if err != nil { + return nil, err + } + pad, err := s.pickPad(rng) + if err != nil { + return nil, err + } + + // 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 + + 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 nil, fmt.Errorf("storagerw: no contract method for operation %d", op) + } +} + +// pickSlot draws the storage slot from the key distribution over the configured +// RecordCount keyspace. With no key distribution it returns the fixed default +// slot and consumes no randomness. +func (s *StorageRWScenario) pickSlot(rng *mrand.Rand) (*big.Int, error) { + cfg := s.scenarioConfig + if cfg.KeyDistribution == nil || cfg.RecordCount == 0 { + return storageRWDefaultSlot, nil + } + idx, err := cfg.KeyDistribution.SampleIndex(rng, 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. With no size distribution it returns an +// empty pad and consumes no randomness. +func (s *StorageRWScenario) pickPad(rng *mrand.Rand) ([]byte, error) { + cfg := s.scenarioConfig + if cfg.SizeDistribution == nil || len(cfg.SizeBuckets) == 0 { + return nil, nil + } + bucket, err := cfg.SizeDistribution.SampleIndex(rng, uint64(len(cfg.SizeBuckets))) + if err != nil { + return nil, err + } + return make([]byte, cfg.SizeBuckets[bucket]), nil +} + +// pickOp selects read, write, or rmw from the configured mix. With no mix it +// returns rmw and consumes no randomness. +func (s *StorageRWScenario) pickOp(rng *mrand.Rand) config.Operation { + if s.scenarioConfig.Operations == nil { + return config.OpRmw + } + return s.scenarioConfig.Operations.Select(rng) } diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index a66de82..e5bc353 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -1,9 +1,14 @@ 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" @@ -65,7 +70,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. @@ -80,3 +85,272 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { require.NoError(t, err) require.Equal(t, rmwSelector, parsed.Methods["rmw"].ID) } + +// newAttachedStorageRW builds a StorageRW scenario from sc and attaches it at a +// known address under mock deploy, mirroring generator.mockDeployAll. It returns +// the generator and a tx scenario carrying a funded sender. +func newAttachedStorageRW(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *types.TxScenario) { + t.Helper() + sc.Name = scenarios.StorageRW + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + gen := scenarios.CreateScenario(sc) + require.NoError(t, gen.Attach(cfg, types.GenerateAccounts(1, false)[0].Address)) + return gen, &types.TxScenario{ + Name: scenarios.StorageRW, + Nonce: 0, + Sender: types.GenerateAccounts(1, true)[0], + } +} + +// decodeStorageRW unpacks StorageRW calldata through the binding's own ABI, so +// the assertions below read against method names rather than byte offsets. The +// slot is the first argument of every method and the pad is the last. +func decodeStorageRW(t *testing.T, data []byte) (method string, slot uint64, padLen int) { + t.Helper() + parsed, err := bindings.StorageRWv1MetaData.GetAbi() + require.NoError(t, err) + require.GreaterOrEqual(t, len(data), 4) + m, err := parsed.MethodById(data[:4]) + require.NoError(t, err) + args, err := m.Inputs.Unpack(data[4:]) + require.NoError(t, err) + require.GreaterOrEqual(t, len(args), 2) + return m.Name, args[0].(*big.Int).Uint64(), len(args[len(args)-1].([]byte)) +} + +// uniformDist unmarshals a uniform Distribution the way a profile would, so the +// tests exercise the same wire path operators use. +func uniformDist(t *testing.T) *config.Distribution { + t.Helper() + var d config.Distribution + require.NoError(t, json.Unmarshal([]byte(`{"Name":"uniform"}`), &d)) + return &d +} + +// TestStorageRWContentionSweep pins the contention continuum at both ends: a +// single-slot keyspace is total conflict, and a large keyspace spreads draws +// across many distinct slots. +func TestStorageRWContentionSweep(t *testing.T) { + t.Run("single slot keyspace is total conflict", func(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 1, + }) + rng := newTestRng(7) + for i := 0; i < 64; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + _, slot, _ := decodeStorageRW(t, tx.Data()) + require.Zero(t, slot) + } + }) + + t.Run("large keyspace spreads draws", func(t *testing.T) { + const keyspace = 10000 + gen, txs := newAttachedStorageRW(t, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: keyspace, + }) + rng := newTestRng(7) + seen := map[uint64]struct{}{} + for i := 0; i < 512; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + _, slot, _ := decodeStorageRW(t, tx.Data()) + require.Less(t, slot, uint64(keyspace)) + seen[slot] = struct{}{} + } + // 512 uniform draws over 10k slots collide rarely; anything near a single + // slot would mean the keyspace is not being indexed. + require.Greater(t, len(seen), 400) + }) +} + +// TestStorageRWSizeBuckets proves the size distribution selects calldata pad +// lengths from the configured histogram, and that gas scales with the pad. +func TestStorageRWSizeBuckets(t *testing.T) { + buckets := []int{0, 128, 1024} + gen, txs := newAttachedStorageRW(t, config.Scenario{ + SizeDistribution: uniformDist(t), + SizeBuckets: buckets, + }) + + rng := newTestRng(11) + seen := map[int]struct{}{} + for i := 0; i < 256; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + _, _, padLen := decodeStorageRW(t, tx.Data()) + require.Contains(t, buckets, padLen) + seen[padLen] = struct{}{} + requireGasCoversFloor(t, tx) + } + require.Len(t, seen, len(buckets), "every bucket should be drawn over 256 samples") +} + +// TestStorageRWOpMix proves the operation selector honors the configured mix: a +// single-weighted op is selected exclusively, and a balanced mix reaches all +// three methods. +func TestStorageRWOpMix(t *testing.T) { + t.Run("single weight is exclusive", func(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + Operations: &config.OperationMix{Write: 1}, + }) + rng := newTestRng(3) + for i := 0; i < 64; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + method, _, _ := decodeStorageRW(t, tx.Data()) + require.Equal(t, "write", method) + } + }) + + t.Run("balanced mix reaches every method", func(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + Operations: &config.OperationMix{Read: 1, Write: 1, Rmw: 1}, + }) + rng := newTestRng(3) + seen := map[string]int{} + for i := 0; i < 600; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + method, _, _ := decodeStorageRW(t, tx.Data()) + seen[method]++ + } + require.Positive(t, seen["read"]) + require.Positive(t, seen["write"]) + require.Positive(t, seen["rmw"]) + }) +} + +// TestStorageRWDefaultPathUnchanged pins the additive guarantee: a scenario with +// no distribution config produces exactly the pre-existing fixed rmw transaction +// and draws no randomness, so adding these fields cannot perturb an existing +// profile's workload. +func TestStorageRWDefaultPathUnchanged(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{}) + + rng := newTestRng(42) + for i := 0; i < 64; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + + method, slot, padLen := decodeStorageRW(t, tx.Data()) + require.Equal(t, "rmw", method) + 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 + // seed is still in lockstep with the one the generator was handed. + require.Equal(t, newTestRng(42).Uint64(), rng.Uint64()) +} + +// TestStorageRWScenarioConfigAdditive proves the new fields are omitempty, so a +// profile that does not set them round-trips without gaining keys. +func TestStorageRWScenarioConfigAdditive(t *testing.T) { + encoded, err := json.Marshal(config.Scenario{Name: scenarios.StorageRW, Weight: 1}) + require.NoError(t, err) + require.NotContains(t, string(encoded), "recordCount") + 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 8d1815c..618dc95 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -41,22 +41,60 @@ // are emitted by `make generate` from the contract bindings — do not edit that // block by hand. // -// # StorageRW scaffold -// -// 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. -// -// 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 -// 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. +// # StorageRW +// +// StorageRW exercises the SLOAD + SSTORE storage path under load against +// 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. +// +// 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 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/main.go b/main.go index d22fea0..28690dd 100644 --- a/main.go +++ b/main.go @@ -430,6 +430,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 } 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()) }) } 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.