Performance on a phone

What actually costs time when Clojure draws through raylib on an iPhone, in the order the measurements arrived rather than the order that makes us look clever. Every number here was read off a device, an iPhone 17 Pro on iOS 26.6.1, over the app's own nREPL while it was running.

The headline, because it inverts the obvious guess: the FFI is not the bottleneck, and the sequence machinery is.

The obvious guess, and why it was wrong

Every draw call crosses libffi. On portable bytecode there is no JIT, and the notebooks this project grew from clocked the interpreter at roughly 45x native on arithmetic. So the natural model is "count your draw calls, that is your budget", and the natural fix is to draw less.

Five scenes were ported in an afternoon and measured. Two were slow:

sceneper framefps
fireworks~120 circles58
hilbert1023 lines, colours precomputed60
tree511 branches, rebuilt each frame for the sway59
stars220 circles58
l-system1488 static segments, one colour59
flow-field90 particles, ~630 segments54

The last two rows together make the point better than the prose does. The L-system draws 1488 segments at 59 fps; the flow field draws roughly 630 and managed 32. Segment count is not the variable. What separates them is that the L-system's geometry is a vector computed once and its colour is a constant, while the flow field steps 130 particles through trigonometry and rebuilds a trail vector for each, every frame.

Tuning it took two rounds of the same lesson. The draw loop recomputed each particle's field angle for its colour when the step had just computed it; carrying the angle on the particle took it from 32 fps to 39. The rest was the stepping itself, so the count came down: measured by re-entering the scene at each setting, 130 gives 39, 90 gives 54 and 60 gives 59.

Hilbert is the one worth staring at. It draws MORE lines than spirograph and runs faster, 1023 at 60 against roughly 1000 at 55, because its curve and its per-segment colours are computed once at init and the draw loop does nothing but read two vectors. The original recomputes each segment's colour inline from three sin calls, which is 3069 transcendentals a frame for a picture that never changes.

Penrose makes more FFI calls than spirograph and runs three times faster. That kills the model outright. Whatever is costing time, it is not the calls.

What it actually was

The difference is what the drawing loop does around each call.

Penrose iterates a plain vector and computes its edge colour once, outside the loop. Spirograph did this:

(doseq [[i [[x1 y1] [x2 y2]]] (map-indexed vector (partition 2 1 points))]
  (rl/draw-line (int x1) (int y1) (int x2) (int y2) (color (spiro/rainbow i))))

which per frame builds a lazy sequence of 1800 pairs, wraps each in an index tuple, destructures four levels deep, and allocates a colour vector. Rewritten as an indexed loop over the same vector, drawing exactly the same lines through exactly the same FFI:

(let [n (count points)]
  (loop [i 1]
    (when (< i n)
      (let [a (nth points (dec i)) b (nth points i)
            [r g b' a'] (spiro/rainbow i)]
        (rl/draw-line (int (nth a 0)) (int (nth a 1)) (int (nth b 0)) (int (nth b 1))
                      (rl/rgba r g b' a')))
      (recur (inc i)))))

Spirograph, after the fix

Spirograph after the loop was rewritten: the curve draws itself at 55 fps where the lazy-sequence version managed 14 at the same point count.

spirograph, points1008128815521768
lazy sequence14---
indexed loop52403732

3.5x, with the same number of draw calls.

Kaleidoscope had the same disease one layer down. Its pure namespace returned a lazy sequence of ready-made segment tuples, 1788 of them, and its helper returned [x y], so two vectors were allocated per line. Handing the host a dozen rotation triples and letting it loop by index, with the arithmetic inlined and nothing allocated per line:

kaleidoscope, 1068 linesfps
tuples from a for comprehension22
indexed loop, place returning [x y]22
indexed loop, arithmetic inlined47

Note the middle row. Removing the lazy sequence alone changed nothing, because the per-line allocation was still there. Both had to go.

The rule this leaves

On this runtime, in a loop that runs hundreds of times per frame:

  • An indexed loop/recur over a vector beats any sequence function. partition, map-indexed, for and friends are fine once per frame and ruinous per element.
  • Allocation is the cost, not the call. A helper returning [x y] is a vector per invocation. Inline the arithmetic on the hot path and leave the readable version beside it for the tests, which is what kaleidoscope/place is for.
  • Hoist anything constant out. Penrose was fast partly because its edge colour is computed once, not per line.
  • The FFI is cheap. 2400 calls a frame held 59 fps. Do not contort a design to avoid draw calls until you have measured that they are the problem.

