-
Notifications
You must be signed in to change notification settings - Fork 632
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
feat: implement NEP264, function call gas weight #6285
Conversation
runtime/runtime/src/ext.rs
Outdated
} | ||
|
||
#[cfg(feature = "protocol_feature_function_call_ratio")] | ||
struct GasRatioMetadata { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We try to avoid u*
types in the runtime as much as possible. If you find usage of u*
type anywhere please file a Github issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I must say I don't entirely understand what should be used instead. As this interfaces with VmLogic
which used raw u64/u32
, we actually have a whole bunch of u64
s in various signatures in this module.
But yeah, I'd probably spell this as:
struct RuntimeExt {
...
gas_weights: Vec<(FunctionCallActionIndex, GasWeight)>
}
struct GasWeight(u64);
struct FunctionCallActionIndex {
receipt_index: usize,
action_inde
}
impl RuntimeExt {
fn get_function_call_action_mut(&mut self, index: FunctionCallActionIndex) -> &mut FunctionCallAction {
}
}
- Separate the two-dimensional index into its own type
- Add a typed wrapper to the weight
- Remove
gas_ratio_sum
field -- it seems we can compute it in thedistribute
function. To check if it's zero, you can check ifgas_weights
is not empty
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you want the u64 removed from the trait interface or just internally in the implementation? I notice for the other types in the signature the types are aliases in the trait definition but then just use the primitive type in the implementation.
As for the proposed API, it wouldn't work with the current implementation because the gas weights need to be borrowed mutably so I can't take a mutable reference on self
when it's used. Seems more complex to read with this change to me, but happy to change to whatever is preferred.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So I've changed to just wrapping internally for now, but can expose this type through the trait interface if that's what we want to move the API toward
runtime/runtime/src/ext.rs
Outdated
panic!("Invalid index for assigning unused gas ratio"); | ||
} | ||
|
||
assign_gas |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is preferable if we always distribute all of the unused gas to avoid emitting gas refund receipt. It is okay if the last receipt gas a little bit extra gas. Right now we have leftover gas because of the division. We then would need to write tests to make sure refund receipt is never emitted. Once we do it the return value is always going to be either equal to gas
or zero, which means it will become semantically a bool.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 to distributing all gas, but let's do it evenly, rather than by just tacking extra onto the last receipt, as that can be very unbalanced. If you distribute 9 gas among five calls, you want to get [2, 2, 2, 2, 1]
rathre than [1, 1, 1, 1, 5]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only issue I see to this is the overhead of getting the modulus of the division before the operations or having to re-index the initial gas % weight_sum
items if we want to micro optimize. I will do this change and then will revisit later to see if implications on the cost of the operation.
Also, it's unclear how the behaviour would work in the cases of a few weights with very large values. For example, if there are two with weights of 1000
and 2000
and only 2500
gas and the remainder would be the whole 2500, would the gas be split evenly among the two? Maybe we don't have to worry about this kind of case, but want to be clear.
Also, balancing it among the first transactions might not be as important as the last one(s), which is commonly callback. Opinionated take but I think in a lot of cases it's better to make sure the final callback has enough gas.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Made changes with Max's suggestion for now for simplicity, but will update once we resolve these edge cases :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feels broadly right to me!
One thing which I feel suuuper uneasy though is the fact that the tests here check our mock implementation, rather than the real one. Here's what we can do here:
- Have at least one integration test here. I don't know what's the right place for it though. They are some more integration tests in
near-vm-runner/tests/rs_contract
, but they still use mocked external - Move promise handling logic from
runtime
tovm-logic
. Today vm-logic depends on primitives, so that should be fine. It'll also allow us to reduce duplication in MockedExternal. - Alternatively, keep actual promises data in
runtime
, but move bookeeping tovm-logic
Though, I'd personally be fine langing this PR without proper tests for new functionality, as long as the feature is experimental. That is, we absolutelly have to add tests which exercise the real thing, but we can do that in a follow-up PR
/// | ||
/// Panics if the `receipt_index` does not refer to a known receipt. | ||
#[cfg(feature = "protocol_feature_function_call_ratio")] | ||
// TODO: unclear if this should be a supertrait to avoid semver breaking changes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We intentionally don't have any semver guarantees for nearcore crates, so, no, you can just change the code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so are you suggesting modifying append_action_function_call
or keeping as is?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation is fine. Please remove TODO here
runtime/runtime/src/ext.rs
Outdated
} | ||
|
||
#[cfg(feature = "protocol_feature_function_call_ratio")] | ||
struct GasRatioMetadata { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I must say I don't entirely understand what should be used instead. As this interfaces with VmLogic
which used raw u64/u32
, we actually have a whole bunch of u64
s in various signatures in this module.
But yeah, I'd probably spell this as:
struct RuntimeExt {
...
gas_weights: Vec<(FunctionCallActionIndex, GasWeight)>
}
struct GasWeight(u64);
struct FunctionCallActionIndex {
receipt_index: usize,
action_inde
}
impl RuntimeExt {
fn get_function_call_action_mut(&mut self, index: FunctionCallActionIndex) -> &mut FunctionCallAction {
}
}
- Separate the two-dimensional index into its own type
- Add a typed wrapper to the weight
- Remove
gas_ratio_sum
field -- it seems we can compute it in thedistribute
function. To check if it's zero, you can check ifgas_weights
is not empty
runtime/runtime/src/ext.rs
Outdated
self.gas_ratio_sum = | ||
self.gas_ratio_sum.checked_add(gas_ratio).ok_or(HostError::IntegerOverflow)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This error condition doesn't feel great to me. This is a code path which could be triggered by the user, it's not just dead code, so it adds to the number of code paths which we need to exercise. "You can't use u64::MAX - 1
as a ratio" is also an error condition the user needs to worry about. Let's implement the logic in a way which can deal with arbitrary ratios. Using u128
to sum the ratios should work I think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like a bit of a foot-gun because the gas maximum is u64::MAX, so having the ratio larger doesn't do anything and goes into the fallback case, which could silently cause unexpected behaviour. Should never realistically happen so maybe not worth handling this case.
runtime/runtime/src/ext.rs
Outdated
panic!("Invalid index for assigning unused gas ratio"); | ||
} | ||
|
||
assign_gas |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 to distributing all gas, but let's do it evenly, rather than by just tacking extra onto the last receipt, as that can be very unbalanced. If you distribute 9 gas among five calls, you want to get [2, 2, 2, 2, 1]
rathre than [1, 1, 1, 1, 5]
You indicated in the issue that you thought this refactor should be done in a separate PR, has your opinion on this changed or are you referring to doing this after this PR? |
Okay I'm going to open this PR for review so I can start getting other feedback while I wait for resolution on a few things or input on those things. The outstanding things are:
|
/// | ||
/// Panics if the `receipt_index` does not refer to a known receipt. | ||
#[cfg(feature = "protocol_feature_function_call_ratio")] | ||
// TODO: unclear if this should be a supertrait to avoid semver breaking changes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation is fine. Please remove TODO here
runtime/runtime/src/ext.rs
Outdated
let mut distribute_gas = |metadata: &FunctionCallActionIndex, assigned_gas: u64| { | ||
let FunctionCallActionIndex { receipt_index, action_index } = metadata; | ||
if let Some(Action::FunctionCall(FunctionCallAction { ref mut gas, .. })) = self | ||
.action_receipts | ||
.get_mut(*receipt_index) | ||
.and_then(|(_, receipt)| receipt.actions.get_mut(*action_index)) | ||
{ | ||
*gas += assigned_gas; | ||
} else { | ||
panic!("Invalid index for assigning unused gas weight"); | ||
} | ||
}; | ||
|
||
let distributed: u64 = self | ||
.gas_weights | ||
.iter() | ||
.map(|(action_index, GasWeight(weight))| { | ||
let assigned_gas = gas_per_weight * weight; | ||
|
||
distribute_gas(action_index, assigned_gas); | ||
|
||
assigned_gas | ||
}) | ||
.sum(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest rewriting this logic using a for loop to make it easier to understand
runtime/near-vm-logic/src/logic.rs
Outdated
pub fn outcome(mut self) -> VMOutcome { | ||
#[cfg(feature = "protocol_feature_function_call_weight")] | ||
if !self.context.is_view() { | ||
// Distribute unused gas to scheduled function calls | ||
let unused_gas = self.context.prepaid_gas - self.gas_counter.used_gas(); | ||
|
||
// Distribute the unused gas and prepay for the gas. | ||
if self.ext.distribute_unused_gas(unused_gas) { | ||
self.gas_counter.prepay_gas(unused_gas).unwrap(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function now has a nontrivial side effect. We might want to rename it and add a comment about what it does
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
took a stab at this, open to any suggestions of naming or any other information to include in the rustdocs since it's opinionated
Also, I forgot to ask one other thing: the hash for the integration tests changed because I updated the protocol version number (which seems to be serialized into the block hash). So I didn't want to just update the test to pass before checking if any other implications should be considered. Is there anything other than updating those hashes to match that needs to be done? |
@@ -276,6 +278,49 @@ pub trait External { | |||
prepaid_gas: Gas, | |||
) -> Result<()>; | |||
|
|||
/// Attach the [`FunctionCallAction`] action to an existing receipt. | |||
/// |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd mention that this is the same function as above (append_action_function_call) with the added argument.
And maybe in this documentation focus a little bit more on what 'gas_weight' means.
And what happens if I set both 'prepaid_gas' and gas_weight?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and what if gas_weight is 0 ? does it still include gas then ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assumed the docs for this would be lighter since it's documented in promise_batch_action_function_call_weight
, but I see the benefit in giving some info here also.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is fine to have lighter docs - but then let's make sure that we have a 'pointer' to the place where people can read more details.
(I actually don't know which file is read by users - this one or logic.rs?)
@@ -209,6 +248,46 @@ impl External for MockedExternal { | |||
fn validator_total_stake(&self) -> Result<Balance> { | |||
Ok(self.validators.values().sum()) | |||
} | |||
|
|||
#[cfg(feature = "protocol_feature_function_call_weight")] | |||
fn distribute_unused_gas(&mut self, gas: Gas) -> GasDistribution { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
comment (especially the part that you're actually modifying the assigned_gas for the receipts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is documented on the trait. Are you suggesting all implementations to give details about how specifically it distributes gas?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, if possible. It helps a lot during code review and later code reading.
{ | ||
*gas += assigned_gas; | ||
} else { | ||
panic!("Invalid index for assigning unused gas weight"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe also print what was the index (and also some other identifier - transaction/receipt id?)
( I assume that this should never ever happen, right?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, this is currently unreachable, but just have a static error message to help debug if the functionality ever changes to re-order actions or receipts.
Using formatting machinery to include indices comes at a cost, can you confirm this is ideal before I make the change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but wait - this cost would be paid only if the panic actually triggers, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, I was more referring to compiled code size. I'll just add it in. I've been wired to be a little too cautious about this
let gas_weight_sum: u128 = | ||
self.gas_weights.iter().map(|(_, GasWeight(weight))| *weight as u128).sum(); | ||
if gas_weight_sum != 0 { | ||
let gas_per_weight = (gas as u128 / gas_weight_sum) as u64; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if someone sets very large 'gas_weight' - then gas_per_weight can be 0, right?
(we should mention it in the comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added docs in vm logic for this, let me know if it's preferable to duplicate docs to the external trait and/or impls
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding those. I'd also add a one-line comment here, that we're using a floor, and we know that it might be 0 if the sum is too large.
} | ||
let outcome = logic.compute_outcome_and_distribute_gas(); | ||
|
||
// Verify that all gas is used when only one ratio is specified |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
confusing comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah, yes, this is also an outdated assertion since now all gas is used. Updated
@mm-near I've tried again to find a clean way to assert the amount of gas allocated with the current testing scaffolding based on your comments, but I'll try to point out why it's more involved and should come in a separate change:
If you think this is imperative to test specific allocated values in this PR I can do the suggestion above with downcasting and re-calculating gas allocations, but I wanted to be clear about context before doing this as it feels janky to me to do in this PR. |
@@ -276,6 +278,49 @@ pub trait External { | |||
prepaid_gas: Gas, | |||
) -> Result<()>; | |||
|
|||
/// Attach the [`FunctionCallAction`] action to an existing receipt. | |||
/// |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is fine to have lighter docs - but then let's make sure that we have a 'pointer' to the place where people can read more details.
(I actually don't know which file is read by users - this one or logic.rs?)
@@ -209,6 +248,46 @@ impl External for MockedExternal { | |||
fn validator_total_stake(&self) -> Result<Balance> { | |||
Ok(self.validators.values().sum()) | |||
} | |||
|
|||
#[cfg(feature = "protocol_feature_function_call_weight")] | |||
fn distribute_unused_gas(&mut self, gas: Gas) -> GasDistribution { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, if possible. It helps a lot during code review and later code reading.
let gas_weight_sum: u128 = | ||
self.gas_weights.iter().map(|(_, GasWeight(weight))| *weight as u128).sum(); | ||
if gas_weight_sum != 0 { | ||
let gas_per_weight = (gas as u128 / gas_weight_sum) as u64; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding those. I'd also add a one-line comment here, that we're using a floor, and we know that it might be 0 if the sum is too large.
{ | ||
*gas += assigned_gas; | ||
} else { | ||
panic!("Invalid index for assigning unused gas weight"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but wait - this cost would be paid only if the panic actually triggers, right?
#[cfg(feature = "protocol_feature_function_call_weight")] | ||
#[test] | ||
fn function_call_weight_single_smoke_test() { | ||
// Single function call |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fine - but then let's have a test that actually test this new funcionality
I'm approving to unblock you - but I'd really like to see a PR with a test that covers this functionality - as from what I see, if I change the logic to (for example) assign all the remaining gas to the last receipt (and ignore the weights) - all the tests would still pass.. |
I'm starting the refactor to allow this now. It makes me equally uncomfortable that this functionality isn't specifically tested and there is code duplication but seems janky to do in this PR and only would test the mock implementation. |
More of a rough draft because I don't have context around the runtime as to the best migration plan of these APIs. I've followed the recommendation of #6150 to avoid refactoring to move action receipts to
VMLogic
, but this means theExternal
trait needs to be updated, and it's unclear if anyone has preferences.TODO:
RuntimeExt
as current tests just coverMockedExternal
(same functionality though)