Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sweepbatcher: add greedy batch selection algorithm #787

Merged

Conversation

starius
Copy link
Collaborator

@starius starius commented Jun 30, 2024

New algorithm is tried to select a batch to add a sweep to. It selects a batch for the sweep using the greedy algorithm, which minimizes costs, and adds the sweep to the batch or creates new batch, if it is the most cost efficient alternative. If the batch does not accept the sweep (too long timeout distance), next batch is tried. If the whole new approach fails, the old algorithm is used, trying to add to any batch, or starting a new batch.

The PR also includes some refactorings and fixes small inaccuracies in fee estimations.

Pull Request Checklist

  • Update release_notes.md if your PR contains major features, breaking changes or bugfixes

@starius starius force-pushed the sweepbatcher-greedy-batch-selection branch from 34c2c1c to 5747074 Compare June 30, 2024 03:18
@starius starius marked this pull request as ready for review July 1, 2024 02:39
@lightninglabs-deploy
Copy link

@bhandras: review reminder
@sputn1ck: review reminder
@hieblmi: review reminder

Copy link
Member

@bhandras bhandras left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just a few comments!

destAddr: s.DestAddr,
minFeeRate: s.MinFeeRate,
}, nil
return b.loadSweep(ctx, dbSweep.SwapHash, dbSweep.Outpoint,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

@@ -898,3 +886,12 @@ func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash,
minFeeRate: s.MinFeeRate,
}, nil
}

// newBatchConfig creates new batch config.
func (b *Batcher) newBatchConfig(maxTimeoutDistance int32) batchConfig {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -895,3 +875,18 @@ func (b *Batcher) newBatchConfig(maxTimeoutDistance int32) batchConfig {
customMuSig2Signer: b.customMuSig2Signer,
}
}

// newBatchKit creates new batch kit.
func (b *Batcher) newBatchKit() batchKit {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

sweep/sweeper.go Outdated

// Add output.
if err := AddOutputEstimate(&weightEstimate, destAddr); err != nil {
return 0, 0, 0, fmt.Errorf("estimate fee: %w", err)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: error message seems out of context, perhaps a bit more detail could be helpful.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Also added error message in case adding input fails.

@@ -848,6 +846,19 @@ func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash,
swapHash[:6], err)
}

var minFeeRate chainfee.SatPerKWeight
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should actually be initialized to chainfee.AbsoluteFeePerKwFloor so if the custom fee rate provider is unset then it still makes sense.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minFeeRate is 0 be default. If customFeeRate is not provided, then rbfCache.FeeRate is also 0 and method batch.updateRbfRate() updates it to current fee rate according to batchConfTarget.

Added a comment to that code about this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

const specialId = -1

// entity is either batch of sweep and it holds data important for selection.
type entity struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think entity is not necessarily the best name as it sounds a bit too general. Maybe consider something like sweepSet or sweepEntity? Also given that the role of this type is to store weight and fee information perhaps we can be even more specific: sweepFeeDetails?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed entity to feeDetails.

sweepbatcher/greedy_batch_selection.go Show resolved Hide resolved
}

// estimateSweepWeight returns sweep's input weight and one sweep batch weight.
func estimateSweepWeight(s *sweep) (*entity, *entity, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i think returning values instead of pointers is cleaner.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

// estimateSweepWeight returns sweep's input weight and one sweep batch weight.
func estimateSweepWeight(s *sweep) (*entity, *entity, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: perhaps naming this functions as estimateSweepFeeDelta or estimateSweepFeeIncrement could be more readable. Maybe the godoc can also be made clearer slightly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to estimateSweepFeeIncrement and added more godoc:

// estimateSweepFeeIncrement returns fee details for adding the sweep to a batch
// and for creating new batch with this sweep only.
func estimateSweepFeeIncrement(s *sweep) (feeDetails, feeDetails, error) {

@starius starius force-pushed the sweepbatcher-greedy-batch-selection branch from 6b0293a to bf645d0 Compare July 8, 2024 17:11
@starius starius requested a review from bhandras July 8, 2024 17:14
@starius
Copy link
Collaborator Author

starius commented Jul 8, 2024

@bhandras Thanks for the review! 💯

I fixed the comments. Please take another look!

Copy link
Member

@bhandras bhandras left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🎉

@@ -848,6 +846,22 @@ func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash,
swapHash[:6], err)
}

// minFeeRate is 0 be default. If customFeeRate is not provided, then
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: s/be/by

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Fixed

@@ -848,6 +846,19 @@ func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash,
swapHash[:6], err)
}

