<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>GPU | Valentin Caries</title><link>https://vcaries.github.io/en/tag/gpu/</link><atom:link href="https://vcaries.github.io/en/tag/gpu/index.xml" rel="self" type="application/rss+xml"/><description>GPU</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Fri, 05 Jun 2026 00:00:00 +0000</lastBuildDate><image><url>https://vcaries.github.io/media/icon_hu536d0651d21529ac336731b24f3e073f_144279_512x512_fill_lanczos_center_3.png</url><title>GPU</title><link>https://vcaries.github.io/en/tag/gpu/</link></image><item><title>BoidForge: Engineering Collective Behavior from Python to Native C</title><link>https://vcaries.github.io/en/project/boid-forge/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://vcaries.github.io/en/project/boid-forge/</guid><description>&lt;h2 id="in-one-minute">In one minute&lt;/h2>
&lt;p>&lt;strong>BoidForge&lt;/strong> is a 2D flocking simulator built around a single principle: a swarm is &lt;em>computed&lt;/em> once and &lt;em>rendered&lt;/em> afterwards, never both at the same time. A &lt;strong>solver&lt;/strong> advances the physics and streams every timestep to a compact binary file. A separate &lt;strong>visualization engine&lt;/strong> then replays that file on the GPU. The two halves share no memory, only an on disk format, so each one can be optimised on its own.&lt;/p>
&lt;p>The result is a project that is equal parts numerical computing and real time graphics. The same flocking model is implemented in &lt;strong>four interchangeable backends&lt;/strong>, ranging from a naïve Python reference to a hand written &lt;strong>C extension&lt;/strong>, and all four are proven to produce strictly identical results down to the last bit. A &lt;strong>ModernGL&lt;/strong> renderer then turns the raw trajectories into the cinematic footage below.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
&lt;iframe src="https://www.youtube.com/embed/5iVkLb3eyhE" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" allowfullscreen title="YouTube Video">&lt;/iframe>
&lt;/div>
&lt;figcaption style="text-align:center;font-size:0.9em;margin-top:0.5em;">&lt;strong>Video.&lt;/strong> The &lt;em>turbulence&lt;/em> scene, rendered offline in 4K at 60&amp;nbsp;fps and coloured by speed. It was produced by replaying a recorded simulation through the GPU visualization engine described further down.&lt;/figcaption>
&lt;p>👉 &lt;strong>&lt;a href="https://github.com/vcaries/boid_forge" target="_blank" rel="noopener">Source code on GitHub&lt;/a>&lt;/strong>&lt;/p>
&lt;h3 id="highlights">Highlights&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>One model, four backends, identical output.&lt;/strong> Naïve Python (L1), vectorized NumPy (L2), a uniform grid spatial hash (L3), and a native C/CPython kernel (L4), all bit for bit identical under a strict determinism contract.&lt;/li>
&lt;li>&lt;strong>Up to about 330 times faster.&lt;/strong> The C backend reaches roughly 120 to 330 times the throughput of the naïve reference over the benchmarked range, and is designed to scale to tens of thousands of agents.&lt;/li>
&lt;li>&lt;strong>GPU visualization in ModernGL.&lt;/strong> Instanced point sprites, additive HDR accumulation, motion trails, bloom, and ACES tone mapping, rendered without a display and streamed straight to FFmpeg for 4K export.&lt;/li>
&lt;li>&lt;strong>Reproducible by construction.&lt;/strong> Every result is a pure function of the configuration and a seed, so the same inputs give the same bytes on any machine.&lt;/li>
&lt;/ul>
&lt;h2 id="inspirations">Inspirations&lt;/h2>
&lt;p>This project grew out of two pieces of online science and engineering culture. The first is &lt;strong>Smarter Every Day&lt;/strong>, whose &lt;a href="https://www.youtube.com/watch?v=4LWmRuB-uNU" target="_blank" rel="noopener">film on starling murmurations&lt;/a> captures how thousands of birds can behave like a single fluid body, and asks the natural question of how such order can emerge with no leader and no plan. The second is &lt;strong>Sebastian Lague&lt;/strong> and his &lt;em>Coding Adventures&lt;/em> series, whose &lt;a href="https://www.youtube.com/watch?v=bqtqltqcQhw" target="_blank" rel="noopener">episode on boids&lt;/a> shows how a few local rules translate into code and how much visual beauty a simple simulation can hold. BoidForge is my attempt to take that curiosity seriously as an engineer: to reproduce the phenomenon faithfully, to push it to a scale where the emergence becomes obvious, and to render it well enough to do the real murmurations justice.&lt;/p>
&lt;h2 id="the-flocking-model">The flocking model&lt;/h2>
&lt;p>Flocking is an &lt;em>emergent&lt;/em> behaviour. There is no leader and no global plan, yet thousands of agents organise themselves into coherent, fluid looking motion. The classic formulation is Craig Reynolds&amp;rsquo; &lt;strong>boids&lt;/strong> model (1987), in which every agent obeys three local steering rules, each computed only from the neighbours that lie within a given radius.&lt;/p>
&lt;p>Let agent $i$ have position $\mathbf{p}_i$ and velocity $\mathbf{v}_i$. Write $\mathcal{N}_i$ for the set of its neighbours inside the radius of the rule being evaluated. Each rule produces a steering acceleration.&lt;/p>
&lt;p>&lt;strong>Separation&lt;/strong> keeps the swarm from collapsing on itself. Each agent is pushed away from its close neighbours, and the push grows stronger the closer they are:&lt;/p>
$$\mathbf{a}_{\text{sep}} = w_{\text{sep}} \sum_{j \in \mathcal{N}_i} \frac{\mathbf{p}_i - \mathbf{p}_j}{\lVert \mathbf{p}_i - \mathbf{p}_j \rVert^{2}}$$
&lt;p>The inverse square weighting is the important detail: a neighbour at half the distance repels four times as hard, which is what spaces the agents out and prevents collisions.&lt;/p>
&lt;p>&lt;strong>Alignment&lt;/strong> makes neighbours travel together, by steering each agent towards the average velocity $\overline{\mathbf{v}}$ of its neighbourhood:&lt;/p>
$$\mathbf{a}_{\text{ali}} = w_{\text{ali}} \left( \overline{\mathbf{v}} - \mathbf{v}_i \right)$$
&lt;p>&lt;strong>Cohesion&lt;/strong> holds the group together, by steering each agent towards the local centre of mass $\overline{\mathbf{p}}$ of its neighbourhood:&lt;/p>
$$\mathbf{a}_{\text{coh}} = w_{\text{coh}} \left( \overline{\mathbf{p}} - \mathbf{p}_i \right)$$
&lt;p>The three contributions are summed into a single acceleration $\mathbf{a}_i$, whose magnitude is capped at a maximum steering force. The velocity is then advanced by one explicit Euler step and clamped to a speed band $[v_{\min}, v_{\max}]$ so that agents never freeze or run away:&lt;/p>
$$\mathbf{v}_i \leftarrow \mathbf{v}_i + \mathbf{a}_i \Delta t$$
&lt;p>Finally the position is advanced with that new velocity, and a boundary rule (wrap around or reflect) keeps the swarm inside the world:&lt;/p>
$$\mathbf{p}_i \leftarrow \mathbf{p}_i + \mathbf{v}_i \Delta t$$
&lt;p>Each rule uses its &lt;strong>own radius&lt;/strong>, and the three weights $w_{\text{sep}}$, $w_{\text{ali}}$, $w_{\text{coh}}$ give a run its character. Tuning them is what produces the &lt;em>murmuration&lt;/em>, &lt;em>schooling&lt;/em>, &lt;em>plasma&lt;/em>, and &lt;em>turbulence&lt;/em> scenes in the repository. The model is short to state. The engineering work is in making it fast and reproducible.&lt;/p>
&lt;h2 id="the-computational-bottleneck">The computational bottleneck&lt;/h2>
&lt;p>The cost of the model is dominated by one question. For each agent, which other agents are its neighbours? Answered naïvely, every agent is compared against every other, which means $O(N^2)$ distance calculations per timestep. At a few hundred agents this is harmless. At tens of thousands it is hopeless, because quadratic growth means a swarm ten times larger costs a hundred times more.&lt;/p>
&lt;p>BoidForge attacks the problem along two independent axes, and gives each one its own backend so that the contribution of every idea can be &lt;em>measured&lt;/em> rather than assumed:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Lower the constant factor.&lt;/strong> Do the same $O(N^2)$ work, but express it in fewer and faster instructions. This is the path from a Python loop to vectorized NumPy and then to native C.&lt;/li>
&lt;li>&lt;strong>Lower the complexity.&lt;/strong> Stop doing $O(N^2)$ work at all, by only ever inspecting agents that could plausibly be neighbours. This is the spatial grid, which brings the search down to roughly $O(N)$.&lt;/li>
&lt;/ol>
&lt;p>All four backends are held to a single &lt;strong>determinism contract&lt;/strong>: for the same configuration and seed, they must emit byte for byte identical output. The naïve backend is the reference, and any backend that disagrees with it is wrong by definition. A test runs every backend and compares their frames. This is a demanding constraint, because it forces even the C kernel to reproduce NumPy&amp;rsquo;s exact &lt;code>float32&lt;/code> summation order, but it is what makes the speedups trustworthy instead of merely fast.&lt;/p>
&lt;h2 id="four-backends-and-what-each-one-buys">Four backends, and what each one buys&lt;/h2>
&lt;h3 id="l1-naïve-python-the-reference">L1, naïve Python (the reference)&lt;/h3>
&lt;p>A direct all pairs implementation, with an explicit Python loop over every pair of agents. It is deliberately simple, because its only job is to &lt;em>define correctness&lt;/em>. Every other backend is validated against it. It is also, predictably, the slowest, since the per element overhead of the Python interpreter sits on top of the $O(N^2)$ work.&lt;/p>
&lt;h3 id="l2-vectorized-numpy">L2, vectorized NumPy&lt;/h3>
&lt;p>The same all pairs algorithm, re expressed with NumPy. The pairwise displacement, distance, and separation terms are built once as whole arrays, and the clamping and integration run as bulk array operations.&lt;/p>
&lt;p>&lt;em>Why it is faster.&lt;/em> The arithmetic moves out of the Python interpreter and into NumPy&amp;rsquo;s compiled C loops, which removes the per element interpreter overhead. On small and medium swarms this is roughly a twofold gain. It is not free, however. The pairwise arrays cost $O(N^2)$ &lt;em>memory&lt;/em>, and at the top of the benchmarked range the cost of moving those large arrays through memory cancels the gain, to the point where L2 is marginally slower than L1 at 2000 agents. That trade off is the lesson in itself: vectorization buys speed with memory, and at scale memory becomes the limiting resource.&lt;/p>
&lt;h3 id="l3-uniform-grid-spatial-hash">L3, uniform grid spatial hash&lt;/h3>
&lt;p>The first &lt;em>algorithmic&lt;/em> change. Agents are sorted into a grid whose cell side equals the neighbour radius. Each agent then inspects only its own cell and the eight cells around it, a three by three block, instead of the whole swarm. Because the cell side equals the radius, any genuine neighbour is guaranteed to fall inside that block, so the grid prunes work without ever missing a neighbour.&lt;/p>
&lt;p>&lt;em>Why it is faster.&lt;/em> It changes the complexity of the neighbour search from $O(N^2)$ to about $O(N)$. The work per agent stops growing with the size of the swarm and depends only on the local density. This is the idea that makes large swarms tractable in principle. In pure Python, though, the bookkeeping of building the grid and gathering candidates carries enough overhead that, at the modest sizes benchmarked here, its timing stays close to the naïve loop. The algorithm is correct, but it is waiting for a faster host language to pay off.&lt;/p>
&lt;h3 id="l4-native-c-extension-the-production-target">L4, native C extension (the production target)&lt;/h3>
&lt;p>That faster host is a hand written &lt;strong>CPython C extension&lt;/strong>, written against the raw C API and built with CMake through scikit-build-core. It combines the $O(N)$ grid of L3 with a set of systems level optimisations that Python cannot offer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Struct of Arrays buffers, shared without copying.&lt;/strong> The state lives as four contiguous &lt;code>float32&lt;/code> arrays (positions and velocities) shared with NumPy through the buffer protocol. There is no copy, no per agent object overhead, and a memory layout the processor can stream through efficiently.&lt;/li>
&lt;li>&lt;strong>A counting sort into cells.&lt;/strong> The grid is built with a counting sort into a compact layout rather than a hash map, so gathering neighbours is a tight loop over contiguous indices instead of dictionary lookups.&lt;/li>
&lt;li>&lt;strong>The interpreter lock is released&lt;/strong> around the compute loop, so the heavy numerical work runs as genuine native code, free of the Python global interpreter lock.&lt;/li>
&lt;li>&lt;strong>No memory allocation inside the hot loop.&lt;/strong> A single workspace is sized once per timestep, and the inner per agent loop never calls the allocator.&lt;/li>
&lt;/ul>
&lt;p>&lt;em>Why it is faster.&lt;/em> Each of these removes a layer of overhead that the Python backends cannot escape, namely interpreter dispatch, object indirection, and allocator pressure, while keeping the $O(N)$ neighbour search. Together they deliver roughly 120 to 330 times the throughput of the naïve reference and put tens of thousands of agents within reach. It does all of this while still producing output identical to L1 down to the bit, which required reproducing NumPy&amp;rsquo;s pairwise summation algorithm exactly in C so that the &lt;code>float32&lt;/code> rounding matches.&lt;/p>
&lt;h2 id="benchmarks">Benchmarks&lt;/h2>
&lt;p>The harness times each backend across a sweep of swarm sizes, from 100 to 2000 agents, over 200 steps each and after a warm up, recording the mean time per frame and the speedup relative to L1.&lt;/p>
&lt;figure>
&lt;a href="https://vcaries.github.io/media/boid-forge/scaling.png" target="_blank" rel="noopener">&lt;img src="https://vcaries.github.io/media/boid-forge/scaling.png" alt="Per-frame time versus number of agents, log-log, for the four backends" style="width:100%;height:auto;">&lt;/a>
&lt;figcaption>&lt;strong>Figure 1.&lt;/strong> Time per frame against swarm size, on logarithmic axes. The naïve (L1), vectorized (L2), and Python spatial hash (L3) backends sit close together at these sizes, while the native C backend (L4) runs one to two orders of magnitude below them. The gap widening towards the right is the $O(N)$ grid pulling away from the $O(N^2)$ all pairs cost. &lt;em>(Click to enlarge.)&lt;/em>&lt;/figcaption>
&lt;/figure>
&lt;figure>
&lt;a href="https://vcaries.github.io/media/boid-forge/speedup.png" target="_blank" rel="noopener">&lt;img src="https://vcaries.github.io/media/boid-forge/speedup.png" alt="Speedup of each backend relative to the naïve baseline, per swarm size" style="width:100%;height:auto;">&lt;/a>
&lt;figcaption>&lt;strong>Figure 2.&lt;/strong> Speedup over the naïve baseline. The native backend dominates, peaking around 330 times at 200 agents and holding well above 100 times across the range, while the pure Python optimisations (L2 and L3) stay close to 1 at these sizes. This confirms that the decisive gain comes from moving the $O(N)$ algorithm into C, and not from vectorization on its own.&lt;/figcaption>
&lt;/figure>
&lt;p>At 2000 agents the naïve reference spends about 202 ms per frame, the equivalent of roughly 5 frames per second, while the C backend spends about 1.7 ms, the equivalent of roughly 590 frames per second. That is the same physics computed about 120 times faster, with its lead still growing as the swarm gets larger.&lt;/p>
&lt;h2 id="visualization-with-moderngl">Visualization with ModernGL&lt;/h2>
&lt;p>The renderer never simulates. It reads precomputed frames from the binary stream and turns each one into an image on the GPU. It is built directly on &lt;strong>ModernGL&lt;/strong>, a thin modern binding to OpenGL 3.3, with hand written GLSL shaders, and it is deliberately a small post processing pipeline rather than a game engine:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Upload and draw.&lt;/strong> Each frame is uploaded to a GPU vertex buffer and every agent is drawn as a single &lt;strong>point sprite&lt;/strong>. The fragment shader shapes each sprite into a soft glowing core surrounded by a wide halo, and its colour is read from a small colour map texture indexed by speed, heading, local density, or a fixed value.&lt;/li>
&lt;li>&lt;strong>Additive HDR accumulation.&lt;/strong> Sprites are blended additively into a floating point buffer, so overlapping agents build up genuine brightness. This is what gives dense clusters their luminous look.&lt;/li>
&lt;li>&lt;strong>Motion trails.&lt;/strong> Instead of clearing that buffer between frames, it is faded slightly towards black. Agents therefore leave decaying trails behind them, which is the source of the silky, fluid sense of motion.&lt;/li>
&lt;li>&lt;strong>Bloom.&lt;/strong> A bright pass keeps only the most luminous pixels, a separable Gaussian blur spreads them out, and the glow is added back, producing the cinematic halo around fast and crowded regions.&lt;/li>
&lt;li>&lt;strong>Composite.&lt;/strong> A final fullscreen shader applies an ACES filmic tone map to bring the high dynamic range scene into a displayable range, together with exposure, an optional vignette, the background tint, and gamma correction.&lt;/li>
&lt;/ol>
&lt;p>Because the GPU context can be created off screen, the very same renderer runs without a display on a headless machine. For video, the rendered frames are streamed straight into an &lt;strong>FFmpeg&lt;/strong> subprocess that encodes H.264, without ever holding the whole clip in memory. That is how the 4K footage at the top of this page was produced.&lt;/p>
&lt;h2 id="architecture-two-subsystems-one-contract">Architecture: two subsystems, one contract&lt;/h2>
&lt;p>The design rule behind everything above is a strict separation between &lt;em>computing&lt;/em> the swarm and &lt;em>drawing&lt;/em> it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">Solver (compute) ---&amp;gt; .bfs binary stream ---&amp;gt; Visualization engine ---&amp;gt; frames / video
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The solver imports nothing from the visualization layer, with no OpenGL and no graphics of any kind, and the visualization layer never advances the physics. Their only shared knowledge is a small versioned &lt;strong>binary format&lt;/strong> (&lt;code>.bfs&lt;/code>): a 32 byte header followed by one record per frame holding the agent count and the four &lt;code>float32&lt;/code> arrays back to back. That layout is chosen so the solver can write each array in a single bulk operation and the renderer can map it straight into a GPU buffer with no repacking on either side. A static test on the import boundaries enforces the separation so that it cannot erode over time.&lt;/p>
&lt;p>This decoupling is what lets the solver be tuned for raw throughput and the renderer for image quality, each on its own terms. It is also why a long simulation can be computed once and then re rendered later with different colours, trails, or resolution as many times as needed.&lt;/p>
&lt;h2 id="skills-demonstrated">Skills demonstrated&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>High performance computing:&lt;/strong> algorithmic complexity reduction from $O(N^2)$ to $O(N)$ with spatial hashing, NumPy vectorization and its memory trade offs, and a hand written CPython C extension with zero copy buffers, interpreter lock release, and an allocation free hot loop.&lt;/li>
&lt;li>&lt;strong>Numerical rigour:&lt;/strong> a bit exact determinism contract across four independent implementations, including reproducing NumPy&amp;rsquo;s &lt;code>float32&lt;/code> pairwise summation in C.&lt;/li>
&lt;li>&lt;strong>GPU and real time graphics:&lt;/strong> a ModernGL and GLSL pipeline with HDR accumulation, motion trails, bloom, and ACES tone mapping, running headless and exporting through FFmpeg.&lt;/li>
&lt;li>&lt;strong>Software architecture:&lt;/strong> two strictly decoupled subsystems joined only by a versioned binary format, with an enforced import boundary.&lt;/li>
&lt;li>&lt;strong>Engineering discipline:&lt;/strong> typed, documented, and tested Python (&lt;code>ruff&lt;/code>, &lt;code>mypy --strict&lt;/code>, &lt;code>pytest&lt;/code>), a CMake and scikit-build-core native build, and reproducibility treated as a first class requirement.&lt;/li>
&lt;/ul>
&lt;h2 id="repository">Repository&lt;/h2>
&lt;p>The full source is open, including the four solver backends, the C kernel, the binary input and output layer, the benchmark harness, and the ModernGL visualization engine.&lt;/p>
&lt;p>The code lives at &lt;strong>&lt;a href="https://github.com/vcaries/boid_forge" target="_blank" rel="noopener">github.com/vcaries/boid_forge&lt;/a>&lt;/strong>, alongside an architecture document that describes the binary format and the determinism contract in full.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>A flocking model is three lines of vector algebra. Making it &lt;em>scale&lt;/em> is an engineering project. BoidForge carries the model from a transparent Python reference to a native C kernel that is two orders of magnitude faster, proves at every step that the extra speed costs nothing in correctness, and then renders the result as something genuinely worth watching, all inside a clean architecture where computation and rendering never touch. The hard part was never the boids. It was making them fast, reproducible, and beautiful.&lt;/p></description></item></channel></rss>