Привет Хабр!
В своей предыдущей статье про разбор кода победившего в 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, он предложил:
Использовать горутины, в моём случае нерелевантно, т.к. остальные ядра CPU уже заняты той же работой для других картинок.
Использовать SIMD, но работающего решения не предложил.
Предложил использовать более быструю библиотеку для работы с картинками, но какую не сказал.
После уточняющих вопросов, предложил развернуть цикл, но я и так это уже сделал.
Комментарии (12)
4p4
00.00.0000 00:00Развивая предложения чатгопоты про SIMD, вот статья где разжевано как AVX512 прикрутить в Go через C/C++ и ассемблер:
https://gorse.io/posts/avx512-in-golang.html#function-calls
Только вместо MUL который они используют в статье, вам видимо надо что-то вроде VPMOVUSQD.
seniorK
00.00.0000 00:00+1Это всё здорово. Только зачем? Пишите сразу на ASM.
thevlad
00.00.0000 00:00+2А вы пробовали на нем писать, что-то больше пары строчек? А потом еще и поддерживать? Странный совет, на вполне нормальное желание разобраться, как работает код и как его можно ускорить. Если писать что-то производительное на плюсах, то это вообще первым пунктом идет - посмотреть асм выхлоп и понять его.
KhodeN
00.00.0000 00:00Вот эту портянку на "go" тоже поддерживать сложно.
thevlad
00.00.0000 00:00+3Сложно по сравнению с чем? С биндингами на Си(или как тут посоветовали ассемблере)? Или не оптимизированным кодом, дающем в три раза меньшую производительность? ("извините но у нас лапки")
Да и называть "портянкой" дополнительные 6 строк кода, в которых даже для меня, человека не знакомого с go, очевидно что происходит, довольно странно.
И я как бы исхожу из предположения, об адекватности автора статьи, и то что это не бессмысленный premature optimization, а реальный хотспот. И в обработке изображений и растеризации такое сплошь и рядом.
KhodeN
00.00.0000 00:00Я скорее имел в виду, что на го писать в таком стиле не особо принято. Это язык прикладного применения, его прелесть в простоте. Для решения подобных низкоуровневых задач с кучей ручных оптимизаций обычно используют си или плюсы.
Поэтому да, на любом языке это будет выглядеть сложно. Применять именно го для этого странно.
seniorK
00.00.0000 00:00-2Пробовал. Прекрасно на нём пишется. В том числе крупные проекты.
Думаете люди до изобретения Go не писали ничего, сложнее калькуляторов? ????
Вы же сами подглядываете в то, что для вас Go компилирует, так почему сразу не сделать как хочется?thevlad
00.00.0000 00:00+2Ссылку на гитхаб дадите? Или расскажите, что вам еще за это и деньги платили? )
Вы же сами подглядываете в то, что для вас Go компилирует, так почему сразу не сделать как хочется?
Потому что даже "просто сделать", сложнее. Не говоря о том, что это еще и поддерживать надо, а это сложнее еще на порядок. А компиляторы уже давно компилируют субоптимальный код, если им не мешать, или понять что мешает.
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
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
Хотя ассемблер выглядит чище ????
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) } } }
lightln2
Про SIMD можно найти прямо вашу задачу на StackOverflow. Хотя, кажется, можно проще, чем там предлагают, если использовать __mm_shuffle_epi8.
Но не уверен, что это сильно поможет - возможно, вы уже приблизились к границе пропускной способности памяти