var minFeeRate chainfee.SatPerKWeight
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

}

// Estimate new batch.
newBatch, err := estimateBatchWeight(batch)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: to avoid confusing names and improve readability, consider naming newBatch => feeDetails1 and batch2 => feeDetails2 or something similar as these are not batches.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed them to fd1 and fd2.

// details has also IsExternalAddr flag. There is a rule that sweeps having flag
// IsExternalAddr must go in individual batches. Cooperative spending is only
// available if all the sweeps support cooperative spending path.
func selectBatch(batches []feeDetails, sweep, oneSweepBatch feeDetails) (int32, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: line too long

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@starius starius force-pushed the sweepbatcher-greedy-batch-selection branch 2 times, most recently from 18ab904 to 64dc3f2 Compare July 11, 2024 02:36
@starius
Copy link
Collaborator Author

starius commented Jul 11, 2024

Updated commit "sweepbatcher: fix inaccuracies in fee estimations": function publishBatch also used AddP2TROutput regardless of destination address type. Fixed it, similarly to publishBatchCoop function: use function AddOutputEstimate to estimate output size based on address type.

@starius starius force-pushed the sweepbatcher-greedy-batch-selection branch from 64dc3f2 to 64d9938 Compare July 11, 2024 13:31
@starius
Copy link
Collaborator Author

starius commented Jul 11, 2024

Rebased

Copy link
Collaborator

@hieblmi hieblmi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great effort to save fees for the users of loop out💰. I've left a few comments.
I will test this locally next.

sweep/sweeper.go Show resolved Hide resolved
sweepbatcher/sweep_batch.go Outdated Show resolved Hide resolved
sweepbatcher/sweep_batcher.go Outdated Show resolved Hide resolved
@@ -465,6 +470,19 @@ func (b *Batcher) handleSweep(ctx context.Context, sweep *sweep,
}
}

// If custom fee rate provider is used, run the greedy algorithm of
// batch selection to minimize costs.
if b.customFeeRate != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we try to minimize only if there's a custom fee provider active? Isn't it possible to optimize when multiple batches exist with the internal fee rate calculation?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, how are you planning to enable the custom fee rates? Will this be a user configured parameter which will be passed in as option to batcher := sweepbatcher.NewBatcher(?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea! We can always use it. I pushed two commits:

  • sweepbatcher: always fill sweep.minFeeRate
  • sweepbatcher: always try greedy batch selection

to implement this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated commit "sweepbatcher: always fill sweep.minFeeRate". Previous version of it caused an extra fee bump and failure of test TestCustomSweepConfTarget. Fixed.

Also pushed commit "loopout_test: enable logging in sweepbatcher", it helped debugging the issue.

sweepbatcher/sweep_batcher.go Outdated Show resolved Hide resolved
sweepbatcher/greedy_batch_selection.go Outdated Show resolved Hide resolved
sweepbatcher/greedy_batch_selection.go Outdated Show resolved Hide resolved
sweepbatcher/greedy_batch_selection_test.go Outdated Show resolved Hide resolved
sweepbatcher/sweep_batcher.go Show resolved Hide resolved
sweepbatcher/greedy_batch_selection.go Outdated Show resolved Hide resolved
@starius starius force-pushed the sweepbatcher-greedy-batch-selection branch 2 times, most recently from ee100d7 to fece888 Compare July 15, 2024 18:03
@starius starius requested a review from hieblmi July 15, 2024 18:07
Copy link
Collaborator

@hieblmi hieblmi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 💾 Frequent loop-out users will love this feature!

// of each alternative. Based on the estimates of selectBatch(), this method
// adds the sweep to the batch that results in the least overall fee increase,
// or creates new batch for it. If this method fails for whatever reason, the
// caller falls back to the simple algorithm (method handleSweep).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 good idea to keep the existing mechanism as fall back

sweepbatcher/greedy_batch_selection.go Outdated Show resolved Hide resolved
Comment on lines 54 to 76
batchId, err := selectBatch(
batches, sweepFeeDetails, newBatchFeeDetails,
)
if err != nil {
return fmt.Errorf("batch selection algorithm failed for sweep "+
"%x: %w", sweep.swapHash[:6], err)
}

// If the best option is to start new batch, do it.
if batchId == newBatchSignal {
return b.spinUpNewBatch(ctx, sweep)
}

// Locate the batch to add the sweep to.
bestBatch, has := b.batches[batchId]
if !has {
return fmt.Errorf("batch selection algorithm returned "+
"batch id %d which doesn't exist, for sweep %x",
batchId, sweep.swapHash[:6])
}

// Add the sweep to the batch.
accepted, err := bestBatch.addSweep(ctx, sweep)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may need to be enhanced

Currently, addSweep will also run checks that are related to the timeout window of the batch vs the sweep that is being added. See here https://github.com/lightninglabs/loop/blob/master/sweepbatcher/sweep_batch.go#L440-L447

This means that the single best solution that the greedy algorithm has calculated may be rejected because the timeout heights of the sweeps are too far away. I believe this is not the desired behavior?

I see 2 possible paths here:

a) Create a version of addSweep that does not check for the timeout window limits, allowing swaps with very distant timeout heights to be in the same batch
b) Enhance algorithm to return an array of "best batches", sorted from best to worst. Then on the above line (76) we would have to iterate over the results and try to addSweep in order from the best to worst option

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course there's option "c) keep it as is"
which will probably cause high failure rates when calling greedyAddSweep, which is OK as long as we fall back to legacy batch selection

