Skip to content

Commit 13aeaef

Browse files
committed
refactor: use ordered publisher list
This change brings down the average cost of a price update without aggregation from 5k to 2k because binary search requires much less lookups. There are mechanims implemented in the code to make sure that upon the upgrade we sort the array without adding overhead to the happy path (working binary search). Also add_publisher now does an insertion sort to keep the list sorted and del_publisher won't change the order of the list.
1 parent ceb4a32 commit 13aeaef

File tree

5 files changed

+400
-15
lines changed

5 files changed

+400
-15
lines changed

program/rust/src/accounts/price.rs

+30
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
pub use price_pythnet::*;
2+
#[cfg(test)]
3+
use quickcheck::Arbitrary;
24
use {
35
super::{
46
AccountHeader,
@@ -188,14 +190,29 @@ mod price_pythnet {
188190
}
189191

190192
#[repr(C)]
193+
#[cfg_attr(test, derive(Debug, PartialEq))]
191194
#[derive(Copy, Clone, Pod, Zeroable)]
192195
pub struct PriceComponent {
193196
pub pub_: Pubkey,
194197
pub agg_: PriceInfo,
195198
pub latest_: PriceInfo,
196199
}
197200

201+
#[cfg(test)]
202+
impl Arbitrary for PriceComponent {
203+
fn arbitrary(g: &mut quickcheck::Gen) -> Self {
204+
let mut key = [0u8; 32];
205+
key.iter_mut().for_each(|item| *item = u8::arbitrary(g));
206+
PriceComponent {
207+
pub_: Pubkey::new_from_array(key),
208+
agg_: PriceInfo::arbitrary(g),
209+
latest_: PriceInfo::arbitrary(g),
210+
}
211+
}
212+
}
213+
198214
#[repr(C)]
215+
#[cfg_attr(test, derive(Debug, PartialEq))]
199216
#[derive(Copy, Clone, Pod, Zeroable)]
200217
pub struct PriceInfo {
201218
pub price_: i64,
@@ -205,6 +222,19 @@ pub struct PriceInfo {
205222
pub pub_slot_: u64,
206223
}
207224

225+
#[cfg(test)]
226+
impl Arbitrary for PriceInfo {
227+
fn arbitrary(g: &mut quickcheck::Gen) -> Self {
228+
PriceInfo {
229+
price_: i64::arbitrary(g),
230+
conf_: u64::arbitrary(g),
231+
status_: u32::arbitrary(g),
232+
corp_act_status_: u32::arbitrary(g),
233+
pub_slot_: u64::arbitrary(g),
234+
}
235+
}
236+
}
237+
208238
#[repr(C)]
209239
#[derive(Copy, Clone, Pod, Zeroable)]
210240
pub struct PriceEma {

program/rust/src/processor/add_publisher.rs

+149-4
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ use {
2424
account_info::AccountInfo,
2525
entrypoint::ProgramResult,
2626
program_error::ProgramError,
27-
program_memory::sol_memset,
27+
program_memory::{
28+
sol_memcmp,
29+
sol_memset,
30+
},
2831
pubkey::Pubkey,
2932
},
3033
std::mem::size_of,
@@ -41,8 +44,7 @@ pub fn add_publisher(
4144
let cmd_args = load::<AddPublisherArgs>(instruction_data)?;
4245

4346
pyth_assert(
44-
instruction_data.len() == size_of::<AddPublisherArgs>()
45-
&& cmd_args.publisher != Pubkey::default(),
47+
instruction_data.len() == size_of::<AddPublisherArgs>(),
4648
ProgramError::InvalidArgument,
4749
)?;
4850

@@ -63,6 +65,14 @@ pub fn add_publisher(
6365

6466
let mut price_data = load_checked::<PriceAccount>(price_account, cmd_args.header.version)?;
6567

68+
// Use the call with the default pubkey (000..) as a trigger to sort the publishers as a
69+
// migration step from unsorted list to sorted list.
70+
if cmd_args.publisher == Pubkey::default() {
71+
let num_comps = try_convert::<u32, usize>(price_data.num_)?;
72+
sort_price_comps(&mut price_data.comp_, num_comps)?;
73+
return Ok(());
74+
}
75+
6676
if price_data.num_ >= PC_NUM_COMP {
6777
return Err(ProgramError::InvalidArgument);
6878
}
@@ -73,14 +83,149 @@ pub fn add_publisher(
7383
}
7484
}
7585

76-
let current_index: usize = try_convert(price_data.num_)?;
86+
let mut current_index: usize = try_convert(price_data.num_)?;
7787
sol_memset(
7888
bytes_of_mut(&mut price_data.comp_[current_index]),
7989
0,
8090
size_of::<PriceComponent>(),
8191
);
8292
price_data.comp_[current_index].pub_ = cmd_args.publisher;
93+
94+
// Shift the element back to keep the publishers components sorted.
95+
while current_index > 0
96+
&& price_data.comp_[current_index].pub_ < price_data.comp_[current_index - 1].pub_
97+
{
98+
price_data.comp_.swap(current_index, current_index - 1);
99+
current_index -= 1;
100+
}
101+
83102
price_data.num_ += 1;
84103
price_data.header.size = try_convert::<_, u32>(PriceAccount::INITIAL_SIZE)?;
85104
Ok(())
86105
}
106+
107+
/// A copy of rust slice/sort.rs heapsort implementation which is small and fast. We couldn't use
108+
/// the sort directly because it was only accessible behind a unstable feature flag at the time of
109+
/// writing this code.
110+
#[inline(always)]
111+
fn heapsort(v: &mut [(Pubkey, usize)]) {
112+
// This binary heap respects the invariant `parent >= child`.
113+
let sift_down = |v: &mut [(Pubkey, usize)], mut node: usize| {
114+
loop {
115+
// Children of `node`.
116+
let mut child = 2 * node + 1;
117+
if child >= v.len() {
118+
break;
119+
}
120+
121+
// Choose the greater child.
122+
if child + 1 < v.len()
123+
&& sol_memcmp(v[child].0.as_ref(), v[child + 1].0.as_ref(), 32) < 0
124+
{
125+
child += 1;
126+
}
127+
128+
// Stop if the invariant holds at `node`.
129+
if sol_memcmp(v[node].0.as_ref(), v[child].0.as_ref(), 32) >= 0 {
130+
break;
131+
}
132+
133+
// Swap `node` with the greater child, move one step down, and continue sifting.
134+
v.swap(node, child);
135+
node = child;
136+
}
137+
};
138+
139+
// Build the heap in linear time.
140+
for i in (0..v.len() / 2).rev() {
141+
sift_down(v, i);
142+
}
143+
144+
// Pop maximal elements from the heap.
145+
for i in (1..v.len()).rev() {
146+
v.swap(0, i);
147+
sift_down(&mut v[..i], 0);
148+
}
149+
}
150+
151+
/// Sort the publishers price component list in place by performing minimal swaps.
152+
/// This code is inspired by the sort_by_cached_key implementation in the Rust stdlib.
153+
/// The rust stdlib implementation is not used because it uses a fast sort variant that has
154+
/// a large code size.
155+
///
156+
/// num_publishers is the number of publishers in the list that should be sorted. It is explicitly
157+
/// passed to avoid callers mistake of passing the full slice which may contain uninitialized values.
158+
#[inline(always)]
159+
fn sort_price_comps(comps: &mut [PriceComponent], num_comps: usize) -> Result<(), ProgramError> {
160+
let comps = comps
161+
.get_mut(..num_comps)
162+
.ok_or(ProgramError::InvalidArgument)?;
163+
164+
let mut keys = comps
165+
.iter()
166+
.enumerate()
167+
.map(|(i, x)| (x.pub_, i))
168+
.collect::<Vec<_>>();
169+
170+
heapsort(&mut keys);
171+
172+
for i in 0..num_comps {
173+
// We know that the publisher with key[i].0 should be at index i in the sorted array and
174+
// want to swap them in-place in O(n). Normally, the publisher at key[i].0 should be at
175+
// key[i].1 but if it is swapped, we need to find the correct index by following the chain
176+
// of swaps.
177+
let mut index = keys[i].1;
178+
179+
while index < i {
180+
index = keys[index].1;
181+
}
182+
// Setting the final index here is important to make the code linear as we won't
183+
// loop over from i to index again when we reach i again.
184+
keys[i].1 = index;
185+
comps.swap(i, index);
186+
}
187+
188+
Ok(())
189+
}
190+
191+
#[cfg(test)]
192+
mod test {
193+
use {
194+
super::*,
195+
quickcheck_macros::quickcheck,
196+
};
197+
198+
#[quickcheck]
199+
pub fn test_sort_price_comps(mut comps: Vec<PriceComponent>) {
200+
let mut rust_std_sorted_comps = comps.clone();
201+
rust_std_sorted_comps.sort_by_key(|x| x.pub_);
202+
203+
let num_comps = comps.len();
204+
assert_eq!(
205+
sort_price_comps(&mut comps, num_comps + 1),
206+
Err(ProgramError::InvalidArgument)
207+
);
208+
209+
assert_eq!(sort_price_comps(&mut comps, num_comps), Ok(()));
210+
assert_eq!(comps, rust_std_sorted_comps);
211+
}
212+
213+
#[quickcheck]
214+
pub fn test_sort_price_comps_smaller_slice(
215+
mut comps: Vec<PriceComponent>,
216+
mut num_comps: usize,
217+
) {
218+
num_comps = if comps.is_empty() {
219+
0
220+
} else {
221+
num_comps % comps.len()
222+
};
223+
224+
let mut rust_std_sorted_comps = comps.get(..num_comps).unwrap().to_vec();
225+
rust_std_sorted_comps.sort_by_key(|x| x.pub_);
226+
227+
228+
assert_eq!(sort_price_comps(&mut comps, num_comps), Ok(()));
229+
assert_eq!(comps.get(..num_comps).unwrap(), rust_std_sorted_comps);
230+
}
231+
}

program/rust/src/processor/upd_price.rs

+99-11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use {
22
crate::{
33
accounts::{
44
PriceAccount,
5+
PriceComponent,
56
PriceInfo,
67
PythOracleSerialize,
78
UPD_PRICE_WRITE_SEED,
@@ -31,6 +32,7 @@ use {
3132
},
3233
program::invoke_signed,
3334
program_error::ProgramError,
35+
program_memory::sol_memcmp,
3436
pubkey::Pubkey,
3537
sysvar::Sysvar,
3638
},
@@ -127,7 +129,7 @@ pub fn upd_price(
127129
// Check clock
128130
let clock = Clock::from_account_info(clock_account)?;
129131

130-
let mut publisher_index: usize = 0;
132+
let publisher_index: usize;
131133
let latest_aggregate_price: PriceInfo;
132134

133135
// The price_data borrow happens in a scope because it must be
@@ -137,17 +139,15 @@ pub fn upd_price(
137139
// Verify that symbol account is initialized
138140
let price_data = load_checked::<PriceAccount>(price_account, cmd_args.header.version)?;
139141

140-
// Verify that publisher is authorized
141-
while publisher_index < try_convert::<u32, usize>(price_data.num_)? {
142-
if price_data.comp_[publisher_index].pub_ == *funding_account.key {
143-
break;
142+
publisher_index = match find_publisher_index(
143+
&price_data.comp_[..try_convert::<u32, usize>(price_data.num_)?],
144+
funding_account.key,
145+
) {
146+
Some(index) => index,
147+
None => {
148+
return Err(OracleError::PermissionViolation.into());
144149
}
145-
publisher_index += 1;
146-
}
147-
pyth_assert(
148-
publisher_index < try_convert::<u32, usize>(price_data.num_)?,
149-
OracleError::PermissionViolation.into(),
150-
)?;
150+
};
151151

152152
latest_aggregate_price = price_data.agg_;
153153
let latest_publisher_price = price_data.comp_[publisher_index].latest_;
@@ -281,6 +281,62 @@ pub fn upd_price(
281281
Ok(())
282282
}
283283

284+
/// Find the index of the publisher in the list of components.
285+
///
286+
/// This method first tries to binary search for the publisher's key in the list of components
287+
/// to get the result faster if the list is sorted. If the list is not sorted, it falls back to
288+
/// a linear search.
289+
#[inline(always)]
290+
fn find_publisher_index(comps: &[PriceComponent], key: &Pubkey) -> Option<usize> {
291+
// Verify that publisher is authorized by initially binary searching
292+
// for the publisher's component in the price account. The binary
293+
// search might not work if the publisher list is not sorted; therefore
294+
// we fall back to a linear search.
295+
let mut binary_search_result = None;
296+
297+
{
298+
// Binary search to find the publisher key. Rust std binary search is not used because
299+
// they guarantee valid outcome only if the array is sorted whereas we want to rely on
300+
// a Equal match if it is a result on an unsorted array. Currently the rust
301+
// implementation behaves the same but we do not want to rely on api internals.
302+
let mut left = 0;
303+
let mut right = comps.len();
304+
while left < right {
305+
let mid = left + (right - left) / 2;
306+
match sol_memcmp(comps[mid].pub_.as_ref(), key.as_ref(), 32) {
307+
i if i < 0 => {
308+
left = mid + 1;
309+
}
310+
i if i > 0 => {
311+
right = mid;
312+
}
313+
_ => {
314+
binary_search_result = Some(mid);
315+
break;
316+
}
317+
}
318+
}
319+
}
320+
321+
match binary_search_result {
322+
Some(index) => Some(index),
323+
None => {
324+
let mut index = 0;
325+
while index < comps.len() {
326+
if sol_memcmp(comps[index].pub_.as_ref(), key.as_ref(), 32) == 0 {
327+
break;
328+
}
329+
index += 1;
330+
}
331+
if index == comps.len() {
332+
None
333+
} else {
334+
Some(index)
335+
}
336+
}
337+
}
338+
}
339+
284340
#[allow(dead_code)]
285341
// Wrapper struct for the accounts required to add data to the accumulator program.
286342
struct MessageBufferAccounts<'a, 'b: 'a> {
@@ -289,3 +345,35 @@ struct MessageBufferAccounts<'a, 'b: 'a> {
289345
oracle_auth_pda: &'a AccountInfo<'b>,
290346
message_buffer_data: &'a AccountInfo<'b>,
291347
}
348+
349+
#[cfg(test)]
350+
mod test {
351+
use {
352+
super::*,
353+
crate::accounts::PriceComponent,
354+
quickcheck_macros::quickcheck,
355+
solana_program::pubkey::Pubkey,
356+
};
357+
358+
/// Test the find_publisher_index method works with an unordered list of components.
359+
#[quickcheck]
360+
pub fn test_find_publisher_index_unordered_comp(comps: Vec<PriceComponent>) {
361+
comps.iter().enumerate().for_each(|(idx, comp)| {
362+
assert_eq!(find_publisher_index(&comps, &comp.pub_), Some(idx));
363+
});
364+
365+
assert_eq!(find_publisher_index(&comps, &Pubkey::new_unique()), None);
366+
}
367+
368+
/// Test the find_publisher_index method works with a sorted list of components.
369+
#[quickcheck]
370+
pub fn test_find_publisher_index_ordered_comp(mut comps: Vec<PriceComponent>) {
371+
comps.sort_by_key(|comp| comp.pub_);
372+
373+
comps.iter().enumerate().for_each(|(idx, comp)| {
374+
assert_eq!(find_publisher_index(&comps, &comp.pub_), Some(idx));
375+
});
376+
377+
assert_eq!(find_publisher_index(&comps, &Pubkey::new_unique()), None);
378+
}
379+
}

0 commit comments

Comments
 (0)