Эта статья выбрана из серии статей «Практика инфраструктуры Byte Beat».
Серия статей «Практика инфраструктуры ByteDance» представляет собой техническую галантерею, созданную техническими командами и экспертами отдела инфраструктуры ByteDance, в которой мы делимся с вами практическим опытом и уроками команды в процессе развития и эволюции инфраструктуры, а также всеми техническими студентами. общаться и расти вместе.
С момента официального выпуска KiteX в 2020.04 количество внутренних сервисов в компании достигло 8 тыс.+, а количество запросов в секунду превысило 100 миллионов. После непрерывной итерации KiteX добился значительного увеличения производительности и задержки. В этой статье мы кратко поделимся некоторыми более эффективными направлениями оптимизации, надеясь предоставить справочную информацию для всех.
предисловие
KiteX — это высокопроизводительная и масштабируемая среда Go RPC следующего поколения, разработанная ByteDance Framework Group. В дополнение к богатым функциям управления услугами, по сравнению с другими фреймворками, он имеет следующие особенности: интегрирует сетевую библиотеку собственной разработки Netpoll; поддерживает протоколы с несколькими сообщениями (Thrift, Protobuf) и методы мультивзаимодействия (Ping-Pong, Oneway, Streaming); Предоставляет более гибкий и расширяемый генератор кода.
В настоящее время основные бизнес-направления компании широко используют KiteX.По статистике, количество текущих сервисов доступа достигает 7000. После запуска KiteX мы постоянно оптимизировали производительность, и в этой статье мы расскажем о некоторых последних работах по оптимизации.
Сетевая библиотека собственной разработки Оптимизация Netpoll
Сетевая библиотека Netpoll собственной разработки на основе epoll была значительно оптимизирована с точки зрения производительности. Тестовые данные показывают, что текущая версия (2020.12) по сравнению споследний общий доступвремя (2020.05), пропускная способность↑30%, задержка СРЕДНЯЯ↓25%, ТП99↓67%, производительность намного превзошла официальную сетевую библиотеку. Ниже мы поделимся двумя решениями, которые могут значительно повысить производительность.
Оптимизация задержки планирования epoll_wait
Когда Netpoll был впервые выпущен, он страдал от низкой задержки AVG, но высокого TP99. После тщательного изучения epoll_wait мы обнаружили, что сочетание двух режимов опроса и триггера события, а также оптимизация стратегии планирования могут значительно сократить задержку.
Во-первых, давайте посмотрим на метод syscall.EpollWait, официально предоставленный Go:
func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error)
Всего здесь предусмотрено 3 параметра, которые представляют собой fd epoll, событие обратного вызова и время ожидания, из которых динамически настраивается только мс.
Обычно, когда мы активно вызываем EpollWait, мы устанавливаем msec=-1, то есть бесконечно ждем прихода события. Фактически, многие сетевые библиотеки с открытым исходным кодом делают то же самое. Однако мы обнаружили, что msec=-1 не является оптимальным решением.
Исходный код ядра epoll_wait (ниже) показывает, что msec=-1 увеличивает количество проверок fetch_events по сравнению с msec=0, поэтому это занимает больше времени.
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
...
if (timeout > 0) {
...
} else if (timeout == 0) {
...
goto send_events;
}
fetch_events:
...
if (eavail)
goto send_events;
send_events:
...
Бенчмарк показывает, что в случае срабатывания события msec=0 примерно на 18% быстрее, чем вызов msec=-1, поэтому в случае частого срабатывания события вызов с msec=0 явно лучше.
В случае отсутствия запуска событий использование msec=0, очевидно, приведет к бесконечному опросу и потреблению большого количества ресурсов.
После всестороннего рассмотрения мы предпочитаем использовать msec=0, когда происходит событие, и использовать msec=-1, когда события отсутствуют, чтобы уменьшить нагрузку на опрос. Псевдокод выглядит следующим образом:
var msec = -1
for {
n, err = syscall.EpollWait(epfd, events, msec)
if n <= 0 {
msec = -1
continue
}
msec = 0
...
}
Так это нормально? Получается, что эффект оптимизации не очевиден.
Давайте еще раз подумаем:
msec=0 только сокращает время, необходимое для одного вызова, на 50 нс, и влияние слишком мало.Если вы хотите еще больше оптимизировать, вы должны настроить логику планирования.
Дальнейшее размышление:
В приведенном выше псевдокоде, когда никакое событие не запускается и msec=-1 корректируется, EpollWait будет выполняться снова немедленно, продолжая напрямую, и, поскольку нет события, msec=-1, текущая горутина будет заблокирована и переключена П. Однако пассивное переключение неэффективно.Если мы активно переключаем горутины на P перед продолжением, мы можем сэкономить время. Поэтому мы меняем приведенный выше псевдокод на следующий:
var msec = -1
for {
n, err = syscall.EpollWait(epfd, events, msec)
if n <= 0 {
msec = -1
runtime.Gosched()
continue
}
msec = 0
...
}
Тесты показывают, что после настройки кода пропускная способность↑12%, ТП99↓64%, со значительным выигрышем в задержке.
Разумное использование unsafe.Pointer
Продолжая изучать epoll_wait, мы обнаружили, что syscall.EpollWait, официально предоставляемый Go, и epollwait, используемый рантаймом, являются разными версиями, то есть используют разные EpollEvents. Ниже мы покажем разницу между ними:
// @syscall
type EpollEvent struct {
Events uint32
Fd int32
Pad int32
}
// @runtime
type epollevent struct {
events uint32
data [8]byte // unaligned uintptr
}
Мы видим, что epollevent, используемый средой выполнения, представляет собой исходную структуру, определенную epoll системного уровня, в то время как внешняя версия инкапсулирует ее, разделяя epoll_data (epollevent.data) на два фиксированных поля: Fd и Pad. Так как же используется среда выполнения? В исходном коде мы видим такую логику:
*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
Очевидно, что среда выполнения использует epoll_data(&ev.data) для непосредственного хранения указателя структуры (pollDesc), соответствующей fd, так что при срабатывании события можно напрямую найти объект структуры и выполнить соответствующую логику. Во внешней версии, поскольку можно получить только инкапсулированные параметры Fd, необходимо ввести дополнительную карту для добавления, удаления, изменения и проверки объектов структуры, поэтому производительность должна сильно отличаться.
Поэтому мы решительно отказались от syscall.EpollWait, а вместо этого сами спроектировали вызов EpollWait, имитируя среду выполнения, а также использовали unsafe.Pointer для доступа к объектам структуры. Тесты показывают, что пропускная способность по этой схеме↑10%, ТП99↓10%, получили более очевидную выгоду.
Бережливая оптимизация сериализации/десериализации
Сериализация относится к процессу преобразования структуры данных или объекта в последовательность байтов, а десериализация — противоположный процесс. RPC должен согласовать протокол сериализации при общении.Клиент сериализует перед отправкой запроса, последовательность байтов передается на сервер по сети, и сервер десериализует ее для логической обработки для завершения запроса RPC. Thrift поддерживает протоколы сериализации Binary, Compact и JSON. В настоящее время внутреннее использование компании в основном двоичное, и здесь представлен только двоичный протокол.
Двоичный код реализуется посредством TLV-кодирования, то есть каждое поле описывается структурой TLV, TLV означает: тип типа, длина длины, значение значения, значение также может быть структурой TLV, где длина типа и длины фиксирована, и длина значения определяется значением длины. Структура кодирования TLV проста и понятна, имеет хорошую масштабируемость, но из-за добавления Type и Length возникают дополнительные накладные расходы на память, особенно когда большинство полей являются базовыми типами, много тратится места.
Оптимизация производительности сериализации и десериализации может быть оптимизирована в двух измерениях пространства и времени с широкой точки зрения. С точки зрения совместимости с существующим двоичным протоколом кажется, что оптимизация в пространстве невозможна, и ее можно оптимизировать только во временном измерении, в том числе:
-
Сократите количество операций с памятью, включая выделение памяти и копирование, и попробуйте предварительно выделить память, чтобы уменьшить ненужные накладные расходы;
-
Сократите количество вызовов функций, таких как корректировка структуры кода и встраивание для оптимизации;
исследовательская работа
в соответствии сgo_serialization_benchmarksМы нашли несколько решений сериализации с отличной производительностью для исследования, надеясь вдохновить нас на работу по оптимизации.
Анализируя protobuf, gogoprotobuf и Cap’n Proto, мы делаем следующие выводы:
-
При передаче по сети для учета операций ввода-вывода передаваемые данные будут сжаты настолько, насколько это возможно protobuf использует кодировку Varint, которая обеспечивает хороший эффект сжатия в большинстве сценариев;
-
gogoprotobuf использует метод предварительного расчета, который может уменьшить количество выделений памяти во время сериализации, тем самым снижая стоимость системных вызовов, блокировок и GC, вызванных выделением памяти;
-
Cap'n Proto напрямую управляет буфером, что также сокращает выделение памяти и копирование памяти (меньше промежуточных структур данных) и разделяет данные типа фиксированной длины и данные типа нефиксированной длины при разработке указателя структуры. типы могут быть обработаны быстро;
С точки зрения совместимости невозможно изменить существующий формат кодирования TLV, поэтому сжатие данных нереалистично, но 2 и 3 полезны для нашей работы по оптимизации, и фактически мы приняли аналогичную идею.
идеи
сократить операции с памятью
управление буфером
Независимо от сериализации или десериализации данные копируются из одной части памяти в другую, что включает в себя выделение памяти и операции копирования памяти.Максимальное избежание операций с памятью может уменьшить ненужные системные вызовы, блокировки и накладные расходы GC.
На самом деле KiteX уже предоставил LinkBuffer для управления буфером. LinkBuffer разработан с цепной структурой и состоит из нескольких блоков, из которых блоки являются блоками памяти фиксированного размера. Пул объектов создан для поддержки свободных блоков, тем самым повторно используя блоки и уменьшая Объем памяти и GC.
В начале мы просто использовали sync.Pool для повторного использования LinkBufferNode netpoll, но это все еще не может решить проблему повторного использования памяти в сценарии с большими пакетами (большие узлы нельзя перерабатывать, иначе это вызовет утечки памяти). В настоящее время мы изменили ведение набора sync.Pool, и размер буфера в каждой группе разный, при создании нового блока он берется из ближайшего к требуемому размеру пула, чтобы память можно было повторно использовать как насколько это возможно С точки зрения тестирования, выделение памяти И эффект оптимизации GC очевиден.
строка/бинарная нулевая копия
Для некоторых служб, таких как службы, связанные с видео, в запросе или возврате будут большие двоичные данные, представляющие обработанные данные видео или изображения, а некоторые службы будут возвращать большую строку (например, полнотекстовую информацию). Подождите. ). В этом сценарии все горячие точки, которые мы видим на графике пламени, находятся на копии данных, поэтому мы подумали, можем ли мы уменьшить эту копию?
Ответ положительный. Поскольку базовый буфер, который мы используем, представляет собой связанный список, мы можем легко вставить узел в середину связанного списка.
Мы придерживаемся аналогичной идеи: когда в процессе сериализации встречается строка или двоичный файл, буфер этого узла делится на два сегмента, а буфер, соответствующий пользовательской строке/двоичному файлу, вставляется посередине, чтобы избежать больших Создается копия строки / двоичного файла.
Позвольте мне еще раз представить. Если мы напрямую используем [] байт (строка) для преобразования строки в [] байт, на самом деле произойдет копирование. Причина в том, что в дизайне Go строка неизменяема, а [] байт изменяем. , поэтому преобразование будет скопировано один раз, если вы хотите не копировать преобразование, вам нужно использовать unsafe:
func StringToSliceByte(s string) []byte {
l := len(s)
return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{
Data: (*(*reflect.StringHeader)(unsafe.Pointer(&s))).Data,
Len: l,
Cap: l,
}))
}
Смысл этого кода в том, чтобы сначала получить адрес строки, а затем собрать заголовок байта слайса, чтобы строку можно было преобразовать в []byte без копирования данных, но следует отметить, что [] сгенерированный таким образом байт не может быть записан, иначе поведение не определено.
предварительный расчет
Существуют сценарии, в которых некоторые службы осуществляют онлайн-передачу больших пакетов.В этом сценарии будет введено много накладных расходов на сериализацию/десериализацию. Как правило, большие пакеты вызваны очень большим размером типа контейнера.Если буфер можно рассчитать заранее, некоторые операции O(n) можно сократить до O(1), что сокращает количество вызовов функций, а также большое количество сценариев больших пакетов.Количество выделений памяти уменьшено, а преимущества значительны.
базовый тип
Если элемент-контейнер является базовым типом (bool, byte, i16, i32, i64, double), поскольку размер базового типа фиксирован, общий размер может быть рассчитан заранее во время сериализации, и на один раз выделяется достаточное количество буфера. время, O (количество операций malloc n) может быть уменьшено до O (1), что значительно уменьшает количество операций malloc Аналогично, количество операций next может быть уменьшено во время десериализации.
структурная перестановка полей
Вышеупомянутая оптимизация действительна только для типа элемента контейнера как базового типа, поэтому может ли она также быть оптимизирована для типа элемента структуры? Ответ положительный.
Следуя изложенной выше идее, если в структуре есть базовые типы полей, вы также можете предварительно вычислить размер этих полей, заранее выделить буферы для этих полей во время сериализации и поставить порядок этих полей на передний план при написании , Это также может в определенной степени уменьшить количество malloc.
разовый расчет
Вышеупомянутое — это оптимизация базовых типов.Если пройтись по всем полям запроса при сериализации, то можно вычислить размер всего запроса, заранее выделить буфер и оперировать буфером непосредственно при сериализации и десериализации, так что для непримитивных типов также можно оптимизировать.
Определите новый интерфейс кодека:
type thriftMsgFastCodec interface {
BLength() int // count length of whole req/resp
FastWrite(buf []byte) int
FastRead(buf []byte) (int, error)
}
Внесите соответствующие изменения в интерфейсы Marshal и Unmarshal:
func (c thriftCodec) Marshal(ctx context.Context, message remote.Message, out remote.ByteBuffer) error {
...
if msg, ok := data.(thriftMsgFastCodec); ok {
msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, thrift.TMessageType(msgType), int32(seqID))
msgEndLen := bthrift.Binary.MessageEndLength()
buf, err := out.Malloc(msgBeginLen + msg.BLength() + msgEndLen)// malloc once
if err != nil {
return perrors.NewProtocolErrorWithMsg(fmt.Sprintf("thrift marshal, Malloc failed: %s", err.Error()))
}
offset := bthrift.Binary.WriteMessageBegin(buf, methodName, thrift.TMessageType(msgType), int32(seqID))
offset += msg.FastWrite(buf[offset:])
bthrift.Binary.WriteMessageEnd(buf[offset:])
return nil
}
...
}
func (c thriftCodec) Unmarshal(ctx context.Context, message remote.Message, in remote.ByteBuffer) error {
...
data := message.Data()
if msg, ok := data.(thriftMsgFastCodec); ok && message.PayloadLen() != 0 {
msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, msgType, seqID)
buf, err := tProt.next(message.PayloadLen() - msgBeginLen - bthrift.Binary.MessageEndLength()) // next once
if err != nil {
return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
}
_, err = msg.FastRead(buf)
if err != nil {
return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
}
err = tProt.ReadMessageEnd()
if err != nil {
return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
}
tProt.Recycle()
return err
}
...
}
Сгенерированный код также модифицируется соответствующим образом:
func (p *Demo) BLength() int {
l := 0
l += bthrift.Binary.StructBeginLength("Demo")
if p != nil {
l += p.field1Length()
l += p.field2Length()
l += p.field3Length()
...
}
l += bthrift.Binary.FieldStopLength()
l += bthrift.Binary.StructEndLength()
return l
}
func (p *Demo) FastWrite(buf []byte) int {
offset := 0
offset += bthrift.Binary.WriteStructBegin(buf[offset:], "Demo")
if p != nil {
offset += p.fastWriteField2(buf[offset:])
offset += p.fastWriteField4(buf[offset:])
offset += p.fastWriteField1(buf[offset:])
offset += p.fastWriteField3(buf[offset:])
}
offset += bthrift.Binary.WriteFieldStop(buf[offset:])
offset += bthrift.Binary.WriteStructEnd(buf[offset:])
return offset
}
Оптимизация кодирования Thrift с помощью SIMD
Тип list
Мы используем avx2, и результаты после оптимизации весьма значительны, при большом объеме данных производительность может быть улучшена в 6 раз для i64 и в 12 раз для i32, улучшение более очевидно при малом объеме данных, и его можно улучшить с помощью 10 раз для i64 i32 можно улучшить в 20 раз.
уменьшить количество вызовов функций
inline
Встроенный — это расширение вызова функции во время компиляции и замена его реализацией этой функции, что может уменьшить накладные расходы на вызовы функций и повысить производительность программы.
Не все функции могут быть встроенными в Go, используйте параметры-gflags="-m"
Запустите процесс, чтобы отобразить встроенные функции. Следующие ситуации не могут быть встроены:
-
Функция, содержащая цикл;
-
Функции, содержащие следующее: вызовы закрытия, сопрограммы, созданные ключевыми словами select, for, defer, go;
-
Для функций определенной длины по умолчанию при разборе AST Go применяет бюджет в 80 узлов для встраивания. Каждый узел потребляет бюджет. Например,
a = a + 1
Эта строка кода содержит 5 узлов: AS, NAME, ADD, NAME, LITERAL. Когда накладные расходы функции превышают этот бюджет, ее нельзя встроить.
Указав параметры во время компиляции-l
Вы можете указать силу встраивания кода компилятором (идите 1.9+), но не рекомендуется всем использовать его здесь, в нашем тестовом сценарии он глючит и не может нормально работать:
// The debug['l'] flag controls the aggressiveness. Note that main() swaps level 0 and 1, making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and are not supported.
// 0: disabled
// 1: 80-nodes leaf functions, oneliners, panic, lazy typechecking (default)
// 2: (unassigned)
// 3: (unassigned)
// 4: allow non-leaf functions
Хотя встраивание может уменьшить накладные расходы на вызовы функций, оно также может снизить частоту попаданий в кэш ЦП из-за повторяющегося кода. Поэтому чрезмерное встраивание нельзя использовать вслепую. Его необходимо анализировать в сочетании с результатами профиля.
go test -gcflags='-m=2' -v -test.run TestNewCodec 2>&1 | grep "function too complex" | wc -l
48
go test -gcflags='-m=2 -l=4' -v -test.run TestNewCodec 2>&1 | grep "function too complex" | wc -l
25
Из приведенных выше выходных результатов мы видим, что усиление степени встраивания действительно уменьшает некоторые «слишком сложные функции», посмотрите на результаты тестов:
Выше включен самый высокий уровень встроенной силы, что действительно устраняет многие функции, которые не могут быть встроены из-за «слишком сложной функции», но результаты стресс-тестов показывают, что преимущества не очевидны.
Результаты теста
Мы построили тесты для сравнения производительности до и после оптимизации, и ниже приведены результаты тестов.
Среда: Go 1.13.5 darwin/amd64 на процессоре Intel Core i7 16 ГБ с тактовой частотой 2,5 ГГц
пакет
data size: 20KB
большая сумка
data size: 6MB
сериализация без копирования
В некоторых сервисах с большими данными запросов и ответов стоимость сериализации и десериализации высока.Есть две идеи оптимизации:
-
Оптимизируйте сериализацию и десериализацию, как описано ранее.
-
вызов с бескопийной сериализацией
исследовательская работа
Вызовы RPC посредством сериализации без копирования изначально были взяты из проекта Cap'n Proto Кентона Варды Cap'n Proto предоставляет набор форматов обмена данными и соответствующие библиотеки кодеков.
Cap'n Proto по сути открывает байтовый срез в качестве буфера. Все операции чтения и записи в структуре данных напрямую читаются и записываются в буфер. После завершения чтения и записи добавьте некоторую информацию о буфере в заголовок и отправьте ее. напрямую, а пир получает его, его можно прочитать позже, потому что нет языковой структуры Go в качестве промежуточного хранилища, поэтому нет необходимости сериализовать этот шаг, и десериализация тоже самое.
Кратко резюмируем характеристики Cap'n Proto:
-
Все данные считываются и записываются в непрерывном сегменте памяти.
-
Подготовьте операцию сериализации и кодируйте и декодируйте данные одновременно с получением/установкой данных.
-
В формате обмена данными через механизм указателя (смещения места хранения данных) данные могут храниться в любой позиции непрерывной памяти, так что данные в структуре могут быть прочитаны и записаны в любом порядке.
- Для полей фиксированного размера структуры переупорядочите их так, чтобы эти поля хранились в непрерывной части памяти.
- Для поля переменного размера структуры (например, списка) оно представлено указателем фиксированного размера, и указатель хранит некоторую информацию, включая местоположение данных.
Во-первых, Cap'n Proto не имеет языковой структуры Go в качестве промежуточного носителя, что позволяет сократить одну копию, затем Cap'n Proto работает с непрерывной памятью, и чтение и запись закодированных данных может выполняться одновременно. По этим двум причинам Cap'n Proto работает с непрерывной памятью. Proto работает хорошо.
Ниже приведен тест Thrift и Cap'n Proto с одной и той же структурой данных.Учитывая, что Cap'n Proto добавляет операции кодирования и декодирования, сравнение представляет собой полный процесс, включая инициализацию данных, то есть инициализацию данных структуры + ( Serialize ) + запись в буфер + чтение из буфера + (десериализация) + чтение данных из структуры.
struct MyTest {
1: i64 Num,
2: Ano Ano,
3: list<i64> Nums, // 长度131072 大小1MB
}
struct Ano {
1: i64 Num,
}
(Десериализация) + чтение данных, в зависимости от размера пакета, производительность Cap'n Proto примерно в 8-9 раз выше, чем у Thrift. Запись данных + (сериализация), в зависимости от размера пакета, производительность Cap'n Proto примерно в 2-8 раз выше, чем у Thrift. Общая производительность Cap' Proto примерно в 4-8 раз выше, чем у Thrift.
О преимуществах Cap'n Proto уже упоминалось ранее, давайте резюмируем некоторые проблемы Cap'n Proto:
-
Проблема, вызванная непрерывным хранением памяти Cap'n Proto: при изменении размера данных неопределенного размера и требуемом пространстве больше, чем исходное пространство, только пространство может быть перераспределено позже, в результате чего исходное пространство данных становится A отверстие, которое невозможно удалить. Эта проблема будет становиться все более и более серьезной с постоянным изменением размера вызывающей ссылки. Чтобы решить ее, можно только строго ограничить всю ссылку: старайтесь избегать изменения размеров полей неопределенного размера. Когда вам нужно изменить размер, перестройте структура и сделать глубокую копию данных.
-
Поскольку Cap'n Proto не имеет языковой структуры Go в качестве промежуточного носителя, все поля можно читать и записывать только через интерфейс, а пользовательский интерфейс оставляет желать лучшего.
Сериализация без копирования, совместимая с протоколом Thrift
Чтобы лучше и эффективнее поддерживать сериализацию без копирования, Cap'n Proto использует набор самостоятельно разработанных форматов кодеков, но его сложно развернуть в текущей среде, где Thrift и ProtoBuf являются основными. Чтобы получить производительность сериализации без копирования при совместимости протокола, мы начали исследование сериализации без копирования, совместимой с протоколом Thrift.
Cap'n Proto — эталон сериализации без копирования, поэтому давайте посмотрим, можно ли применить оптимизации Cap'n Proto к Thrift:
-
Естественно, это ядро сериализации без копирования, которая не использует структуру языка Go в качестве промежуточного носителя, сокращая одну копию. Эта точка оптимизации не зависит от протокола и может быть применена к любому существующему протоколу и, естественно, совместима с протоколом Thrift, но с точки зрения использования Cap'n Proto пользовательский интерфейс необходимо тщательно отполировать.
-
Cap'n Proto работает с непрерывным сегментом памяти, и чтение и запись закодированных данных могут выполняться одновременно. Причина, по которой Cap'n Proto может работать с непрерывной памятью: благодаря механизму указателей данные могут храниться где угодно, что позволяет записывать поля в любом порядке, не влияя на декодирование. Однако, с одной стороны, легко оставить дыры при ресайзе из-за неправильной работы с непрерывной памятью, с другой стороны, в Thrift нет механизма, аналогичного указателю, поэтому предъявляются более жесткие требования к размещению данных. Здесь есть две идеи:
- Настаивайте на работе с непрерывной памятью и налагайте строгие требования на использование пользователем: 1. Операция изменения размера должна перестраивать структуру данных 2. При вложенности структур существуют строгие требования к порядку записи полей (это можно представить как существующая вложенная структура расширяется снаружи внутрь, и запись должна быть написана в порядке расширения), и из-за взаимосвязи кодирования TLV, такого как двоичный, пользователю необходимо активно объявлять (например, StartWriteFieldX ) когда каждое гнездо начинает писать.
- Не оперируйте полностью непрерывной памятью, локальная память является непрерывной, а переменные поля выделяются в отдельный участок памяти.Поскольку память не является полностью непрерывной, естественно невозможно выполнить вывод за одну операцию записи. Чтобы максимально приблизиться к производительности записи данных за один раз, мы принимаем схему цепного буфера: с одной стороны, при изменении размера поля переменной необходимо заменить только один узел цепного буфера, а нет необходимости перестраивать структуру как у Cap'n Proto.С другой стороны, когда требуется вывод, нет необходимости воспринимать реальную структуру как у Thrift, пока буфер на всю ссылку пишется.
Во-первых, подытожим два определенных в настоящее время пункта: 1. Не использовать языковую структуру Go в качестве промежуточного носителя, управлять базовой памятью напрямую через интерфейс и выполнять кодирование и декодирование во время Get/Set 2. Хранить данные через буферы цепочки
Тогда давайте взглянем на проблемы, которые еще предстоит решить:
-
Ухудшение пользовательского опыта после отказа от использования языковых структур Go
- Решение. Улучшите работу с интерфейсом Get/Set и постарайтесь максимально упростить использование структуры языка Go.
-
Двоичный формат Cap'n Proto специально разработан для сценариев сериализации без копирования.Хотя он будет декодироваться один раз при каждом получении, но стоимость декодирования очень мала. Протокол Thrift (в качестве примера возьмем Binary) не имеет механизма, аналогичного указателю: при наличии нескольких полей неопределенного размера или вложенности они должны анализироваться последовательно и не могут напрямую получить расположение данных поля путем вычисления смещения. Последовательный анализ для каждого Get непомерно дорог.
- Решение: когда мы представляем структуру, в дополнение к записи узла буфера структуры мы также добавляем индекс, который записывает указатель узла буфера в начале каждого поля переменного размера.
Ниже приведена текущая схема сериализации без копирования и FastRead/Write, экстремальный сравнительный тест производительности на 4 ядрах:
Резюме результатов испытаний:
-
В сценариях с небольшими пакетами производительность десериализации низкая, около 85% от FastWrite/FastRead.
-
В сценариях с большими пакетами производительность без сериализации лучше, а пакеты размером более 4 КБ на 7–40 % выше, чем FastWrite/FastRead.
постскриптум
Надеюсь, что приведенный выше обмен может быть полезен сообществу. В то же время мы также пытаемся совместно использовать IPC на основе памяти, io_uring, нулевое копирование tcp, RDMA и т. д., чтобы лучше повысить производительность KiteX; сосредоточиться на оптимизации сценариев связи одной и той же машины и контейнера. Приглашаем всех заинтересованных студентов присоединиться к нам и вместе построить языковую экосистему Go!
использованная литература
Команда инфраструктуры ByteDance
Команда инфраструктуры ByteDance — важная команда, которая поддерживает бесперебойную работу множества пользовательских продуктов ByteDance, включая Douyin, Today's Toutiao, Xigua Video и Volcano Small Video, Стабильная разработка обеспечивает гарантии и импульс.
В компании команда инфраструктуры в основном отвечает за создание частного облака ByteDance, управление десятками тысяч кластеров серверного масштаба, отвечает за десятки тысяч гибридных развертываний вычислений/хранилищ и гибридных развертываний онлайн/офлайн, а также за поддержку стабильного хранилища. нескольких массивных данных ЭП.
В культурном плане команда активно использует открытый исходный код и инновационные аппаратные и программные архитектуры. Мы давно набираем студентов по направлению инфраструктура.Подробности смотрите на job.bytedance.com ("Читать исходный текст" в конце статьи).Если вам интересно, вы можете связаться с адрес электронной почты:tech@bytedance.com,заголовок письма:Название - Годы службы - Инфраструктура.
Добро пожаловать в "Техническая команда ByteDance"
Контактный адрес электронной почты для доставки резюме "tech@bytedance.com"