detached d40e1f9

#25 fix(drm): retry commits refused for memory instead of stopping the output

state
merged · fdd661a · merge commit
branch
fix/drm-enomem-retry → main
opened
bnema ·
updated

Problem

Under VRAM pressure amdgpu can refuse an atomic commit with ENOMEM (the kernel fails to pin the new framebuffer). The previous frame stays on screen, but NeferWL treated the error as fatal: the output stopped and stayed dark until the next hotplug.

Change

  • ENOMEM on a frame, state or modeset commit keeps the output: the last frame stays on screen and the commit retries with a backoff from 16 ms to 1 s, without limit and without the stuck-recovery modeset.
  • The frame lifecycle tracks the current wait kind (flip, busy, nomem) apart from the memory-refusal run, so EBUSY after ENOMEM keeps its event unblocking and modeset recovery.
  • Direct scanout refused for memory pauses scanout for 2 s and composes into the resident output images.
  • Signalled render fences are reaped during retries, so long refusals do not accumulate descriptors.
  • Protected (locked) commits keep their existing retry path: a refused frame is never certified or reported.
  • Logs: one warn per refusal run, another after 5 s, info commit recovered with attempts and outage.

Checks

make check, make race, make mocks-check, staticcheck ./...; unit tests for backoff, ENOMEM→EBUSY, lasting refusal, modeset retry, scanout fallback, protected path, fence reaping. Not tested on real hardware under memory pressure.

Out of scope: renderer out-of-memory and GBM allocation failures at start/resume still stop the output.

CommitSubjectAuthorDateSignatureStatus
d40e1f9fix(drm): reap signalled render fences during commit retriesbriceGPG Verified
7703bb9fix(drm): separate the frame wait reason from the memory-refusal runbriceGPG Verified
d707400fix(drm): retry commits refused for memory instead of stopping the outputbriceGPG Verified

Checks 1