None of this is exotic Clojure advice. What is different is the magnitude: on a JIT these habits cost a few percent, and here they cost three to four times.

Penrose P3 tiling Kaleidoscope

Left: penrose, 340 triangles and about 2400 FFI calls a frame, at 57 fps. It is the scene that disproved the draw-call theory. Right: kaleidoscope, 708 lines a frame at 58, after the allocation came out of its draw loop.

Where the budget lands

With the loops fixed, the remaining limit really is line count. Numbers to plan against, all at 59 fps unless stated:

sceneper framefps
kaleidoscope, 60-point trail708 lines58
spirograph, 1000 points~1000 lines55
penrose, 4 deflations340 triangles, ~2400 calls57
boids, 452025 distance tests, 90 draws52
fireworks~120 circles58
lorenz, 450-point trail450 lines, each re-projected58
tesseract32 lines59

So roughly a thousand primitives a frame is comfortable, and the scenes that push past it carry a knob: trail-length, max-points, default-deflations, default-count. Each is a plain def, which matters for the next section.

A filled rectangle is cheaper than a line. That thousand came from scenes drawing lines and circles, and it turns out to understate what DrawRectangle will take. The cellular automaton draws 2551 of them a frame and holds 58 fps, which is two and a half times the figure above.

sceneper framefps
life, 48x93 grid1470 rectangles59
automata, rule 302551 rectangles58

Worth knowing before sizing a grid down to fit a budget it is not actually in. Both of those were tuned twice on the assumption that a thousand was the ceiling, and both had room.

When a thousand primitives is the wrong budget

Lorenz broke the rule above and it took a sweep to see why. It draws 450 lines, well inside the comfortable range, and it will not go much past that. The L-system draws 1488 and holds 59.

The difference is not how many lines get drawn, it is what happens per line before the draw. The L-system's segments are computed once at init and then handed to DrawLine unchanged every frame. Lorenz orbits its camera, so every point is rotated and perspective-divided on every frame, and the cost is that arithmetic rather than the drawing.

The first version made it worse by allocating: project returned a fresh [x y] vector per point and trail-colour a fresh [r g b] per segment, so a frame allocated about 2400 short-lived vectors. That ran at 18 fps. Rewriting the loop to carry the previous screen point in primitive loop bindings and inline both computations took it to 31, which is the same 1.7x the draw-loop rewrite bought earlier in this guide, for the same reason.

Then the sweep, at 450 through 1200 points, reading last-fps after letting raylib's 30-frame ring refill at each step:

trail12001000800600500480460440420400300
fps2523293256595958585959

Flat at vsync to 480, slipping at 500, and falling away steeply past 600. The default is 450, which sits inside the flat region with room for a phone that has warmed up.

Read the steep end with suspicion. The same 1200-point trail measured 31, then 20, then 24 to 28 across one session, a spread of about 30% on identical code. Once a scene is over budget the readings stop being repeatable, most likely thermal. The flat end is solid and the cliff edge is real, but treat any single number past the knee as indicative.

The rule the earlier sections give still holds, it just needs its terms stated properly. The budget is per-frame work, and primitive count is only a good proxy for it while the work per primitive is roughly constant. A scene that computes geometry once and redraws it gets the thousand. A scene that recomputes every vertex every frame gets a few hundred.

How these were measured

All of it live, over the nREPL, without a rebuild between readings. That is worth its own note because it changed how the work went.

raylib.probe/fps-every-frame? turns on a per-frame GetFPS call and parks the answer in last-fps, so frame rate becomes a value to read rather than a console line to scrape. raylib.gallery/tap! opens a scene without a finger. Together they let a single session open each scene in turn and read its cost.

Two traps, both of which produced wrong numbers before they were noticed.

Sample against the thing that varies. Spirograph's point count cycles from zero to its cap and resets, so a single fps reading catches an arbitrary phase. The first four readings were 18, 17, 27 and 15, all of the same code. Reading the point count alongside fps is what turned noise into a curve.

Build --dev or redefinition does not reach the loop. A release build inlines across call sites, so alter-var-root updates what the REPL sees while the running loop keeps calling the original. Every tuning number above came from a DEV_BUILD=1 build, where a redefined trail-length takes effect on the next frame. The cost is negligible: --dev held mean 17.05 ms against release's 17.02.