mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 23:38:08 +00:00
503 lines
19 KiB
C++
503 lines
19 KiB
C++
#include <xrpl/protocol/Fees.h>
|
|
#include <xrpl/tx/wasm/HostFunc.h>
|
|
#include <xrpl/tx/wasm/WasmCommon.h>
|
|
#include <xrpl/tx/wasm/WasmVM.h>
|
|
|
|
#include <benchmark/benchmark.h>
|
|
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
|
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
|
#include <tx/wasm/fixtures/WasmLedger.h>
|
|
#include <tx/wasm/fixtures/WasmRun.h>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <format>
|
|
#include <map>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <tuple>
|
|
#include <utility>
|
|
|
|
namespace xrpl::test::bench {
|
|
namespace {
|
|
|
|
// What `bytecodeSizeLimit` costs a run's `gasLimit`, and what the pair of them costs a validator.
|
|
//
|
|
// wasmi 2.0 bills **7 fuel per byte of reached function body** as it lowers that body to IR, out of
|
|
// the run's own fuel. The charge lands on the first call into each function, so it is paid during
|
|
// the run rather than at preflight — which couples the two limits: `7 x reached-code-size` is a
|
|
// floor a contract pays before executing one instruction of its own. README.md has the decision
|
|
// rule these cases are meant to feed.
|
|
|
|
// `Config.h`'s defaults. Hard-coded because `src/xrpld` is not on a libxrpl benchmark's include
|
|
// path — keep in step with `Config::gasLimit` and `Config::bytecodeSizeLimit`.
|
|
constexpr std::int64_t kDefaultGasLimit = 1'000'000;
|
|
constexpr std::int64_t kDefaultBytecodeSizeLimit = 100'000;
|
|
|
|
// Filler bodies per module, held constant across the sweep so that what varies with the target size
|
|
// is body *bytes* and not function count — the rate under test is per byte. Sixteen keeps every
|
|
// body well inside wasmparser's per-function ceiling even at the largest size swept.
|
|
constexpr std::size_t kFillerFunctions = 16;
|
|
|
|
// `check_sig` calls the size sweep's entry point makes. Constant across the sweep, so it moves
|
|
// `charged_gas` by a fixed amount and never its slope; small, so it does not swamp the wall time
|
|
// the sweep is there to show. `worstCase` below is where the count is turned up instead.
|
|
constexpr int kSweepWorkCalls = 4;
|
|
|
|
// `check_sig` charged 300 gas against a suggested 26,188. It is both the
|
|
// most expensive thing a contract can ask for and the most underpriced, which is exactly the
|
|
// combination a limit has to be chosen against.
|
|
constexpr std::string_view kImport =
|
|
R"( (import "host_lib" "check_sig" (func $check_sig (param i32 i32 i32 i32 i32 i32) (result i32)))
|
|
)";
|
|
|
|
constexpr int kMessageOffset = 0;
|
|
constexpr int kSignatureOffset = 256;
|
|
constexpr int kPubkeyOffset = 512;
|
|
|
|
// The signed message seeded into guest memory, and the call that verifies it. Offsets and shape
|
|
// follow host_functions/CheckSignature.cpp; a wrong argument would take the rejection path, which
|
|
// is far cheaper and would quietly halve every number here.
|
|
std::string const&
|
|
workData()
|
|
{
|
|
static auto const kData = [] {
|
|
auto const& m = Fixtures::instance().signedMessage();
|
|
return dataSegment(kMessageOffset, m.message) + dataSegment(kSignatureOffset, m.signature) +
|
|
dataSegment(kPubkeyOffset, m.publicKey);
|
|
}();
|
|
return kData;
|
|
}
|
|
|
|
std::string const&
|
|
workCall()
|
|
{
|
|
static auto const kCall = [] {
|
|
auto const& m = Fixtures::instance().signedMessage();
|
|
return std::format(
|
|
" (local.set $r (call $check_sig (i32.const {}) (i32.const {}) (i32.const {}) "
|
|
"(i32.const {}) (i32.const {}) (i32.const {})))\n",
|
|
kMessageOffset,
|
|
m.message.size(),
|
|
kSignatureOffset,
|
|
m.signature.size(),
|
|
kPubkeyOffset,
|
|
m.publicKey.size());
|
|
}();
|
|
return kCall;
|
|
}
|
|
|
|
// A function that is `deadBytes` bigger than it needs to be, and no more expensive to run.
|
|
//
|
|
// Everything after the `return` is unreachable, so executing this body costs two instructions
|
|
// whatever its size — but *translating* it walks the whole body, and the fuel charged is the length
|
|
// of the body slice. That separation is the point: it isolates the translation rate from execution,
|
|
// and it is also the adversarial shape, since a contract maximizing translation cost per byte would
|
|
// look exactly like this.
|
|
std::string
|
|
fillerWat(std::size_t index, std::size_t deadBytes)
|
|
{
|
|
auto out = std::format(" (func $f{0} (result i32)\n (return (i32.const {0}))\n ", index);
|
|
out.reserve(out.size() + (deadBytes * 5) + 32);
|
|
for (auto i = 0uz; i < deadBytes; ++i)
|
|
{
|
|
// One byte of body each (opcode 0x01), so the binary grows by exactly `deadBytes`.
|
|
out += "(nop)";
|
|
}
|
|
out += "\n (i32.const 0))\n";
|
|
return out;
|
|
}
|
|
|
|
// The entry point: call the first `reached` fillers, forcing each to be translated, then verify
|
|
// `calls` signatures.
|
|
//
|
|
// `compilation_mode` is `LazyTranslation` (vm.rs), so a declared-but-never-called function is
|
|
// validated at compile time and translated never. Calling it is what makes its bytes cost fuel,
|
|
// which is why `reached` is a parameter rather than "all of them".
|
|
std::string
|
|
entryWat(std::size_t reached, int calls)
|
|
{
|
|
auto out = std::string{
|
|
" (func (export \"escrow_finish\") (result i32)\n"
|
|
" (local $r i32)\n"};
|
|
for (auto i = 0uz; i < reached; ++i)
|
|
{
|
|
out += std::format(" (drop (call $f{}))\n", i);
|
|
}
|
|
for (auto i = 0; i < calls; ++i)
|
|
{
|
|
out += workCall();
|
|
}
|
|
out += " (local.get $r))\n";
|
|
return out;
|
|
}
|
|
|
|
std::string
|
|
moduleWat(std::size_t deadTotal, std::size_t reached, int calls)
|
|
{
|
|
auto out = std::format("(module\n{} (memory (export \"memory\") 1)\n{}", kImport, workData());
|
|
auto const each = deadTotal / kFillerFunctions;
|
|
auto const extra = deadTotal % kFillerFunctions;
|
|
for (auto i = 0uz; i < kFillerFunctions; ++i)
|
|
{
|
|
out += fillerWat(i, each + (i == 0 ? extra : 0));
|
|
}
|
|
out += entryWat(reached, calls);
|
|
out += ")\n";
|
|
return out;
|
|
}
|
|
|
|
// The largest module of this shape that fits in `targetBytes`.
|
|
//
|
|
// Each `(nop)` is one binary byte, so size is `base + deadTotal` up to the odd byte where a LEB
|
|
// length prefix widens; solving is therefore one guess and a correction rather than a search. The
|
|
// loop is bounded and keeps the best under-target candidate, so a shape whose overhead moves does
|
|
// not turn into a hang.
|
|
//
|
|
// Padding is what makes the work count and the size limit independent: adding a `check_sig` call
|
|
// displaces nops rather than growing the module, so a run's translation charge stays pinned to the
|
|
// size limit while its execution charge varies.
|
|
Bytes const&
|
|
moduleOfSize(std::int64_t targetBytes, std::size_t reached, int calls)
|
|
{
|
|
// Cached so that `--benchmark_repetitions` re-enters a case without re-assembling megabytes of
|
|
// WAT, and so `worstCase`'s probes and the case they size are built once between them.
|
|
static auto cache = std::map<std::tuple<std::int64_t, std::size_t, int>, Bytes>{};
|
|
auto const key = std::tuple{targetBytes, reached, calls};
|
|
if (auto const found = cache.find(key); found != cache.end())
|
|
{
|
|
return found->second;
|
|
}
|
|
|
|
auto dead = std::size_t{0};
|
|
auto candidate = assembleWat(moduleWat(dead, reached, calls));
|
|
if (static_cast<std::int64_t>(candidate.size()) > targetBytes)
|
|
{
|
|
fixtureFailed("the module's fixed overhead already exceeds the target size");
|
|
}
|
|
|
|
auto best = candidate;
|
|
for (auto round = 0; round < 8; ++round)
|
|
{
|
|
auto const diff = targetBytes - static_cast<std::int64_t>(candidate.size());
|
|
if (diff == 0)
|
|
{
|
|
break;
|
|
}
|
|
if (diff < 0 && -diff > static_cast<std::int64_t>(dead))
|
|
{
|
|
break;
|
|
}
|
|
dead = static_cast<std::size_t>(static_cast<std::int64_t>(dead) + diff);
|
|
candidate = assembleWat(moduleWat(dead, reached, calls));
|
|
if (static_cast<std::int64_t>(candidate.size()) <= targetBytes &&
|
|
candidate.size() > best.size())
|
|
{
|
|
best = candidate;
|
|
}
|
|
}
|
|
|
|
return cache.emplace(key, std::move(best)).first->second;
|
|
}
|
|
|
|
// One run at a caller-chosen budget. `timeRun` always passes `kBenchGas`, which is the right
|
|
// default everywhere else and wrong here: `worstCase` is about what fits inside a real `gasLimit`.
|
|
Timing
|
|
timeRunWithGas(HostFunctions& host, Bytes const& wasm, std::int64_t gas)
|
|
{
|
|
auto const start = std::chrono::steady_clock::now();
|
|
auto outcome = runEscrowWasm(wasm, host, gas);
|
|
auto const elapsed = std::chrono::steady_clock::now() - start;
|
|
|
|
benchmark::DoNotOptimize(outcome);
|
|
return {
|
|
.seconds = std::chrono::duration<double>(elapsed).count(),
|
|
.gas = outcome.has_value() ? outcome->cost : std::int64_t{0}};
|
|
}
|
|
|
|
// Fuel a run spends on nothing but being large: this module's charge minus that of a same-sized
|
|
// module with no filler reached.
|
|
//
|
|
// Both are solved to the same target, so they differ only in which bodies get translated and in the
|
|
// entry point's `call` instructions. The difference is the translation charge plus the handful of
|
|
// fuel those calls cost.
|
|
std::int64_t
|
|
translationGas(std::int64_t targetBytes, int calls, std::int64_t chargedGas)
|
|
{
|
|
auto host = Fixtures::instance().host();
|
|
auto const twin = runEscrowWasm(moduleOfSize(targetBytes, 0, calls), *host, kBenchGas);
|
|
return twin.has_value() ? chargedGas - twin->cost : 0;
|
|
}
|
|
|
|
// Accumulate a whole-run case and turn it into counters.
|
|
//
|
|
// Unlike the host-function harness this subtracts nothing: the subject is the whole run, and the
|
|
// question is what that run costs against the two ceilings it has to fit inside.
|
|
class SizedRun
|
|
{
|
|
public:
|
|
SizedRun(benchmark::State& state, std::int64_t moduleBytes, std::int64_t gasLimit)
|
|
: state_{state}, moduleBytes_{moduleBytes}, gasLimit_{gasLimit}
|
|
{
|
|
}
|
|
|
|
void
|
|
add(Timing const& timing)
|
|
{
|
|
state_.SetIterationTime(timing.seconds);
|
|
total_ += timing.seconds;
|
|
sumSquares_ += timing.seconds * timing.seconds;
|
|
gasTotal_ += static_cast<double>(timing.gas);
|
|
++rounds_;
|
|
}
|
|
|
|
// `translationOf` is the target size to price translation against, or zero to leave the
|
|
// translation counters off — they cost an extra untimed run to produce.
|
|
void
|
|
report(std::int64_t translationOf, int calls)
|
|
{
|
|
if (rounds_ == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
auto const count = static_cast<double>(rounds_);
|
|
auto const mean = total_ / count;
|
|
auto const variance = std::max(0.0, (sumSquares_ / count) - (mean * mean));
|
|
auto const spread = mean > 0.0 ? std::sqrt(variance) / mean : 0.0;
|
|
auto const charged = gasTotal_ / count;
|
|
|
|
auto const& calibration = Calibration::instance();
|
|
auto const perGas = calibration.secondsPerGas();
|
|
auto const equivalent = perGas > 0.0 ? mean / perGas : 0.0;
|
|
|
|
state_.counters["module_bytes"] = static_cast<double>(moduleBytes_);
|
|
state_.counters["charged_gas"] = charged;
|
|
state_.counters["gas_per_byte"] =
|
|
moduleBytes_ > 0 ? charged / static_cast<double>(moduleBytes_) : 0.0;
|
|
state_.counters["ns_per_op"] = mean * 1e9;
|
|
// The run's wall time in the units the fuel meter uses, so a charge and its cost can be
|
|
// compared. Unlike the host-function cases this covers stages the guest is *not* charged
|
|
// for — compile, instantiate, teardown — so it reads high against `charged_gas` by
|
|
// construction.
|
|
state_.counters["gas_equivalent"] = equivalent;
|
|
// Above 1, the run is billed more fuel than its wall time is worth at the rate a guest
|
|
// instruction is priced at; below 1, less.
|
|
state_.counters["charge_ratio"] = equivalent > 0.0 ? charged / equivalent : 0.0;
|
|
// **The answer to the limits question.** At 100 the contract's whole budget goes to getting
|
|
// itself running, and past 100 it cannot be finished at all.
|
|
state_.counters["pct_gas_limit"] = 100.0 * charged / static_cast<double>(gasLimit_);
|
|
|
|
if (translationOf > 0)
|
|
{
|
|
auto const translation = static_cast<double>(
|
|
translationGas(translationOf, calls, static_cast<std::int64_t>(charged)));
|
|
state_.counters["translation_gas"] = translation;
|
|
state_.counters["translation_share"] = charged > 0.0 ? translation / charged : 0.0;
|
|
}
|
|
|
|
auto const caseStdErr = spread / std::sqrt(count);
|
|
auto const perGasErr = calibration.secondsPerGasRelStdErr();
|
|
auto const totalErr = std::sqrt((caseStdErr * caseStdErr) + (perGasErr * perGasErr));
|
|
state_.counters["rel_error"] = totalErr;
|
|
state_.counters["unreliable"] = totalErr > kMaxRelativeSpread ? 1 : 0;
|
|
}
|
|
|
|
private:
|
|
benchmark::State& state_;
|
|
std::int64_t moduleBytes_{};
|
|
std::int64_t gasLimit_{};
|
|
double total_{};
|
|
double sumSquares_{};
|
|
double gasTotal_{};
|
|
std::int64_t rounds_{};
|
|
};
|
|
|
|
// One whole `runEscrowWasm` per iteration, unamortized: compile, instantiate, translate on first
|
|
// call, run, read the meter.
|
|
void
|
|
benchmarkSizedRun(
|
|
benchmark::State& state,
|
|
Bytes const& wasm,
|
|
std::int64_t gas,
|
|
std::int64_t gasLimit,
|
|
std::int64_t translationOf,
|
|
int calls)
|
|
{
|
|
// Force the shared calibration before the clock starts, as `benchmarkRun` does.
|
|
[[maybe_unused]] auto const& calibration = Calibration::instance();
|
|
|
|
auto probe = Fixtures::instance().host();
|
|
auto const check = runEscrowWasm(wasm, *probe, gas);
|
|
if (!check.has_value())
|
|
{
|
|
state.SkipWithError("the benchmarked contract did not run to completion");
|
|
return;
|
|
}
|
|
// A soft host error still completes the run, and the rejection path is far cheaper than the
|
|
// verification — the case would report a confident number for work it never did.
|
|
if (check->result <= 0)
|
|
{
|
|
state.SkipWithError(
|
|
"check_sig answered " + std::to_string(check->result) +
|
|
"; the case would be measuring the rejection path, not the verification");
|
|
return;
|
|
}
|
|
|
|
auto run = SizedRun{state, static_cast<std::int64_t>(wasm.size()), gasLimit};
|
|
for (auto _ : state)
|
|
{
|
|
auto host = Fixtures::instance().host();
|
|
run.add(timeRunWithGas(*host, wasm, gas));
|
|
}
|
|
run.report(translationOf, calls);
|
|
}
|
|
|
|
void
|
|
sweep(benchmark::State& state, std::int64_t targetBytes, std::size_t reached)
|
|
{
|
|
benchmarkSizedRun(
|
|
state,
|
|
moduleOfSize(targetBytes, reached, kSweepWorkCalls),
|
|
kBenchGas,
|
|
kDefaultGasLimit,
|
|
reached > 0 ? targetBytes : 0,
|
|
kSweepWorkCalls);
|
|
}
|
|
|
|
// Every declared function reached, so the module pays translation on all of its bytes. The worst
|
|
// case a size limit has to survive, and the row to read against `gasLimit`.
|
|
void
|
|
fullyReached(benchmark::State& state)
|
|
{
|
|
sweep(state, state.range(0), kFillerFunctions);
|
|
}
|
|
BENCHMARK(fullyReached)
|
|
->UseManualTime()
|
|
->Iterations(kBenchIterations)
|
|
->Arg(25'000)
|
|
->Arg(50'000)
|
|
->Arg(kDefaultBytecodeSizeLimit)
|
|
->Arg(kMaxBytecodeSizeLimit);
|
|
|
|
// The same sizes with nothing reached: validated, never translated, so none of those bytes are
|
|
// charged. Against `fullyReached` per size, this separates what a big module *costs* the validator
|
|
// from what it *bills* the contract — the first is wall time nobody pays for, the second comes out
|
|
// of `gasLimit`.
|
|
void
|
|
neverReached(benchmark::State& state)
|
|
{
|
|
sweep(state, state.range(0), 0);
|
|
}
|
|
BENCHMARK(neverReached)
|
|
->UseManualTime()
|
|
->Iterations(kBenchIterations)
|
|
->Arg(25'000)
|
|
->Arg(50'000)
|
|
->Arg(kDefaultBytecodeSizeLimit)
|
|
->Arg(kMaxBytecodeSizeLimit);
|
|
|
|
// At the votable size ceiling, sweeping how much of the module a run actually reaches.
|
|
//
|
|
// `7 x bytecodeSizeLimit` is the bound, not the bill: a real contract runs one path and leaves the
|
|
// rest untranslated. This is the sweep to argue a *default* from, where `fullyReached` at
|
|
// `kMaxBytecodeSizeLimit` is the one to argue a *maximum* from. `Arg` is sixteenths of the module.
|
|
void
|
|
partiallyReached(benchmark::State& state)
|
|
{
|
|
auto const reached = static_cast<std::size_t>(state.range(0));
|
|
sweep(state, kMaxBytecodeSizeLimit, reached);
|
|
state.counters["reached_pct"] = 100.0 * static_cast<double>(reached) / kFillerFunctions;
|
|
}
|
|
BENCHMARK(partiallyReached)
|
|
->UseManualTime()
|
|
->Iterations(kBenchIterations)
|
|
->Arg(1)
|
|
->Arg(4)
|
|
->Arg(8)
|
|
->Arg(12);
|
|
|
|
// How many `check_sig` calls a fully-reached module of `sizeLimit` bytes can still afford inside
|
|
// `gasLimit`, once translation has taken its cut.
|
|
//
|
|
// Fuel is exact and the charge is linear in the call count, so two probes solve it: the slope is
|
|
// the per-call charge, the intercept is everything the run pays for existing. One call is then
|
|
// backed off if rounding put the answer over, which it can when a `data` segment or a LEB prefix
|
|
// shifts under the added instructions.
|
|
int
|
|
affordableCalls(std::int64_t sizeLimit, std::int64_t gasLimit)
|
|
{
|
|
auto chargeFor = [&](int calls) {
|
|
auto host = Fixtures::instance().host();
|
|
auto const outcome =
|
|
runEscrowWasm(moduleOfSize(sizeLimit, kFillerFunctions, calls), *host, kBenchGas);
|
|
if (!outcome.has_value())
|
|
{
|
|
fixtureFailed("the worst-case probe did not run to completion");
|
|
}
|
|
return outcome->cost;
|
|
};
|
|
|
|
static constexpr auto kLow = 4;
|
|
static constexpr auto kHigh = 20;
|
|
auto const low = chargeFor(kLow);
|
|
auto const high = chargeFor(kHigh);
|
|
auto const perCall = static_cast<double>(high - low) / (kHigh - kLow);
|
|
if (perCall <= 0.0)
|
|
{
|
|
fixtureFailed("the worst-case probes disagree about what a call costs");
|
|
}
|
|
auto const fixed = static_cast<double>(low) - (kLow * perCall);
|
|
|
|
auto calls = static_cast<int>(std::max(0.0, (static_cast<double>(gasLimit) - fixed) / perCall));
|
|
while (calls > 0 && chargeFor(calls) > gasLimit)
|
|
{
|
|
--calls;
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
// The whole point of the file: the longest a validator can be made to spend on one `EscrowFinish`
|
|
// at a given `(gasLimit, bytecodeSizeLimit)` pair. A maximal, fully-reached module that spends
|
|
// every remaining unit of fuel on the most underpriced call there is.
|
|
//
|
|
// Only pairs that can actually run are listed. Size voted to `kMaxBytecodeSizeLimit` against the
|
|
// default `gasLimit` is not among them: `fullyReached/200000` charges more than that budget holds,
|
|
// so such a contract cannot be finished at all. The two limits are voted independently through
|
|
// `FeeVoteImpl`, so that pairing is reachable — see README.md.
|
|
void
|
|
worstCase(benchmark::State& state)
|
|
{
|
|
auto const gasLimit = state.range(0);
|
|
auto const sizeLimit = state.range(1);
|
|
|
|
auto const calls = affordableCalls(sizeLimit, gasLimit);
|
|
if (calls <= 0)
|
|
{
|
|
state.SkipWithError("translation alone exhausts this gas limit; no work fits");
|
|
return;
|
|
}
|
|
|
|
benchmarkSizedRun(
|
|
state,
|
|
moduleOfSize(sizeLimit, kFillerFunctions, calls),
|
|
gasLimit,
|
|
gasLimit,
|
|
sizeLimit,
|
|
calls);
|
|
state.counters["check_sig_calls"] = calls;
|
|
state.counters["ms_per_op"] = state.counters["ns_per_op"] / 1e6;
|
|
}
|
|
BENCHMARK(worstCase)
|
|
->UseManualTime()
|
|
->Iterations(kBenchIterations)
|
|
->Args({kDefaultGasLimit, kDefaultBytecodeSizeLimit})
|
|
->Args({kMaxGasLimit, kMaxBytecodeSizeLimit})
|
|
->Args({kMaxGasLimit, kDefaultBytecodeSizeLimit});
|
|
|
|
} // namespace
|
|
} // namespace xrpl::test::bench
|