Команда Go for Devs подготовила перевод статьи о том, насколько быстрым может быть Go. Автор проверил это на практике — написал симуляцию миллионов частиц с мультиплеером, только на CPU и так, чтобы оно работало даже на смарт-ТВ. Go оказался одновременно и разочарованием, и восторгом: он не дотягивает до Rust в вычислительных задачах, но удивляет своей простотой и тем, как легко масштабируется до сотен клиентов.
Задача: смоделировать миллионы частиц на Go, с поддержкой мультиплеера, только на CPU и так, чтобы работало на смарт-ТВ.
Поехали. Да, следующий каламбур очень уместен.

На работе мне пришлось писать ws-сервер, который объединял несколько апстрим ws-серверов в один общий. (Не спрашивайте.) Я застрял — и даже сила Claude, Gemini и Cursor не спасла. Вайбкодинга явно не хватало, чтобы доделать проект. Пришлось изучать «настоящие» вещи.
Чтобы разобраться в этих вещах, я решил написать симуляцию частиц и проверить, сколько частиц Go сможет осилить. Раньше я делал такое на JavaScript, Rust и Swift. Но, зная, что Go не поддерживает SIMD, я понимал: сравнение с другими языками будет нечестным, а главное — скучным и никак не помогающим моей рабочей задаче.
Я решил усложнить задачу и добавить мультиплеер в симуляцию. Всё-таки Go известен как быстрый и эффективный язык для бэкенда. Но достаточно ли он быстрый и эффективный, чтобы смоделировать миллион частиц и синхронизировать их сотням клиентов?
Есть только один способ проверить.
Это длинный текст, но если хотите сами поиграть с симуляцией частиц с мультиплеером, заходите на howfastisgo.dev. А если покрутить заголовок вверху страницы, то это просто iframe с живым сайтом.
Для тех, кому интересно: код лежит на GitHub. Я поправил баг с отключением клиентов и поднял количество частиц до 4 миллионов. Приятного тестирования.
Без жульничества
Есть правило, и оно важное: никакой симуляции на клиенте. Только сервер. Клиент должен быть простой веб-страницей, работающей где угодно, где есть браузер.
Детерминизм. Ключевое слово в компьютерных науках, и здесь оно тоже важно. Если вы начинаете с одного и того же начального состояния и применяете одинаковые входные данные, результат всегда будет одинаковым. Предсказуемым и воспроизводимым.
Многие мультиплеерные игры используют детерминизм, чтобы эффективно отвязать большой игровой стейт от данных, которые сервер обязан синхронизировать между клиентами.
Игры в жанре RTS — яркий пример. Вместо того чтобы отправлять клиентам позиции тысяч юнитов, снарядов, их здоровье и так далее, сервер шлёт только данные о действиях игроков, а клиент сам восстанавливает состояние игры в данный момент времени. Добавьте немного предсказания на стороне клиента, откаты — и в итоге получится плавный игровой процесс. Ничего особенного.
Но для этого клиент должен уметь симулировать игру, а далеко не все клиенты быстрые. Как это должно работать везде, где есть браузер, если я хочу моделировать миллионы частиц?
Я знаю, что сказал бы сейчас старина Gaffer on Games: даже если не использовать детерминизм, всё равно придётся что-то симулировать на клиенте. Если я хочу отправлять миллионы позиций частиц на клиент, то как минимум координаты нужно передавать, верно? Скорости можно вычислить, к тому же можно «заквантовать всё до упора». Уверен, есть и другие трюки, которые я уже подзабыл из его отличной серии блогов о сетевом коде игровых стейтов.
Но я и это считаю жульничеством: клиенту приходится что-то симулировать, а значит, это может не масштабироваться до миллионов частиц.
У меня есть другая идея. Я могу отвязать размер симуляции — примерно так же, как это делает детерминизм, — если подсмотреть подходы у графического программирования.
G-Buffer
Когда-то, во времена Doom 3, на пике карьеры Кармака (Mr. Carmack), игры рассчитывали освещение, строя геометрию теней из полигонов. Именно так это было сделано в Doom 3 — и выглядело потрясающе. Но чем больше становилось полигонов, тем дороже обходились тени.
Индустрия затем нашла способ отвязать количество полигонов от освещения с помощью графического буфера и отложенного шейдинга (deferred shading). Детали тут сложные, но главное — стоимость расчёта освещения больше не была прямо пропорциональна числу полигонов в сцене. Она стала зависеть только от «фиксированного» размера g-buffer, который пропорционален разрешению рендера.
Вот почему 4K так дорого рендерить и почему индустрия игр так активно вкладывается в AI-апскейлинг и интерполяцию кадров. Меньше пикселей — быстрее рендер. Геометрия сейчас возвращается в игру с виртуализацией, но это отдельная тема. Главное — deferred shading отвязал количество полигонов от расчёта освещения.
А что если пойти ещё дальше и отрисовывать всё на сервере, отправляя клиентам только кадры симуляции? Клиенту останется лишь воспроизводить «видео» в элементе html canvas. Да, ввод с клиента всё равно придётся пересылать на сервер, но размер симуляции значения иметь не будет. Стоимость будет фиксироваться только разрешением клиента.
Хоть миллиард частиц — объём данных для клиента не изменится. Другое дело, что если частиц всего несколько тысяч, такой подход не оправдан.
Но как понять, стоит ли оно того?
Немного математики
Я хочу симулировать 1 миллион частиц в HD-разрешении, то есть 1920x1080 пикселей. На каждый пиксель приходится 4 байта RGBA, но мне нужен только RGB. Более того, на самом деле достаточно 1 байта на пиксель — так работали предыдущие симуляции частиц на других языках.
Это значит, что я передаю 2 073 600 байт на кадр, чуть больше 2 МБ. На 60 FPS это уже 120 МБ/с. Ого.
Если сжимать кадры, размер можно сократить в разы. H.264 или H.265 (платный) уменьшат его примерно до 260 КБ на кадр. А если слать 24 кадра в секунду, то получится почти как стриминг обычного видео.
Важно: речь идёт о байтах, а не о битах.
Если бы я передавал сами данные частиц, то каждая частица описывается x, y, dx и dy. Это числа с плавающей точкой по 4 байта каждое, то есть 16 байт на частицу. Но dx и dy можно вычислить, поэтому пусть будет 8 байт на частицу. Для миллиона частиц это 8 000 000 байт, или 8 МБ. В 4 раза больше, чем «сырой» буфер кадра. С помощью квантизации можно уменьшить ещё сильнее, и пересылать данные не обязательно 60 раз в секунду. Но что если частиц будет 10 миллионов? Тут буфер кадров уже выглядит привлекательнее.
Есть и ещё плюс: отправляя кадры, я упрощаю архитектуру. Не нужно заморачиваться с предсказаниями и интерполяцией на клиенте. Для шутера это, конечно, не подходит, но там и не приходится синхронизировать миллионы частиц.
Сжатие с потерями здесь невозможно. Симуляция частиц «ломается» от таких алгоритмов из-за высокой плотности данных. В них мало повторяющихся структур, и HEVC или любой другой lossy-кодек просто «убьёт» картинку.
Нужен только lossless.
Например, посмотрите на это изображение: видите весь этот шум? Он плохо сжимается, особенно в динамике.

