Skip to main content

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}