Skip to content

Commit bc75f47

Browse files
author
merge-queue-bot
committed
Merge PR #833: perf: fix top 5 high-performance-go violations (struct layout + allocations)
2 parents b3f414a + e66c831 commit bc75f47

12 files changed

Lines changed: 183 additions & 9 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package linkvalidity
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/jeduden/mdsmith/internal/structlayout"
8+
)
9+
10+
// TestRevMatchFieldOrder guards the GC ptrdata region of revMatch:
11+
// the pointer-containing text and url byte slices must precede the
12+
// scalar col0 and matchEnd ints. Go's GC ptrdata for a struct spans
13+
// from offset 0 through the last pointer-containing field's pointer
14+
// word; with the scalars first (the prior order), ptrdata still had
15+
// to cover both slices, so grouping the slices first instead shrinks
16+
// ptrdata from 48 bytes to 32 on a 64-bit platform. See
17+
// docs/development/high-performance-go.md "Struct layout". The test
18+
// fails (red) until col0 and matchEnd are moved after text and url.
19+
func TestRevMatchFieldOrder(t *testing.T) {
20+
structlayout.AssertPointerFieldsFirst(t, reflect.TypeOf(revMatch{}))
21+
}

internal/rules/linkvalidity/rule.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ func isInlineNode(n ast.Node) bool {
219219
// --- reversed-link scan (MD011) ---
220220

221221
type revMatch struct {
222-
col0 int // 0-based byte index of '(' within the line
223-
matchEnd int // exclusive byte index just past ']' within the line
224222
text []byte
225223
url []byte
224+
col0 int // 0-based byte index of '(' within the line
225+
matchEnd int // exclusive byte index just past ']' within the line
226226
}
227227

228228
// reversedNeedle is the two-byte sequence every reversedRe match must
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package noinlinehtml
2+
3+
import "testing"
4+
5+
// allocBudgetExtractTagUppercase is the per-call ceiling for
6+
// extractTag on a mixed-case tag name. tagNameRe.FindSubmatch pays
7+
// one allocation for the match slice; the old
8+
// strings.ToLower(string(m[1])) paid two more whenever m[1] contained
9+
// an uppercase byte — the string(m[1]) copy, then strings.ToLower's
10+
// own buffer since it cannot return the input unchanged.
11+
// asciiLowerTag folds those two into one allocation, bringing the
12+
// total from 3 down to 2 — see
13+
// docs/development/high-performance-go.md "Allocations."
14+
const allocBudgetExtractTagUppercase = 2
15+
16+
func TestExtractTagUppercaseAllocBudget(t *testing.T) {
17+
if testing.Short() {
18+
t.Skip("alloc gate skipped in -short mode")
19+
}
20+
if raceEnabled {
21+
t.Skip("alloc gate skipped under -race")
22+
}
23+
raw := []byte("<DIV class=\"x\">")
24+
_ = extractTag(raw) // warm up regex program cache
25+
allocs := testing.AllocsPerRun(200, func() {
26+
_ = extractTag(raw)
27+
})
28+
t.Logf("extractTag(uppercase) allocs/op = %.0f (budget = %d)", allocs, allocBudgetExtractTagUppercase)
29+
if allocs > float64(allocBudgetExtractTagUppercase) {
30+
t.Fatalf("extractTag(uppercase) allocs/op = %.0f, budget = %d: lowercase the "+
31+
"matched bytes directly instead of string(m[1]) followed by strings.ToLower",
32+
allocs, allocBudgetExtractTagUppercase)
33+
}
34+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//go:build !race
2+
3+
package noinlinehtml
4+
5+
const raceEnabled = false
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//go:build race
2+
3+
package noinlinehtml
4+
5+
const raceEnabled = true

internal/rules/noinlinehtml/rule.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,30 @@ func extractTag(raw []byte) string {
284284
if m == nil {
285285
return ""
286286
}
287-
return strings.ToLower(string(m[1]))
287+
return asciiLowerTag(m[1])
288+
}
289+
290+
// asciiLowerTag returns the lowercased tag name for b in one
291+
// allocation. tagNameRe only matches [a-zA-Z][a-zA-Z0-9-]*, so b is
292+
// always ASCII: strings.ToLower(string(b)) allocates the string(b)
293+
// copy and then, for any uppercase input, a second buffer inside
294+
// strings.ToLower — see docs/development/high-performance-go.md's
295+
// allocation guidance on avoiding a redundant intermediate copy.
296+
func asciiLowerTag(b []byte) string {
297+
for _, c := range b {
298+
if 'A' <= c && c <= 'Z' {
299+
var sb strings.Builder
300+
sb.Grow(len(b))
301+
for _, c := range b {
302+
if 'A' <= c && c <= 'Z' {
303+
c += 'a' - 'A'
304+
}
305+
sb.WriteByte(c)
306+
}
307+
return sb.String()
308+
}
309+
}
310+
return string(b)
288311
}
289312

290313
func isClosingTag(raw []byte) bool {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package samefileanchor
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/jeduden/mdsmith/internal/structlayout"
8+
)
9+
10+
// TestAnchorCheckerFieldOrder guards the GC ptrdata region of
11+
// anchorChecker: built (a bool, no pointers) must come after the
12+
// pointer-containing r, f, slugs, and diags fields. Go's GC ptrdata
13+
// for a struct spans from offset 0 through the end of the last
14+
// pointer-containing field, so a scalar declared before diags (the
15+
// last pointer field) costs GC scan bytes for nothing — a saving of
16+
// one word on a 64-bit platform. See
17+
// docs/development/high-performance-go.md "Struct layout". The test
18+
// fails (red) until built is moved after diags.
19+
func TestAnchorCheckerFieldOrder(t *testing.T) {
20+
structlayout.AssertPointerFieldsFirst(t, reflect.TypeOf(anchorChecker{}))
21+
}

internal/rules/samefileanchor/rule.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ type anchorChecker struct {
7272
r *Rule
7373
f *lint.File
7474
slugs map[string]struct{}
75-
built bool
7675
diags []lint.Diagnostic
76+
built bool
7777
}
7878

7979
// visit records a diagnostic when n is a same-file fragment link whose
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package tableformat
2+
3+
import (
4+
"testing"
5+
6+
"github.com/jeduden/mdsmith/internal/lint"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// TestCheckColumnCountCompliant_NoAlloc pins that checkColumnCount
11+
// returns nil, with zero allocations, when every row's cell count
12+
// matches the header. The old make([]lint.Diagnostic, 0,
13+
// len(t.rows)-1) allocated a slice on every call and then discarded
14+
// it via `if len(diags) == 0 { return nil }` on this common
15+
// compliant-table path — see docs/development/high-performance-go.md's
16+
// "Return nil, not []T{}" pattern.
17+
func TestCheckColumnCountCompliant_NoAlloc(t *testing.T) {
18+
f, err := lint.NewFile("t.md", []byte("| a | b |\n|---|---|\n| 1 | 2 |\n"))
19+
require.NoError(t, err)
20+
tables := findStructureTables(f.Lines, structureSkipFunc(f))
21+
require.Len(t, tables, 1)
22+
tbl := tables[0]
23+
24+
if got := checkColumnCount(f, tbl, "MDS025", "table-format"); got != nil {
25+
t.Fatalf("checkColumnCount on a compliant table = %#v, want nil", got)
26+
}
27+
28+
if testing.Short() {
29+
t.Skip("alloc gate skipped in -short mode")
30+
}
31+
if raceEnabled {
32+
t.Skip("alloc gate skipped under -race")
33+
}
34+
allocs := testing.AllocsPerRun(200, func() {
35+
_ = checkColumnCount(f, tbl, "MDS025", "table-format")
36+
})
37+
t.Logf("checkColumnCount(compliant) allocs/op = %.0f", allocs)
38+
if allocs > 0 {
39+
t.Fatalf("checkColumnCount(compliant) allocs/op = %.0f, want 0: allocate diags "+
40+
"lazily via append instead of an eager make() that gets discarded", allocs)
41+
}
42+
}

internal/rules/tableformat/structure.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,17 +211,17 @@ func checkPipeStyle(f *lint.File, t tableBlock, style, ruleID, ruleName string)
211211

212212
func checkColumnCount(f *lint.File, t tableBlock, ruleID, ruleName string) []lint.Diagnostic {
213213
want := t.rows[0].cells
214-
diags := make([]lint.Diagnostic, 0, len(t.rows)-1)
214+
// Most tables are column-count compliant; an eager make() here
215+
// would allocate a slice only to discard it on that common path,
216+
// so diags starts nil and grows lazily instead.
217+
var diags []lint.Diagnostic
215218
for _, row := range t.rows[1:] {
216219
if row.cells == want {
217220
continue
218221
}
219222
diags = append(diags, structureDiag(f, row.lineNum, 1, ruleID, ruleName,
220223
fmt.Sprintf("table column count; expected %d, got %d", want, row.cells)))
221224
}
222-
if len(diags) == 0 {
223-
return nil
224-
}
225225
return diags
226226
}
227227

0 commit comments

Comments
 (0)