Skip to content

Commit 1229a5f

Browse files
Merge pull request #55 from multiversx/selection-by-ppu
Improve selection algorithm
2 parents 8704d43 + 68835d1 commit 1229a5f

35 files changed

+1913
-3140
lines changed

testscommon/txcachemocks/txGasHandlerMock.go

+41-45
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,52 @@
11
package txcachemocks
22

33
import (
4+
"math/big"
5+
6+
"github.com/multiversx/mx-chain-core-go/core"
47
"github.com/multiversx/mx-chain-core-go/data"
58
)
69

7-
// TxGasHandler -
8-
type TxGasHandler interface {
9-
SplitTxGasInCategories(tx data.TransactionWithFeeHandler) (uint64, uint64)
10-
GasPriceForProcessing(tx data.TransactionWithFeeHandler) uint64
11-
GasPriceForMove(tx data.TransactionWithFeeHandler) uint64
12-
MinGasPrice() uint64
13-
MinGasLimit() uint64
14-
MinGasPriceForProcessing() uint64
15-
IsInterfaceNil() bool
16-
}
17-
1810
// TxGasHandlerMock -
1911
type TxGasHandlerMock struct {
20-
MinimumGasMove uint64
21-
MinimumGasPrice uint64
22-
GasProcessingDivisor uint64
23-
}
24-
25-
// SplitTxGasInCategories -
26-
func (ghm *TxGasHandlerMock) SplitTxGasInCategories(tx data.TransactionWithFeeHandler) (uint64, uint64) {
27-
moveGas := ghm.MinimumGasMove
28-
return moveGas, tx.GetGasLimit() - moveGas
29-
}
30-
31-
// GasPriceForProcessing -
32-
func (ghm *TxGasHandlerMock) GasPriceForProcessing(tx data.TransactionWithFeeHandler) uint64 {
33-
return tx.GetGasPrice() / ghm.GasProcessingDivisor
34-
}
35-
36-
// GasPriceForMove -
37-
func (ghm *TxGasHandlerMock) GasPriceForMove(tx data.TransactionWithFeeHandler) uint64 {
38-
return tx.GetGasPrice()
39-
}
40-
41-
// MinGasPrice -
42-
func (ghm *TxGasHandlerMock) MinGasPrice() uint64 {
43-
return ghm.MinimumGasPrice
44-
}
45-
46-
// MinGasLimit -
47-
func (ghm *TxGasHandlerMock) MinGasLimit() uint64 {
48-
return ghm.MinimumGasMove
49-
}
50-
51-
// MinGasPriceProcessing -
52-
func (ghm *TxGasHandlerMock) MinGasPriceForProcessing() uint64 {
53-
return ghm.MinimumGasPrice / ghm.GasProcessingDivisor
12+
minGasLimit uint64
13+
minGasPrice uint64
14+
gasPerDataByte uint64
15+
gasPriceModifier float64
16+
}
17+
18+
// NewTxGasHandlerMock -
19+
func NewTxGasHandlerMock() *TxGasHandlerMock {
20+
return &TxGasHandlerMock{
21+
minGasLimit: 50000,
22+
minGasPrice: 1000000000,
23+
gasPerDataByte: 1500,
24+
gasPriceModifier: 0.01,
25+
}
26+
}
27+
28+
// WithGasPriceModifier -
29+
func (ghm *TxGasHandlerMock) WithGasPriceModifier(gasPriceModifier float64) *TxGasHandlerMock {
30+
ghm.gasPriceModifier = gasPriceModifier
31+
return ghm
32+
}
33+
34+
// ComputeTxFee -
35+
func (ghm *TxGasHandlerMock) ComputeTxFee(tx data.TransactionWithFeeHandler) *big.Int {
36+
dataLength := uint64(len(tx.GetData()))
37+
gasPriceForMovement := tx.GetGasPrice()
38+
gasPriceForProcessing := uint64(float64(gasPriceForMovement) * ghm.gasPriceModifier)
39+
40+
gasLimitForMovement := ghm.minGasLimit + dataLength*ghm.gasPerDataByte
41+
if tx.GetGasLimit() < gasLimitForMovement {
42+
panic("tx.GetGasLimit() < gasLimitForMovement")
43+
}
44+
45+
gasLimitForProcessing := tx.GetGasLimit() - gasLimitForMovement
46+
feeForMovement := core.SafeMul(gasPriceForMovement, gasLimitForMovement)
47+
feeForProcessing := core.SafeMul(gasPriceForProcessing, gasLimitForProcessing)
48+
fee := big.NewInt(0).Add(feeForMovement, feeForProcessing)
49+
return fee
5450
}
5551

