Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lru: use weak pointers in LRU stack #49

Merged
merged 3 commits into from
Feb 26, 2025
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
8 changes: 4 additions & 4 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
strategy:
matrix:
os: [macOS-latest, ubuntu-latest]
goversion: [1.17, 1.18, '1.19', '1.20', '1.21', '1.22', '1.23']
goversion: [1.17, 1.18, '1.19', '1.20', '1.21', '1.22', '1.23', '1.24']
steps:
- name: Set up Go ${{matrix.goversion}} on ${{matrix.os}}
uses: actions/setup-go@v3
Expand All @@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v1

- name: gofmt
if: ${{matrix.goversion == '1.23'}}
if: ${{matrix.goversion == '1.24'}}
run: |
[[ -z $(gofmt -l $(find . -name '*.go') ) ]]

Expand All @@ -43,9 +43,9 @@ jobs:
GO111MODULE: on
run: go test -race -mod=readonly -count 2 ./...

# Run all consistenthash Fuzz tests for 30s with go 1.20
# Run all consistenthash Fuzz tests for 30s with go 1.24
- name: Fuzz Consistent-Hash
if: ${{matrix.goversion == '1.20'}}
if: ${{matrix.goversion == '1.24'}}
env:
GO111MODULE: on
run: go test -fuzz=. -fuzztime=30s ./consistenthash
Expand Down
2 changes: 1 addition & 1 deletion lru/typed_ll.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build go1.18
//go:build go1.18 && !go1.24

