Привет Хабр!

В своей предыдущей статье про разбор кода победившего в VK Cup'22/23 я описывал как мне удалось ускорить копирование одной картинки в другую в 30 раз с помощью чёрной магии unsafe. Однако я не переставал задаваться вопросом, можно ли увеличить скорость еще больше. Я даже привлёк OpenAI в поисках решения, но он мне помог только с картинкой для обложки статьи. В итоге я нашел способ улучшить код еще в 2 раза. Чем и хочу поделиться.

Задача

Изначально задача была в построение коллажа из нескольких картинок размером 512x512px, которая свелась к задаче по копированию синей составляющей картинки image.RGBA в индекс цвета палитры в image.Paletted. Для целей исследования я упростил задачу до: надо скопировать синюю составляющую из картинки image.RGBA размером 512x512px в image.Paletted такого же размера.

Решение с использованием методов пакета image выглядит так:

func BenchmarkCopySTD(b *testing.B) {
	for i := 0; i < b.N; i++ {
		for y := 0; y < imageHeight; y++ {
			dstPixOffset := dstPaletted.PixOffset(0, y)
			for x := 0; x < imageWidth; x++ {
				_, _, b, _ := srcRGBA.At(x, y).RGBA()
				dstPaletted.Pix[dstPixOffset+x] = uint8(b)
			}
		}
	}
}

Оно долгое, много времени тратится на вызовы At(). Типы картинок нам известны, поэтому очевидное решение работать напрямую с пикселами:

func BenchmarkCopySliceElements(b *testing.B) {
	for i := 0; i < b.N; i++ {
		for y := 0; y < imageHeight; y++ {
			srcPixOffset := srcRGBA.PixOffset(0, y)
			dstPixOffset := dstPaletted.PixOffset(0, y)
			for x := 0; x < imageWidth; x++ {
				dstPaletted.Pix[dstPixOffset+x] = srcRGBA.Pix[srcPixOffset+x*4+1]
			}
		}
	}
}

Сразу получаем большой буст:

BenchmarkCopySTD-16                	    1590	   3730930 ns/op	 1048600 B/op	  262144 allocs/op
BenchmarkCopySliceElements-16      	   22778	    260128 ns/op	       0 B/op	       0 allocs/op

Дальше начинается чёрная магия unsafe.

Избавляемся от Bounds Check

Если посмотреть на ассемблер, то можно сильно удивиться количеству операций необходимых для копирования одного элемента слайса в другой:

На самом деле это проверка на невыход за границы слайса. Её можно отключить глобально при компиляции, но в данном случае нам надо её отключить только в одном месте программы. Для этого нужно обращаться напрямую по адресу нужного элемента, вычислить который можно используя адрес нулевого элемента. Переписанный код выглядит так:

func BenchmarkCopyUnsafe(b *testing.B) {
	for i := 0; i < b.N; i++ {
		dstAddr := uintptr(unsafe.Pointer(&dstPaletted.Pix[0]))
		srcAddr := uintptr(unsafe.Pointer(&srcRGBA.Pix[0]))
		for y := 0; y < imageHeight; y++ {
			srcPixOffset := srcRGBA.PixOffset(0, y)
			dstPixOffset := dstPaletted.PixOffset(0, y)
			for x := 0; x < imageWidth; x++ {
				//dst.Pix[dstPixOffset+x] = src.Pix[srcPixOffset+x*4+1]
				*(*uint8)((unsafe.Pointer)(dstAddr + uintptr(dstPixOffset+x))) = *(*uint8)((unsafe.Pointer)(srcAddr + uintptr(srcPixOffset+x*4+2)))
			}
		}
	}
}

В строках (3) и (4) получаем адреса в памяти нулевых элементов, а дальше обращаемся к ним по смещению относительно адреса. Время на копирование сократилось в 2 раза:

BenchmarkCopySTD-16                	    1590	   3730930 ns/op	 1048600 B/op	  262144 allocs/op
BenchmarkCopySliceElements-16      	   22778	    260128 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnsafe-16             	   44539	    131881 ns/op	       0 B/op	       0 allocs/op

Количество инструкций CPU сильно уменьшилось:

Такое решение было отправлено на конкурс, но количество инструкций меня всё ещё смущало и уже после конкурса я начал искать пути как это можно ускорить.

Избавляемся от сложной математики

Первое что бросается в глаза, это умножение. Плюс при работе с памятью напрямую можно не вычислять смещение, а просто жонглировать указателями:

func BenchmarkCopyUnsafeV2(b *testing.B) {
	for i := 0; i < b.N; i++ {
		dstAddr := uintptr(unsafe.Pointer(&dstPaletted.Pix[0]))
		srcAddr := uintptr(unsafe.Pointer(&srcRGBA.Pix[0])) + 2
		for y := 0; y < imageHeight; y++ {
			for x := 0; x < imageWidth; x++ {
				*(*uint8)((unsafe.Pointer)(dstAddr)) = *(*uint8)((unsafe.Pointer)(srcAddr))
				dstAddr++
				srcAddr += 4
			}
		}
	}
}

