zdc_bench/scaling.rs
1//! What the approach costs as programs get bigger — Swift's question.
2//!
3//! Swift (SOSP'07) is ZDeceptron's thesis already built: security labels
4//! driving automatic client/server partitioning. It won Best Paper and it
5//! was abandoned, and the number that says why is **~800 bytes of
6//! JavaScript per line of source** — a 6-line null program emitting 73 kB
7//! and a 1,094-line application emitting 1.21 MB. The machinery that makes
8//! the network boundary invisible ended up in the bundle.
9//!
10//! That is the documented failure mode of this entire design, and until
11//! now ZDeceptron had never been measured against it. This module supplies
12//! the measurement, in the same units, plus the three things that would
13//! turn a good number today into a bad one later:
14//!
15//! * whether emitted size grows **linearly** with source size, or worse;
16//! * whether tier splitting is really the **product** of the definition set
17//! and the root set that §17.2 describes — routing multiplies the roots,
18//! one per page, so a quadratic here is a finding for the routing work;
19//! * how deep a **fold** goes before the host's recursion budget gives out,
20//! since §17.4.9's index recursion is linear in stack depth.
21//!
22//! Everything here is either an exact byte count or a wall-clock time of
23//! *the compiler*, which is Rust. Nothing here times generated JavaScript:
24//! see `BENCHMARKS.md` for why the embedded interpreter cannot resolve that
25//! question either way.
26
27use std::time::{Duration, Instant};
28
29use crate::sizes::{repository_path, try_compile};
30
31/// Swift's headline: bytes of JavaScript per line of application source.
32pub const SWIFT_BYTES_PER_LINE: usize = 800;
33/// Swift's null program: 6 lines in, 73 kB of JavaScript out.
34pub const SWIFT_NULL_PROGRAM_LINES: usize = 6;
35/// Swift's null program, in bytes.
36pub const SWIFT_NULL_PROGRAM_JS: usize = 73_000;
37/// Swift's largest application, `Shop`.
38pub const SWIFT_LARGEST_APP_LINES: usize = 1_094;
39/// `Shop`'s emitted JavaScript, in bytes.
40pub const SWIFT_LARGEST_APP_JS: usize = 1_210_000;
41
42/// The runtime a program that renders a view and nothing else links.
43///
44/// `elements.js` is not in the sum: generated code never imports it
45/// (§16.3.1), so it is not shipped. Uncompressed and unminified, because
46/// there is no minifier in the pipeline and a projected figure would be a
47/// claim about a tool that does not exist.
48///
49/// **This is a reference figure, not the gate's input.** The runtime is
50/// several files and a bundle links a subset — `rpc.js` and `store.js`
51/// only where the split found a crossing or a durable key, `foreign.js`
52/// only where the program writes a `foreign … gives view`. Charging a
53/// program a fixed sum is therefore wrong in both directions: it flatters
54/// a live-sync program and penalises one with an FFI. Every gate below
55/// uses [`Emitted::runtime_js`], which is measured from the bundle's own
56/// import closure; this function names the floor that a plain rendering
57/// program pays, which is what the `B/line+rt` column is relative to.
58pub fn runtime_js_bytes() -> usize {
59 // As a release build ships them: the `// $dev` assertions (#140) are
60 // not downloaded by a reader, so charging them here would report a
61 // cost nobody pays.
62 let release = |source| zdc_runtime::for_mode(source, zdc_runtime::Mode::Release).len();
63 release(zdc_runtime::SIGNAL_JS) + release(zdc_runtime::DOM_JS)
64}
65
66/// The runtime files one bundle actually links, in bytes.
67///
68/// The set comes from `Bundle::runtime`, which is the same closure the
69/// emitter used to decide the import list — one decision rather than two
70/// that have to agree. This is what makes "ships nothing it does not use"
71/// a measured property here rather than an assumption baked into a sum.
72pub fn linked_runtime_bytes(runtime: &std::collections::BTreeSet<&'static str>) -> usize {
73 // Release, because the size claims in `BENCHMARKS.md` are claims about
74 // what a reader downloads.
75 linked_runtime_bytes_in(runtime, zdc_codegen::Mode::Release)
76}
77
78/// The same, for whichever build is asked about.
79///
80/// What a development build costs is worth measuring rather than
81/// estimating: #140's whole argument is that the assertions are free to a
82/// reader because they are stripped, and the number that makes that
83/// checkable is the difference between the two.
84pub fn linked_runtime_bytes_in(
85 runtime: &std::collections::BTreeSet<&'static str>,
86 mode: zdc_codegen::Mode,
87) -> usize {
88 zdc_codegen::runtime_files(runtime, mode)
89 .iter()
90 .map(|(_, source)| source.len())
91 .sum()
92}
93
94/// A ZDeceptron source line that carries a program.
95///
96/// Swift counted "lines of application Jif", and the repository's examples
97/// are teaching files whose comments outnumber their code — `hello.zd` is
98/// twelve lines of which six are prose. Counting those would flatter the
99/// ratio by a factor of two, so they are excluded and the raw line count is
100/// reported next to it.
101pub fn code_lines(source: &str) -> usize {
102 source
103 .lines()
104 .filter(|line| {
105 let trimmed = line.trim_start();
106 !trimmed.is_empty() && !trimmed.starts_with('#')
107 })
108 .count()
109}
110
111/// One program, compiled, in Swift's units.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Emitted {
114 pub name: String,
115 /// Every line in the file, comments and blanks included.
116 pub lines: usize,
117 /// Lines that carry a program.
118 pub code_lines: usize,
119 pub client_js: usize,
120 /// `client.js` plus the stylesheet, the entry document and the manifest.
121 pub bundle: usize,
122 /// The runtime files this bundle links, in bytes — its own closure,
123 /// not a constant. See [`linked_runtime_bytes`].
124 pub runtime_js: usize,
125}
126
127impl Emitted {
128 /// Bytes of JavaScript per line of source, the runtime excluded.
129 ///
130 /// This is the marginal cost of a line: what the program adds to a
131 /// bundle whose runtime is already there.
132 pub fn bytes_per_line(&self) -> usize {
133 self.client_js / self.code_lines.max(1)
134 }
135
136 /// The same, charging this program's own runtime closure to it.
137 ///
138 /// This is the number for a single-page application that ships nothing
139 /// else — the worst case, and the one that dominates at small sizes.
140 pub fn bytes_per_line_with_runtime(&self) -> usize {
141 self.shipped() / self.code_lines.max(1)
142 }
143
144 /// Every byte of JavaScript a visitor downloads for this program.
145 pub fn shipped(&self) -> usize {
146 self.client_js + self.runtime_js
147 }
148}
149
150/// Every `.zd` file in the repository worth sizing, in a fixed order.
151fn survey_sources() -> Vec<(String, String)> {
152 let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(repository_path("examples"))
153 .expect("the examples directory exists")
154 .filter_map(|entry| entry.ok())
155 .map(|entry| entry.path())
156 .filter(|path| path.extension().is_some_and(|ext| ext == "zd"))
157 .collect();
158 paths.sort();
159 paths.push(repository_path("crates/zdc-bench/bench/row.zd"));
160
161 // Named by repository-relative path, the same way `bundle_sizes` names
162 // them. The name is not decoration: the emitter writes it into
163 // `client.js`, so two spellings of the same file differ in byte count
164 // by the difference in their lengths, and the two tables in this file
165 // would not agree.
166 let root = repository_path("");
167 paths
168 .into_iter()
169 .map(|path| {
170 let name = path
171 .strip_prefix(&root)
172 .unwrap_or(&path)
173 .to_string_lossy()
174 .into_owned();
175 let source = std::fs::read_to_string(&path)
176 .unwrap_or_else(|e| panic!("reading {}: {e}", path.display()));
177 (name, source)
178 })
179 .collect()
180}
181
182/// Every example that builds, sized; and every one that does not, with why.
183///
184/// Both halves are returned together because a bytes-per-line figure over a
185/// subset is only honest if the subset is named.
186pub fn survey() -> (Vec<Emitted>, Vec<(String, Vec<String>)>) {
187 let mut built = Vec::new();
188 let mut refused = Vec::new();
189 for (name, source) in survey_sources() {
190 match try_compile(&source, &name) {
191 Ok(bundle) => built.push(Emitted {
192 lines: source.lines().count(),
193 code_lines: code_lines(&source),
194 client_js: bundle.client_js.len(),
195 bundle: bundle.client_js.len()
196 + bundle.styles_css.len()
197 + bundle.index_html.as_deref().map_or(0, str::len)
198 + bundle.manifest_json.len(),
199 runtime_js: linked_runtime_bytes(&bundle.runtime),
200 name,
201 }),
202 Err(errors) => refused.push((name, errors)),
203 }
204 }
205 (built, refused)
206}
207
208/// The empty-program baseline, in Swift's shape.
209///
210/// Swift's was six lines. This is six lines that do the least a ZDeceptron
211/// program can do and still be one: hold a value and show it.
212pub const NULL_PROGRAM: &str = "state greeting is client Text starting \"\"\n\
213 \n\
214 view\n\
215 \x20 Column\n\
216 \x20 Input greeting, hint is \"say something\"\n\
217 \x20 Text greeting\n";
218
219/// The smallest program the compiler will build at all.
220pub const SMALLEST_PROGRAM: &str = "view\n\x20 Text \"x\"\n";
221
222/// The null program plus the one construct that links `foreign.js`.
223///
224/// Kept beside `NULL_PROGRAM` because the pair is the measurement: the
225/// difference between what these two ship is exactly what a DOM-owning
226/// foreign costs, and stating it as a diff is what stops the split from
227/// being a way to make the headline number smaller than the truth.
228pub const FOREIGN_VIEW_PROGRAM: &str = "foreign gauge is client\n\
229 \x20 from \"./gauge.js\" as \"mount\"\n\
230 \x20 takes level is Whole\n\
231 \x20 gives view\n\
232 \n\
233 state level is client Whole starting 40\n\
234 \n\
235 view\n\
236 \x20 Column\n\
237 \x20 gauge level is level\n";
238
239/// Compile a source that is expected to build, or explain what refused it.
240pub fn build(source: &str, name: &str) -> Emitted {
241 let bundle = try_compile(source, name)
242 .unwrap_or_else(|errors| panic!("{name} failed to compile:\n {}", errors.join("\n ")));
243 Emitted {
244 name: name.to_string(),
245 lines: source.lines().count(),
246 code_lines: code_lines(source),
247 client_js: bundle.client_js.len(),
248 bundle: bundle.client_js.len()
249 + bundle.styles_css.len()
250 + bundle.index_html.as_deref().map_or(0, str::len)
251 + bundle.manifest_json.len(),
252 runtime_js: linked_runtime_bytes(&bundle.runtime),
253 }
254}
255
256/// `n` client signals, each declared once and read once in the view.
257///
258/// The growth series' independent variable. Every line is a line a person
259/// could have written, and nothing about the shape gets cheaper with `n`,
260/// so a superlinear emission would show up here first.
261pub fn program_with_signals(n: usize) -> String {
262 let mut source = String::new();
263 for i in 0..n {
264 source.push_str(&format!("state s{i} is client Whole starting {i}\n"));
265 }
266 source.push_str("\nview\n Column\n");
267 for i in 0..n {
268 source.push_str(&format!(" Text s{i}\n"));
269 }
270 source
271}
272
273/// `count` instantiations of a chain of components `depth` deep.
274///
275/// §16.10 states the components trade-off as a dilemma: *"either the
276/// compiler inlines bodies into the parent's template, multiplying
277/// template bytes and destroying per-component incremental compilation, or
278/// a call site becomes a dynamic hole with its own clone, degrading toward
279/// one clone per component."* Issue #209 asks which horn this compiler is
280/// on and what it costs, and neither question has an answer without a
281/// program whose component depth and count can be varied independently.
282///
283/// Each level declares one component whose body instantiates the next, so
284/// the source is O(depth + count) lines while the fully expanded view is
285/// depth × count bodies. A superlinear emission in either variable shows
286/// up here and nowhere else in this file: `program_with_depth` nests
287/// built-in elements, which the emitter has never had to expand.
288///
289/// `shared` is the other half of the question, and it is the half that
290/// decides whether the bytes were avoidable. A component handed a literal
291/// at each call site folds that literal into the markup, so no two copies
292/// of its body are the same string and there is nothing a compiler could
293/// have shared. A component reading one module-level signal has a hole
294/// there instead, so every copy is byte-identical — and the bytes are then
295/// a choice rather than a necessity.
296pub fn program_with_components(depth: usize, count: usize, shared: bool) -> String {
297 let depth = depth.max(1);
298 let mut source = String::new();
299 if shared {
300 source.push_str("state caption is client Text starting \"caption\"\n\n");
301 }
302 source.push_str(
303 "component C0 with label\n \
304 Column\n \
305 Heading label\n \
306 Text \"a static caption line\"\n\n",
307 );
308 for level in 1..depth {
309 source.push_str(&format!(
310 "component C{level} with label\n \
311 Column\n \
312 C{} label\n \
313 Text \"a static caption line\"\n\n",
314 level - 1
315 ));
316 }
317 source.push_str("view\n Column\n");
318 for i in 0..count {
319 let argument = if shared {
320 "caption".to_string()
321 } else {
322 format!("\"card {i}\"")
323 };
324 source.push_str(&format!(" C{} {argument}\n", depth - 1));
325 }
326 source
327}
328
329/// The same view with the components written out by hand.
330///
331/// The control the component measurement needs: whatever the emitter does
332/// with a component, this is what the programmer would otherwise have
333/// typed, so the difference between the two is what components cost. It is
334/// the *source* that differs, not the tree — both render the same page.
335pub fn program_without_components(depth: usize, count: usize, shared: bool) -> String {
336 let depth = depth.max(1);
337 let mut source = String::new();
338 if shared {
339 source.push_str("state caption is client Text starting \"caption\"\n\n");
340 }
341 source.push_str("view\n Column\n");
342 for i in 0..count {
343 let argument = if shared {
344 "caption".to_string()
345 } else {
346 format!("\"card {i}\"")
347 };
348 write_inlined(depth, 8, &argument, &mut source);
349 }
350 source
351}
352
353/// One instantiation of the component chain, written out.
354///
355/// `C{n}`'s body is a `Column` holding `C{n-1}` and a caption, and `C0`'s
356/// is a `Column` holding a heading and a caption, so the expansion is a
357/// nest of `Column`s each with the caption after the one inside it. Written
358/// recursively because that is the shape; a loop got the caption order
359/// wrong at depth 2 and the compiler caught it.
360fn write_inlined(remaining: usize, indent: usize, argument: &str, out: &mut String) {
361 let pad = " ".repeat(indent);
362 let inner = " ".repeat(indent + 4);
363 out.push_str(&format!("{pad}Column\n"));
364 if remaining == 1 {
365 out.push_str(&format!("{inner}Heading {argument}\n"));
366 } else {
367 write_inlined(remaining - 1, indent + 4, argument, out);
368 }
369 out.push_str(&format!("{inner}Text \"a static caption line\"\n"));
370}
371
372/// The bytes a module spends on static markup.
373///
374/// Every `template('…')` argument in an emission, summed. This is the
375/// quantity §16.10's dilemma is about: the byte count that inlining
376/// multiplies, as distinct from the module's total size, which also
377/// carries the walk to each hole and the bindings attached there.
378pub fn template_bytes(client_js: &str) -> usize {
379 let mut total = 0;
380 let mut rest = client_js;
381 while let Some(open) = rest.find("template('") {
382 rest = &rest[open + "template('".len()..];
383 // The emitter escapes every quote it interpolates (§16.3.5), so
384 // the first unescaped `'` ends the literal.
385 let mut end = 0;
386 let bytes = rest.as_bytes();
387 while end < bytes.len() && !(bytes[end] == b'\'' && (end == 0 || bytes[end - 1] != b'\\')) {
388 end += 1;
389 }
390 total += end;
391 rest = &rest[end.min(bytes.len())..];
392 }
393 total
394}
395
396/// A view nested `n` elements deep around a single leaf.
397pub fn program_with_depth(n: usize) -> String {
398 let mut source = String::from("state leaf is client Text starting \"leaf\"\n\nview\n");
399 for i in 0..n {
400 source.push_str(&format!("{}Column\n", " ".repeat(4 * (i + 1))));
401 }
402 source.push_str(&format!("{}Text leaf\n", " ".repeat(4 * (n + 1))));
403 source
404}
405
406/// `defs` shared definitions reachable from each of `roots` roots.
407///
408/// §17.2 makes tier splitting reachability over the product of the
409/// definition set and the root set. To measure the product rather than
410/// either factor, the definitions must be *shared*: a chain of `defs`
411/// functions, and `roots` server-placed signals each rooted at the head of
412/// that chain. The source is O(defs + roots) lines and the reachable set is
413/// `defs × roots` pairs, so any gap between the two is the pass's own.
414///
415/// Server placement is what mints a root here. `zdc build` refuses to emit
416/// a server function (§16.5, M6), which is why this measures `split` and
417/// `ifc` directly rather than going through the whole pipeline — those two
418/// passes run on the program regardless of whether anything is emitted.
419pub fn program_with_roots(defs: usize, roots: usize) -> String {
420 let defs = defs.max(1);
421 let mut source = String::from("function f0 with x\n give x + 1\n");
422 for i in 1..defs {
423 source.push_str(&format!(
424 "function f{i} with x\n give f{} with x\n",
425 i - 1
426 ));
427 }
428 for i in 0..roots {
429 source.push_str(&format!(
430 "state v{i} is server Whole from f{} with {i}\n",
431 defs - 1
432 ));
433 }
434 source.push_str("\nview\n Column\n");
435 for i in 0..roots {
436 source.push_str(&format!(" Text v{i}\n"));
437 }
438 source
439}
440
441/// What the two graph passes cost on one program.
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct GraphTimes {
444 pub defs: usize,
445 pub roots: usize,
446 pub split: Duration,
447 pub ifc: Duration,
448}
449
450impl GraphTimes {
451 /// The size of the set §17.2 says the splitter walks.
452 pub fn pairs(&self) -> usize {
453 self.defs * self.roots
454 }
455}
456
457/// Time `split` and `ifc` separately, averaged over `reps` runs.
458///
459/// Parsing and resolving are done once and outside the timed region: they
460/// are linear in the source and not what is in question. The information-
461/// flow pass is timed on its own because §17.3 is the pass most likely to
462/// be blamed for a slow compiler and the only way to know is to separate it.
463pub fn time_graph_passes(source: &str, reps: u32) -> GraphTimes {
464 let reps = reps.max(1);
465 let program = zdc_parser::parse(source).unwrap_or_else(|e| panic!("{}", e.message));
466 let hir = zdc_resolve::Resolver::new(&program)
467 .resolve()
468 .unwrap_or_else(|errors| panic!("{}", errors[0].message));
469
470 let started = Instant::now();
471 for _ in 0..reps {
472 std::hint::black_box(zdc_graph::split(&hir));
473 }
474 let split = started.elapsed() / reps;
475
476 let tier_split = zdc_graph::split(&hir);
477 let started = Instant::now();
478 for _ in 0..reps {
479 std::hint::black_box(zdc_graph::ifc(&hir, &tier_split));
480 }
481 let ifc = started.elapsed() / reps;
482
483 GraphTimes {
484 defs: hir.defs.len(),
485 roots: tier_split.roots.len(),
486 split,
487 ifc,
488 }
489}
490
491/// How many elements an index-recursive fold gets through before the host
492/// refuses.
493///
494/// §17.4.10's finding, reduced to its cause. There are no local bindings in
495/// ZDeceptron, so a fold cannot carry an accumulator through a loop;
496/// §17.4.9's technique is index recursion, and stack depth is therefore
497/// linear in the input. What is measured is the shape that emits — one
498/// self-call per element — against the interpreter the rest of this suite
499/// runs in. The number is that interpreter's recursion budget, not the
500/// language's and not a browser's; what the language contributes is that
501/// the depth grows with the input at all.
502pub fn deepest_fold() -> usize {
503 let folds = |n: usize| {
504 let mut context = boa_engine::Context::default();
505 let source = format!(
506 "function sumFrom(xs, i) {{ if (i >= xs.length) return 0; \
507 return xs[i] + sumFrom(xs, i + 1); }}\n\
508 sumFrom(new Array({n}).fill(1), 0)"
509 );
510 context
511 .eval(boa_engine::Source::from_bytes(source.as_bytes()))
512 .is_ok()
513 };
514
515 // Bisection rather than a scan: the budget is a cliff, not a slope.
516 let mut deepest = 1usize;
517 let mut refused = 1usize << 14;
518 assert!(folds(deepest), "a one-element fold must succeed");
519 while deepest + 1 < refused {
520 let middle = deepest + (refused - deepest) / 2;
521 if folds(middle) {
522 deepest = middle;
523 } else {
524 refused = middle;
525 }
526 }
527 deepest
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn comments_and_blanks_are_not_program_lines() {
536 assert_eq!(code_lines("# a\n\n # b\nview\n Text \"x\"\n"), 2);
537 }
538
539 #[test]
540 fn the_null_program_is_six_lines_like_swifts() {
541 assert_eq!(NULL_PROGRAM.lines().count(), SWIFT_NULL_PROGRAM_LINES);
542 }
543
544 #[test]
545 fn the_generators_produce_the_sizes_they_claim() {
546 assert_eq!(code_lines(&program_with_signals(8)), 8 + 8 + 2);
547 let times = time_graph_passes(&program_with_roots(4, 4), 1);
548 // Two singletons (§17.2.6) plus one endpoint per server signal.
549 assert_eq!(times.roots, 4 + 2);
550 }
551}