Skip to content
Merged
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: 12 additions & 7 deletions .github/actions/setup-deps/ubuntu_apt.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,25 @@

def configure(apt_dir, architecture):
if architecture in ("amd64", "i386"):
archive = "https://archive.ubuntu.com/ubuntu/"
security = "https://security.ubuntu.com/ubuntu/"
azure = "https://azure.archive.ubuntu.com/ubuntu/"
archive = (azure, "https://archive.ubuntu.com/ubuntu/")
security = (azure, "https://security.ubuntu.com/ubuntu/")
else:
archive = security = "https://ports.ubuntu.com/ubuntu-ports/"
archive = security = ("https://ports.ubuntu.com/ubuntu-ports/",)

# The runner's mirror+file source selected a malformed https:/ URL and
# then stalled on another mirror for the entire 45-minute Wasm job.
# Preserve source suites, components and signing keys; replace only the
# two Ubuntu mirror lists when the runner already uses them.
for name, url in (("apt-mirrors.txt", archive),
("apt-mirrors-security.txt", security)):
# two Ubuntu mirror lists when the runner already uses them. APT tries
# another mirror on download failures; explicit priorities avoid randomly
# selecting an unavailable archive endpoint before the Azure mirror.
for name, mirrors in (("apt-mirrors.txt", archive),
("apt-mirrors-security.txt", security)):
path = apt_dir / name
if path.exists():
path.write_text(url + "\n")
path.write_text("".join(
f"{url}\tpriority:{priority}\n"
for priority, url in enumerate(mirrors, 1)))

