Skip to content

Commit 91e8cec

Browse files
Merge pull request #411 from sonicfromnewyoke/sonic/perf-new-transaction
perf(transaction): add cap hints and use pk instead of str
2 parents 5ea9440 + 05c352e commit 91e8cec

2 files changed

Lines changed: 211 additions & 37 deletions

File tree

transaction.go

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,11 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr
257257
}
258258
}
259259

260-
addressLookupKeysMap := make(map[PublicKey]addressTablePubkeyWithIndex) // all accounts from tables as map
260+
totalTableEntries := 0
261+
for _, t := range options.addressTables {
262+
totalTableEntries += len(t)
263+
}
264+
addressLookupKeysMap := make(map[PublicKey]addressTablePubkeyWithIndex, totalTableEntries) // all accounts from tables as map
261265
sortedTableKeys := make(PublicKeySlice, 0, len(options.addressTables))
262266
for k := range options.addressTables {
263267
sortedTableKeys = append(sortedTableKeys, k)
@@ -284,8 +288,12 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr
284288
}
285289
}
286290

287-
programIDs := make(PublicKeySlice, 0)
288-
accounts := []*AccountMeta{}
291+
totalAccounts := 0
292+
for _, instruction := range instructions {
293+
totalAccounts += len(instruction.Accounts())
294+
}
295+
programIDs := make(PublicKeySlice, 0, len(instructions))
296+
accounts := make([]*AccountMeta, 0, totalAccounts+len(instructions))
289297
for _, instruction := range instructions {
290298
accounts = append(accounts, instruction.Accounts()...)
291299
programIDs.UniqueAppend(instruction.ProgramID())
@@ -315,8 +323,16 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr
315323
return 0
316324
})
317325

