A Pass-Order Study in Tubular
Technical report: link
This post documents the research process behind my recent exploration of optimization pass ordering in Tubular, a small compiler that emits WebAssembly Text (WAT), I originally built for my class CSE 450, but kept developing it — adding optimization passes, configurable pass ordering, and the data pipelines described here. The accompanying report contains all tables and quantitative results; here I focus on the methodology — how the compiler and experiments were designed, how the data pipeline evolved, and what lessons emerged about making self-driven research reproducible.
1. Motivation and Scope
Tubular began as a teaching project: a C++20 implementation of the classic frontend–middle–backend pipeline for a simple imperative language. Once the core pipeline was stable (lexer, parser, type system, WAT generator), I added three optimization passes:
- FunctionInliningPass (small/pure functions),
- LoopUnrollingPass (affine while loops with literal bounds), and
- TailRecursionPass (loopification via a dedicated
ASTNode_TailCallLoop).
Each pass worked in isolation, but I wanted to replicate a long-standing observation from production compilers: the order in which passes run can influence the final code. Rather than rely on anecdotes, I decided to quantify that effect in Tubular's controlled setting.
Research question:
When does the ordering of inlining, unrolling, and tail recursion elimination matter for small WebAssembly kernels, and by how much?
2. Building the Experimental Substrate
2.1 Configurable Pass Ordering
I introduced a --pass-order=inline,unroll,tail flag (arbitrary
permutation) and refactored Tubular::RunOptimizationPasses to schedule
passes dynamically. This required:
- A shared AST cloner (
src/core/ASTCloner.hpp) capable of deep-copying every node type. - Defensive pass implementations (e.g., unroll respects break/continue, inliner clones call arguments cautiously).
- A CLI parser that validates permutations and meaningful combinations (
--unroll-factor,--no-inline,--tail=off).
2.2 Curated Benchmark Suite
To probe distinct behaviors, I created ten workloads under
research_tests/, each returning a deterministic integer:
- recursive Fibonacci (rt01),
- tail-recursive factorial (rt02),
- simple loop summation (rt03),
- stride-heavy helper loop (rt04),
- nested loop mix (rt05),
- string wrapping with helper calls (rt06),
- helper-heavy arithmetic loop (rt07),
- branchy loop with continue (rt08),
- matrix-style nested loop (rt09), and
- a control baseline (rt10).
Each benchmark got a plain-text comment documenting the expected output. The
manifest (research_tests/config.json) ties benchmarks to expected
values, optimization variants, and pass orders.
2.3 Automation Infrastructure
I wrote two scripts:
-
scripts/collect_data.py: rebuilds Tubular, runs the legacy regression suite, executes every benchmark/variant/order combination with warm-ups and timed runs, and writesartifacts/research/results.csv+summary.json. -
scripts/repeat_collection.py: orchestrates multiple runs (default 3), storing each run inartifacts/research/batch_runs/runN/with logs and metadata.
Key design decisions:
- Warm-up iterations precede timing to avoid cold-start bias.
- Node.js executes
.wasmoutputs because it's easy to script, but the framework is runtime-agnostic. - Runs are verbose by default (
[RUN …]/[OK …]) so long sweeps show progress and become easier to debug.
3. From Single Runs to Robust Data
3.1 First Iteration: Single Sweep
Initially I built the pipeline with 5 timed runs per configuration. It quickly highlighted order-dependent spikes (nested loops, helper-heavy workloads), but conclusions felt brittle. Repeating the sweep manually showed some order swaps, suggesting more samples were needed.
3.2 Scaling Up
I changed the manifest to runs=50, warmup_runs=10. Each sweep now
executes 360 combinations × 50 runs = 18,000 timed executions. Runtime per
sweep went from ~2 minutes to ~10 minutes, still manageable.
3.3 Multi-Run Validation
With scripts/repeat_collection.py --runs 3, I ran the full pipeline
three times (each including the regression suite). Post-run analysis showed:
- Best-order medians vary only ~0.33 ms on average (max ~1.34 ms).
- Worst-order medians vary ~0.49 ms on average.
- The absolute gaps are small, but they're enough for the "winning" order to flip between runs when the contenders are separated by tenths of a millisecond.
Rather than chase a single "true" ordering, I normalized by computing regret: for each row (benchmark, variant, run), the percentage above that row's best-order median. That provided a risk profile per order (mean, median, 95th percentile).
4. Analysis Approach
4.1 Regret Rather Than Absolute Time
Absolute medians across orders differ by only ~0.25 % on average. Regret (excess percentage vs. per-row best) is more informative when ranking orders. I plotted the empirical CDF: inline → tail → unroll consistently has the lowest mean and 95th percentile regret, making it the best "fixed" order for risk-averse tuning.
4.2 Win Shares and Dominance
To capture "who wins where," I computed win shares per benchmark/order and produced stacked bar charts. Some benchmarks (e.g., rt01-fib-recursive) showed fragmented shares, implying no stable order; others (e.g., rt05-nested-mix) had a clear dominant color.
I also calculated a "dominance score": fraction of wins captured by the best order for each benchmark. Long bars = robust winners; short bars = contested cases.
4.3 Sensitivity by Unroll Factor
By slicing rows by --unroll-factor, I found distribution medians rising
with the factor: unroll-8 cases showed larger best–worst gaps than unroll-4 or
no unrolling. A violin plot captured this visually.
4.4 Static Feature Correlation
Using the feature table, I inspected patterns:
- Tail recursion (rt02) prefers orders that loopify before unrolling.
- Nested loops (rt05, rt09) lean toward inline-first sequences.
- Branch-heavy loops (rt08) favor tail-first ordering to simplify control before unrolling.
This hints that even simple static features can guide pass selection, a good direction for "future work."
5. Reproducibility Checklist
All artifacts mentioned in the report live in Git:
- Benchmarks:
research_tests/*.tube - Scripts:
scripts/collect_data.py,scripts/repeat_collection.py - Raw data:
artifacts/research/batch_runs/run*/{summary.json, results.csv, collect.log} - Aggregated metrics:
artifacts/research/aggregated_metrics.json - Tables for the report:
docs/figures/*.csv - LaTeX report:
docs/technical_report.tex.
Re-run the experiment in one command:
python3 scripts/collect_data.py
Steamroll the entire batch (three runs):
./scripts/repeat_collection.py --runs 3
Everything is self-contained: the technical report references the scripts and directories explicitly for reviewers.
6. Lessons Learned
- Automate early. The first version used manual scripts; rerunning by hand highlighted the need for
collect_data.pyand verbose logging. - Sample heavily. With only five runs per configuration, outliers dominated. Jumping to 50 runs smoothed noise and made regret analysis meaningful.
- Normalize comparisons. Regret (and win shares) communicate risk better than absolute medians.
- Correlate with structure. Static features — loop depth, tail recursion, branching — provided intuitive explanations for the observed "winners."
- Version everything. Raw and aggregated data are stored alongside code; the report is reproducible down to the CSV tables.
7. Future Directions
- External runtimes: Replicate results under Wasmtime (AOT + JIT) to see if host optimizations change the order rankings.
- Additional passes: Introduce dead-code elimination or common subexpression elimination to see if the "robust" order persists.
- Feature-driven selection: Train a simple heuristic or classifier on the existing dataset to choose pass order per benchmark.
- Longer kernels: Port the methodology to larger programs or real workloads to test scalability.
For the full quantitative treatment (regret CDFs, stacked win-share bars, unroll-factor vioplots, and detailed tables), see the technical report. All code, data, and plots live in the repository (github.com/AdityaC4/tubular-upgrade).