Skip to content

Commit a674b0b

Browse files
authored
Merge pull request #286 from plasma-umass/rust-support-verify
Fix Rust support on macOS and Linux
2 parents 6fe62be + 1d581df commit a674b0b

21 files changed

Lines changed: 1029 additions & 53 deletions

.github/scripts/check_profile.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env python3
2+
"""Assert that a coz profile contains experiments attributed to a source file.
3+
4+
Usage: check_profile.py <profile.jsonl> <expected-source-substring>
5+
6+
Used by the per-language CI jobs. A binding is only "working" if libcoz both
7+
registered its progress points and attributed experiments back to that
8+
language's source, so this checks for experiment records naming the file.
9+
"""
10+
import collections
11+
import json
12+
import sys
13+
14+
15+
def main() -> int:
16+
if len(sys.argv) != 3:
17+
print(f"usage: {sys.argv[0]} <profile.jsonl> <expected-source-substring>")
18+
return 2
19+
20+
path, expected = sys.argv[1], sys.argv[2]
21+
22+
records = []
23+
with open(path) as handle:
24+
for line in handle:
25+
line = line.strip()
26+
if line:
27+
records.append(json.loads(line))
28+
29+
kinds = collections.Counter(r["type"] for r in records)
30+
print(f"record types: {dict(kinds)}")
31+
32+
experiments = [r for r in records if r["type"] == "experiment"]
33+
if not experiments:
34+
print("ERROR: profile contains no experiments")
35+
return 1
36+
37+
matching = [e for e in experiments if expected in e.get("selected", "")]
38+
selected = collections.Counter(e.get("selected", "") for e in experiments)
39+
print(f"experiments: {len(experiments)} ({len(matching)} naming {expected!r})")
40+
for location, count in selected.most_common(10):
41+
print(f" {count:4d} {location}")
42+
43+
if not matching:
44+
print(f"ERROR: no experiment selected a line in {expected!r}")
45+
return 1
46+
47+
print(f"OK: {len(matching)} experiments attributed to {expected!r}")
48+
return 0
49+
50+
51+
if __name__ == "__main__":
52+
sys.exit(main())