318-
uniqAccountsMap := map[PublicKey]uint64{}
319-
uniqAccounts := []*AccountMeta{}
326+
// Hint the map only above a threshold: for small txs, an empty map is
327+
// cheaper than a single pre-allocated bucket (~640B for PublicKey keys).
328+
// For larger txs, the hint avoids several bucket-grow operations.
329+
var uniqAccountsMap map[PublicKey]uint64
330+
if len(accounts) > 16 {
331+
uniqAccountsMap = make(map[PublicKey]uint64, len(accounts))
332+
} else {
333+
uniqAccountsMap = map[PublicKey]uint64{}
334+
}
335+
uniqAccounts := make([]*AccountMeta, 0, len(accounts))
320336
for _, acc := range accounts {
321337
if index, found := uniqAccountsMap[acc.PublicKey]; found {
322338
uniqAccounts[index].IsWritable = uniqAccounts[index].IsWritable || acc.IsWritable
@@ -426,9 +442,14 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr
426442
lookups := make([]MessageAddressTableLookup, 0, len(lookupsMap))
427443

428444
sortedLookupKeys := make(PublicKeySlice, 0, len(lookupsMap))
429-
for k := range lookupsMap {
445+
var totalWritable, totalReadonly int
446+
for k, l := range lookupsMap {
430447
sortedLookupKeys = append(sortedLookupKeys, k)
448+
totalWritable += len(l.Writable)
449+
totalReadonly += len(l.Readonly)
431450
}
451+
lookupsWritableKeys = make([]PublicKey, 0, totalWritable)
452+
lookupsReadOnlyKeys = make([]PublicKey, 0, totalReadonly)
432453
slices.SortFunc(sortedLookupKeys, func(a, b PublicKey) int {
433454
return bytes.Compare(a[:], b[:])
434455
})
@@ -475,6 +496,7 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr
475496
)
476497
}
477498

499+
message.Instructions = make([]CompiledInstruction, 0, len(instructions))
478500
for txIdx, instruction := range instructions {
479501
accounts = instruction.Accounts()
480502
accountIndex := make([]uint16, len(accounts))
@@ -506,10 +528,11 @@ func (tx *Transaction) MarshalBinary() ([]byte, error) {
506528
}
507529

508530
signatures := tx.Signatures
509-
for i := len(signatures); i < int(tx.Message.Header.NumRequiredSignatures); i++ {
510-
// append dummy signatures to the transaction, without it serialized transaction will be invalid
531+
if missing := int(tx.Message.Header.NumRequiredSignatures) - len(signatures); missing > 0 {
532+
// append zero-valued dummy signatures to the transaction, without them
533+
// the serialized transaction will be invalid.
511534
// reference: https://github.com/solana-labs/solana-web3.js/blob/4e9988cfc561f3ed11f4c5016a29090a61d129a8/src/transaction/versioned.ts#L36
512-
signatures = append(signatures, SignatureFromBytes(make([]byte, SignatureLength)))
535+
signatures = append(signatures, make([]Signature, missing)...)
513536
}
514537

515538
var signaturesCountBytes []byte
@@ -793,42 +816,18 @@ func countWriteableAccounts(tx *Transaction) (count int) {
793816
return count
794817
}
795818
numStaticKeys := len(tx.Message.AccountKeys)
796-
staticKeys := tx.Message.AccountKeys
797819
h := tx.Message.Header
798-
for _, key := range staticKeys {
799-
accIndex, ok := getStaticAccountIndex(tx, key)
800-
if !ok {
801-
continue
802-
}
803-
index := int(accIndex)
804-
is := false
805-
if index >= int(h.NumRequiredSignatures) {
806-
// Use int arithmetic to avoid underflow (Rust uses saturating_sub here).
807-
numWritableUnsigned := max(numStaticKeys-int(h.NumRequiredSignatures)-int(h.NumReadonlyUnsignedAccounts), 0)
808-
is = index-int(h.NumRequiredSignatures) < numWritableUnsigned
809-
} else {
810-
is = index < max(int(h.NumRequiredSignatures)-int(h.NumReadonlySignedAccounts), 0)
811-
}
812-
if is {
813-
count++
814-
}
815-
}
820+
numSig := int(h.NumRequiredSignatures)
821+
numWritableSigned := max(numSig-int(h.NumReadonlySignedAccounts), 0)
822+
numWritableUnsigned := max(numStaticKeys-numSig-int(h.NumReadonlyUnsignedAccounts), 0)
823+
count += numWritableSigned + numWritableUnsigned
816824
if tx.Message.IsResolved() {
817825
return count
818826
}
819827
count += tx.Message.NumWritableLookups()
820828
return count
821829
}
822830

823-
func getStaticAccountIndex(tx *Transaction, key PublicKey) (int, bool) {
824-
for idx, a := range tx.Message.AccountKeys {
825-
if a.Equals(key) {
826-
return (idx), true
827-
}
828-
}
829-
return -1, false
830-
}
831-
832831
func (tx *Transaction) IsVote() bool {
833832
// is vote if any of the instructions are of the vote program
834833
for _, inst := range tx.Message.Instructions {

transaction_bench_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package solana
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// buildBenchInstructions creates a realistic set of instructions for benchmarking
8+
// NewTransaction. Each instruction references a distinct program ID and mixes
9+
// signer/writable/readonly accounts so the builder exercises all header-counting
10+
// and indexing paths.
11+
//
12+
// numInstructions: how many instructions in the transaction.
13+
// accountsPerIx: how many AccountMeta entries each instruction references.
14+
// (first is always a signer; second is always writable; rest readonly)
15+
func buildBenchInstructions(numInstructions, accountsPerIx int) ([]Instruction, Hash) {
16+
// Pre-generate a pool of unique accounts so instructions share some accounts
17+
// (realistic — the same fee payer / writable state account appears in many ixs)
18+
// while still producing a meaningful total.
19+
poolSize := numInstructions * accountsPerIx / 2
20+
if poolSize < accountsPerIx {
21+
poolSize = accountsPerIx
22+
}
23+
pool := make([]PublicKey, poolSize)
24+
for i := range pool {
25+
pool[i] = newUniqueKey()
26+
}
27+
28+
feePayer := pool[0]
29+
30+
instructions := make([]Instruction, numInstructions)
31+
for i := 0; i < numInstructions; i++ {
32+
accounts := make(AccountMetaSlice, accountsPerIx)
33+
for j := 0; j < accountsPerIx; j++ {
34+
pk := pool[(i*accountsPerIx+j)%len(pool)]
35+
var isSigner, isWritable bool
36+
switch j {
37+
case 0:
38+
// fee payer — signer, writable
39+
pk = feePayer
40+
isSigner = true
41+
isWritable = true
42+
case 1:
43+
isWritable = true
44+
}
45+
accounts[j] = &AccountMeta{
46+
PublicKey: pk,
47+
IsSigner: isSigner,
48+
IsWritable: isWritable,
49+
}
50+
}
51+
instructions[i] = NewInstruction(
52+
newUniqueKey(), // distinct program ID per instruction
53+
accounts,
54+
[]byte{byte(i), 0xAA, 0xBB, 0xCC},
55+
)
56+
}
57+
58+
return instructions, Hash{1, 2, 3, 4, 5}
59+
}
60+
61+
// buildBenchInstructionsWithLookups is like buildBenchInstructions but also
62+
// prepares an address-lookup-table so most of the accounts get compiled into
63+
// ATL lookups instead of the static key list. This stresses the path where
64+
// lookupsWritableKeys / lookupsReadOnlyKeys are non-empty.
65+
func buildBenchInstructionsWithLookups(numInstructions, accountsPerIx int) ([]Instruction, Hash, map[PublicKey]PublicKeySlice) {
66+
instructions, blockhash := buildBenchInstructions(numInstructions, accountsPerIx)
67+
68+
// Collect all non-signer, non-program accounts into a single ATL.
69+
seen := make(map[PublicKey]struct{})
70+
var tableAccounts PublicKeySlice
71+
for _, ix := range instructions {
72+
for _, am := range ix.Accounts() {
73+
if am.IsSigner {
74+
continue
75+
}
76+
if _, ok := seen[am.PublicKey]; ok {
77+
continue
78+
}
79+
seen[am.PublicKey] = struct{}{}
80+
tableAccounts = append(tableAccounts, am.PublicKey)
81+
if len(tableAccounts) == 256 {
82+
break
83+
}
84+
}
85+
if len(tableAccounts) == 256 {
86+
break
87+
}
88+
}
89+
90+
tableKey := newUniqueKey()
91+
tables := map[PublicKey]PublicKeySlice{
92+
tableKey: tableAccounts,
93+
}
94+
return instructions, blockhash, tables
95+
}
96+
97+
// Realistic transaction shapes for solana-go benchmarks.
98+
// Upper bound is ~10 instructions / ~30 accounts per ix — beyond that
99+
// becomes a synthetic stress shape that doesn't represent real traffic.
100+
var benchTxShapes = []struct {
101+
name string
102+
numInstructions int
103+
accountsPerIx int
104+
}{
105+
{"small_2ix_5accts", 2, 5},
106+
{"medium_5ix_15accts", 5, 15},
107+
{"large_10ix_30accts", 10, 30},
108+
}
109+
110+
func BenchmarkNewTransaction(b *testing.B) {
111+
for _, tc := range benchTxShapes {
112+
tc := tc
113+
b.Run(tc.name, func(b *testing.B) {
114+
instructions, blockhash := buildBenchInstructions(tc.numInstructions, tc.accountsPerIx)
115+
b.ReportAllocs()
116+
b.ResetTimer()
117+
for i := 0; i < b.N; i++ {
118+
tx, err := NewTransaction(instructions, blockhash)
119+
if err != nil {
120+
b.Fatal(err)
121+
}
122+
_ = tx
123+
}
124+
})
125+
}
126+
}
127+
128+
func BenchmarkNewTransaction_WithLookupTable(b *testing.B) {
129+
for _, tc := range benchTxShapes {
130+
tc := tc
131+
b.Run(tc.name, func(b *testing.B) {
132+
instructions, blockhash, tables := buildBenchInstructionsWithLookups(tc.numInstructions, tc.accountsPerIx)
133+
opt := TransactionAddressTables(tables)
134+
b.ReportAllocs()
135+
b.ResetTimer()
136+
for i := 0; i < b.N; i++ {
137+
tx, err := NewTransaction(instructions, blockhash, opt)
138+
if err != nil {
139+
b.Fatal(err)
140+
}
141+
_ = tx
142+
}
143+
})
144+
}
145+
}
146+
147+
func BenchmarkTransaction_NumWriteableAccounts(b *testing.B) {
148+
// Legacy (non-versioned) takes the AccountMetaList path.
149+
b.Run("legacy_large", func(b *testing.B) {
150+
instructions, blockhash := buildBenchInstructions(10, 30)
151+
tx, err := NewTransaction(instructions, blockhash)
152+
if err != nil {
153+
b.Fatal(err)
154+
}
155+
b.ReportAllocs()
156+
b.ResetTimer()
157+
for i := 0; i < b.N; i++ {
158+
_ = tx.NumWriteableAccounts()
159+
}
160+
})
161+
162+
// V0 with ATL takes the static-key-scan path (the O(n^2) candidate).
163+
b.Run("v0_large", func(b *testing.B) {
164+
instructions, blockhash, tables := buildBenchInstructionsWithLookups(10, 30)
165+
tx, err := NewTransaction(instructions, blockhash, TransactionAddressTables(tables))
166+
if err != nil {
167+
b.Fatal(err)
168+
}
169+
b.ReportAllocs()
170+
b.ResetTimer()
171+
for i := 0; i < b.N; i++ {
172+
_ = tx.NumWriteableAccounts()
173+
}
174+
})
175+
}

0 commit comments

Comments
 (0)