Skip to content
Merged
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
97 changes: 91 additions & 6 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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
}
111 changes: 111 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
4 changes: 2 additions & 2 deletions config/distribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions config/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
//
Expand All @@ -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.
Expand Down Expand Up @@ -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
67 changes: 67 additions & 0 deletions config/operation.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
bdchatham marked this conversation as resolved.
switch u := rng.Uint64N(total); {
case u < m.Rmw:
return OpRmw
case u < m.Rmw+m.Read:
return OpRead
default:
return OpWrite
}
}
Loading
Loading