Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: ci

on:
push:
branches: ['**']
pull_request:

jobs:
test:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: go test -race ./...
- run: go vet ./...
47 changes: 39 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,46 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: go test ./...
- run: go test -race ./...
- run: go vet ./...

release:
validate-release:
needs: test
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { goos: linux, goarch: amd64, cgo: '0', runner: ubuntu-latest }
- { goos: linux, goarch: arm64, cgo: '0', runner: ubuntu-latest }
- { goos: linux, goarch: arm, goarm: '7', cgo: '0', runner: ubuntu-latest }
- { goos: linux, goarch: '386', cgo: '0', runner: ubuntu-latest }
- { goos: darwin, goarch: amd64, cgo: '1', runner: macos-26-intel }
- { goos: darwin, goarch: arm64, cgo: '1', runner: macos-latest }
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Test native platform
if: matrix.goos == 'darwin' || matrix.goarch == 'amd64'
run: go test ./...
- name: Validate release build
env:
CGO_ENABLED: ${{ matrix.cgo }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
MACOSX_DEPLOYMENT_TARGET: '12.0'
run: go build -trimpath -o /tmp/snailrace ./cmd/snailrace

release:
needs: validate-release
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
Expand All @@ -32,8 +63,8 @@ jobs:
- { goos: darwin, goarch: amd64, cgo: '1', runner: macos-26-intel, label: darwin-amd64 }
- { goos: darwin, goarch: arm64, cgo: '1', runner: macos-latest, label: darwin-arm64 }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Test Darwin
Expand All @@ -45,7 +76,7 @@ jobs:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos_target || '12.0' }}
MACOSX_DEPLOYMENT_TARGET: '12.0'
run: |
go build -trimpath \
-ldflags="-s -w -X github.com/shellcell/snailrace/internal/app.Version=${GITHUB_REF_NAME}" \
Expand All @@ -72,7 +103,7 @@ jobs:
nfpm package -f /tmp/nfpm.yaml -p "$fmt" -t pkgs
done
- name: Upload to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
${{ env.ARCHIVE }}
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ Compare shell commands:
./snailrace -n 20 -c 'grep needle data.txt' -c 'rg needle data.txt'
```

Save an HTML report:
An HTML report is saved to the current directory by default. Choose another directory:

```sh
./snailrace -format html -output ./reports -- ./my-program --flag
```

Use `-no-save` when only stdout output is wanted.

Measure an interactive TUI:

```sh
Expand All @@ -85,8 +87,9 @@ Measure an interactive TUI:
| `-interval` | Sampling interval; default 10 ms |
| `-index` | Index dimensions: `time,cpu,ram,disk` |
| `-baseline` | 1-based baseline; `0` selects the winner |
| `-f`, `-format` | `html`, `svg`, `markdown`, `json`, or `text` |
| `-o`, `-output` | Report directory |
| `-f`, `-format` | Saved format; default `html` |
| `-o`, `-output` | Report directory; default current directory |
| `-no-save` | Do not save report files |
| `-verbose` | Full statistical tables on stdout |
| `-show-output` | Forward command output to stderr |
| `tui` | Run inside a pseudo-terminal |
Expand All @@ -106,28 +109,41 @@ Measure an interactive TUI:

Raw runs and summary statistics are available in JSON. Reports include mean,
sample standard deviation, median, p95, range, and a 95% Student's t interval.
Every format identifies the dimensions actually included in the balanced index
and records sampling or metric-availability caveats.

## Method

- Runs use randomized counterbalanced order.
- The default index combines normalized time, CPU, and RAM costs.
- RAM cost combines mean and peak RSS.
- Sampling-limited RAM is excluded from the index.
- Commands with non-zero measured exits continue running but are excluded from rankings.
- If none of the selected dimensions is usable, reports mark the balanced ranking unavailable.
- Ctrl+C keeps completed comparison rounds.
- Command output is discarded unless `-show-output` is set.
- Native executables run through the inspected artifact and are identity/hash-checked after measurement.
- Scripts preserve their original invocation path so `$0`-relative behavior is unchanged.

Saved reports are staged before publication and receive a numeric suffix rather
than overwriting an existing report with the same timestamp.

## Platform Notes

