Skip to content

golang/tools: reduce jitter of packages.Load

In CL 614975, I have migrated deprecated loader from ssa package. However, miller has reported the tests became extremely slows. In an operation-system with a slow file system, such as plan9, the test slowed by more than 50x.

Oops, a un-intentional change:( I have submitted a CL to load 300+ packages by packages.Load once instead of loading them many. Alan thought it's a bravo change:)

Moreover, when I involved to investigate why the packages.Load slows so much, I have learned a lot. This blog records how I investigate them.

Who to blame, go or tools?

The packages.Load calls go list underlying to list all the packages that matches the patterns. When we recorded the elapsed time of packages.Load, sometimes it takes more than 2s while the typical elapsed time is 300ms. Alan suspects go kills some time by sleep intentionally.

But when comparing time took by go list and packages.Load, the execution time of go has a low variance. It's the tools own issue.

Why

During construct the full DAG from a preliminary one, the packages uses a naive way. The parent spawns go routines for each of its imports and waits them done.

When we use packages.Load in parallel, the synchronization efforts are a lot so some of the packages.Load jitter.

func (ld *loader) loadRecursive(lpkg *loaderPackage) {
    lpkg.loadOnce.Do(func() {
        // Load the direct dependencies, in parallel.
        var wg sync.WaitGroup
        for _, ipkg := range lpkg.Imports {
            imp := ld.pkgs[ipkg.ID]
            wg.Add(1)
            go func(imp *loaderPackage) {
                ld.loadRecursive(imp)
                wg.Done()
            }(imp)
        }
        wg.Wait()
        ld.loadPackage(lpkg)
    })
}

How to fix it

Alan's approach is better by finding all leaves of DAG and trigger the parent packages when all its import packages are done.

enqueue = func(lpkg *loaderPackage) {
    g.Go(func() error {
        // Parse and type-check.
        ld.loadPackage(lpkg)

        // Notify each waiting predecessor,
        // and enqueue it when it becomes a leaf.
        for _, pred := range lpkg.preds {
            if pred.unfinishedSuccs.Add(-1) == 0 { // decref
                enqueue(pred)
            }
        }

        return nil
    })
}

How to fix it(my bad approach)

At beginning, I have a fix but I don't think it's better than alan's. I have tried a top-down approach and resolved all the import packages inside the spawning go routines. It spawns huge amount of go routines for every package and actually I cannot prevent the duplication. As a result, too many go routines are spawned and it requires a lot of synchronization efforts.

func (ld *loader) enqueue(lpkg *loaderPackage) {
    ld.g.Go(func() error {
        limiter <- struct{}{}
        defer func() { <-limiter }()
        // some imports are not finished, so need to wait
        if lpkg.unfinishedImports.Load() != 0 {
            for _, ipkg := range lpkg.Imports {
                imp := ld.pkgs[ipkg.ID]
                ld.enqueue(imp)
            }
            return nil
        }

        // this package is done already, need do nothing
        if lpkg.done.Load() {
            return nil
        }

        if !lpkg.running.CompareAndSwap(false, true) {
            return nil // another place is running, skip it
        }

        // all imports are ready, so load lpkg as well
        ld.loadPackage(lpkg)

        // after lpkg is ready, decrease all parents reference
        // if this makes parent ready, enqueue parent to start load as well
        for pred := range lpkg.preds {
            if pred.unfinishedImports.Add(-1) == 0 {
                ld.enqueue(pred)
            }
        }
        lpkg.done.Store(true)
        lpkg.running.Store(false)
        return nil
    })
}

Limit spawning go routines

Besides that, we can also use a channel to limit the spawned go routine numbers with capacity runtime.GOMAXPROCS(0).

var cpuLimit = make(chan bool, runtime.GOMAXPROCS(0))
func (ld *loader) loadPackage(lpkg *loaderPackage) {
    cpuLimit <- true              // acquire a token
    defer func() { <-cpuLimit }() // release a token

    typErr := types.NewChecker(tc, ld.Fset, lpkg.Types, lpkg.TypesInfo).Files(lpkg.Syntax)
}

Conclusion

By reducing spawning go routines for a DAG and limiting the executing of spawned go routines, we reduce the jitter caused by synchronization of too many go routines.

Note that it doesn't improve the function efficiency.