.github/workflows/ci.yml

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
pull_request:
77
branches: [master]
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
build-linux:
1114
runs-on: ubuntu-latest
@@ -292,3 +295,108 @@ jobs:
292295
fi
293296
echo "Profile created:"
294297
cat profile.jsonl
298+
299+
lang-rust:
300+
name: Rust bindings (${{ matrix.os }})
301+
runs-on: ${{ matrix.os }}
302+
strategy:
303+
fail-fast: false
304+
matrix:
305+
os: [ubuntu-latest, macos-latest]
306+
307+
steps:
308+
- uses: actions/checkout@v4
309+
310+
- name: Install dependencies (Linux)
311+
if: runner.os == 'Linux'
312+
run: |
313+
sudo apt-get update
314+
sudo apt-get install -y build-essential cmake pkg-config
315+
echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid
316+
317+
- name: Install dependencies (macOS)
318+
if: runner.os == 'macOS'
319+
run: brew install pkg-config coreutils || true
320+
321+
- name: Build coz
322+
run: |
323+
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
324+
cmake --build build -j3
325+
test -f build/libcoz/libcoz.so || test -f build/libcoz/libcoz.dylib
326+
327+
- name: Check the crate on both platforms
328+
working-directory: rust
329+
run: |
330+
cargo build --release --examples
331+
cargo test --release
332+
333+
- name: Profile the Rust toy under coz
334+
run: |
335+
# `timeout` is GNU-only; coreutils provides gtimeout on macOS.
336+
TIMEOUT=timeout
337+
command -v gtimeout >/dev/null && TIMEOUT=gtimeout
338+
# rust/examples/toy runs ~9e6 work units of 10k iterations each across
339+
# two threads (~1-2 minutes on a 2-core runner); the timeout is a
340+
# safety net since experiments are written to the profile as they run.
341+
$TIMEOUT 300 ./coz run -o profile.jsonl --- ./rust/target/release/examples/toy || true
342+
test -s profile.jsonl
343+
344+
- name: Validate the Rust profile
345+
run: python3 .github/scripts/check_profile.py profile.jsonl toy.rs
346+
347+
lang-go:
348+
name: Go bindings (${{ matrix.os }})
349+
runs-on: ${{ matrix.os }}
350+
strategy:
351+
fail-fast: false
352+
matrix:
353+
os: [ubuntu-latest, macos-latest]
354+
355+
steps:
356+
- uses: actions/checkout@v4
357+
358+
- uses: actions/setup-go@v5
359+
with:
360+
go-version: '1.26'
361+
362+
- name: Install dependencies (Linux)
363+
if: runner.os == 'Linux'
364+
run: |
365+
sudo apt-get update
366+
sudo apt-get install -y build-essential cmake pkg-config
367+
echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid
368+
369+
- name: Install dependencies (macOS)
370+
if: runner.os == 'macOS'
371+
run: brew install pkg-config coreutils || true
372+
373+
- name: Build coz
374+
run: |
375+
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
376+
cmake --build build -j3
377+
test -f build/libcoz/libcoz.so || test -f build/libcoz/libcoz.dylib
378+
379+
- name: Vet, format and test
380+
working-directory: go
381+
run: |
382+
test -z "$(gofmt -l .)" || { echo "gofmt found unformatted files:"; gofmt -l .; exit 1; }
383+
go vet ./...
384+
go test -race ./...
385+
# The package must also build with cgo off, degrading to no-ops.
386+
CGO_ENABLED=0 go build ./...
387+
388+
- name: Build the Go toy
389+
working-directory: go
390+
run: |
391+
# coz cannot read Go's compressed DWARF (.zdebug_* / __zdebug_*).
392+
go build -ldflags=-compressdwarf=false -o "$RUNNER_TEMP/gotoy" ./example
393+
394+
- name: Profile the Go toy under coz
395+
run: |
396+
TIMEOUT=timeout
397+
command -v gtimeout >/dev/null && TIMEOUT=gtimeout
398+
$TIMEOUT 180 ./coz run -o profile.jsonl --- "$RUNNER_TEMP/gotoy" || true
399+
test -s profile.jsonl
400+
401+
- name: Validate the Go profile
402+
run: python3 .github/scripts/check_profile.py profile.jsonl toy.go

