Skip to content

Cut process-stage CPU and allocations across the indexer and processors - #685

Open
aditya1702 wants to merge 24 commits into
live-ingest-persist-pathfrom
live-ingest-processor-perf
Open

Cut process-stage CPU and allocations across the indexer and processors#685
aditya1702 wants to merge 24 commits into
live-ingest-persist-pathfrom
live-ingest-processor-perf

Conversation

@aditya1702

@aditya1702 aditya1702 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review commit-by-commit, in order. ~1 hour. No schema changes, no new flags, no behavior changes. Every risky rewrite has a differential test against the implementation it replaced.

Why

After #684, the process stage was the bottleneck for the p99 ≤ block-time target, and GC was 20–28% of ingest CPU. This PR only cuts CPU and allocations on that stage.

What changed

  1. Each operation's ledger changes are decoded once and shared. Before, every processor re-decoded them through the SDK. This is the biggest win: 40–50% less wall time per ledger in the process benchmark. The shared decode also feeds ContractData collection, which moved off the serial persist goroutine and now skips classic transactions entirely (only Soroban transactions can write ContractData).
  2. Transaction envelopes are hashed in parallel instead of single-threaded inside the SDK reader. ~3× faster ledger loading.
  3. State changes are built with less garbage. The builder is by-value instead of heap-allocated, account comparison stopped XDR-marshaling both entries, the XDR encoder is pooled, and dead output fields are deleted.
  4. Participants are deduped on raw ed25519 keys and the Soroban walk fills one accumulator instead of allocating a set per tree node.
  5. Hot conversions are memoized: one shared asset→contract-ID memo serves all processors, and address→BYTEA conversion is cached per COPY batch. Riding along: an oversized address payload now errors instead of silently truncating.

State-change ordinals are assigned with a counting map — linear even for the migration path, which passes a whole window at once.

How it's verified

Rewrite Gate
Parallel hashing Differential vs the SDK reader over every committed fixture
ContractData at process time Differential vs reader-based extraction on real ledgers
Participant dedupe Old implementation kept in the test file as oracle
Field-wise account comparison 28-case differential vs the SDK function
By-value builder, ordinals Existing exact-output suites; byte-identical IDs

Plus 583 lines of new sponsorship-effects coverage (previously none).

Numbers

Combined loadtest rig result for the campaign: 18,458 tx/s, process p99 0.992 s — this PR took process p99 under the 1 s bar. Individual PRs were not re-benchmarked.

Stack

