Background
I want to build a tool like scc or tokei, just for learning. Writing a simple version was very easy: tally - first commit. I'll optimize it in the second half of this post.
First, allow me to explain it to you.
Steps
The counting machine works a lot like the classic "putting an elephant in the freezer" analogy:
- Walk the directory tree.
- Read files and count lines.
- Output the results.
Walk directory
filepath.Walk makes walking the directory tree very easy:
filepath.Walk(os.Args[1], func(path string, info os.FileInfo, err error) error {
if err != nil {
panic(err)
}
if info.IsDir() {
return nil
}
return countLine(path)
})
Read and count
To count lines, we first need to define what a line is: a string ending with \n or \r\n. So we can split the file content on \n (or \r\n) to get the lines.
Since we're counting code, we also need to ignore comment lines. I used a simple approach: ignore any line that starts with a rule string. (By the way, the first version only handled single-line comments.)
type Counter struct {
idx int
lang string
comment string
exts []string
}
var (
Go = Counter{1, "Go", "//", vec(".go")}
Rust = Counter{2, "Rust", "//", vec(".rs")}
Java = Counter{3, "Java", "//", vec(".java")}
Python = Counter{4, "Python", "#", vec(".py")}
C = Counter{5, "C", "//", vec(".c", ".h")}
Cpp = Counter{6, "C++", "//", vec(".cpp", ".hpp")}
Js = Counter{7, "Javascript", "//", vec(".js")}
Ts = Counter{8, "Typescript", "//", vec(".ts")}
HTML = Counter{9, "HTML", "//", vec(".html", ".htm")}
JSON = Counter{10, "JSON", "//", vec(".json")}
Protobuf = Counter{11, "Protobuf", "//", vec(".proto")}
Markdown = Counter{12, "Markdown", "//", vec(".md")}
Shell = Counter{13, "Shell", "#", vec(".sh")}
YAML = Counter{14, "YAML", "#", vec(".yaml", ".yml")}
)
vecis a helper that creates a slice — a little nostalgia for Rust'svec!:p
Count line logic:
func countLine(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
c := guessLang(path)
if c.lang == "" {
return nil
}
lines := bytes.Split(data, []byte("\n"))
item := Item{
lang: c.lang,
lines: len(lines),
files: 1,
}
for _, line := range lines {
line := bytes.TrimSpace(line)
if len(line) == 0 {
item.blank++
continue
}
if c.isComment(line) {
item.comment++
continue
}
item.code++
}
result.Add(c, item)
return nil
}
Output style
Actually, this was the hardest part — presenting results in a readable way. Thanks to tokei and scc, I just copied their output format :smile:.
func (r *Result) String() {
itemF := "%-10s %10d %10d %10d %10d %10d\n"
headerF := "%-10s %10s %10s %10s %10s %10s\n"
fmt.Printf(strings.Repeat("━", 65) + "\n")
fmt.Printf(headerF, "Language", "Files", "Lines", "Code", "Comments", "Blanks")
fmt.Printf(strings.Repeat("━", 65) + "\n")
var total Item
sort.Slice(r.data, func(i, j int) bool {
return r.data[i].lines > r.data[j].lines
})
for _, item := range r.data {
if item.files == 0 {
continue
}
total = mergeItem(total, item)
fmt.Printf(itemF, item.lang, item.files, item.lines, item.code, item.comment, item.blank)
}
fmt.Printf(strings.Repeat("━", 65) + "\n")
fmt.Printf(itemF, "Total", total.files, total.lines, total.code, total.comment, total.blank)
fmt.Printf(strings.Repeat("━", 65) + "\n")
}
Let's test it.
tally .
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Language Files Lines Code Comments Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Go 1 242 199 0 43
Markdown 1 3 2 0 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total 2 245 201 0 44
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
It looks very good :)
Next, I'll benchmark and optimize it.
Benchmark
Using the time command for benchmarks isn't accurate enough.
stackoverflow - Is the UNIX
timecommand accurate enough for benchmarks? [closed]
TLDR: use
perf statfor benchmarks:
perf stat -r 10 -d <CMD>
There are plenty of powerful benchmarking tools.
I use hyperfine for benchmarks at my MacBook Pro 16 (2019) machine.
hyperfine supports a warmup phase to reduce noise, compares multiple commands, and can export results as markdown. (Very nice! :smile:)
hyperfine 'tokei .' 'scc .' 'tally .' --warmup 3 --export-markdown bench.md
Small repo
I benchmarked against the https://github.com/firecracker-microvm/firecracker repo.
Results:
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
tokei . |
36.2 ± 6.3 | 25.0 | 56.5 | 1.00 |
scc . |
41.0 ± 7.7 | 28.0 | 58.9 | 1.13 ± 0.29 |
tally . |
114.4 ± 4.8 | 108.5 | 123.6 | 3.16 ± 0.57 |
The original version isn't fast enough, but it's still usable.
Now let's try a large repo.
Large repo
Use https://github.com/moby/moby
Results:
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
tokei . |
447.5 ± 60.5 | 385.4 | 541.4 | 1.30 ± 0.21 |
scc . |
343.7 ± 29.4 | 296.0 | 399.3 | 1.00 |
tally . |
1038.1 ± 77.2 | 947.3 | 1173.8 | 3.02 ± 0.34 |
There's a fairly large gap between tokei/scc and tally.
Optimize1 - Improve the code
My first version was very simple: it only supported inline comments and used split to count lines.
There are two optimization points:
- Use
bufio.Scannerto read lines - Support multiple comments
See the commits https://github.com/abcdlsj/share/commits/master/go/tally for both.
There's nothing to say here.
Compare
- Small repo
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
tally . |
70.2 ± 5.9 | 56.4 | 86.1 | 1.00 |
tally1 . |
119.9 ± 11.3 | 108.5 | 151.2 | 1.71 ± 0.22 |
- Large repo
| Command | Mean [s] | Min [s] | Max [s] | Relative |
|---|---|---|---|---|
tally . |
1.073 ± 0.129 | 0.892 | 1.295 | 1.00 |
tally1 . |
1.113 ± 0.139 | 0.928 | 1.277 | 1.04 ± 0.18 |
The gap is much smaller on the small repo — bufio.Scanner is effective there — but on the large repo it barely matters.
Optimize2 - Faster filepath walking
According to this post, the official filepath.Walk is already fast enough. I also want tally to have zero third-party dependencies, so I won't pull in a faster walkdir library.
I currently use filepath.Walk; switching to filepath.WalkDir would be a little faster.
> hyperfine 'tally .' 'tally_walkdir .' --warmup 3
Benchmark 1: tally .
Time (mean ± σ): 66.4 ms ± 6.6 ms [User: 21.8 ms, System: 43.0 ms]
Range (min … max): 58.0 ms … 82.8 ms 37 runs
Benchmark 2: tally_walkdir .
Time (mean ± σ): 62.8 ms ± 8.1 ms [User: 21.5 ms, System: 39.1 ms]
Range (min … max): 51.8 ms … 89.9 ms 43 runs
Summary
tally_walkdir . ran
1.06 ± 0.17 times faster than tally .
(It's only a small improvement, so I won't commit it.)
Optimize3 - Parallelism
Fanout
Go makes concurrency easy. I used a fan-out pattern to distribute files to workers.
The steps are:
- Pass files through a
channel. - The directory walk sends files to the
channelfirst. - Multiple workers read files from the
channel. sync.WaitGroupwaits for the workers to finish.- A
mutexprotects the shared result data.
These are changes:
diff --git a/go/tally/main.go b/go/tally/main.go
index f184b31..23f5fae 100644
--- a/go/tally/main.go
+++ b/go/tally/main.go
@@ -6,8 +6,10 @@ import (
"fmt"
"os"
"path/filepath"
+ "runtime"
"sort"
"strings"
+ "sync"
)
type Counter struct {
@@ -48,18 +50,29 @@ func init() {
}
}
- result = NewResult()
+ result = &Result{
+ data: make([]Item, registedNum),
+ }
}
var result *Result
-func main() {
- if len(os.Args) < 2 {
- fmt.Println("Usage: tally <path>")
- os.Exit(1)
+var fileChan = make(chan string, 100)
+
+func process(dir string) {
+ var wg sync.WaitGroup
+ wg.Add(runtime.NumCPU() * 2)
+
+ for i := 0; i < runtime.NumCPU()*2; i++ {
+ go func() {
+ defer wg.Done()
+ for file := range fileChan {
+ countLine(file)
+ }
+ }()
}
- filepath.Walk(os.Args[1], func(path string, info os.FileInfo, err error) error {
+ filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
panic(err)
}
@@ -68,9 +81,24 @@ func main() {
return nil
}
- return countLine(path)
+ fileChan <- path
+
+ return nil
})
+ close(fileChan)
+
+ wg.Wait()
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ fmt.Println("Usage: tally <path>")
+ os.Exit(1)
+ }
+
+ process(os.Args[1])
+
result.String()
}
@@ -95,16 +123,13 @@ func mergeItem(a, b Item) Item {
}
type Result struct {
+ mu sync.Mutex
data []Item
}
-func NewResult() *Result {
- return &Result{
- data: make([]Item, registedNum),
- }
-}
-
func (r *Result) Add(c Counter, item Item) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
r.data[c.idx-1] = mergeItem(r.data[c.idx-1], item)
}
@@ -174,6 +199,7 @@ func guessLang(file string) Counter {
func countLine(path string) error {
f, err := os.Open(path)
scanner := bufio.NewScanner(f)
+
if err != nil {
return err
}
The fileChan buffer is 100 and the worker count is 2 × the CPU core count. I didn't do much tuning here.
The general rule of thumb:
- For CPU-bound tasks, workers shouldn't exceed the number of logical CPU cores — more workers only adds context-switching overhead.
- For I/O-bound tasks, more workers than cores usually helps, because blocked I/O lets other workers proceed.
Result
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
tally . |
65.6 ± 6.1 | 56.8 | 85.1 | 1.89 ± 0.33 |
tally_fanout . |
34.8 ± 5.2 | 26.9 | 53.2 | 1.00 |
The fan-out pattern speeds things up a lot.
Optimize4 - Use Pprof
TODO...
End
This post is my learning journey — the source is at github - abcdlsj/tally.
I'll benchmark and optimize more in the future.
Ref
https://github.com/boyter/scc/
https://boyter.org/posts/sloc-cloc-code/
https://blog.burntsushi.net/ripgrep/
https://engineering.kablamo.com.au/posts/2021/quick-comparison-between-go-file-walk-implementations/