Skip to content

Commit 5864483

Browse files
committed
Add Image.Extent() interface
Extent() return the next extent in the image, starting at the specified offset, and limited by the specified length. An extent is a run of clusters of with the same status. Using Extent() we can iterate over the image and skip extents that reads as zeros, instead of reading a zero buffer and detecting that the buffer is full of zeros. Benchmarking show 2 orders of magnitude speeded compared with zero detection (10 TiB/s instead of 100 GiB/s) % go test -bench Benchmark0p/qcow2$/read Benchmark0p/qcow2/read-12 516 2503752 ns/op 107213.28 MB/s 1050537 B/op 39 allocs/op % go test -bench NextUnallocated BenchmarkNextUnallocated-12 116 10236186 ns/op 10489666.91 MB/s 0 B/op 0 allocs/op Only qcow2 image has a real implementation. For other image formats we treat all clusters as allocated, so Extent() always return one allocated extent. For raw format we can implement Extents() later using SEEK_DATA and SEEK_HOLE. Extent() is more strict than ReadAt and fails when offset+length are after the end of the image. We always know the length of the image so requesting an extent after the end of the image is likely a bug in the user code and failing fast may help to debug such issues. Signed-off-by: Nir Soffer <nirsof@gmail.com>
1 parent a4bc0dd commit 5864483

File tree

5 files changed

+323
-0
lines changed

5 files changed

+323
-0
lines changed