- Linux reads `/proc` and follows each process's children.
- Linux reads `/proc` and measures every member of the command's process group,
including descendants reparented while the benchmark is running.
- macOS uses cgo with `proc_listpgrppids`, `proc_pidinfo`, and
`proc_pid_rusage`.
- macOS physical footprint estimates memory pressure charged to each process.
- Physical-footprint validity is tracked per run; unavailable samples are not treated as zero.
- Tree RSS can double-count shared pages.
- Sampled peaks can miss activity shorter than the interval.
- macOS does not report file descriptor counts.

## Disk Footprint

- For a simple external `-c` command, disk footprint describes its leading executable;
shell builtins describe the shell itself.
- Disk-backed `@rpath`, `@loader_path`, and `@executable_path` libraries are
resolved recursively.
- macOS dyld shared-cache libraries are listed but excluded from byte totals.
Expand Down
1 change: 1 addition & 0 deletions completions/_snailrace
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ _snailrace() {
'-baseline[set 1-based baseline]:index:' \
'*'{-f,-format}'[select report format]:format:(html svg markdown json text)' \
'(-o -output)'{-o,-output}'[set report directory]:directory:_directories' \
'-no-save[do not save report files]' \
'-verbose[print full statistical tables]' \
'-show-output[forward command output]' \
'(-d -duration)'{-d,-duration}'[set fixed TUI duration]:duration:' \
Expand Down
2 changes: 1 addition & 1 deletion completions/snailrace.bash
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ _snailrace() {
local cur prev options command_seen expect_value after_separator word i
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
options="-c -command -label -prepare -n -runs -warmups -interval -index -baseline -f -format -o -output -verbose -show-output -d -duration -width -height -v -version -h -help"
options="-c -command -label -prepare -n -runs -warmups -interval -index -baseline -f -format -o -output -no-save -verbose -show-output -d -duration -width -height -v -version -h -help"

command_seen=0
expect_value=0
Expand Down
1 change: 1 addition & 0 deletions completions/snailrace.fish
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ complete -c snailrace -l index -r -a 'time cpu ram disk' -d 'Set index dimension
complete -c snailrace -l baseline -r -d 'Set 1-based baseline'
complete -c snailrace -s f -l format -r -a 'html svg markdown json text' -d 'Select report format'
complete -c snailrace -s o -l output -r -a '(__fish_complete_directories)' -d 'Set report directory'
complete -c snailrace -l no-save -d 'Do not save report files'
complete -c snailrace -l verbose -d 'Print full statistical tables'
complete -c snailrace -l show-output -d 'Forward command output'
complete -c snailrace -s d -l duration -r -d 'Set fixed TUI duration'
Expand Down
19 changes: 17 additions & 2 deletions docs/snailrace.1
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ Commands are given either after a
separator or with repeated
.Fl c
flags.
.Pp
Commands with non-zero measured exits continue through all configured rounds,
but are reported as failures and excluded from rankings.
If none of the selected dimensions is usable, the balanced ranking is marked
unavailable instead of selecting a winner.
.Pp
For simple external shell commands, executable metadata and disk footprint
describe the leading command. Shell builtins describe the shell itself.
Scripts retain their original invocation path.
The
.Cm tui
subcommand renders a live terminal view while measuring.
Expand Down Expand Up @@ -71,8 +80,14 @@ Saved report format:
.Ql markdown ,
.Ql json ,
.Ql text .
The default is
.Ql html .
.It Fl o , Fl output Ar string
Directory for a saved report.
Directory for a saved report; defaults to the current directory.
Reports are staged before publication and receive a numeric suffix instead of
overwriting an existing report with the same name.
.It Fl no-save
Do not save report files; write the text report to standard output only.
.It Fl verbose
Print full statistical tables to stdout.
.It Fl show-output
Expand All @@ -92,7 +107,7 @@ TUI rows; default inherits the terminal.
.It Fl v , Fl version
Print version and exit.
.It Fl h , Fl help
Show help.
Show help and exit successfully.
.El
.Sh EXAMPLES
Benchmark one command with warmups:
Expand Down
8 changes: 5 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
module github.com/shellcell/snailrace

go 1.22
go 1.25.0

toolchain go1.25.12

require (
github.com/creack/pty v1.1.24
golang.org/x/term v0.21.0
golang.org/x/term v0.45.0
)

require golang.org/x/sys v0.21.0 // indirect
require golang.org/x/sys v0.47.0
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
Loading