5652
// IsInterfaceNil -

txcache/README.md

+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
## Mempool
2+
3+
### Glossary
4+
5+
1. **selection session:** an ephemeral session during which the mempool selects transactions for a proposer. A session starts when a proposer asks the mempool for transactions and ends when the mempool returns the transactions. The most important part of a session is the _selection loop_.
6+
2. **transaction PPU:** the price per unit of computation, for a transaction. It's computed as `initiallyPaidFee / gasLimit`.
7+
3. **initially paid transaction fee:** the fee for processing a transaction, as known before its actual processing. That is, without knowing the _refund_ component.
8+
9+
### Configuration
10+
11+
1. **SelectTransactions::gasRequested:** `10_000_000_000`, the maximum total gas limit of the transactions to be returned to a proposer (one _selection session_). This value is provided by the Protocol.
12+
2. **SelectTransactions::maxNum:** `50_000`, the maximum number of transactions to be returned to a proposer (one _selection session_). This value is provided by the Protocol.
13+
14+
### Transactions selection
15+
16+
### Paragraph 1
17+
18+
When a proposer asks the mempool for transactions, it provides the following parameters:
19+
20+
- `gasRequested`: the maximum total gas limit of the transactions to be returned
21+
- `maxNum`: the maximum number of transactions to be returned
22+
23+
### Paragraph 2
24+
25+
The PPU (price per gas unit) of a transaction, is computed (once it enters the mempool) as follows:
26+
27+
```
28+
ppu = initiallyPaidFee / gasLimit
29+
```
30+
31+
In the formula above,
32+
33+
```
34+
initiallyPaidFee =
35+
dataCost * gasPrice +
36+
executionCost * gasPrice * network.gasPriceModifier
37+
38+
dataCost = network.minGasLimit + len(data) * network.gasPerDataByte
39+
40+
executionCost = gasLimit - dataCost
41+
```
42+
43+
Network parameters (as of November of 2024):
44+
45+
```
46+
gasPriceModifier = 0.01
47+
minGasLimit = 50_000
48+
gasPerDataByte = 1_500
49+
```
50+
51+
#### Examples
52+
53+
**(a)** A simple native transfer with `gasLimit = 50_000` and `gasPrice = 1_000_000_000`:
54+
55+
```
56+
initiallyPaidFee = 50_000_000_000 atoms
57+
ppu = 1_000_000_000 atoms
58+
```
59+
60+
**(b)** A simple native transfer with `gasLimit = 50_000` and `gasPrice = 1_500_000_000`:
61+
62+
```
63+
initiallyPaidFee = gasLimit * gasPrice = 75_000_000_000 atoms
64+
ppu = 75_000_000_000 / 50_000 = 1_500_000_000 atoms
65+
```
66+
67+
**(c)** A simple native transfer with a data payload of 7 bytes, with `gasLimit = 50_000 + 7 * 1500` and `gasPrice = 1_000_000_000`:
68+
69+
```
70+
initiallyPaidFee = 60_500_000_000_000 atoms
71+
ppu = 60_500_000_000_000 / 60_500 = 1_000_000_000 atoms
72+
```
73+
74+
That is, for simple native transfers (whether they hold a data payload or not), the PPU is equal to the gas price.
75+
76+
**(d)** A contract call with `gasLimit = 75_000_000` and `gasPrice = 1_000_000_000`, with a data payload of `42` bytes:
77+
78+
```
79+
initiallyPaidFee = 861_870_000_000_000 atoms
80+
ppu = 11_491_600 atoms
81+
```
82+
83+
**(e)** Similar to **(d)**, but with `gasPrice = 2_000_000_000`:
84+
85+
```
86+
initiallyPaidFee = 1_723_740_000_000_000 atoms
87+
ppu = 22_983_200 atoms
88+
```
89+
90+
That is, for contract calls, the PPU is not equal to the gas price, but much lower, due to the contract call _cost subsidy_. **A higher gas price will result in a higher PPU.**
91+
92+
### Paragraph 3
93+
94+
Transaction **A** is considered **more valuable (for the Network)** than transaction **B** if **it has a higher PPU**.
95+
96+
If two transactions have the same PPU, they are ordered using an arbitrary, but deterministic rule: the transaction with the "lower" transaction hash "wins" the comparison.
97+
98+
Pseudo-code:
99+
100+
```
101+
func isTransactionMoreValuableForNetwork(A, B):
102+
if A.ppu > B.ppu:
103+
return true
104+
if A.ppu < B.ppu:
105+
return false
106+
return A.hash < B.hash
107+
```
108+
109+
### Paragraph 4
110+
111+
The mempool selects transactions as follows (pseudo-code):
112+
113+
```
114+
func selectTransactions(gasRequested, maxNum):
115+
// Setup phase
116+
senders := list of all current senders in the mempool, in an arbitrary order
117+
bunchesOfTransactions := sourced from senders; nonces-gap-free, duplicates-free, nicely sorted by nonce
118+
119+
// Holds selected transactions
120+
selectedTransactions := empty
121+
122+
// Holds not-yet-selected transactions, ordered by PPU
123+
competitionHeap := empty
124+
125+
for each bunch in bunchesOfTransactions:
126+
competitionHeap.push(next available transaction from bunch)
127+
128+
// Selection loop
129+
while competitionHeap is not empty:
130+
mostValuableTransaction := competitionHeap.pop()
131+
132+
// Check if adding the next transaction exceeds limits
133+
if selectedTransactions.totalGasLimit + mostValuableTransaction.gasLimit > gasRequested:
134+
break
135+
if selectedTransactions.length + 1 > maxNum:
136+
break
137+
138+
selectedTransactions.append(mostValuableTransaction)
139+
140+
nextTransaction := next available transaction from the bunch of mostValuableTransaction
141+
if nextTransaction exists:
142+
competitionHeap.push(nextTransaction)
143+
144+
return selectedTransactions
145+
```
146+
147+
Thus, the mempool selects transactions using an efficient and value-driven algorithm that ensures the most valuable transactions (in terms of PPU) are prioritized while maintaining correct nonce sequencing per sender. The selection process is as follows:
148+
149+
**Setup phase:**
150+
151+
- **Snapshot of senders:**
152+
- Before starting the selection loop, obtain a snapshot of all current senders in the mempool in an arbitrary order.
153+
154+
- **Organize transactions into bunches:**
155+
- For each sender, collect all their pending transactions and organize them into a "bunch."
156+
- Each bunch is:
157+
- **Nonce-gap-free:** There are no missing nonces between transactions.
158+
- **Duplicates-free:** No duplicate transactions are included.
159+
- **Sorted by nonce:** Transactions are ordered in ascending order based on their nonce values.
160+
161+
- **Prepare the heap:**
162+
- Extract the first transaction (lowest nonce) from each sender's bunch.
163+
- Place these transactions onto a max heap, which is ordered based on the transaction's PPU.
164+
165+
**Selection loop:**
166+
167+
- **Iterative selection:**
168+
- Continue the loop until either the total gas of selected transactions meets or exceeds `gasRequested`, or the number of selected transactions reaches `maxNum`.
169+
- In each iteration:
170+
- **Select the most valuable transaction:**
171+
- Pop the transaction with the highest PPU from the heap.
172+
- Append this transaction to the list of `selectedTransactions`.
173+
- **Update the sender's bunch:**
174+
- If the sender of the selected transaction has more transactions in their bunch:
175+
- Take the next transaction (next higher nonce) from the bunch.
176+
- Push this transaction onto the heap to compete in subsequent iterations.
177+
- This process ensures that at each step, the most valuable transaction across all senders is selected while maintaining proper nonce order for each sender.
178+
179+
- **Early termination:**
180+
- The selection loop can terminate early if either of the following conditions is satisfied before all transactions are processed:
181+
- The accumulated gas of selected transactions meets or exceeds `gasRequested`.
182+
- The number of selected transactions reaches `maxNum`.
183+
184+
185+
### Paragraph 5
186+
187+
On the node's side, the selected transactions are shuffled using a deterministic algorithm. This shuffling ensures that the transaction order remains unpredictable to the proposer, effectively preventing _front-running attacks_. Therefore, being selected first by the mempool does not guarantee that a transaction will be included first in the block. Additionally, selection by the mempool does not ensure inclusion in the very next block, as the proposer has the final authority on which transactions to include, based on **the remaining space available** in the block.

