This article details an experiment to simulate millions of particles in Go, targeting multi-player compatibility, CPU-only execution, and smart TV support. The author's primary challenge was Go's lack of SIMD support, leading to an innovative server-side rendering (SSR) approach. Instead of synchronizing particle data, the server renders simulation frames (initially 1-byte per pixel, later optimized to 1-bit per pixel) and streams them to clients as a "video," effectively decoupling simulation complexity from client performance.
The implementation involved Go routines, double buffering, channel-based client communication, and various optimization attempts. Initial compression strategies (bitmask, RLE, delta encoding) proved too complex or inefficient for dense, rapidly changing particle data. The final solution adopted a 1-bit per pixel format combined with a uint64-to-byte lookup table for highly optimized frame packing and unpacking. Despite Go's limitations in numerical computation, the system achieved 2.5 million particles at 60 simulation frames per second, streaming to clients at 30 frames per second, theoretically supporting over 300 concurrent users on a modest ARM virtual machine. While Go excelled in backend and networking, its performance for the simulation-specific number-crunching lagged behind languages like Rust (due to SIMD) and even JavaScript.
Summarizing "How fast is go? simulating millions of particles on a smart tv"
Motivation & Core Challenge: The author aimed to simulate millions of particles in Go, supporting multi-player, CPU-only, and smart TV compatibility, primarily to push Go's performance limits given its lack of SIMD (Single Instruction, Multiple Data) support.
The "No Cheating" Rule: A strict rule was set: no client-side simulation. The client should be a simple webpage, making the server responsible for all particle physics and rendering.
Server-Side Rendering (SSR) Concept: Inspired by deferred shading in graphics, the solution involved rendering the particle simulation entirely on the server and sending video frames to clients. This decouples the simulation's particle count from the client's processing load, meaning a billion particles would cost the client the same as a thousand (fixed cost based on resolution).
Initial Data Transfer Calculations:
A raw HD frame (1920x1080) at 1 byte per pixel for luminance would be ~2MB/frame.
Sending 1 million particles' (x, y) coordinates at 8 bytes/particle would be 8MB, making raw frames more appealing for large particle counts.
Lossless compression was deemed necessary as lossy compression would destroy the dense information in particle simulations.
TCP WebSockets were chosen for communication despite potential lag spikes from head-of-line blocking, due to better web support than UDP.
Initial Go Implementation:
The simulation used a Particle struct and distributed updates across multiple Go routines.
Double buffering was used for frames to avoid conflicts during sending.
Input data was stored in a fixed-size array, accessed by client index to avoid locking hot paths.
Frame buffer building was identified as the most expensive part.
Performance Bottlenecks & Optimizations:
Pprof Analysis: Initially, creating Go routines was a bottleneck; this was resolved by having persistent worker Go routines listen for SimJob requests via channels.
Frame Broadcasting: A dedicated thread handled broadcasting frames to client-specific channels, reusing frame buffers from a pool.
Compression Attempts: Various encoding schemes (bitmask, run-length, variable-length, delta encoding with zigzag) were tried but found to be too complex, slow, or ineffective for the highly dynamic particle data, often resulting in larger data sizes or brief screen flashes upon client frame drops.
"Stupid Simple" Solution: The author reverted to a 1-bit per pixel representation for frame data, significantly reducing frame size (hundreds of KB for HD).
Client Camera State: Client camera (X, Y, Width, Height) was included in the input message.
Frame Packing/Unpacking Optimization: A uint64ToByteLUT (lookup table) was implemented to efficiently pack 8 individual 1-bit pixel values into a single byte, dramatically speeding up frame slicing and packing for clients.
Final Bottleneck: After optimizations, the core main.worker (particle simulation logic) became the dominant bottleneck, highlighting Go's limitations for numerical computation without SIMD.
Memory Usage: The server used less than 100MB of RAM for millions of particles.
Micro-optimizations: Referencing particles as pointers (&particles[i]) improved performance by reducing bounds checking.
Deployment & Scaling:
Deployed on a modest Netcup 16GB, 10-core ARM VM (approx. $8/month).
Achieved simulation of 2.5 million particles at 60 frames per second (simulation rate) and sending frames at 30 frames per second to clients.
Theoretically supports over 300 clients at 1920x1080 resolution due to a 2.5 Gbit pipe and the efficient 1-bit per pixel format.
The system can run on smart TVs as the client is a simple browser page playing a "video."
Conclusion: Go proved impressive for scaling backend and networking components, but its lack of SIMD led to disappointing number-crunching performance compared to languages like Rust (which leveraged SIMD for even naive simulations) or even JavaScript. The trade-off was Go's ease of use versus raw compute power.