Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions runtime/internal/runtime/map_faststr.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ type stringStruct struct {
len int
}

func stringStructOf(s *string) *stringStruct {
return (*stringStruct)(unsafe.Pointer(s))
// The hasher only borrows its input. On targets where LLGo's escape analysis
// heap-promotes an address-taken string parameter, keep the temporary header
// in explicit stack storage to avoid allocating it for each operation.
func hashMapKeyString(t *maptype, key string, seed uintptr) uintptr {
p := c.Alloca(unsafe.Sizeof(key))
*(*string)(p) = key
return t.Hasher(p, seed)
}

func memequal(p, q unsafe.Pointer, size uintptr) bool {
Expand All @@ -35,7 +40,7 @@ func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer {
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}
key := stringStructOf(&ky)
key := stringStruct{unsafe.Pointer(unsafe.StringData(ky)), len(ky)}
if h.B == 0 {
// One-bucket table.
b := (*bmap)(h.buckets)
Expand Down Expand Up @@ -91,7 +96,7 @@ func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer {
return unsafe.Pointer(&zeroVal[0])
}
dohash:
hash := t.Hasher(noescape(unsafe.Pointer(&ky)), uintptr(h.hash0))
hash := hashMapKeyString(t, ky, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.BucketSize)))
if c := h.oldbuckets; c != nil {
Expand Down Expand Up @@ -135,7 +140,7 @@ func mapaccess2_faststr(t *maptype, h *hmap, ky string) (unsafe.Pointer, bool) {
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}
key := stringStructOf(&ky)
key := stringStruct{unsafe.Pointer(unsafe.StringData(ky)), len(ky)}
if h.B == 0 {
// One-bucket table.
b := (*bmap)(h.buckets)
Expand Down Expand Up @@ -191,7 +196,7 @@ func mapaccess2_faststr(t *maptype, h *hmap, ky string) (unsafe.Pointer, bool) {
return unsafe.Pointer(&zeroVal[0]), false
}
dohash:
hash := t.Hasher(noescape(unsafe.Pointer(&ky)), uintptr(h.hash0))
hash := hashMapKeyString(t, ky, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.BucketSize)))
if c := h.oldbuckets; c != nil {
Expand Down Expand Up @@ -237,8 +242,8 @@ func mapassign_faststr(t *maptype, h *hmap, s string) unsafe.Pointer {
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
key := stringStructOf(&s)
hash := t.Hasher(noescape(unsafe.Pointer(&s)), uintptr(h.hash0))
key := stringStruct{unsafe.Pointer(unsafe.StringData(s)), len(s)}
hash := hashMapKeyString(t, s, uintptr(h.hash0))

// Set hashWriting after calling t.hasher for consistency with mapassign.
h.flags ^= hashWriting
Expand Down Expand Up @@ -312,7 +317,7 @@ bucketloop:

insertk = add(unsafe.Pointer(insertb), dataOffset+inserti*2*goarch.PtrSize)
// store new key at insert position
*((*stringStruct)(insertk)) = *key
*((*stringStruct)(insertk)) = key
h.count++

done:
Expand All @@ -332,8 +337,8 @@ func mapdelete_faststr(t *maptype, h *hmap, ky string) {
fatal("concurrent map writes")
}

key := stringStructOf(&ky)
hash := t.Hasher(noescape(unsafe.Pointer(&ky)), uintptr(h.hash0))
key := stringStruct{unsafe.Pointer(unsafe.StringData(ky)), len(ky)}
hash := hashMapKeyString(t, ky, uintptr(h.hash0))

// Set hashWriting after calling t.hasher for consistency with mapdelete
h.flags ^= hashWriting
Expand Down
34 changes: 21 additions & 13 deletions runtime/internal/runtime/z_slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,31 +34,39 @@ type Slice struct {
}

func NewSlice2(base unsafe.Pointer, eltSize, cap int, i, j int64, iSigned, jSigned, upperIsLen bool) (s Slice) {
upperCode := boundsSliceAcap
if upperIsLen {
upperCode = boundsSliceAlen
}
if boundsOutOfRange(j, jSigned, cap, true) {
// cap is non-negative. Converting both indexes to uint64 makes the
// successful-path checks independent of the source index signedness: a
// negative signed index, and an unsigned index whose high bit is set, both
// compare above the non-negative bound. Keep the signed flags only for the
// panic text on the cold path.
if uint64(j) > uint64(cap) {
upperCode := boundsSliceAcap
if upperIsLen {
upperCode = boundsSliceAlen
}
panicBounds(j, jSigned, cap, upperCode)
}
if boundsAbove(i, iSigned, j) {
if uint64(i) > uint64(j) {
panicBounds(i, iSigned, int(j), boundsSliceB)
}
return newSliceUnchecked(base, eltSize, int(i), int(j), cap)
}

func NewSlice3Bounds(base unsafe.Pointer, eltSize, cap int, i, j, k int64, iSigned, jSigned, kSigned, upperIsLen bool) (s Slice) {
upperCode := boundsSlice3Acap
if upperIsLen {
upperCode = boundsSlice3Alen
}
if boundsOutOfRange(k, kSigned, cap, true) {
// As in NewSlice2, check from the upper bound down so each successful
// uint64 comparison establishes 0 <= index <= bound. Since cap fits int,
// k and then j also fit int before the subsequent checks and conversions.
if uint64(k) > uint64(cap) {
Comment thread
go-wyvern marked this conversation as resolved.
Resolved
upperCode := boundsSlice3Acap
if upperIsLen {
upperCode = boundsSlice3Alen
}
panicBounds(k, kSigned, cap, upperCode)
}
if boundsAbove(j, jSigned, k) {
if uint64(j) > uint64(k) {
panicBounds(j, jSigned, int(k), boundsSlice3B)
}
if boundsAbove(i, iSigned, j) {
if uint64(i) > uint64(j) {
panicBounds(i, iSigned, int(j), boundsSlice3C)
}
return newSliceUnchecked(base, eltSize, int(i), int(j), int(k))
Expand Down
203 changes: 203 additions & 0 deletions runtime/internal/test/map_faststr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
//go:build llgo

package test

import (
"runtime"
"strings"
"testing"
"unsafe"

"github.com/xgo-dev/llgo/runtime/abi"
rt "github.com/xgo-dev/llgo/runtime/internal/runtime"
)

func TestMapFastStringKeyHeaders(t *testing.T) {
mt := abi.TypeFor[map[string]int]().MapType()
// The long keys share their first and last four bytes, forcing the
// one-bucket lookup to hash when more than one candidate remains.
keys := []string{
"", "short", "a\x00b", strings.Repeat("x", 31),
"head" + strings.Repeat("a", 24) + "tail",
"head" + strings.Repeat("b", 24) + "tail",
("!unaligned string")[1:],
}
for _, hint := range []int{0, 64} {
h := rt.MakeMap(mt, hint)
for i, key := range keys {
*(*int)(rt.MapAssignFastStr(mt, h, strings.Clone(key))) = i + 1
}
for i, key := range keys {
query := strings.Clone(key)
if got := *(*int)(rt.MapAccess1FastStr(mt, h, query)); got != i+1 {
t.Fatalf("hint %d access1 %q = %d, want %d", hint, key, got, i+1)
}
p, ok := rt.MapAccess2FastStr(mt, h, query)
if !ok || *(*int)(p) != i+1 {
t.Fatalf("hint %d access2 %q returned an incorrect value", hint, key)
}
*(*int)(rt.MapAssignFastStr(mt, h, query)) = i + 101
}
for _, key := range []string{"missing", "head" + strings.Repeat("c", 24) + "tail"} {
if p, ok := rt.MapAccess2FastStr(mt, h, key); ok || *(*int)(p) != 0 {
t.Fatalf("hint %d missing key %q returned a value", hint, key)
}
}
for i, key := range keys {
if got := *(*int)(rt.MapAccess1FastStr(mt, h, key)); got != i+101 {
t.Fatalf("hint %d updated key %q = %d, want %d", hint, key, got, i+101)
}
rt.MapDeleteFastStr(mt, h, strings.Clone(key))
if _, ok := rt.MapAccess2FastStr(mt, h, key); ok {
t.Fatalf("hint %d deleted key %q is still present", hint, key)
}
}
}
}

//go:noinline
func mapFastStringFreshKey(i int) string {
buf := make([]byte, 96)
for j := range buf {
buf[j] = 'x'
}
buf[12], buf[13] = byte(i), byte(i>>8)
return string(buf)
}

func TestMapFastStringKeysSurviveGrowthAndGC(t *testing.T) {
mt := abi.TypeFor[map[string]int]().MapType()
h := rt.MakeMap(mt, 0)
const n = 256
for i := 0; i < n; i++ {
// Only the map retains the backing bytes after this call returns.
*(*int)(rt.MapAssignFastStr(mt, h, mapFastStringFreshKey(i))) = i + 1
}
runtime.GC()
for i := 0; i < n; i++ {
p, ok := rt.MapAccess2FastStr(mt, h, mapFastStringFreshKey(i))
if !ok || *(*int)(p) != i+1 {
t.Fatalf("key %d did not survive map growth and GC", i)
}
}
runtime.KeepAlive(h)
}

func TestMapFastStringHasherInput(t *testing.T) {
mt := new(abi.MapType)
*mt = *abi.TypeFor[map[string]int]().MapType()
hasher := mt.Hasher
key := strings.Clone("a key long enough to take the hashed lookup path")
calls := 0
var seed uintptr
mt.Hasher = func(p unsafe.Pointer, hashSeed uintptr) uintptr {
if got := *(*string)(p); got != key {
t.Fatalf("hasher received %q, want %q", got, key)
}
if calls != 0 && seed != hashSeed {
t.Fatal("hasher received a different map seed")
}
seed = hashSeed
calls++
return hasher(p, hashSeed)
}
h := rt.MakeMap(mt, 64)
*(*int)(rt.MapAssignFastStr(mt, h, key)) = 42
if got := *(*int)(rt.MapAccess1FastStr(mt, h, key)); got != 42 {
t.Fatalf("access1 = %d, want 42", got)
}
if p, ok := rt.MapAccess2FastStr(mt, h, key); !ok || *(*int)(p) != 42 {
t.Fatal("access2 returned an incorrect value")
}
rt.MapDeleteFastStr(mt, h, key)
if calls != 4 {
t.Fatalf("hasher called %d times, want 4", calls)
}
}

// LLGo's allocation counters record every runtime allocation, unlike
// ReadMemStats.Mallocs on its BDWGC backend. Keep the profile buffer outside
// the measured calls.
var mapFastStringProfileRecords [32]rt.MemProfileRecord

func mapFastStringAllocationCount() int64 {
n, ok := rt.MemProfile(mapFastStringProfileRecords[:], true)
if !ok {
return -1
}
var total int64
for i := 0; i < n; i++ {
total += mapFastStringProfileRecords[i].AllocObjects
}
return total
}

//go:noinline
func exerciseMapFastStringOperations(mt *abi.MapType, h *rt.Map, key, updateKey, absentKey string, n int) bool {
for i := 0; i < n; i++ {
if *(*int)(rt.MapAccess1FastStr(mt, h, key)) != 42 {
return false
}
if p, ok := rt.MapAccess2FastStr(mt, h, key); !ok || *(*int)(p) != 42 {
return false
}
if *(*int)(rt.MapAccess1FastStr(mt, h, absentKey)) != 0 {
return false
}
if p, ok := rt.MapAccess2FastStr(mt, h, absentKey); ok || *(*int)(p) != 0 {
return false
}
// Updating an existing entry and deleting an absent key need no
// bucket allocation, so all four paths can be measured together.
*(*int)(rt.MapAssignFastStr(mt, h, updateKey)) = 42
rt.MapDeleteFastStr(mt, h, absentKey)
}
return true
}

func TestMapFastStringOperationsDoNotAllocateKeyHeaders(t *testing.T) {
mt := abi.TypeFor[map[string]int]().MapType()
for _, tc := range []struct {
name, key, otherKey, absentKey string
}{
{"short", "existing key", "other key", "absent key"},
// Equal lengths and matching first/last four bytes force one-bucket
// long-key lookups into the hashed path, for both hits and misses.
{"long", "head" + strings.Repeat("a", 24) + "tail", "head" + strings.Repeat("b", 24) + "tail", "head" + strings.Repeat("c", 24) + "tail"},
} {
// Keep lookup storage distinct from the key installed by updates so
// later rounds cannot bypass hashing through pointer equality.
key, updateKey := strings.Clone(tc.key), strings.Clone(tc.key)
for _, hint := range []int{0, 64} {
h := rt.MakeMap(mt, hint)
*(*int)(rt.MapAssignFastStr(mt, h, strings.Clone(tc.key))) = 42
*(*int)(rt.MapAssignFastStr(mt, h, tc.otherKey)) = 43
if !exerciseMapFastStringOperations(mt, h, key, updateKey, tc.absentKey, 1) {
t.Fatal("string map warmup returned an incorrect value")
}
for _, n := range []int{512, 1024} {
// These counters are process-wide: test timers or another runtime
// thread may allocate during a measurement. Require a zero-allocation
// run, allowing a few attempts to exclude that unrelated activity.
best := int64(1<<63 - 1)
for attempt := 0; attempt < 4; attempt++ {
before := mapFastStringAllocationCount()
ok := exerciseMapFastStringOperations(mt, h, key, updateKey, tc.absentKey, n)
after := mapFastStringAllocationCount()
if !ok {
t.Fatal("string map operations returned an incorrect value")
}
if before < 0 || after < 0 {
t.Fatal("allocation profile buffer is too small")
}
if delta := after - before; delta < best {
best = delta
}
}
if best != 0 {
t.Fatalf("%s key, hint %d: %d rounds of string map operations allocated at least %d objects", tc.name, hint, n, best)
}
}
}
}
}
Loading