-
Notifications
You must be signed in to change notification settings - Fork 3.7k
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
[loader-v2] Fixing global cache reads & read-before-write on publish #15285
base: main
Are you sure you want to change the base?
Conversation
⏱️ 5h 30m total CI duration on this PR
🚨 2 jobs on the last run were significantly faster/slower than expected
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
0f7f5b2
to
3b8ce5c
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
enum ModuleRead<DC, VC, S> { | ||
/// Read from the cross-block module cache. | ||
GlobalCache, | ||
GlobalCache(Arc<ModuleCode<DC, VC, S>>), | ||
/// Read from per-block cache ([SyncCodeCache]) used by parallel execution. | ||
PerBlockCache(Option<(Arc<ModuleCode<DC, VC, S>>, Option<TxnIndex>)>), |
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.
can you explain why do we distinguish reads here based on where we got the data from? also what is Option<TxnIndex>
in the PerBlockCache ?
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.
Option - module does not exist (in StateView even).
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.
Different reads - different validations. We need to check that global reads are still valid, and per-block reads have the same version
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.
stupid formatting, didn't show I was referring to TxnIndex
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 - None is a storage version
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 find it better than Result<TxnIndex, StorageVersion>
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.
why distinction between storage version and global cache?
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.
Different validation paths: for global cache read we need to check if the read is still valid in cache. For per-block we go to MVHashMap. Now, the question is about storage read: we issue it only when there is a cache miss in per-block cache, so it gets validated there.
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.
Basically "storage version" can be later drained into global cache, but otherwise exists only in per-block
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 from validation perspective - there is no distinction
distinction is ONLY there to make updating global cache (i.e. draining to it) be faster/cheaper by skipping things that are already there.
is that correct?
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
3b8ce5c
to
59e4942
Compare
59e4942
to
dc8af3f
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
✅ Forge suite
|
✅ Forge suite
|
enum ModuleRead<DC, VC, S> { | ||
/// Read from the cross-block module cache. | ||
GlobalCache, | ||
GlobalCache(Arc<ModuleCode<DC, VC, S>>), | ||
/// Read from per-block cache ([SyncCodeCache]) used by parallel execution. | ||
PerBlockCache(Option<(Arc<ModuleCode<DC, VC, S>>, Option<TxnIndex>)>), |
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 from validation perspective - there is no distinction
distinction is ONLY there to make updating global cache (i.e. draining to it) be faster/cheaper by skipping things that are already there.
is that correct?
@@ -661,7 +658,7 @@ where | |||
} | |||
|
|||
self.module_reads.iter().all(|(key, read)| match read { | |||
ModuleRead::GlobalCache => global_module_cache.contains_valid(key), | |||
ModuleRead::GlobalCache(_) => global_module_cache.contains_valid(key), |
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.
should this whole match be equivalent to:
self.module_reads.iter().all(|(key, read)| {
let previous_version = match read {
ModuleRead::GlobalCache(_) => None, // i.e. storage version
ModuleRead::PerBlockCache(previous) => previous.as_ref().map(|(_, version)| *version);
};
let current_version = per_block_module_cache.get_module_version(key);
current_version == previous_version
})
why do we need to update GlobalCache at all while executing a block?
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 do if we read first from it (to know if entry is overridden or not). An alternative is to check lower level cache first, but this means performance penalty due to locking.
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 code can be somewhat equivalent, but:
let current_version = per_block_module_cache.get_module_version(key);
causes a prefetch of storage version by default. We would need to special case validation to not do it. An we also end up locking the cache (shard, worst case), instead of checking an atomic 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.
this is because we may publish a module that invalidates the global cache that's being read I think
} | ||
|
||
// Otherwise, it is a miss. Check global cache. |
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.
why do we check global cache before checking state.versioned_map.module_cache ?
on rolling commit - are we updating GlobalCache itself?
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 update global cache at rolling commit - if published keys exist in global cache, we mark them as invalid. So reads to them results in a cache miss and we fallback to MVHashMap where we have placed the write at commit time.
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.
You can check versioned before, but then you end up acquiring a lock for potentially non-republished module (publish is rare). If 32 threads do this for aptos-framework, this is bad.
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 instead, we lookup in global first, but check an atomic bool flag there (better than a lock), so we optimize for read case
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 see, then I would rename PerBlockCache to UnfinalizedBlockCache or something like that - to make it clear it only ever refers to things before rolling commit, and GlobalCache is global and updated within the block itself.
(you can do that in separate PR of course :) )
} | ||
|
||
// Otherwise, it is a miss. Check global cache. | ||
if let Some(module) = self.global_module_cache.get_valid(key) { |
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.
why do we reverse the order of checking now? (I was wondering for the previous pr about the order too)
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.
Now, we always check local cache first. If it is not there, we as before check 1) global first, if valid, 2) per-block next. In both cases, clone the module to captured reads (local cache). So next read always reads the same thing. Does this make sense?
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 was asking more about why we checked global cache in previous pr, is this an orthogonal change or we need to reverse the order now?
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 still first check global cache.
What is added here is check in captured reads - meaning whether this same transaction has already read it, and if it did - do not read it again
@@ -1233,8 +1240,17 @@ where | |||
Version = Option<TxnIndex>, | |||
>, | |||
) -> Result<(), PanicError> { | |||
let (id, write_op) = write.unpack(); | |||
// Enforce read-before-write because storage (DB) relies on this assumption. | |||
let _ = base_view.get_state_value(&key).map_err(|err| { |
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 need some tests for this assumption 🤦♂️
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.
@msmouse Do you have a code pointer how I can test this with a DB?
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 think he adds a panic here #15283 so we just need to test such some publish scenarios (with real db)
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.
many tests fail with the panic.. I need to debug today to see how to enforce the assumption better.
none the less I think we need to trigger some module republishing in a smoke test
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.
@msmouse does it need to be a smoke test? we can have executor benchmark lib unit test with publishing with 3 lines of 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.
it's hard to construct some complex scenario in smoke test because most of the time a block only has one txn (block produces too fast). it's easier to use executor + db alone to reproduce. I thought we already have module publish in smoke tests
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.
#[test]
fn test_module_publish_txns() {
AptosVM::set_num_shards_once(1);
AptosVM::set_concurrency_level_once(4);
AptosVM::set_processed_transactions_detailed_counters();
test_generic_benchmark::<AptosVMBlockExecutor>(
Some(TransactionTypeArg::PublishPackage),
true,
);
}
this doesn't call on them afterwards though
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 need to repeatedly publish the same module and the module must be being used so it resides in cache, is that simple to implement?
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.
Should be, let me see
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.
here is the republish and call mixed workload - #15292, and it fails.
you can use it to confirm/test this fix.
@@ -1233,8 +1240,17 @@ where | |||
Version = Option<TxnIndex>, | |||
>, | |||
) -> Result<(), PanicError> { | |||
let (id, write_op) = write.unpack(); | |||
// Enforce read-before-write because storage (DB) relies on this assumption. | |||
let _ = base_view.get_state_value(&key).map_err(|err| { |
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.
here is the republish and call mixed workload - #15292, and it fails.
you can use it to confirm/test this fix.
Description
How Has This Been Tested?
Key Areas to Review
Type of Change
Which Components or Systems Does This Change Impact?
Checklist