image/image.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,28 @@ import (
88
// Type must be a "Backing file format name string" that appears in QCOW2.
99
type Type string
1010

11+
// Extent describes a byte range in the image with the same allocation,
12+
// compression, or zero status. Extents are aligned to the underlying file
13+
// system block size (e.g. 4k), or the image format cluster size (e.g. 64k). One
14+
// extent can describe one or more file system blocks or image clusters.
15+
type Extent struct {
16+
// Offset from start of the image in bytes.
17+
Start int64 `json:"start"`
18+
// Length of this extent in bytes.
19+
Length int64 `json:"length"`
20+
// Set if this extent is allocated.
21+
Allocated bool `json:"allocated"`
22+
// Set if this extent is read as zeros.
23+
Zero bool `json:"zero"`
24+
// Set if this extent is compressed.
25+
Compressed bool `json:"compressed"`
26+
}
27+
1128
// Image implements [io.ReaderAt] and [io.Closer].
1229
type Image interface {
1330
io.ReaderAt
1431
io.Closer
32+
Extent(start, length int64) (Extent, error)
1533
Type() Type
1634
Size() int64 // -1 if unknown
1735
Readable() error

image/qcow2/qcow2.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,9 @@ func (img *Qcow2) getClusterMeta(off int64, cm *clusterMeta) error {
776776
if cm.L2Entry.compressed() {
777777
cm.Compressed = true
778778
} else {
779+
// When using extended L2 clusters this is always false. To find which sub
780+
// cluster is allocated/zero we need to iterate over the allocation bitmap in
781+
// the extended l2 cluster entry.
779782
cm.Zero = standardClusterDescriptor(desc).allZero()
780783
}
781784

@@ -966,6 +969,110 @@ func readZero(p []byte, off int64, sz uint64) (int, error) {
966969
return l, err
967970
}
968971

972+
// clusterStatus returns an extent describing a single cluster. off must be aligned to
973+
// cluster size.
974+
func (img *Qcow2) clusterStatus(off int64) (image.Extent, error) {
975+
var cm clusterMeta
976+
if err := img.getClusterMeta(off, &cm); err != nil {
977+
return image.Extent{}, err
978+
}
979+
980+
if !cm.Allocated {
981+
// If there is no backing file, or the cluster cannot be in the backing file,
982+
// return an unallocated cluster.
983+
if img.backingImage == nil || off >= img.backingImage.Size() {
984+
// Unallocated cluster reads as zeros.
985+
unallocated := image.Extent{Start: off, Length: int64(img.clusterSize), Zero: true}
986+
return unallocated, nil
987+
}
988+
989+
// Get the cluster from the backing file.
990+
length := int64(img.clusterSize)
991+
if off+length > img.backingImage.Size() {
992+
length = img.backingImage.Size() - off
993+
}
994+
parent, err := img.backingImage.Extent(off, length)
995+
if err != nil {
996+
return parent, err
997+
}
998+
// The backing image may be a raw image not aligned to cluster size.
999+
parent.Length = int64(img.clusterSize)
1000+
return parent, nil
1001+
}
1002+
1003+
// Cluster present in this image.
1004+
allocated := image.Extent{
1005+
Start: off,
1006+
Length: int64(img.clusterSize),
1007+
Allocated: true,
1008+
Compressed: cm.Compressed,
1009+
Zero: cm.Zero,
1010+
}
1011+
return allocated, nil
1012+
}
1013+
1014+
// Return true if extents have the same status.
1015+
func sameStatus(a, b image.Extent) bool {
1016+
return a.Allocated == b.Allocated && a.Zero == b.Zero && a.Compressed == b.Compressed
1017+
}
1018+
1019+
// Extent returns the next extent starting at the specified offset. An extent
1020+
// describes one or more clusters having the same status. The maximum length of
1021+
// the returned extent is limited by the specified length. The minimum length of
1022+
// the returned extent is length of one cluster.
1023+
func (img *Qcow2) Extent(start, length int64) (image.Extent, error) {
1024+
// Default to zero length non-existent cluster.
1025+
var current image.Extent
1026+
1027+
if img.errUnreadable != nil {
1028+
return current, img.errUnreadable
1029+
}
1030+
if img.clusterSize == 0 {
1031+
return current, errors.New("cluster size cannot be 0")
1032+
}
1033+
if start+length > int64(img.Header.Size) {
1034+
return current, errors.New("length out of bounds")
1035+
}
1036+
1037+
// Compute the clusterStart of the first cluster to query. This may be behind start.
1038+
clusterStart := start / int64(img.clusterSize) * int64(img.clusterSize)
1039+
1040+
remaining := length
1041+
for remaining > 0 {
1042+
clusterStatus, err := img.clusterStatus(clusterStart)
1043+
if err != nil {
1044+
return current, err
1045+
}
1046+
1047+
// First cluster: if start is not aligned to cluster size, clip the start.
1048+
if clusterStatus.Start < start {
1049+
clusterStatus.Start = start
1050+
clusterStatus.Length -= start - clusterStatus.Start
1051+
}
1052+
1053+
// Last cluster: if start+length is not aligned to cluster size, clip the end.
1054+
if remaining < int64(img.clusterSize) {
1055+
clusterStatus.Length -= int64(img.clusterSize) - remaining
1056+
}
1057+
1058+
if current.Length == 0 {
1059+
// First cluster: copy status to current.
1060+
current = clusterStatus
1061+
} else if sameStatus(current, clusterStatus) {
1062+
// Cluster with same status: extend current.
1063+
current.Length += clusterStatus.Length
1064+
} else {
1065+
// Start of next extent
1066+
break
1067+
}
1068+
1069+
clusterStart += int64(img.clusterSize)
1070+
remaining -= clusterStatus.Length
1071+
}
1072+
1073+
return current, nil
1074+
}
1075+
9691076
// ReadAt implements [io.ReaderAt].
9701077
func (img *Qcow2) ReadAt(p []byte, off int64) (n int, err error) {
9711078
if img.errUnreadable != nil {

image/raw/raw.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package raw
22

33
import (
4+
"errors"
45
"io"
56
"os"
67

@@ -14,6 +15,18 @@ type Raw struct {
1415
io.ReaderAt `json:"-"`
1516
}
1617

18+
// Extent returns an allocated extent starting at the specified offset with
19+
// specified length. It is used when the speicfic image type does not implement
20+
// Extent(). The implementation is correct but inefficient. Fails if image size
21+
// is unknown.
22+
func (img *Raw) Extent(start, length int64) (image.Extent, error) {
23+
if start+length > img.Size() {
24+
return image.Extent{}, errors.New("length out of bounds")
25+
}
26+
// TODO: Implement using SEEK_HOLE/SEEK_DATA when supported by the file system.
27+
return image.Extent{Start: start, Length: length, Allocated: true}, nil
28+
}
29+
1730
func (img *Raw) Close() error {
1831
if closer, ok := img.ReaderAt.(io.Closer); ok {
1932
return closer.Close()

image/stub/stub.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ type Stub struct {
2525
t image.Type
2626
}
2727

28+
func (img *Stub) Extent(start, length int64) (image.Extent, error) {
29+
return image.Extent{}, fmt.Errorf("unimplemented type: %q", img.t)
30+
}
31+
2832
func (img *Stub) ReadAt([]byte, int64) (int, error) {
2933
return 0, img.Readable()
3034
}

qcow2reader_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/lima-vm/go-qcow2reader"
1313
"github.com/lima-vm/go-qcow2reader/convert"
14+
"github.com/lima-vm/go-qcow2reader/image"
1415
"github.com/lima-vm/go-qcow2reader/test/qemuimg"
1516
)
1617

@@ -19,6 +20,186 @@ const (
1920
GiB = int64(1) << 30
2021
)
2122

23+
func TestExtentsUnallocated(t *testing.T) {
24+
path := filepath.Join(t.TempDir(), "image")
25+
if err := qemuimg.Create(path, qemuimg.FormatQcow2, 4*GiB, "", ""); err != nil {
26+
t.Fatal(err)
27+
}
28+
f, err := os.Open(path)
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
defer f.Close()
33+
img, err := qcow2reader.Open(f)
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
defer img.Close()
38+
39+
t.Run("entire image", func(t *testing.T) {
40+
actual, err := img.Extent(0, img.Size())
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
expected := image.Extent{Start: 0, Length: img.Size(), Zero: true}
45+
if actual != expected {
46+
t.Fatalf("expected %+v, got %+v", expected, actual)
47+
}
48+
})
49+
t.Run("same result", func(t *testing.T) {
50+
r1, err := img.Extent(0, img.Size())
51+
if err != nil {
52+
t.Fatal(err)
53+
}
54+
r2, err := img.Extent(0, img.Size())
55+
if err != nil {
56+
t.Fatal(err)
57+
}
58+
if r1 != r2 {
59+
t.Fatalf("expected %+v, got %+v", r1, r2)
60+
}
61+
})
62+
t.Run("all segments", func(t *testing.T) {
63+
for i := int64(0); i < img.Size(); i += 32 * MiB {
64+
segment, err := img.Extent(i, 32*MiB)
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
expected := image.Extent{Start: i, Length: 32 * MiB, Zero: true}
69+
if segment != expected {
70+
t.Fatalf("expected %+v, got %+v", expected, segment)
71+
}
72+
}
73+
})
74+
t.Run("start unaligned", func(t *testing.T) {
75+
start := 32*MiB + 42
76+
length := 32 * MiB
77+
actual, err := img.Extent(start, length)
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
expected := image.Extent{Start: start, Length: length, Zero: true}
82+
if actual != expected {
83+
t.Fatalf("expected %+v, got %+v", expected, actual)
84+
}
85+
})
86+
t.Run("length unaligned", func(t *testing.T) {
87+
start := 32 * MiB
88+
length := 32*MiB - 42
89+
actual, err := img.Extent(start, length)
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
expected := image.Extent{Start: start, Length: length, Zero: true}
94+
if actual != expected {
95+
t.Fatalf("expected %+v, got %+v", expected, actual)
96+
}
97+
})
98+
t.Run("start and length unaligned", func(t *testing.T) {
99+
start := 32*MiB + 42
100+
length := 32*MiB - 42
101+
actual, err := img.Extent(start, length)
102+
if err != nil {
103+
t.Fatal(err)
104+
}
105+
expected := image.Extent{Start: start, Length: length, Zero: true}
106+
if actual != expected {
107+
t.Fatalf("expected %+v, got %+v", expected, actual)
108+
}
109+
})
110+
t.Run("length after end of image", func(t *testing.T) {
111+
start := img.Size() - 31*MiB
112+
actual, err := img.Extent(start, 32*MiB)
113+
if err == nil {
114+
t.Fatal("out of bounds request did not fail")
115+
}
116+
var expected image.Extent
117+
if actual != expected {
118+
t.Fatalf("expected %+v, got %+v", expected, actual)
119+
}
120+
})
121+
t.Run("start after end of image", func(t *testing.T) {
122+
start := img.Size() + 1*MiB
123+
actual, err := img.Extent(start, 32*MiB)
124+
if err == nil {
125+
t.Fatal("out of bounds request did not fail")
126+
}
127+
var expected image.Extent
128+
if actual != expected {
129+
t.Fatalf("expected %+v, got %+v", expected, actual)
130+
}
131+
})
132+
}
133+
134+
func TestExtentsRaw(t *testing.T) {
135+
path := filepath.Join(t.TempDir(), "disk.img")
136+
size := 4 * GiB
137+
f, err := os.Create(path)
138+
if err != nil {
139+
t.Fatal(err)
140+
}
141+
defer f.Close()
142+
if err := f.Truncate(size); err != nil {
143+
t.Fatal(err)
144+
}
145+
img, err := qcow2reader.Open(f)
146+
if err != nil {
147+
t.Fatal(err)
148+
}
149+
defer img.Close()
150+
151+
t.Run("entire image", func(t *testing.T) {
152+
actual, err := img.Extent(0, img.Size())
153+
if err != nil {
154+
t.Fatal(err)
155+
}
156+
// Currently we always report raw images as fully allocated.
157+
expected := image.Extent{Start: 0, Length: img.Size(), Allocated: true}
158+
if actual != expected {
159+
t.Fatalf("expected %+v, got %+v", expected, actual)
160+
}
161+
})
162+
t.Run("length after end of image", func(t *testing.T) {
163+
start := img.Size() - 31*MiB
164+
actual, err := img.Extent(start, 32*MiB)
165+
if err == nil {
166+
t.Fatal("out of bounds request did not fail")
167+
}
168+
var expected image.Extent
169+
if actual != expected {
170+
t.Fatalf("expected %+v, got %+v", expected, actual)
171+
}
172+
})
173+
}
174+
175+
func BenchmarkExtentsUnallocated(b *testing.B) {
176+
path := filepath.Join(b.TempDir(), "image")
177+
if err := qemuimg.Create(path, qemuimg.FormatQcow2, 100*GiB, "", ""); err != nil {
178+
b.Fatal(err)
179+
}
180+
f, err := os.Open(path)
181+
if err != nil {
182+
b.Fatal(err)
183+
}
184+
img, err := qcow2reader.Open(f)
185+
if err != nil {
186+
b.Fatal(err)
187+
}
188+
expected := image.Extent{Start: 0, Length: img.Size(), Zero: true}
189+
resetBenchmark(b, img.Size())
190+
for i := 0; i < b.N; i++ {
191+
b.StartTimer()
192+
actual, err := img.Extent(0, img.Size())
193+
b.StopTimer()
194+
if err != nil {
195+
b.Fatal(err)
196+
}
197+
if actual != expected {
198+
b.Fatalf("expected %+v, got %+v", expected, actual)
199+
}
200+
}
201+
}
202+
22203
// Benchmark completely empty sparse image (0% utilization). This is the best
23204
// case when we don't have to read any cluster from storage.
24205
func Benchmark0p(b *testing.B) {

0 commit comments

Comments
 (0)