diff --git a/.changeset/dull-toes-drop.md b/.changeset/dull-toes-drop.md new file mode 100644 index 00000000..51211c23 --- /dev/null +++ b/.changeset/dull-toes-drop.md @@ -0,0 +1,11 @@ +--- +"python-sdk": patch +"eppo_core": patch +"ruby-sdk": patch +"elixir-sdk": patch +"rust-sdk": patch +--- + +Added experimental support for CityHash-based hashing in bandit evaluation via the `EPPO_EXPERIMENTAL_BANDITS_CITYHASH` environment variable (set to `"1"`, `"true"`, or `"TRUE"` to enable). This provides significant performance improvements over the default MD5 implementation, especially when evaluating bandits with many actions. + +**Warning**: This feature is experimental and unstable. Enabling CityHash will produce different bandit evaluation results compared to the default MD5 implementation and other Eppo SDKs. Do not enable this if you need consistent results across multiple SDKs, services, or for historical data comparisons. diff --git a/eppo_core/Cargo.toml b/eppo_core/Cargo.toml index 49890dad..393babe5 100644 --- a/eppo_core/Cargo.toml +++ b/eppo_core/Cargo.toml @@ -26,6 +26,7 @@ vendored = [] [dependencies] base64 = "0.22.1" chrono = { version = "0.4.38", features = ["serde"] } +cityhasher = "0.1.0" derive_more = { version = "2.0.0", default-features = false, features = ["from", "into"] } faststr = { version = "0.2.23", features = ["serde"] } log = { version = "0.4.21", features = ["kv", "kv_serde"] } diff --git a/eppo_core/src/configuration.rs b/eppo_core/src/configuration.rs index b926da75..2d6d6565 100644 --- a/eppo_core/src/configuration.rs +++ b/eppo_core/src/configuration.rs @@ -8,6 +8,13 @@ use crate::{ Str, }; +/// Hashing algorithm to use for bandit evaluation. +#[derive(Debug, Clone, Copy)] +pub(crate) enum BanditHashingAlgorithm { + Md5, + CityHash, +} + /// Remote configuration for the eppo client. It's a central piece that defines client behavior. #[derive(Debug)] pub struct Configuration { @@ -17,6 +24,8 @@ pub struct Configuration { pub(crate) flags: UniversalFlagConfig, /// Bandits configuration. pub(crate) bandits: Option, + /// Hashing algorithm for bandit evaluation. + pub(crate) bandit_hashing_algorithm: BanditHashingAlgorithm, } impl Configuration { @@ -27,10 +36,23 @@ impl Configuration { ) -> Configuration { let now = Utc::now(); + // Check environment variable for experimental CityHash support + let bandit_hashing_algorithm = std::env::var("EPPO_EXPERIMENTAL_BANDITS_CITYHASH") + .ok() + .and_then(|val| { + if val == "1" || val == "true" || val == "TRUE" { + Some(BanditHashingAlgorithm::CityHash) + } else { + None + } + }) + .unwrap_or(BanditHashingAlgorithm::Md5); + Configuration { fetched_at: now, flags: config, bandits, + bandit_hashing_algorithm, } } diff --git a/eppo_core/src/eval/eval_bandits.rs b/eppo_core/src/eval/eval_bandits.rs index 2911f35a..521176c0 100644 --- a/eppo_core/src/eval/eval_bandits.rs +++ b/eppo_core/src/eval/eval_bandits.rs @@ -4,12 +4,16 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use serde::Serialize; +use cityhasher::CityHasher; +use md5::{Digest, Md5}; +use std::hash::Hasher; + use crate::bandits::{ BanditCategoricalAttributeCoefficient, BanditModelData, BanditNumericAttributeCoefficient, }; +use crate::configuration::BanditHashingAlgorithm; use crate::error::EvaluationFailure; use crate::events::{AssignmentEvent, BanditEvent}; -use crate::sharder::PreSaltedSharder; use crate::ufc::{Assignment, AssignmentValue, VariationType}; use crate::{Configuration, EvaluationError, Str}; use crate::{ContextAttributes, SdkMetadata}; @@ -187,27 +191,29 @@ fn get_bandit_action_with_visitor( return result; }; - let evaluation = - match bandit - .model_data - .evaluate(flag_key, subject_key, subject_attributes, actions.iter()) - { - Ok(evaluation) => evaluation, - Err(err) => { - // We've evaluated a flag but now bandit evaluation failed. (Likely to user supplying - // empty actions, or NaN attributes.) - // - // Abort evaluation and return default variant. - let result = BanditResult { - variation, - action: None, - assignment_event: assignment.event, - bandit_event: None, - }; - visitor.on_result(Err(err), &result); - return result; - } - }; + let evaluation = match bandit.model_data.evaluate( + flag_key, + subject_key, + subject_attributes, + actions.iter(), + configuration.bandit_hashing_algorithm, + ) { + Ok(evaluation) => evaluation, + Err(err) => { + // We've evaluated a flag but now bandit evaluation failed. (Likely to user supplying + // empty actions, or NaN attributes.) + // + // Abort evaluation and return default variant. + let result = BanditResult { + variation, + action: None, + assignment_event: assignment.event, + bandit_event: None, + }; + visitor.on_result(Err(err), &result); + return result; + } + }; let action_attributes = &actions[&evaluation.action_key]; let bandit_event = BanditEvent { @@ -236,6 +242,108 @@ fn get_bandit_action_with_visitor( return result; } +/// Trait for hashing in bandit evaluation. +/// +/// This trait abstracts the hashing logic for bandit evaluation, allowing different +/// implementations (MD5, CityHash) to be used interchangeably. +trait BanditHasher: Clone { + /// Create a new hasher pre-initialized with flag_key + "-" + subject_key + fn new(flag_key: &str, subject_key: &str) -> Self; + + /// Get the selection hash (0.0..1.0) for choosing action based on weights + fn selection_hash(&self) -> f64; + + /// Compute hash for shuffling a specific action + fn action_shuffle_hash(&self, action_key: &str) -> u64; +} + +/// MD5-based bandit hasher (10k shards, compatible with existing SDKs) +#[derive(Clone)] +struct Md5BanditHasher { + selection_hash: f64, + shuffle_ctx: Md5, // flag_key + "-" + subject_key + "-" +} + +impl BanditHasher for Md5BanditHasher { + fn new(flag_key: &str, subject_key: &str) -> Self { + const TOTAL_SHARDS: u32 = 10_000; + let mut base_ctx = Md5::new(); + base_ctx.update(flag_key.as_bytes()); + base_ctx.update(b"-"); + base_ctx.update(subject_key.as_bytes()); + + // Compute selection hash once + let selection_hash = { + let hash = base_ctx.clone().finalize(); + let value = u32::from_be_bytes(hash[0..4].try_into().unwrap()); + (value % TOTAL_SHARDS) as f64 / TOTAL_SHARDS as f64 + }; + + // Prepare context for shuffling + let mut shuffle_ctx = base_ctx; + shuffle_ctx.update(b"-"); + + Md5BanditHasher { + selection_hash, + shuffle_ctx, + } + } + + fn selection_hash(&self) -> f64 { + self.selection_hash + } + + fn action_shuffle_hash(&self, action_key: &str) -> u64 { + const TOTAL_SHARDS: u32 = 10_000; + let mut ctx = self.shuffle_ctx.clone(); + ctx.update(action_key.as_bytes()); + let hash = ctx.finalize(); + let value = u32::from_be_bytes(hash[0..4].try_into().unwrap()); + (value % TOTAL_SHARDS) as u64 + } +} + +/// CityHash-based bandit hasher (experimental, better performance) +#[derive(Clone)] +struct CityHashBanditHasher { + selection_hash: f64, + shuffle_ctx: CityHasher, +} + +impl BanditHasher for CityHashBanditHasher { + fn new(flag_key: &str, subject_key: &str) -> Self { + let mut base_ctx = CityHasher::new(); + base_ctx.write(flag_key.as_bytes()); + base_ctx.write(b"-"); + base_ctx.write(subject_key.as_bytes()); + + // Compute selection hash once + let selection_hash = { + let hash = base_ctx.clone().finish(); + hash as u32 as f64 / u32::MAX as f64 + }; + + // Prepare context for shuffling + let mut shuffle_ctx = base_ctx; + shuffle_ctx.write(b"-"); + + CityHashBanditHasher { + selection_hash, + shuffle_ctx, + } + } + + fn selection_hash(&self) -> f64 { + self.selection_hash + } + + fn action_shuffle_hash(&self, action_key: &str) -> u64 { + let mut ctx = self.shuffle_ctx.clone(); + ctx.write(action_key.as_bytes()); + ctx.finish() as u32 as u64 + } +} + impl BanditModelData { // Exported to super, so we can use it in precomputed evaluation. pub(super) fn evaluate<'a>( @@ -244,11 +352,32 @@ impl BanditModelData { subject_key: &str, subject_attributes: &ContextAttributes, actions: impl Iterator, + hashing_algorithm: BanditHashingAlgorithm, ) -> Result { - // total_shards is not configurable at the moment. - const TOTAL_SHARDS: u32 = 10_000; + match hashing_algorithm { + BanditHashingAlgorithm::Md5 => self.evaluate_with_hasher::( + flag_key, + subject_key, + subject_attributes, + actions, + ), + BanditHashingAlgorithm::CityHash => self.evaluate_with_hasher::( + flag_key, + subject_key, + subject_attributes, + actions, + ), + } + } - let sharder = PreSaltedSharder::new(&[flag_key, "-", subject_key], TOTAL_SHARDS); + fn evaluate_with_hasher<'a, H: BanditHasher>( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &ContextAttributes, + actions: impl Iterator, + ) -> Result { + let hasher = H::new(flag_key, subject_key); // Pseudo-random deterministic shuffle of actions. Shuffling is unique per subject, so when // weights change slightly, large swatches of subjects are not reassigned from one action to @@ -260,7 +389,7 @@ impl BanditModelData { .collect::>(); // Sort actions by their shard value. Use action key as tie breaker. shuffled_actions.sort_by_cached_key(|action| { - let hash = sharder.shard(&["-", action.key]); + let hash = hasher.action_shuffle_hash(action.key); (hash, action.key) }); shuffled_actions @@ -298,7 +427,7 @@ impl BanditModelData { let weights = self.weigh_actions(&scores, best); debug_assert_eq!(shuffled_actions.len(), weights.len()); - let selection_hash = sharder.shard(&[] as &[&str; 0]) as f64 / TOTAL_SHARDS as f64; + let selection_hash = hasher.selection_hash(); let selected_action = { let mut cumulative_weight = 0.0; @@ -315,7 +444,6 @@ impl BanditModelData { Ok(BanditEvaluationDetails { action_key: shuffled_actions[selected_action].key.to_owned(), - // action_attributes: actions[selected_action].to_owned(), action_weight: weights[selected_action], optimality_gap, }) diff --git a/eppo_core/src/eval/eval_precomputed.rs b/eppo_core/src/eval/eval_precomputed.rs index ad359893..440e2724 100644 --- a/eppo_core/src/eval/eval_precomputed.rs +++ b/eppo_core/src/eval/eval_precomputed.rs @@ -95,7 +95,13 @@ pub fn get_precomputed_configuration( let bandit_evaluation = bandit_model .model_data - .evaluate(flag_key, subject_key, subject_attributes, actions.iter()) + .evaluate( + flag_key, + subject_key, + subject_attributes, + actions.iter(), + configuration.bandit_hashing_algorithm, + ) .ok()?; let selected_action = &actions[&bandit_evaluation.action_key];