(apt_dir / "apt.conf.d" / "80-llgo-downloads").write_text('''\
Acquire::ForceIPv4 "true";
Expand Down
18 changes: 12 additions & 6 deletions .github/actions/setup-deps/ubuntu_apt_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@
class UbuntuAPTTest(unittest.TestCase):
def test_runner_mirror_lists(self):
for arch, archive, security in (
("amd64", "https://archive.ubuntu.com/ubuntu/",
"https://security.ubuntu.com/ubuntu/"),
("arm64", "https://ports.ubuntu.com/ubuntu-ports/",
"https://ports.ubuntu.com/ubuntu-ports/"),
("amd64", "https://azure.archive.ubuntu.com/ubuntu/\tpriority:1\n"
"https://archive.ubuntu.com/ubuntu/\tpriority:2\n",
"https://azure.archive.ubuntu.com/ubuntu/\tpriority:1\n"
"https://security.ubuntu.com/ubuntu/\tpriority:2\n"),
("i386", "https://azure.archive.ubuntu.com/ubuntu/\tpriority:1\n"
"https://archive.ubuntu.com/ubuntu/\tpriority:2\n",
"https://azure.archive.ubuntu.com/ubuntu/\tpriority:1\n"
"https://security.ubuntu.com/ubuntu/\tpriority:2\n"),
("arm64", "https://ports.ubuntu.com/ubuntu-ports/\tpriority:1\n",
"https://ports.ubuntu.com/ubuntu-ports/\tpriority:1\n"),
):
with self.subTest(arch=arch), tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
Expand All @@ -29,8 +35,8 @@ def test_runner_mirror_lists(self):
third_party.write_text("https://apt.llvm.org/noble/\n")
configure(root, arch)
configure(root, arch)
self.assertEqual((root / "apt-mirrors.txt").read_text(), archive + "\n")
self.assertEqual((root / "apt-mirrors-security.txt").read_text(), security + "\n")
self.assertEqual((root / "apt-mirrors.txt").read_text(), archive)
self.assertEqual((root / "apt-mirrors-security.txt").read_text(), security)
self.assertEqual(sources.read_text(), source_text)
self.assertEqual(third_party.read_text(), "https://apt.llvm.org/noble/\n")

Expand Down
7 changes: 4 additions & 3 deletions benchmark/escape/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ llgo build -O3 -lto=full -o defer ./benchmark/escape/defer

`boxing` reports bytes for 100,000 calls; `borrow` reports seven timed samples
of 100,000 calls; `defer` uses testing.Benchmark with a 500 ms target. Warm-up
calls precede allocation snapshots. The reported `reportedMallocsPerOp` field in `defer`
is informational only: #2923 affects this counter on native BDWGC. Use
`TotalAlloc`/allocated bytes for allocation comparisons.
calls precede allocation snapshots. Native BDWGC `MemStats.Mallocs` now reuses the
allocator's recorded event counts. Historical `reportedMallocsPerOp` values
from before the #2923 fix are informational only. Report event counts and
`TotalAlloc` bytes separately; allocator size classes differ.

Timings are isolated kernels, not a VM/application throughput claim. Compare
both affected and control cases, native binary size, and compiler time/memory.
Expand Down
2 changes: 1 addition & 1 deletion doc/escape-analysis-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ llgo build -O3 -lto=full -o /tmp/llgo-escape ./benchmark/escape/coverage
llgo build -gcflags=-m=2 ./benchmark/escape/coverage
```

Measure `MemStats.TotalAlloc` separately from elapsed time: LLGo's native `Mallocs` accounting remains tracked by #2923. Compare allocation bytes within each compiler; BDWGC rounding and Go allocation classes differ. Use the shared probe source from this PR for both worktrees. Existing baseline/follow-up LLGo binaries must use their matching runtime source roots, so runtime helper changes do not contaminate the baseline.
Measure `MemStats.TotalAlloc` separately from elapsed time. Native BDWGC `Mallocs` now reports recorded allocation events; historical runs before the #2923 fix did not use it. Compare allocation bytes within each compiler; BDWGC rounding and Go allocation classes differ. Use the shared probe source from this PR for both worktrees. Existing baseline/follow-up LLGo binaries must use their matching runtime source roots, so runtime helper changes do not contaminate the baseline.

Go references: [graph and solver](https://github.com/golang/go/tree/go1.27.3/src/cmd/compile/internal/escape), [frontend pass order](https://github.com/golang/go/blob/go1.27.3/src/cmd/compile/internal/gc/main.go), [make lowering](https://github.com/golang/go/blob/go1.27.3/src/cmd/compile/internal/walk/builtin.go), [conversion lowering](https://github.com/golang/go/blob/go1.27.3/src/cmd/compile/internal/walk/convert.go), [string concatenation](https://github.com/golang/go/blob/go1.27.3/src/cmd/compile/internal/walk/expr.go). Semantic regressions are exercised by [`test/escape`](../test/escape), engine proofs by `internal/goescape`, and target IR by `cl/escape_test.go` and `ssa/make_32bit_test.go`.

Expand Down
2 changes: 1 addition & 1 deletion doc/escape-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ llgo tool compile -m=2 file.go
- `test/go/keep_alive_stack_test.go`: common Go/LLGo tests cover reachable stack fields, boxing before GC, deferred iteration snapshots, panic-unwind GC and callable function values. Focused tests pass natively and on actual Wasm J32/J64/W32 runtimes. The aggregate benchmark covers the allocation-free path; its measurements are separate from the earlier full escape benchmark batch.
- `internal/build/escape_test.go`: cold/warm archives and 1/8 workers in fresh processes, with allocation checks and diagnostic proofs for cross-package borrows and callback boxes. A caller rebuild reuses the analyzed dependency's archive.
- `test/escape`: forced GC and finalizers, stack-held fields, boxed closures, escaped aliases, loop identity, atomic publication, goroutines, recovery and mixed defer/range-function ownership. Run natively, with `-O3 -lto=full`, and with `-target wasi|emscripten|emscripten-memory64 -emulator`. Multiworker Emscripten uses `LLGO_WASM_WORKERS=2`.
- [benchmark/escape](../benchmark/escape/README.md): reproducible issue probes and measured allocation, timing and build-size comparisons. Allocation bytes are reported separately from timing; native `MemStats.Mallocs` is not used because of #2923.
- [benchmark/escape](../benchmark/escape/README.md): reproducible issue probes and measured allocation, timing and build-size comparisons. Report allocation events, bytes and timing separately. Native BDWGC `MemStats.Mallocs` uses the existing allocator event counters; historical measurements predating the #2923 fix did not use it.

The broad native unwind suite has two full-LTO source-line expectation failures (`TestDeferredCallerClosingLine`, `TestRuntimeDeferredPanicLine`) that reproduce unchanged on the baseline. The new lifetime tests pass under full LTO; the existing line failures must not be represented as a clean full-LTO suite.

Expand Down
41 changes: 41 additions & 0 deletions internal/build/allocation_events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package build

import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/xgo-dev/llgo/internal/lto"
)

func TestNativeAllocationEventsExactIntegration(t *testing.T) {
if testing.Short() || (runtime.GOOS != "darwin" && runtime.GOOS != "linux" && runtime.GOOS != "windows") {
t.Skip("builds and runs a native BDWGC allocation probe")
}
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Setenv("LLGO_ROOT", root)
for _, mode := range []lto.Mode{lto.Off, lto.Full} {
t.Run(mode.String(), func(t *testing.T) {
conf := NewDefaultConf(ModeBuild)
conf.LTO = mode
conf.OutFile = filepath.Join(t.TempDir(), "allocation-events")
if runtime.GOOS == "windows" {
conf.OutFile += ".exe"
}
if _, err := Build(Invocation{Args: []string{"."}, Config: conf, Dir: filepath.Join(root, "internal/build/testdata/allocation_events")}); err != nil {
t.Fatal(err)
}
for i := 0; i < 10; i++ {
out, err := exec.CommandContext(t.Context(), conf.OutFile).CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) != "allocation events: OK" {
t.Fatalf("exact allocation events: %v\n%s", err, out)
}
}
})
}
}
3 changes: 3 additions & 0 deletions internal/build/testdata/allocation_events/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module allocationevents

go 1.27.0
46 changes: 46 additions & 0 deletions internal/build/testdata/allocation_events/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package main

import (
"fmt"
"runtime"
)

var bytesSink []byte
var pointerSink *node
var before, after runtime.MemStats

type node struct {
next *node
data int
}

//go:noinline
func allocate(size int) {
if size == 0 {
pointerSink = &node{next: pointerSink, data: 42}
} else {
bytesSink = make([]byte, size)
}
}

func main() {
// Run without testing's background goroutines, whose allocations would
// contribute to the same process-wide counter during the measurement.
for _, size := range []int{1, 1024, 65536, 0} {
for i := 0; i < 100; i++ {
allocate(size)
}
runtime.ReadMemStats(&before)
runtime.ReadMemStats(&after)
runtime.ReadMemStats(&before)
const count = 1000
for i := 0; i < count; i++ {
allocate(size)
}
runtime.ReadMemStats(&after)
if got := after.Mallocs - before.Mallocs; got != count {
panic(fmt.Sprintf("size=%d: %d allocations report %d events", size, count, got))
}
}
fmt.Println("allocation events: OK")
}
5 changes: 2 additions & 3 deletions runtime/_patch/runtime/metrics/memstats_llgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import "runtime"
func llgoReadMetricMemStats() runtime.MemStats {
var m runtime.MemStats
runtime.ReadMemStats(&m)
if m.HeapObjects == 0 {
m.HeapObjects = llgoSaturatingSub(m.Mallocs, m.Frees)
}
// A collector may expose allocation events without live/free object
// counts. Do not turn an unavailable live count into cumulative Mallocs.
return m
}
4 changes: 4 additions & 0 deletions runtime/internal/lib/runtime/runtime_gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ func ReadMemStats(m *runtime.MemStats) {
heapIdle := freeBytes + unmappedBytes
heapInuse := saturatingSub(heapSys, heapIdle)
heapAlloc := heapInuse
// Byte totals use BDWGC accounting; object events use the Go allocator's
// existing counters. BDWGC does not expose collected/live object counts,
// so Frees and HeapObjects remain zero, including in runtime/metrics.
*m = runtime.MemStats{
Alloc: uint64(heapAlloc),
TotalAlloc: uint64(totalBytes),
Sys: uint64(heapSys),
Mallocs: llruntime.MemProfileMallocs(),
Comment thread
cpunion marked this conversation as resolved.
Resolved
HeapAlloc: uint64(heapAlloc),
HeapSys: uint64(heapSys),
HeapIdle: uint64(heapIdle),
Expand Down
1 change: 1 addition & 0 deletions runtime/internal/lib/runtime/runtime_gc_nonmoving.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func ReadMemStats(m *runtime.MemStats) {
m.Sys = stats.Sys
m.Mallocs = stats.Mallocs
m.Frees = stats.Frees
m.HeapObjects = stats.Mallocs - stats.Frees
m.HeapAlloc = stats.HeapAlloc
m.HeapSys = stats.HeapSys
m.HeapIdle = stats.HeapIdle
Expand Down
11 changes: 11 additions & 0 deletions runtime/internal/runtime/memprofile.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ func memProfileSizeClass(size uintptr) uintptr {
return memProfileBuckets[len(memProfileBuckets)-1].size
}

// MemProfileMallocs returns recorded managed-allocation events across all
// size classes. Reuse the allocator's existing counters without allocating
// a profile snapshot or adding work to allocation call sites.
func MemProfileMallocs() uint64 {
var total uint64
for i := range memProfileBuckets {
total += memProfileLoadObjects(&memProfileBuckets[i].objects)
}
return total
}

func MemProfile(p []MemProfileRecord, inuseZero bool) (n int, ok bool) {
for i := range memProfileBuckets {
if memProfileLoadObjects(&memProfileBuckets[i].objects) != 0 {
Expand Down
4 changes: 2 additions & 2 deletions test/go/channel_waiter_alloc_llgo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ func TestChannelWaiterSteadyStateAllocations(t *testing.T) {
t.Logf("capacity %d: %d collector bytes / 10000 exchanges", capacity, allocated)
// TotalAlloc is collector accounting, not Go's allocation counter.
// Allow incidental runtime work but reject per-exchange waiter heaps.
// Each capacity has its own measured delta and bound. LLGo does not
// populate MemStats.Mallocs, so it cannot provide an allocation gate.
// Each capacity has its own measured delta and bound; the separate
// allocation-event controls verify MemStats.Mallocs reporting.
if allocated > 64<<10 {
t.Errorf("capacity %d: steady-state channel exchanges allocated %d bytes", capacity, allocated)
}
Expand Down
29 changes: 29 additions & 0 deletions test/std/runtime/metrics/objects_bdwgc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build llgo && !baremetal && !wasm && !nogc

package metrics_test

import (
"runtime"
"runtime/metrics"
"testing"
)

func TestBDWGCObjectMetrics(t *testing.T) {
// BDWGC reports allocation events, but not live/free object counts.
// The live metric must not silently become a cumulative allocation count.
runtime.GC()
samples := []metrics.Sample{
{Name: "/gc/heap/allocs:objects"},
{Name: "/gc/heap/frees:objects"},
{Name: "/gc/heap/objects:objects"},
}
metrics.Read(samples)
if samples[0].Value.Uint64() == 0 {
t.Fatal("allocation metric reports no events")
}
for _, sample := range samples[1:] {
if got := sample.Value.Uint64(); got != 0 {
t.Fatalf("unavailable %s = %d, want 0", sample.Name, got)
}
}
}
80 changes: 80 additions & 0 deletions test/std/testing/allocation_events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//go:build !nogc

package testing_test

import (
"runtime"
"testing"
)

var allocationEventSink []byte
var allocationEventPointerSink *allocationEventNode

type allocationEventNode struct {
next *allocationEventNode
data int
}

//go:noinline
func allocateEventControl(size int) {
allocationEventSink = make([]byte, size)
}

//go:noinline
func allocatePointerEventControl() {
allocationEventPointerSink = &allocationEventNode{next: allocationEventPointerSink, data: 42}
}

func TestAllocationEventControls(t *testing.T) {
for _, size := range []int{1, 16, 1024, 65536} {
if n := testing.AllocsPerRun(100, func() { allocateEventControl(size) }); n < 1 {
Comment thread
cpunion marked this conversation as resolved.
Resolved
t.Fatalf("escaping %d-byte allocation reports %v allocations, want >= 1", size, n)
}
}
if n := testing.AllocsPerRun(100, allocatePointerEventControl); n < 1 {
t.Fatalf("escaping pointer-bearing object reports %v allocations, want >= 1", n)
}
if n := testing.AllocsPerRun(100, func() { allocationEventSink[0]++ }); n != 0 {
t.Fatalf("in-place mutation reports %v allocations, want 0", n)
}
if n := testing.AllocsPerRun(100, func() { allocateEventControl(0) }); n != 0 {
t.Fatalf("zero-sized allocation reports %v allocations, want 0", n)
}
// Keep snapshot storage outside the measured callback so this checks
// ReadMemStats itself, independently of caller-side placement decisions.
var stats runtime.MemStats
if n := testing.AllocsPerRun(100, func() {
runtime.ReadMemStats(&stats)
}); n != 0 {
t.Fatalf("ReadMemStats reports %v allocations, want 0", n)
}
}

func TestReadMemStatsAllocationEvents(t *testing.T) {
allocateEventControl(1024)
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
for i := 0; i < 1000; i++ {
allocateEventControl(1024)
}
runtime.ReadMemStats(&after)
if count := after.Mallocs - before.Mallocs; count < 1000 {
t.Fatalf("1000 escaping allocations report %d events, want >= 1000", count)
}
if after.TotalAlloc <= before.TotalAlloc {
t.Fatal("escaping allocations did not increase TotalAlloc")
}
runtime.KeepAlive(allocationEventSink)
}

func TestBenchmarkAllocationEvents(t *testing.T) {
result := testing.Benchmark(func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
allocateEventControl(1024)
}
})
if result.AllocsPerOp() < 1 || result.AllocedBytesPerOp() == 0 {
t.Fatalf("escaping benchmark allocation reports %s", result.MemString())
}
}
Loading