Skip to content
Open
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
19 changes: 19 additions & 0 deletions .github/llgo-benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ include:
- "^BenchmarkChannel(Buffered|Handoff)$"
- "^BenchmarkTimer(AfterFuncZeroDelivery|CreateStop|RearmStopped|ResetActive|ResetHeap1024)/(Go|LLGo)$"
- "^BenchmarkStringEquality/"
- "^BenchmarkGoroutineLifecycle/"
groups:
programs: "^Program/"
compiler: "^(MergeCompilerFlags|MergeLinkerFlags|LookupPCRandom)$"
Expand All @@ -18,6 +19,7 @@ groups:
- "^(RuntimeGetG|Global(Read|Write))$"
- "^(DirectCall|InterfaceCall|Defer|Goroutine)$"
- "^Channel(Buffered|Handoff)$"
goroutines: "^GoroutineLifecycle/"
timers: "^Timer(AfterFuncZeroDelivery|CreateStop|RearmStopped|ResetActive|ResetHeap1024)/(Go|LLGo)$"
strings: "^StringEquality/"
views:
Expand Down Expand Up @@ -87,3 +89,20 @@ views:
benchmark:
title: Operation and runtime
trim-prefix: BenchmarkTimer
goroutines:
title: Goroutine creation and serial dispatch
select:
groups: "^goroutines$"
metrics: "^ns/op$"
table:
rows: [platform, benchmark]
columns: [metric]
# Eight native platforms and 16 cases produce 128 rows.
max-rows: 200
collapsed: true
missing: error
empty: error
dimensions:
benchmark:
title: Operation, batch size and runtime
trim-prefix: BenchmarkGoroutineLifecycle/
16 changes: 16 additions & 0 deletions benchmark/baseline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ byte mismatches use fixed one-million-call samples with checked results. LLGo
uses `-O3 -lto=full`; Go uses its defaults. The remaining conversion cost is
included when measuring a compiler that has not eliminated that conversion.

The `benchmark/goroutine` suite measures two workloads with native Go and LLGo.
In `create-and-wait/goroutines=N`, each timed iteration creates N goroutines,
each performing one atomic increment, and waits for the whole batch to finish.
In `serial-dispatch/tasks=N`, each iteration sends one batch to a single
pre-existing goroutine, which performs N atomic increments serially and sends a
completion notification. The worker reuses its request and completion channels.
The sizes are 1, 16, 128, and 1,024. The reported `ns/op` covers one complete batch
in each workload. Compare each case against the same case in the PR base to
measure compiler and runtime changes.

Each case completes one warmup batch before resetting the timer. Creation uses
ten fixed iterations per sample to bound native thread creation. Serial dispatch
uses calibrated 250 ms samples. Both workloads collect five samples. Native Go
uses one P. LLGo retains its native execution model, which currently does not
limit goroutine execution through `GOMAXPROCS`.

