Skip to content

Commit a08e800

Browse files
author
merge-queue-bot
committed
Merge PR #762: perf: fix top 5 high-performance-go.md violations found by codebase audit
2 parents 6138b7b + d67a633 commit a08e800

15 files changed

Lines changed: 504 additions & 66 deletions

File tree

internal/foreignregion/scan.go

Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package foreignregion
1010

1111
import (
12+
"bytes"
1213
"fmt"
1314
"strings"
1415

@@ -35,16 +36,80 @@ func Scan(f *lint.File, regions []config.ForeignRegion) ([]lint.LineRange, []lin
3536
if len(regions) == 0 || f == nil {
3637
return nil, nil
3738
}
39+
states := make([]regionScanState, len(regions))
40+
for i, reg := range regions {
41+
states[i] = regionScanState{
42+
start: []byte(strings.TrimSpace(reg.Start)),
43+
end: []byte(strings.TrimSpace(reg.End)),
44+
}
45+
}
46+
3847
var ranges []lint.LineRange
3948
var diags []lint.Diagnostic
40-
for _, reg := range regions {
41-
rs, ds := scanOne(f, reg)
42-
ranges = append(ranges, rs...)
43-
diags = append(diags, ds...)
49+
for i, line := range f.Lines {
50+
lineNum := i + 1
51+
trimmed := bytes.TrimSpace(line)
52+
for s := range states {
53+
rs, ds := states[s].step(trimmed, lineNum)
54+
ranges = append(ranges, rs...)
55+
diags = append(diags, ds...)
56+
}
57+
}
58+
for s := range states {
59+
if ds := states[s].unclosed(); ds != nil {
60+
diags = append(diags, ds...)
61+
}
4462
}
4563
return ranges, diags
4664
}
4765

66+
// regionScanState tracks one declared region's marker pair across a
67+
// single walk of f.Lines. A single pass evaluates every region's
68+
// state against the line's shared trim, instead of Scan walking
69+
// f.Lines once per region and re-converting every line to a string
70+
// each time — the redundant re-scanning docs/development/
71+
// high-performance-go.md calls out.
72+
type regionScanState struct {
73+
start, end []byte
74+
openLine int // 1-based line of the current unclosed start; 0 when none
75+
}
76+
77+
// step evaluates one already-trimmed line against this region's
78+
// markers and returns any newly closed range or malformed-region
79+
// diagnostic.
80+
func (st *regionScanState) step(trimmed []byte, lineNum int) ([]lint.LineRange, []lint.Diagnostic) {
81+
switch {
82+
case bytes.Equal(trimmed, st.start):
83+
if st.openLine != 0 {
84+
return nil, []lint.Diagnostic{diag(lineNum, fmt.Sprintf(
85+
"duplicate foreign-region start marker %q before %q closed the open region",
86+
st.start, st.end))}
87+
}
88+
st.openLine = lineNum
89+
case bytes.Equal(trimmed, st.end):
90+
if st.openLine == 0 {
91+
return nil, []lint.Diagnostic{diag(lineNum, fmt.Sprintf(
92+
"foreign-region end marker %q without a matching start marker %q",
93+
st.end, st.start))}
94+
}
95+
r := lint.LineRange{From: st.openLine, To: lineNum}
96+
st.openLine = 0
97+
return []lint.LineRange{r}, nil
98+
}
99+
return nil, nil
100+
}
101+
102+
// unclosed reports the missing-end diagnostic for a region whose
103+
// start marker was never closed, once the whole file has been walked.
104+
func (st *regionScanState) unclosed() []lint.Diagnostic {
105+
if st.openLine == 0 {
106+
return nil
107+
}
108+
return []lint.Diagnostic{diag(st.openLine, fmt.Sprintf(
109+
"foreign-region start marker %q has no matching end marker %q",
110+
st.start, st.end))}
111+
}
112+
48113
// Apply appends the foreign-region spans for path to f.GeneratedRanges —
49114
// the same exclusion set that keeps style rules and fixers out of
50115
// `<?include?>` / `<?catalog?>` bodies — and returns the malformed-region
@@ -94,46 +159,6 @@ func resolve(f *lint.File, cfg *config.Config, path string) ([]lint.LineRange, [
94159
return ranges, diags
95160
}
96161

97-
// scanOne walks f.Lines once for a single marker pair, matching a line
98-
// against a marker by trimmed-line equality so leading indentation does
99-
// not defeat the match while incidental in-prose mentions do not.
100-
func scanOne(f *lint.File, reg config.ForeignRegion) ([]lint.LineRange, []lint.Diagnostic) {
101-
start := strings.TrimSpace(reg.Start)
102-
end := strings.TrimSpace(reg.End)
103-
var ranges []lint.LineRange
104-
var diags []lint.Diagnostic
105-
openLine := 0 // 1-based line of the current unclosed start; 0 when none
106-
for i, line := range f.Lines {
107-
lineNum := i + 1
108-
trimmed := strings.TrimSpace(string(line))
109-
switch trimmed {
110-
case start:
111-
if openLine != 0 {
112-
diags = append(diags, diag(lineNum, fmt.Sprintf(
113-
"duplicate foreign-region start marker %q before %q closed the open region",
114-
start, end)))
115-
continue
116-
}
117-
openLine = lineNum
118-
case end:
119-
if openLine == 0 {
120-
diags = append(diags, diag(lineNum, fmt.Sprintf(
121-
"foreign-region end marker %q without a matching start marker %q",
122-
end, start)))
123-
continue
124-
}
125-
ranges = append(ranges, lint.LineRange{From: openLine, To: lineNum})
126-
openLine = 0
127-
}
128-
}
129-
if openLine != 0 {
130-
diags = append(diags, diag(openLine, fmt.Sprintf(
131-
"foreign-region start marker %q has no matching end marker %q",
132-
start, end)))
133-
}
134-
return ranges, diags
135-
}
136-
137162
// diag builds one malformed-region diagnostic at the given 1-based line
138163
// (in f.Lines coordinates; the caller adds any front-matter offset).
139164
func diag(line int, msg string) lint.Diagnostic {

internal/foreignregion/scan_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,49 @@ func TestScanIndentedMarkerMatches(t *testing.T) {
103103
require.Len(t, ranges, 1)
104104
assert.Equal(t, lint.LineRange{From: 1, To: 3}, ranges[0])
105105
}
106+
107+
// TestScanMultipleRegions_AllocsScaleWithLinesNotRegions pins
108+
// docs/development/high-performance-go.md's "stay in []byte" and
109+
// "skip redundant re-scanning" patterns for Scan with more than one
110+
// declared region. Before this test, Scan called scanOne once per
111+
// region, and each scanOne call re-walked the whole f.Lines slice
112+
// converting every line via strings.TrimSpace(string(line)) — an
113+
// O(regions x lines) cost with one string allocation per line per
114+
// region, even though a single per-line trim can be checked against
115+
// every region's markers in the same pass.
116+
//
117+
// The assertion: allocs for 5 regions must not exceed roughly what 1
118+
// region costs by more than a small per-region constant (each region
119+
// still needs its own open-marker state and, on this fixture, no
120+
// diagnostics) — a multi-pass implementation would instead scale
121+
// allocs linearly with len(f.Lines) times len(regions).
122+
func TestScanMultipleRegions_AllocsScaleWithLinesNotRegions(t *testing.T) {
123+
var src []byte
124+
for i := 0; i < 100; i++ {
125+
src = append(src, []byte("A representative line of prose in the file.\n")...)
126+
}
127+
f := newFile(t, string(src))
128+
129+
oneRegion := []config.ForeignRegion{apm}
130+
fiveRegions := []config.ForeignRegion{
131+
apm,
132+
{Start: "<!-- r2:start -->", End: "<!-- r2:end -->"},
133+
{Start: "<!-- r3:start -->", End: "<!-- r3:end -->"},
134+
{Start: "<!-- r4:start -->", End: "<!-- r4:end -->"},
135+
{Start: "<!-- r5:start -->", End: "<!-- r5:end -->"},
136+
}
137+
138+
oneAllocs := testing.AllocsPerRun(20, func() { Scan(f, oneRegion) })
139+
fiveAllocs := testing.AllocsPerRun(20, func() { Scan(f, fiveRegions) })
140+
141+
// A single-pass, []byte-only scan pays a near-constant per-region
142+
// setup cost (a states slice entry) regardless of file size; a
143+
// multi-pass, string()-converting scan pays roughly 5x oneAllocs
144+
// (100 lines re-converted per extra region). 3x oneAllocs+10 is
145+
// comfortably below the multi-pass cost and above single-pass
146+
// noise.
147+
assert.LessOrEqualf(t, fiveAllocs, oneAllocs*3+10,
148+
"Scan with 5 regions allocs (%v) must not scale with lines-per-region "+
149+
"(1-region allocs: %v) — each region should reuse one per-line trim",
150+
fiveAllocs, oneAllocs)
151+
}

internal/lint/file.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,11 @@ type memoEntry struct {
263263
//
264264
// build is invoked directly (no wrapping closure) so the call adds
265265
// no per-Memo-call allocation beyond the cold-path memoEntry itself.
266+
// The warm path checks Load before LoadOrStore for the same reason:
267+
// LoadOrStore's second argument (&memoEntry{}) is constructed before
268+
// the call and discarded whenever the key already exists, so a plain
269+
// LoadOrStore would allocate one on every call regardless of hit or
270+
// miss.
266271
//
267272
// Panic safety mirrors sync.Once: if build panics, the entry is
268273
// still marked done (via the deferred Store) and the mutex is
@@ -271,8 +276,16 @@ type memoEntry struct {
271276
// Subsequent calls on the same key serve the zero-value cached
272277
// result instead of re-running build, matching upstream sync.Once.
273278
func (f *File) Memo(key string, build func() any) any {
279+
if v, ok := f.scratch.Load(key); ok {
280+
return memoLoad(v.(*memoEntry), build)
281+
}
274282
ei, _ := f.scratch.LoadOrStore(key, &memoEntry{})
275-
e := ei.(*memoEntry)
283+
return memoLoad(ei.(*memoEntry), build)
284+
}
285+
286+
// memoLoad runs build at most once for e, then returns the cached
287+
// value — the double-checked-lock body shared by Memo and MemoFile.
288+
func memoLoad(e *memoEntry, build func() any) any {
276289
if e.done.Load() {
277290
return e.val
278291
}
@@ -294,10 +307,16 @@ func (f *File) Memo(key string, build func() any) any {
294307
//
295308
// Panic safety matches Memo's contract: defer Unlock + defer
296309
// done.Store(true) keep the per-entry mutex from leaking a lock and
297-
// match sync.Once's "panic still marks done" semantics.
310+
// match sync.Once's "panic still marks done" semantics. The warm
311+
// path checks Load before LoadOrStore for the same reason Memo does.
298312
func (f *File) MemoFile(key string, build func(*File) any) any {
299-
ei, _ := f.scratch.LoadOrStore(key, &memoEntry{})
300-
e := ei.(*memoEntry)
313+
var e *memoEntry
314+
if v, ok := f.scratch.Load(key); ok {
315+
e = v.(*memoEntry)
316+
} else {
317+
ei, _ := f.scratch.LoadOrStore(key, &memoEntry{})
318+
e = ei.(*memoEntry)
319+
}
301320
if e.done.Load() {
302321
return e.val
303322
}

internal/lint/file_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,30 @@ func TestFile_Memo(t *testing.T) {
266266
"a distinct key must not re-run the first key's build")
267267
}
268268

269+
// TestFile_Memo_WarmPathAllocatesNothing pins Memo's cache-hit cost at
270+
// zero allocs. MemoFile.CollectSectionParagraphs feeds every
271+
// paragraph-aware rule, so a per-call allocation on the warm path
272+
// multiplies by every rule pass that re-touches an already-memoized
273+
// key on the same File.
274+
//
275+
// Before this test, the warm path still paid for the throwaway
276+
// &memoEntry{} that f.scratch.LoadOrStore's second argument
277+
// constructs before the call — Go evaluates that argument whether or
278+
// not the key is already present, and the freshly built entry is
279+
// discarded on a hit. A Load-first check must run ahead of
280+
// LoadOrStore to avoid it.
281+
func TestFile_Memo_WarmPathAllocatesNothing(t *testing.T) {
282+
f := &File{Path: "t.md"}
283+
build := func() any { return 42 }
284+
285+
f.Memo("k", build)
286+
287+
allocs := testing.AllocsPerRun(200, func() {
288+
f.Memo("k", build)
289+
})
290+
assert.Zero(t, allocs, "Memo's cache-hit path must not allocate")
291+
}
292+
269293
// TestFile_Memo_ConcurrentSingleBuild pins that build runs exactly
270294
// once even under the concurrent readers the LSP can run against a
271295
// single document.

internal/lint/runcache.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package lint
33
import (
44
"strings"
55
"sync"
6+
"sync/atomic"
67
)
78

89
// RunCache memoizes per-target-file reads (front matter, include
@@ -73,10 +74,16 @@ type RunCache struct {
7374
}
7475

7576
// runCacheEntry guards a single cache slot so build runs exactly once
76-
// per key even when multiple goroutines race for it.
77+
// per key even when multiple goroutines race for it. atomic.Bool +
78+
// mutex is used instead of sync.Once, matching file.go's memoEntry:
79+
// once.Do takes a func() argument, and the closure load would pass
80+
// (`func() { e.val = build() }`) captures e and build, so it
81+
// allocates on every call regardless of whether Do's internal check
82+
// makes it a no-op.
7783
type runCacheEntry struct {
78-
once sync.Once
7984
val any
85+
done atomic.Bool
86+
mu sync.Mutex
8087
}
8188

8289
// ParsedSchemaMetadata is the optional interface a parsed-schema
@@ -583,10 +590,33 @@ func (c *RunCache) InvalidateWikilinks() {
583590
})
584591
}
585592

586-
// load is the shared LoadOrStore + sync.Once primitive for both maps.
593+
// load is the shared cache-slot primitive for every RunCache map. It
594+
// checks Load before LoadOrStore so the warm (already-built) path
595+
// never constructs the throwaway &runCacheEntry{} that LoadOrStore's
596+
// second argument would otherwise allocate on every call — the same
597+
// value gets discarded whenever the key is already present, but Go
598+
// evaluates that argument before LoadOrStore can say so. build is
599+
// invoked directly (no wrapping closure), mirroring file.go's Memo.
587600
func load(m *sync.Map, key string, build func() any) any {
601+
if v, ok := m.Load(key); ok {
602+
return loadEntry(v.(*runCacheEntry), build)
603+
}
588604
ei, _ := m.LoadOrStore(key, &runCacheEntry{})
589-
e := ei.(*runCacheEntry)
590-
e.once.Do(func() { e.val = build() })
605+
return loadEntry(ei.(*runCacheEntry), build)
606+
}
607+
608+
// loadEntry runs build at most once for e, then returns the cached
609+
// value. The atomic.Bool fast path costs one atomic load on every
610+
// warm call; the mutex only guards the cold build.
611+
func loadEntry(e *runCacheEntry, build func() any) any {
612+
if e.done.Load() {
613+
return e.val
614+
}
615+
e.mu.Lock()
616+
defer e.mu.Unlock()
617+
if !e.done.Load() {
618+
defer e.done.Store(true)
619+
e.val = build()
620+
}
591621
return e.val
592622
}

internal/lint/runcache_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,3 +1071,31 @@ func TestDuplicateParagraphs_InvalidateDropsEveryKeyForPath(t *testing.T) {
10711071
assert.Equal(t, 5, builds,
10721072
"both a.md keys must rebuild; b.md's slot must survive untouched")
10731073
}
1074+
1075+
// TestLoad_WarmPathAllocatesNothing pins load's cache-hit cost at zero
1076+
// allocs. Every RunCache accessor (FrontMatter, Includes, GlobMatches,
1077+
// ParsedSchema, DuplicateParagraphs, Wikilinks, CompiledCUE,
1078+
// UniqueFieldIndex) is called at least once per host file across a
1079+
// workspace run, so a per-call allocation on the warm path multiplies
1080+
// by the corpus size.
1081+
//
1082+
// Before this test, load's warm path paid two allocations per call
1083+
// regardless of hit/miss: LoadOrStore's second argument (&runCacheEntry{})
1084+
// is constructed before the call and discarded on a hit, and
1085+
// e.once.Do(func() { e.val = build() }) allocates the wrapping closure
1086+
// as an argument even when Do's internal check makes it a no-op — the
1087+
// exact closure-box anti-pattern file.go's memoEntry doc comment
1088+
// describes fixing for File.Memo, which had the same gap: a Load-first
1089+
// check before the throwaway LoadOrStore value must run first.
1090+
func TestLoad_WarmPathAllocatesNothing(t *testing.T) {
1091+
var m sync.Map
1092+
build := func() any { return 42 }
1093+
1094+
// Warm the entry.
1095+
load(&m, "k", build)
1096+
1097+
allocs := testing.AllocsPerRun(200, func() {
1098+
load(&m, "k", build)
1099+
})
1100+
assert.Zero(t, allocs, "load's cache-hit path must not allocate")
1101+
}

internal/rules/crossfilereferenceintegrity/layout_test.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ import (
88
// TestStructLayout asserts the optimal size for the Rule struct.
99
// Moving bool fields to the end (previously between larger fields, wasting
1010
// padding bytes) reduces size from 128 to 120 bytes and improves cache
11-
// utilisation across per-Check calls.
11+
// utilisation across per-Check calls. The cachedGlobSettingsErr memo
12+
// (globSettingsErr, globSettingsMu, globSettingsDone) added 24 bytes of
13+
// data; grouped with the two trailing bools, that 14-byte tail block
14+
// still rounds up to a 16-byte multiple of the struct's 8-byte
15+
// alignment, so the total grows from 120 to 144 with no extra padding
16+
// beyond that unavoidable 2-byte tail.
1217
func TestStructLayout(t *testing.T) {
1318
got := unsafe.Sizeof(Rule{})
14-
const want = uintptr(120)
19+
const want = uintptr(144)
1520
if got != want {
1621
t.Errorf("unsafe.Sizeof(Rule{}) = %d; want %d (reorder fields to eliminate padding)",
1722
got, want)

0 commit comments

Comments
 (0)