Дополнительно: сжатие тоже не бесплатное. Если я хочу масштабироваться на сотни клиентов, я не могу тратить всё время на компрессию данных. Это важный фактор.
Я собираюсь использовать TCP через WebSocket. Для низкой задержки в реальном времени лучше подошёл бы UDP — он допускает доставку вне порядка и избегает «блокировки начала очереди» (head-of-line). Но с UDP всё значительно сложнее, да и в вебе он поддерживается плохо. Гарантированная упорядоченность сообщений в TCP приведёт к редким лагам, но, увы, это лучшее, что веб сейчас предлагает. QUIC, конечно, существует, однако он помогает с HOL-блокировкой только при множественных соединениях, а здесь их не будет.
Итак, WebSocket поверх TCP. Вперёд!
Прототип
Финальная цель — настраиваемая игровая область, где у каждого клиента свой «вид» на мир. Лучшая отправная точка — один общий вид, который рассылается всем клиентам.
Сама симуляция довольно проста. Есть структура частицы, которая «раздаёт» обновления множеству горутин. Я понимал, что нельзя писать в буфер, пока он отправляется клиенту, поэтому настроил двойную буферизацию кадров. ticker
— простой способ завести игровой цикл, хотя я его не обожаю.
Суть такая:
type Particle struct {
x float32
y float32
dx float32
dy float32
}
type Input struct {
X float32
Y float32
IsTouchDown bool
}
type SimState struct {
dt float32
width uint32
height uint32
}
// инициализация переменных
func startSim() {
// инициализационный код
for range ticker.C {
// метаданные
wg.Add(numThreads)
// без блокировок — это нормально
numClients := len(clients)
const friction = 0.99
for i := 0; i < numThreads; i++ {
go func(threadID int) {
defer wg.Done()
startIndex := threadID particlesPerThread
endIndex := startIndex + particlesPerThread
if threadID == numThreads-1 {
endIndex = particleCount
}
for p := startIndex; p < endIndex; p++ {
for i := 0; i < numClients; i++ {
input := inputs[i]
if input.IsTouchDown {
// применить гравитацию
}
}
particles[p].x += particles[p].dx
particles[p].y += particles[p].dy
particles[p].dx = friction
particles[p].dy *= friction
// отскок, если вышли за границы
}
}(i)
}
// ждем завершения
wg.Wait()
framebuffer := getWriteBuffer()
copy(framebuffer, bytes.Repeat([]byte{0}, len(framebuffer)))
for _, p := range particles {
// собрать буфер кадра
}
swapBuffers()
go func(data []byte) {
// Неблокирующая отправка: если канал заполнен, самый старый кадр отбрасывается
select {
case fameChannel <- framebuffer:
default:
// Канал заполнен — кадр сброшен
}
}(framebuffer)
}
}
Несколько заметок. Симуляция довольно простая. Частицы можно «подтягивать» движениями игроков, при этом они замедляются за счёт коэффициента трения.
Математическая библиотека Go оставляет желать лучшего. Она поддерживает только 64-битные числа с плавающей точкой, что глупо. На самом деле, многие примитивы и стандартные библиотеки Go оказались неудобными в использовании, но это, в целом, ожидаемо — язык ведь и не позиционируется как системный.
Я стараюсь максимально избегать блокировок. Это значит, что я хочу заранее выделить фиксированный объём памяти, исходя из максимального количества клиентов, которое смогу обслужить, а затем использовать индекс клиента как быстрый способ доступа к его данным, в данном случае — к данным ввода.
Я также хочу избежать записи буфера кадров для каждого клиента, так как знаю: запись в буфер кадров не дружит с кешем и должна выполняться только один раз. Более того, именно построение буфера кадров — самая дорогая операция. Я понял это ещё при работе с другими языками. Всё дело в локальности данных в кеше и подобных тонкостях.
Ещё один момент, который я заметил в Go: он может быть многословным. Не до уровня Java, но всё же писать приходится довольно много.
Бойлерплейт-код я опущу.
func main() {
go startSim()
http.HandleFunc("/ws", wsHandler)
// для веб-страницы
http.Handle("/", http.FileServer(http.Dir("./public")))
log.Println("Сервер запущен на :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
Изначально каждый websocket запускает свой собственный тикер, который пишет в клиент последний буфер кадра. Это выглядит некрасиво и вызывает проблемы с синхронизацией, но для первой версии сойдёт.
func wsHandler(w http.ResponseWriter, r *http.Request) {
// шаблонный код
clientsMu.Lock()
// безопасное добавление клиента
clientsMu.Unlock()
defer func() {
// корректная очистка ресурсов
}()
go func() {
// ожидание сообщений
var input Input
err := binary.Read(bytes.NewReader(message), binary.LittleEndian, &input)
// возможно позже мы добавим блокировку или найдём способ обойтись без неё в горячем участке
// пока это нормально: некорректные данные могут появиться только если кто-то подключится или отключится в момент обновления input
idx := findClientIndex(conn)
if idx != -1 && idx < maxClients {
inputs[idx] = input
}
}()
ticker := time.NewTicker(time.Second / 30)
for range ticker.C {
data := getReadBuffer()
if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
log.Println("Ошибка записи:", err)
break
}
}
}
Индекс клиента реализован довольно нелепо, но я совсем не хочу блокировать горячий участок здесь.
Я опущу клиентскую часть на JavaScript/HTML — там ничего интересного: настраиваем websocket, выводим данные в элемент canvas с помощью setPixels
, всё как обычно.
Ну и как оно работает? В целом, неплохо. Но не быстро — передаётся просто тонны данных. Иногда ещё мигает, когда тикеры обращаются к буферу кадра одновременно с потоком симуляции.
Тем не менее, это хорошая отправная точка.
Скорость, скорость, скорость
Я использовал pprof
, чтобы снять несколько сэмплов и посмотреть, где именно тормозит программа. Оказалось, что значительное время уходит на создание горутин. Да, они «лёгкие», но всё же не бесплатные. Идея была в том, чтобы горутины просто слушали каналы. Я переписал симуляцию так, чтобы она ждала входящих запросов SimJob
.
type SimJob struct {
startIndex int
endIndex int
simState SimState
inputs [maxClients]Input
numClients int
}
// другой код
jobs := make(chan SimJob, numThreads)
var wg sync.WaitGroup
for i := 0; i < numThreads; i++ {
go worker(jobs, &wg)
}
// внутри цикла симуляции
for i := 0; i < numThreads; i++ {
startIndex := i * particlesPerThread
endIndex := startIndex + particlesPerThread
if i == numThreads-1 {
endIndex = particleCount
}
jobs <- SimJob{startIndex, endIndex, simState, &inputs, numClients, &framebuffer}
}
Я также начал переиспользовать кадровые буферы через пул, а синхронизацию исправил так: пушил кадры в канал, который слушал отдельный поток. Этот поток записывал новый кадр всем клиентам, а затем возвращал его в пул.
Сначала я делал это обычным циклом for
, но в таком случае запись шла с той скоростью, с какой успевал самый медленный клиент. Поэтому я изменил схему: кадр сначала подготавливался, а потом отправлялся в отдельный набор каналов — по одному на каждого клиента. Потоки, слушающие эти каналы, уже занимались реальной отправкой данных конкретному клиенту.
func broadcastFrames(ch <-chan *Frame, pool *sync.Pool) {
for {
frame := <-ch
// подготовка данных
clientsMu.Lock()
for i, conn := range clients {
// отправляем в отдельный канал
select {
case clientSendChannelMap[conn] <- dataToSend:
default:
log.Printf("Канал клиента %d переполнен, кадр отброшен. Запрошен полный кадр.", i)
}
}
clientsMu.Unlock()
pool.Put(frame)
}
}
// другой код
func writePump(conn *websocket.Conn) {
var channel = clientSendChannelMap[conn]
for {
message, ok := <-channel
if !ok {
return
}
if err := conn.WriteMessage(websocket.BinaryMessage, message); err != nil {
log.Printf("Не удалось отправить данные клиенту: %v", err)
return
}
}
}
Я запускаю «памп» при подключении клиента и останавливаю при его отключении. Это работает довольно хорошо и решает проблему синхронизации.
Я немного поэкспериментировал со сжатием, используя стандартную библиотеку flate
. Она работала, но оказалась довольно медленной. Потом я попробовал все виды кодирования, которые смог придумать, чтобы сократить объём передаваемых данных.
Сначала я попробовал битовую маску: отправлял для каждого пикселя флаг 0/1, а затем подряд данные по изменившимся пикселям. Для отдельного клиента это было быстро, но когда частицы начинали активно двигаться, итоговый объём оказывался даже больше, чем у исходных кадров.
Я пробовал кодирование серий (RLE), переменной длины (VLE) и дельта-кодирование — в разных комбинациях.
Здесь я столкнулся с некоторыми нюансами. Для работы RLE нужен специальный sentinel-байт, чтобы обозначать, есть ли серия одинаковых данных. Это уменьшает доступное пространство на пиксель. Теоретически можно кодировать несколько пикселей сразу, но на практике это оказалось не очень эффективно. VLE — это по сути то же RLE, но с использованием варинтов для сокращения размера маленьких чисел. Этот подход сработал и позволил в среднем уменьшить объём почти на 8%.
Самым интересным оказалось дельта-кодирование. Я пробовал кодировать дельты между кадрами. Тогда при передаче этих дельт в RLE данные сжимались в разы. Проблема была в том, что количество частиц в каждом пикселе могло как увеличиваться, так и уменьшаться. Пришлось использовать «зигзаг»-кодирование, но и там возникли трудности.
Если каждый пиксель занимает один байт, а его значение за один кадр может измениться от 0 до 255, то разница (дельта) должна помещаться в диапазон от -255 до +255. Это уже не умещается в один байт. Зигзаг-кодирование сработало, и я решил использовать 7 бит на пиксель.
О зигзаг-кодировании я узнал из статьи Microsoft про оптимизированное сжатие данных глубины с нескольких сенсоров Kinect. Название я не помню. Чтобы не тратить время, просто спросите у ИИ — он подскажет.
func CreateDeltaBuffer(oldBuffer, newBuffer []byte) []byte {
if len(oldBuffer) != len(newBuffer) {
return newBuffer
}
deltaBuffer := make([]byte, len(newBuffer))
for i := 0; i < len(newBuffer); i++ {
diff := int16(newBuffer[i]) - int16(oldBuffer[i])
deltaBuffer[i] = zigZagEncode(int8(diff))
}
return deltaBuffer
}
И это работает. Если весь кадр не двигался целиком, размер данных был примерно на 30% меньше исходного. А если ничего не менялось — передавались всего несколько байт! Но, как обычно, появились новые проблемы.
Код получился громоздким. Мне пришлось отслеживать, отправляю ли я дельта-кадры или полные кадры, а если клиент терял кадр, нужно было снова высылать полный. Из-за этого на экране появлялась короткая вспышка. Я мог бы добавить логику счётчика кадров, чтобы клиент умел «выпадать» из цепочки дельт и возвращаться в синхронизацию, но код и так становился слишком перегруженным.
func broadcastFrames(ch <-chan *Frame, pool *sync.Pool) {
for {
frame := <-ch
fullFrameBuffer.Reset()
fullFrameBuffer.WriteByte(OpCodeFullFrame)
fullFrameBuffer.Write(frame.FullBuffer)
fullFrameBytes := fullFrameBuffer.Bytes()
deltaFrameBuffer.Reset()
deltaFrameBuffer.WriteByte(OpCodeDeltaFrame)
deltaFrameBuffer.Write(frame.Delta)
deltaFrameBytes := deltaFrameBuffer.Bytes()
clientsMu.Lock()
for i, conn := range clients {
var dataToSend []byte
if clientFullFrameRequestFlags[i] {
dataToSend = fullFrameBytes
clientFullFrameRequestFlags[i] = false
} else {
dataToSend = deltaFrameBytes
}
select {
case clientSendChannelMap[conn] <- dataToSend:
default:
log.Printf("Канал клиента %d переполнен, кадр отброшен. Запрошен полный кадр.", i)
clientFullFrameRequestFlags[i] = true
}
}
clientsMu.Unlock()
pool.Put(frame)
}
}
И всё же это работало.
Но мне хочется, чтобы у каждого клиента был свой «вид» на большой кадр, чтобы он мог перемещать камеру по миру и сражаться за частицы. Для этого при нынешней архитектуре пришлось бы отслеживать дельта-кадры отдельно для каждого клиента и ещё как-то аккуратно обрабатывать потерянные дельты.
Придётся вернуться к чертежной доске.
Чертёжная доска
Предельно просто — не значит глупо.
Я оставлю по 1 биту на пиксель. То есть будет только одно значение «яркости». В предыдущем видео на клиенте фактически использовалось всего несколько градаций — примерно 8 уровней «яркости». Держу пари, вы думали, что их больше, верно?
Так всё упростится. Единственная сложность — это упаковка и распаковка данных. Заодно я понял, что можно реализовать ещё и состояние камеры на клиенте. Я немного колебался, как хранить это состояние и как его обновлять.
Должен ли клиент присылать свою позицию камеры напрямую? Что, если окно изменит размер? Что, если клиент пришлёт отрицательный размер камеры? А бесконечный? Как избежать блокировок?
В итоге я повторил подход, который использовал для входных данных.
type ClientCam struct {
X float32
Y float32
Width int32
Height int32
}
var (
inputs [maxClients]Input
cameras [maxClients]ClientCam
)
Клиент будет отправлять данные камеры вместе с уже существующим сообщением ввода.
for {
mt, message, err := conn.ReadMessage()
if err != nil {
log.Println("Read failed:", err)
return
}
if mt == websocket.BinaryMessage {
reader := bytes.NewReader(message)
var touchInput Input
// интерпретируем x и y как смещения
var camInput ClientCam
// читаем ввод
errInput := binary.Read(reader, binary.LittleEndian, &touchInput)
errCam := binary.Read(reader, binary.LittleEndian, &camInput)
// устанавливаем состояние ввода/камеры клиента
// обрабатываем ошибки во входных данных, границы, отрицательные значения и т.д.
}
}
Для отправки кадров сначала я наивно упаковывал буфер, а затем распаковывал его на основе области, которую рендерит клиент.
// в потоке симуляции
for _, p := range particles {
x := int(p.x)
y := int(p.y)
if x >= 0 && x < int(simState.width) && y >= 0 && y < int(simState.height) {
idx := (y*int(simState.width) + x)
if idx < int(simState.width*simState.height) {
byteIndex := idx / 8
bitOffset := idx % 8
if byteIndex < len(framebuffer) {
framebuffer[byteIndex] |= (1 << bitOffset)
}
}
}
}
// в канале отправки кадров перед отправкой клиенту
// получаем информацию о камере клиента
for row := int32(0); row < height; row++ {
for col := int32(0); col < width; col++ {
mainFrameIndex := ((y+row)*int32(simState.width) + (x + col))
dataToSendIndex := (row*width + col)
if mainFrameIndex/8 < int32(len(frameBuffer)) && dataToSendIndex/8 < int32(len(dataToSend)) {
isSet := (frameBuffer[mainFrameIndex/8] >> (mainFrameIndex % 8)) & 1
if isSet == 1 {
dataToSend[dataToSendIndex/8] |= (1 << (dataToSendIndex % 8))
}
}
}
}
Так в Full HD получается всего несколько сотен килобайт на кадр.
pprof
умеет удобно снимать сэмплы работающего приложения, которые потом можно загрузить и проанализировать в их CLI-инструменте.
Я снял сэмпл через pprof
и заметил, что симуляция сильно тормозила на этапе построения буфера кадра. Поэтому я перенёс построение кадров в рабочие потоки симуляции. Теперь это возможно, потому что каждый поток просто выставляет бит, если там есть частица. То есть мне не нужно ничего блокировать — можно позволить последнему записавшему «побеждать».
До
Showing top 10 nodes out of 35
flat flat% sum% cum cum%
16.75s 52.54% 52.54% 17.05s 53.48% main.worker
6.42s 20.14% 72.68% 6.49s 20.36% main.broadcastFrames
После
Showing top 10 nodes out of 36
flat flat% sum% cum cum%
13.01s 46.46% 46.46% 13.28s 47.43% main.worker
6.98s 24.93% 71.39% 7.08s 25.29% main.broadcastFrames
Симуляция стала немного быстрее, но видно, что рассылка кадров работает медленно. Помните, как я всё упаковывал? Да, это оказалось очень медленным. Четверть времени тратится на рассылку кадров нескольким клиентам из-за постоянных побитовых операций упаковки и распаковки.
Простое решение — использовать целые байты, даже если значения только 0 или 1, а затем lookup-map, чтобы избежать побитовых операций, вот так:
var uint64ToByteLUT = make(map[uint64]byte)
func init() {
var byteSlice = make([]byte, 8)
for i := 0; i < 256; i++ {
for bit := 0; bit < 8; bit++ {
if (i>>bit)&1 == 1 {
byteSlice[bit] = 1
} else {
byteSlice[bit] = 0
}
}
// trust me bro
uint64ToByteLUT[BytesToUint64Unsafe(byteSlice)] = byte(i)
}
}
for row := int32(0); row < height; row++ {
yOffset := (y + row) * int32(simState.width)
for col := int32(0); col < width; col += 8 {
fullBufferIndex := yOffset + (x + col)
chunk := frameBuffer[fullBufferIndex : fullBufferIndex+8]
key := BytesToUint64Unsafe(chunk)
packedByte, _ := uint64ToByteLUT[key]
if outputByteIndex < int32(len(dataToSend)) {
dataToSend[outputByteIndex] = packedByte
outputByteIndex++
}
}
}
Теперь вырезать нужный регион кадра для клиента почти ничего не стоит. Идея проста: интерпретировать 8 байт сразу как uint64
, который мапится в один байт с нужными битами. На клиенте я сделал похожее, чтобы распаковка тоже шла быстрее. Дополнительной памяти тут тратится немного, и это того стоит.
Неплохо. Уверен, можно сделать ещё лучше, но основное время всё равно съедает сама симуляция.
Showing top 10 nodes out of 49
flat flat% sum% cum cum%
15.62s 58.85% 58.85% 15.83s 59.65% main.worker
4.66s 17.56% 76.41% 4.66s 17.56% runtime.pthread_cond_wait
2.96s 11.15% 87.57% 2.96s 11.15% runtime.pthread_cond_signal
0.90s 3.39% 90.96% 0.90s 3.39% runtime.kevent
0.47s 1.77% 92.73% 0.80s 3.01% runtime.mapaccess2_fast64
0.36s 1.36% 94.08% 1.17s 4.41% main.broadcastFrames
0.20s 0.75% 94.84% 0.20s 0.75% runtime.asyncPreempt
0.17s 0.64% 95.48% 0.17s 0.64% runtime.madvise
0.17s 0.64% 96.12% 0.17s 0.64% runtime.pthread_kill
0.17s 0.64% 96.76% 0.17s 0.64% runtime.usleep
Кстати о памяти: даже с миллионами частиц сервер едва переваливает за 100 МБ. Это не уровень Rust, конечно, но Node уже съел бы пару гигабайт, а Python вряд ли оказался бы легче.
Я снова потратил больше дня, пытаясь прикрутить Go-ассемблер ради тех самых SIMD-ускорений, но безуспешно. Попробовал и несколько библиотек — ни одна не дала лучшего результата, чем мой код. Хотя в компиляторе Go есть интересные штуки: например, оптимизация разыменований и проверок границ дала мне прирост примерно на 30%.
Например, раньше я обновлял частицы так:
for i := job.startIndex; i < job.endIndex; i++ {
particles[i].x += particles[i].dx
// и так далее
}
Но если переписать вот так:
for i := job.startIndex; i < job.endIndex; i++ {
p := &particles[i]
p.x += p.dx
}
Получается заметный прирост. Логично: проверки границ происходили слишком часто. Я сделал ещё пару микрооптимизаций, которые немного помогли, но не кардинально. Пробовал и SoA, но результат оказался хуже, чем с AoS. Забавно.
Я также пытался подключить быстрый inverse square root, но по какой-то причине версия на Go вообще не заработала, хотя в JavaScript с этим проблем не было. Ну да ладно.
Предыдущие профили я снимал при симуляции дюжины клиентов с несколькими миллионами частиц. Если поднять число частиц до 20 миллионов, картина такая:
Showing top 10 nodes out of 21
flat flat% sum% cum cum%
113.16s 93.06% 93.06% 115.22s 94.75% main.worker
2.27s 1.87% 94.93% 2.27s 1.87% runtime.pthread_cond_wait
2.04s 1.68% 96.60% 2.04s 1.68% runtime.asyncPreempt
1.23s 1.01% 97.62% 1.23s 1.01% runtime.pthread_cond_signal
0.63s 0.52% 98.13% 1.56s 1.28% runtime.mapaccess2_fast64
0.30s 0.25% 98.38% 1.92s 1.58% main.broadcastFrames
Симуляция — явное узкое место, и без SIMD я не уверен, что получится выжать из неё что-то существенно большее.
Тем не менее всё работает. Время выкатывать это в облако.
Облако
Изначально я собирался использовать доклет Digital Ocean, но там за что-то быстрее тостера пришлось бы платить как за абонемент в Equinox. В итоге я остановился на Netcup: $8/месяц за ARM-VM с 16 ГБ памяти и 10 ядрами в Манассасе, Вирджиния, с пингом больше 300 мс. Отлично. Правда, почему их панель DNS уверяет, что это Германия — загадка.
Я открыл пару портов под root, как настоящий психопат, и запустил сервис.
Выглядит вполне бодро. Дополнительные клиенты почти не нагружают CPU. Но выдержит ли оно тысячу клиентов? Тут я не уверен.
У VPS Netcup пропускная способность 2.5 Гбит. При разрешении 1920×1080 и 1 бите на пиксель с частотой 30 кадров/с по каналу это теоретически позволяет обслуживать больше 300 клиентов. А если брать мобильные устройства с куда меньшими разрешениями, например 390×844 (и это ещё не нативные значения), то масштабироваться может даже лучше.
Стоит отметить, что система периодически страдает от head-of-line blocking, особенно если поднять разрешение. Клиенты с разрешением выше, чем максимальная сетка частиц, игнорируются — так что ваш 5K-дисплей работать не будет.
Насколько быстр Go?
Я немного довёл до ума клиент — добавил скроллинг по краям, панорамирование, поддержку мобилок и так далее, но этот пост не про JavaScript. Он про Go.
Go способен симулировать 2.5 миллиона частиц на 60 fps, параллельно отправляя данные на 30 fps — теоретически более чем 300 клиентам, а может и тысяче. И поскольку всё это работает на сервере, оно запускается даже на смарт-телевизоре. Не верите? Просто зайдите на howfastisgo.dev или посмотрите видео ниже.
И хотя Go точно не тянет на «рабочую лошадку» для вычислений, это всё равно впечатляет. Любое устройство с браузером — и оно работает! Go просто НАСТОЛЬКО быстр.
Это какое-то проклятие, и я в восторге.
Если серьёзно, я немного разочаровался в производительности Go на вычислительных задачах. Отсутствие поддержки SIMD привело к тому, что в части симуляции Go показал себя хуже, чем JavaScript. Rust на одном ядре справился лучше, чем Go на восьми, потому что Rust мог векторизовать даже самый наивный код симуляции.
Но Go и не создавался для «числодробилки». Зато в него было намного проще войти, чем в Rust, так что, пожалуй, простота использования здесь перевешивает.
Я ещё пытался настроить WebRTC с неупорядоченными сообщениями. Лаги при этом остались, плюс добавились артефакты, которые, уверен, можно было бы поправить. Но раз это не помогало от заиканий, смысла дальше копаться не было. Что ж, попытка была.
Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
Spiritschaser
obfs4proxy на роутерах съедает вообще всю память. https://github.com/openwrt/packages/issues/24880