Skip to content

Commit 5bba5b2

Browse files
committed
runtime: rewrite traceMap to scale better
The existing implementation of traceMap is a hash map with a fixed bucket table size which scales poorly with the number of elements added to the map. After a few thousands elements are in the map, it tends to fall over. Furthermore, cleaning up the trace map is currently non-preemptible, without very good reason. This change replaces the traceMap implementation with a simple append-only concurrent hash-trie. The data structure is incredibly simple and does not suffer at all from the same scaling issues. Because the traceMap no longer has a lock, and the traceRegionAlloc it embeds is not thread-safe, we have to push that lock down. While we're here, this change also makes the fast path for the traceRegionAlloc lock-free. This may not be inherently faster due to contention on the atomic add, but it creates an easy path to sharding the main allocation buffer to reduce contention in the future. (We might want to also consider a fully thread-local allocator that covers both string and stack tables. The only reason a thread-local allocator isn't feasible right now is because each of these has their own region, but we could certainly group all them together.) Change-Id: I8c06d42825c326061a1b8569e322afc4bc2a513a Reviewed-on: https://go-review.googlesource.com/c/go/+/570035 Reviewed-by: Carlos Amedee <[email protected]> Auto-Submit: Michael Knyszek <[email protected]> TryBot-Bypass: Michael Knyszek <[email protected]> Reviewed-by: David Chase <[email protected]>
1 parent 7398169 commit 5bba5b2

File tree

7 files changed

+293
-162
lines changed

7 files changed

+293
-162
lines changed

src/runtime/export_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,3 +1874,15 @@ func UnsafePoint(pc uintptr) bool {
18741874
panic("invalid unsafe point code " + string(itoa(buf[:], uint64(v))))
18751875
}
18761876
}
1877+
1878+
type TraceMap struct {
1879+
traceMap
1880+
}
1881+
1882+
func (m *TraceMap) PutString(s string) (uint64, bool) {
1883+
return m.traceMap.put(unsafe.Pointer(unsafe.StringData(s)), uintptr(len(s)))
1884+
}
1885+
1886+
func (m *TraceMap) Reset() {
1887+
m.traceMap.reset()
1888+
}

src/runtime/trace2map.go

Lines changed: 72 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,54 @@
44

55
//go:build goexperiment.exectracer2
66

7-
// Simple hash table for tracing. Provides a mapping
8-
// between variable-length data and a unique ID. Subsequent
9-
// puts of the same data will return the same ID.
7+
// Simple append-only thread-safe hash map for tracing.
8+
// Provides a mapping between variable-length data and a
9+
// unique ID. Subsequent puts of the same data will return
10+
// the same ID. The zero value is ready to use.
1011
//
11-
// Uses a region-based allocation scheme and assumes that the
12-
// table doesn't ever grow very big.
12+
// Uses a region-based allocation scheme internally, and
13+
// reset clears the whole map.
1314
//
14-
// This is definitely not a general-purpose hash table! It avoids
15-
// doing any high-level Go operations so it's safe to use even in
16-
// sensitive contexts.
15+
// It avoids doing any high-level Go operations so it's safe
16+
// to use even in sensitive contexts.
1717

1818
package runtime
1919

2020
import (
21+
"internal/cpu"
22+
"internal/goarch"
2123
"internal/runtime/atomic"
2224
"runtime/internal/sys"
2325
"unsafe"
2426
)
2527

2628
type traceMap struct {
27-
lock mutex // Must be acquired on the system stack
29+
root atomic.UnsafePointer // *traceMapNode (can't use generics because it's notinheap)
30+
_ cpu.CacheLinePad
2831
seq atomic.Uint64
32+
_ cpu.CacheLinePad
2933
mem traceRegionAlloc
30-
tab [1 << 13]atomic.UnsafePointer // *traceMapNode (can't use generics because it's notinheap)
3134
}
3235

36+
// traceMapNode is an implementation of a lock-free append-only hash-trie
37+
// (a trie of the hash bits).
38+
//
39+
// Key features:
40+
// - 4-ary trie. Child nodes are indexed by the upper 2 (remaining) bits of the hash.
41+
// For example, top level uses bits [63:62], next level uses [61:60] and so on.
42+
// - New nodes are placed at the first empty level encountered.
43+
// - When the first child is added to a node, the existing value is not moved into a child.
44+
// This means that you must check the key at each level, not just at the leaf.
45+
// - No deletion or rebalancing.
46+
// - Intentionally devolves into a linked list on hash collisions (the hash bits will all
47+
// get shifted out during iteration, and new nodes will just be appended to the 0th child).
3348
type traceMapNode struct {
34-
_ sys.NotInHeap
35-
link atomic.UnsafePointer // *traceMapNode (can't use generics because it's notinheap)
36-
hash uintptr
37-
id uint64
38-
data []byte
39-
}
49+
_ sys.NotInHeap
4050

