docs/SPEED.mdCompilation speed
What teq’s speed is on the inputs of the front page, what in the compiler’s design makes it so, how to see where a build’s time goes, the dialect flags that turn costly constructs off, how a long-running session holds its memory, and what the release build adds.
The numbers
Wall-clock time of a full build on an Apple M4 Max. scalac 3.8.4 runs through scala-cli, once with a
cold JVM and once with its compile server warm after a clean (the JIT warm, nothing incremental); teq is
the release build, and builds both sides to JavaScript.
| Input | scalac, cold JVM | scalac, warm server | teq |
|---|---|---|---|
| hello world | 1.0 s | 0.3 s | 3.2 ms, 100–300× faster |
| realistic: frontend (JS target), 64k lines | 42 s | 36 s | 0.87 s, 41–49× faster |
| realistic: API (JVM target), 51k lines | 33 s | 29 s | 0.18 s, 160–180× faster |
Hello world is a two-line @main. The realistic benchmark is a 104k-line synthetic application generated
to the shape of a production Scala.js code base (the same counts of case classes, derivations, givens and
macro sites per module) over the cats and sourcecode jars: a 64k-line frontend that scalac compiles through
Scala.js and a 51k-line API server it compiles for the JVM (teq’s own JVM build of the API side stops in
cats’ bodies today). Teq’s time on the frontend is 91% type checking, macro expansion the largest part.
The core-only benchmark is 107k generated lines of enums, ADTs, pattern matching, givens, extension methods, unions, for-comprehensions and closures, with no jars, macros or derivation, the compiler’s core alone, which its compile-time budgets are kept against. Teq compiles it in 72 ms (read 2 ms, lex and parse 6 ms, type check 60 ms, reach 7 ms, emit 8 ms, write 1 ms; peak memory about 220 MB); scalac 3.8.4 takes 30 s wall and 95 s CPU with a cold JVM. At 2.1M lines in 201 files teq takes 2.1 s; scalac did not finish within an hour. Hello world types in under half a millisecond, the rest of its 3.2 ms being the process.
What makes teq fast
- Flat data. AST, types and the typed IR live in arrays addressed by 32-bit ids, with argument lists stored as ranges in side pools; there are no per-node boxes, and names are interned.
- Hash-consed types. A type is a 32-bit id, so equality is an integer comparison, and a substitution returns the same id when nothing changes.
- One pass per phase, with lazy completion. The compiler is whole-program and runs each phase once;
signatures are completed on first use, so no phase ordering or re-traversal is needed, and a build from
scratch is faster than an incremental check would be. The one incremental step, in watch mode, re-types
the bodies of an edited file against the last build’s symbol table (
docs/TARGETS.md). The standard library’s files are parsed and entered as the program names what they define, and their method bodies are typed and emitted only where the program reaches them. - Indexed given resolution. Givens are indexed per package, and per class for companions and imported objects, by the type classes they implement and the extension methods they provide, so a search never scans, and a candidate is instantiated once however many rivals are tried. Implicit conversions are indexed the same way and searched only where the program would be an error without them.
- Exports as hash tables. What a class exports is flattened into one table per class on first use, so a name that comes through a prelude object with hundreds of exported members costs one more hash lookup.
- Parallel front and back end. Files are lexed and parsed on all cores with thread-local interners,
the names remapped afterwards in one pass over the flat arrays; JavaScript is emitted in chunks on all
cores, and with
--splitevery module in parallel, each compared with the file on disk before a write. - The typer’s workers. A build of 512 KiB of source or more types its bodies on several workers, by
default as many as the machine’s cores up to eight, with the same output whatever their count;
--threads nsets it, and a watch session’s retypes run on one worker (docs/TARGETS.md). - Only what is reached is emitted. Before emission a walk over the typed IR marks what the entry
point and the exported definitions reach, typing the libraries’ bodies as it meets them; what is not
reached is not emitted, nor are the runtime’s helpers the output does not refer to. A small program
pays for the library’s signatures alone, and
teq compiler checknever types a library’s body.
Where the time goes
--time prints the phases of a build with their wall-clock time: read, parse, type check, reach, emit and
write. On a program of its own the type phase is most of it; on a build over many jars the reach is the
largest phase after it, since the libraries’ bodies are typed there (on a production application over 69
jars, about half a second of a 1.8 s build, four fifths of it in the libraries’ bodies).
--profile attributes the type phase’s time to the constructs that search and the sites that use them:
given searches (by target type class and site, with the candidates tried and the depth of nested
searches), overload resolutions (by method and site, with the alternatives that fit the shape and the
applicable ones), conversion searches (found and in vain, by receiver and member), inline expansions (by
callee, with the depth and a split of their own time by part), extension lookups that went past the
lexical scope, function literals typed ahead of an overload resolution and typed again, and the classes
read from a classpath. The report on stderr has the totals per kind against the type phase, the top 20
sites by self time (nested searches counted at their own sites; incl adds them back) and the top groups
per kind, each line with the hint that would remove the cost; --profile=out.json writes the whole table.
Off, every hook is one branch; on, a run pays about 35 ns per search or expansion.
profile: type phase 12.2 ms, of which 3.35 ms (27.4%) in 40020 searches and expansions
kind count self share
conversion search 20000 1.40 ms 11.5%
extension lookup in the implicit scope 20000 1.95 ms 15.9%
top 20 sites by self time (incl counts what nested inside):
view_200.scala:5931:13: conversion A3.m0 (1 x, 1 candidates, 1 found): self 0.03 ms, incl 0.03 ms, 0.2%
hint: an extension method m0 on A3 is found by name; the dialect flag no-implicit-conversions
removes conversions
A hint names the change that ends the search at its site: the given to write by hand where it is
summoned, the alternative to call under a distinct name, the extension method in place of the conversion,
the plain def in place of the inline one, the type ascription that ends a second typing, or the dialect
flag that removes the construct from the code base. Above, A3 lacks m0 and a conversion was searched;
an extension method m0 on A3 is found by name, with no search at all.
The dialect flags
A feature is made fast as it is where that is possible, supported at its cost otherwise, and then given an opt-in way to be fast; the dialect flags are that way. Each turns a costly construct off for a code base, and where it is met the compiler rejects it with a message that names the flag and the cost.
| flag | rejects | the cost it removes |
|---|---|---|
no-implicit-conversions |
implicit def conversions and Conversion givens, and a library’s in use |
a search at every member the receiver lacks and every argument that does not conform |
no-overloading |
a second alternative of a name in one scope | resolving every call of the name among the alternatives by shape and applicability |
no-inline |
a call of an inline method (inline defs are still parsed) | the body typed again at every call |
explicit-result-types |
a non-private def or val without a declared type |
a type that depends on a body, typed before any use |
no-scala2-implicits |
implicit definitions and implicit parameter clauses |
wildcard imports that widen every given search; a conversion per implicit def |
strict-equality |
== between types without a CanEqual instance (scalac’s -language:strictEquality) |
comparisons accepted between unrelated types |
no-nonlocal-returns |
return inside a function literal |
a try/catch per method that has one |
One flag extends the language instead. interpreted-constants lets an inline val, an inline if
condition and an inline match scrutinee be any pure expression over the standard library
("ab".length, List(1, 2).sum), which the interpreter evaluates at compile time; without it they are
constants as scalac counts them (literals, primitive operations and + over them, values of literal
types). strict does not include it.
--dialect takes a comma-separated list, or strict for all of the flags in the table (strict-equality
is also the flag --strict-equality). A teq.toml next to the sources (in an input directory, or beside
an input file) sets a project’s dialect, and the command line adds to it:
dialect = "strict"
[dialect]
no-overloading = false
A session’s memory
A resident process (teq compiler watch, the language server) lives across builds and threads, and three rules
keep its memory in check. The compiler’s own allocator, a thread-local one in place of malloc, lets a thread
that goes idle or ends hand what it freed to a centre the other threads draw from, and maps requests of
1 MB and more itself; a one-shot build keeps what it frees on the thread and leaves without dropping what
it built. The interpreter registers the holders of the reference cycles that macro expansions leave and
cuts them at the start of every full build. And once the records that retypes leave dead have grown the
session by half of what its last full build left, and by 16 MB at least, the next build takes the full
path. Over the core-only benchmark a watch --split session grows by 1 MB per body edit and stands at
407 MB after 300, against 57 MB per edit before; over a production application on 69 jars, 300 body edits
end at 2.7 GB rather than 8.2, and twenty full builds at 2.9 GB rather than 17. What remains is the cycles
of a retype that expands macros, swept only at a full build, 2 to 6 MB per such edit, until a sweep at
every build takes them.
The release build
cargo build --release gives the plain build, which every suite and measurement runs on and the table
above was measured with; a build guided by a profile of the compiler running over the benchmark programs
retires about 20% fewer instructions and compiles 10 to 40% faster, since the inliner then shapes the
typer’s hottest functions from counts rather than sizes, and is what the published macOS and Linux
binaries are (the Windows binary is a plain build for now). The output is byte-identical from either.