beck_core/cost.rs
1//! The placement cost model — §3.4's node costs, edge costs and byte estimates.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.4:
4//!
5//! > **Node costs**: ∞ for forbidden tiers; tier-specific compute cost otherwise (client CPU is
6//! > expensive and untrusted; fold-engine compute is cheap and adjacent to state).
7//! > **Edge costs**: for each *signal edge or call* that crosses tiers, `latency + bytes × unit`,
8//! > bytes estimated from row types.
9//!
10//! Everything here is an **integer**. Not for speed — the graphs are tiny — but because §3.4's
11//! first guardrail is determinism, and a cost model in floating point makes "these two placements
12//! cost the same" a question about rounding. Costs are in hundredths of a notional millisecond, so
13//! a latency of 25 ms is `2_500`.
14//!
15//! # The numbers, and where they come from
16//!
17//! They are not measured; they are *ordered*, and the ordering is what a placement decision reads.
18//! Every one is a ratio the design already states, so the model can be argued with rather than
19//! tuned in the dark:
20//!
21//! | quantity | value | from |
22//! |---|---|---|
23//! | compute weight, data | 1 | "fold-engine compute is cheap and adjacent to state" (§3.4) |
24//! | compute weight, server | 2 | the middle |
25//! | compute weight, client | 8 | "client CPU is expensive and untrusted" (§3.4) |
26//! | latency, data ↔ server | 1 ms | same pod network |
27//! | latency, server ↔ client | 25 ms | Phase 0's realistic RTT ([`18`](../../../../../docs/18-phase-0-report.md)) |
28//! | latency, data ↔ client | 26 ms | it goes through the server |
29//! | bytes → cost | ×5 per byte | so a 2 KB crossing (~100 ms) outweighs an RTT, which is the
30//! | | | trade a placement decision is actually making |
31//!
32//! There is no constant for "a fold that is not at the data tier". [`node_cost`] charges such a
33//! fold an *edge to the log*, sized from the accumulator, because that is what it physically pays:
34//! the log is at the data tier, and an accumulator kept elsewhere crosses to it on every event. A
35//! constant would have been a number to tune; an edge is a number to derive.
36//!
37//! # The one rule worth arguing with
38//!
39//! A crossing is charged the **smaller** of its two endpoints' values, and that is not a
40//! simplification — it is §5.1's Mode A/B question, expressed as a cost instead of as a mode.
41//! Between a `Signal[State]` at the data tier and a `Signal[Html]` at the browser, the compiler may
42//! either send the state and render in the browser (Mode B: 2 KB of data patches) or render first
43//! and send the document (Mode A: 1 KB of DOM patches). It will pick the cheaper, so the cheaper is
44//! what the boundary costs. Phase 2 only *implements* Mode A, so today the minimum is a prediction
45//! rather than a choice — and it is the right prediction to be making when Phase 3 makes the choice
46//! real.
47
48use std::collections::BTreeMap;
49use std::sync::Arc;
50
51use crate::ty::{Effect, Row, Tier, Ty, TyDecl};
52
53/// Costs are in hundredths of a millisecond.
54pub type Cost = i64;
55
56/// A placement that cannot be, kept finite so that sums never overflow and comparisons stay total.
57pub const FORBIDDEN: Cost = 1_000_000_000;
58
59/// §3.4: "client CPU is expensive and untrusted; fold-engine compute is cheap and adjacent to state".
60pub fn compute_weight(t: Tier) -> Cost {
61 match t {
62 Tier::Data => 1,
63 Tier::Server => 2,
64 Tier::Client => 8,
65 // Unplaced code is compiled into whichever tier needs it, so it is charged to that tier's
66 // own work rather than to a placement of its own (§3.3).
67 Tier::Any => 0,
68 }
69}
70
71/// Round-trip latency between two tiers, in hundredths of a millisecond.
72pub fn latency(a: Tier, b: Tier) -> Cost {
73 use Tier::*;
74 match (a, b) {
75 // Unplaced code crosses nothing: it is duplicated to the tier that calls it.
76 (Any, _) | (_, Any) => 0,
77 (x, y) if x == y => 0,
78 (Data, Server) | (Server, Data) => 100,
79 (Server, Client) | (Client, Server) => 2_500,
80 (Data, Client) | (Client, Data) => 2_600,
81 _ => 0,
82 }
83}
84
85/// The cost of moving one byte across a tier boundary.
86pub const BYTE_UNIT: Cost = 5;
87
88/// The node cost of putting `row` on `tier`, given how much work the node does and — for a durable
89/// fold — what its accumulator looks like.
90pub fn node_cost(
91 tier: Tier,
92 row: &Row,
93 work: i64,
94 state: Option<&Ty>,
95 types: &BTreeMap<Arc<str>, TyDecl>,
96) -> Cost {
97 if !row.atoms.iter().all(|e| tier.discharges(e)) {
98 return FORBIDDEN;
99 }
100 let mut cost = compute_weight(tier) * work;
101 // The log lives at the data tier. A fold placed anywhere else does not merely compute
102 // elsewhere; it crosses to the log on every event, carrying its accumulator. That is an edge,
103 // so it is priced as one.
104 if row.atoms.contains(&Effect::Durable) && tier != Tier::Data {
105 let bytes = state.map(|t| estimate_bytes(t, types)).unwrap_or(0);
106 cost += latency(tier, Tier::Data) + bytes * BYTE_UNIT;
107 }
108 cost
109}
110
111/// The cost of an edge between two placed nodes.
112///
113/// The two types are what each *end* produces. The bytes charged are the smaller: see the module
114/// docs — the compiler may compute on either side of a boundary and will send whichever is less, so
115/// the boundary costs the lesser of the two.
116pub fn edge_cost(
117 a: Tier,
118 b: Tier,
119 ty_a: &Ty,
120 ty_b: &Ty,
121 types: &BTreeMap<Arc<str>, TyDecl>,
122) -> Cost {
123 if a == b || a == Tier::Any || b == Tier::Any {
124 return 0;
125 }
126 let bytes = estimate_bytes(ty_a, types).min(estimate_bytes(ty_b, types));
127 latency(a, b) + bytes * BYTE_UNIT
128}
129
130/// Estimate the wire size of a value of this type — §3.4's "bytes estimated from row types".
131///
132/// An estimate, and deliberately a crude one: what a placement decision needs is the *ratio*
133/// between a `Map[Id, Todo]` and an `Int`, not a byte count. `ASSUMED_CARDINALITY` is the one place
134/// a guess is made about data the compiler cannot see, and it is named so that it can be argued
135/// with rather than discovered.
136pub fn estimate_bytes(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>) -> Cost {
137 fn go(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>, depth: u32) -> Cost {
138 if depth > 8 {
139 // A recursive type: stop, and charge it as one more element rather than diverging.
140 return 32;
141 }
142 match ty {
143 Ty::Var(_) => 16,
144 Ty::Fun(..) => 0, // a function does not cross; a closure is not Sendable
145 Ty::Con(name, args) => match name.as_ref() {
146 Ty::BOOL => 1,
147 Ty::INT | Ty::FLOAT => 8,
148 Ty::STR => 32,
149 Ty::UNIT => 0,
150 // A rendered document, or a patch derived from one. Big enough that placing a view
151 // across a boundary is visible in the model, which is the point.
152 Ty::HTML => 1_024,
153 Ty::ATTR => 32,
154 Ty::LIST | Ty::STREAM | Ty::SIGNAL | Ty::OPTION | Ty::ENVELOPE | Ty::SECRET => {
155 let elem = args.first().map(|a| go(a, types, depth + 1)).unwrap_or(16);
156 if name.as_ref() == Ty::OPTION
157 || name.as_ref() == Ty::ENVELOPE
158 || name.as_ref() == Ty::SECRET
159 {
160 elem + 8
161 } else {
162 elem * ASSUMED_CARDINALITY
163 }
164 }
165 Ty::MAP => {
166 let k = args.first().map(|a| go(a, types, depth + 1)).unwrap_or(16);
167 let v = args.get(1).map(|a| go(a, types, depth + 1)).unwrap_or(16);
168 (k + v) * ASSUMED_CARDINALITY
169 }
170 Ty::RESULT => args
171 .iter()
172 .map(|a| go(a, types, depth + 1))
173 .max()
174 .unwrap_or(16),
175 other => match types.get(other) {
176 Some(TyDecl::Model { fields, .. }) => {
177 fields.iter().map(|(_, t)| go(t, types, depth + 1)).sum()
178 }
179 // A union costs its largest variant plus a tag.
180 Some(TyDecl::Union { variants, .. }) => {
181 1 + variants
182 .iter()
183 .map(|v| v.fields.iter().map(|(_, t)| go(t, types, depth + 1)).sum())
184 .max()
185 .unwrap_or(0)
186 }
187 Some(TyDecl::Newtype { inner, .. }) => go(inner, types, depth + 1),
188 Some(TyDecl::Alias { ty, .. }) => go(ty, types, depth + 1),
189 None => 16,
190 },
191 },
192 }
193 }
194 go(ty, types, 0)
195}
196
197/// How many elements a collection is assumed to hold when nothing says otherwise.
198///
199/// The compiler cannot know, and the honest options are to guess visibly or to pretend a `Map` is
200/// the size of a pointer. `beck tune` (Phase 4) is where a measured number would replace this one.
201pub const ASSUMED_CARDINALITY: Cost = 16;
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::ty::Variant;
207
208 fn types() -> BTreeMap<Arc<str>, TyDecl> {
209 BTreeMap::from([
210 (
211 Arc::from("Todo"),
212 TyDecl::Model {
213 name: Arc::from("Todo"),
214 params: Vec::new(),
215 fields: vec![
216 (Arc::from("text"), Ty::str_()),
217 (Arc::from("done"), Ty::bool_()),
218 ],
219 },
220 ),
221 (
222 Arc::from("Command"),
223 TyDecl::Union {
224 name: Arc::from("Command"),
225 params: Vec::new(),
226 variants: vec![
227 Variant {
228 name: Arc::from("Toggle"),
229 fields: vec![(Arc::from("id"), Ty::str_())],
230 },
231 Variant {
232 name: Arc::from("Add"),
233 fields: vec![
234 (Arc::from("id"), Ty::str_()),
235 (Arc::from("text"), Ty::str_()),
236 ],
237 },
238 ],
239 },
240 ),
241 ])
242 }
243
244 #[test]
245 fn a_collection_costs_more_to_cross_than_a_scalar() {
246 let t = types();
247 let scalar = estimate_bytes(&Ty::int(), &t);
248 let record = estimate_bytes(&Ty::con("Todo"), &t);
249 let collection = estimate_bytes(&Ty::map(Ty::str_(), Ty::con("Todo")), &t);
250 assert!(scalar < record, "{scalar} < {record}");
251 assert!(record < collection, "{record} < {collection}");
252 // A union is its largest variant plus a tag, not the sum of all of them.
253 assert_eq!(estimate_bytes(&Ty::con("Command"), &t), 1 + 64);
254 }
255
256 #[test]
257 fn a_function_type_has_no_wire_size_because_it_never_crosses() {
258 assert_eq!(
259 estimate_bytes(&Ty::fun(vec![Ty::int()], Ty::int()), &types()),
260 0
261 );
262 }
263
264 #[test]
265 fn a_recursive_type_terminates() {
266 // `model Tree: kids: list[Tree]` — the estimate must stop rather than diverge.
267 let t = BTreeMap::from([(
268 Arc::from("Tree"),
269 TyDecl::Model {
270 name: Arc::from("Tree"),
271 params: Vec::new(),
272 fields: vec![(Arc::from("kids"), Ty::list(Ty::con("Tree")))],
273 },
274 )]);
275 assert!(estimate_bytes(&Ty::con("Tree"), &t) > 0);
276 }
277
278 #[test]
279 fn a_forbidden_tier_costs_more_than_any_reachable_placement() {
280 let t = types();
281 let durable = Row::of([Effect::Durable]);
282 let state = Ty::map(Ty::str_(), Ty::con("Todo"));
283 assert_eq!(
284 node_cost(Tier::Client, &durable, 1, Some(&state), &t),
285 FORBIDDEN
286 );
287 // …and the log's residency makes the data tier the cheap place for a real accumulator, by a
288 // margin no amount of compute closes: the charge is the accumulator's own size.
289 assert!(
290 node_cost(Tier::Data, &durable, 1_000, Some(&state), &t)
291 < node_cost(Tier::Server, &durable, 1, Some(&state), &t)
292 );
293 // A trivial accumulator is a different matter, and the model says so rather than pretending
294 // otherwise — which is the difference between a derived number and a tuned one.
295 assert!(
296 node_cost(Tier::Server, &durable, 1, Some(&Ty::int()), &t)
297 < node_cost(Tier::Server, &durable, 1, Some(&state), &t)
298 );
299 }
300
301 #[test]
302 fn crossing_to_a_browser_costs_an_rtt_and_crossing_within_a_pod_does_not() {
303 let t = types();
304 assert_eq!(
305 edge_cost(Tier::Data, Tier::Data, &Ty::int(), &Ty::int(), &t),
306 0
307 );
308 assert!(
309 edge_cost(Tier::Server, Tier::Client, &Ty::int(), &Ty::int(), &t)
310 > edge_cost(Tier::Data, Tier::Server, &Ty::int(), &Ty::int(), &t)
311 );
312 // Unplaced code is duplicated rather than called across a boundary, so it crosses nothing.
313 assert_eq!(
314 edge_cost(Tier::Any, Tier::Client, &Ty::html(), &Ty::html(), &t),
315 0
316 );
317 }
318
319 #[test]
320 fn a_crossing_costs_the_smaller_of_its_two_ends() {
321 // §5.1's Mode A/B decision as a cost: between a big state and a smaller rendered document,
322 // the boundary carries the document, because a compiler free to render on either side will.
323 let t = types();
324 let state = Ty::map(Ty::str_(), Ty::con("Todo"));
325 let both = edge_cost(Tier::Data, Tier::Client, &state, &Ty::html(), &t);
326 let doc_only = edge_cost(Tier::Data, Tier::Client, &Ty::html(), &Ty::html(), &t);
327 assert_eq!(both, doc_only);
328 assert!(both < edge_cost(Tier::Data, Tier::Client, &state, &state, &t));
329 }
330}