41-
// next is a type-safe wrapper around link.
42-
func (n *traceMapNode) next() *traceMapNode {
43-
return (*traceMapNode)(n.link.Load())
51+
children [4]atomic.UnsafePointer // *traceMapNode (can't use generics because it's notinheap)
52+
hash uintptr
53+
id uint64
54+
data []byte
4455
}
4556

4657
// stealID steals an ID from the table, ensuring that it will not
@@ -51,7 +62,7 @@ func (tab *traceMap) stealID() uint64 {
5162

5263
// put inserts the data into the table.
5364
//
54-
// It's always safe to noescape data because its bytes are always copied.
65+
// It's always safe for callers to noescape data because put copies its bytes.
5566
//
5667
// Returns a unique ID for the data and whether this is the first time
5768
// the data has been added to the map.
@@ -60,59 +71,47 @@ func (tab *traceMap) put(data unsafe.Pointer, size uintptr) (uint64, bool) {
6071
return 0, false
6172
}
6273
hash := memhash(data, 0, size)
63-
// First, search the hashtable w/o the mutex.
64-
if id := tab.find(data, size, hash); id != 0 {
65-
return id, false
66-
}
67-
// Now, double check under the mutex.
68-
// Switch to the system stack so we can acquire tab.lock
69-
var id uint64
70-
var added bool
71-
systemstack(func() {
72-
lock(&tab.lock)
73-
if id = tab.find(data, size, hash); id != 0 {
74-
unlock(&tab.lock)
75-
return
76-
}
77-
// Create new record.
78-
id = tab.seq.Add(1)
79-
vd := tab.newTraceMapNode(data, size, hash, id)
8074

81-
// Insert it into the table.
82-
//
83-
// Update the link first, since the node isn't published yet.
84-
// Then, store the node in the table as the new first node
85-
// for the bucket.
86-
part := int(hash % uintptr(len(tab.tab)))
87-
vd.link.StoreNoWB(tab.tab[part].Load())
88-
tab.tab[part].StoreNoWB(unsafe.Pointer(vd))
89-
unlock(&tab.lock)
90-
91-
added = true
92-
})
93-
return id, added
94-
}
95-
96-
// find looks up data in the table, assuming hash is a hash of data.
97-
//
98-
// Returns 0 if the data is not found, and the unique ID for it if it is.
99-
func (tab *traceMap) find(data unsafe.Pointer, size, hash uintptr) uint64 {
100-
part := int(hash % uintptr(len(tab.tab)))
101-
for vd := tab.bucket(part); vd != nil; vd = vd.next() {
102-
// Synchronization not necessary. Once published to the table, these
103-
// values are immutable.
104-
if vd.hash == hash && uintptr(len(vd.data)) == size {
105-
if memequal(unsafe.Pointer(&vd.data[0]), data, size) {
106-
return vd.id
75+
var newNode *traceMapNode
76+
m := &tab.root
77+
hashIter := hash
78+
for {
79+
n := (*traceMapNode)(m.Load())
80+
if n == nil {
81+
// Try to insert a new map node. We may end up discarding
82+
// this node if we fail to insert because it turns out the
83+
// value is already in the map.
84+
//
85+
// The discard will only happen if two threads race on inserting
86+
// the same value. Both might create nodes, but only one will
87+
// succeed on insertion. If two threads race to insert two
88+
// different values, then both nodes will *always* get inserted,
89+
// because the equality checking below will always fail.
90+
//
91+
// Performance note: contention on insertion is likely to be
92+
// higher for small maps, but since this data structure is
93+
// append-only, either the map stays small because there isn't
94+
// much activity, or the map gets big and races to insert on
95+
// the same node are much less likely.
96+
if newNode == nil {
97+
newNode = tab.newTraceMapNode(data, size, hash, tab.seq.Add(1))
98+
}
99+
if m.CompareAndSwapNoWB(nil, unsafe.Pointer(newNode)) {
100+
return newNode.id, true
101+
}
102+
// Reload n. Because pointers are only stored once,
103+
// we must have lost the race, and therefore n is not nil
104+
// anymore.
105+
n = (*traceMapNode)(m.Load())
106+
}
107+
if n.hash == hash && uintptr(len(n.data)) == size {
108+
if memequal(unsafe.Pointer(&n.data[0]), data, size) {
109+
return n.id, false
107110
}
108111
}
112+
m = &n.children[hashIter>>(8*goarch.PtrSize-2)]
113+
hashIter <<= 2
109114
}
110-
return 0
111-
}
112-
113-
// bucket is a type-safe wrapper for looking up a value in tab.tab.
114-
func (tab *traceMap) bucket(part int) *traceMapNode {
115-
return (*traceMapNode)(tab.tab[part].Load())
116115
}
117116

118117
func (tab *traceMap) newTraceMapNode(data unsafe.Pointer, size, hash uintptr, id uint64) *traceMapNode {
@@ -134,18 +133,10 @@ func (tab *traceMap) newTraceMapNode(data unsafe.Pointer, size, hash uintptr, id
134133

135134
// reset drops all allocated memory from the table and resets it.
136135
//
137-
// tab.lock must be held. Must run on the system stack because of this.
138-
//
139-
//go:systemstack
136+
// The caller must ensure that there are no put operations executing concurrently
137+
// with this function.
140138
func (tab *traceMap) reset() {
141-
assertLockHeld(&tab.lock)
142-
tab.mem.drop()
139+
tab.root.Store(nil)
143140
tab.seq.Store(0)
144-
// Clear table without write barriers. The table consists entirely
145-
// of notinheap pointers, so this is fine.
146-
//
147-
// Write barriers may theoretically call into the tracer and acquire
148-
// the lock again, and this lock ordering is expressed in the static
149-
// lock ranking checker.
150-
memclrNoHeapPointers(unsafe.Pointer(&tab.tab), unsafe.Sizeof(tab.tab))
141+
tab.mem.drop()
151142
}

src/runtime/trace2map_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package runtime_test
6+
7+
import (
8+
. "runtime"
9+
"strconv"
10+
"sync"
11+
"testing"
12+
)
13+
14+
func TestTraceMap(t *testing.T) {
15+
var m TraceMap
16+
17+
// Try all these operations multiple times between resets, to make sure
18+
// we're resetting properly.
19+
for range 3 {
20+
var d = [...]string{
21+
"a",
22+
"b",
23+
"aa",
24+
"ab",
25+
"ba",
26+
"bb",
27+
}
28+
for i, s := range d {
29+
id, inserted := m.PutString(s)
30+
if !inserted {
31+
t.Errorf("expected to have inserted string %q, but did not", s)
32+
}
33+
if id != uint64(i+1) {
34+
t.Errorf("expected string %q to have ID %d, but got %d instead", s, i+1, id)
35+
}
36+
}
37+
for i, s := range d {
38+
id, inserted := m.PutString(s)
39+
if inserted {
40+
t.Errorf("inserted string %q, but expected to have not done so", s)
41+
}
42+
if id != uint64(i+1) {
43+
t.Errorf("expected string %q to have ID %d, but got %d instead", s, i+1, id)
44+
}
45+
}
46+
m.Reset()
47+
}
48+
}
49+
50+
func TestTraceMapConcurrent(t *testing.T) {
51+
var m TraceMap
52+
53+
var wg sync.WaitGroup
54+
for i := range 3 {
55+
wg.Add(1)
56+
go func(i int) {
57+
defer wg.Done()
58+
59+
si := strconv.Itoa(i)
60+
var d = [...]string{
61+
"a" + si,
62+
"b" + si,
63+
"aa" + si,
64+
"ab" + si,
65+
"ba" + si,
66+
"bb" + si,
67+
}
68+
ids := make([]uint64, 0, len(d))
69+
for _, s := range d {
70+
id, inserted := m.PutString(s)
71+
if !inserted {
72+
t.Errorf("expected to have inserted string %q, but did not", s)
73+
}
74+
ids = append(ids, id)
75+
}
76+
for i, s := range d {
77+
id, inserted := m.PutString(s)
78+
if inserted {
79+
t.Errorf("inserted string %q, but expected to have not done so", s)
80+
}
81+
if id != ids[i] {
82+
t.Errorf("expected string %q to have ID %d, but got %d instead", s, ids[i], id)
83+
}
84+
}
85+
}(i)
86+
}
87+
wg.Wait()
88+
m.Reset()
89+
}

0 commit comments

Comments
 (0)