txcache/benchmarks.sh

-2
This file was deleted.

txcache/config.go

+21-25
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,25 @@ const maxNumBytesUpperBound = 1_073_741_824 // one GB
1515
const maxNumItemsPerSenderLowerBound = 1
1616
const maxNumBytesPerSenderLowerBound = maxNumItemsPerSenderLowerBound * 1
1717
const maxNumBytesPerSenderUpperBound = 33_554_432 // 32 MB
18-
const numTxsToPreemptivelyEvictLowerBound = 1
19-
const numSendersToPreemptivelyEvictLowerBound = 1
18+
const numItemsToPreemptivelyEvictLowerBound = uint32(1)
2019

2120
// ConfigSourceMe holds cache configuration
2221
type ConfigSourceMe struct {
23-
Name string
24-
NumChunks uint32
25-
EvictionEnabled bool
26-
NumBytesThreshold uint32
27-
NumBytesPerSenderThreshold uint32
28-
CountThreshold uint32
29-
CountPerSenderThreshold uint32
30-
NumSendersToPreemptivelyEvict uint32
22+
Name string
23+
NumChunks uint32
24+
EvictionEnabled bool
25+
NumBytesThreshold uint32
26+
NumBytesPerSenderThreshold uint32
27+
CountThreshold uint32
28+
CountPerSenderThreshold uint32
29+
NumItemsToPreemptivelyEvict uint32
3130
}
3231

3332
type senderConstraints struct {
3433
maxNumTxs uint32
3534
maxNumBytes uint32
3635
}
3736

38-
// TODO: Upon further analysis and brainstorming, add some sensible minimum accepted values for the appropriate fields.
3937
func (config *ConfigSourceMe) verify() error {
4038
if len(config.Name) == 0 {
4139
return fmt.Errorf("%w: config.Name is invalid", common.ErrInvalidConfig)
@@ -49,16 +47,15 @@ func (config *ConfigSourceMe) verify() error {
4947
if config.CountPerSenderThreshold < maxNumItemsPerSenderLowerBound {
5048
return fmt.Errorf("%w: config.CountPerSenderThreshold is invalid", common.ErrInvalidConfig)
5149
}
52-
if config.EvictionEnabled {
53-
if config.NumBytesThreshold < maxNumBytesLowerBound || config.NumBytesThreshold > maxNumBytesUpperBound {
54-
return fmt.Errorf("%w: config.NumBytesThreshold is invalid", common.ErrInvalidConfig)
55-
}
56-
if config.CountThreshold < maxNumItemsLowerBound {
57-
return fmt.Errorf("%w: config.CountThreshold is invalid", common.ErrInvalidConfig)
58-
}
59-
if config.NumSendersToPreemptivelyEvict < numSendersToPreemptivelyEvictLowerBound {
60-
return fmt.Errorf("%w: config.NumSendersToPreemptivelyEvict is invalid", common.ErrInvalidConfig)
61-
}
50+
51+
if config.NumBytesThreshold < maxNumBytesLowerBound || config.NumBytesThreshold > maxNumBytesUpperBound {
52+
return fmt.Errorf("%w: config.NumBytesThreshold is invalid", common.ErrInvalidConfig)
53+
}
54+
if config.CountThreshold < maxNumItemsLowerBound {
55+
return fmt.Errorf("%w: config.CountThreshold is invalid", common.ErrInvalidConfig)
56+
}
57+
if config.NumItemsToPreemptivelyEvict < numItemsToPreemptivelyEvictLowerBound {
58+
return fmt.Errorf("%w: config.NumItemsToPreemptivelyEvict is invalid", common.ErrInvalidConfig)
6259
}
6360

6461
return nil
@@ -75,7 +72,7 @@ func (config *ConfigSourceMe) getSenderConstraints() senderConstraints {
7572
func (config *ConfigSourceMe) String() string {
7673
bytes, err := json.Marshal(config)
7774
if err != nil {
78-
log.Error("ConfigSourceMe.String()", "err", err)
75+
log.Error("ConfigSourceMe.String", "err", err)
7976
}
8077

8178
return string(bytes)
@@ -90,7 +87,6 @@ type ConfigDestinationMe struct {
9087
NumItemsToPreemptivelyEvict uint32
9188
}
9289

93-
// TODO: Upon further analysis and brainstorming, add some sensible minimum accepted values for the appropriate fields.
9490
func (config *ConfigDestinationMe) verify() error {
9591
if len(config.Name) == 0 {
9692
return fmt.Errorf("%w: config.Name is invalid", common.ErrInvalidConfig)
@@ -104,7 +100,7 @@ func (config *ConfigDestinationMe) verify() error {
104100
if config.MaxNumBytes < maxNumBytesLowerBound || config.MaxNumBytes > maxNumBytesUpperBound {
105101
return fmt.Errorf("%w: config.MaxNumBytes is invalid", common.ErrInvalidConfig)
106102
}
107-
if config.NumItemsToPreemptivelyEvict < numTxsToPreemptivelyEvictLowerBound {
103+
if config.NumItemsToPreemptivelyEvict < numItemsToPreemptivelyEvictLowerBound {
108104
return fmt.Errorf("%w: config.NumItemsToPreemptivelyEvict is invalid", common.ErrInvalidConfig)
109105
}
110106

@@ -115,7 +111,7 @@ func (config *ConfigDestinationMe) verify() error {
115111
func (config *ConfigDestinationMe) String() string {
116112
bytes, err := json.Marshal(config)
117113
if err != nil {
118-
log.Error("ConfigDestinationMe.String()", "err", err)
114+
log.Error("ConfigDestinationMe.String", "err", err)
119115
}
120116

121117
return string(bytes)

txcache/constants.go

+3-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
package txcache
22

3-
const estimatedNumOfSweepableSendersPerSelection = 100
4-
5-
const senderGracePeriodLowerBound = 2
6-
7-
const senderGracePeriodUpperBound = 2
8-
9-
const numEvictedTxsToDisplay = 3
3+
const diagnosisMaxTransactionsToDisplay = 10000
4+
const diagnosisSelectionGasRequested = 10_000_000_000
5+
const initialCapacityOfSelectionSlice = 30000

0 commit comments

Comments
 (0)