Performance¶
luish is meant to be as fast as dash, the fastest of the common POSIX shells,
for scripts and sh -c.
bash and zsh are up to five times slower on the same scripts. What luish adds to dash (plugins, the interactive features, luish’s own options) is opt-in, and costs scripts nothing.
Scripts¶
These are the script benchmarks in the repository’s bench/ directory:
scripts of a few hundred lines, each mixing parsing, expansion, function calls,
built-ins and forks, rather than microbenchmarks. All the shells that run a
benchmark print the same output for it.
Benchmark |
What it does |
|---|---|
arith |
Mandelbrot set in fixed point, sieve of Eratosthenes, Collatz lengths |
functions |
Recursion (Hanoi, Fibonacci, Ackermann), wrappers, |
strings |
Pure-shell string processing: upper-casing, replacing, JSON and URL escaping, word frequencies |
textproc |
Generating and parsing an access log and an INI file with |
configure |
An autoconf-style |
build |
A make-like build that runs |
arrays |
Not POSIX: sorting, a sieve, word counts, grouping, matrices, a search and slices, in indexed and associative arrays |
The script and startup measurements on this page were made on 2026-10-19, with the release build of luish at git
revision a00a69d (pixi run release), on a laptop with an Intel Core i7-1260P (4 performance cores of 2 threads each,
and 8 slower efficiency cores) running Ubuntu 26.04, in its performance power profile. Every measurement ran pinned to
the same performance core (taskset -c 3), one shell and one benchmark at a time, after a 30-second pause to let the
processor cool, with the desktop running. The other shells are Ubuntu’s dash 0.5.12, bash 5.3 (--posix) and BusyBox
1.37.0 (busybox sh, from the busybox-static package), and zsh 5.9 (--emulate sh) from conda-forge, as pinned in
luish’s pixi.toml. Two complete runs of the script benchmarks gave times within 1% of each other for dash and luish
(luish’s ratios within 0.01), and within 3% for the other shells.
Seconds (and the ratio to dash; lower is faster), mean of 40 runs (two runs of bench/run.sh -r 20):
Benchmark |
dash |
luish |
bash |
zsh |
busybox |
|---|---|---|---|---|---|
arith |
0.203 (1.00) |
0.132 (0.65) |
0.533 (2.63) |
0.339 (1.67) |
0.277 (1.36) |
functions |
0.117 (1.00) |
0.109 (0.93) |
0.656 (5.61) |
0.479 (4.10) |
0.152 (1.30) |
strings |
0.160 (1.00) |
0.124 (0.78) |
0.526 (3.30) |
0.424 (2.66) |
0.225 (1.41) |
textproc |
0.124 (1.00) |
0.125 (1.01) |
0.317 (2.56) |
0.385 (3.10) |
0.188 (1.51) |
configure |
0.644 (1.00) |
0.695 (1.08) |
0.845 (1.31) |
0.794 (1.23) |
0.296 (0.46)* |
build |
0.893 (1.00) |
0.312 (0.35) |
1.001 (1.12) |
1.003 (1.12) |
0.134 (0.15)* |
The first four run mostly inside the shell, where luish is as fast as dash, or faster. In strings, luish runs
$(printf ...) without a subshell (see Shell language extensions), so it forks
23 times to dash’s 195. The next two spend most of their time starting programs. configure starts the same
programs in both shells, with the same system calls, and takes 8% longer, mostly because luish itself starts more
slowly and a command substitution that forks costs more (below). build takes about a third as long: luish runs
basename and dirname in the shell, also in $(basename "$(dirname "$src")"), where dash starts the utility, so
it starts 346 programs to dash’s 834 (it still starts a new shell for each file it compiles). Ubuntu 26.04’s cat, basename and other basic utilities
are those of the Rust coreutils, which start in about 1.3 ms (to 0.4 ms for /bin/true), so these two take about
twice as long for every shell as they did under Ubuntu 24.04, and each program a script doesn’t have to start saves
more.
* BusyBox’s times for configure and build don’t measure the same work: the static BusyBox runs its own versions
of sed, cat, basename, dirname, rm and other utilities in the shell’s process instead of running the
system’s programs (build runs 204 programs under it, to 834 under dash and 346 under luish), and as a static
program it starts faster. This counts for more with the slower utilities of Ubuntu 26.04.
arrays uses what zsh and bash scripts use beyond POSIX (arrays, associative arrays, [[ ... ]],
${x//pattern/replacement}, ${x:offset:length}), so dash and BusyBox can’t run it. Its ratios are to luish:
Benchmark |
luish |
bash |
zsh |
|---|---|---|---|
arrays |
0.111 (1.00) |
0.483 (4.36) |
0.529 (4.77) |
Startup and single commands¶
Mean of 1000 runs for the first two rows and of 20 for the others (with hyperfine), each shell run with a cleared
environment (only PATH, HOME set to an empty directory, and LC_ALL=C):
Benchmark |
dash |
luish |
bash |
zsh |
|---|---|---|---|---|
|
0.31 ms |
0.52 ms |
0.52 ms |
0.75 ms |
|
0.60 ms |
0.79 ms |
0.79 ms |
1.00 ms |
|
80 ms |
42 ms |
174 ms |
138 ms |
Loop running |
0.89 s |
0.90 s |
1.19 s |
1.31 s |
Loop running |
0.23 s |
0.32 s |
0.52 s |
0.44 s |
Loop running |
0.24 s |
0.002 s |
0.49 s |
0.39 s |
Starting luish takes about 0.2 ms longer than dash, as luish is a larger program (5.8 MB, with the plugin support
built in): it loads more libraries and touches more memory (180 page faults to dash’s 100). Starting a program costs
about the same as in dash. A command substitution that forks takes about 1.4 times as long as in dash: it makes the
same system calls, but forking a larger process costs more. One that is only echo, printf, pwd, command -v
or type with plain words (or basename, dirname, expr, seq, id or uname, see
Shell language extensions) doesn’t fork at all, and takes about 1% as long.
Interactive startup¶
With cached startup files, a new interactive shell doesn’t run its startup files:
it restores the state they left, as shell commands saved in a file. Here the startup files load
nvm 0.39.7 (. "$NVM_DIR/nvm.sh" in rc.d), and the saved state is 153 KB, with
110 functions. Mean of 100 runs, each started through env -i (which adds about 1 ms), measured on 2026-09-27 with
luish 0.1.0 (git revision 76b9576), on a similar machine:
Run |
luish |
dash |
bash |
zsh |
|---|---|---|---|---|
|
3.4 ms |
3.0 ms |
3.1 ms |
3.1 ms |
Sourcing |
20.7 ms |
14.6 ms |
27.9 ms |
106 ms |
Parsing the saved state ( |
8.5 ms |
5.2 ms |
9.8 ms |
11.8 ms |
Running the saved state |
8.9 ms |
5.9 ms |
11.2 ms |
11.2 ms |
An interactive shell ( |
8.2 ms |
So an interactive shell with nvm set up starts in about 8 ms, however long the startup files take to run. nvm here
has no version of Node installed; with a default version, nvm.sh also runs nvm use, and takes much longer, as
do conda’s initialization and other scripts that run programs, while the cached state stays the same size.
luish parses large files more slowly than dash: nvm.sh (144 KB) takes about 4 ms to parse in luish and 2 ms in
dash, which is most of the difference in sourcing it without the cache.
Commands in Rhai¶
A plugin can add commands written in Rhai (sh::builtin).
bench/extensions/ has four commands written both ways, as luish shell functions (tasks.lsh, using local,
arrays and associative arrays) and as an extension’s built-ins (ext.rhai), which must print the same output:
Task |
What it does |
|---|---|
collatz |
One call: the number below 12,000 with the longest Collatz sequence (about a million loop iterations) |
wordfreq |
One call: counts the words of a 20,000-line file and finds the five most frequent (the shell reads it with |
urlencode |
8,000 calls from a shell loop, each percent-encoding a 60-character string into |
calls |
300,000 calls from a shell loop of a command that adds two numbers into |
The fastest of 10 runs of bench/extensions/run.sh -r 10, on 2026-09-29, on a machine with 16 cores:
Task |
Shell functions (ms) |
Rhai built-ins (ms) |
Shell / Rhai |
|---|---|---|---|
collatz |
834 |
380 |
2.2 |
wordfreq |
550 |
189 |
2.9 |
urlencode |
673 |
332 |
2.0 |
calls |
472 |
758 |
0.6 |
Work inside a command (loops, arithmetic, strings, maps) runs two to three times as fast in Rhai. Rhai evaluates its
syntax tree much as luish evaluates shell code, so the gain is not larger. A command that does almost nothing is
faster as a shell function: calling into Rhai costs no more than calling a shell function (40,000 calls of an empty
command added about 5 ms either way to a loop that took 47 ms), but each Rhai operation costs more than the shell
expansion it replaces (parse_int twice, a string template and sh::setvar, against REPLY=$(( $1 + $2 ))).
The first extension creates Rhai’s engine, once, which takes about 0.6 ms and 1 MB of memory (-c with plugin load
of an empty extension, against true); compiling and running ext.rhai takes about 0.6 ms more.
Running the benchmarks¶
The benchmarks need pixi, and hyperfine if it is installed. In a checkout of luish:
pixi run bench # release build, then all benchmarks with every shell installed
bench/run.sh -n 4 -r 10 arith # one benchmark, 4 times the work, 10 runs per shell
bench/README.md describes the options and the scripts. Absolute times depend
on the machine and its load, so compare shells within one run. On a machine whose cores differ in speed (such as
Intel’s performance and efficiency cores), pin the run to one core, such as taskset -c 3 bench/run.sh, so that
every shell runs on the same one; and run one benchmark at a time. bench/extensions/run.sh runs the comparison of
commands in Rhai with shell functions.