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

feat: implement NEP264, function call gas weight #6285

Merged
merged 29 commits into from
Mar 10, 2022
Merged

Conversation

austinabell
Copy link
Contributor

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 the External trait needs to be updated, and it's unclear if anyone has preferences.

TODO:

  • e2e test to cover RuntimeExt as current tests just cover MockedExternal (same functionality though)
    • Also these tests don't check that gas was distributed to the correct function calls
  • Determine the best path for trait migration (would we do the refactor mentioned above before any releases?)
  • Where does the protocol version number come from, and what should this one be numbered?
  • Benchmark to gauge gas cost and verify existing impl doesn't have higher cost
  • Would be nice to fuzz test this to make sure there aren't any weird edge cases

runtime/near-vm-logic/src/dependencies.rs Outdated Show resolved Hide resolved
runtime/near-vm-logic/src/logic.rs Show resolved Hide resolved
runtime/near-vm-logic/src/logic.rs Outdated Show resolved Hide resolved
}

#[cfg(feature = "protocol_feature_function_call_ratio")]
struct GasRatioMetadata {
Copy link
Contributor

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.

Copy link
Contributor

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 u64s 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 the distribute function. To check if it's zero, you can check if gas_weights is not empty

Copy link
Contributor Author

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.

Copy link
Contributor Author

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/near-vm-logic/src/dependencies.rs Show resolved Hide resolved
panic!("Invalid index for assigning unused gas ratio");
}

assign_gas
Copy link
Contributor

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.

Copy link
Contributor

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]

Copy link
Contributor Author

@austinabell austinabell Feb 18, 2022

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.

Copy link
Contributor Author

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 :)

@matklad matklad self-requested a review February 15, 2022 10:26
Copy link
Contributor

@matklad matklad left a 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 to vm-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 to vm-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
Copy link
Contributor

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.

Copy link
Contributor Author

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?

Copy link
Collaborator

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/near-vm-logic/src/logic.rs Outdated Show resolved Hide resolved
runtime/near-vm-runner/src/imports.rs Outdated Show resolved Hide resolved
}

#[cfg(feature = "protocol_feature_function_call_ratio")]
struct GasRatioMetadata {
Copy link
Contributor

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 u64s 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 the distribute function. To check if it's zero, you can check if gas_weights is not empty

Comment on lines 306 to 307
self.gas_ratio_sum =
self.gas_ratio_sum.checked_add(gas_ratio).ok_or(HostError::IntegerOverflow)?;
Copy link
Contributor

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.

Copy link
Contributor Author

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.

panic!("Invalid index for assigning unused gas ratio");
}

assign_gas
Copy link
Contributor

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]

@austinabell
Copy link
Contributor Author

  • Move promise handling logic from runtime to vm-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 to vm-logic

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?

@matklad matklad mentioned this pull request Feb 21, 2022
@austinabell austinabell changed the title feat: implement NEP264, function call gas ratio feat: implement NEP264, function call gas weight Feb 22, 2022
@austinabell
Copy link
Contributor Author

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:

  • Where does the protocol version number come from? I picked the next number I could see, but not sure if it's overlapping
  • Move promise handling logic from runtime to vm-logic based on a suggestion from @matklad. I was originally avoiding this based on NEP 264 tracking issue #6150 recommending doing this in a separate PR, unclear if the plan is changed and now is intended to do in this one.
  • e2e test to cover RuntimeExt. Scoping this out it seems like this is more involved since there isn't the test scaffolding set up to allow testing these, and maybe not that necessary if logic is moved from the previous point
  • Should the GasWeight type be exposed to the trait API? feat: implement NEP264, function call gas weight #6285 (comment) and related comments suggested this, but it is unlike other types in related files that are aliases of the primitive types. I've made the newtype part of the API here 78d4a95 (#6285) but I don't think it's very consistent if we don't update others.

core/primitives/src/version.rs Outdated Show resolved Hide resolved
core/primitives/src/version.rs Outdated Show resolved Hide resolved
///
/// 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
Copy link
Collaborator

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

Comment on lines 450 to 473
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();
Copy link
Collaborator

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

Comment on lines 2558 to 2568
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();
}
}
Copy link
Collaborator

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

Copy link
Contributor Author

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

@austinabell
Copy link
Contributor Author

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.
///
Copy link
Contributor

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?

Copy link
Contributor

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 ?

Copy link
Contributor Author

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.

Copy link
Contributor

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 {
Copy link
Contributor

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

Copy link
Contributor Author

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?

Copy link
Contributor

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");
Copy link
Contributor

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?)

Copy link
Contributor Author

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?

Copy link
Contributor

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?

Copy link
Contributor Author

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;
Copy link
Contributor

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)

Copy link
Contributor Author

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

Copy link
Contributor

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
Copy link
Contributor

Choose a reason for hiding this comment

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

confusing comment

Copy link
Contributor Author

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

runtime/runtime/src/ext.rs Show resolved Hide resolved
runtime/runtime/src/ext.rs Show resolved Hide resolved
@austinabell
Copy link
Contributor Author

@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:

  • External in VM logic is &'a mut dyn External so it would require downcasting just to get a MockedExternal to assert specific values on a mock implementation (doesn't seem that valuable)
    • Would also be asserting specific values assuming gas usage on a mocked implementation. This also doesn't seem valuable and is very brittle for this test breaking for other reasons unless the test is involved and recalculates everything
  • There is no testing scaffolding to test non-mock implementations of External. This would be much easier and more valuable if testing after migration of receipt logic from external to VM logic
    • Quite an involved change for this, and didn't want to block this implementation

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.

@austinabell austinabell requested review from mm-near and removed request for olonho March 9, 2022 18:50
@@ -276,6 +278,49 @@ pub trait External {
prepaid_gas: Gas,
) -> Result<()>;

/// Attach the [`FunctionCallAction`] action to an existing receipt.
///
Copy link
Contributor

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 {
Copy link
Contributor

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;
Copy link
Contributor

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");
Copy link
Contributor

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?

runtime/runtime/src/ext.rs Show resolved Hide resolved
#[cfg(feature = "protocol_feature_function_call_weight")]
#[test]
fn function_call_weight_single_smoke_test() {
// Single function call
Copy link
Contributor

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

@mm-near
Copy link
Contributor

mm-near commented Mar 9, 2022

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..

@austinabell
Copy link
Contributor Author

austinabell commented Mar 10, 2022

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.

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.

6 participants