From 82b3e2985f7d1d3987e9b6ce09e65bc91185736a Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Fri, 17 Apr 2026 10:56:30 +0800 Subject: [PATCH] chore --- src/main.rs | 43 ++++++++++----- src/oracle.rs | 142 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 141 insertions(+), 44 deletions(-) diff --git a/src/main.rs b/src/main.rs index 050616a..c0b6e6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -479,20 +479,39 @@ fn cmd_oracle( cfg.sim.trace_path, cfg.sim.max_requests ); let reader = TraceReader::open(&cfg.sim.trace_path, cfg.sim.max_requests)?; - let mut records: Vec<_> = reader.collect::, _>>()?; - let raw_count = records.len(); - driver::apply_input_length_filter(&mut records, &cfg.sim); - if records.len() != raw_count { + let records: Vec<_> = reader.collect::, _>>()?; + + // Build a count-mask: all records feed the cache, but only records inside + // the input-length range contribute to hit/miss accounting. This way a + // 128k+ bucket still benefits from prefix blocks populated by shorter + // requests, matching the real mixed-workload ceiling. + let lo = cfg.sim.input_length_min.unwrap_or(0); + let hi = cfg.sim.input_length_max.unwrap_or(u32::MAX); + let has_filter = lo > 0 || hi < u32::MAX; + let count_mask: Option> = if has_filter { + let mask: Vec = records + .iter() + .map(|r| r.input_len >= lo && r.input_len <= hi) + .collect(); + let counted = mask.iter().filter(|&&v| v).count(); eprintln!( - "[oracle] input_length filter [{}, {}] kept {}/{} requests", - cfg.sim.input_length_min.unwrap_or(0), - cfg.sim - .input_length_max - .map_or("∞".to_string(), |v| v.to_string()), + "[oracle] input_length filter [{}, {}] counting {}/{} requests \ + (all {} used for cache state)", + lo, + if hi == u32::MAX { + "∞".to_string() + } else { + hi.to_string() + }, + counted, + records.len(), records.len(), - raw_count, ); - } + Some(mask) + } else { + None + }; + eprintln!( "[oracle] loaded {} requests; analyzing with capacity = {} blocks \ ({} per-instance × {} instances{})", @@ -507,7 +526,7 @@ fn cmd_oracle( } ); - let result = oracle::analyze(&records, capacity); + let result = oracle::analyze(&records, capacity, count_mask.as_deref()); let json = serde_json::to_string_pretty(&result)?; println!("{}", json); diff --git a/src/oracle.rs b/src/oracle.rs index 05df0bd..064a2d2 100644 --- a/src/oracle.rs +++ b/src/oracle.rs @@ -60,33 +60,66 @@ impl TierResult { } } -pub fn analyze(records: &[RequestRecord], capacity_blocks: u64) -> OracleResult { - // total / unique counters - let total_blocks: u64 = records.iter().map(|r| r.hash_ids.len() as u64).sum(); +/// Run the oracle analysis over `records`. +/// +/// When `count_mask` is `Some`, **all** records still feed the caches (so the +/// cache state reflects the full workload), but only records where +/// `count_mask[i]` is `true` contribute to hit / miss / total accounting. +/// This is the correct way to answer "what is the theoretical hit-rate for a +/// particular input-length bucket within a mixed workload?" — the cache sees +/// every request, but the metric only measures the bucket of interest. +/// +/// When `count_mask` is `None`, every record is counted (original behaviour). +pub fn analyze( + records: &[RequestRecord], + capacity_blocks: u64, + count_mask: Option<&[bool]>, +) -> OracleResult { + // Build a default all-true mask when none is supplied. + let default_mask; + let mask: &[bool] = match count_mask { + Some(m) => { + assert_eq!(m.len(), records.len()); + m + } + None => { + default_mask = vec![true; records.len()]; + &default_mask + } + }; + + // total / unique counters — only for counted records + let mut total_blocks: u64 = 0; let mut unique = AHashSet::new(); - for r in records { - for &h in &r.hash_ids { - unique.insert(h); + let mut num_requests: u64 = 0; + for (i, r) in records.iter().enumerate() { + if mask[i] { + total_blocks += r.hash_ids.len() as u64; + num_requests += 1; + for &h in &r.hash_ids { + unique.insert(h); + } } } // 1. Unlimited cache - let unlimited_hits = run_unlimited(records); + let unlimited_hits = run_unlimited(records, mask); let unlimited = TierResult::from_counts("unlimited", u64::MAX, unlimited_hits, total_blocks); - // 2. Precompute next-use index for Belady + // 2. Precompute next-use index for Belady (over ALL records — eviction + // decisions must consider future accesses from the full workload) let next_use = build_next_use(records); // 3. Belady at the given capacity - let belady_hits = run_belady(records, &next_use, capacity_blocks as usize); + let belady_hits = run_belady(records, &next_use, capacity_blocks as usize, mask); let belady = TierResult::from_counts("belady", capacity_blocks, belady_hits, total_blocks); // 4. LRU baseline at the same capacity - let lru_hits = run_lru(records, capacity_blocks as usize); + let lru_hits = run_lru(records, capacity_blocks as usize, mask); let lru = TierResult::from_counts("lru", capacity_blocks, lru_hits, total_blocks); OracleResult { - num_requests: records.len() as u64, + num_requests, total_blocks, unique_blocks: unique.len() as u64, unlimited, @@ -95,18 +128,21 @@ pub fn analyze(records: &[RequestRecord], capacity_blocks: u64) -> OracleResult } } -fn run_unlimited(records: &[RequestRecord]) -> u64 { +fn run_unlimited(records: &[RequestRecord], mask: &[bool]) -> u64 { let mut seen: AHashSet = AHashSet::with_capacity(1 << 18); let mut hits: u64 = 0; - for r in records { - // Longest prefix match against `seen` - for &h in &r.hash_ids { - if seen.contains(&h) { - hits += 1; - } else { - break; + for (i, r) in records.iter().enumerate() { + // Longest prefix match against `seen` — only count for masked records + if mask[i] { + for &h in &r.hash_ids { + if seen.contains(&h) { + hits += 1; + } else { + break; + } } } + // Always populate the cache so all requests contribute to cache state for &h in &r.hash_ids { seen.insert(h); } @@ -114,15 +150,20 @@ fn run_unlimited(records: &[RequestRecord]) -> u64 { hits } -fn run_lru(records: &[RequestRecord], capacity: usize) -> u64 { +fn run_lru(records: &[RequestRecord], capacity: usize, mask: &[bool]) -> u64 { if capacity == 0 { return 0; } let mut cache = LruBlocks::new(capacity); let mut hits: u64 = 0; let mut evicted = Vec::new(); - for r in records { - hits += cache.longest_prefix(&r.hash_ids) as u64; + for (i, r) in records.iter().enumerate() { + // Always touch the cache (longest_prefix updates LRU recency) so that + // the eviction order reflects the real mixed workload. + let prefix_len = cache.longest_prefix(&r.hash_ids) as u64; + if mask[i] { + hits += prefix_len; + } evicted.clear(); cache.insert_blocks(&r.hash_ids, &mut evicted); } @@ -155,7 +196,7 @@ fn build_next_use(records: &[RequestRecord]) -> Vec> { /// Implementation: lazy-deletion max-heap keyed by next-use index. Each /// cache entry has a version; the heap may contain stale entries from /// previous insertions, which we skip on pop. -fn run_belady(records: &[RequestRecord], next_use: &[Vec], capacity: usize) -> u64 { +fn run_belady(records: &[RequestRecord], next_use: &[Vec], capacity: usize, mask: &[bool]) -> u64 { if capacity == 0 { return 0; } @@ -168,16 +209,19 @@ fn run_belady(records: &[RequestRecord], next_use: &[Vec], capacity: usize) let mut hits: u64 = 0; for (i, r) in records.iter().enumerate() { - // 1. Longest-prefix hit accounting against current cache. - for &h in &r.hash_ids { - if in_cache.contains_key(&h) { - hits += 1; - } else { - break; + // 1. Longest-prefix hit accounting — only count for masked records. + if mask[i] { + for &h in &r.hash_ids { + if in_cache.contains_key(&h) { + hits += 1; + } else { + break; + } } } // 2. Insert / update each block in the request with its new next-use. + // Always executed so the cache reflects the full workload. for (j, &h) in r.hash_ids.iter().enumerate() { let nu = next_use[i][j]; if let Some(slot) = in_cache.get_mut(&h) { @@ -237,7 +281,7 @@ mod tests { req(1, 1.0, vec![1, 2, 3, 4]), req(2, 2.0, vec![1, 2, 3, 4, 5]), ]; - let out = analyze(&recs, 100); + let out = analyze(&recs, 100, None); // total = 3 + 4 + 5 = 12 assert_eq!(out.total_blocks, 12); // unique = {1,2,3,4,5} = 5 @@ -256,7 +300,7 @@ mod tests { for (i, &h) in pattern.iter().enumerate() { recs.push(req(i as u64, i as f64, vec![h])); } - let out = analyze(&recs, 2); + let out = analyze(&recs, 2, None); assert!( out.belady_finite.hits >= out.lru_finite.hits, "belady should be at least as good as lru: belady={} lru={}", @@ -273,8 +317,42 @@ mod tests { req(2, 2.0, vec![60]), req(3, 3.0, vec![10, 20, 30, 40, 50, 60]), ]; - let out = analyze(&recs, 3); + let out = analyze(&recs, 3, None); assert!(out.unlimited.hit_rate >= out.belady_finite.hit_rate); assert!(out.belady_finite.hit_rate >= out.lru_finite.hit_rate - 1e-9); } + + #[test] + fn count_mask_filters_accounting_not_cache() { + // req 0 populates blocks [1,2,3] but is not counted. + // req 1 has prefix [1,2,3,4] — the first 3 blocks are cache hits + // because req 0 populated them, even though req 0 is masked out. + let recs = vec![ + req(0, 0.0, vec![1, 2, 3]), + req(1, 1.0, vec![1, 2, 3, 4]), + ]; + let mask = vec![false, true]; + let out = analyze(&recs, 100, Some(&mask)); + // Only req 1 is counted: total = 4, hits = 3 (prefix [1,2,3] hit) + assert_eq!(out.num_requests, 1); + assert_eq!(out.total_blocks, 4); + assert_eq!(out.unlimited.hits, 3); + assert!((out.unlimited.hit_rate - 3.0 / 4.0).abs() < 1e-9); + } + + #[test] + fn count_mask_none_matches_all_true() { + let recs = vec![ + req(0, 0.0, vec![1, 2, 3]), + req(1, 1.0, vec![1, 2, 3, 4]), + req(2, 2.0, vec![1, 2, 3, 4, 5]), + ]; + let out_none = analyze(&recs, 10, None); + let all_true = vec![true; recs.len()]; + let out_all = analyze(&recs, 10, Some(&all_true)); + assert_eq!(out_none.unlimited.hits, out_all.unlimited.hits); + assert_eq!(out_none.belady_finite.hits, out_all.belady_finite.hits); + assert_eq!(out_none.lru_finite.hits, out_all.lru_finite.hits); + assert_eq!(out_none.total_blocks, out_all.total_blocks); + } }