perfscan is a staticcheck-style performance linter for Go: a registry of
stable, benchmark-verified checks (PS1001…) that find performance
anti-patterns, with graded auto-fixing — from idiomatic rewrites a
reviewer waves through to hyper-optimizations that must be benchmark-gated.
It generalizes an internal performance scanner that found dozens of
measured wins (2x–6x on hot kernels) in a large numerical-computing
codebase into a standalone, community-maintained utility with a
staticcheck-like architecture: every check is a standard
golang.org/x/tools/go/analysis.Analyzer, wrapped in metadata for stable
IDs, categories, documentation and fix-level gating. The engine itself is
fully generic — anything project-specific lives in a JSON vocabulary
config, so any codebase (including the one it came from) is supported
given the right configuration.
go install github.com/jxsl13/perfscan@latestOr download a prebuilt binary from the
releases page — assets are named
perfscan_<version>_<os>_<arch>.tar.gz (.zip on Windows), published on
plain vX.Y.Z tags.
perfscan ./... # report all findings
perfscan -checks PS2* ./... # only allocation checks
perfscan -checks all,-PS3003 ./... # everything except one check
perfscan -level 1 ./... # only findings with idiomatic (L1) fixes
perfscan -level 1 -fix ./... # report + apply only L1 (idiomatic) fixes
perfscan -level 2 -fix ./... # report + apply L1 and L2 fixes
perfscan -fix ./... # default -level 3: apply every available fix
perfscan -diff ./... # dry run: unified diff of what -fix would do,
# changes nothing, exit 1 if fixes are pending
perfscan -json ./... # machine-readable output (editor quick-fixes)
perfscan -sarif ./... # SARIF 2.1.0 (GitHub Code Scanning)
perfscan -list # the check table
perfscan -explain PS2005 # a check's full documentationFindings print in the standard file:line:col: message (PSid Ln) shape, so
any editor problem-matcher picks them up.
Record the current findings once, then fail CI only on regressions while the backlog is burned down incrementally:
perfscan -baseline perfscan-baseline.yaml -write-baseline ./... # accept today's findings
perfscan -baseline perfscan-baseline.yaml ./... # exit 1 only on NEW findingsBaseline identity is line-independent ({module, file, check, message} with
counts), so unrelated edits that shift line numbers do not resurrect
accepted findings. New baselines store files relative to their discovered Go
module, so they remain portable across checkout/worktree directory names even
when the baseline is stored outside the repository. Module paths keep files in
different workspace modules distinct. Sources without module metadata retain
baseline-file-relative paths. Version-1 baselines remain readable with their
original path convention; regenerate them once with -write-baseline to adopt
portable paths. Re-run -write-baseline after fixing a batch to ratchet
the accepted set down. Baselines also record the Go toolchain and target that
created them. If the compiler generation changes, regenerate performance
baselines, or explicitly carry them forward only after validating the new
compiler; perfscan warns when it detects such a change. A GOOS/GOARCH change
warns as well, so cross-target results cannot be mistaken for same-target runs.
JSON findings, every SARIF run, and every written baseline carry a toolchain
fingerprint: the effective go command's GOVERSION (including GOTOOLCHAIN
and toolchain selection), its package-loading GOOS/GOARCH, and the main
module's go directive. SARIF stores it once in
runs[].properties, and baselines store it once under metadata; both retain
it when a scan has no findings. For backward
compatibility, perfscan -json remains a top-level findings array and adds the
same precomputed metadata object to each finding. Consequently an empty JSON
array has no container in which to carry run metadata; no sentinel finding or
breaking wrapper is introduced.
For GitHub Actions, perfscan inspects literal go-version values and literal
go-version-file paths on actions/setup-go steps in the repository-root
.github/workflows/*.yml and *.yaml. Version files are read conservatively
when they name go.mod, go.work, .go-version, or .tool-versions; an
explicit go-version takes precedence as setup-go specifies. Perfscan warns
when the selected major/minor generation conflicts with the main go.mod.
For version files containing both directives, setup-go v6+ selects toolchain
while older literal action versions select go; unknown action refs stay
conservative when those directives differ.
Dynamic expressions (${{ ... }}), aliases such as stable, missing or
outside-repository version files, malformed workflows, and ambiguous
multi-module workspaces are left alone rather than guessed.
A fingerprint identifies the compiler/target but does not prove every tagged path builds there. Toolchain upgrades should also run architecture-tagged full-tree compile probes (and any focused experimental API compiles) for the targets represented by retained benchmark evidence.
Performance fixes differ wildly in what they cost the reader. perfscan
makes that cost a first-class property: every check carries a level, and
one -level knob gates both what is reported and what -fix applies: you fix exactly what you see.
| Level | Name | Character | Auto-fix policy |
|---|---|---|---|
| L1 | idiomatic | The fix is idiomatic Go a reviewer waves through: hoist a regexp.MustCompile out of the loop, pre-size a slice or builder. Deterministic, mechanical, bit-identical. |
Fixed whenever reported (-fix). |
| L2 | structured | The fix restructures code: loop interchange, slab allocation, sync.Pool scratch, map→slice densification. Correct, but it changes the shape of the code. |
Fixed when reported (-level ≥ 2 with -fix); review + benchmark expected. |
| L3 | aggressive | Hyper-optimization: unroll-and-jam, register tiling, band parallelization, branchless clamps — what the reference corpus does on its hot kernels. Buys the last factor at a real maintainability price. | Fixed when reported (default -level 3 with -fix), and only where the shape makes the rewrite provably behavior-preserving; everything else stays advisory pending an A/B benchmark. |
The philosophy is inherited from the reference tool's pattern catalog: every finding is a candidate, not a verdict. Static analysis sees syntax, not hotness — confirm with a pre/post benchmark and skip cold paths where the fix isn't worth the code.
Every check has a stable PS-prefixed 4-digit ID, grouped by the thousands digit:
| Range | Category |
|---|---|
| PS1xxx | per-element access in hot loops |
| PS2xxx | allocation |
| PS3xxx | indirection / reflection |
| PS4xxx | vectorization |
| PS5xxx | arithmetic |
| PS6xxx | verification gaps |
| PS7xxx | offload / device transfer |
IDs are never reused: a retired check leaves a hole. IDs inherited from
the original internal registry keep their numbers; perfscan-original
checks use the x1xx block of each category (e.g. PS2101). Run
perfscan -list for the live table.
//perfscan:ignore PS2005 pattern varies per tenant, compile is cold
for _, t := range tenants {
re := regexp.MustCompile(t.Pattern)
...
}The directive suppresses the named checks (comma/space separated) on its own
line or the line below; a bare //perfscan:ignore suppresses everything on
that line. Suppressions name IDs, which is why IDs are stable.
Most checks are pure language/stdlib shapes and run on any Go module with no configuration. Domain checks key on a project's own vocabulary — its element accessors, allocators, fast-path helpers, vectorized kernels — which lives in a JSON config, not in the engine:
# perfscan.yaml (auto-discovered up to the module root, or -config file.yaml;
# JSON files still parse — YAML is a superset)
elementAccessors: [AtF64, SetF64]
fastPathHelpers: [flatF64, flatF32]
elementCountMethods: [Numel]
allocatorFuncs: [New, Zeros, Cast]
perElementVisitors: [readGen, fillGen]
vectorizedSiblingFuncs: [vexpF32, vsiluF32]
fanOutHelpers: [parallel.For]
dtypeMethods: [Dtype]Domain checks are opt-in: without their vocabulary they are skipped
silently (the CONFIG column of perfscan -list shows what each needs).
Naming one explicitly (-checks PS1001) without its vocabulary prints a
warning saying exactly which fields are missing — an explicitly requested
check that cannot fire is worth one loud line, a wildcard run is not.
The text output matches a standard problem-matcher; for VS Code:
perfscan -json keeps its top-level findings array. Every finding includes
additive toolchain metadata and its fix's text edits (line/col ranges, byte
offsets, replacement text) for quick-fix integrations.
Because every check is a plain analysis.Analyzer, you can also embed them
in your own multichecker or go vet -vettool binary.
perfscan ships a module plugin for golangci-lint's custom build system:
# .custom-gcl.yml
version: v2.1.0
plugins:
- module: 'github.com/jxsl13/perfscan/plugin'
version: latest# .golangci.yml
linters:
enable: [perfscan]
settings:
custom:
perfscan:
type: module
settings:
maxLevel: 2 # only L1/L2 findings
vocabulary: # optional domain vocabulary (perfscan.yaml shape)
fanOutHelpers: [parallelFor]Then golangci-lint custom builds the binary.
perfscan is young. The engine, CLI, fix-level gating, vocabulary config and a growing check set are in place; the original reference registry (~80 checks, all benchmark-verified) is being ported check by check. See ROADMAP.md for the porting status and CONTRIBUTING.md if you want to help — new checks need a measured win, a positive + negative fixture, and a stable ID.