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, getopts in functions, a stack and a queue

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 while read

configure

An autoconf-style configure script: many small forks, here-documents, sed

build

A make-like build that runs $SH -c for each object: fork, exec and shell startup

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

sh -c true

0.31 ms

0.52 ms

0.52 ms

0.75 ms

sh -c /bin/true

0.60 ms

0.79 ms

0.79 ms

1.00 ms

while loop, 100,000 iterations of $((i+1))

80 ms

42 ms

174 ms

138 ms

Loop running /bin/true 3000 times

0.89 s

0.90 s

1.19 s

1.31 s

Loop running x=$(f) 3000 times, f a function that runs echo hi

0.23 s

0.32 s

0.52 s

0.44 s

Loop running x=$(echo hi) 3000 times

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 -f

-c true (for comparison)

3.4 ms

3.0 ms

3.1 ms

3.1 ms

Sourcing nvm.sh, without the cache

20.7 ms

14.6 ms

27.9 ms

106 ms

Parsing the saved state (-n)

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 (-i -c true), from the cache

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 while read, Rhai with fs::read_file)

urlencode

8,000 calls from a shell loop, each percent-encoding a 60-character string into REPLY (the shell function with a case table, not a $(printf) per character)

calls

300,000 calls from a shell loop of a command that adds two numbers into REPLY: the cost of a call

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.