From 118cf6948d3753c5bd6d4737440b6fee896889a7 Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Wed, 10 Jun 2026 19:31:59 +0200 Subject: [PATCH 1/2] perf(gnovm): parallelize TestFiles and TestStdlibs TestFiles ran its 2339 non-long filetests sequentially on a single goroutine; they now run as parallel subtests, drawing a TestOptions (with its store) from a GOMAXPROCS-sized pool so loaded packages are still reused across tests. -update-golden-tests keeps the fully sequential single-store behavior. TestStdlibs ran most stdlib suites sequentially on a shared store in the parent test body, which also delayed the parallel heavy suites (bytes, strconv, ...) until the walk finished, since parallel subtests only start once the parent body returns. Every stdlib package now runs as a parallel subtest with its own store. gnoBuiltinsCache was lazily populated from TypeCheckMemPackage without synchronization; with type-checks now running concurrently from the start, that latent race becomes load-bearing. The cache is now built eagerly at package init and read-only afterwards. --- gnovm/pkg/gnolang/files_test.go | 49 +++++++++++++++++++------------- gnovm/pkg/gnolang/gotypecheck.go | 32 +++++++++++---------- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/gnovm/pkg/gnolang/files_test.go b/gnovm/pkg/gnolang/files_test.go index a5b1d5977ef..1b56d1454fe 100644 --- a/gnovm/pkg/gnolang/files_test.go +++ b/gnovm/pkg/gnolang/files_test.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "strings" "testing" @@ -51,8 +52,19 @@ func TestFiles(t *testing.T) { ) return o } - // sharedOpts is used for all "short" tests. - sharedOpts := newOpts() + + // Tests run in parallel, drawing a TestOptions (with its store) from this + // pool. Reusing stores lets tests share previously loaded packages. + // When syncing golden files, run sequentially to keep walk-order writes + // and error propagation deterministic. + poolSize := runtime.GOMAXPROCS(0) + if *withSync { + poolSize = 1 + } + optsPool := make(chan *test.TestOptions, poolSize) + for range poolSize { + optsPool <- newOpts() + } dir := "../../tests/files" fsys := os.DirFS(dir) @@ -100,11 +112,17 @@ func TestFiles(t *testing.T) { var criticalError error t.Run(subTestName, func(t *testing.T) { - opts := sharedOpts - if isLong { - // Long tests are run in parallel, and with their own store. + if !*withSync { t.Parallel() + } + var opts *test.TestOptions + if isLong { + // Long tests get their own store, so they don't hold + // up a pool slot for their whole (long) duration. opts = newOpts() + } else { + opts = <-optsPool + defer func() { optsPool <- opts }() } changed, _, _, err := opts.RunFiletest(path, content, opts.TestStore) if err != nil { @@ -144,8 +162,6 @@ func TestStdlibs(t *testing.T) { opts.Verbose = true return } - sharedCapture, sharedOpts := newOpts() - dir := "../../stdlibs/" fsys := os.DirFS(dir) err = fs.WalkDir(fsys, ".", func(path string, de fs.DirEntry, err error) error { @@ -177,7 +193,6 @@ func TestStdlibs(t *testing.T) { // Read and run tests. mpkg := gnolang.MustReadMemPackage(fp, path, gnolang.MPStdlibAll) t.Run(strings.ReplaceAll(mpkg.Path, "/", "-"), func(t *testing.T) { - capture, opts := sharedCapture, sharedOpts switch mpkg.Path { // Excluded in short case @@ -187,20 +202,11 @@ func TestStdlibs(t *testing.T) { if testing.Short() { t.Skip("Skipped because of -short, and this stdlib is very long currently.") } - fallthrough - // Run using separate store, as it's faster - case - "math/rand", - "regexp", - "regexp/syntax", - "sort": - t.Parallel() - capture, opts = newOpts() } - if capture != nil { - capture.Reset() - } + // Run in parallel, each package using its own store. + t.Parallel() + capture, opts := newOpts() err := test.Test(mpkg, "", opts) if !testing.Verbose() { @@ -217,6 +223,9 @@ func TestStdlibs(t *testing.T) { t.Fatal(err) } + // Shared by the tests/stdlibs walk below, which runs sequentially. + sharedCapture, sharedOpts := newOpts() + testDir := "../../tests/stdlibs/" testFs := os.DirFS(testDir) err = fs.WalkDir(testFs, ".", func(path string, de fs.DirEntry, err error) error { diff --git a/gnovm/pkg/gnolang/gotypecheck.go b/gnovm/pkg/gnolang/gotypecheck.go index f46e18567d8..2d894aef437 100644 --- a/gnovm/pkg/gnolang/gotypecheck.go +++ b/gnovm/pkg/gnolang/gotypecheck.go @@ -25,20 +25,15 @@ import ( // While makeGnoBuiltins() returns a *std.MemFile to inject into each package, // they may need to import a central package if they declare any types, // otherwise each .gnobuiltins.gno would be declaring their own types. -var gnoBuiltinsCache = make(map[string]*std.MemPackage) // pkgPath -> mpkg or nil. +// The map is populated at init and must not be mutated afterwards: it is read +// concurrently by parallel type-checks. +var gnoBuiltinsCache = map[string]*std.MemPackage{ + "gnobuiltins/gno0p9": gnoBuiltinsGno0p9(), +} -func gnoBuiltinsMemPackage(pkgPath string) *std.MemPackage { - if !strings.HasPrefix(pkgPath, "gnobuiltins/") { - panic("expected pkgPath to start with gnobuiltins/") - } - mpkg, ok := gnoBuiltinsCache[pkgPath] - if ok { - return mpkg - } - switch pkgPath { - case "gnobuiltins/gno0p9": // 0.9 - mpkg = &std.MemPackage{Type: MPStdlibProd, Name: "gno0p9", Path: "gnobuiltins/gno0p9"} - mpkg.SetFile("gno0p9.gno", `package gno0p9 +func gnoBuiltinsGno0p9() *std.MemPackage { // 0.9 + mpkg := &std.MemPackage{Type: MPStdlibProd, Name: "gno0p9", Path: "gnobuiltins/gno0p9"} + mpkg.SetFile("gno0p9.gno", `package gno0p9 type realm interface { Address() address PkgPath() string @@ -58,10 +53,17 @@ func (a address) String() string { return string(a) } func (a address) IsValid() bool { return false } // shim type Address = address `) - default: + return mpkg +} + +func gnoBuiltinsMemPackage(pkgPath string) *std.MemPackage { + if !strings.HasPrefix(pkgPath, "gnobuiltins/") { + panic("expected pkgPath to start with gnobuiltins/") + } + mpkg, ok := gnoBuiltinsCache[pkgPath] + if !ok { panic("unrecognized gnobuiltins pkgpath") } - gnoBuiltinsCache[pkgPath] = mpkg return mpkg } From 7966fc9d6544e79f4d4f27e044003eec59ee8d03 Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Wed, 10 Jun 2026 19:32:11 +0200 Subject: [PATCH 2/2] feat(gnovm): add -jobs flag to gno test, use it in CI gno test runs packages sequentially on a single-threaded VM; on the gnovm stdlibs CI job this is ~455s of test time on one core of a 4-vCPU runner (bytes 174s, strconv 86s, math/overflow 60s, ...). -jobs N (default 1: behavior unchanged) tests up to N packages in parallel. Each worker owns a TestOptions/store, reused across the packages it runs; per-package output is buffered and printed in package order as results complete, so runs remain readable and deterministic. Incompatible with the interactive -debug mode. The CI gno-test step (gnovm stdlibs and examples jobs) now passes -jobs 4 to match the runner's 4 vCPUs. --- .github/workflows/_ci-gno.yml | 2 +- gnovm/cmd/gno/test.go | 322 +++++++++++++++++++++++----------- 2 files changed, 219 insertions(+), 105 deletions(-) diff --git a/.github/workflows/_ci-gno.yml b/.github/workflows/_ci-gno.yml index 85d98ead2c5..44b631bf7af 100644 --- a/.github/workflows/_ci-gno.yml +++ b/.github/workflows/_ci-gno.yml @@ -97,6 +97,6 @@ jobs: echo "LOG_PATH_DIR=$LOG_PATH_DIR" >> "$GITHUB_ENV" - name: Run gno test - run: go run ./gnovm/cmd/gno test -C "${INPUTS_PATH}" -v -print-runtime-metrics -print-events ./... + run: go run ./gnovm/cmd/gno test -C "${INPUTS_PATH}" -v -print-runtime-metrics -print-events -jobs 4 ./... env: INPUTS_PATH: ${{ inputs.path }} diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index edccb180385..eb25d07b872 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "errors" "flag" @@ -10,7 +11,10 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" + "sync" + "sync/atomic" "time" "github.com/gnolang/gno/gnovm/pkg/gnoenv" @@ -33,6 +37,7 @@ type testCmd struct { printEvents bool debug bool debugAddr string + jobs int } func newTestCmd(io commands.IO) *commands.Command { @@ -179,6 +184,14 @@ func (c *testCmd) RegisterFlags(fs *flag.FlagSet) { "", "enable interactive debugger using tcp address in the form [host]:port", ) + + fs.IntVar( + &c.jobs, + "jobs", + 1, + "number of packages to test in parallel; 0 means GOMAXPROCS. "+ + "When above 1, the output of each package is buffered and printed once the package's tests complete.", + ) } func execTest(cmd *testCmd, args []string, io commands.IO) error { @@ -216,20 +229,22 @@ func execTest(cmd *testCmd, args []string, io commands.IO) error { }() } + if cmd.jobs != 1 && (cmd.debug || cmd.debugAddr != "") { + return errors.New("the interactive debugger can only be used with -jobs 1") + } + // Set up options to run tests. - stdout := goio.Discard - if cmd.verbose { - stdout = io.Out() + newOpts := func(stdout, stderr goio.Writer) *test.TestOptions { + opts := test.NewTestOptions(cmd.rootDir, stdout, stderr, pkgs) + opts.RunFlag = cmd.run + opts.Sync = cmd.updateGoldenTests + opts.Verbose = cmd.verbose + opts.Metrics = cmd.printRuntimeMetrics + opts.Events = cmd.printEvents + opts.Debug = cmd.debug + opts.FailfastFlag = cmd.failfast + return opts } - opts := test.NewTestOptions(cmd.rootDir, stdout, io.Err(), pkgs) - opts.RunFlag = cmd.run - opts.Sync = cmd.updateGoldenTests - opts.Verbose = cmd.verbose - opts.Metrics = cmd.printRuntimeMetrics - opts.Events = cmd.printEvents - opts.Debug = cmd.debug - opts.FailfastFlag = cmd.failfast - cache := make(gno.TypeCheckCache, 64) // test.ProdStore() is suitable for type-checking prod (non-test) files. // _, pgs := test.ProdStore(cmd.rootDir, opts.WriterForStore()) @@ -241,119 +256,218 @@ func execTest(cmd *testCmd, args []string, io commands.IO) error { return fmt.Errorf("FAIL: %d build errors, %d test errors", buildErrCount, testErrCount) } - for _, pkg := range pkgs { - // Relativize and prepend dot to pkg dir if possible - // We ignore errors since it's a cosmetic thing - // XXX: use pkg import path instead of this when printing if possible - prettyDir := pkg.Dir - if filepath.IsAbs(pkg.Dir) { - cwd, err := os.Getwd() - if err == nil { - relDir, err := filepath.Rel(cwd, pkg.Dir) - if err == nil { - prettyDir = relDir - if prettyDir != "." && !strings.HasPrefix(prettyDir, "."+string(filepath.Separator)) { - prettyDir = "." + string(filepath.Separator) + prettyDir - } - } + if cmd.jobs == 1 { + // Sequential run: all packages share a single store, and print + // their output directly as they run. + stdout := goio.Discard + if cmd.verbose { + stdout = io.Out() + } + opts := newOpts(stdout, io.Err()) + cache := make(gno.TypeCheckCache, 64) + + for _, pkg := range pkgs { + buildErrs, testErrs := cmd.testPkg(pkg, opts, cache, io) + buildErrCount += buildErrs + testErrCount += testErrs + if testErrs > 0 && cmd.failfast { + return fail() } } + } else { + // Parallel run: cmd.jobs workers, each with its own store. The + // output of each package is buffered, and printed in package order + // as results come in. + jobs := cmd.jobs + if jobs == 0 { + jobs = runtime.GOMAXPROCS(0) + } + jobs = min(jobs, len(pkgs)) - for _, err := range pkg.Errors { - io.ErrPrintfln("%s", err.Error()) - buildErrCount++ + type pkgResult struct { + out, errOut bytes.Buffer + buildErrs int + testErrs int + done chan struct{} } - // don't test packages with load errors - if len(pkg.Errors) != 0 { - io.ErrPrintfln("FAIL %s \t[setup failed]", prettyDir) - continue + results := make([]pkgResult, len(pkgs)) + for i := range results { + results[i].done = make(chan struct{}) } - // don't test packages not listed in patterns - if len(pkg.Match) == 0 { - continue + var ( + nextIdx atomic.Int64 + failed atomic.Bool + wg sync.WaitGroup + ) + for range jobs { + wg.Add(1) + go func() { + defer wg.Done() + cache := make(gno.TypeCheckCache, 64) + // One TestOptions (and store) per worker, reused across the + // packages it runs so that loaded packages are shared; only + // the writers are swapped per package. + opts := newOpts(goio.Discard, goio.Discard) + for { + i := int(nextIdx.Add(1)) - 1 + if i >= len(pkgs) { + return + } + res := &results[i] + if cmd.failfast && failed.Load() { + // don't start new tests after the first test failure + close(res.done) + continue + } + opts.Output = goio.Discard + if cmd.verbose { + opts.Output = &res.out + } + opts.Error = &res.errOut + pio := commands.NewTestIO() + pio.SetOut(commands.WriteNopCloser(&res.out)) + pio.SetErr(commands.WriteNopCloser(&res.errOut)) + res.buildErrs, res.testErrs = cmd.testPkg(pkgs[i], opts, cache, pio) + if res.testErrs > 0 { + failed.Store(true) + } + close(res.done) + } + }() } - - if len(pkg.Files[packages.FileKindTest]) == 0 && len(pkg.Files[packages.FileKindXTest]) == 0 && len(pkg.Files[packages.FileKindFiletest]) == 0 { - io.ErrPrintfln("? %s \t[no test files]", prettyDir) - continue + for i := range results { + res := &results[i] + <-res.done + if res.out.Len() > 0 { + _, _ = io.Out().Write(res.out.Bytes()) + } + if res.errOut.Len() > 0 { + _, _ = io.Err().Write(res.errOut.Bytes()) + } + buildErrCount += res.buildErrs + testErrCount += res.testErrs } + wg.Wait() + } + if testErrCount > 0 || buildErrCount > 0 { + return fail() + } - // Read and parse gnomod.toml directly. - fpath := filepath.Join(pkg.Dir, "gnomod.toml") - mod, err := gnomod.ParseFilepath(fpath) - if errors.Is(err, fs.ErrNotExist) { - if cmd.autoGnomod { - modulePath, _ := determinePkgPath(nil, pkg.Dir, cmd.rootDir) - modstr := gno.GenGnoModLatest(modulePath) - mod, err = gnomod.ParseBytes("gnomod.toml", []byte(modstr)) - if err != nil { - panic(fmt.Errorf("unexpected panic parsing default gnomod.toml bytes: %w", err)) - } - io.ErrPrintfln("auto-generated %q", fpath) - err = mod.WriteFile(fpath) - if err != nil { - panic(fmt.Errorf("unexpected panic writing to %q: %w", fpath, err)) + return nil +} + +// testPkg loads and tests pkg, printing results to io. It returns the number +// of build errors and test errors encountered. +func (c *testCmd) testPkg( + pkg *packages.Package, + opts *test.TestOptions, + cache gno.TypeCheckCache, + io commands.IO, +) (buildErrCount, testErrCount int) { + // Relativize and prepend dot to pkg dir if possible + // We ignore errors since it's a cosmetic thing + // XXX: use pkg import path instead of this when printing if possible + prettyDir := pkg.Dir + if filepath.IsAbs(pkg.Dir) { + cwd, err := os.Getwd() + if err == nil { + relDir, err := filepath.Rel(cwd, pkg.Dir) + if err == nil { + prettyDir = relDir + if prettyDir != "." && !strings.HasPrefix(prettyDir, "."+string(filepath.Separator)) { + prettyDir = "." + string(filepath.Separator) + prettyDir } - // err == nil. } } + } - // Determine pkgPath from gno.mod. - pkgPath, ok := determinePkgPath(mod, pkg.Dir, cmd.rootDir) - if !ok { - io.ErrPrintfln("WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") - } + for _, err := range pkg.Errors { + io.ErrPrintfln("%s", err.Error()) + buildErrCount++ + } + // don't test packages with load errors + if len(pkg.Errors) != 0 { + io.ErrPrintfln("FAIL %s \t[setup failed]", prettyDir) + return + } + // don't test packages not listed in patterns + if len(pkg.Match) == 0 { + return + } - // Read MemPackage with all files. - mpkg := gno.MustReadMemPackage(pkg.Dir, pkgPath, gno.MPAnyAll) - var didPanic, didError bool - startedAt := time.Now() - didPanic = catchPanic(pkg.Dir, pkgPath, io.Err(), func() { - if mod == nil || !mod.Ignore { - _, errs := lintTypeCheck(io, pkg.Dir, mpkg, gno.TypeCheckOptions{ - Getter: opts.TestStore, - TestGetter: opts.TestStore, - Mode: gno.TCLatestRelaxed, - Cache: cache, - }) - if errs != nil { - didError = true - // already printed in lintTypeCheck. - // io.ErrPrintln(errs) - return - } - } else if cmd.verbose { - io.ErrPrintfln("%s: module is ignore, skipping type check", pkgPath) + if len(pkg.Files[packages.FileKindTest]) == 0 && len(pkg.Files[packages.FileKindXTest]) == 0 && len(pkg.Files[packages.FileKindFiletest]) == 0 { + io.ErrPrintfln("? %s \t[no test files]", prettyDir) + return + } + + // Read and parse gnomod.toml directly. + fpath := filepath.Join(pkg.Dir, "gnomod.toml") + mod, err := gnomod.ParseFilepath(fpath) + if errors.Is(err, fs.ErrNotExist) { + if c.autoGnomod { + modulePath, _ := determinePkgPath(nil, pkg.Dir, c.rootDir) + modstr := gno.GenGnoModLatest(modulePath) + mod, err = gnomod.ParseBytes("gnomod.toml", []byte(modstr)) + if err != nil { + panic(fmt.Errorf("unexpected panic parsing default gnomod.toml bytes: %w", err)) + } + io.ErrPrintfln("auto-generated %q", fpath) + err = mod.WriteFile(fpath) + if err != nil { + panic(fmt.Errorf("unexpected panic writing to %q: %w", fpath, err)) } + // err == nil. + } + } + + // Determine pkgPath from gno.mod. + pkgPath, ok := determinePkgPath(mod, pkg.Dir, c.rootDir) + if !ok { + io.ErrPrintfln("WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") + } - /////////////////////////////////// - // Run the tests found in the mpkg. - errs := test.Test(mpkg, prettyDir, opts) + // Read MemPackage with all files. + mpkg := gno.MustReadMemPackage(pkg.Dir, pkgPath, gno.MPAnyAll) + var didPanic, didError bool + startedAt := time.Now() + didPanic = catchPanic(pkg.Dir, pkgPath, io.Err(), func() { + if mod == nil || !mod.Ignore { + _, errs := lintTypeCheck(io, pkg.Dir, mpkg, gno.TypeCheckOptions{ + Getter: opts.TestStore, + TestGetter: opts.TestStore, + Mode: gno.TCLatestRelaxed, + Cache: cache, + }) if errs != nil { didError = true - io.ErrPrintln(errs) + // already printed in lintTypeCheck. + // io.ErrPrintln(errs) return } - }) - - // Print status with duration. - duration := time.Since(startedAt) - dstr := fmtDuration(duration) - if didPanic || didError { - io.ErrPrintfln("FAIL %s \t%s", prettyDir, dstr) - testErrCount++ - if cmd.failfast { - return fail() - } - } else { - io.ErrPrintfln("ok %s \t%s", prettyDir, dstr) + } else if c.verbose { + io.ErrPrintfln("%s: module is ignore, skipping type check", pkgPath) } - } - if testErrCount > 0 || buildErrCount > 0 { - return fail() - } - return nil + /////////////////////////////////// + // Run the tests found in the mpkg. + errs := test.Test(mpkg, prettyDir, opts) + if errs != nil { + didError = true + io.ErrPrintln(errs) + return + } + }) + + // Print status with duration. + duration := time.Since(startedAt) + dstr := fmtDuration(duration) + if didPanic || didError { + io.ErrPrintfln("FAIL %s \t%s", prettyDir, dstr) + testErrCount++ + } else { + io.ErrPrintfln("ok %s \t%s", prettyDir, dstr) + } + return } func determinePkgPath(mod *gnomod.File, dir, rootDir string) (string, bool) {