Skip to content

Commit ca47942

Browse files
authored
Merge pull request #31 from nirs/cache-l2-tables
Cache l2 tables
2 parents 63138be + a9e7f3a commit ca47942

File tree

4 files changed

+301
-2
lines changed

4 files changed

+301
-2
lines changed

image/qcow2/qcow2.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/lima-vm/go-qcow2reader/align"
1515
"github.com/lima-vm/go-qcow2reader/image"
1616
"github.com/lima-vm/go-qcow2reader/log"
17+
"github.com/lima-vm/go-qcow2reader/lru"
1718
)
1819

1920
const Type = "qcow2"
@@ -536,20 +537,25 @@ type Qcow2 struct {
536537
errUnreadable error
537538
clusterSize int
538539
l1Table []l1TableEntry
540+
l2TableCache *lru.Cache[l1TableEntry, []l2TableEntry]
539541
decompressor Decompressor
540542
BackingFile string `json:"backing_file"`
541543
BackingFileFullPath string `json:"backing_file_full_path"`
542544
BackingFileFormat image.Type `json:"backing_file_format"`
543545
backingImage image.Image
544546
}
545547

548+
// With the default cluster size (64 Kib) this uses 1 MiB and cover 8 GiB image.
549+
const maxL2Tables = 16
550+
546551
// Open opens an qcow2 image.
547552
//
548553
// To open an image with backing files, ra must implement [Namer],
549554
// and openWithType must be non-nil.
550555
func Open(ra io.ReaderAt, openWithType image.OpenWithType) (*Qcow2, error) {
551556
img := &Qcow2{
552-
ra: ra,
557+
ra: ra,
558+
l2TableCache: lru.New[l1TableEntry, []l2TableEntry](maxL2Tables),
553559
}
554560
r := io.NewSectionReader(ra, 0, -1)
555561
var err error
@@ -680,6 +686,19 @@ func (img *Qcow2) extendedL2() bool {
680686
return img.Header.HeaderFieldsV3 != nil && img.Header.HeaderFieldsV3.IncompatibleFeatures&(1<<IncompatibleFeaturesExtendedL2EntriesBit) != 0
681687
}
682688

689+
func (img *Qcow2) getL2Table(l1Entry l1TableEntry) ([]l2TableEntry, error) {
690+
l2Table, ok := img.l2TableCache.Get(l1Entry)
691+
if !ok {
692+
var err error
693+
l2Table, err = readL2Table(img.ra, l1Entry.l2Offset(), img.clusterSize)
694+
if err != nil {
695+
return nil, err
696+
}
697+
img.l2TableCache.Add(l1Entry, l2Table)
698+
}
699+
return l2Table, nil
700+
}
701+
683702
// readAtAligned requires that off and off+len(p)-1 belong to the same cluster.
684703
func (img *Qcow2) readAtAligned(p []byte, off int64) (int, error) {
685704
l2Entries := img.clusterSize / 8
@@ -712,7 +731,7 @@ func (img *Qcow2) readAtAligned(p []byte, off int64) (int, error) {
712731
extL2Entry = &extL2Table[l2Index]
713732
l2Entry = extL2Entry.L2TableEntry
714733
} else {
715-
l2Table, err := readL2Table(img.ra, l2TableOffset, img.clusterSize)
734+
l2Table, err := img.getL2Table(l1Entry)
716735
if err != nil {
717736
return 0, fmt.Errorf("failed to read L2 table for L1 entry %v (index %d): %w", l1Entry, l1Index, err)
718737
}

lru/lru.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package lru
2+
3+
import (
4+
"container/list"
5+
"sync"
6+
)
7+
8+
// Cache keeps recently used values. Safe for concurrent use by multiple
9+
// goroutines.
10+
type Cache[K comparable, V any] struct {
11+
mutex sync.Mutex
12+
entries map[K]*list.Element
13+
recentlyUsed *list.List
14+
capacity int
15+
}
16+
17+
type cacheEntry[K comparable, V any] struct {
18+
Key K
19+
Value V
20+
}
21+
22+
// New returns a new empty cache that can hold up to capacity items.
23+
func New[K comparable, V any](capacity int) *Cache[K, V] {
24+
return &Cache[K, V]{
25+
entries: make(map[K]*list.Element),
26+
recentlyUsed: list.New(),
27+
capacity: capacity,
28+
}
29+
}
30+
31+
// Get returns the value stored in the cache for a key, or zero value if no
32+
// value is present. The ok result indicates whether value was found in the
33+
// cache.
34+
func (c *Cache[K, V]) Get(key K) (V, bool) {
35+
c.mutex.Lock()
36+
defer c.mutex.Unlock()
37+
38+
if elem, ok := c.entries[key]; ok {
39+
c.recentlyUsed.MoveToFront(elem)
40+
entry := elem.Value.(*cacheEntry[K, V])
41+
return entry.Value, true
42+
}
43+
44+
var missing V
45+
return missing, false
46+
}
47+
48+
// Add adds key and value to the cache. If the cache is full, the oldest entry
49+
// is removed. If key is already in the cache, value replaces the cached value.
50+
func (c *Cache[K, V]) Add(key K, value V) {
51+
c.mutex.Lock()
52+
defer c.mutex.Unlock()
53+
54+
if elem, ok := c.entries[key]; ok {
55+
c.recentlyUsed.MoveToFront(elem)
56+
entry := elem.Value.(*cacheEntry[K, V])
57+
entry.Value = value
58+
return
59+
}
60+
61+
if len(c.entries) >= c.capacity {
62+
oldest := c.recentlyUsed.Back()
63+
c.recentlyUsed.Remove(oldest)
64+
entry := oldest.Value.(*cacheEntry[K, V])
65+
delete(c.entries, entry.Key)
66+
}
67+
68+
entry := &cacheEntry[K, V]{Key: key, Value: value}
69+
c.entries[key] = c.recentlyUsed.PushFront(entry)
70+
}

lru/lru_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package lru
2+
3+
import (
4+
"testing"
5+
)
6+
7+
type item struct {
8+
key int
9+
value string
10+
}
11+
12+
func TestCache(t *testing.T) {
13+
items := []item{{0, "0"}, {1, "1"}, {2, "2"}, {3, "3"}, {4, "4"}}
14+
cache := New[int, string](5)
15+
16+
// Cache is empty.
17+
for _, i := range items {
18+
if _, ok := cache.Get(i.key); ok {
19+
t.Errorf("key %d in cache", i.key)
20+
}
21+
}
22+
23+
// Add all items to cache.
24+
for _, i := range items {
25+
cache.Add(i.key, i.value)
26+
}
27+
28+
// Verify that all items are cached.
29+
for _, i := range items {
30+
if value, ok := cache.Get(i.key); !ok {
31+
t.Errorf("cached value for %d missing", i.key)
32+
} else if value != i.value {
33+
t.Errorf("expected %q, got %q", value, i.value)
34+
}
35+
}
36+
37+
// Adding next item will remove the least used item (0).
38+
cache.Add(5, "5")
39+
40+
// New item in cache.
41+
if value, ok := cache.Get(5); !ok {
42+
t.Errorf("cached value for 5 missing")
43+
} else if value != "5" {
44+
t.Errorf("expected \"5\", got %q", value)
45+
}
46+
47+
// Removed item not in cache.
48+
if _, ok := cache.Get(0); ok {
49+
t.Error("key 0 in cache")
50+
}
51+
52+
// Rest of items not affected.
53+
for _, i := range items[1:] {
54+
if value, ok := cache.Get(i.key); !ok {
55+
t.Errorf("cached value for %d missing", i.key)
56+
} else if value != i.value {
57+
t.Errorf("expected %q, got %q", value, i.value)
58+
}
59+
}
60+
61+
}

qcow2reader_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package qcow2reader
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"math/rand"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"testing"
13+
14+
"github.com/lima-vm/go-qcow2reader/image"
15+
"github.com/lima-vm/go-qcow2reader/image/qcow2"
16+
)
17+
18+
const (
19+
MiB = int64(1) << 20
20+
GiB = int64(1) << 30
21+
22+
CompressionTypeNone = qcow2.CompressionType(255)
23+
)
24+
25+
func BenchmarkRead(b *testing.B) {
26+
const size = 256 * MiB
27+
base := filepath.Join(b.TempDir(), "image")
28+
if err := createTestImage(base, size); err != nil {
29+
b.Fatal(err)
30+
}
31+
b.Run("qcow2", func(b *testing.B) {
32+
img := base + ".qocw2"
33+
if err := qemuImgConvert(base, img, qcow2.Type, CompressionTypeNone); err != nil {
34+
b.Fatal(err)
35+
}
36+
resetBenchmark(b, size)
37+
for i := 0; i < b.N; i++ {
38+
benchmarkRead(b, img)
39+
}
40+
})
41+
b.Run("qcow2 zlib", func(b *testing.B) {
42+
img := base + ".zlib.qcow2"
43+
if err := qemuImgConvert(base, img, qcow2.Type, qcow2.CompressionTypeZlib); err != nil {
44+
b.Fatal(err)
45+
}
46+
resetBenchmark(b, size)
47+
for i := 0; i < b.N; i++ {
48+
benchmarkRead(b, img)
49+
}
50+
})
51+
// TODO: qcow2 zstd (not supported yet)
52+
}
53+
54+
func benchmarkRead(b *testing.B, filename string) {
55+
b.StartTimer()
56+
57+
f, err := os.Open(filename)
58+
if err != nil {
59+
b.Fatal(err)
60+
}
61+
defer f.Close()
62+
img, err := Open(f)
63+
if err != nil {
64+
b.Fatal(err)
65+
}
66+
defer img.Close()
67+
buf := make([]byte, 1*MiB)
68+
reader := io.NewSectionReader(img, 0, img.Size())
69+
n, err := io.CopyBuffer(io.Discard, reader, buf)
70+
71+
b.StopTimer()
72+
73+
if err != nil {
74+
b.Fatal(err)
75+
}
76+
if n != img.Size() {
77+
b.Fatalf("Expected %d bytes, read %d bytes", img.Size(), n)
78+
}
79+
}
80+
81+
func resetBenchmark(b *testing.B, size int64) {
82+
b.StopTimer()
83+
b.ResetTimer()
84+
b.SetBytes(size)
85+
b.ReportAllocs()
86+
}
87+
88+
// createTestImage creates a 50% allocated raw image with fake data that
89+
// compresses like real image data.
90+
func createTestImage(filename string, size int64) error {
91+
const chunkSize = 4 * MiB
92+
file, err := os.Create(filename)
93+
if err != nil {
94+
return err
95+
}
96+
defer file.Close()
97+
if err := file.Truncate(size); err != nil {
98+
return err
99+
}
100+
reader := &Generator{}
101+
for offset := int64(0); offset < size; offset += 2 * chunkSize {
102+
_, err := file.Seek(offset, io.SeekStart)
103+
if err != nil {
104+
return err
105+
}
106+
chunk := io.LimitReader(reader, chunkSize)
107+
if n, err := io.Copy(file, chunk); err != nil {
108+
return err
109+
} else if n != chunkSize {
110+
return fmt.Errorf("expected %d bytes, wrote %d bytes", chunkSize, n)
111+
}
112+
}
113+
return file.Close()
114+
}
115+
116+
// Generator generates fake data that compresses like a real image data (30%).
117+
type Generator struct{}
118+
119+
func (g *Generator) Read(b []byte) (int, error) {
120+
for i := 0; i < len(b); i++ {
121+
b[i] = byte(i & 0xff)
122+
}
123+
rand.Shuffle(len(b)/8*5, func(i, j int) {
124+
b[i], b[j] = b[j], b[i]
125+
})
126+
return len(b), nil
127+
}
128+
129+
func qemuImgConvert(src, dst string, dstFormat image.Type, compressionType qcow2.CompressionType) error {
130+
args := []string{"convert", "-O", string(dstFormat)}
131+
if compressionType != CompressionTypeNone {
132+
args = append(args, "-c", "-o", "compression_type="+compressionType.String())
133+
}
134+
args = append(args, src, dst)
135+
cmd := exec.Command("qemu-img", args...)
136+
137+
var stderr bytes.Buffer
138+
cmd.Stderr = &stderr
139+
140+
if err := cmd.Run(); err != nil {
141+
// Return qemu-img stderr instead of the unhelpful default error (exited
142+
// with status 1).
143+
if _, ok := err.(*exec.ExitError); ok {
144+
return errors.New(stderr.String())
145+
}
146+
return err
147+
}
148+
return nil
149+
}

0 commit comments

Comments
 (0)