Copy link
Collaborator

@hieblmi hieblmi Jul 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How often does it happen that a sweep is rejected based on the timeout? If that's not a common case then we can KISS and just try and add to the selected greedy batch, and if timeout prevents addition then fail the greedy algo and fall back to the default. Wdyt?
Didn't see your c), is a high failure rate expected?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't see your c), is a high failure rate expected?

That highly depends on the type of looper, frequent loopers will probably encounter this more often, as they are the ones having many batches running in parallel

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the option b) Enhance algorithm to return an array of "best batches", sorted from best to worst.

The selection algorithm already has a metric by which is compares all the alternatives, so it is easy to extend to return a sorted array.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented it and pushed a temporary commit with this change. Please take a look! I'll squash it before merging.

Copy link
Member

@GeorgeTsagk GeorgeTsagk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome 🔥
LGTM

return nil
}

log.Infof("Batch selection algorithm returned batch id %d for "+
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think it's better to do Debugf here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

// Sort the alternatives by cost. The lower the cost, the better.
sort.Slice(alternatives, func(i, j int) bool {
return alternatives[i].cost < alternatives[j].cost
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

beautiful 💯

The reason is because we want to know in advance if fee rates come from
an external source or are determined by sweepbatcher internally.

Also remove WithNoBumping option. It is now a part of WithCustomFeeRate:
if WithCustomFeeRate is passed, automatic fee bumping by sweepbatcher
is disabled.
It adds output to transaction weight estimator depending on address type.
If custom fee rates are used, the algorithm is tried. It selects a batch for the
sweep using the greedy algorithm, which minimizes costs, and adds the sweep to
the batch. If it fails, old algorithm is used, trying to add to any batch, or
starting a new batch.
Use SigHashDefault instead of SigHashAll in weight estimations.
Actual code uses SigHashDefault, not SigHashAll. SigHashAll is 1wu larger.

Use the actual destination address in weight estimator, not taproot always.
In the test the type of address is P2WPKH, not taproot.

Updated testSweepFetcher to calculate fee, fee rate and weight accurately and
to compare the data observed in the published transaction with the estimates.
This is useful to debug. It used to fail silently.
Use customFeeRate if it is provided, otherwise use wallet's EstimateFeeRate.

Added flag SkipNextBump to rbfCache to avoid extra bumping upon updating
fee rate externally.

Fix test TestSweepBatcherCloseDuringAdding, it didn't have confTarget and
failed in wallet.EstimateFeeRate call.
Now that sweep.minFeeRate is always set, greedy batch selection has all needed
inputs to work even without customFeeRate provider.
@starius starius force-pushed the sweepbatcher-greedy-batch-selection branch from 60d06ab to 7a61d5e Compare July 18, 2024 16:40
@starius starius merged commit 4c49039 into lightninglabs:master Jul 18, 2024
4 checks passed
@starius starius deleted the sweepbatcher-greedy-batch-selection branch July 18, 2024 17:19
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.

5 participants