Я использую Go для написания рекламной сети вот уже почти год. Разработку веду на сервере Intel i7-7700, 16Gb RAM, 256Gb SSD. И в скрипте который выполняется раз в сутки появилась задача выбрать все показы за прошедшие сутки и пересчитать на этой основе статистику за день сразу по нескольким объектам (сайт, кампания, баннер).
По идиомам Go делается всё достаточно тривиально:
Всё работает. И скорость выборки 36 секунд для почти 56 миллионов записей.
Под капотом анализатора производительности go tool pprof видим примерно следующее
Можно заметить что мы работаем в текстовом протоколе MySQL по mysql.(*textRows).readRow, соответственно пришедшие строки Scan конвертирует в uin32 типы. Но на первом месте по времени у нас функция выделения памяти.
Что тут можно ускорить?
Случайно на глаза мне попался тип RawBytes который гарантирует что байты из драйвера базы данных будут переданы пользователю без копирования. Что же. Попытаемся извлечь Scan в промежуточную структуру с полями sql.RawBytes и переконвертируем сами потом []bytes в uint32 с помощью наскоро написанной функции bu2, выбросив проверки на ошибки (ведь вы не станете их искать в пришедшем от БД тексте, да?)
В итоге время обработки сократилось до 28 секунд, что дает уже чтение 2 миллионов строк в секунду!
И профайлер даёт уже такую картину
Что же, неплохо как для начала. Далее я полез изучать драйвер MySQL, который как оказалось написан специально для Go и реализует низкоуровневые протоколы сам, с помощью сокетов. И вот второй протокол MySQL оказался бинарным. Что в теории дает более быструю генерацию ответа MySQL-сервера. Соответственно и драйвер, меньше вызывает функций конвертаций текст-целое число. Чтобы задействовать бинарный протокол надо перейти от db.Query до db.Prepare — stsm.Query — минимум изменений исходного кода и вуаля — 26.70 секунд выполнения.
Профилировщик показывает, что протокол уже действительно бинарный по (*binaryRows).readRow, но при чтении в RawBytes всё равно проходит конвертация в текст, а потом обратно.
Давайте же Scan делать сразу в uint32 структуры! Уже ничего не должно конвертироваться — только преобразование целое-целое.
Итог оказался печальным — 49.827306314s То есть замедление вообще ужасающее. Самый тупящий вариант из всех возможных, несмотря на хорошую теоретическую основу для самого быстрого результата. В чем же дело?
Смотрим:
Судя по наличию strconv.ParseUint — преобразование 2 типов выполняется через строку! Серьезно? reflect-преобразования вышли на первые строчки по времени выполнения. Не зря Роб Пайк говорит об осторожном использовании рефлексии. Можно натворить дел.
Изучив драйвер MySQL я наткнулся на то, что с бинарного протокола все данные преобразуются в int64 — попробуем извлечь из этого пользу. Scan делаем в структуру
Результат получился 33.98 сек. С таким раскладом по функциям
Видно, что sql.convertAssign уменьшает всю выгоду от использования бинарного протокола. И теперь данные не копируются через текст, но внутри reflect определить что int64 можно копировать в переменную int64 пользователя — ещё довольно сложно. И копирование числа в текст и обратно идет быстрее, чем reflect.directlyAssignable — reflect.Value.assignTo.
В качестве разминки я попробовал перевести функцию b2u на Go-ассемблер. Ассемблер был моим одним из первых выученных в школе языков программирования на БК-0011 без дисковода и кассетного магнитофона) Так что это было забавно. Хотя Go генерирует практически оптимальный код и если вы не придумаете алгоритмические трики или использование нестандартных команд языка ASM — то смысла особого в написании этих функций нет.
По тестам она даёт ускорение 2-20%, от Go-версии. Зависит от кол-ва цифр в числе.
В итоге рабочий пример ускорился до 26.94 секунды.
Вывод из статьи, для тех, кто только просматривал текст — самый быстрый способ прочитать большой объем целочисленных данных из MySQL в память — использовать db.Prepare — stmt.Query — Scan в interface{} и преобразование uint32(hit.siteID.(int64)) в беззнаковое целое число, которое работает и для uint64, не срезая верхний бит.
То есть, показанные в стандартных примерах способы работы с драйвером не всегда оптимальны. Возможно, разработчики обратят внимание на поведение драйвера, потому что очень много скрытых вызовов и оверхедов как для простого и не перегруженного лишним функционалом языка. Ведь Go в тестах, в которых появляются SELECT-выборки из БД, не блещет производительностью. Мало того, не все опытные программисты имеют время и желание покопаться под капотом. Насколько мне известно, подобные тесты на SELECT вообще никто не проводил.
UPD: В комментариях привели пример чудодейственного драйвера github.com/lazada/sqle якобы рассчитанного на быстрое чтение. Итог оказался при чтении через
rows.Scan(&h.siteID, &h.zoneID, &h.poolID, &h.mediaID, &h.campaignID) в uint32 переменные очень печальный
И если посмотреть на то, чем занималась минуту программа, становится понятно, что об оптимизации этого случая там просто не задумывались. Стандартный драйвер выигрывает в 2 раза.
Если читать Scan в []byte переменные и конвертировать через b2u() в uint32 получается 44 секунды. По-моему, дальше можно не тестировать, какой замечательный заменитель стандартного database/sql отписали ребята. Очередной миф про ускорение развенчался об практические тесты.
UPD2: Для понимания скорости чтения из MySQL сделал цикл без конвертаций и вычислений
итог — 11.9 секунд. Таким образом, 15 секунд из 27 тратися на конвертацию и работу с хешмапами.
UPD3: Написав статью, я стал больше разбираться во внутренностях Go — и нашел пилюлю для своего случая прямо под носом:
golang.org/pkg/database/sql/#Rows.Scan
If an argument has type *interface{}, Scan copies the value provided by the underlying driver without conversion.
Вот финальная версия кода, которую всем рекомендую:
Итог не оставит никого равнодушным:
20.126921479s на техже 55,9 миллионов записей. И это после 27 секунд, на которых я думал, что достиг нирваны.
И профилировщик выдает уже вполне хороший расклад — большую часть времени программа обновляет счетчики в hash. Бинарные данные отправляются из драйвера прямиком туда, куда их ждут. Ассемблер оказался вовсе не обязателен.
По идиомам Go делается всё достаточно тривиально:
type Hit struct { siteID, zoneID, poolID, mediaID, campaignID uint32 } rows, err := db.Query("SELECT siteID, zoneID, poolID, mediaID, campaignID FROM "+where) if err != nil { log.Fatal("Query fail", err) } defer rows.Close() var ( c uint32 h Hit ) for rows.Next() { rows.Scan(&h.siteID, &h.zoneID, &h.poolID, &h.mediaID, &h.campaignID) campCounter.Inc(h.campaignID) siteCounter.Inc(h.siteID) zoneCounter.Inc(h.zoneID) poolCounter.Inc(h.poolID) mediaCounter.Inc(h.mediaID) c++ } if err := rows.Err(); err != nil { log.Fatal("Scan Rows err", err) } log.Println(name, " ", c, " ", where, "in", time.Since(now))
Всё работает. И скорость выборки 36 секунд для почти 56 миллионов записей.
hit_20180507 55928930 time BETWEEN 1525640400 AND 1525726799 in 36.331342451s
Под капотом анализатора производительности go tool pprof видим примерно следующее
flat flat% sum% cum cum% 7130ms 18.32% 18.32% 10800ms 27.75% runtime.mallocgc 2380ms 6.12% 24.43% 5710ms 14.67% fmt.(*pp).doPrintf 2140ms 5.50% 29.93% 13300ms 34.17% github.com/go-sql-driver/mysql.(*textRows).readRow 1800ms 4.62% 34.56% 2170ms 5.58% runtime.mapassign_fast32 1700ms 4.37% 38.93% 1700ms 4.37% runtime.heapBitsSetType 1170ms 3.01% 41.93% 36350ms 93.40% main.loadHits 1110ms 2.85% 44.78% 8500ms 21.84% runtime.convT2Eslice 1070ms 2.75% 47.53% 1970ms 5.06% fmt.(*fmt).fmt_integer 950ms 2.44% 49.97% 1380ms 3.55% github.com/go-sql-driver/mysql.readLengthEncodedString 930ms 2.39% 52.36% 1060ms 2.72% runtime.freedefer 930ms 2.39% 54.75% 930ms 2.39% runtime.mapaccess1_fast32 910ms 2.34% 57.09% 2070ms 5.32% runtime.deferreturn 860ms 2.21% 59.30% 1220ms 3.13% runtime.scanobject
Можно заметить что мы работаем в текстовом протоколе MySQL по mysql.(*textRows).readRow, соответственно пришедшие строки Scan конвертирует в uin32 типы. Но на первом месте по времени у нас функция выделения памяти.
Что тут можно ускорить?
Случайно на глаза мне попался тип RawBytes который гарантирует что байты из драйвера базы данных будут переданы пользователю без копирования. Что же. Попытаемся извлечь Scan в промежуточную структуру с полями sql.RawBytes и переконвертируем сами потом []bytes в uint32 с помощью наскоро написанной функции bu2, выбросив проверки на ошибки (ведь вы не станете их искать в пришедшем от БД тексте, да?)
func b2u(b []byte) uint32 { n := uint32(0) for _, c := range b { n = n*uint32(10) + uint32(c-'0') } return n } type HitRaw struct { siteID, zoneID, poolID, mediaID, campaignID sql.RawBytes }
В итоге время обработки сократилось до 28 секунд, что дает уже чтение 2 миллионов строк в секунду!
И профайлер даёт уже такую картину
4690ms 15.68% 15.68% 7630ms 25.51% runtime.mallocgc 2400ms 8.02% 23.70% 2700ms 9.03% runtime.mapaccess1_fast32 1660ms 5.55% 29.25% 1660ms 5.55% runtime.heapBitsSetType 1640ms 5.48% 34.74% 28110ms 93.98% main.loadHits 1590ms 5.32% 40.05% 1860ms 6.22% runtime.mapassign_fast32 1300ms 4.35% 44.40% 12450ms 41.62% github.com/go-sql-driver/mysql.(*textRows).readRow 1140ms 3.81% 48.21% 2090ms 6.99% runtime.deferreturn 1060ms 3.54% 51.76% 1470ms 4.91% github.com/go-sql-driver/mysql.readLengthEncodedString 1050ms 3.51% 55.27% 1050ms 3.51% main.b2u 1040ms 3.48% 58.74% 1130ms 3.78% database/sql.convertAssign 910ms 3.04% 61.79% 8640ms 28.89% runtime.convT2Eslice 730ms 2.44% 64.23% 2540ms 8.49% database/sql.(*Rows).Scan
Что же, неплохо как для начала. Далее я полез изучать драйвер MySQL, который как оказалось написан специально для Go и реализует низкоуровневые протоколы сам, с помощью сокетов. И вот второй протокол MySQL оказался бинарным. Что в теории дает более быструю генерацию ответа MySQL-сервера. Соответственно и драйвер, меньше вызывает функций конвертаций текст-целое число. Чтобы задействовать бинарный протокол надо перейти от db.Query до db.Prepare — stsm.Query — минимум изменений исходного кода и вуаля — 26.70 секунд выполнения.
stmtOut, err := db.Prepare(sqlQ) defer stmtOut.Close() if err != nil { log.Fatal("prepare", err, sqlQ) } rows, err := stmtOut.Query() if err != nil { log.Fatal("query", err, sqlQ) } defer rows.Close()
Профилировщик показывает, что протокол уже действительно бинарный по (*binaryRows).readRow, но при чтении в RawBytes всё равно проходит конвертация в текст, а потом обратно.
flat flat% sum% cum cum% 2910ms 10.79% 10.79% 3310ms 12.27% runtime.mallocgc 2280ms 8.45% 19.24% 2600ms 9.64% runtime.mapaccess1_fast32 1960ms 7.27% 26.51% 7070ms 26.21% database/sql.convertAssign 1530ms 5.67% 32.18% 1810ms 6.71% runtime.mapassign_fast32 1460ms 5.41% 37.60% 6660ms 24.69% github.com/go-sql-driver/mysql.(*binaryRows).readRow 1420ms 5.27% 42.86% 26680ms 98.92% main.loadHits 1210ms 4.49% 47.35% 3010ms 11.16% strconv.AppendInt 1100ms 4.08% 51.43% 1320ms 4.89% strconv.formatBits 950ms 3.52% 54.95% 1650ms 6.12% runtime.deferreturn 820ms 3.04% 57.99% 820ms 3.04% reflect.ValueOf 810ms 3.00% 60.99% 4120ms 15.28% runtime.convT2E64 750ms 2.78% 63.77% 4240ms 15.72% database/sql.asBytes
Давайте же Scan делать сразу в uint32 структуры! Уже ничего не должно конвертироваться — только преобразование целое-целое.
Итог оказался печальным — 49.827306314s То есть замедление вообще ужасающее. Самый тупящий вариант из всех возможных, несмотря на хорошую теоретическую основу для самого быстрого результата. В чем же дело?
Смотрим:
4620ms 9.22% 9.22% 29230ms 58.32% database/sql.convertAssign 3610ms 7.20% 16.42% 4010ms 8.00% runtime.mallocgc 3010ms 6.01% 22.43% 8610ms 17.18% reflect.(*rtype).Name 2980ms 5.95% 28.37% 5600ms 11.17% reflect.(*rtype).String 2770ms 5.53% 33.90% 3330ms 6.64% runtime.mapaccess1_fast32 2570ms 5.13% 39.03% 2570ms 5.13% reflect.ValueOf 1760ms 3.51% 42.54% 1980ms 3.95% runtime.mapassign_fast32 1640ms 3.27% 45.81% 6630ms 13.23% github.com/go-sql-driver/mysql.(*binaryRows).readRow 1540ms 3.07% 48.88% 3870ms 7.72% strconv.FormatInt 1240ms 2.47% 51.36% 49600ms 98.96% main.loadHits 1150ms 2.29% 53.65% 1150ms 2.29% reflect.Value.Type 1120ms 2.23% 55.89% 1120ms 2.23% reflect.Value.Elem 1070ms 2.13% 58.02% 30950ms 61.75% database/sql.(*Rows).Scan 1070ms 2.13% 60.16% 1070ms 2.13% strconv.ParseUint
Судя по наличию strconv.ParseUint — преобразование 2 типов выполняется через строку! Серьезно? reflect-преобразования вышли на первые строчки по времени выполнения. Не зря Роб Пайк говорит об осторожном использовании рефлексии. Можно натворить дел.
Изучив драйвер MySQL я наткнулся на то, что с бинарного протокола все данные преобразуются в int64 — попробуем извлечь из этого пользу. Scan делаем в структуру
type HitRaw struct { siteID, zoneID, poolID, mediaID, campaignID int64 } ... h.siteID = uint32(raw.siteID) h.zoneID = uint32(raw.zoneID) h.poolID = uint32(raw.poolID) h.mediaID = uint32(raw.mediaID) h.campaignID = uint32(raw.campaignID)
Результат получился 33.98 сек. С таким раскладом по функциям
3600ms 10.48% 10.48% 14360ms 41.79% database/sql.convertAssign 2860ms 8.32% 18.80% 3340ms 9.72% runtime.mallocgc 2560ms 7.45% 26.25% 2920ms 8.50% runtime.mapaccess1_fast32 1660ms 4.83% 31.08% 6730ms 19.59% github.com/go-sql-driver/mysql.(*binaryRows).readRow 1540ms 4.48% 35.56% 33970ms 98.86% main.loadHits 1410ms 4.10% 39.67% 1690ms 4.92% runtime.mapassign_fast32 1340ms 3.90% 43.57% 1340ms 3.90% reflect.ValueOf 1290ms 3.75% 47.32% 4010ms 11.67% reflect.Value.Set 940ms 2.74% 50.06% 15960ms 46.45% database/sql.(*Rows).Scan 900ms 2.62% 52.68% 900ms 2.62% reflect.Value.Elem 840ms 2.44% 55.12% 840ms 2.44% reflect.Value.Type 840ms 2.44% 57.57% 1500ms 4.37% runtime.deferreturn 810ms 2.36% 59.92% 810ms 2.36% reflect.directlyAssignable 760ms 2.21% 62.14% 760ms 2.21% runtime.getitab 730ms 2.12% 64.26% 900ms 2.62% reflect.Value.assignTo 720ms 2.10% 66.36% 4060ms 11.82% runtime.convT2E64
Видно, что sql.convertAssign уменьшает всю выгоду от использования бинарного протокола. И теперь данные не копируются через текст, но внутри reflect определить что int64 можно копировать в переменную int64 пользователя — ещё довольно сложно. И копирование числа в текст и обратно идет быстрее, чем reflect.directlyAssignable — reflect.Value.assignTo.
В качестве разминки я попробовал перевести функцию b2u на Go-ассемблер. Ассемблер был моим одним из первых выученных в школе языков программирования на БК-0011 без дисковода и кассетного магнитофона) Так что это было забавно. Хотя Go генерирует практически оптимальный код и если вы не придумаете алгоритмические трики или использование нестандартных команд языка ASM — то смысла особого в написании этих функций нет.
// func b2u(data []byte) uint32 // // memory layout of the stack relative to FP // +0 data slice ptr // +8 data slice len // +16 data slice cap #include "textflag.h" TEXT ·B2u(SB),NOSPLIT,$0-24 // data ptr MOVQ data+0(FP), SI // data len MOVQ data+8(FP), CX // result in AX MOVBLZX (SI), AX // - '0' SUBL $48, AX // check end of loop DECQ CX JZ AX2RET LOOPBYTE: //move to one byte upper INCQ SI MOVBLZX (SI), BX //prev result *= 10 IMULL $10, AX // bx -= '0' SUBL $48, BX ADDL BX, AX // check end of loop while (cx--) DECQ CX JNZ LOOPBYTE AX2RET: MOVL AX, ret+24(FP) RET
По тестам она даёт ускорение 2-20%, от Go-версии. Зависит от кол-ва цифр в числе.
В итоге рабочий пример ускорился до 26.94 секунды.
Вывод из статьи, для тех, кто только просматривал текст — самый быстрый способ прочитать большой объем целочисленных данных из MySQL в память — использовать db.Prepare — stmt.Query — Scan в interface{} и преобразование uint32(hit.siteID.(int64)) в беззнаковое целое число, которое работает и для uint64, не срезая верхний бит.
То есть, показанные в стандартных примерах способы работы с драйвером не всегда оптимальны. Возможно, разработчики обратят внимание на поведение драйвера, потому что очень много скрытых вызовов и оверхедов как для простого и не перегруженного лишним функционалом языка. Ведь Go в тестах, в которых появляются SELECT-выборки из БД, не блещет производительностью. Мало того, не все опытные программисты имеют время и желание покопаться под капотом. Насколько мне известно, подобные тесты на SELECT вообще никто не проводил.
UPD: В комментариях привели пример чудодейственного драйвера github.com/lazada/sqle якобы рассчитанного на быстрое чтение. Итог оказался при чтении через
rows.Scan(&h.siteID, &h.zoneID, &h.poolID, &h.mediaID, &h.campaignID) в uint32 переменные очень печальный
55928930 time BETWEEN 1525640400 AND 1525726799 in 1m0.307942824sИ если посмотреть на то, чем занималась минуту программа, становится понятно, что об оптимизации этого случая там просто не задумывались. Стандартный драйвер выигрывает в 2 раза.
flat flat% sum% cum cum%
4.63s 7.62% 7.62% 29.25s 48.11% database/sql.convertAssign
4.22s 6.94% 14.56% 4.68s 7.70% runtime.mallocgc
2.97s 4.88% 19.44% 8.48s 13.95% reflect.(*rtype).Name
2.96s 4.87% 24.31% 5.51s 9.06% reflect.(*rtype).String
2.90s 4.77% 29.08% 41.28s 67.89% github.com/lazada/sqle.(*Rows).Scan
2.51s 4.13% 33.21% 2.95s 4.85% runtime.mapaccess1_fast32
2.38s 3.91% 37.12% 2.38s 3.91% reflect.ValueOf
1.92s 3.16% 40.28% 3.69s 6.07% runtime.assertE2I2
1.77s 2.91% 43.19% 1.77s 2.91% runtime.getitab
1.65s 2.71% 45.90% 7.04s 11.58% github.com/go-sql-driver/mysql.(*binaryRows).readRow
1.49s 2.45% 48.36% 1.86s 3.06% runtime.mapassign_fast32
1.35s 2.22% 50.58% 60.27s 99.13% main.loadHits
1.31s 2.15% 52.73% 4.01s 6.60% github.com/lazada/sqle.typeCheck
1.31s 2.15% 54.88% 4.19s 6.89% strconv.FormatInt
1.28s 2.11% 56.99% 2.88s 4.74% strconv.formatBits
1.25s 2.06% 59.05% 1.25s 2.06% reflect.(*rtype).Kind
1.12s 1.84% 60.89% 31.05s 51.07% database/sql.(*Rows).Scan
Если читать Scan в []byte переменные и конвертировать через b2u() в uint32 получается 44 секунды. По-моему, дальше можно не тестировать, какой замечательный заменитель стандартного database/sql отписали ребята. Очередной миф про ускорение развенчался об практические тесты.
UPD2: Для понимания скорости чтения из MySQL сделал цикл без конвертаций и вычислений
for rows.Next() { c++ }
итог — 11.9 секунд. Таким образом, 15 секунд из 27 тратися на конвертацию и работу с хешмапами.
UPD3: Написав статью, я стал больше разбираться во внутренностях Go — и нашел пилюлю для своего случая прямо под носом:
golang.org/pkg/database/sql/#Rows.Scan
If an argument has type *interface{}, Scan copies the value provided by the underlying driver without conversion.
Вот финальная версия кода, которую всем рекомендую:
type HitRaw struct { siteID, zoneID, poolID, mediaID, campaignID, status interface{} } var ( hit HitRaw h Hit ) for rows.Next() { rows.Scan(&hit.siteID, &hit.zoneID, &hit.poolID, &hit.mediaID, &hit.campaignID, &hit.status) h.siteID = uint32(hit.siteID.(int64)) h.zoneID = uint32(hit.zoneID.(int64)) h.siteID = uint32(hit.siteID.(int64)) h.poolID = uint32(hit.poolID.(int64)) h.mediaID = uint32(hit.mediaID.(int64)) h.campaignID = uint32(hit.campaignID.(int64)) h.status = uint8(hit.status.(int64))
Итог не оставит никого равнодушным:
20.126921479s на техже 55,9 миллионов записей. И это после 27 секунд, на которых я думал, что достиг нирваны.
И профилировщик выдает уже вполне хороший расклад — большую часть времени программа обновляет счетчики в hash. Бинарные данные отправляются из драйвера прямиком туда, куда их ждут. Ассемблер оказался вовсе не обязателен.
flat flat% sum% cum cum%
3110ms 15.20% 15.20% 3460ms 16.91% runtime.mallocgc
2350ms 11.49% 26.69% 2700ms 13.20% runtime.mapaccess1_fast32
1850ms 9.04% 35.73% 2150ms 10.51% runtime.mapassign_fast32
1460ms 7.14% 42.86% 6670ms 32.60% github.com/go-sql-driver/mysql.(*binaryRows).readRow
1420ms 6.94% 49.80% 20070ms 98.09% main.loadHits
1170ms 5.72% 55.52% 1190ms 5.82% database/sql.convertAssign
840ms 4.11% 59.63% 4300ms 21.02% runtime.convT2E64
710ms 3.47% 63.10% 2470ms 12.07% database/sql.(*Rows).Scan
680ms 3.32% 66.42% 1260ms 6.16% runtime.deferreturn
680ms 3.32% 69.75% 680ms 3.32% sync.(*RWMutex).RLock
650ms 3.18% 72.92% 650ms 3.18% runtime.aeshash32
630ms 3.08% 76.00% 630ms 3.08% sync.(*RWMutex).RUnlock
