Skip to main content

pagesift_plan/
learn.rs

1//! 按站点自学习从哪个浏览器内核开始渲染。
2//!
3//! 每个站、每个内核记一份"这个内核渲染出来就够用"的 Beta 计数。开始渲染时对内核链按顺序做
4//! Thompson 采样(Thompson 1933;Chapelle & Li 2011):从后验里抽一个"够用"的概率,抽到的值
5//! 大于 1/2 就从这个内核开始,否则看下一个。探索来自后验的不确定性本身,不再需要 0.81 的
6//! "连续 3 次回退才换起点、每 16 次探索一次"。
7//!
8//! - 先验包 v0:每个内核 [`PRIOR_SUFFICIENT`] 个"够用"的伪观测、没有"不够用"的伪观测。还没见过
9//!   某个内核不够用时一定从它开始(和 0.81 一样,冷启动不变);见过 3 次不够用,后验均值正好
10//!   降到 1/2,对应 0.81 连续 3 次换起点的规则。
11//! - 遗忘:每次观测前旧计数乘 `1−δ`,有效样本数不超过 `1/δ`,站点改版后能重新学回来。
12//! - "开浏览器有没有用"不在这里学:0.87 起由 `engine/planner.rs` 按路径族的后验决定。
13//!
14//! 表按主机哈希分片(分片只为减少锁争用),总容量 [`MAX_HOSTS`];某片满了时淘汰这一片里证据量
15//! 较少的一半,而不是整表清空:只是少一次优化,不影响结果。
16
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::hash::{BuildHasher, RandomState};
20use std::sync::Mutex;
21
22use pagesift_stat::Delta;
23use pagesift_stat::rng::Rng;
24
25/// [策略] 先验包 v0:0.81 的现值(连续 3 次回退才换起点内核)。
26pub const PRIOR_SUFFICIENT: f32 = 3.0;
27/// [策略] 先验包 v0:0.81 的现值。每条约 50 字节,满了约 5 MiB。
28pub const MAX_HOSTS: usize = 100_000;
29
30/// 一个内核"够用 / 不够用"的计数(已乘过遗忘因子)。
31#[derive(Default, Clone, Copy, Debug)]
32struct Counts {
33    sufficient: f32,
34    insufficient: f32,
35}
36
37impl Counts {
38    fn observe(&mut self, sufficient: bool, keep: f32) {
39        self.sufficient *= keep;
40        self.insufficient *= keep;
41        if sufficient {
42            self.sufficient += 1.0;
43        } else {
44            self.insufficient += 1.0;
45        }
46    }
47
48    /// 这个内核这次够不够用:没见过不够用时直接算够用,否则 Thompson 采样。
49    fn sample_sufficient(&self, rng: &mut Rng) -> bool {
50        if self.insufficient <= 0.0 {
51            return true;
52        }
53        let a = f64::from(PRIOR_SUFFICIENT + self.sufficient);
54        let b = f64::from(self.insufficient);
55        // [推导] 对称损失下的贝叶斯判定点:抽到的"够用"概率过半就用这个内核
56        rng.beta(a, b) > 0.5
57    }
58
59    fn evidence(&self) -> f32 {
60        self.sufficient + self.insufficient
61    }
62}
63
64#[derive(Default)]
65struct Shard {
66    hosts: HashMap<u64, Vec<Counts>>,
67}
68
69/// 每次记录一个内核的结果时调用:(站点, 内核序号, 结果是否足够)。多服务器时工人用它把观测报给网关。
70pub type EngineObserver = std::sync::Arc<dyn Fn(&str, usize, bool) + Send + Sync>;
71
72pub struct HostLearner {
73    observer: std::sync::OnceLock<EngineObserver>,
74    hasher: RandomState,
75    shards: Box<[Mutex<Shard>]>,
76    per_shard: usize,
77    keep: f32,
78}
79
80thread_local! {
81    static RNG: RefCell<Rng> = RefCell::new(Rng::from_entropy());
82}
83
84impl Default for HostLearner {
85    fn default() -> Self {
86        Self::with_capacity(MAX_HOSTS, Delta::DEFAULT)
87    }
88}
89
90impl HostLearner {
91    fn with_capacity(capacity: usize, delta: Delta) -> Self {
92        let delta = delta.get();
93        // 分片数随核数,但每片至少放得下 1/δ 个站:哈希不均时也不会过早淘汰
94        let min_per_shard = (1.0 / delta).ceil() as usize;
95        let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
96        let shards = cores
97            .next_power_of_two()
98            .min((capacity / min_per_shard).max(1));
99        HostLearner {
100            observer: std::sync::OnceLock::new(),
101            hasher: RandomState::new(),
102            shards: (0..shards).map(|_| Mutex::default()).collect(),
103            per_shard: capacity.div_ceil(shards),
104            keep: (1.0 - delta) as f32,
105        }
106    }
107
108    fn shard(&self, key: u64) -> std::sync::MutexGuard<'_, Shard> {
109        let i = (key as usize) % self.shards.len();
110        self.shards[i].lock().unwrap_or_else(|e| e.into_inner())
111    }
112
113    /// 这个站从第几个内核开始渲染。
114    pub fn start_engine(&self, host: &str) -> usize {
115        let key = self.hasher.hash_one(host);
116        let shard = self.shard(key);
117        let Some(engines) = shard.hosts.get(&key) else {
118            return 0;
119        };
120        RNG.with_borrow_mut(|rng| {
121            engines
122                .iter()
123                .position(|c| c.sample_sufficient(rng))
124                .unwrap_or(engines.len())
125        })
126    }
127
128    /// 记录一次渲染:从 `from` 开始,最后用的是 `used`。`from..used` 的内核不够用,`used` 够用。
129    pub fn record_engine(&self, host: &str, from: usize, used: usize) {
130        let key = self.hasher.hash_one(host);
131        let mut shard = self.shard(key);
132        if shard.hosts.len() >= self.per_shard && !shard.hosts.contains_key(&key) {
133            evict_low_evidence(&mut shard.hosts);
134        }
135        let engines = shard.hosts.entry(key).or_default();
136        if engines.len() <= used {
137            engines.resize(used + 1, Counts::default());
138        }
139        for (i, c) in engines.iter_mut().enumerate().take(used + 1).skip(from) {
140            c.observe(i == used, self.keep);
141        }
142        drop(shard);
143        if let Some(o) = self.observer.get() {
144            for i in from..=used {
145                o(host, i, i == used);
146            }
147        }
148    }
149
150    /// 接上观测回调(只能接一次)。
151    pub fn observe_with(&self, o: EngineObserver) -> bool {
152        self.observer.set(o).is_ok()
153    }
154
155    /// 导入别处学到的计数(网关下发的先验)。本地这个站点的这个内核已经有观测时不导入:本地的观测
156    /// 已经报给过网关,再加上网关的合并结果会重复计数。
157    pub fn import(&self, host: &str, engine: usize, sufficient: f64, insufficient: f64) {
158        let key = self.hasher.hash_one(host);
159        let mut shard = self.shard(key);
160        if shard.hosts.len() >= self.per_shard && !shard.hosts.contains_key(&key) {
161            evict_low_evidence(&mut shard.hosts);
162        }
163        let engines = shard.hosts.entry(key).or_default();
164        if engines.len() <= engine {
165            engines.resize(engine + 1, Counts::default());
166        }
167        let c = &mut engines[engine];
168        if c.evidence() == 0.0 {
169            c.sufficient = sufficient as f32;
170            c.insufficient = insufficient as f32;
171        }
172    }
173}
174
175/// 淘汰证据量较少的一半:[推导] 每次淘汰一半,摊到每次插入上是 O(1),留下的是信息量多的那一半。
176fn evict_low_evidence(hosts: &mut HashMap<u64, Vec<Counts>>) {
177    let evidence = |v: &Vec<Counts>| v.iter().map(Counts::evidence).sum::<f32>();
178    let mut keys: Vec<(f32, u64)> = hosts.iter().map(|(k, v)| (evidence(v), *k)).collect();
179    let half = keys.len() / 2;
180    keys.select_nth_unstable_by(half, |a, b| a.0.total_cmp(&b.0));
181    for (_, k) in &keys[..half] {
182        hosts.remove(k);
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn cold_start_always_starts_at_the_first_engine() {
192        let l = HostLearner::default();
193        for _ in 0..1000 {
194            assert_eq!(l.start_engine("new.test"), 0);
195        }
196        // 只见过够用,也一直从第一个开始
197        l.record_engine("ok.test", 0, 0);
198        for _ in 0..1000 {
199            assert_eq!(l.start_engine("ok.test"), 0);
200        }
201    }
202
203    #[test]
204    fn learns_the_starting_engine_and_keeps_exploring() {
205        let l = HostLearner::default();
206        for _ in 0..20 {
207            l.record_engine("spa.test", 0, 1);
208        }
209        let n = 2000;
210        let skipped = (0..n).filter(|_| l.start_engine("spa.test") == 1).count();
211        // 20 次都不够用:绝大多数从第二个内核开始,但仍偶尔试第一个(探索)
212        assert!(skipped > n * 9 / 10, "{skipped}");
213        assert!(skipped < n, "{skipped}");
214    }
215
216    #[test]
217    fn three_misses_put_the_posterior_mean_at_one_half() {
218        let mut c = Counts::default();
219        for _ in 0..3 {
220            c.observe(false, 1.0);
221        }
222        let mean = (PRIOR_SUFFICIENT + c.sufficient) / (PRIOR_SUFFICIENT + c.evidence());
223        assert_eq!(mean, 0.5);
224    }
225
226    #[test]
227    fn relearns_after_the_site_changes() {
228        let l = HostLearner::default();
229        for _ in 0..30 {
230            l.record_engine("spa.test", 0, 1);
231        }
232        // 改版后第一个内核又够用了:探索时记下的"够用"很快把起点拉回来
233        for _ in 0..30 {
234            l.record_engine("spa.test", 0, 0);
235        }
236        let n = 2000;
237        let first = (0..n).filter(|_| l.start_engine("spa.test") == 0).count();
238        assert!(first > n * 9 / 10, "{first}");
239    }
240
241    #[test]
242    fn full_shard_evicts_the_less_informed_half() {
243        let l = HostLearner::with_capacity(40, Delta::DEFAULT);
244        assert!(l.shards.len() <= 2);
245        l.record_engine("busy.test", 0, 1);
246        l.record_engine("busy.test", 0, 1);
247        for i in 0..200 {
248            l.record_engine(&format!("h{i}.test"), 0, 0);
249        }
250        let total: usize = l.shards.iter().map(|s| s.lock().unwrap().hosts.len()).sum();
251        assert!(total <= 40, "{total}");
252        let busy = l.hasher.hash_one("busy.test");
253        assert!(l.shard(busy).hosts.contains_key(&busy));
254    }
255}