├── .github
└── workflows
│ └── go.yml
├── LICENSE
├── README.md
├── demo
├── k9s-perfetto.png
├── k9s-s3.dot
├── k9s-s3.svg
├── k9s-trace.json
└── k9s.json
├── go.mod
├── go.sum
├── graph.go
├── main.go
├── top.go
├── tree.go
└── types.go
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Go
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Set up Go
16 | uses: actions/setup-go@v4
17 | with:
18 | go-version: "1.20"
19 |
20 | - name: Build
21 | run: go build -v ./...
22 |
23 | - name: Test
24 | run: go test -v ./...
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Ravelin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # actiongraph
2 |
3 | > 👋 We wrote this to figure out why our CI had slowed down a bit and found
4 | > something was accidentally compiling in much more Kubernetes than an offline
5 | > CLI needed. If you enjoy writing performant Go too, please do considering
6 | > joining us in the fight against fraud: [ravelin.com/careers].
7 |
8 | `actiongraph` is a CLI for investigating where `go build` is spending its time
9 | compiling. It consumes the file written using the compile.json output by `go
10 | build -debug-actiongraph=compile.json`, which includes information about compile
11 | steps and dependencies between packages.
12 |
13 | `actiongraph` can help you identify which packages take a lot of compilation
14 | time with `top`, summarise where that time is spent with `tree`, and identify
15 | where the dependencies come from with `graph --why`.
16 |
17 | Alongside the `-debug-actiongraph` flag is the `-debug-trace` flag which this
18 | program does not use, but is a similarly instructive tool which can help you
19 | identify parallelism and build order. For completeness, it is also shown below.
20 |
21 | > ℹ Both the `-debug-actiongraph` and `-debug-trace` flags are not supported and
22 | > deliberately not documented by the Go team:
23 | > https://github.com/golang/go/commit/8fce59eab5cb2facfafca89e047b4b43ba44785f.
24 |
25 | ## Installation
26 |
27 | go install github.com/icio/actiongraph@latest
28 |
29 | ## Usage overview
30 |
31 | # Optionally clear your go build cache, if you're wanting to understanding why
32 | # a build in CI is taking a while (depending on its cache reuse):
33 | go clean -cache
34 |
35 | # Compile your program using the undocumented -debug-actiongraph flag:
36 | go build -debug-actiongraph=compile.json ./my-prog
37 |
38 | # Show the slowest individual packages:
39 | actiongraph top -f compile.json
40 |
41 | # Show aggregate time spent compiling nested packages:
42 | actiongraph tree -f compile.json
43 |
44 | # Show aggregate time spent compiling github packages:
45 | actiongraph tree -f compile.json -L 2 github.com
46 |
47 | # Render dependency diagrams of packages, focusing on why PKG was compiled in:
48 | actiongraph graph --why PKG -f compile.json > compile-pkg.dot
49 | dot -Tsvg -Grankdir=LR < compile-pkg.dot > compile-pkg.svg
50 |
51 | ## Worked example
52 |
53 | In this example, we're going to look inside one of @icio's favourite CLIs,
54 | https://k9scli.io/.
55 |
56 | First, let's check that we're all set up:
57 |
58 | go version
59 | actiongraph -h
60 |
61 | then create our demo space:
62 |
63 | mkdir demo
64 | cd demo
65 |
66 | We want to investigate a fresh build, so let's clear the build cache:
67 |
68 | go clean -cache
69 |
70 | Now we compile. We're going to specify body `-debug-actiongraph` and `-debug-trace` here so we can compare the results. (And we're going to send the compiled binary into a temporary directory.)
71 |
72 | GOBIN=$(mktemp -d) go install -debug-actiongraph=k9s.json -debug-trace=k9s-trace.json github.com/derailed/k9s@v0.27.4
73 |
74 | We should have a new file called `k9s.json` describing the actiongraph. Let's take a look at the first few entries `jq '.[:5] | .[]' -c < k9s.json`:
75 |
76 | {"ID":0,"Mode":"go install","Package":"","Deps":[1],"Priority":1190,"TimeReady":"2023-05-12T09:24:39.647607494+01:00","TimeStart":"2023-05-12T09:24:39.647610965+01:00","TimeDone":"2023-05-12T09:24:39.647611018+01:00","Cmd":null}
77 | {"ID":1,"Mode":"link-install","Package":"github.com/derailed/k9s","Deps":[2],"Objdir":"/tmp/go-build3966251150/b001/","Target":"/tmp/tmp.WIZyR7yX3Y/k9s","Priority":1189,"Built":"/tmp/tmp.WIZyR7yX3Y/k9s","BuildID":"LZKyYv0RT8N-_YjcRQ1R/PsuartwNDnqAz2PYjtcW/lvWIs0IgoEnXVRitK8yx/ClYoHVGOEFtTZ-Gnzq2h","TimeReady":"2023-05-12T09:24:39.647340894+01:00","TimeStart":"2023-05-12T09:24:39.647348289+01:00","TimeDone":"2023-05-12T09:24:39.647607252+01:00","Cmd":null}
78 | {"ID":2,"Mode":"link","Package":"github.com/derailed/k9s","Deps":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1102,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188],"Objdir":"/tmp/go-build3966251150/b001/","Target":"/tmp/go-build3966251150/b001/exe/a.out","Priority":1188,"Built":"/tmp/go-build3966251150/b001/exe/a.out","ActionID":"LZKyYv0RT8N-_YjcRQ1R","BuildID":"LZKyYv0RT8N-_YjcRQ1R/PsuartwNDnqAz2PYjtcW/lvWIs0IgoEnXVRitK8yx/ClYoHVGOEFtTZ-Gnzq2h","TimeReady":"2023-05-12T09:24:33.62117428+01:00","TimeStart":"2023-05-12T09:24:33.621181576+01:00","TimeDone":"2023-05-12T09:24:39.647340491+01:00","Cmd":["/home/icio/sdk/go/pkg/tool/linux_amd64/link -o /tmp/go-build3966251150/b001/exe/a.out -importcfg /tmp/go-build3966251150/b001/importcfg.link -X=runtime.godebugDefault=panicnil=1 -buildmode=exe -buildid=LZKyYv0RT8N-_YjcRQ1R/PsuartwNDnqAz2PYjtcW/lvWIs0IgoEnXVRitK8yx/LZKyYv0RT8N-_YjcRQ1R -extld=gcc /tmp/go-build3966251150/b001/_pkg_.a"],"CmdReal":5757072143,"CmdUser":6457235000,"CmdSys":853740000}
79 | {"ID":3,"Mode":"build","Package":"github.com/derailed/k9s","Deps":[4,5,6,7,8,1189],"Objdir":"/tmp/go-build3966251150/b001/","Priority":1187,"NeedBuild":true,"ActionID":"PsuartwNDnqAz2PYjtcW","BuildID":"PsuartwNDnqAz2PYjtcW/lvWIs0IgoEnXVRitK8yx","TimeReady":"2023-05-12T09:24:33.604116889+01:00","TimeStart":"2023-05-12T09:24:33.604117711+01:00","TimeDone":"2023-05-12T09:24:33.621174013+01:00","Cmd":["/home/icio/sdk/go/pkg/tool/linux_amd64/compile -o /tmp/go-build3966251150/b001/_pkg_.a -trimpath \"/tmp/go-build3966251150/b001=>\" -p main -lang=go1.20 -complete -buildid PsuartwNDnqAz2PYjtcW/PsuartwNDnqAz2PYjtcW -c=4 -nolocalimports -importcfg /tmp/go-build3966251150/b001/importcfg -pack /home/icio/go/pkg/mod/github.com/derailed/k9s@v0.27.4/main.go"],"CmdReal":15611237,"CmdUser":16273000}
80 | {"ID":4,"Mode":"build","Package":"flag","Deps":[9,10,11,12,13,14,8,15,16,17,18],"Objdir":"/tmp/go-build3966251150/b002/","Priority":44,"NeedBuild":true,"ActionID":"fhRdV7jbdbpj3WwhqK7M","BuildID":"fhRdV7jbdbpj3WwhqK7M/fpr490Yv8t_PFttE-9YV","TimeReady":"2023-05-12T09:23:48.146874388+01:00","TimeStart":"2023-05-12T09:23:48.146904614+01:00","TimeDone":"2023-05-12T09:23:48.29620602+01:00","Cmd":["/home/icio/sdk/go/pkg/tool/linux_amd64/compile -o /tmp/go-build3966251150/b002/_pkg_.a -trimpath \"/tmp/go-build3966251150/b002=>\" -p flag -std -complete -buildid fhRdV7jbdbpj3WwhqK7M/fhRdV7jbdbpj3WwhqK7M -c=4 -nolocalimports -importcfg /tmp/go-build3966251150/b002/importcfg -pack /home/icio/sdk/go/src/flag/flag.go"],"CmdReal":140280033,"CmdUser":144616000,"CmdSys":22834000}
81 |
82 | So let's find the compile steps that took the longest:
83 |
84 | $ actiongraph -f k9s.json top
85 | 9.016s 2.75% build k8s.io/api/core/v1
86 | 6.026s 4.59% link github.com/derailed/k9s
87 | 5.071s 6.13% build github.com/aws/aws-sdk-go/service/s3
88 | 3.620s 7.23% build github.com/aws/aws-sdk-go/aws/endpoints
89 | 3.474s 8.29% build net/http
90 | 3.215s 9.27% build net
91 | 2.869s 10.15% build github.com/google/gnostic/openapiv2
92 | 2.846s 11.02% build github.com/google/gnostic/openapiv3
93 | 2.581s 11.80% build k8s.io/apimachinery/pkg/apis/meta/v1
94 | 2.476s 12.56% build google.golang.org/protobuf/internal/impl
95 | 2.186s 13.22% build github.com/gogo/protobuf/proto
96 | 1.974s 13.83% build k8s.io/api/extensions/v1beta1
97 | 1.919s 14.41% build github.com/derailed/tview
98 | 1.898s 14.99% build k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1
99 | 1.840s 15.55% build github.com/klauspost/compress/zstd
100 | 1.796s 16.10% build github.com/derailed/k9s/internal/view
101 | 1.671s 16.61% build k8s.io/kubectl/pkg/describe
102 | 1.658s 17.11% build github.com/prometheus/procfs
103 | 1.624s 17.61% build k8s.io/api/apps/v1
104 | 1.596s 18.09% build runtime
105 |
106 | By default, `actiongraph top` will show the 20 slowest steps. This may be
107 | overridden using the `-n0` flag. For example, to get the fastest steps we could
108 | check:
109 |
110 | $ actiongraph -f k9s.json top -n0 | tail -5
111 | 0.006s 100.00% build google.golang.org/protobuf/internal/flags
112 | 0.000s 100.00% link-install github.com/derailed/k9s
113 | 0.000s 100.00% nop
114 | 0.000s 100.00% built-in package unsafe
115 | 0.000s 100.00% go install
116 |
117 | Here we can see that the second column is showing the cumulative percentage of
118 | time spent up to that package.
119 |
120 | We can summarise the time spent within package domain/directories using the
121 | `tree` subcommand:
122 |
123 | $ actiongraph -f k9s.json tree -L 1
124 | 322.013s (root)
125 | 154.673s k8s.io
126 | 83.357s github.com
127 | 40.294s std
128 | 12.338s sigs.k8s.io
129 | 10.886s golang.org
130 | 7.178s google.golang.org
131 | 5.413s helm.sh
132 | 2.428s gopkg.in
133 | 2.344s go.starlark.net
134 | 1.702s go.opentelemetry.io
135 | 1.400s oras.land
136 |
137 | We can see that we spent 322 seconds compiling k9s, though some of that compilation would have happened in parallel, and so the
138 |
139 | We've rolled up all standard libraries under `std`:
140 |
141 | $ actiongraph -f k9s.json tree encoding
142 | 322.013s (root)
143 | 40.294s std
144 | 1.942s 0.017s std/encoding
145 | 0.572s 0.572s std/encoding/json
146 | 0.564s 0.564s std/encoding/xml
147 | 0.207s 0.207s std/encoding/asn1
148 | 0.192s 0.192s std/encoding/binary
149 | 0.096s 0.096s std/encoding/csv
150 | 0.080s 0.080s std/encoding/hex
151 | 0.074s 0.074s std/encoding/base64
152 | 0.073s 0.073s std/encoding/pem
153 | 0.067s 0.067s std/encoding/base32
154 |
155 | Let's look at which github repos are taking the longest to compile:
156 |
157 | $ actiongraph -f k9s.json tree github.com -L 2 | head -15
158 | 322.013s (root)
159 | 83.357s github.com
160 | 17.307s github.com/aws
161 | 17.307s github.com/aws/aws-sdk-go
162 | 16.360s github.com/derailed
163 | 7.674s 0.017s github.com/derailed/k9s
164 | 4.517s github.com/derailed/popeye
165 | 2.250s github.com/derailed/tcell
166 | 1.919s 1.919s github.com/derailed/tview
167 | 8.206s github.com/google
168 | 6.524s github.com/google/gnostic
169 | 1.014s github.com/google/go-cmp
170 | 0.277s 0.277s github.com/google/btree
171 | 0.163s 0.115s github.com/google/gofuzz
172 | 0.150s 0.150s github.com/google/uuid
173 |
174 | We saw from `top` that package github.com/aws/aws-sdk-go/service/s3 was one of the slowest to compile. To understand why, we're going to use the `graph` subcommand which can filter down the dependency list to highlight all import paths leading from our build target to the package indicated by `--why PKG`:
175 |
176 | actiongraph -f k9s.json graph --why github.com/aws/aws-sdk-go/service/s3 > k9s-s3.dot
177 |
178 | and then render it using [Graphviz](https://graphviz.org/)'s `dot`:
179 |
180 | dot -Tsvg < k9s-s3.dot > k9s-s3.svg
181 |
182 | which looks something like this:
183 |
184 | 
185 |
186 | Lastly we can open our k9s-trace.json in https://ui.perfetto.dev/ or Google
187 | Chrome/Chromium's chrome://tracing. This shows us how successfully the scheduler
188 | utilises the multiple cores on our machine when compiling each package:
189 |
190 | 
191 |
192 | At the time of writing, `-debug-trace` is under development in
193 | https://github.com/golang/go/issues/38714.
194 |
195 | [ravelin.com/careers]: https://www.ravelin.com/careers?utm_medium=referral&utm_source=github&utm_campaign=actiongraph
196 |
--------------------------------------------------------------------------------
/demo/k9s-perfetto.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/icio/actiongraph/3cf5d9b3415761ba1efef5d97b2289f4190e0b8c/demo/k9s-perfetto.png
--------------------------------------------------------------------------------
/demo/k9s-s3.dot:
--------------------------------------------------------------------------------
1 | digraph {
2 | 3 [label=<github.com/derailed
k9s
build 17.056302ms>; shape=box];
3 | 3 -> 5;
4 | 5 [label=<github.com/derailed/k9s
cmd
build 170.344979ms>; shape=box];
5 | 5 -> 22;
6 | 5 -> 23;
7 | 22 [label=<github.com/derailed/k9s/internal
ui
build 750.135239ms>; shape=box];
8 | 22 -> 98;
9 | 22 -> 99;
10 | 23 [label=<github.com/derailed/k9s/internal
view
build 1.795657517s>; shape=box];
11 | 23 -> 98;
12 | 23 -> 99;
13 | 23 -> 112;
14 | 23 -> 22;
15 | 23 -> 115;
16 | 23 -> 117;
17 | 98 [label=<github.com/derailed/k9s/internal
dao
build 788.569378ms>; shape=box];
18 | 98 -> 277;
19 | 99 [label=<github.com/derailed/k9s/internal
model
build 444.912435ms>; shape=box];
20 | 99 -> 98;
21 | 99 -> 117;
22 | 112 [label=<github.com/derailed/k9s/internal
perf
build 43.795665ms>; shape=box];
23 | 112 -> 98;
24 | 115 [label=<github.com/derailed/k9s/internal/ui
dialog
build 41.487591ms>; shape=box];
25 | 115 -> 22;
26 | 117 [label=<github.com/derailed/k9s/internal
xray
build 345.505127ms>; shape=box];
27 | 117 -> 98;
28 | 277 [label=<github.com/derailed/popeye
pkg
build 364.880583ms>; shape=box];
29 | 277 -> 526;
30 | 526 [label=<github.com/aws/aws-sdk-go/service/s3
s3manager
build 356.912126ms>; shape=box];
31 | 526 -> 700;
32 | 526 -> 701;
33 | 700 [label=<github.com/aws/aws-sdk-go/service
s3
build 5.070865122s>; shape=box];
34 | 701 [label=<github.com/aws/aws-sdk-go/service/s3
s3iface
build 444.346913ms>; shape=box];
35 | 701 -> 700;
36 | }
37 |
--------------------------------------------------------------------------------
/demo/k9s-s3.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
244 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/icio/actiongraph
2 |
3 | go 1.20
4 |
5 | require (
6 | github.com/spf13/cobra v1.7.0
7 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53
8 | )
9 |
10 | require (
11 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
12 | github.com/spf13/pflag v1.0.5 // indirect
13 | )
14 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
2 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
6 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
7 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
8 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
10 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
13 |
--------------------------------------------------------------------------------
/graph.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "path/filepath"
7 |
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | func addGraphCommand(prog *cobra.Command) {
12 | cmd := cobra.Command{
13 | GroupID: "actiongraph",
14 | Use: "graph [-f compile.json] [--why PKG]",
15 | Short: "Graphviz visaualisation of the build steps",
16 | RunE: func(cmd *cobra.Command, args []string) error {
17 | opt, err := loadOptions(cmd)
18 | if err != nil {
19 | return err
20 | }
21 |
22 | why, err := cmd.Flags().GetString("why")
23 | if err != nil {
24 | return err
25 | }
26 |
27 | return graph(opt, why)
28 | },
29 | }
30 | cmd.Flags().String("why", "", "show only paths to the given package")
31 | prog.AddCommand(&cmd)
32 | }
33 |
34 | func graph(opt *options, why string) error {
35 | actions := opt.actions
36 |
37 | // show is a shortcut set of actions with Deps leading to the destination.
38 | show := make([]int, len(actions))
39 | shown := 0
40 |
41 | // Ignore "nop" nodes.
42 | for _, act := range actions {
43 | if act.Mode == "nop" {
44 | // TODO: What is the Mode:"nop" action? It typically has many Deps
45 | // that make rendering the graph complicated.
46 | show[act.ID] = avoid
47 | }
48 | }
49 |
50 | if why != "" {
51 | // Look for our destination node.
52 | for i, act := range actions {
53 | if act.Mode == "build" && act.Package == why {
54 | shown++
55 | show[i] = follow
56 | break
57 | }
58 | }
59 | if shown == 0 {
60 | return fmt.Errorf("could not find package %q", why)
61 | }
62 | }
63 |
64 | if shown == 0 {
65 | // If there are no specific nodes we want, show them all.
66 | for i, g := range show {
67 | if g != avoid {
68 | show[i] = follow
69 | }
70 | }
71 | } else if shown > 0 {
72 | // Find the first build step.
73 | start := -1
74 | for _, act := range actions {
75 | if act.Mode == "build" {
76 | start = act.ID
77 | break
78 | }
79 | }
80 | if start == -1 {
81 | return errors.New("no first build step")
82 | }
83 |
84 | // Show all nodes between the start and the other nodes we want to show.
85 | pathfind(start, show, func(n int) []int { return actions[n].Deps })
86 | }
87 |
88 | fmt.Fprintln(opt.stdout, "digraph {")
89 | for i, g := range show {
90 | if g != follow {
91 | continue
92 | }
93 | act := actions[i]
94 | fmt.Fprintf(opt.stdout, "%d [label=<%s>; shape=box];\n", i, ""+filepath.Dir(act.Package)+"
"+filepath.Base(act.Package)+"
"+act.Mode+" "+act.TimeDone.Sub(act.TimeStart).String())
95 |
96 | for _, dep := range act.Deps {
97 | if show[dep] != follow {
98 | continue
99 | }
100 | fmt.Printf("\t%d -> %d;\n", i, dep)
101 | }
102 | }
103 | fmt.Fprintln(opt.stdout, "}")
104 |
105 | return nil
106 | }
107 |
108 | const (
109 | avoid = -1
110 | unknown = 0
111 | follow = 1
112 | )
113 |
114 | func pathfind(start int, guide []int, edges func(int) []int) {
115 | stack := [][]int{{start}}
116 | for len(stack) > 0 {
117 | // Pop the stack.
118 | depth := len(stack) - 1
119 | n := stack[depth][0]
120 |
121 | switch guide[n] {
122 | case avoid:
123 | // Nothing.
124 | case unknown:
125 | // Step into the children.
126 | if deps := edges(n); len(deps) > 0 {
127 | stack = append(stack, deps)
128 | continue
129 | }
130 | case follow:
131 | // Mark the path to this point as followable.
132 | for i := range stack {
133 | guide[stack[i][0]] = follow
134 | }
135 | }
136 |
137 | // Trim the stack.
138 | for d := len(stack) - 1; d >= 0; d-- {
139 | s := stack[d]
140 | m := s[0]
141 | if guide[m] != follow {
142 | guide[m] = avoid
143 | }
144 |
145 | if len(s) == 1 {
146 | stack = stack[:d]
147 | continue
148 | }
149 | stack[d] = s[1:]
150 | break
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | txttpl "text/template"
11 | "time"
12 |
13 | "github.com/spf13/cobra"
14 | )
15 |
16 | func main() {
17 | err := run(os.Args[1:]...)
18 | if err != nil {
19 | fmt.Fprintf(os.Stderr, "actiongraph: %s\n", err)
20 | os.Exit(1)
21 | }
22 | }
23 |
24 | func run(args ...string) error {
25 | prog := &cobra.Command{
26 | Use: "actiongraph",
27 | SilenceUsage: true,
28 | SilenceErrors: true,
29 | }
30 |
31 | prog.PersistentFlags().StringP("file", "f", "-", "JSON file to read (use - for stdin)")
32 | prog.MarkFlagRequired("file")
33 | prog.RegisterFlagCompletionFunc("file", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
34 | return []string{"json"}, cobra.ShellCompDirectiveFilterFileExt
35 | })
36 |
37 | addTopCommand(prog)
38 | addTreeCommand(prog)
39 | addTypesCommand(prog)
40 | addGraphCommand(prog)
41 |
42 | prog.AddGroup(&cobra.Group{
43 | ID: "actiongraph",
44 | Title: "Actiongraph:",
45 | })
46 |
47 | prog.SetArgs(args)
48 | return prog.Execute()
49 | }
50 |
51 | type options struct {
52 | stdin io.Reader
53 | stdout io.Writer
54 | args []string
55 | funcs txttpl.FuncMap
56 | actions []action
57 | total time.Duration
58 | }
59 |
60 | func loadOptions(cmd *cobra.Command) (*options, error) {
61 | opt := options{
62 | stdin: cmd.InOrStdin(),
63 | stdout: cmd.OutOrStdout(),
64 | args: cmd.Flags().Args(),
65 |
66 | funcs: txttpl.FuncMap{
67 | "base": filepath.Base,
68 | "dir": filepath.Dir,
69 | "seconds": func(d time.Duration) string {
70 | return fmt.Sprintf("%.3fs", d.Seconds())
71 | },
72 | "percent": func(v float64) string {
73 | return fmt.Sprintf("%.2f%%", v)
74 | },
75 | "right": func(n int, s string) string {
76 | if len(s) > n {
77 | return s
78 | }
79 | return strings.Repeat(" ", n-len(s)) + s
80 | },
81 | },
82 | }
83 |
84 | // Open the actiongraph JSON file.
85 | fn, err := cmd.Flags().GetString("file")
86 | if err != nil {
87 | return nil, err
88 | }
89 | f, err := openFile(fn)
90 | if err != nil {
91 | return nil, err
92 | }
93 | defer f.Close()
94 |
95 | // Decode the actions.
96 | if err := json.NewDecoder(f).Decode(&opt.actions); err != nil {
97 | return nil, fmt.Errorf("decoding input: %w", err)
98 | }
99 |
100 | // A few top-level calculations.
101 | for i := range opt.actions {
102 | // TODO: Flag to look at CmdReal/CmdUser instead? We can use the Cmd
103 | // field being non-null to differentiate between cached and
104 | // non-cached steps, too.
105 | d := opt.actions[i].TimeDone.Sub(opt.actions[i].TimeStart)
106 | opt.actions[i].Duration = d
107 | opt.total += d
108 | }
109 | for i := range opt.actions {
110 | opt.actions[i].Percent = 100 * float64(opt.actions[i].Duration) / float64(opt.total)
111 | }
112 | return &opt, nil
113 | }
114 |
115 | func openFile(path string) (*os.File, error) {
116 | switch path {
117 | case "", "-", "/dev/stdin", "/dev/fd/0":
118 | return os.Stdin, nil
119 | default:
120 | return os.Open(path)
121 | }
122 | }
123 |
124 | type action struct {
125 | ID int
126 | Mode string
127 | Package string
128 | Deps []int
129 | Objdir string
130 | Target string
131 | Priority int
132 | Built string
133 | BuildID string
134 | TimeReady time.Time
135 | TimeStart time.Time
136 | TimeDone time.Time
137 | Cmd any
138 | ActionID string
139 | CmdReal int
140 | CmdUser int64
141 | CmdSys int
142 | NeedBuild bool
143 |
144 | Duration time.Duration
145 | Percent float64
146 | }
147 |
--------------------------------------------------------------------------------
/top.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 | "text/template"
7 | "time"
8 |
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | func addTopCommand(cmd *cobra.Command) {
13 | topCmd := cobra.Command{
14 | GroupID: "actiongraph",
15 | Use: "top [-f compile.json] [-n limit]",
16 | Short: "List slowest build steps",
17 | RunE: func(cmd *cobra.Command, args []string) error {
18 | opt, err := loadOptions(cmd)
19 | if err != nil {
20 | return err
21 | }
22 |
23 | flags := cmd.Flags()
24 | limit, err := flags.GetInt("limit")
25 | if err != nil {
26 | return err
27 | }
28 |
29 | tplStr, err := flags.GetString("tpl")
30 | if err != nil {
31 | return err
32 | }
33 | tpl, err := template.New("top").Funcs(opt.funcs).Parse(tplStr)
34 | if err != nil {
35 | return fmt.Errorf("parsing tpl: %w", err)
36 | }
37 |
38 | return top(opt, limit, tpl)
39 | },
40 | }
41 | flags := topCmd.Flags()
42 | flags.IntP("limit", "n", 20, "number of slowest build steps to show")
43 | flags.String("tpl", `{{ .Duration | seconds | right 8 }}{{ .CumulativePercent | percent | right 8 }} {{.Mode}} {{.Package}}`, "template for output")
44 | cmd.AddCommand(&topCmd)
45 | }
46 |
47 | func top(opt *options, limit int, tpl *template.Template) error {
48 | actions := opt.actions
49 |
50 | sort.Slice(actions, func(i, j int) bool {
51 | return actions[i].Duration >= actions[j].Duration
52 | })
53 |
54 | var cum time.Duration
55 | for i, node := range actions {
56 | if limit > 0 && i >= limit {
57 | break
58 | }
59 |
60 | cum += node.Duration
61 | err := tpl.Execute(opt.stdout, topAction{
62 | action: node,
63 | CumulativePercent: 100 * float64(cum) / float64(opt.total),
64 | })
65 | if err != nil {
66 | return err
67 | }
68 | fmt.Fprintln(opt.stdout)
69 | }
70 | return nil
71 | }
72 |
73 | type topAction struct {
74 | action
75 | CumulativePercent float64
76 | }
77 |
--------------------------------------------------------------------------------
/tree.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "text/template"
7 | "time"
8 |
9 | "github.com/spf13/cobra"
10 | "golang.org/x/exp/maps"
11 | "golang.org/x/exp/slices"
12 | )
13 |
14 | func addTreeCommand(prog *cobra.Command) {
15 | cmd := cobra.Command{
16 | GroupID: "actiongraph",
17 | Use: "tree [-m] [-f compile.json] [package...]",
18 | Short: "Total build times by directory",
19 | Args: cobra.ArbitraryArgs,
20 | RunE: func(cmd *cobra.Command, args []string) error {
21 | opt, err := loadOptions(cmd)
22 | if err != nil {
23 | return err
24 | }
25 |
26 | flags := cmd.Flags()
27 | level, err := flags.GetInt("level")
28 | if err != nil {
29 | return nil
30 | }
31 |
32 | tplStr, err := flags.GetString("tpl")
33 | if err != nil {
34 | return err
35 | }
36 | tpl, err := template.New("top").Funcs(opt.funcs).Parse(tplStr)
37 | if err != nil {
38 | return fmt.Errorf("parsing tpl: %w", err)
39 | }
40 |
41 | return tree(opt, level, args, tpl)
42 | },
43 | }
44 |
45 | flags := cmd.Flags()
46 | flags.IntP("level", "L", -1, "descend only level directories deep (-ve for unlimited)")
47 | flags.String("tpl", `{{ .CumulativeDuration | seconds | right 8 }} {{ if eq .ID -1 }} {{ else }}{{ .Duration | seconds | right 8 }}{{ end }} {{.Indent}}{{.Package}}`, "template for output")
48 |
49 | prog.AddCommand(&cmd)
50 | }
51 |
52 | func tree(opt *options, level int, focus []string, tpl *template.Template) error {
53 | actions := opt.actions
54 | root := buildTree(actions)
55 |
56 | if len(focus) != 0 {
57 | filterActs := make([]action, len(focus))
58 | for i, pkg := range focus {
59 | filterActs[i] = action{
60 | ID: 0, // buildTree and pruneTree use -1 for intermediary nodes.
61 | Mode: "build", // buildTree ignores non-build actions.
62 | Package: strings.TrimRight(pkg, "/."),
63 | }
64 | }
65 | pruneTree(root, buildTree(filterActs))
66 | }
67 |
68 | dirs := append(make([][]*pkgtree, 0, 10), []*pkgtree{root})
69 | for len(dirs) > 0 {
70 | // Step up from empty paths.
71 | last := len(dirs) - 1
72 | if len(dirs[last]) == 0 {
73 | dirs = dirs[:last]
74 | continue
75 | }
76 |
77 | // Take the next node.
78 | n := dirs[last][0]
79 | dirs[last] = dirs[last][1:]
80 | if level >= 0 && n.depth > level {
81 | continue
82 | }
83 |
84 | // Display the node.
85 | node := treeAction{
86 | ID: n.id,
87 | Package: n.path,
88 | Depth: n.depth,
89 | Indent: strings.Repeat(" ", last),
90 | CumulativePercent: 100 * float64(n.d) / float64(opt.total),
91 | CumulativeDuration: n.d,
92 | }
93 | if n.id > 0 {
94 | node.action = actions[n.id]
95 | }
96 | err := tpl.Execute(opt.stdout, node)
97 | if err != nil {
98 | return err
99 | }
100 | fmt.Fprintln(opt.stdout)
101 |
102 | // Step into the children.
103 | if len(n.dir) > 0 {
104 | kids := maps.Values(n.dir)
105 | slices.SortFunc(kids, func(a, b *pkgtree) bool { return a.d > b.d })
106 | dirs = append(dirs, kids)
107 | continue
108 | }
109 | }
110 | return nil
111 | }
112 |
113 | type pkgtree struct {
114 | path string
115 | depth int
116 | d time.Duration
117 | id int
118 |
119 | dir map[string]*pkgtree
120 | }
121 |
122 | func buildTree(actions []action) *pkgtree {
123 | root := pkgtree{
124 | path: "(root)",
125 | id: -1,
126 | }
127 |
128 | // Loop over each built package path.
129 | for _, act := range actions {
130 | if act.Mode != "build" {
131 | continue
132 | }
133 |
134 | // Assume all packages without a "." are part of the standard library.
135 | // TODO: Go modules don't need to start with a domain, so this is wrong.
136 | pkg := act.Package
137 | if isStdlib(pkg) {
138 | pkg = "std/" + pkg
139 | }
140 |
141 | // Create the tree of nodes for this one package.
142 | actNode := &root
143 | actNode.d += act.Duration
144 | p := 0
145 | depth := 0
146 | for more := true; more; {
147 | depth++
148 |
149 | // Read the next deepest path from pkg.
150 | pn := strings.Index(pkg[p+1:], "/")
151 | if pn == -1 {
152 | p = len(pkg)
153 | more = false
154 | } else {
155 | p += pn + 1
156 | }
157 | path := pkg[:p]
158 |
159 | // Ensure a node for this path exists.
160 | if actNode.dir == nil {
161 | actNode.dir = make(map[string]*pkgtree, 1)
162 | }
163 | p := actNode.dir[path]
164 | if p == nil {
165 | p = &pkgtree{
166 | id: -1,
167 | path: path,
168 | depth: depth,
169 | }
170 | actNode.dir[path] = p
171 | }
172 |
173 | // Descend into the node for this path.
174 | actNode = p
175 | actNode.d += act.Duration
176 | }
177 |
178 | actNode.id = act.ID
179 | }
180 | return &root
181 | }
182 |
183 | func isStdlib(pkg string) bool {
184 | root, _, _ := strings.Cut(pkg, "/")
185 | return !strings.Contains(root, ".")
186 | }
187 |
188 | // pruneTree removes dir grandchildren from root that are not in keep and resets
189 | // the depth according to the closest grandchild in keep.
190 | func pruneTree(root, keep *pkgtree) {
191 | type job struct{ r, k *pkgtree }
192 | work := make([]job, 0, 10)
193 | work = append(work, job{root, keep})
194 |
195 | // TODO: actiongraph tree pkg1 pkg1/pkg2/pkg3 -L 0 should show
196 | // pkg1/pkg2/pkg3 but we don't currently traverse past pkg1.
197 | for len(work) > 0 {
198 | j := work[len(work)-1]
199 | work = work[:len(work)-1]
200 |
201 | // TODO: Can we consolidate these two cases?
202 | if j.k.id == -1 {
203 | // Branch nodes in keep have ID -1. We want to kepe the common
204 | // children of root and keep.
205 | for path, rChild := range j.r.dir {
206 | rChild.depth = 0
207 | kChild := j.k.dir[path]
208 | if kChild == nil {
209 | delete(j.r.dir, path)
210 | } else {
211 | work = append(work, job{rChild, kChild})
212 | }
213 | }
214 | } else {
215 | // This path was explicitly added to keep. We want to reset the
216 | // depth of each child from this point.
217 | for path, rChild := range j.r.dir {
218 | rChild.depth -= j.k.depth
219 | kChild := j.k.dir[path]
220 | if kChild == nil {
221 | kChild = &pkgtree{
222 | id: 0,
223 | path: path,
224 | depth: j.k.depth, // Keep this depth.
225 | }
226 | }
227 | work = append(work, job{rChild, kChild})
228 | }
229 | }
230 | }
231 | }
232 |
233 | type treeAction struct {
234 | ID int
235 | Package string
236 | Indent string
237 | Depth int
238 | CumulativeDuration time.Duration
239 | CumulativePercent float64
240 | action
241 | }
242 |
--------------------------------------------------------------------------------
/types.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 | "text/template"
7 | "time"
8 |
9 | "golang.org/x/exp/maps"
10 |
11 | "github.com/spf13/cobra"
12 | )
13 |
14 | func addTypesCommand(cmd *cobra.Command) {
15 | topCmd := cobra.Command{
16 | GroupID: "actiongraph",
17 | Use: "types [-f compile.json] [-n limit]",
18 | Short: "List slowest action types",
19 | RunE: func(cmd *cobra.Command, args []string) error {
20 | opt, err := loadOptions(cmd)
21 | if err != nil {
22 | return err
23 | }
24 |
25 | flags := cmd.Flags()
26 |
27 | tplStr, err := flags.GetString("tpl")
28 | if err != nil {
29 | return err
30 | }
31 | tpl, err := template.New("top").Funcs(opt.funcs).Parse(tplStr)
32 | if err != nil {
33 | return fmt.Errorf("parsing tpl: %w", err)
34 | }
35 |
36 | return typesTop(opt, tpl)
37 | },
38 | }
39 | flags := topCmd.Flags()
40 | flags.String("tpl", `{{ .Duration | seconds | right 8 }}{{ .Percentage | percent | right 8 }} {{.Mode}}`, "template for output")
41 | cmd.AddCommand(&topCmd)
42 | }
43 |
44 | func typesTop(opt *options, tpl *template.Template) error {
45 | actions := opt.actions
46 | types := map[string]typesAction{}
47 | var cum time.Duration
48 | for _, node := range actions {
49 | cum += node.Duration
50 | ta, f := types[node.Mode]
51 | if !f {
52 | ta = typesAction{Mode: node.Mode}
53 | }
54 | ta.Duration += node.Duration
55 | ta.Percentage = 100 * float64(ta.Duration) / float64(opt.total)
56 | types[node.Mode] = ta
57 | }
58 | actionTypes := maps.Values(types)
59 | sort.Slice(actionTypes, func(i, j int) bool {
60 | return actionTypes[i].Duration >= actionTypes[j].Duration
61 | })
62 |
63 | for _, node := range actionTypes {
64 | err := tpl.Execute(opt.stdout, node)
65 | if err != nil {
66 | return err
67 | }
68 | fmt.Fprintln(opt.stdout)
69 | }
70 | return nil
71 | }
72 |
73 | type typesAction struct {
74 | Mode string
75 | Duration time.Duration
76 | Percentage float64
77 | }
78 |
--------------------------------------------------------------------------------