/*
Copyright 2022 Vimeo Inc.
Expand Down
172 changes: 172 additions & 0 deletions lru/typed_ll_weak.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//go:build go1.24

/*
Copyright 2025 Vimeo Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package lru

import (
"fmt"
"weak"
)

// Default-disable paranoid checks so they get compiled out.
// If this const is renamed/moved/updated make sure to update the sed
// expression in the github action. (.github/workflows/go.yml)
const paranoidLL = false

// LinkedList using generics to reduce the number of heap objects
// Used for the LRU stack in TypedCache
// This implementation switches to using weak pointers when using go 1.24+ so
// the GC can skip scanning the linked list elements themselves.
type linkedList[T any] struct {
head weak.Pointer[llElem[T]]
tail weak.Pointer[llElem[T]]
size int
}

type llElem[T any] struct {
value T
next, prev weak.Pointer[llElem[T]]
}

func (l *llElem[T]) Next() *llElem[T] {
return l.next.Value()
}

func (l *llElem[T]) Prev() *llElem[T] {
return l.prev.Value()
}

func (l *linkedList[T]) PushFront(val T) *llElem[T] {
if paranoidLL {
l.checkHeadTail()
defer l.checkHeadTail()
}
elem := llElem[T]{
next: l.head,
prev: weak.Pointer[llElem[T]]{}, // first element
value: val,
}
weakElem := weak.Make(&elem)
if lHead := l.head.Value(); lHead != nil {
lHead.prev = weakElem
}
if lTail := l.tail.Value(); lTail == nil {
l.tail = weakElem
}
l.head = weakElem
l.size++

return &elem
}

func (l *linkedList[T]) MoveToFront(e *llElem[T]) {
if paranoidLL {
if e == nil {
panic("nil element")
}
l.checkHeadTail()
defer l.checkHeadTail()
}

extHead := l.head.Value()

if extHead == e {
// nothing to do
return
}
eWeak := weak.Make(e)

if eNext := e.next.Value(); eNext != nil {
// update the previous pointer on the next element
eNext.prev = e.prev
}
if ePrev := e.prev.Value(); ePrev != nil {
ePrev.next = e.next
}
if lHead := l.head.Value(); lHead != nil {
lHead.prev = eWeak
}

if lTail := l.tail.Value(); lTail == e {
l.tail = e.prev
}
e.next = l.head
l.head = eWeak
e.prev = weak.Pointer[llElem[T]]{}
}

func (l *linkedList[T]) checkHeadTail() {
if !paranoidLL {
return
}
if (l.head.Value() != nil) != (l.tail.Value() != nil) {
panic(fmt.Sprintf("invariant failure; nilness mismatch: head: %+v; tail: %+v (size %d)",
l.head, l.tail, l.size))
}

if l.size > 0 && (l.head.Value() == nil || l.tail.Value() == nil) {
panic(fmt.Sprintf("invariant failure; head and/or tail nil with %d size: head: %+v; tail: %+v",
l.size, l.head, l.tail))
}

if lHead := l.head.Value(); lHead != nil && (lHead.prev.Value() != nil || (lHead.next.Value() == nil && l.size != 1)) {
panic(fmt.Sprintf("invariant failure; head next/prev invalid with %d size: head: %+v; tail: %+v",
l.size, l.head, l.tail))
}
if lTail := l.tail.Value(); lTail != nil && ((lTail.prev.Value() == nil && l.size != 1) || lTail.next.Value() != nil) {
panic(fmt.Sprintf("invariant failure; tail next/prev invalid with %d size: head: %+v; tail: %+v",
l.size, l.head, l.tail))
}
}

func (l *linkedList[T]) Remove(e *llElem[T]) {
if paranoidLL {
if e == nil {
panic("nil element")
}
l.checkHeadTail()
defer l.checkHeadTail()
}
if l.tail.Value() == e {
l.tail = e.prev
}
if l.head.Value() == e {
l.head = e.next
}

if eNext := e.next.Value(); eNext != nil {
// update the previous pointer on the next element
eNext.prev = e.prev
}
if ePrev := e.prev.Value(); ePrev != nil {
ePrev.next = e.next
}
l.size--
}

func (l *linkedList[T]) Len() int {
return l.size
}

func (l *linkedList[T]) Front() *llElem[T] {
return l.head.Value()
}

func (l *linkedList[T]) Back() *llElem[T] {
return l.tail.Value()
}
4 changes: 4 additions & 0 deletions lru/typed_lru.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

/*
Copyright 2013 Google Inc.
Copyright 2022-2025 Vimeo Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -120,6 +121,9 @@ func (c *TypedCache[K, V]) RemoveOldest() {
func (c *TypedCache[K, V]) removeElement(e *llElem[typedEntry[K, V]]) {
c.ll.Remove(e)
kv := e.value
// Wait until after we've removed the element from the linked list
// before removing from the map so we can leverage weak pointers in
// the linked list/LRU stack.
delete(c.cache, kv.key)
if c.OnEvicted != nil {
c.OnEvicted(kv.key, kv.value)
Expand Down
42 changes: 42 additions & 0 deletions lru/typed_lru_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

/*
Copyright 2013 Google Inc.
Copyright 2022-2025 Vimeo Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -21,6 +22,7 @@ package lru
import (
"fmt"
"testing"
"unicode/utf8"
)

func TestTypedGet(t *testing.T) {
Expand Down Expand Up @@ -92,6 +94,46 @@ func TestTypedEvict(t *testing.T) {

}

func FuzzTypedAddRemove(f *testing.F) {
// Use a primitive bytecode scheme so the Fuzzer can add/remove/lookup objects pathwise and try to exercize as many states as possible
// we statically set the size to 16 entries to keep the state sane.
// "I" inserts the next key (with an empty value) up until the next recognized byte
// "D" deletes the next key (same as I -- up to the next recognized byte)
// "L" does a lookup for the next key (ditto)
// anything before a recognized command is ignored
f.Add("")
f.Add("IabcdLabcdDabcd")
f.Add("IabcLabcDabcIabcdLabcdDabcd")
f.Fuzz(func(t *testing.T, a string) {
l := TypedNew[string, struct{}](16)

cmd := ' '
keyStart := 0
for o, r := range a {
// skip invalid runes
if !utf8.ValidRune(r) {
continue
}
switch r {
case 'I', 'D', 'L':
key := a[keyStart:o]
switch cmd {
case 'I':
l.Add(key, struct{}{})
case 'D':
l.Remove(key)
case 'L':
l.Get(key)
}
cmd = r
// we need to start with the next rune
keyStart = o + utf8.RuneLen(r)
default:
}
}
})
}

func BenchmarkTypedGetAllHits(b *testing.B) {
b.ReportAllocs()
type complexStruct struct {
Expand Down