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

Batched nodes in SortedTroves #94

Merged
merged 12 commits into from
Apr 11, 2024
Merged

Batched nodes in SortedTroves #94

merged 12 commits into from
Apr 11, 2024

Conversation

danielattilasimon
Copy link
Collaborator

@danielattilasimon danielattilasimon commented Apr 4, 2024

[Note: we should update the base branch after #85 gets merged].

This is only the first half of batched interest rate delegation work, however it's big enough that it might be best to review and merge it early.

The doubly linked list is extended with additional links to facilitate quick traversal over contiguous batches:

image

I also took the opportunity to address the hint recovery issues we had in v1 liquity/dev#600. My philosophy was to optimize for the most likely case of interference, where one of the 2 hints gets moved or removed. Recovery when both hints are messed up is almost guaranteed to fail anyway. With that in mind, I came up with the following strategy:

  • If the original insert position was found to be in a terminal position (head or tail, i.e. one of the hints is 0) then we start the recovery from that end of the list. This addresses Implement workaround for SortedTroves edge case dev#659.
  • If it's obvious which of the 2 hints is outdated, i.e. because one node's been removed or is now on the wrong side of the list, then we start the recovery from the other, good-looking hint.
  • If it's not obvious which hint is outdated, i.e. neither node's been removed and the correct insert position is still somewhere between both, then we start looking inwards from both hints simultaneously. Assuming the most likely case where only one hint has been interfered with, we will quickly find the correct position. Previously, we had a 50-50% chance of either recovering quickly or potentially failing.

Copy link
Collaborator

@bingen bingen left a comment

Choose a reason for hiding this comment

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

This is pretty cool!!
I’ll finish reviewing tomorrow, but I’m leaving a first batch (pun intended) of comments.
At high level, my main concern would be that we are writing the batch address for every node, which seems a lot of repeated used gas. But as long as @ColinPlatt doesn’t notice it’s fine. ;-P
More seriously, I think if we can combine exists and batchId it wouldn’t matter, as the storage slot would be used anyway.

contracts/src/Types/TroveId.sol Show resolved Hide resolved
contracts/test/AccessControlTest.js Show resolved Hide resolved
contracts/src/SortedTroves.sol Show resolved Hide resolved
contracts/src/SortedTroves.sol Show resolved Hide resolved
contracts/src/SortedTroves.sol Outdated Show resolved Hide resolved
contracts/src/SortedTroves.sol Outdated Show resolved Hide resolved
@ColinPlatt
Copy link
Collaborator

ColinPlatt commented Apr 4, 2024 via email

@danielattilasimon danielattilasimon changed the base branch from apply_and_mint_interest_rebase to main April 5, 2024 05:06
@danielattilasimon
Copy link
Collaborator Author

danielattilasimon commented Apr 5, 2024

Gas comparison pre- and post-batching for normal Troves (as delegated Troves aren't implemented in TroveManager/BorrowerOperations yet):
image

Opening a Trove is slightly more expensive (maybe worth looking into why, as we're talking about non-batched Troves), on the other hand, adjusting interest rate is significantly cheaper now, so it should be worth it in the long run.

Edit: actually, these gas figures aren't reflective of normal usage, as the test cases I'm using to measure them (our Solidity tests) don't use proper hints.

Copy link
Collaborator

@bingen bingen left a comment

Choose a reason for hiding this comment

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

Really cool! All good to me. Some more comments below, the only important one is about the inequality strictness, not sure if I’m missing something.

contracts/src/SortedTroves.sol Outdated Show resolved Hide resolved
contracts/src/SortedTroves.sol Show resolved Hide resolved
contracts/src/SortedTroves.sol Outdated Show resolved Hide resolved
contracts/src/SortedTroves.sol Outdated Show resolved Hide resolved
contracts/src/test/SortedTroves.t.sol Show resolved Hide resolved
@danielattilasimon
Copy link
Collaborator Author

danielattilasimon commented Apr 5, 2024

I whipped up a test fixture for measuring gas costs of SortedTroves functions. In the case of findInsertPosition(), I'm measuring the costs of finding the correct position when both hints are zero. For all other functions, I'm passing the hints returned by findInsertPosition() (the happy path). Now the gas costs are down in every case: Insert still takes a hit:

image

Edit: uploaded a new image with corrected gas costs. Initial test had a bug.

Edit 2: and comparing the new SortedTroves (which needs no client-side hacks to find the right hints) against the old one, but using the hacks we employ in the SDK:

image

It's also a good example of why we had to resort to hacks in v1 😆

Disable HintHelpers tests that haven't been fixed since changing
Trove order to be based on interest rate.
Certain tools such as stateful fuzzers (e.g. echidna) or symbolic
test execution engines (e.g. halmos) ignore reverts and only report
assertion failures (error `Panic(1)`) as counterexamples.

As such, code that is never intended to be reached should revert via
assertion failure so that if the code _is_ reached (through a bug) it
is reported by such tools.
Copy link
Collaborator

@RickGriff RickGriff left a comment

Choose a reason for hiding this comment

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

Awesome batching/slice design and optimization! Just one question about ascending/descending as below.

data.nodes[prevId].nextId = _id;
data.nodes[nextId].prevId = _id;
// Check that the new insert position isn't the same as the existing one
if (_nextId != _sliceHead && _prevId != _sliceTail) {
Copy link
Collaborator

@RickGriff RickGriff Apr 10, 2024

Choose a reason for hiding this comment

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

Hmm how can this case occur?

_findInsertPosition should still always return two adjacent nodes (right)? So I guess this case could only occur for a slice length of 2 (and maybe also 1)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This happens in case of re-insertion, even if the slice is singular. Of course, BorrowerOperations will try to avoid re-inserting if the interest rate hasn't changed, but it can still happen that the interest rate changes so little that the correct position remains the same.

Because it's a re-insertion, the list will already contain the node that is to be re-inserted at the time that hints are computed off-chain using _findInsertPosition(). In V1, this lead to some issues. The re-insertion was done in 2 steps, by first removing then inserting the node again. As such, the node wasn't part of the list when _validInsertPosition() / _findInsertPosition() was being used on-chain, as opposed to the call to _findInsertPosition() off-chain. This lead to the hints being wrong for re-insertions that didn't change the position of the node. Eventually, we worked around this in the SDK by skipping over the node that was being re-inserted:
https://github.com/liquity/dev/blob/e38edf3dd67e5ca7e38b83bcf32d515f896a7d2f/packages/lib-ethers/src/PopulatableEthersLiquity.ts#L747-L756

When implementing batching, I saw an opportunity to improve on v1 by not removing the node before it's inserted again. This not only lets us keep the hint search simple and free of workarounds, but it also optimizes gas usage in the (rare) case that the position remains the same.

}
uint256 batchTail = batches[_batchId].tail;

if (batchTail == 0) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is the case where the batch doesn't yet exist right?

I'm having to remember that batch head/tail values of 0 mean the batch doesn't exist, but nodes[0] is the head/tail of the list. Would it make any sense to make the ID for the special head/tail of the list some magic number other than 0, i.e. uint256.max?

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 can see where you're coming from, it is confusing. I also thought about using a different magic number, but I decided against it because it would break compatibility with existing code where the SortedTroves list is iterated on externally.

However, we could alleviate the confusion by defining the magic ID in a constant, to make its purpose really clear. Since it's both the head and tail of the list, what do you think about calling it ROOT_NODE_ID?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Made some improvements to readability by pulling out some constants:
464cb76

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Also, I've made sure that the newly written tests still pass if you change ROOT_NODE_ID to e.g. type(uint256).max, just in case we want to change it at some point, but it might brake some tests — haven't checked yet.

if (_pos.nextId == 0 || _annualInterestRate > _troveManager.getTroveAnnualInterestRate(_pos.nextId)) {
found = true;
} else {
_pos.prevId = _skipToBatchTail(_pos.nextId);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm should we return _pos here? otherwise this else branch seems redundant (it just returns false, and we throw away the changes to _pos.prevId and _pos.nextId... unless I'm missing something

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Argh, it's pretty confusing when viewed in diff form. I think it's easier to read only the refactored code.

The loop that descends the list:

function _descendList(ITroveManager _troveManager, uint256 _annualInterestRate, uint256 _startId) internal view returns (uint256, uint256) {
Position memory pos = Position(_startId, nodes[_startId].nextId);
while (!_descendOne(_troveManager, _annualInterestRate, pos)) {}
return (pos.prevId, pos.nextId);
}

The helper it uses to make a single step:

function _descendOne(ITroveManager _troveManager, uint256 _annualInterestRate, Position memory _pos) internal view returns (bool found) {
if (_pos.nextId == ROOT_NODE_ID || _annualInterestRate > _troveManager.getTroveAnnualInterestRate(_pos.nextId)) {
found = true;
} else {
_pos.prevId = _skipToBatchTail(_pos.nextId);
_pos.nextId = nodes[_pos.prevId].nextId;
}
}

In every iteration we evaluate the search condition, and if we're not in the right position yet, take one step (which can be quite a large step, in case we encounter a batch).

If we entered the else branch, the changes aren't wasted, because that position will be tested in the next iteration. If a position meets the search condition, it is returned from the outer loop (_descendList() above).

The reason for factoring out _descendOne() & _ascendOne() and for keeping the positions in memory is to make it easy to implement an alternating walk:

for (;;) {
if (_descendOne(_troveManager, _annualInterestRate, descentPos)) {
return (descentPos.prevId, descentPos.nextId);
}
if (_ascendOne(_troveManager, _annualInterestRate, ascentPos)) {
return (ascentPos.prevId, ascentPos.nextId);
}
}

contracts/src/SortedTroves.sol Show resolved Hide resolved
@@ -357,36 +424,50 @@ contract SortedTroves is Ownable, CheckContract, ISortedTroves {
return _findInsertPosition(troveManager, _annualInterestRate, _prevId, _nextId);
}

// This function is optimized under the assumption that only one of the original neighbours has been (re)moved.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Really cool!

@danielattilasimon danielattilasimon merged commit 7edba5d into main Apr 11, 2024
5 checks passed
@danielattilasimon danielattilasimon deleted the batch-delegation branch April 12, 2024 06:15
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.

4 participants