Время ещё сократилось в 1.5 раза:

BenchmarkCopySTD-16                	    1590	   3730930 ns/op	 1048600 B/op	  262144 allocs/op
BenchmarkCopySliceElements-16      	   22778	    260128 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnsafe-16             	   44539	    131881 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnsafeV2-16           	   65779	     88408 ns/op	       0 B/op	       0 allocs/op

Инструкций CPU осталось минимум:

Кажется что всё, быстрее быть не может, но ...

Loop unrolling

В какой-то момент я подумал, а чтобы сделал C++ компилятор с -O3 и это меня натолкнуло на мысль, почему Go не сделал оптимизацию "Размотка цикла"? Надо попробовать сделать руками:

func BenchmarkCopyUnrolledLoop4(b *testing.B) {
	for i := 0; i < b.N; i++ {
		dstAddr := uintptr(unsafe.Pointer(&dstPaletted.Pix[0]))
		srcAddr := uintptr(unsafe.Pointer(&srcRGBA.Pix[0]))
		for y := 0; y < imageHeight; y++ {
			for x := 0; x < imageWidth; x += 4 {
				*(*uint8)((unsafe.Pointer)(dstAddr + 0)) = *(*uint8)((unsafe.Pointer)(srcAddr + 0))
				*(*uint8)((unsafe.Pointer)(dstAddr + 1)) = *(*uint8)((unsafe.Pointer)(srcAddr + 4))
				*(*uint8)((unsafe.Pointer)(dstAddr + 2)) = *(*uint8)((unsafe.Pointer)(srcAddr + 8))
				*(*uint8)((unsafe.Pointer)(dstAddr + 3)) = *(*uint8)((unsafe.Pointer)(srcAddr + 12))

				dstAddr += 4
				srcAddr += 16
			}
		}
	}
}

И это ускорило ещё на треть. На самом деле я попробовал разные количество операций за 1 цикл, включая все 512 px, но самый лучший результат, по крайней мере на моём CPU, показал вариант с 4 операциями:

BenchmarkCopySTD-16                	    1590	   3730930 ns/op	 1048600 B/op	  262144 allocs/op
BenchmarkCopySliceElements-16      	   22778	    260128 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnsafe-16             	   44539	    131881 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnsafeV2-16           	   65779	     88408 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnrolledLoop2-16      	   88812	     66479 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnrolledLoop4-16      	   95296	     58995 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnrolledLoop8-16      	   95563	     62937 ns/op	       0 B/op	       0 allocs/op
BenchmarkCopyUnrolledLoop512-16    	   95064	     62808 ns/op	       0 B/op	       0 allocs/op

Заключение

Кажется здесь мне можно остановиться, ускорение в 63 раза относительно вызова функций и в почти 4.5 раза относительно копированию элементов по индексам выглядит неплохо. Дальше можно попробовать поиграться с векторными командами, возможно в следующий раз. Но для желающих я сделал задачу на HighLoad.Fun, где можно попробовать свои силы и ещё ускорить код, причём не только на Go, но и на C++ или Rust.

P.S. Ради интереса я спросил у ChatGPT как можно ускорить BenchmarkCopySliceElements, он предложил:

  1. Использовать горутины, в моём случае нерелевантно, т.к. остальные ядра CPU уже заняты той же работой для других картинок.

  2. Использовать SIMD, но работающего решения не предложил.

  3. Предложил использовать более быструю библиотеку для работы с картинками, но какую не сказал.

  4. После уточняющих вопросов, предложил развернуть цикл, но я и так это уже сделал.