Third of four PRs from the loadtest campaign (replaces #682). Review order: #679#684 → this. #686 is independent.

Follow-up, decided separately: the BALANCE_AUTHORIZATION change for a new trustline inherits trustline_limit_new from its sibling trustline change. Preserved byte-identically here; the field arguably doesn't belong there.

…per processor

Every state-change and effects processor independently called
Transaction.GetOperationChanges for the same operation, and the SDK
accessor re-decodes the op's meta, re-allocates, and re-sorts the
changes on every call — 19% of process-stage CPU in
GetChangesFromLedgerEntryChanges, 14% in sortChanges, and a large share
of GC pressure on a production profile. All of an operation's
processors share one TransactionOperationWrapper, so Changes() now
memoizes the extraction on the wrapper; all 14 call sites route through
it, audited read-only (the memoized slice is shared — callers must not
mutate it or write through Pre/Post). A wrapper copied to describe a
different operation resets its memo so the cache always matches Index.

BenchmarkProcessRealLedger on the five real-ledger fixtures: 40-50%
less wall time, ~50% fewer bytes and ~57% fewer allocations per ledger.
Pairing metas with envelopes needs every envelope's hash, and building it was
the pipeline's largest serial stretch: on the loadtest rig it ran ~0.37s
single-threaded ahead of the fan-out, 41% of the process stage, of which 85%
was the hashing itself. Reading transactions out of a LedgerCloseMeta now
hashes envelopes across the indexer's worker pool instead of on the caller's
goroutine.

Three costs went with it. The network id is a hash of the passphrase alone,
so it is computed once per ledger rather than once per transaction. Each
chunk marshals through one xdr.EncodingBuffer, which reuses its scratch space
across transactions instead of allocating a buffer per hash — this path alone
was 8.5% of everything the ingest process allocated. And the envelope's
transaction is tagged in place rather than copied by value.

On the real pubnet fixtures, where the fan-out barely engages at a few hundred
transactions per ledger: 2.8-3.8x faster, 4.3-8.6x fewer allocations.

Also restores the independence of the two reader-based oracles in the test
suite. They materialized their transactions through the same function they
were the oracle for, so they now go through getLedgerTransactionsViaReader,
which drives the SDK reader and shares no code with the fan-out. That helper
is the merge gate for this change too, via
TestGetLedgerTransactions_EquivalenceOnRealLedgers.
isLiquidityPool and isClaimableBalance ran a full strkey decode — base32,
CRC16 validation, and a byte copy of the input — on both endpoints of every
transfer event. Those endpoints are ordinary account and contract addresses
almost every time, so nearly all of that work only ever concluded "no".

A strkey version byte is a multiple of 8, so its top five bits are exactly
what the first base32 character encodes: only an 'L' can decode to a liquidity
pool and only a 'B' to a claimable balance, whatever the payload. Checking
that character first skips the decode for everything else, and a string that
passes it still goes through the decode, so validation is unchanged.

Together this was 13.6% of the token-transfer processor on the loadtest rig.
…wrapper memos

The persist stage extracted each ledger's ContractData changes through
tx.GetChanges(), which rebuilds and ledger-key-sorts every operation's change
group — work the process stage had already done and memoized on the shared
TransactionOperationWrapper. On the loadtest rig that duplicate path was 8.7s
of a 239.7s profile (3.6% of ingest CPU, plus its allocation/GC halo), all of
it on the serial persist goroutine.

processTransaction now collects each successful transaction's ContractData
changes from the wrappers' memoized Changes() slices, mirroring GetChanges'
composition per meta version (tx-level before, operations ascending, tx-level
after); transaction-level segments are only materialized when a type-tag scan
of the raw group mentions ContractData at all. Results fold into the ledger
buffer in transaction order and reach the persist stage on the buffer it
already carries, replacing the contractDataMemo and the transactions plumbing
through processedLedger; ProcessLedger no longer returns the materialized
transactions.

Merge gate: TestProcessLedgerTransactions_ContractDataChangesMatchReaderExtraction
compares the buffer's collection against the reader-based tx.GetChanges()
reference over every real-ledger fixture; synthetic tests pin the success
gate, the meta-version guard, wrapper-memo serving, and the no-wrapper
fallback.
AddressByteaMemo caches the 33-byte form per unique address string within one
COPY build, where addresses repeat heavily (a token's transfers share token_id,
an account's rows share account_id). A hit skips the strkey decode and the
33-byte allocation entirely: 278ns/200B/4allocs → 9.3ns/0B/0allocs.

AddressBytea.Value now rejects payloads that are not exactly 32 bytes instead
of silently truncating them (an M-address decodes to 40 bytes; unreachable
from current callers, defensive).
dedupeParticipants keyed a map on id.Address(), strkey-encoding every
participant just to throw the string away — the caller immediately re-encodes
the survivors. It now compares the raw ed25519 keys with a linear scan over the
kept prefix, filtering in place: zero allocations, zero encodes, and the output
keeps deterministic first-seen order instead of map order (downstream folds
into a set, so ordering was and stays unobservable).

The replaced implementation stays in the test file as a differential oracle.
GetOperationsParticipants hoists the envelope operations and ledger close time
out of the per-op loop, sizes its result map, and drops the unreachable
merge branch (opID embeds the operation index, so each iteration writes a
distinct key). Per-op participant sets are pre-sized. The metric-only
unique-participants map is gone: the tx-level participant set doubles as the
cardinality accumulator once its slice snapshot is taken.
…ator

The Soroban participant walk allocated a set per processor, per auth entry, and
per invocation-tree node, merging each level with Union (which allocates a third
set per call), and re-encoded the op source address in every sub-processor. The
whole path now adds into the single set GetOperationParticipants already built:
no intermediate sets, no Unions, one source encode per op.

Riding along, profile-guided:
- The per-node reflective utils.IsEmpty guard (which heap-escapes the whole
  invocation) is a typed nil-arm check: a zero-value invocation reports the
  ContractFn arm with a nil pointer, which GetContractFn would dereference.
- Sub-processors fetch MustInvokeHostFunctionOp once for both the host-function
  args and the auth entries (was two to three union-arm copies per op).
- Invocation nodes and auth entries are visited by pointer instead of by value.

Behavior is pinned by the existing exact-set expectations plus new tests for the
zero-value invocation and the muxed-source M-address form.
Every state_changes row decoded up to six strkey addresses from scratch, and
the values repeat heavily within one batch: every transfer of a token shares
its token_id and an account's fee/balance/effect rows share its account_id.
One AddressByteaMemo per BatchCopy call now serves all six columns, replacing
the pgtypeBytesFromNullAddressBytea helper whose only caller this was.
Participants are deduplicated per transaction/per operation upstream, so a busy
account is decoded once per tx or op it touches in transactions_accounts and
operations_accounts. One AddressByteaMemo per BatchCopyAccounts call collapses
that to one decode per unique address per batch.
SourceAccount() returned a pointer, heap-escaping a stack local on every call
in the common no-explicit-source branch — and it is called several times per
operation across the participants and effects paths. It now returns the
xdr.MuxedAccount by value; callers that need a pointer or addressability take
a local first.
TransactionOperationWrapper copied the whole ingest.LedgerTransaction (envelope,
result, meta, fee changes, ledger header) into every per-operation wrapper. The
field is now a pointer: GetOperationsParticipants materializes the transaction
once and every wrapper of the ledger transaction aliases it. The wrapper path
is read-only, and each per-transaction worker owns its transaction, so nothing
is shared across goroutines.

makeFeeBumpOp (test util) clones the transaction before rewriting the envelope
so a fee-bump variant cannot write through the base op's now-shared pointer.
AssignStateChangeOrdinals runs four times per transaction, and its group
counter map — sized to the stream — was allocated and discarded on every
call. Streams are a handful of changes, so counting earlier same-group
elements with a quadratic scan is cheaper and allocation-free, and
produces byte-identical IDs.
…nge path

- Sponsorship scans allocate their detail maps only after detecting a
  transition; accounts with no sponsored signers skip the SDK's
  SponsorPerSigner map-and-slice build entirely.
- EffectOutput drops five write-only fields (EffectID, EffectIndex,
  LedgerClosed, LedgerSequence, AddressMuxed) and the per-effect
  fmt.Sprintf that populated them; nothing reads them.
- Transaction hashes are hex-encoded at the rare log/error sites instead
  of eagerly per operation.
- Soroban-only processors check the operation type before installing
  their duration-metric closure, so classic operations pay neither the
  timestamps nor the label lookup; invalid-op-type calls no longer land
  in the histogram.
- ContractDeploy's dedupe map allocates on first deploy, not per call.
- Zero-effect operations return before the master builder is built.
Deriving a SAC contract ID costs a SHA-256 over the asset's contract-ID
preimage plus a strkey encode, and both the effects and token-transfer
processors recompute it for the same few assets on every event. Each
processor now memoizes by asset identity; entries are content-derived
under the processor's fixed network passphrase, so they never invalidate,
and sync.Map keeps the lookup safe across the indexer's pool workers.
The sponsorship effect paths had no coverage: no test exercised
addSignerSponsorshipEffects, addLedgerEntrySponsorshipEffects, or the
accountHasSponsoredSigner guard that spares the SponsorPerSigner build
for accounts sponsoring nothing. Covers every transition (created,
removed, updated, unchanged, whole-entry removal), the guard's
over-admit-never-over-reject contract, deterministic signer ordering,
and each sponsorable entry type's details shape.
Every NewStateChangeBuilder and Clone() heap-allocated a ~480-byte
builder, and the hot paths clone constantly — a plain payment paid four
heap builders for three state changes. The builder is now used by value:
With* methods mutate their own copy and return it, branching is plain
assignment, Clone() is gone, and builders stay on the stack. The
write-only metricsService field is dropped from the builder along with
the constructor parameter.

parseTrustline now returns the builder it produced so the
balance-authorization change emitted for a new trustline keeps
inheriting the resolved token and the trustline limit exactly as it did
through the shared pointer; whether that inherited limit belongs on a
BALANCE_AUTHORIZATION row is flagged in the PR as a follow-up decision.

Helper signatures across the effects, token-transfer, contract-deploy,
SAC, SEP-41, and Blend processors take the builder by value; output is
byte-identical everywhere.
The AddressMuxed removal left github.com/guregu/null unused module-wide,
and the sponsorship fixtures always build entries for sponsoredAccount,
so the account parameter comes off both helpers (unparam).
…tion XDR encoder

The signers-only skip asked the SDK's AccountChangedExceptSigners, which
deep-copies both account entries, XDR-marshals each, and compares the
bytes — 2.6% of ingest CPU and 5.7% of allocated bytes at ~18k tx/s. A
local field comparison preserves the exact semantics (only the Signers
slice is ignored; NumSubEntries and V2 signer-sponsoring IDs still
count; a missing extension equals V1 with zero liabilities), pinned by a
25-case differential test against the SDK function as oracle. The
always-nil error plumbing collapses with it.

ConvertOperation's per-operation XDR marshal allocated a fresh encoder
and growth-doubled buffer each call; a pooled EncodingBuffer reuses the
scratch across pool workers and allocates only the retained, exact-size
copy of each operation's bytes.
The quadratic scan's justifying comment — streams are a handful of changes
per transaction — is true for the indexer's per-transaction call but false
for the sep41 and blend processors, which assign ordinals over a whole
staged migration window at persist time, inside the CAS-guarded window
transaction, with nothing bounding the slice but the window. A counting
map keeps the assignment linear at every call site; IDs are byte-identical
to the scan's. The per-call map allocation is noise on the live path: four
calls per transaction against a process stage that allocates five orders
of magnitude more per ledger.
…s into functions

CreateContractV1OpProcessor, CreateContractV2OpProcessor, and
InvokeContractOpProcessor each held one field, had one method, and were
constructed and consumed on adjacent lines inside participantsForSorobanOp's
switch — with no other consumer in the tree. Each method also re-checked the
op type and host-function type the switch had already decided, and re-copied
the op body. They are now unexported functions taking the network passphrase
and the InvokeHostFunctionOp the caller already holds.
…n table missed

Three accountChangedExceptSigners branches had no reaching table row in
TestAccountChangedExceptSigners_MatchesSDK: a differing AccountId, an
InflationDest transitioning set to nil, and sponsoring-ID slices of
different length (nil vs one element). Each new row differs from the base
entry in exactly one field, so its verdict can only come from that field's
guard; the SDK oracle agrees on all three.
Only Soroban host functions write ContractData entries, so
transactionContractDataChanges now returns before walking or allocating
anything when the transaction carries no SorobanTransactionData — the
common case on a payment-dominated ledger. The per-transaction wrapper
lookup becomes a dense slice instead of a map, and the result map is
allocated on the first change actually collected instead of eagerly.
The reader-extraction equivalence test still passes over the real-ledger
fixtures, which pins that the gate drops nothing a Soroban transaction
produces.
…m one memo

AssetContractIDMemo is the package's single cache for the SHA-256-plus-
strkey derivation: token_transfer's hand-inlined sync.Map becomes a memo
field, and the two SAC comparison sites that recomputed the derivation per
event (isSACContract, trustlineEntryMatches) now read through a memo on the
processor. Three accessors normalize the three caller shapes onto one
complete key, so the same asset lands on one entry no matter which path
reaches it; the key's completeness and the accessor split's cost reasoning
are documented on the type. Trustline matching compares contract IDs
strkey-encoded — equivalent by injectivity — which retires a per-call
decode. The preimage path in contract_operations stays uncached: it fires
roughly once per asset lifetime (SAC deploys), not per event.
@aditya1702
aditya1702 force-pushed the live-ingest-processor-perf branch from c4aa67e to f6407a2 Compare August 22, 2026 13:40
@aditya1702
aditya1702 force-pushed the live-ingest-persist-path branch from fade6bb to 2d00170 Compare August 22, 2026 13:40
@aditya1702 aditya1702 self-assigned this Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant