Skip to content

Commit 543248f

Browse files
committed
Bound arena retention by bytes instead of object count
A live pointer into an arena chunk retains the entire backing allocation, including unused capacity and unrelated dead values. Resetting typed parser and binder arenas at every file multiplies terminal chunk slack. Uniform 256-object chunks are especially wasteful for large records. Target 512 bytes per chunk before allocator rounding, preserving stable direct pointers and exact-capacity slices. Larger contiguous slices keep their separate allocation path. No GC settings, cross-file pooling, or pointer decoding are introduced. Six fresh-process interleaved VS Code comparisons against the parent commit, using normal GC, reduce retained bytes 8.54%, scan bytes 10.44%, allocated bytes 5.74%, and whole-process instructions 9.43%. Allocation events rise 13.91%; wall time is inconclusive on the shared machine (p=0.937). Four-object chunks saved slightly more heap but raised allocation events 32.15%. A 512-byte target keeps most of the memory savings with less than half that allocation penalty. Reusing partial parser and binder chunks also saved memory but would couple otherwise independent file lifetimes. The retention regression fails with the old allocator. Mixed singleton, slice, clone, growth, zero-size, and GC cases preserve addresses and data. Production diagnostics and file, line, identifier, symbol, type, and instantiation counts match exactly. goos: linux goarch: amd64 pkg: github.com/microsoft/TypeScript/tsc/internal/checker cpu: Intel(R) Core(TM) i9-10900K CPU @ 3.70GHz │ vscode-arena-before.txt │ vscode-arena.txt │ │ retained-B │ retained-B vs base │ ExternalProgram-20 3.823Gi ± 0% 3.497Gi ± 0% -8.54% (p=0.002 n=6) │ vscode-arena-before.txt │ vscode-arena.txt │ │ scan-B │ scan-B vs base │ ExternalProgram-20 3.418Gi ± 0% 3.062Gi ± 0% -10.44% (p=0.002 n=6) │ vscode-arena-before.txt │ vscode-arena.txt │ │ B/op │ B/op vs base │ ExternalProgram-20 5.442Gi ± 0% 5.130Gi ± 0% -5.74% (p=0.002 n=6) │ vscode-arena-before.txt │ vscode-arena.txt │ │ allocs/op │ allocs/op vs base │ ExternalProgram-20 27.36M ± 0% 31.17M ± 0% +13.91% (p=0.002 n=6) │ arena-before-instructions.txt │ arena-instructions.txt │ │ instructions/op │ instructions/op vs base │ ExternalInstructions-20 300.8G ± 3% 272.5G ± 6% -9.43% (p=0.002 n=6)
1 parent 296bafa commit 543248f

2 files changed

Lines changed: 115 additions & 8 deletions

File tree

‎tsc/internal/core/arena.go‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package core
22

3-
import "slices"
3+
import (
4+
"slices"
5+
"unsafe"
6+
)
47

58
// Arena allocator
69

710
type Arena[T any] struct {
811
data []T
912
}
1013

11-
// Allocate a single element in the arena and return a pointer to the element. If the arena is at capacity,
12-
// a new arena of the next size up is allocated.
14+
// Allocate a single element at a stable address. Small chunks bound the unused
15+
// capacity and unrelated values retained by a surviving pointer into a chunk.
1316
func (a *Arena[T]) New() *T {
1417
if len(a.data) == cap(a.data) {
15-
nextSize := nextArenaSize(len(a.data))
18+
nextSize := nextArenaSize[T](len(a.data))
1619
// Use the same trick as slices.Concat; Grow rounds up to the next size class.
1720
a.data = slices.Grow[[]T](nil, nextSize)
1821
}
@@ -30,7 +33,7 @@ func (a *Arena[T]) NewSlice(size int) []T {
3033
return nil
3134
}
3235
if len(a.data)+size > cap(a.data) {
33-
nextSize := nextArenaSize(len(a.data))
36+
nextSize := nextArenaSize[T](len(a.data))
3437
if size > nextSize {
3538
return make([]T, size)
3639
}
@@ -58,9 +61,11 @@ func (a *Arena[T]) Clone(t []T) []T {
5861
return slice
5962
}
6063

61-
func nextArenaSize(size int) int {
62-
// This compiles down branch-free.
64+
func nextArenaSize[T any](size int) int {
65+
// Target 512 bytes before allocator size-class rounding, with at least one
66+
// value. A surviving interior pointer retains and scans the whole chunk.
67+
maxSize := max(512/max(int(unsafe.Sizeof(*new(T))), 1), 1)
6368
size = max(size, 1)
64-
size = min(size*2, 256)
69+
size = min(size*2, 256, maxSize)
6570
return size
6671
}

‎tsc/internal/core/arena_test.go‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package core_test
2+
3+
import (
4+
"runtime"
5+
"strconv"
6+
"testing"
7+
8+
"github.com/microsoft/TypeScript/tsc/internal/core"
9+
"gotest.tools/v3/assert"
10+
)
11+
12+
func TestArenaBoundsSingleValueRetention(t *testing.T) { //nolint:paralleltest
13+
type value struct {
14+
pointer *int
15+
padding [65528]byte
16+
}
17+
runtime.GC()
18+
var before runtime.MemStats
19+
runtime.ReadMemStats(&before)
20+
var arena core.Arena[value]
21+
var retained *value
22+
for i := range 300 {
23+
retained = arena.New()
24+
retained.pointer = new(i)
25+
}
26+
arena = core.Arena[value]{}
27+
runtime.GC()
28+
runtime.GC()
29+
var after runtime.MemStats
30+
runtime.ReadMemStats(&after)
31+
assert.Equal(t, *retained.pointer, 299)
32+
assert.Assert(t, after.HeapAlloc < before.HeapAlloc+1024*1024, "one live value retained more than 1 MiB")
33+
runtime.KeepAlive(retained)
34+
}
35+
36+
func TestArenaMixedSingleValuesAndSlices(t *testing.T) {
37+
t.Parallel()
38+
type value struct {
39+
number int
40+
text *string
41+
}
42+
var arena core.Arena[value]
43+
assert.Assert(t, arena.NewSlice(0) == nil)
44+
first := arena.New()
45+
first.number, first.text = 1, new("first")
46+
small := arena.NewSlice1(value{number: 3, text: new("small")})
47+
copyOfSmall := arena.Clone(small)
48+
small[0].number = 4
49+
slices := make([][]value, 0, 7)
50+
for _, size := range []int{1, 2, 31, 32, 33, 64, 100} {
51+
slice := arena.NewSlice(size)
52+
assert.Equal(t, len(slice), size)
53+
assert.Equal(t, cap(slice), size)
54+
for i := range slice {
55+
assert.Equal(t, slice[i], value{})
56+
slice[i] = value{number: i + size, text: new(strconv.Itoa(size))}
57+
}
58+
slices = append(slices, slice)
59+
assert.Equal(t, *arena.New(), value{})
60+
}
61+
values := arena.NewSlice(100)
62+
assert.Equal(t, len(values), 100)
63+
assert.Equal(t, cap(values), 100)
64+
for i := range values {
65+
values[i].number = i
66+
}
67+
last := arena.New()
68+
last.number, last.text = 2, new("last")
69+
for range 1024 {
70+
arena.New()
71+
}
72+
arena = core.Arena[value]{}
73+
runtime.GC()
74+
assert.Equal(t, first.number, 1)
75+
assert.Equal(t, *first.text, "first")
76+
assert.Equal(t, last.number, 2)
77+
assert.Equal(t, *last.text, "last")
78+
assert.Equal(t, small[0].number, 4)
79+
assert.Equal(t, *small[0].text, "small")
80+
assert.Equal(t, copyOfSmall[0].number, 3)
81+
assert.Equal(t, *copyOfSmall[0].text, "small")
82+
for _, slice := range slices {
83+
for i := range slice {
84+
assert.Equal(t, slice[i].number, i+len(slice))
85+
assert.Equal(t, *slice[i].text, strconv.Itoa(len(slice)))
86+
}
87+
}
88+
for i := range values {
89+
assert.Equal(t, values[i].number, i)
90+
}
91+
}
92+
93+
func TestArenaZeroSizedValues(t *testing.T) {
94+
t.Parallel()
95+
var arena core.Arena[struct{}]
96+
for range 1024 {
97+
assert.Assert(t, arena.New() != nil)
98+
}
99+
values := arena.NewSlice(300)
100+
assert.Equal(t, len(values), 300)
101+
assert.Equal(t, cap(values), 300)
102+
}

0 commit comments

Comments
 (0)