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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,46 @@ 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 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 []int `json:"sizeBuckets,omitempty"`
// Operations is the read/write/rmw selection mix. Nil, the default, is
// all-rmw.
Operations *OperationMix `json:"operations,omitempty"`
}

// maxCalldataPadBytes caps each SizeBuckets entry. It guards against 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 the per-scenario invariants that a malformed config would
// otherwise surface as a hot-path panic or an OOM. Call it 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. Call it once 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
}
53 changes: 53 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package config

import (
"testing"

"github.com/stretchr/testify/require"
)

// TestScenarioValidateSizeBuckets: a negative pad length would panic makeslice
// on the hot path and an over-cap length risks an OOM, so both are rejected at
// load. A valid histogram, the cap boundary, and an absent bucket list pass.
func TestScenarioValidateSizeBuckets(t *testing.T) {
t.Parallel()

t.Run("negative rejected", func(t *testing.T) {
t.Parallel()
s := Scenario{Name: "s", SizeBuckets: []int{0, -1}}
require.ErrorContains(t, s.Validate(), "negative")
})

t.Run("over cap rejected", func(t *testing.T) {
t.Parallel()
s := Scenario{Name: "s", SizeBuckets: []int{maxCalldataPadBytes + 1}}
require.ErrorContains(t, s.Validate(), "cap")
})

t.Run("valid accepted", func(t *testing.T) {
t.Parallel()
s := Scenario{Name: "s", SizeBuckets: []int{0, 64, maxCalldataPadBytes}}
require.NoError(t, s.Validate())
})

t.Run("absent accepted", func(t *testing.T) {
t.Parallel()
require.NoError(t, (&Scenario{Name: "s"}).Validate())
})
}

// TestValidateScenariosReportsOffendingScenario: validation runs across every
// scenario and the error 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", SizeBuckets: []int{0, 32}},
{Name: "bad", SizeBuckets: []int{-1}},
}}
require.ErrorContains(t, cfg.ValidateScenarios(), `scenario "bad"`)

cfg.Scenarios[1].SizeBuckets = []int{16}
require.NoError(t, cfg.ValidateScenarios())
}
48 changes: 48 additions & 0 deletions config/operation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package config

import (
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"`
}

// 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
// division by zero.
//
// 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
}
}
104 changes: 104 additions & 0 deletions config/operation_test.go
Original file line number Diff line number Diff line change
@@ -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 point or so of its
// tenth. 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())
}
94 changes: 83 additions & 11 deletions generator/scenarios/StorageRW.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,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 (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 = 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 100%-conflict default.
var storageRWDefaultSlot = big.NewInt(0)

// StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations
type StorageRWScenario struct {
*ContractScenarioBase[bindings.StorageRWv1]
Expand Down Expand Up @@ -78,12 +89,73 @@ 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 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.
//
// 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.
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
}

// 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

switch s.pickOp(rng) {
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 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)
}
Loading
Loading