Комментарии (12)


  1. lightln2
    00.00.0000 00:00
    +1

    Про SIMD можно найти прямо вашу задачу на StackOverflow. Хотя, кажется, можно проще, чем там предлагают, если использовать __mm_shuffle_epi8.
    Но не уверен, что это сильно поможет - возможно, вы уже приблизились к границе пропускной способности памяти


  1. 4p4
    00.00.0000 00:00

    Развивая предложения чатгопоты про SIMD, вот статья где разжевано как AVX512 прикрутить в Go через C/C++ и ассемблер:

    https://gorse.io/posts/avx512-in-golang.html#function-calls

    Только вместо MUL который они используют в статье, вам видимо надо что-то вроде VPMOVUSQD.


  1. seniorK
    00.00.0000 00:00
    +1

    Это всё здорово. Только зачем? Пишите сразу на ASM.


    1. thevlad
      00.00.0000 00:00
      +2

      А вы пробовали на нем писать, что-то больше пары строчек? А потом еще и поддерживать? Странный совет, на вполне нормальное желание разобраться, как работает код и как его можно ускорить. Если писать что-то производительное на плюсах, то это вообще первым пунктом идет - посмотреть асм выхлоп и понять его.


      1. KhodeN
        00.00.0000 00:00

        Вот эту портянку на "go" тоже поддерживать сложно.


        1. thevlad
          00.00.0000 00:00
          +3

          Сложно по сравнению с чем? С биндингами на Си(или как тут посоветовали ассемблере)? Или не оптимизированным кодом, дающем в три раза меньшую производительность? ("извините но у нас лапки")

          Да и называть "портянкой" дополнительные 6 строк кода, в которых даже для меня, человека не знакомого с go, очевидно что происходит, довольно странно.

          И я как бы исхожу из предположения, об адекватности автора статьи, и то что это не бессмысленный premature optimization, а реальный хотспот. И в обработке изображений и растеризации такое сплошь и рядом.


          1. KhodeN
            00.00.0000 00:00

            Я скорее имел в виду, что на го писать в таком стиле не особо принято. Это язык прикладного применения, его прелесть в простоте. Для решения подобных низкоуровневых задач с кучей ручных оптимизаций обычно используют си или плюсы.

            Поэтому да, на любом языке это будет выглядеть сложно. Применять именно го для этого странно.


      1. seniorK
        00.00.0000 00:00
        -2

        Пробовал. Прекрасно на нём пишется. В том числе крупные проекты.
        Думаете люди до изобретения Go не писали ничего, сложнее калькуляторов? ????
        Вы же сами подглядываете в то, что для вас Go компилирует, так почему сразу не сделать как хочется?


        1. thevlad
          00.00.0000 00:00
          +2

          Ссылку на гитхаб дадите? Или расскажите, что вам еще за это и деньги платили? )

          Вы же сами подглядываете в то, что для вас Go компилирует, так почему сразу не сделать как хочется?

          Потому что даже "просто сделать", сложнее. Не говоря о том, что это еще и поддерживать надо, а это сложнее еще на порядок. А компиляторы уже давно компилируют субоптимальный код, если им не мешать, или понять что мешает.


  1. xakep666
    00.00.0000 00:00
    +1

    Вместо преобразования указателя к uintptr лучше использовать https://pkg.go.dev/unsafe#Add (и в описании к Ponter сказано, почему)
    Получится примерно такое

    dstAddr := unsafe.Pointer(&dstPaletted.Pix[0])
    srcAddr := unsafe.Add(unsafe.Pointer(&srcRGBA.Pix[0])), 2) 
    for y := 0; y < imageHeight; y++ {
    	for x := 0; x < imageWidth; x++ {
    		*(*uint8)(dstAddr) = *(*uint8)(srcAddr)
    		dstAddr = unsafe.Add(dstAddr, 1)
    		srcAddr = unsafe.Add(srcAddr, 4)
    	}
    }
    

    А вообще, проблеме с удалением bounds check уже немало лет, https://github.com/golang/go/issues/14808


    1. svistunov Автор
      00.00.0000 00:00
      +1

      Странно, но этот вариант выигрывает у CopyUnsafeV2

      BenchmarkCopyUnsafeV2-16           	   68326	     84459 ns/op	       0 B/op	       0 allocs/op
      BenchmarkCopyUnsafeV3-16           	   80085	     75279 ns/op	       0 B/op	       0 allocs/op

      , но проигрывает в Unrolled варианте:

      BenchmarkCopyUnrolledLoop4-16      	  103786	     55923 ns/op	       0 B/op	       0 allocs/op
      BenchmarkCopyUnrolledLoop4v2-16    	   96066	     61919 ns/op	       0 B/op	       0 allocs/op

      Хотя ассемблер выглядит чище ????


      1. neolink
        00.00.0000 00:00

        А попробуйте ещё такие варианты:

        func BenchmarkCopySliceElementsV2(b *testing.B) {
        	for i := 0; i < b.N; i++ {
        		dst := dstPaletted.Pix
        		src := srcRGBA.Pix
        
        		dstAddr := imageWidth*imageHeight - 4
        		srcAddr := imageWidth*imageHeight*4 - 16
        
        		_ = dst[dstAddr+3]
        		_ = src[srcAddr+12]
        
        		for dstAddr >= 0 {
        			dst[dstAddr+0] = src[srcAddr+0]
        			dst[dstAddr+1] = src[srcAddr+4]
        			dst[dstAddr+2] = src[srcAddr+8]
        			dst[dstAddr+3] = src[srcAddr+12]
        
        			dstAddr -= 4
        			srcAddr -= 16
        		}
        	}
        }
        func BenchmarkCopyUnrolledLoop4V2(b *testing.B) {
        	for i := 0; i < b.N; i++ {
        		dstAddr := unsafe.Pointer(&dstPaletted.Pix[0])
        		srcAddr := unsafe.Pointer(&srcRGBA.Pix[0])
        		dstAddrMax := unsafe.Add(dstAddr, imageHeight*imageWidth)
        		for uintptr(dstAddr) < uintptr(dstAddrMax) {
        			*(*uint32)(dstAddr) = *(*uint32)(unsafe.Add(srcAddr, 0)) |
        				(*(*uint32)(unsafe.Add(srcAddr, 4)) << 8) |
        				(*(*uint32)(unsafe.Add(srcAddr, 8)) << 16) |
        				(*(*uint32)(unsafe.Add(srcAddr, 12)) << 32)
        
        			dstAddr = unsafe.Add(dstAddr, 4)
        			srcAddr = unsafe.Add(srcAddr, 16)
        		}
        	}
        }