Skip to content

Commit 1599c9f

Browse files
author
merge-queue-bot
committed
Merge PR #683: perf: reduce GC scan span and eliminate padding in hot structs
2 parents 7d37ea5 + e0efed0 commit 1599c9f

10 files changed

Lines changed: 171 additions & 37 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package crossfilereferenceintegrity
2+
3+
import (
4+
"testing"
5+
"unsafe"
6+
)
7+
8+
// TestStructLayout asserts the optimal size for the Rule struct.
9+
// Moving bool fields to the end (previously between larger fields, wasting
10+
// padding bytes) reduces size from 128 to 120 bytes and improves cache
11+
// utilisation across per-Check calls.
12+
func TestStructLayout(t *testing.T) {
13+
got := unsafe.Sizeof(Rule{})
14+
const want = uintptr(120)
15+
if got != want {
16+
t.Errorf("unsafe.Sizeof(Rule{}) = %d; want %d (reorder fields to eliminate padding)",
17+
got, want)
18+
}
19+
}

internal/rules/crossfilereferenceintegrity/rule.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,16 @@ type LinksConfig struct {
3333

3434
// Rule checks Markdown links for missing target files and missing heading
3535
// anchors in linked Markdown files.
36+
// Fields are ordered large-to-small to eliminate bool-induced padding:
37+
// slices first, then string, then embedded struct, then bools last.
3638
type Rule struct {
3739
Include []string
3840
Exclude []string
39-
Strict bool
4041
Placeholders []string // placeholder tokens to treat as opaque
41-
Wikilinks bool // when true, validate Obsidian-style [[...]] targets
4242
WikilinkStyle string // resolution style; only "obsidian" ships today
4343
Links LinksConfig
44+
Strict bool
45+
Wikilinks bool // when true, validate Obsidian-style [[...]] targets
4446
}
4547

4648
// ID implements rule.Rule.
@@ -61,15 +63,18 @@ func (r *Rule) Category() string { return "link" }
6163
// relative-link check needs them and the cache is package-scope
6264
// (cachedAbsRoot) so the cost is paid once across all Files in
6365
// one run. Plan 195 task 5.
66+
// checkCtx fields are ordered so pointer-containing fields (maps, then
67+
// strings) precede the bool, reducing the GC pointer-scan span from 72 to 56.
6468
type checkCtx struct {
65-
f *lint.File
66-
rule *Rule
69+
f *lint.File
70+
rule *Rule
71+
selfAnchors map[string]struct{}
72+
anchorCache map[string]map[string]struct{}
73+
6774
resolvedRoot string
6875
resolvedSiteRoot string
6976

70-
selfAnchors map[string]struct{}
7177
selfAnchorsBuilt bool
72-
anchorCache map[string]map[string]struct{}
7378
}
7479

7580
// ensureSelfAnchors lazily builds the heading-anchor set for f.
@@ -268,12 +273,15 @@ func wikilinkRoot(f *lint.File) fs.FS {
268273
// via f.RunCache), the resolver serves every lookup from that
269274
// index — turning N files × M targets × workspace-walk into one
270275
// walk per workspace.
276+
// wikilinkResolver fields are ordered so the interface (root) and pointer
277+
// fields (index, memory) precede the strings, reducing GC pointer-scan span
278+
// from 64 to 56 bytes.
271279
type wikilinkResolver struct {
272280
root fs.FS
273-
from string
274-
style string
275281
index *linkgraph.WikilinkIndex
276282
memory map[string]wikilinkResolveResult
283+
from string
284+
style string
277285
}
278286

279287
type wikilinkResolveResult struct {
@@ -766,7 +774,10 @@ func (r *Rule) SettingMergeMode(key string) rule.MergeMode {
766774
return rule.MergeReplace
767775
}
768776

777+
// targetFile fields are ordered so the func field (a single pointer) precedes
778+
// the strings, reducing the GC pointer-scan span from 40 to 32 bytes.
769779
type targetFile struct {
780+
read func() ([]byte, error)
770781
// cacheKey is the per-Check cache key (the `cache` map in
771782
// anchorsForFile). Prefixed with "os:" or "fs:" so OS and FS
772783
// resolutions of the same path do not collide within one
@@ -779,7 +790,6 @@ type targetFile struct {
779790
// runCacheKey signals "skip the RunCache slot, use the
780791
// per-Check cache only".
781792
runCacheKey string
782-
read func() ([]byte, error)
783793
}
784794

785795
func anchorsForFile(

internal/rules/listscan/listscan.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,24 @@ type Item struct {
3535
// inside a once-nested list, and so on. It matches the count of
3636
// *ast.ListItem ancestors in goldmark's tree.
3737
Level int
38-
// Ordered reports whether the item belongs to an ordered list.
39-
Ordered bool
4038
// Number is the literal ordered number written on the marker line
4139
// (ordered items only; 0 for bullets).
4240
Number int
43-
// Marker is the marker byte: '-', '*', or '+' for bullets; '.' or
44-
// ')' for ordered items.
45-
Marker byte
41+
// Ordered reports whether the item belongs to an ordered list.
42+
Ordered bool
4643
// MultiBlock reports whether the item contains more than one block
4744
// child, matching goldmark's isMultiItem (ListItem.ChildCount > 1).
4845
MultiBlock bool
46+
// Marker is the marker byte: '-', '*', or '+' for bullets; '.' or
47+
// ')' for ordered items.
48+
Marker byte
4949
}
5050

5151
// List is one parsed list: a maximal run of sibling items at the same
5252
// nesting level with the same ordered-ness.
5353
type List struct {
54-
// Ordered reports whether this is an ordered list.
55-
Ordered bool
54+
// Items holds the list's direct child items in document order.
55+
Items []Item
5656
// Start is the list's start value: for an ordered list, the literal
5757
// number of its first item (matching goldmark list.Start); 0 for an
5858
// unordered list.
@@ -65,10 +65,10 @@ type List struct {
6565
// LastLine is the 1-based source line of the list's last content
6666
// line (including continuation and nested-list lines).
6767
LastLine int
68+
// Ordered reports whether this is an ordered list.
69+
Ordered bool
6870
// TopLevel reports whether the list has no *ast.ListItem ancestor.
6971
TopLevel bool
70-
// Items holds the list's direct child items in document order.
71-
Items []Item
7272
}
7373

7474
// Parse scans lines and returns every list in document order plus a flat
@@ -111,6 +111,11 @@ type frame struct {
111111
// blockCount counts the item's block children, mirroring goldmark's
112112
// ChildCount used by isMultiItem.
113113
blockCount int
114+
// childListIndex is the index of the open child list nested directly
115+
// under this item, or -1 when none is open. It lets a following
116+
// nested marker rejoin the same child list instead of starting a new
117+
// one.
118+
childListIndex int
114119
// pendingBlank records a blank line seen while this item is open but
115120
// not yet followed by a continuation; it makes the next content line
116121
// start a new block child.
@@ -119,11 +124,6 @@ type frame struct {
119124
// item was paragraph text, so a following text line at lower indent
120125
// can lazily continue it.
121126
inParagraph bool
122-
// childListIndex is the index of the open child list nested directly
123-
// under this item, or -1 when none is open. It lets a following
124-
// nested marker rejoin the same child list instead of starting a new
125-
// one.
126-
childListIndex int
127127
// emptyLine is true while the item has no recorded source line yet
128128
// (an empty marker line whose content has not arrived). The first
129129
// continuation line that attaches content sets the item's Line.
@@ -133,10 +133,10 @@ type frame struct {
133133
type parser struct {
134134
lines [][]byte
135135
lists []List
136-
// itemCount counts appended items for sizing the flat slice in Parse.
137-
itemCount int
138136
// stack holds the currently open list items, outermost first.
139137
stack []frame
138+
// itemCount counts appended items for sizing the flat slice in Parse.
139+
itemCount int
140140
// blankRun counts consecutive blank lines pending before the current
141141
// line.
142142
blankRun int
@@ -360,10 +360,10 @@ func hasMarkerToken(line []byte, indent int) bool {
360360

361361
// markerInfo describes a recognized list-item marker on a line.
362362
type markerInfo struct {
363-
ordered bool
364363
number int
365-
marker byte
366364
contentCol int
365+
ordered bool
366+
marker byte
367367
// empty reports that the marker line carries no content after the
368368
// marker. goldmark gives such an item no source line of its own, so
369369
// its recorded Line is 0 until a continuation line attaches content;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package listscan
2+
3+
import (
4+
"testing"
5+
"unsafe"
6+
)
7+
8+
// TestStructLayout asserts the optimal field-aligned sizes for the hot-path
9+
// structs in this package. Each struct is allocated in slices during Parse;
10+
// tighter layouts reduce per-Check memory and improve cache utilisation.
11+
// The test fails (red) until fields are reordered to achieve the target sizes.
12+
func TestStructLayout(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
got uintptr
16+
want uintptr
17+
}{
18+
{"Item", unsafe.Sizeof(Item{}), 32},
19+
{"List", unsafe.Sizeof(List{}), 64},
20+
{"frame", unsafe.Sizeof(frame{}), 48},
21+
{"markerInfo", unsafe.Sizeof(markerInfo{}), 24},
22+
}
23+
for _, tc := range tests {
24+
if tc.got != tc.want {
25+
t.Errorf("unsafe.Sizeof(%s{}) = %d; want %d (reorder fields to eliminate padding)",
26+
tc.name, tc.got, tc.want)
27+
}
28+
}
29+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package tablefmt
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// TestStripPrefixNoAlloc asserts that stripPrefix performs zero heap
8+
// allocations when given a non-empty prefix. The previous implementation
9+
// allocated string(line) and []byte(…) — two allocs per table row.
10+
func TestStripPrefixNoAlloc(t *testing.T) {
11+
if raceEnabled {
12+
t.Skip("alloc gate skipped under -race")
13+
}
14+
line := []byte("> | col | other |")
15+
prefix := "> "
16+
var sink []byte
17+
allocs := testing.AllocsPerRun(100, func() {
18+
sink = stripPrefix(line, prefix)
19+
})
20+
_ = sink
21+
if allocs != 0 {
22+
t.Errorf("stripPrefix allocated %.0f times; want 0", allocs)
23+
}
24+
}

internal/rules/tablefmt/tablefmt.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import (
1515

1616
// Violation describes a single table whose source formatting differs
1717
// from the canonical layout produced by this package.
18+
// Message (a string, with a pointer) precedes StartLine to minimise GC scan span.
1819
type Violation struct {
19-
StartLine int // 1-based line number of the table's first row
2020
Message string // diagnostic message including the first differing row
21+
StartLine int // 1-based line number of the table's first row
2122
}
2223

2324
// Config controls how tables are formatted.
@@ -171,18 +172,23 @@ func normalizeConfig(cfg Config) Config {
171172
}
172173

173174
// table represents a parsed markdown table with its source location.
175+
// Fields are ordered so slices and the string (all pointer-bearing) precede
176+
// the scalar int, and the string sits between the two slices so all
177+
// pointers are contiguous — reducing GC pointer-scan span from 56 to 48.
174178
type table struct {
175-
startLine int // 1-based line number of the first row
176179
rawLines [][]byte // raw source lines (including prefix)
177180
prefix string // blockquote/list prefix (e.g. "> ", " ")
178181
rows []row // parsed rows (header, separator, data)
182+
startLine int // 1-based line number of the first row
179183
}
180184

181185
// row is a single table row with its cells.
186+
// Fields are ordered so pointer-containing fields (slices) precede the bool,
187+
// minimising the GC pointer-scan span.
182188
type row struct {
183189
cells []string // trimmed cell contents
184-
isSeparator bool // true for the separator row (|---|---|)
185190
alignments []align // alignment per column (only for separator row)
191+
isSeparator bool // true for the separator row (|---|---|)
186192
}
187193

188194
// align represents column alignment in a table.
@@ -434,13 +440,16 @@ func detectPrefix(line []byte) string {
434440
}
435441

436442
// stripPrefix removes the detected prefix from a line.
443+
// Uses the compiler-optimised string(line[:n]) == prefix comparison
444+
// (same pattern as tableformat/structure.go rowContent) to avoid
445+
// allocating a temporary string copy of the full line.
437446
func stripPrefix(line []byte, prefix string) []byte {
438-
if prefix == "" {
447+
plen := len(prefix)
448+
if plen == 0 || len(line) < plen {
439449
return line
440450
}
441-
s := string(line)
442-
if strings.HasPrefix(s, prefix) {
443-
return []byte(s[len(prefix):])
451+
if string(line[:plen]) == prefix {
452+
return line[plen:]
444453
}
445454
return line
446455
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package tableformat
2+
3+
import (
4+
"testing"
5+
"unsafe"
6+
)
7+
8+
// TestRuleFieldOrder asserts that the Style string field comes first in the
9+
// MDS025 Rule struct so that the GC pointer-scan span is minimised to 8 bytes
10+
// (just the string data pointer at offset 0) rather than 24 bytes (when Style
11+
// sat after two int fields). The test fails (red) until fields are reordered.
12+
func TestRuleFieldOrder(t *testing.T) {
13+
got := unsafe.Offsetof(Rule{}.Style)
14+
const want = uintptr(0)
15+
if got != want {
16+
t.Errorf("unsafe.Offsetof(Rule.Style) = %d; want %d (move Style first to minimise GC scan)",
17+
got, want)
18+
}
19+
}

internal/rules/tableformat/rule.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ func init() {
2828
// Rule gates table well-formedness: edge-pipe style (MD055), column
2929
// count vs the header (MD056), surrounding blank lines (MD058), and
3030
// the column-alignment / padding pass that gives the rule its name.
31+
// Style (a string, containing a pointer) sits first to keep the GC
32+
// pointer-scan span at 8 bytes instead of 24.
3133
type Rule struct {
32-
Pad int // spaces on each side of cell content
33-
SeparatorStyle tablefmt.SeparatorStyle
3434
Style string // edge-pipe style: one of the Style* constants
35+
Pad int // spaces on each side of cell content
36+
SeparatorStyle tablefmt.SeparatorStyle
3537
}
3638

3739
// ID implements rule.Rule.

internal/rules/toc/layout_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package toc
2+
3+
import (
4+
"testing"
5+
"unsafe"
6+
)
7+
8+
// TestRuleFieldOrder asserts that the engine pointer field comes first in the
9+
// MDS038 Rule struct. The current layout puts engine after engineOnce (a
10+
// sync.Once = 12 bytes with no pointers, padded to 16 to align the pointer),
11+
// forcing GC to scan 24 bytes. Moving engine first reduces the GC pointer-scan
12+
// span to 8 bytes. The test fails (red) until fields are reordered.
13+
func TestRuleFieldOrder(t *testing.T) {
14+
got := unsafe.Offsetof(Rule{}.engine)
15+
const want = uintptr(0)
16+
if got != want {
17+
t.Errorf("unsafe.Offsetof(Rule.engine) = %d; want %d (move engine first to minimise GC scan)",
18+
got, want)
19+
}
20+
}

internal/rules/toc/rule.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ func init() {
2323
// singleton and the LSP server may call Check from concurrent
2424
// goroutines, where a plain check-then-set on the engine field
2525
// would race.
26+
// engine sits before engineOnce so the GC pointer-scan span is 8 bytes
27+
// (just the pointer) rather than 24 (pointer buried after sync.Once).
2628
type Rule struct {
27-
engineOnce sync.Once
2829
engine *gensection.Engine
30+
engineOnce sync.Once
2931
}
3032

3133
// ID implements rule.Rule.

0 commit comments

Comments
 (0)