go/README.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# coz-go
2+
3+
Go support for the [`coz` causal profiler](https://github.com/plasma-umass/coz).
4+
5+
A traditional profiler tells you *where* your program spends time. Coz tells you
6+
whether optimizing a line would actually make the program faster — which, in
7+
concurrent code, is a different question.
8+
9+
Works on **Linux** and **macOS**.
10+
11+
## Usage
12+
13+
First [install `coz`](https://github.com/plasma-umass/coz#installation), then add
14+
the module:
15+
16+
```
17+
go get github.com/plasma-umass/coz/go
18+
```
19+
20+
Mark the points where your program makes progress. For throughput — "I wish this
21+
happened more often":
22+
23+
```go
24+
import coz "github.com/plasma-umass/coz/go"
25+
26+
for _, req := range requests {
27+
handle(req)
28+
coz.Progress() // equivalent of COZ_PROGRESS
29+
}
30+
```
31+
32+
`coz.ProgressNamed("requests")` is the equivalent of `COZ_PROGRESS_NAMED`.
33+
34+
For latency — "I wish this finished sooner" — mark both ends of the operation:
35+
36+
```go
37+
func handle(req Request) {
38+
defer coz.Scope("request")() // COZ_BEGIN now, COZ_END on return
39+
// ...
40+
}
41+
```
42+
43+
`Scope` fires its end counter even on an early return or a panic. If you need the
44+
two halves apart, `coz.Begin("request")` and `coz.End("request")` are available.
45+
46+
On a hot path you can hoist the counter lookup out of the loop:
47+
48+
```go
49+
requests := coz.NewThroughput("requests")
50+
for _, req := range reqs {
51+
handle(req)
52+
requests.Increment()
53+
}
54+
```
55+
56+
`coz.Available()` reports whether the program is running under `coz run`.
57+
58+
## Building and running
59+
60+
Profiling requires **cgo** (`CGO_ENABLED=1`, the default when a C toolchain is
61+
present), because reaching libcoz means calling `dlsym`. With `CGO_ENABLED=0`
62+
this package still builds — every entry point compiles to a no-op — so you can
63+
leave progress points in code that also ships as a static binary.
64+
65+
Coz needs DWARF line tables, and it cannot read the compressed DWARF that Go's
66+
linker emits by default. Build with compression off:
67+
68+
```
69+
go build -ldflags=-compressdwarf=false -o myapp .
70+
coz run --- ./myapp
71+
coz plot --text
72+
```
73+
74+
Without `-compressdwarf=false`, Go emits `.zdebug_*` (ELF) / `__zdebug_*`
75+
(Mach-O) sections and coz will report "Debug information was not found."
76+
77+
If line attribution looks coarse because of inlining, also pass
78+
`-gcflags=all=-l` to disable inlining, or `-gcflags=all='-N -l'` to disable
79+
optimization entirely. Both change the performance profile of the program, so
80+
prefer leaving them off unless you need the extra source fidelity.
81+
82+
## Example
83+
84+
`example/toy.go` runs two goroutines per round; one does twice the work of the
85+
other, so it sits on the critical path.
86+
87+
```
88+
go build -ldflags=-compressdwarf=false -o toy ./example
89+
coz run --- ./toy
90+
coz plot --text
91+
```
92+
93+
```
94+
Source Line | Slope | R² | Max Speedup | Points
95+
---------------------------+---------+-------+-------------+-------
96+
go/example/toy.go:35 | 1.082 | 0.91 | + 29.9% | 4
97+
go/example/toy.go:41 | 0.137 | 1.00 | + 8.9% | 2
98+
```
99+
100+
Line 35 is the loop inside `slowWork` and line 41 the loop inside `fastWork`.
101+
Coz correctly predicts that speeding up `slowWork` speeds up the program roughly
102+
proportionally, while `fastWork` is nearly irrelevant.
103+
104+
## Caveats
105+
106+
**Do not use `runtime/pprof` CPU profiling at the same time.** Coz samples with
107+
`SIGPROF`, which is the same signal Go's own CPU profiler uses; the two cannot
108+
both own it. Calling `pprof.StartCPUProfile` under `coz run` will fight coz for
109+
the signal. Memory, block, and mutex profiles are unaffected.
110+
111+
**Results are per-OS-thread, not per-goroutine.** Coz's virtual speedup works by
112+
delaying threads. Go multiplexes goroutines onto OS threads (`GOMAXPROCS`), so a
113+
progress point tells you about the thread that executed it, and a goroutine that
114+
migrates between threads is not tracked as a unit. In practice this is fine for
115+
CPU-bound work, but a goroutine that is descheduled mid-operation may attribute
116+
some of its delay elsewhere.
117+
118+
**Progress points must be reached often enough.** Coz needs at least a handful of
119+
progress-point visits per experiment (roughly 5), and each experiment runs for
120+
about half a second. A program that reaches its progress point a dozen times
121+
total will produce very few data points.
122+
123+
**On Linux, `coz plot` reports `Runtime: 0.0s` for Go programs.** Go's runtime
124+
exits with a direct `exit_group` syscall rather than libc's `exit`, so coz's
125+
shutdown hook never runs and the final `runtime` record is not written. The
126+
per-experiment records are flushed as they complete, so the profile itself is
127+
intact; only the total-runtime line (used for phase correction) is missing.
128+
129+
## How it works
130+
131+
`coz_shim.c` resolves `_coz_get_counter` and `_coz_add_delays` out of the
132+
injected `libcoz` with `dlsym(RTLD_DEFAULT, ...)`. That means the package does
133+
not need `coz.h` installed at build time, and a binary built against it runs
134+
normally when `libcoz` is absent — every entry point degrades to a no-op.
135+
136+
Incrementing a counter is a plain relaxed atomic add on memory owned by `libcoz`,
137+
performed from Go, so there is no cgo call on the hot path on Linux. On macOS
138+
each progress point additionally calls `_coz_add_delays()`, because macOS has no
139+
per-thread sampling timer and a worker thread only discovers the virtual delay it
140+
owes when it reaches a progress point. This mirrors `_COZ_CHECK_DELAYS` in
141+
`include/coz.h`.

0 commit comments

Comments
 (0)