35 files · +742 -1833
1/1
M .goreleaser.yaml +2 -2
M .mockery.yml +0 -5
M LICENSE +80 -67
M Makefile +1 -1
M README.md +2 -2
M docs/dev/architecture.md +0 -7
M internal/adapters/drm/atomic.go +0 -9
M internal/adapters/drm/commit_test.go +6 -8
M internal/adapters/drm/cursor.go +29 -73
D internal/adapters/drm/cursor_images.go +0 -164
D internal/adapters/drm/cursor_images_mock_test.go +0 -204
D internal/adapters/drm/cursor_images_test.go +0 -491
D internal/adapters/drm/dumb.go +0 -77
D internal/adapters/drm/dumb_device_test.go +0 -111
M internal/adapters/drm/frame_lifecycle.go +55 -4
M internal/adapters/drm/frame_lifecycle_test.go +66 -0
M internal/adapters/drm/kms_mock_test.go +0 -308
M internal/adapters/drm/kms_test.go +0 -3
A internal/adapters/drm/nomem_reads_test.go +73 -0
A internal/adapters/drm/nomem_test.go +208 -0
M internal/adapters/drm/output.go +83 -67
M internal/adapters/drm/reads.go +3 -1
M internal/adapters/drm/scanout.go +10 -0
M internal/adapters/drm/stuck_test.go +13 -1
D internal/adapters/hdrcursor/convert_test.go +0 -29
D internal/adapters/hdrcursor/hdrcursor.go +0 -100
@@ -1,100 +0,0 @@
1-// Package hdrcursor converts cursor images to the HDR10 output encoding
2-// (BT.2020 PQ) for cursor planes, whichever adapter owns the image memory.
3-package hdrcursor
4-
5-import (
6- "crypto/sha256"
7- "encoding/binary"
8- "math"
9-)
10-
11-var srgbTable = func() (table [256]float64) {
12- for i := range table {
13- table[i] = SRGBToLinear(float64(i) / 255)
14- }
15- return
16-}()
17-
18-// SRGBToLinear decodes a unit-range sRGB component.
19-func SRGBToLinear(v float64) float64 {
20- if v <= 0.04045 {
21- return v / 12.92
22- }
23- return math.Pow((v+0.055)/1.055, 2.4)
24-}
25-
26-// PQEncode encodes absolute luminance in nits using SMPTE ST 2084.
27-func PQEncode(nits float64) float64 {
28- if nits <= 0 {
29- return 0
30- }
31- x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
32- return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
33-}
34-
35-// Pixel converts premultiplied BGRA bytes in place, preserving alpha. The
36-// source is quantized to its 8-bit unassociated sRGB value for the LUT.
37-func Pixel(p []byte, nits float64) {
38- a := float64(p[3]) / 255
39- if a == 0 {
40- clear(p[:3])
41- return
42- }
43- index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
44- r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
45- r, g, b = PQEncode((0.627404*r+0.329283*g+0.043313*b)*nits), PQEncode((0.069097*r+0.919540*g+0.011362*b)*nits), PQEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
46- p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
47-}
48-
49-// Cache converts cursor images and keeps only the last result, bounded by
50-// the cursor plane's maximum side regardless of cursor theme size. The
51-// zero value is ready; it is not safe for concurrent use.
52-type Cache struct {
53- key [32]byte
54- pixels []byte
55-}
56-
57-// Len is the size of the cached conversion in bytes (0: empty).
58-func (c *Cache) Len() int { return len(c.pixels) }
59-
60-// Key identifies the cached conversion (the zero key: empty).
61-func (c *Cache) Key() [32]byte { return c.key }
62-
63-// Reset drops the cached conversion.
64-func (c *Cache) Reset() { *c = Cache{} }
65-
66-func key(pixels []byte, w, h, side int, nits float64) [32]byte {
67- var header [32]byte
68- binary.LittleEndian.PutUint64(header[:8], uint64(w))
69- binary.LittleEndian.PutUint64(header[8:16], uint64(h))
70- binary.LittleEndian.PutUint64(header[16:24], math.Float64bits(nits))
71- binary.LittleEndian.PutUint64(header[24:], uint64(side))
72- digest := sha256.New()
73- _, _ = digest.Write(header[:])
74- _, _ = digest.Write(pixels)
75- var k [32]byte
76- digest.Sum(k[:0])
77- return k
78-}
79-
80-// Convert returns the w×h premultiplied BGRA image (w*4 per row) cropped
81-// to side×side and converted for an SDR white of nits, packed without row
82-// padding: min(w, side) pixels per row, min(h, side) rows. The result is
83-// owned by the cache and valid until the next call.
84-func (c *Cache) Convert(pixels []byte, w, h, side int, nits float64) []byte {
85- pixels = pixels[:w*h*4]
86- k := key(pixels, w, h, side, nits)
87- if c.pixels != nil && c.key == k {
88- return c.pixels
89- }
90- cw, ch := min(w, side)*4, min(h, side)
91- out := make([]byte, cw*ch)
92- for y := range ch {
93- copy(out[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
94- for x := 0; x < cw; x += 4 {
95- Pixel(out[y*cw+x:y*cw+x+4], nits)
96- }
97- }
98- c.key, c.pixels = k, out
99- return out
100-}
@@ -1,100 +0,0 @@
1-// Package hdrcursor converts cursor images to the HDR10 output encoding
2-// (BT.2020 PQ) for cursor planes, whichever adapter owns the image memory.
3-package hdrcursor
4-
5-import (
6- "crypto/sha256"
7- "encoding/binary"
8- "math"
9-)
10-
11-var srgbTable = func() (table [256]float64) {
12- for i := range table {
13- table[i] = SRGBToLinear(float64(i) / 255)
14- }
15- return
16-}()
17-
18-// SRGBToLinear decodes a unit-range sRGB component.
19-func SRGBToLinear(v float64) float64 {
20- if v <= 0.04045 {
21- return v / 12.92
22- }
23- return math.Pow((v+0.055)/1.055, 2.4)
24-}
25-
26-// PQEncode encodes absolute luminance in nits using SMPTE ST 2084.
27-func PQEncode(nits float64) float64 {
28- if nits <= 0 {
29- return 0
30- }
31- x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
32- return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
33-}
34-
35-// Pixel converts premultiplied BGRA bytes in place, preserving alpha. The
36-// source is quantized to its 8-bit unassociated sRGB value for the LUT.
37-func Pixel(p []byte, nits float64) {
38- a := float64(p[3]) / 255
39- if a == 0 {
40- clear(p[:3])
41- return
42- }
43- index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
44- r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
45- r, g, b = PQEncode((0.627404*r+0.329283*g+0.043313*b)*nits), PQEncode((0.069097*r+0.919540*g+0.011362*b)*nits), PQEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
46- p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
47-}
48-
49-// Cache converts cursor images and keeps only the last result, bounded by
50-// the cursor plane's maximum side regardless of cursor theme size. The
51-// zero value is ready; it is not safe for concurrent use.
52-type Cache struct {
53- key [32]byte
54- pixels []byte
55-}
56-
57-// Len is the size of the cached conversion in bytes (0: empty).
58-func (c *Cache) Len() int { return len(c.pixels) }
59-
60-// Key identifies the cached conversion (the zero key: empty).
61-func (c *Cache) Key() [32]byte { return c.key }
62-
63-// Reset drops the cached conversion.
64-func (c *Cache) Reset() { *c = Cache{} }
65-
66-func key(pixels []byte, w, h, side int, nits float64) [32]byte {
67- var header [32]byte
68- binary.LittleEndian.PutUint64(header[:8], uint64(w))
69- binary.LittleEndian.PutUint64(header[8:16], uint64(h))
70- binary.LittleEndian.PutUint64(header[16:24], math.Float64bits(nits))
71- binary.LittleEndian.PutUint64(header[24:], uint64(side))
72- digest := sha256.New()
73- _, _ = digest.Write(header[:])
74- _, _ = digest.Write(pixels)
75- var k [32]byte
76- digest.Sum(k[:0])
77- return k
78-}
79-
80-// Convert returns the w×h premultiplied BGRA image (w*4 per row) cropped
81-// to side×side and converted for an SDR white of nits, packed without row
82-// padding: min(w, side) pixels per row, min(h, side) rows. The result is
83-// owned by the cache and valid until the next call.
84-func (c *Cache) Convert(pixels []byte, w, h, side int, nits float64) []byte {
85- pixels = pixels[:w*h*4]
86- k := key(pixels, w, h, side, nits)
87- if c.pixels != nil && c.key == k {
88- return c.pixels
89- }
90- cw, ch := min(w, side)*4, min(h, side)
91- out := make([]byte, cw*ch)
92- for y := range ch {
93- copy(out[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
94- for x := 0; x < cw; x += 4 {
95- Pixel(out[y*cw+x:y*cw+x+4], nits)
96- }
97- }
98- c.key, c.pixels = k, out
99- return out
100-}
D internal/adapters/hdrcursor/hdrcursor_test.go +0 -76
@@ -1,76 +0,0 @@
1-package hdrcursor
2-
3-import (
4- "bytes"
5- "math"
6- "testing"
7-)
8-
9-// vector is a 5×3 premultiplied BGRA image: alpha ramps up with one fully
10-// transparent pixel (index 3).
11-func vector() (pixels []byte, w, h int) {
12- w, h = 5, 3
13- pixels = make([]byte, w*h*4)
14- for i := range w * h {
15- a := byte(i * 255 / (w*h - 1))
16- if i == 3 {
17- a = 0
18- }
19- c := func(v int) byte { return byte(v * int(a) / 255) }
20- pixels[i*4], pixels[i*4+1], pixels[i*4+2], pixels[i*4+3] = c(i*37%256), c((i*91+20)%256), c((i*53+200)%256), a
21- }
22- return
23-}
24-
25-// The conversion is byte-identical to the one the Vulkan renderer did
26-// before it moved here (203 nits, cropped to 4×4).
27-func TestConvertMatchesRecordedVector(t *testing.T) {
28- pixels, w, h := vector()
29- want := []byte{0x0, 0x0, 0x0, 0x0, 0x5, 0x7, 0x9, 0x12, 0xd, 0x13, 0xf, 0x24, 0x0, 0x0, 0x0, 0x0, 0x2e, 0x31, 0x31, 0x5b, 0x3a, 0x1f, 0x20, 0x6d, 0x20, 0x39, 0x30, 0x7f, 0x33, 0x51, 0x46, 0x91, 0x4c, 0x58, 0x5f, 0xb6, 0x5f, 0x72, 0x5e, 0xc8, 0x6d, 0x4e, 0x4c, 0xda, 0x81, 0x75, 0x6c, 0xec}
30- var c Cache
31- if got := c.Convert(pixels, w, h, 4, 203); !bytes.Equal(got, want) {
32- t.Fatalf("converted\n got %#v\nwant %#v", got, want)
33- }
34-}
35-
36-func TestPixelWhite(t *testing.T) {
37- p := []byte{255, 255, 255, 255}
38- Pixel(p, 203)
39- if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
40- t.Fatalf("cursor white: %v", p)
41- }
42- if PQEncode(0) != 0 || math.Abs(PQEncode(10000)-1) > 1e-9 {
43- t.Fatal("PQ endpoints")
44- }
45-}
46-
47-func TestCacheKeepsLastConversion(t *testing.T) {
48- var c Cache
49- px := []byte{255, 255, 255, 255}
50- a := c.Convert(px, 1, 1, 64, 203)
51- if c.Len() != 4 || a[0] != 148 {
52- t.Fatalf("len %d pixel %v", c.Len(), a)
53- }
54- first := c.Key()
55- if b := c.Convert(px, 1, 1, 64, 203); &a[0] != &b[0] {
56- t.Fatal("same image converted again")
57- }
58- for i := range 12 {
59- v := byte(i * 18)
60- c.Convert([]byte{v, v, v, 255}, 1, 1, 64, 203)
61- if c.Len() != 4 {
62- t.Fatalf("cache grew after image %d: %d", i, c.Len())
63- }
64- }
65- if c.Key() == first {
66- t.Fatal("conversion not replaced")
67- }
68- c.Convert(px, 1, 1, 64, 100)
69- if c.Key() == first {
70- t.Fatal("nits not part of the key")
71- }
72- c.Reset()
73- if c.Len() != 0 || c.Key() != [32]byte{} {
74- t.Fatal("reset left a conversion")
75- }
76-}
@@ -1,76 +0,0 @@
1-package hdrcursor
2-
3-import (
4- "bytes"
5- "math"
6- "testing"
7-)
8-
9-// vector is a 5×3 premultiplied BGRA image: alpha ramps up with one fully
10-// transparent pixel (index 3).
11-func vector() (pixels []byte, w, h int) {
12- w, h = 5, 3
13- pixels = make([]byte, w*h*4)
14- for i := range w * h {
15- a := byte(i * 255 / (w*h - 1))
16- if i == 3 {
17- a = 0
18- }
19- c := func(v int) byte { return byte(v * int(a) / 255) }
20- pixels[i*4], pixels[i*4+1], pixels[i*4+2], pixels[i*4+3] = c(i*37%256), c((i*91+20)%256), c((i*53+200)%256), a
21- }
22- return
23-}
24-
25-// The conversion is byte-identical to the one the Vulkan renderer did
26-// before it moved here (203 nits, cropped to 4×4).
27-func TestConvertMatchesRecordedVector(t *testing.T) {
28- pixels, w, h := vector()
29- want := []byte{0x0, 0x0, 0x0, 0x0, 0x5, 0x7, 0x9, 0x12, 0xd, 0x13, 0xf, 0x24, 0x0, 0x0, 0x0, 0x0, 0x2e, 0x31, 0x31, 0x5b, 0x3a, 0x1f, 0x20, 0x6d, 0x20, 0x39, 0x30, 0x7f, 0x33, 0x51, 0x46, 0x91, 0x4c, 0x58, 0x5f, 0xb6, 0x5f, 0x72, 0x5e, 0xc8, 0x6d, 0x4e, 0x4c, 0xda, 0x81, 0x75, 0x6c, 0xec}
30- var c Cache
31- if got := c.Convert(pixels, w, h, 4, 203); !bytes.Equal(got, want) {
32- t.Fatalf("converted\n got %#v\nwant %#v", got, want)
33- }
34-}
35-
36-func TestPixelWhite(t *testing.T) {
37- p := []byte{255, 255, 255, 255}
38- Pixel(p, 203)
39- if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
40- t.Fatalf("cursor white: %v", p)
41- }
42- if PQEncode(0) != 0 || math.Abs(PQEncode(10000)-1) > 1e-9 {
43- t.Fatal("PQ endpoints")
44- }
45-}
46-
47-func TestCacheKeepsLastConversion(t *testing.T) {
48- var c Cache
49- px := []byte{255, 255, 255, 255}
50- a := c.Convert(px, 1, 1, 64, 203)
51- if c.Len() != 4 || a[0] != 148 {
52- t.Fatalf("len %d pixel %v", c.Len(), a)
53- }
54- first := c.Key()
55- if b := c.Convert(px, 1, 1, 64, 203); &a[0] != &b[0] {
56- t.Fatal("same image converted again")
57- }
58- for i := range 12 {
59- v := byte(i * 18)
60- c.Convert([]byte{v, v, v, 255}, 1, 1, 64, 203)
61- if c.Len() != 4 {
62- t.Fatalf("cache grew after image %d: %d", i, c.Len())
63- }
64- }
65- if c.Key() == first {
66- t.Fatal("conversion not replaced")
67- }
68- c.Convert(px, 1, 1, 64, 100)
69- if c.Key() == first {
70- t.Fatal("nits not part of the key")
71- }
72- c.Reset()
73- if c.Len() != 0 || c.Key() != [32]byte{} {
74- t.Fatal("reset left a conversion")
75- }
76-}
M internal/adapters/vulkan/cursor.go +42 -5
@@ -1,8 +1,11 @@
11 package vulkan
22
33 import (
4+ "crypto/sha256"
5+ "encoding/binary"
46 "errors"
57 "fmt"
8+ "math"
69 "os"
710 "unsafe"
811
@@ -105,6 +108,26 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
105108 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
106109 }
107110
111+// cursorConversion keeps only the last converted image, bounded by the
112+// cursor plane's maximum dimensions, regardless of cursor theme size.
113+type cursorConversion struct {
114+ key [32]byte
115+ pixels []byte
116+}
117+
118+func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119+ var header [24]byte
120+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
121+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122+ binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123+ digest := sha256.New()
124+ _, _ = digest.Write(header[:])
125+ _, _ = digest.Write(pixels)
126+ var key [32]byte
127+ digest.Sum(key[:0])
128+ return key
129+}
130+
108131 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
109132 // into cursor image i at its row pitch, cropped and cleared around.
110133 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -118,13 +141,27 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
118141 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
119142 clear(mem)
120143 cw := min(w, c.size) * 4
121- src, stride := pixels, w*4
122144 if r.hdrNits > 0 {
123145 // WriteCursor is called only on image changes, never on moves.
124- src, stride = r.cursorCache.Convert(pixels, w, h, c.size, r.hdrNits), cw
125- }
126- for y := range min(h, c.size) {
127- copy(mem[y*c.pitch:y*c.pitch+cw], src[y*stride:y*stride+cw])
146+ key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)
147+ converted := r.cursorCache.pixels
148+ if converted == nil || r.cursorCache.key != key {
149+ converted = make([]byte, cw*min(h, c.size))
150+ for y := range min(h, c.size) {
151+ copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152+ for x := 0; x < cw; x += 4 {
153+ hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154+ }
155+ }
156+ r.cursorCache = cursorConversion{key: key, pixels: converted}
157+ }
158+ for y := range min(h, c.size) {
159+ copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160+ }
161+ } else {
162+ for y := range min(h, c.size) {
163+ copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164+ }
128165 }
129166 return nil
130167 }
@@ -1,8 +1,11 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4+ "crypto/sha256"
5+ "encoding/binary"
4 "errors"6 "errors"
5 "fmt"7 "fmt"
8+ "math"
6 "os"9 "os"
7 "unsafe"10 "unsafe"
8 11
@@ -105,6 +108,26 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
105 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil108 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
106 }109 }
107 110
111+// cursorConversion keeps only the last converted image, bounded by the
112+// cursor plane's maximum dimensions, regardless of cursor theme size.
113+type cursorConversion struct {
114+ key [32]byte
115+ pixels []byte
116+}
117+
118+func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119+ var header [24]byte
120+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
121+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122+ binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123+ digest := sha256.New()
124+ _, _ = digest.Write(header[:])
125+ _, _ = digest.Write(pixels)
126+ var key [32]byte
127+ digest.Sum(key[:0])
128+ return key
129+}
130+
108 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)131 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
109 // into cursor image i at its row pitch, cropped and cleared around.132 // into cursor image i at its row pitch, cropped and cleared around.
110 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {133 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -118,13 +141,27 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
118 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)141 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
119 clear(mem)142 clear(mem)
120 cw := min(w, c.size) * 4143 cw := min(w, c.size) * 4
121- src, stride := pixels, w*4
122 if r.hdrNits > 0 {144 if r.hdrNits > 0 {
123 // WriteCursor is called only on image changes, never on moves.145 // WriteCursor is called only on image changes, never on moves.
124- src, stride = r.cursorCache.Convert(pixels, w, h, c.size, r.hdrNits), cw146+ key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)
125- }147+ converted := r.cursorCache.pixels
126- for y := range min(h, c.size) {148+ if converted == nil || r.cursorCache.key != key {
127- copy(mem[y*c.pitch:y*c.pitch+cw], src[y*stride:y*stride+cw])149+ converted = make([]byte, cw*min(h, c.size))
150+ for y := range min(h, c.size) {
151+ copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152+ for x := 0; x < cw; x += 4 {
153+ hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154+ }
155+ }
156+ r.cursorCache = cursorConversion{key: key, pixels: converted}
157+ }
158+ for y := range min(h, c.size) {
159+ copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160+ }
161+ } else {
162+ for y := range min(h, c.size) {
163+ copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164+ }
128 }165 }
129 return nil166 return nil
130 }167 }
M internal/adapters/vulkan/cursor_test.go +25 -6
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
4747 }
4848 }
4949
50-func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
50+func TestHDRCursorCachesConversion(t *testing.T) {
5151 r, err := New(32, 16)
5252 if err != nil {
5353 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,6 +65,28 @@ func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
6565 if err := r.WriteCursor(0, px, 1, 1); err != nil {
6666 t.Fatal(err)
6767 }
68+ if len(r.cursorCache.pixels) != 4 {
69+ t.Fatalf("cache size %d", len(r.cursorCache.pixels))
70+ }
71+ if err := r.WriteCursor(1, px, 1, 1); err != nil {
72+ t.Fatal(err)
73+ }
74+ if len(r.cursorCache.pixels) != 4 {
75+ t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76+ }
77+ first := r.cursorCache.key
78+ for i := 0; i < 12; i++ {
79+ color := byte(i * 18)
80+ if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81+ t.Fatal(err)
82+ }
83+ if len(r.cursorCache.pixels) != 4 {
84+ t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85+ }
86+ }
87+ if first == r.cursorCache.key {
88+ t.Fatal("cursor conversion not replaced")
89+ }
6890 if err := r.WriteCursor(1, px, 1, 1); err != nil {
6991 t.Fatal(err)
7092 }
@@ -75,13 +97,10 @@ func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
7597 }
7698 }
7799 r.SetHDR(0)
78- if r.cursorCache.Len() != 0 {
79- t.Fatal("SetHDR kept conversion")
80- }
81100 if err := r.WriteCursor(0, px, 1, 1); err != nil {
82101 t.Fatal(err)
83102 }
84- if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || r.cursorCache.Len() != 0 {
85- t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())
103+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {
104+ t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))
86105 }
87106 }
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
47 }47 }
48 }48 }
49 49
50-func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {50+func TestHDRCursorCachesConversion(t *testing.T) {
51 r, err := New(32, 16)51 r, err := New(32, 16)
52 if err != nil {52 if err != nil {
53 t.Skipf("Vulkan unavailable: %v", err)53 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,6 +65,28 @@ func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
65 if err := r.WriteCursor(0, px, 1, 1); err != nil {65 if err := r.WriteCursor(0, px, 1, 1); err != nil {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68+ if len(r.cursorCache.pixels) != 4 {
69+ t.Fatalf("cache size %d", len(r.cursorCache.pixels))
70+ }
71+ if err := r.WriteCursor(1, px, 1, 1); err != nil {
72+ t.Fatal(err)
73+ }
74+ if len(r.cursorCache.pixels) != 4 {
75+ t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76+ }
77+ first := r.cursorCache.key
78+ for i := 0; i < 12; i++ {
79+ color := byte(i * 18)
80+ if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81+ t.Fatal(err)
82+ }
83+ if len(r.cursorCache.pixels) != 4 {
84+ t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85+ }
86+ }
87+ if first == r.cursorCache.key {
88+ t.Fatal("cursor conversion not replaced")
89+ }
68 if err := r.WriteCursor(1, px, 1, 1); err != nil {90 if err := r.WriteCursor(1, px, 1, 1); err != nil {
69 t.Fatal(err)91 t.Fatal(err)
70 }92 }
@@ -75,13 +97,10 @@ func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
75 }97 }
76 }98 }
77 r.SetHDR(0)99 r.SetHDR(0)
78- if r.cursorCache.Len() != 0 {
79- t.Fatal("SetHDR kept conversion")
80- }
81 if err := r.WriteCursor(0, px, 1, 1); err != nil {100 if err := r.WriteCursor(0, px, 1, 1); err != nil {
82 t.Fatal(err)101 t.Fatal(err)
83 }102 }
84- if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || r.cursorCache.Len() != 0 {103+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {
85- t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())104+ t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))
86 }105 }
87 }106 }
M internal/adapters/vulkan/hdr.go +2 -2
@@ -28,7 +28,7 @@ type hdrPass struct {
2828 // remains owner-goroutine confined; all GPU objects are reused each frame.
2929 func (r *Renderer) SetHDR(nits float64) {
3030 if nits != r.hdrNits {
31- r.cursorCache.Reset()
31+ r.cursorCache = cursorConversion{}
3232 if r.hdrNits > 0 && nits > 0 {
3333 r.hdrOwn.valid = false
3434 }
@@ -47,7 +47,7 @@ func (r *Renderer) SetHDR(nits float64) {
4747 }
4848 r.hdrNits = nits
4949 if nits <= 0 {
50- r.cursorCache.Reset()
50+ r.cursorCache = cursorConversion{}
5151 r.hdrError = nil
5252 return
5353 }
@@ -28,7 +28,7 @@ type hdrPass struct {
28 // remains owner-goroutine confined; all GPU objects are reused each frame.28 // remains owner-goroutine confined; all GPU objects are reused each frame.
29 func (r *Renderer) SetHDR(nits float64) {29 func (r *Renderer) SetHDR(nits float64) {
30 if nits != r.hdrNits {30 if nits != r.hdrNits {
31- r.cursorCache.Reset()31+ r.cursorCache = cursorConversion{}
32 if r.hdrNits > 0 && nits > 0 {32 if r.hdrNits > 0 && nits > 0 {
33 r.hdrOwn.valid = false33 r.hdrOwn.valid = false
34 }34 }
@@ -47,7 +47,7 @@ func (r *Renderer) SetHDR(nits float64) {
47 }47 }
48 r.hdrNits = nits48 r.hdrNits = nits
49 if nits <= 0 {49 if nits <= 0 {
50- r.cursorCache.Reset()50+ r.cursorCache = cursorConversion{}
51 r.hdrError = nil51 r.hdrError = nil
52 return52 return
53 }53 }
M internal/adapters/vulkan/pq.go +34 -6
@@ -1,13 +1,21 @@
11 package vulkan
22
3-import (
4- "math"
3+import "math"
54
6- "github.com/bnema/neferwl/internal/adapters/hdrcursor"
7-)
5+var srgbTable = func() (table [256]float64) {
6+ for i := range table {
7+ table[i] = srgbToLinear(float64(i) / 255)
8+ }
9+ return
10+}()
811
912 // srgbToLinear decodes a unit-range sRGB component.
10-func srgbToLinear(v float64) float64 { return hdrcursor.SRGBToLinear(v) }
13+func srgbToLinear(v float64) float64 {
14+ if v <= 0.04045 {
15+ return v / 12.92
16+ }
17+ return math.Pow((v+0.055)/1.055, 2.4)
18+}
1119
1220 // linearToSRGB encodes a unit-range linear component; the inverse of
1321 // srgbToLinear.
@@ -19,7 +27,13 @@ func linearToSRGB(v float64) float64 {
1927 }
2028
2129 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
22-func pqEncode(nits float64) float64 { return hdrcursor.PQEncode(nits) }
30+func pqEncode(nits float64) float64 {
31+ if nits <= 0 {
32+ return 0
33+ }
34+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
35+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
36+}
2337
2438 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
2539 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
@@ -28,3 +42,17 @@ func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
2842 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
2943 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
3044 }
45+
46+// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
47+// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
48+func hdrCursorPixel(p []byte, nits float64) {
49+ a := float64(p[3]) / 255
50+ if a == 0 {
51+ clear(p[:3])
52+ return
53+ }
54+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
55+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
56+ r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
57+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
58+}
@@ -1,13 +1,21 @@
1 package vulkan1 package vulkan
2 2
3-import (3+import "math"
4- "math"
5 4
6- "github.com/bnema/neferwl/internal/adapters/hdrcursor"5+var srgbTable = func() (table [256]float64) {
7-)6+ for i := range table {
7+ table[i] = srgbToLinear(float64(i) / 255)
8+ }
9+ return
10+}()
8 11
9 // srgbToLinear decodes a unit-range sRGB component.12 // srgbToLinear decodes a unit-range sRGB component.
10-func srgbToLinear(v float64) float64 { return hdrcursor.SRGBToLinear(v) }13+func srgbToLinear(v float64) float64 {
14+ if v <= 0.04045 {
15+ return v / 12.92
16+ }
17+ return math.Pow((v+0.055)/1.055, 2.4)
18+}
11 19
12 // linearToSRGB encodes a unit-range linear component; the inverse of20 // linearToSRGB encodes a unit-range linear component; the inverse of
13 // srgbToLinear.21 // srgbToLinear.
@@ -19,7 +27,13 @@ func linearToSRGB(v float64) float64 {
19 }27 }
20 28
21 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.29 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
22-func pqEncode(nits float64) float64 { return hdrcursor.PQEncode(nits) }30+func pqEncode(nits float64) float64 {
31+ if nits <= 0 {
32+ return 0
33+ }
34+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
35+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
36+}
23 37
24 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.38 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
25 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {39 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
@@ -28,3 +42,17 @@ func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
28 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),42 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
29 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)43 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
30 }44 }
45+
46+// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
47+// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
48+func hdrCursorPixel(p []byte, nits float64) {
49+ a := float64(p[3]) / 255
50+ if a == 0 {
51+ clear(p[:3])
52+ return
53+ }
54+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
55+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
56+ r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
57+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
58+}
M internal/adapters/vulkan/pq_test.go +5 -0
@@ -13,4 +13,9 @@ func TestPQMath(t *testing.T) {
1313 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
1414 t.Fatal("PQ endpoints")
1515 }
16+ p := []byte{255, 255, 255, 255}
17+ hdrCursorPixel(p, 203)
18+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19+ t.Fatalf("cursor white: %v", p)
20+ }
1621 }
@@ -13,4 +13,9 @@ func TestPQMath(t *testing.T) {
13 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {13 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
14 t.Fatal("PQ endpoints")14 t.Fatal("PQ endpoints")
15 }15 }
16+ p := []byte{255, 255, 255, 255}
17+ hdrCursorPixel(p, 203)
18+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19+ t.Fatalf("cursor white: %v", p)
20+ }
16 }21 }
M internal/adapters/vulkan/renderer.go +1 -2
@@ -9,7 +9,6 @@ import (
99 "strconv"
1010 "unsafe"
1111
12- "github.com/bnema/neferwl/internal/adapters/hdrcursor"
1312 "github.com/bnema/neferwl/internal/adapters/hintlabel"
1413 "github.com/bnema/neferwl/internal/ports"
1514 "github.com/bnema/zerowrap"
@@ -56,7 +55,7 @@ type Renderer struct {
5655 virtual bool
5756 physical vk.PhysicalDevice
5857 // One converted cursor image is kept; changes replace it in bounded memory.
59- cursorCache hdrcursor.Cache
58+ cursorCache cursorConversion
6059 // lastHDRTarget is the index in targets of the exported PQ target the
6160 // last HDR frame rendered into; -1 when the current targets hold none.
6261 lastHDRTarget int
@@ -9,7 +9,6 @@ import (
9 "strconv"9 "strconv"
10 "unsafe"10 "unsafe"
11 11
12- "github.com/bnema/neferwl/internal/adapters/hdrcursor"
13 "github.com/bnema/neferwl/internal/adapters/hintlabel"12 "github.com/bnema/neferwl/internal/adapters/hintlabel"
14 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
15 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
@@ -56,7 +55,7 @@ type Renderer struct {
56 virtual bool55 virtual bool
57 physical vk.PhysicalDevice56 physical vk.PhysicalDevice
58 // One converted cursor image is kept; changes replace it in bounded memory.57 // One converted cursor image is kept; changes replace it in bounded memory.
59- cursorCache hdrcursor.Cache58+ cursorCache cursorConversion
60 // lastHDRTarget is the index in targets of the exported PQ target the59 // lastHDRTarget is the index in targets of the exported PQ target the
61 // last HDR frame rendered into; -1 when the current targets hold none.60 // last HDR frame rendered into; -1 when the current targets hold none.
62 lastHDRTarget int61 lastHDRTarget int
M packaging/arch/PKGBUILD +1 -1
@@ -5,7 +5,7 @@ pkgrel=1
55 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself'
66 arch=('x86_64')
77 url='https://github.com/bnema/neferwl'
8-license=('AGPL-3.0-only')
8+license=('GPL-3.0-only')
99 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.
1010 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'
1111 'vulkan-driver' 'wayland' 'dbus' 'libcap')
@@ -5,7 +5,7 @@ pkgrel=1
5 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself'5 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself'
6 arch=('x86_64')6 arch=('x86_64')
7 url='https://github.com/bnema/neferwl'7 url='https://github.com/bnema/neferwl'
8-license=('AGPL-3.0-only')8+license=('GPL-3.0-only')
9 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.9 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.
10 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'10 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'
11 'vulkan-driver' 'wayland' 'dbus' 'libcap')11 'vulkan-driver' 'wayland' 'dbus' 'libcap')
M packaging/aur/neferwl-git/PKGBUILD +1 -1
@@ -7,7 +7,7 @@ pkgrel=1
77 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself (git version)'
88 arch=('x86_64' 'aarch64')
99 url='https://git.bnema.dev/neferwl'
10-license=('AGPL-3.0-only')
10+license=('GPL-3.0-only')
1111 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.
1212 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'
1313 'vulkan-driver' 'wayland' 'dbus' 'libcap')
@@ -7,7 +7,7 @@ pkgrel=1
7 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself (git version)'7 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself (git version)'
8 arch=('x86_64' 'aarch64')8 arch=('x86_64' 'aarch64')
9 url='https://git.bnema.dev/neferwl'9 url='https://git.bnema.dev/neferwl'
10-license=('AGPL-3.0-only')10+license=('GPL-3.0-only')
11 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.11 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.
12 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'12 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'
13 'vulkan-driver' 'wayland' 'dbus' 'libcap')13 'vulkan-driver' 'wayland' 'dbus' 'libcap')

Comments1 open / 1

bnema

Field check on the target machine (RX 9070 XT, two outputs) while a PyTorch training job ran for 30 min: 10 commits refused with ENOMEM (HDMI-A-1: 2, DP-2: 8). Every refusal recovered on the first retry (attempts: 1) after 19 to 80 ms; both outputs stayed on. Before this change each refusal stopped its output.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close