Skip to content

Commit 061f89a

Browse files
authored
Merge pull request #1 from maksimtech/codspeed-wizard-1786967135660
2 parents 40a8e8e + 00ce0b7 commit 061f89a

10 files changed

Lines changed: 391 additions & 0 deletions

File tree

.github/workflows/codspeed.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: codspeed
2+
3+
# Default to 'contents: read', which grants actions to read commits.
4+
#
5+
# If any permission is set, any permission not included in the list is
6+
# implicitly set to "none".
7+
#
8+
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
on:
17+
# "workflow_dispatch" also allows CodSpeed to trigger backtest performance
18+
# analysis to generate initial data.
19+
workflow_dispatch:
20+
push:
21+
branches:
22+
- 'master'
23+
- '[0-9]+.[0-9]+'
24+
- '[0-9]+.x'
25+
tags:
26+
- 'v*'
27+
pull_request:
28+
29+
jobs:
30+
benchmark:
31+
runs-on: ubuntu-24.04
32+
permissions:
33+
contents: read # required for actions/checkout
34+
id-token: write # required for OIDC authentication with CodSpeed
35+
steps:
36+
-
37+
name: Checkout
38+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
39+
with:
40+
persist-credentials: false
41+
-
42+
name: Set up Go
43+
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
44+
with:
45+
go-version: "1.26.4"
46+
cache: false
47+
-
48+
name: Prepare
49+
run: |
50+
# run in go modules mode to prevent traversing to nested modules
51+
ln -s vendor.mod go.mod
52+
ln -s vendor.sum go.sum
53+
-
54+
name: Run benchmarks
55+
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
56+
with:
57+
mode: walltime
58+
run: |
59+
go test -bench=. \
60+
./cli/command/commands/ \
61+
./cli/command/container/ \
62+
./cli/command/formatter/ \
63+
./cli/command/system/ \
64+
./cli/config/ \
65+
./opts/ \
66+
./templates/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[![Go Report Card](https://goreportcard.com/badge/github.com/docker/cli)](https://goreportcard.com/report/github.com/docker/cli)
77
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/docker/cli/badge)](https://scorecard.dev/viewer/?uri=github.com/docker/cli)
88
[![Codecov](https://img.shields.io/codecov/c/github/docker/cli?logo=codecov)](https://codecov.io/gh/docker/cli)
9+
[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://app.codspeed.io/maksimtech/cli?utm_source=badge)
910

1011
## About
1112

TESTING.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,36 @@ Fakes, and testing utilities can be found in
3232
[internal/test](https://godoc.org/github.com/docker/cli/internal/test) and
3333
[gotest.tools](https://godoc.org/gotest.tools).
3434

35+
## Benchmarks
36+
37+
Performance sensitive code should be covered by benchmarks. Benchmarks use the
38+
standard Go [testing](https://pkg.go.dev/testing#hdr-Benchmarks) conventions and
39+
live next to the unit tests in `_test.go` files, named using the convention:
40+
41+
```
42+
Benchmark<Function Name>[<Test Case Name>]
43+
```
44+
45+
Prefer [`b.Loop()`](https://pkg.go.dev/testing#B.Loop) over `for i := 0; i < b.N; i++`,
46+
and call `b.ReportAllocs()` to keep track of allocations.
47+
48+
Benchmarks can be run locally with:
49+
50+
```shell
51+
go test -bench=. ./templates/
52+
```
53+
54+
Benchmarks are also run continuously in CI through
55+
[CodSpeed](https://app.codspeed.io/maksimtech/cli), which reports the
56+
performance impact of a pull request. The packages that are benchmarked in CI
57+
are listed in [.github/workflows/codspeed.yml](.github/workflows/codspeed.yml).
58+
To run them the same way CodSpeed does, install the
59+
[CodSpeed CLI](https://codspeed.io/docs/cli) and run:
60+
61+
```shell
62+
codspeed run --skip-upload --mode walltime -- go test -bench=. ./templates/
63+
```
64+
3565
## End-to-End Test Suite
3666

3767
The end-to-end test suite tests a cli binary against a real API backend.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package commands
2+
3+
import (
4+
"testing"
5+
6+
"github.com/docker/cli/internal/test"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
// BenchmarkAddCommands measures the cost of constructing the command-tree.
11+
// The command-tree is constructed on every invocation of the CLI, and as
12+
// part of generating shell-completion scripts and documentation.
13+
func BenchmarkAddCommands(b *testing.B) {
14+
dockerCLI := test.NewFakeCli(nil)
15+
16+
b.ReportAllocs()
17+
for b.Loop() {
18+
AddCommands(&cobra.Command{Use: "docker"}, dockerCLI)
19+
}
20+
}

cli/command/container/opts_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,3 +1157,58 @@ func TestConvertToStandardNotation(t *testing.T) {
11571157
}
11581158
}
11591159
}
1160+
1161+
// BenchmarkParseRun measures parsing the flags of a "docker run" invocation,
1162+
// which includes constructing the flag-set, and converting the options to
1163+
// the container-, host-, and networking-config.
1164+
func BenchmarkParseRun(b *testing.B) {
1165+
for _, tc := range []struct {
1166+
doc string
1167+
args []string
1168+
}{
1169+
{
1170+
doc: "minimal",
1171+
args: []string{"ubuntu", "bash"},
1172+
},
1173+
{
1174+
doc: "many flags",
1175+
args: []string{
1176+
"--hostname", "my-hostname",
1177+
"--user", "1000:1000",
1178+
"--workdir", "/some/workdir",
1179+
"--env", "FOO=bar",
1180+
"--env", "SOME_OTHER_VAR=some-other-value",
1181+
"--label", "com.example.label=some-value",
1182+
"--publish", "8080:80/tcp",
1183+
"--publish", "127.0.0.1:8443:443",
1184+
"--expose", "9000-9010",
1185+
"--volume", "/tmp/source:/mnt/source:ro",
1186+
"--mount", "type=volume,source=my-volume,target=/data,readonly",
1187+
"--tmpfs", "/run:size=1m",
1188+
"--network", "my-network",
1189+
"--dns", "1.1.1.1",
1190+
"--add-host", "example.com:127.0.0.1",
1191+
"--cap-add", "NET_ADMIN",
1192+
"--cap-drop", "MKNOD",
1193+
"--memory", "512m",
1194+
"--cpus", "1.5",
1195+
"--restart", "on-failure:5",
1196+
"--ulimit", "nofile=1024:2048",
1197+
"--health-cmd", "curl -f http://localhost/ || exit 1",
1198+
"--health-interval", "30s",
1199+
"--log-driver", "json-file",
1200+
"--log-opt", "max-size=10m",
1201+
"ubuntu", "bash",
1202+
},
1203+
},
1204+
} {
1205+
b.Run(tc.doc, func(b *testing.B) {
1206+
b.ReportAllocs()
1207+
for b.Loop() {
1208+
if _, _, _, err := parseRun(tc.args); err != nil {
1209+
b.Fatal(err)
1210+
}
1211+
}
1212+
})
1213+
}
1214+
}

cli/command/formatter/container_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,3 +953,66 @@ func TestDisplayablePorts(t *testing.T) {
953953
assert.Check(t, is.Equal(port.expected, actual))
954954
}
955955
}
956+
957+
// genContainers generates a list of containers to be used as a realistic
958+
// input for benchmarks; the containers have all fields set that are used
959+
// by the various formats.
960+
func genContainers(count int) []container.Summary {
961+
created := time.Now().Add(-72 * time.Hour).Unix()
962+
containers := make([]container.Summary, 0, count)
963+
for i := range count {
964+
containers = append(containers, container.Summary{
965+
ID: fmt.Sprintf("%064x", i),
966+
Names: []string{fmt.Sprintf("/container_%d", i), fmt.Sprintf("/other_name_%d", i)},
967+
Image: "docker.io/library/ubuntu:24.04",
968+
ImageID: fmt.Sprintf("sha256:%064x", i),
969+
Command: `/bin/sh -c "while true; do echo hello world; sleep 1; done"`,
970+
Created: created,
971+
Ports: []container.PortSummary{
972+
{IP: netip.MustParseAddr("0.0.0.0"), PrivatePort: 80, PublicPort: uint16(30000 + i), Type: "tcp"},
973+
{IP: netip.MustParseAddr("::"), PrivatePort: 443, PublicPort: uint16(40000 + i), Type: "tcp"},
974+
{PrivatePort: 8080, Type: "tcp"},
975+
},
976+
SizeRw: 123456789,
977+
SizeRootFs: 987654321,
978+
Labels: map[string]string{
979+
"com.docker.compose.project": "some-project",
980+
"com.docker.compose.service": fmt.Sprintf("service-%d", i),
981+
"org.opencontainers.image.source": "https://github.com/docker/cli",
982+
},
983+
State: "running",
984+
Status: "Up 3 days (healthy)",
985+
Mounts: []container.MountPoint{
986+
{Type: "volume", Name: fmt.Sprintf("volume-%d", i), Destination: "/data"},
987+
{Type: "bind", Source: "/tmp/source", Destination: "/mnt/source"},
988+
},
989+
})
990+
}
991+
return containers
992+
}
993+
994+
func BenchmarkContainerWrite(b *testing.B) {
995+
containers := genContainers(100)
996+
for _, tc := range []struct {
997+
doc string
998+
format Format
999+
}{
1000+
{doc: "table", format: NewContainerFormat("table", false, false)},
1001+
{doc: "table-with-size", format: NewContainerFormat("table", false, true)},
1002+
{doc: "quiet", format: NewContainerFormat("table", true, false)},
1003+
{doc: "raw", format: NewContainerFormat("raw", false, false)},
1004+
{doc: "json", format: NewContainerFormat("json", false, false)},
1005+
{doc: "custom", format: NewContainerFormat(`{{.ID}}: {{.Names}} {{.Ports}} {{.Labels}} {{.Mounts}}`, false, false)},
1006+
} {
1007+
b.Run(tc.doc, func(b *testing.B) {
1008+
b.ReportAllocs()
1009+
out := bytes.NewBuffer(nil)
1010+
for b.Loop() {
1011+
out.Reset()
1012+
if err := ContainerWrite(Context{Format: tc.format, Output: out, Trunc: true}, containers); err != nil {
1013+
b.Fatal(err)
1014+
}
1015+
}
1016+
})
1017+
}
1018+
}

cli/command/formatter/image_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,60 @@ func TestImageContextWriteWithNoImage(t *testing.T) {
367367
})
368368
}
369369
}
370+
371+
// genImages generates a list of images to be used as a realistic input for
372+
// benchmarks; the images have all fields set that are used by the various
373+
// formats.
374+
func genImages(count int) []image.Summary {
375+
created := time.Now().AddDate(0, 0, -1).Unix()
376+
images := make([]image.Summary, 0, count)
377+
for i := range count {
378+
images = append(images, image.Summary{
379+
ID: fmt.Sprintf("sha256:%064x", i),
380+
Created: created,
381+
RepoTags: []string{
382+
fmt.Sprintf("docker.io/library/image-%d:latest", i),
383+
fmt.Sprintf("example.com/some/longer/name/image-%d:v1.2.3", i),
384+
},
385+
RepoDigests: []string{
386+
fmt.Sprintf("docker.io/library/image-%d@sha256:%064x", i, i),
387+
},
388+
Size: 123456789,
389+
SharedSize: 12345678,
390+
Containers: 3,
391+
Labels: map[string]string{"org.opencontainers.image.source": "https://github.com/docker/cli"},
392+
})
393+
}
394+
return images
395+
}
396+
397+
func BenchmarkImageWrite(b *testing.B) {
398+
images := genImages(100)
399+
for _, tc := range []struct {
400+
doc string
401+
format Format
402+
digest bool
403+
}{
404+
{doc: "table", format: NewImageFormat("table", false, false)},
405+
{doc: "table-with-digest", format: NewImageFormat("table", false, true), digest: true},
406+
{doc: "quiet", format: NewImageFormat("table", true, false)},
407+
{doc: "raw", format: NewImageFormat("raw", false, false)},
408+
{doc: "json", format: NewImageFormat("json", false, false)},
409+
{doc: "custom", format: NewImageFormat(`{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}`, false, false)},
410+
} {
411+
b.Run(tc.doc, func(b *testing.B) {
412+
b.ReportAllocs()
413+
out := bytes.NewBuffer(nil)
414+
for b.Loop() {
415+
out.Reset()
416+
ctx := ImageContext{
417+
Context: Context{Format: tc.format, Output: out, Trunc: true},
418+
Digest: tc.digest,
419+
}
420+
if err := ImageWrite(ctx, images); err != nil {
421+
b.Fatal(err)
422+
}
423+
}
424+
})
425+
}
426+
}

cli/config/config_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,39 @@ func TestSetDir(t *testing.T) {
482482
SetDir(expected)
483483
assert.Check(t, is.Equal(Dir(), expected))
484484
}
485+
486+
// benchConfig is a realistic configuration file, as loaded on every
487+
// invocation of the CLI.
488+
const benchConfig = `{
489+
"auths": {
490+
"https://index.docker.io/v1/": {"auth": "am9lam9lOmhlbGxv", "email": "user@example.com"},
491+
"registry.example.com": {"auth": "am9lam9lOmhlbGxv"},
492+
"registry.example.com:5000": {"auth": "am9lam9lOmhlbGxv"},
493+
"some-other-registry.example.com": {"auth": "am9lam9lOmhlbGxv", "identitytoken": "super-secret-token"}
494+
},
495+
"credsStore": "desktop",
496+
"credHelpers": {
497+
"registry.example.com": "secretservice",
498+
"other.example.com": "pass"
499+
},
500+
"psFormat": "table {{.ID}}\\t{{.Image}}\\t{{.Command}}\\t{{.Status}}",
501+
"imagesFormat": "table {{.Repository}}\\t{{.Tag}}\\t{{.ID}}\\t{{.Size}}",
502+
"detachKeys": "ctrl-e,e",
503+
"currentContext": "default",
504+
"plugins": {
505+
"buildx": {"defaultBuilder": "default"},
506+
"compose": {"someOption": "someValue"}
507+
},
508+
"aliases": {"builder": "buildx"},
509+
"features": {"containerd-snapshotter": "true"},
510+
"experimental": "enabled"
511+
}`
512+
513+
func BenchmarkLoadFromReader(b *testing.B) {
514+
b.ReportAllocs()
515+
for b.Loop() {
516+
if _, err := LoadFromReader(strings.NewReader(benchConfig)); err != nil {
517+
b.Fatal(err)
518+
}
519+
}
520+
}

opts/mount_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,3 +586,25 @@ func TestMountOptSetBindRecursive(t *testing.T) {
586586
}, m.Value()))
587587
})
588588
}
589+
590+
func BenchmarkMountOptSet(b *testing.B) {
591+
for _, tc := range []struct {
592+
doc string
593+
value string
594+
}{
595+
{doc: "volume", value: "type=volume,source=my-volume,target=/data"},
596+
{doc: "volume-with-opts", value: "type=volume,source=my-volume,target=/data,readonly,volume-nocopy,volume-driver=local,volume-label=foo=bar,volume-opt=type=nfs,volume-opt=device=:/some/path"},
597+
{doc: "bind", value: "type=bind,source=/home/path,target=/target,readonly,bind-propagation=rprivate"},
598+
{doc: "tmpfs", value: "type=tmpfs,target=/target,tmpfs-size=1m,tmpfs-mode=0700"},
599+
} {
600+
b.Run(tc.doc, func(b *testing.B) {
601+
b.ReportAllocs()
602+
for b.Loop() {
603+
var m MountOpt
604+
if err := m.Set(tc.value); err != nil {
605+
b.Fatal(err)
606+
}
607+
}
608+
})
609+
}
610+
}

0 commit comments

Comments
 (0)