-
Notifications
You must be signed in to change notification settings - Fork 187
optimized extranonce prefix generation #2049
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
Open
coleFD
wants to merge
1
commit into
stratum-mining:main
Choose a base branch
from
coleFD:optimized-enonce-prefix-gen
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| use bitvec::prelude::*; | ||
|
|
||
| /// Represents a unique extranonce prefix for a client connection. | ||
| #[derive(Eq, Hash, PartialEq, Clone, Debug)] | ||
| pub struct ExtranoncePrefix { | ||
| bit_index: u16, | ||
| id: [u8; 4], | ||
| } | ||
| impl PartialOrd for ExtranoncePrefix { | ||
| fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> { | ||
| Some(self.cmp(other)) | ||
| } | ||
| } | ||
| impl Ord for ExtranoncePrefix { | ||
| fn cmp(&self, other: &Self) -> core::cmp::Ordering { | ||
| self.bit_index.cmp(&other.bit_index) | ||
| } | ||
| } | ||
|
|
||
| /// Extranonce prefixes are generated using a bit vector where each prefix represents a bit | ||
| /// in the vector. The prefix is then composed of the index of the assigned bit | ||
| /// and the server ID. When a channel ends, the bit is cleared, allowing it to be reused. | ||
| /// | ||
| /// # Generic: | ||
| /// `NUM_BIT_CONTAINERS`: The number of u64 containers allocated for generating unique extranonce prefixes (ex. `ExtranoncePrefixManager<625>` results in `40k` bits for generating extranonce prefixes) | ||
| /// The extranonce prefix component of the extranonce1 is capped at 2 bytes, so the maximum useful value for NUM_BIT_CONTAINERS is 1024 (65536 bits). | ||
| pub struct ExtranoncePrefixManager<const NUM_BIT_CONTAINERS: usize> { | ||
| server_id: Option<u8>, | ||
| prefix_bits: BitVec<u64>, | ||
| last_freed_bit_index: Option<usize>, | ||
| last_allocated_bit_index: Option<usize>, | ||
| } | ||
|
|
||
| impl<const NUM_BIT_CONTAINERS: usize> ExtranoncePrefixManager<NUM_BIT_CONTAINERS> { | ||
| /// NOTE: we are using u64 as the underlying type for the bit vector for performance reasons (~90% faster than u8). | ||
| /// The performance is most likely attributed to less underlying container loads. To calculate the total | ||
| /// available bits, we take the number of containers and multiply by 64 (bits in a u64). | ||
| pub const AVAILABLE_BITS: usize = NUM_BIT_CONTAINERS * 64; | ||
| pub fn new(server_id: Option<u8>) -> Self { | ||
| assert!( | ||
| Self::AVAILABLE_BITS - 1 <= u16::MAX as usize, | ||
| "Cannot allocate more than 65536 bits for extranonce prefix" | ||
| ); | ||
| let prefix_bits = bitvec!(u64, Lsb0; 0; Self::AVAILABLE_BITS); | ||
| Self { | ||
| server_id, | ||
| prefix_bits, | ||
| last_freed_bit_index: None, | ||
| last_allocated_bit_index: None, | ||
| } | ||
| } | ||
|
|
||
| /// this is slow, so we should only use for testing | ||
| /// "full" is when there is only one zero bit left in the prefix_bits bit vector | ||
| /// (this is for optimization purposes) | ||
| #[cfg(test)] | ||
| fn is_full(&self) -> bool { | ||
| let zero_count = self.prefix_bits.count_ones(); | ||
| Self::AVAILABLE_BITS - zero_count <= 1 | ||
| } | ||
|
|
||
| pub fn generate_extranonce_prefix(&mut self) -> Option<ExtranoncePrefix> { | ||
| // start at the last_allocated_index because it's more likely to have free slots after it that have not just been freed | ||
| let start_index = self | ||
| .last_allocated_bit_index | ||
| .map_or(0, |idx| (idx + 1) % self.prefix_bits.len()); | ||
| // create an open slot iterator that starts from the last allocated bit index and wraps around | ||
| // PERFORMANCE NOTE: `.iter_zeros()` is ~12x more performant that iterating over all bits | ||
| let iter_zeros = self.prefix_bits.iter_zeros(); | ||
| let first_set = iter_zeros.skip_while(|bit_index| *bit_index < start_index); // this only clones the iterator state, not the underlying vector | ||
| let second_set = iter_zeros.take_while(|bit_index| *bit_index < start_index); | ||
| let shifted_zeros_bit_iter = first_set.chain(second_set); | ||
|
|
||
| // search for the first zero bit, skipping the last freed bit index if necessary | ||
| let mut bit_index = None; | ||
| for zero_bit_index in shifted_zeros_bit_iter { | ||
| // this is only a sanity check to prevent reusing the last freed bit index, | ||
| // since we start searching from the last set bit index | ||
| if let Some(last_freed_bit_index) = self.last_freed_bit_index { | ||
| if zero_bit_index == last_freed_bit_index { | ||
| continue; | ||
| } | ||
| } | ||
| bit_index = Some(zero_bit_index); | ||
| break; | ||
| } | ||
|
|
||
| // update state if we found a zero bit, else let the caller know to drop the attempted connection | ||
| let client_bit_index = if let Some(index) = bit_index { | ||
| self.prefix_bits.set(index, true); | ||
| self.last_allocated_bit_index = Some(index); | ||
| index as u16 | ||
| } else { | ||
| return None; | ||
| }; | ||
|
|
||
| // convert bits to ExtranoncePrefix | ||
| let mut extranonce_prefix_bytes = [0u8; 4]; | ||
| extranonce_prefix_bytes[0..2].copy_from_slice(&(client_bit_index).to_le_bytes()); | ||
| let server_id_bytes = match self.server_id { | ||
| Some(id) => (id as u16).to_be_bytes(), | ||
| None => 0u16.to_be_bytes(), | ||
| }; | ||
| extranonce_prefix_bytes[2..4].copy_from_slice(&server_id_bytes); | ||
| Some(ExtranoncePrefix { | ||
| bit_index: client_bit_index, | ||
| id: extranonce_prefix_bytes, | ||
| }) | ||
| } | ||
|
|
||
| pub fn free_extranonce_prefix(&mut self, extranonce_prefix: &ExtranoncePrefix) { | ||
| self.prefix_bits | ||
| .set(extranonce_prefix.bit_index as usize, false); | ||
| self.last_freed_bit_index = Some(extranonce_prefix.bit_index as usize); | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod test { | ||
| use super::*; | ||
| use std::collections::HashSet; | ||
|
|
||
| #[test] | ||
| fn test_extranonce_prefix_manager() { | ||
| const ALLOCATED_PREFIX_BITS: usize = 1024; // 1024 * u64 bits for extranonce prefix, allows for 65536 unique IDs | ||
| let mut manager = ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::new(Some(0)); | ||
| assert!(!manager.is_full()); | ||
| assert_eq!( | ||
| ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::AVAILABLE_BITS, | ||
| u16::MAX as usize + 1 | ||
| ); | ||
|
|
||
| let mut used_ids = HashSet::new(); | ||
| // Start timing | ||
| let start = std::time::Instant::now(); | ||
|
|
||
| // Generate all available prefixes | ||
| for _ in 0..ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::AVAILABLE_BITS { | ||
| let extranonce_prefix = manager | ||
| .generate_extranonce_prefix() | ||
| .expect("Failed to generate extranonce prefix"); | ||
| assert_eq!( | ||
| extranonce_prefix.id.len(), | ||
| 4, | ||
| "ExtranoncePrefix is not 4 bytes" | ||
| ); | ||
| assert!(used_ids.insert(extranonce_prefix)); | ||
| } | ||
|
|
||
| // End timing | ||
| let duration = start.elapsed(); | ||
| let avg = duration.as_secs_f64() | ||
| / ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::AVAILABLE_BITS as f64; | ||
| println!( | ||
| "Total time: {:?}, average per loop: {:.9} micros", | ||
| duration, | ||
| avg * 1_000_000.0 | ||
| ); | ||
| // try to generate one more extranonce prefix, should fail | ||
| let extranonce_prefix = manager.generate_extranonce_prefix(); | ||
| assert!(extranonce_prefix.is_none()); | ||
| assert!(manager.is_full()); | ||
|
|
||
| // free 2 extranonce prefix and check if we can generate a new one | ||
| let last_allocated_bit_index = manager | ||
| .last_allocated_bit_index | ||
| .expect("No last allocated bit index"); | ||
| let mut extranonce_prefix_sorted = used_ids.iter().cloned().collect::<Vec<_>>(); | ||
| extranonce_prefix_sorted.sort(); | ||
| let mut extranonce_prefix_iterator = extranonce_prefix_sorted.iter(); | ||
| let mut free_extranonce_prefix = ExtranoncePrefix { | ||
| bit_index: 0, | ||
| id: [0u8; 4], | ||
| }; | ||
| for _i in 0..2 { | ||
| let extranonce_prefix = extranonce_prefix_iterator | ||
| .next() | ||
| .cloned() | ||
| .expect("No extranonce prefixes to free"); | ||
| free_extranonce_prefix = extranonce_prefix.clone(); | ||
| manager.free_extranonce_prefix(&extranonce_prefix); | ||
| } | ||
| assert!(!manager.is_full()); // after filling the manager, freeing one it should be full until 2 are freed | ||
| let extranonce_prefix = manager | ||
| .generate_extranonce_prefix() | ||
| .expect("Failed to generate extranonce prefix"); | ||
| assert!( | ||
| extranonce_prefix.bit_index < last_allocated_bit_index as u16, | ||
| "ExtranoncePrefix generator did not wrap" | ||
| ); | ||
| assert!(manager.is_full()); | ||
| assert!( | ||
| manager.generate_extranonce_prefix().is_none(), | ||
| "extranonce prefixes should be 'full' again" | ||
| ); | ||
| assert_ne!( | ||
| extranonce_prefix.bit_index, free_extranonce_prefix.bit_index, | ||
| "Even when exhausting the manager, the new extranonce prefix bit index should not be the same as the last freed one" | ||
| ); | ||
| assert_ne!( | ||
| extranonce_prefix.id, free_extranonce_prefix.id, | ||
| "Even when exhausting the manager, the new extranonce prefix should not be the same as the last freed one" | ||
| ); | ||
|
|
||
| let mut used_ids_iter = used_ids.into_iter(); | ||
| let first_extranonce_prefix = used_ids_iter | ||
| .next() | ||
| .expect("No extranonce prefixes to check"); | ||
| let second_extranonce_prefix = used_ids_iter | ||
| .next() | ||
| .expect("No second extranonce prefix to check"); | ||
|
|
||
| // we need to know which extranonce prefix is smaller and which is bigger, so we can ensure we ignore the smaller one in the next test, since | ||
| // `generate_extranonce_prefix` iterates zeros from smallest to largest bit_index | ||
| let (smaller_extranonce_prefix, bigger_extranonce_prefix) = | ||
| if first_extranonce_prefix.bit_index < second_extranonce_prefix.bit_index { | ||
| (first_extranonce_prefix, second_extranonce_prefix) | ||
| } else { | ||
| (second_extranonce_prefix, first_extranonce_prefix) | ||
| }; | ||
|
|
||
| manager.free_extranonce_prefix(&bigger_extranonce_prefix); | ||
| manager.free_extranonce_prefix(&smaller_extranonce_prefix); | ||
| let new_extranonce_prefix = manager | ||
| .generate_extranonce_prefix() | ||
| .expect("Failed to generate extranonce prefix after freeing"); | ||
| assert!( | ||
| new_extranonce_prefix.bit_index != smaller_extranonce_prefix.bit_index, | ||
| "The new extranonce prefix should pass over the last freed bit index" | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
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.
id is hardcoded to 4 bytes because it gives 2 bytes (65,536 connections) which is more than enough, and another 2 bytes for the server_id. This could be changed to support 3 or 4 as well but not sure if it's worth the extra byte. I dont see a reason to support more than 4 bytes
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.
are we assuming that each connection will only consume one single
extranonce_prefix? what if the same connection has multiple channels?also, what's the rationale behind the number 65,536?
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.
rather than connections, i should say channels or "unique ids".
65,536 is a u16::max (2 bytes). It's fairly easy to make this size configurable to whatever size the user wants with generics.
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 an added benefit of restricting the total extranonce-prefix to 4 bytes is that it could be used as a channel id as well
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.
@coleFD what do you think about using 1 byte for the
server_id(which would support 256 different servers), and 3 bytes for theextranonce_prefix(es)(which would support up to 16.777.216 different channels)?Uh oh!
There was an error while loading. Please reload this page.
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.
that works too. You could allow this to be "configurable" with generics. Or maybe the optimal is somewhere in between like 12 bits for server ID (4096 servers) and 20 bits (1.05M) for the channel component. I think it's just preference. One thing to note is that as the channel component increases, finding a free bit gets slower since it's O(n). at 2 bytes, i benchmarked somewhere around 1-2 micros for finding available bits which is fine considering this is only called once per channel at origination. There may be a better way to start at the last freed bit index as opposed to
.skip_while()though. Either way, performance should be considered because it is not uncommon to drop/connect a bunch of miners as fast as possible (ie. curtailment and non-agg proxies) and the connection time across thousands of clients add up.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.
Another thing to note which may point out the benefit to extra serverID bits is it can be used to distinguish between clusters in different regions, like
1 byte serverID + 1 byte cluster ID + 2 bytes channel component. In this case the api can recognize this as2 bytes server ID + 2 bytes channel componentwhere it is up to the user to ensure the server ID is unique with the cluster ID. This is the route we are going