Once the timer selectors are present on the target branch, a paired pull-request
result compiles `/LLGo` once from the merge base and once from the head, while
`/Go` is repeated as a same-runner control. A timer-runtime pull request whose
Expand Down
16 changes: 16 additions & 0 deletions benchmark/baseline/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ var expectedGoBenchmarks = append([]string{
"BenchmarkMergeCompilerFlags",
"BenchmarkMergeLinkerFlags",
"BenchmarkRuntimeGetG",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=1/Go",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=1/LLGo",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=16/Go",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=16/LLGo",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=128/Go",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=128/LLGo",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=1024/Go",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=1024/LLGo",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=1/Go",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=1/LLGo",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=16/Go",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=16/LLGo",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=128/Go",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=128/LLGo",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=1024/Go",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=1024/LLGo",
"BenchmarkTimerAfterFuncZeroDelivery/Go",
"BenchmarkTimerAfterFuncZeroDelivery/LLGo",
"BenchmarkTimerCreateStop/Go",
Expand Down
2 changes: 2 additions & 0 deletions benchmark/baseline/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ func TestExportBenchmarks(t *testing.T) {
"BenchmarkProgram/cprintf 1 1 file-bytes 1 text-bytes 1 data-bytes 1 bss-bytes 1 build-ns 1 run-ns",
"BenchmarkProgram/cprintf-lto 1 1 file-bytes 1 text-bytes 1 data-bytes 1 bss-bytes 1 build-ns 1 run-ns",
"BenchmarkRuntimeGetG-1 100 12.5 ns/op",
"BenchmarkGoroutineLifecycle/create-and-wait/goroutines=1024/LLGo-1 100 12.5 ns/op",
"BenchmarkGoroutineLifecycle/serial-dispatch/tasks=1/Go-1 100 12.5 ns/op",
"BenchmarkTimerAfterFuncZeroDelivery/Go-1 100 12.5 ns/op",
"BenchmarkTimerResetHeap1024/LLGo-1 100 12.5 ns/op",
} {
Expand Down
26 changes: 26 additions & 0 deletions benchmark/baseline/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@ run_llgo_timer_samples '^BenchmarkTimer(RearmStopped|ResetActive|ResetHeap1024)$
./test/llgoext
) | tee -a "$go_results"

# Bound native thread creation while calibrating the persistent-worker cases.
# Both compilers use the same warmed benchmark source and sampling budgets.
(
cd "$harness_root"
GOMAXPROCS=1 go test \
-run '^$' \
-bench '^BenchmarkGoroutineLifecycle/create-and-wait$' \
-benchtime=10x -count=5 -cpu=1 \
./benchmark/goroutine
GOMAXPROCS=1 go test \
-run '^$' \
-bench '^BenchmarkGoroutineLifecycle/serial-dispatch$' \
-benchtime=250ms -count=5 -cpu=1 \
./benchmark/goroutine
GOMAXPROCS=1 LLGO_ROOT="$source_root" "$llgo_output" test \
-run '^$' \
-bench '^BenchmarkGoroutineLifecycle/create-and-wait$' \
-benchtime=10x -count=5 \
./benchmark/goroutine
GOMAXPROCS=1 LLGO_ROOT="$source_root" "$llgo_output" test \
-run '^$' \
-bench '^BenchmarkGoroutineLifecycle/serial-dispatch$' \
-benchtime=250ms -count=5 \
./benchmark/goroutine
) | tee -a "$go_results"

(
cd "$harness_root"
go run ./benchmark/baseline \
Expand Down
119 changes: 119 additions & 0 deletions benchmark/goroutine/goroutine_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package goroutine

import (
"strconv"
"sync"
"sync/atomic"
"testing"
)

var benchmarkSink atomic.Int64

func benchmarkTask() {
benchmarkSink.Add(1)
}

func runGoroutineBatch(count int) {
var wg sync.WaitGroup
wg.Add(count)
for range count {
go func() {
benchmarkTask()
wg.Done()
}()
}
wg.Wait()
}

type reusableWorker struct {
batches chan int
done chan struct{}
stop chan struct{}
stopped chan struct{}
}

func newReusableWorker() *reusableWorker {
w := &reusableWorker{
batches: make(chan int),
done: make(chan struct{}),
stop: make(chan struct{}),
stopped: make(chan struct{}),
}
go func() {
defer close(w.stopped)
for {
select {
case count := <-w.batches:
for range count {
benchmarkTask()
}
w.done <- struct{}{}
case <-w.stop:
return
}
}
}()
return w
}

func (w *reusableWorker) run(count int) {
w.batches <- count
<-w.done
}

func (w *reusableWorker) close() {
close(w.stop)
<-w.stopped
}

func TestReusableWorkerBatches(t *testing.T) {
worker := newReusableWorker()
t.Cleanup(worker.close)
for _, count := range []int{0, 1, 16, 128, 1024, 1, 0} {
before := benchmarkSink.Load()
worker.run(count)
if got := benchmarkSink.Load() - before; got != int64(count) {
t.Fatalf("batch of %d tasks completed %d tasks", count, got)
}
}
}

func BenchmarkGoroutineLifecycle(b *testing.B) {
for _, count := range []int{1, 16, 128, 1024} {
b.Run("create-and-wait/goroutines="+strconv.Itoa(count)+"/"+goroutineImplementation, func(b *testing.B) {
runGoroutineBatch(count)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
runGoroutineBatch(count)
}
})

b.Run("serial-dispatch/tasks="+strconv.Itoa(count)+"/"+goroutineImplementation, func(b *testing.B) {
worker := newReusableWorker()
b.Cleanup(worker.close)
worker.run(count)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
worker.run(count)
}
})
}
}
21 changes: 21 additions & 0 deletions benchmark/goroutine/variant_go_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//go:build !llgo

/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package goroutine

const goroutineImplementation = "Go"
21 changes: 21 additions & 0 deletions benchmark/goroutine/variant_llgo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//go:build llgo

/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package goroutine

const goroutineImplementation = "LLGo"
61 changes: 61 additions & 0 deletions internal/build/native_channel_teardown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package build

import (
stdcontext "context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)

func TestNativeChannelWaiterTeardown(t *testing.T) {
// Linux's collector waits through a C wrapper rather than this binding.
if runtime.GOOS != "darwin" {
t.Skip("requires the Darwin pthread wait path")
}
if testing.Short() {
t.Skip("builds and runs native lifecycle probes")
}
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Setenv("LLGO_ROOT", root)
oldCacheRoot := cacheRootFunc
cacheDir := t.TempDir()
cacheRootFunc = func() string { return cacheDir }
t.Cleanup(func() { cacheRootFunc = oldCacheRoot })
file := filepath.Join(root, "runtime/internal/sync/sync_unix.go")
source, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
patched := string(source)
for _, op := range []string{"cond_wait", "cond_destroy", "mutex_destroy"} {
name := "C.pthread_" + op
if strings.Count(patched, name) != 1 {
t.Fatalf("expected one binding for %s", name)
}
patched = strings.Replace(patched, name, "C.llgo_teardown_"+op, 1)
}
conf := NewDefaultConf(ModeBuild)
conf.Overlay = map[string][]byte{file: []byte(patched)}
conf.OutFile = filepath.Join(t.TempDir(), "channel-teardown")
fixture := filepath.Join(root, "internal/build/testdata/native-channel-teardown")
if _, err := Do([]string{fixture}, conf); err != nil {
t.Fatal(err)
}
for _, scenario := range []string{"main-goexit", "foreign-thread"} {
t.Run(scenario, func(t *testing.T) {
ctx, cancel := stdcontext.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, conf.OutFile, scenario).CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) != "ok" {
t.Fatalf("channel waiter teardown: %v (context: %v)\n%s", err, ctx.Err(), out)
}
})
}
}
3 changes: 2 additions & 1 deletion internal/build/native_channel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ func TestNativeChannelSignalLifetime(t *testing.T) {
}
// Compile the shared Go/LLGo testcase with a companion that replaces only
// its synchronization factory. Keep each scenario in a separate process so
// the C probe can target exactly one wait.
// the C probe can target exactly one wait. The probe checks notification
// completion even when the waiter retains its signal until its G exits.
dir := t.TempDir()
for _, name := range []string{"channel_waiter_test.go", "channel_waiter_norace_test.go", "_wrap/channel_signal_lifetime.c"} {
data, err := os.ReadFile(filepath.Join(root, "test/go", name))
Expand Down
Loading
Loading